18 KiB
Библиотека: хранилища, шаблоны путей, сканер, поиск
Хранилища (sources)
Источник — корень, из которого берутся файлы. Управляет только admin.
| Тип | Как подключается |
|---|---|
nfs |
Админ указывает server, export, опции. storage-agent выполняет mount -t nfs -o <opts> server:export /library/<id>. |
local |
Папка на диске сервера, заранее смонтированная на хосте (например, диск /mnt/music через fstab) и указанная в LIBRARY_HOST. В контейнерах видна как /library-host; админ выбирает подпапку. |
Монтирование (storage-agent)
- Общий каталог хоста
/srv/pipu/libraryмонтируется:- в
storage-agent—bind,propagation: rshared; - в
api,nginx,analyzer—bind,read_only,propagation: rslave.
- в
- Опции NFS по умолчанию:
ro,nfsvers=4.1,soft,timeo=150,retrans=3,noatime.roпринудительно добавляется для всех источников, кроме отмеченныхaccepts_uploads(см. ниже). - Валидация ввода:
server— hostname/IP,export— абсолютный путь без.., опции — только из whitelist (nfsvers, port, timeo, retrans, soft, hard, rsize, wsize, noatime, nolock, sec). Никаких произвольных строк в команду. POST /admin/sources/{id}/test: агент монтирует во временную точку, проверяет чтение листинга и одного файла, возвращает число найденных аудиофайлов в первых N каталогах.- Health-check: агент раз в минуту делает
statс таймаутом; при сбое —status=error, сообщение в админке; треки источника помечаются недоступными (не выдаются в волну, в UI серые), но не удаляются. - При рестарте агента — перемонтирование всех
status != disabledисточников.
Запись в хранилище
Единственный сценарий записи — импорт одобренных треков из очереди на добавление.
- Источник с
accepts_uploads = trueагент монтирует дважды:/library/<id>—ro, как обычно (его видятapi,nginx,analyzer);/library-rw/<id>—rw, виден только самому агенту (каталог без propagation наружу).
- Пишет только
storage-agentпо командеapi(POST /internal/files): создание каталогов, запись во временный файл,fsync, сверка SHA-256, атомарныйrename. Перезапись существующих файлов и удаление запрещены на уровне кода агента. - Права на NFS-сервере: экспорт должен разрешать запись для UID/GID агента (
PIPU_FILE_UID/PIPU_FILE_GID, задаются в.envпод владельца музыки на файловом сервере); новые файлы —0644, каталоги —0755. POST /admin/sources/{id}/testдля такого источника дополнительно проверяет право записи — без создания файлов.- У источника задаётся
write_template— шаблон пути для новых файлов, с автоподбором по существующей структуре и проверкой обратимости через регэкспы источника (подробно — uploads.md).
Реализация.
- Агент:
POST /internal/files{source_id, local_path?, rel_path, staging_path, sha256}→{rel_path, size, renamed};POST /internal/files/test— проверка записи. NFS сaccepts_uploadsмонтируется rw в/library-rw/<id>(каталог внутри контейнера агента), а в/library/<id>— bind этой точки сremount,ro. Local-источники пишутся через/library-host-rw(тот жеLIBRARY_HOST, на запись — только у агента). На музыкальный диск не пишется ничего, кроме самих треков: файл создаётся сразу под итоговым именем (O_EXCL— существующий не перезаписывается; занято —(2),(3)…),fsync, сверка SHA-256; при ошибке агент удаляет свой недописанный файл и созданные для него пустые каталоги. Проверка записи —access(W_OK)и флаг read-only файловой системы, без пробного файла. Остальные сервисы видят музыку только на чтение (read_onlyв compose). Владелец новых файлов и каталогов —PIPU_FILE_UID/PIPU_FILE_GID(-1— не менять). - Шаблон:
meta.ParseTemplate/Render(очистка имён, пределы длины),meta.SuggestTemplate(регэксп → шаблон черезregexp/syntax: именованные группы → поля,(?:(?P<disc>\d)-)?→{disc:1-}, необязательная группа →[ ], хвост\.\w+$→.{ext}, ширина{track:0N}— по выборке),meta.CheckTemplate(50 случайных треков источника). - API:
accepts_uploads,write_templateвSource;POST /admin/sources/{id}/write-template/suggest|check; проверка подключения для принимающего источника дополнительно возвращаетwrite_ok. UI — страница шаблонов путей.
Шаблоны путей (регэкспы)
Для каждого источника — упорядоченный список регулярных выражений (синтаксис RE2 / Go regexp)
с именованными группами. Применяются к пути относительно корня источника, с / как разделителем.
Первое совпавшее выражение побеждает.
Поддерживаемые группы:
| Группа | Поле |
|---|---|
artist |
Исполнитель трека |
albumartist |
Исполнитель альбома |
album |
Альбом |
year |
Год (4 цифры) |
disc |
Номер диска |
track |
Номер трека |
title |
Название |
genre |
Жанр |
skip |
Если совпала — файл игнорируется (например, ^(?P<skip>_incoming)/) |
Примеры:
# Исполнитель/2019 - Альбом/01 - Название.flac
^(?P<artist>[^/]+)/(?P<year>\d{4}) - (?P<album>[^/]+)/(?:(?P<disc>\d)-)?(?P<track>\d{1,3}) - (?P<title>[^/]+)\.\w+$
# Жанр/Исполнитель/Альбом (2019)/03. Название.mp3
^(?P<genre>[^/]+)/(?P<artist>[^/]+)/(?P<album>.+?) \((?P<year>\d{4})\)/(?P<track>\d+)\. (?P<title>[^/]+)\.\w+$
# Сборники: Compilations/Альбом/01 - Исполнитель - Название.mp3
^Compilations/(?P<album>[^/]+)/(?P<track>\d+) - (?P<artist>.+?) - (?P<title>[^/]+)\.\w+$
Слияние с тегами
tag_priority источника:
tags_first(по умолчанию): значение из тега, если тег пуст — из шаблона;path_first: значение из шаблона, если группа не совпала — из тега.
Если нет ни тега, ни шаблона: title = имя файла без расширения и номера, artist = «Unknown artist»,
album = имя родительской папки.
Нормализация: обрезка пробелов, _ → пробел, Unicode NFC; для ключей сопоставления (name_norm) —
lower-case, ё → е, удаление пунктуации, схлопывание пробелов. Множественные исполнители разбираются по
разделителям ;, /, feat., ft., & (последний — только если такого составного имени нет в библиотеке).
Предпросмотр
POST /admin/sources/{id}/patterns/preview берёт случайную выборку (по умолчанию 50) путей из
media_files (или из листинга, если скана ещё не было) и возвращает для каждого: какое выражение совпало,
извлечённые поля, значения из тегов, итоговый результат после слияния. UI показывает таблицу с подсветкой
групп и счётчик «не совпало ни одно выражение». Регэксп валидируется при вводе (компиляция на сервере,
ошибка с позицией). Ограничение длины выражения — 1000 символов (RE2 линейный, ReDoS невозможен).
Сканер
Задачи River в api:
| Задача | Когда |
|---|---|
scan_source(full=false) |
По расписанию источника, вручную, после изменения шаблонов (с full=true) |
scan_source(full=true) |
Перечитать теги / шаблоны у всех файлов |
scan_path(source_id, rel_path) |
Точечный скан одного файла после импорта из очереди |
extract_cover(album_id) |
Новый альбом |
fingerprint_file(file_id) |
Новый / изменённый файл: полный SHA-256 + Chromaprint (fpcalc -raw, первые 120 с) — для проверки дублей при загрузке; низкий приоритет. Реализация — library.Fingerprinter: один проход по файлам с sha256 IS NULL / fingerprinted_at IS NULL после каждого скана и при старте, по одному файлу. Без fpcalc (FPCALC_PATH) считается только SHA-256. Изменение содержимого файла сбрасывает отпечатки. Сравнение — library.Similarity (доля совпавших бит лучшего выравнивания, сдвиг ±80 точек; ≥ 0.75 — тот же трек) |
reindex_search |
Вручную / после крупного скана |
Алгоритм инкрементального скана:
- Обход дерева (
filepath.WalkDir), фильтр по расширениямflac mp3 m4a aac ogg opus wav aiff wv ape. Параллелизм — 8 воркеров (NFS любит параллельные запросы, но не сотни). - Для каждого файла: если
(rel_path, size, mtime)не изменились иfull=false— пропуск. - Иначе —
ffprobe(длительность, кодек, битрейт) + чтение тегов, применение шаблонов,content_hash. - Сопоставление: по
content_hashищем «пропавший» файл — если нашли, это переименование / перенос: обновляем путь,track_idсохраняется (лайки и история не теряются). - Upsert исполнителей / альбомов / треков; дубли (одинаковые artist+album+title+duration ±2 с) сводятся к
одному треку,
primary_file_id— файл лучшего качества (lossless > битрейт). - Файлы, не встреченные при полном обходе, получают
missing_since; через 30 дней без появления трек удаляется (лайки / история сохраняются в событиях, в UI — «трек недоступен»). - Новые треки →
analysis_jobs, батчевое обновление Meilisearch (по 1000 документов). - Прогресс публикуется в Redis (
scan:<source_id>), админка опрашиваетGET /admin/library/statusраз в 2 с (или SSE/admin/library/events).
Ожидаемое время первого скана 11k файлов по NFS — 5–15 минут (bottleneck — чтение тегов).
Во время обхода админка показывает число посещённых папок (включая корень), найденных
аудиофайлов и текущую папку относительно корня хранилища. До завершения обхода общее
число файлов неизвестно, поэтому индикатор показывает активность без процента.
Затем отображаются этапы сопоставления, обработки файлов с известным общим числом
и завершения скана. API передаёт их в ScanProgress.phase, walked_dirs, current_dir.
Ошибки в итогах скана относятся только к этому запуску. Общий счётчик и список
ошибок чтения учитывают сохранённые ошибки только присутствующих файлов:
после успешного скана пропавшие файлы исключаются из них, сохраняя запись на срок
MissingRetention. В блоке аудиоанализа администратор может открыть «Показать файлы»:
окно загружает ошибки анализа страницами через GET /api/v1/admin/library/analysis/errors
и показывает трек, хранилище, относительный путь из текущего каталога и причину сбоя.
Обложки
Источники по приоритету: встроенная в файл → cover.jpg|folder.jpg|front.* в папке альбома → плейсхолдер,
сгенерированный из цвета. Конвертируются в WebP 64/300/1200 px в /srv/pipu/cache/covers/, вычисляется
палитра из 3 цветов (k-means по пикселям; cover_colors) — для ambient-фона, цветов кнопки волны и плейсхолдеров.
Поиск
Meilisearch, индексы:
| Индекс | Поля поиска (по убыванию веса) | Фильтры / сортировка |
|---|---|---|
artists |
name, name_translit, aliases |
popularity |
albums |
title, artist_name, title_translit |
year, popularity |
tracks |
title, artist_names, album_title, title_translit |
genre, year, popularity, available |
*_translit— транслитерация (кириллица ↔ латиница), плюс поле с исправлением раскладки (ghbdtn→привет): при пустом результате клиентский запрос повторяется в альтернативной раскладке на сторонеapi.- Ранжирование: стандартные правила Meilisearch +
popularity:desc(глобальные засчитанные прослушивания за 90 дней). /search/suggestиспользуетmulti-search(один запрос — три индекса), отдаёт ≤ 3/3/5. Клиент делает debounce 150 мс, отменяет предыдущий запрос (AbortController).- Персонализация:
suggestберёт из Meilisearch втрое больше кандидатов и переранжирует их вapi(internal/search/personal.go). Вес объекта — прослушивания + 2 × доигрывания − ранние скипы, лайк трека ±10, реакция на исполнителя ±20; альбом и исполнитель суммируют вес своих треков. Знакомое поднимается на 2·ln(1+вес) позиций, нелюбимое опускается на 3 — релевантность остаётся основой порядка. - Синхронизация:
apiпосле изменения каталога ставит документы в батч-очередь; полная переиндексация — задачаreindex_search(индекс собирается во временном индексе и подменяется черезswap-indexes, без даунтайма).