20 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 — чтение тегов).
В редакторе хранилища поле «Не сканировать папки» задаёт до 100 исключений:
по одному пути относительно корня хранилища в строке, например Downloads_PumpEars
или Artist/Samples. Исключается вся ветка; соседняя Downloads_PumpEars-copy
не затрагивается. Пути чувствительны к регистру, шаблоны и абсолютные пути не используются.
Настройка хранится в library_sources.excluded_dirs; пустой список снимает исключения.
Во время активного скана менять список нельзя.
Исключённые ветки не обходятся; точечный скан такого пути отклоняется. Уже записанные файлы остаются в каталоге и не помечаются пропавшими. Сразу после сохранения исключений ошибки чтения и задачи аудиоанализа этих файлов перестают попадать в диагностику и повтор. Анализатор не забирает их в работу; если у трека есть другая разрешённая копия, использует её. После снятия исключений сохранённые задачи снова учитываются; неудавшиеся можно повторить. Файлы на диске не изменяются.
Во время обхода админка показывает число посещённых папок (включая корень), найденных
аудиофайлов и текущую папку относительно корня хранилища. До завершения обхода общее
число файлов неизвестно, поэтому индикатор показывает активность без процента.
Затем отображаются этапы сопоставления, обработки файлов с известным общим числом
и завершения скана. 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-фона, цветов кнопки волны и плейсхолдеров.
Поиск файлов в папке не зависит от регистра: cover.jpg, cover.jpeg, Cover.png,
а также folder, front, album, albumart с расширениями JPG/JPEG/PNG/WebP.
Картинка должна лежать рядом с аудиофайлом; глубина папки относительно корня источника не важна.
Папки, исключённые из сканирования, не используются для поиска обложек.
После успешного скана (в том числе обычного, без перечитывания тегов) альбомы без обложек
проверяются повторно. Обработчик запускается также при старте API, готовит до четырёх альбомов
одновременно и сохраняет готовые обложки по мере обработки. Одинаковые изображения используют
общие файлы кэша; ошибки чтения и конвертации остаются для повтора при следующем запуске обработчика.
Клиент получает ссылку на готовый WebP через API каталога; nginx раздаёт файл из кэша.
Поиск
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, без даунтайма).