Files
pipu-music/docs/library-and-storage.md
T
mrleo1nid 0dc0384c78
ci / api / api (push) Successful in 55s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m6s
ci / db / db (push) Successful in 52s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m38s
release / android (APK в релиз) (push) Successful in 3m50s
ci / ts / ts (packages + web) (push) Successful in 3m20s
release / release (версия и заметки) (push) Successful in 5s
release / desktop (Linux и Windows в релиз) (push) Successful in 44m45s
release / prune (старые файлы и прогоны) (push) Successful in 7s
fix(library): expose scan activity and failed analysis file details
2026-10-22 15:25:48 +03:00

18 KiB
Raw Permalink Blame History

Библиотека: хранилища, шаблоны путей, сканер, поиск

Хранилища (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 Вручную / после крупного скана

Алгоритм инкрементального скана:

  1. Обход дерева (filepath.WalkDir), фильтр по расширениям flac mp3 m4a aac ogg opus wav aiff wv ape. Параллелизм — 8 воркеров (NFS любит параллельные запросы, но не сотни).
  2. Для каждого файла: если (rel_path, size, mtime) не изменились и full=false — пропуск.
  3. Иначе — ffprobe (длительность, кодек, битрейт) + чтение тегов, применение шаблонов, content_hash.
  4. Сопоставление: по content_hash ищем «пропавший» файл — если нашли, это переименование / перенос: обновляем путь, track_id сохраняется (лайки и история не теряются).
  5. Upsert исполнителей / альбомов / треков; дубли (одинаковые artist+album+title+duration ±2 с) сводятся к одному треку, primary_file_id — файл лучшего качества (lossless > битрейт).
  6. Файлы, не встреченные при полном обходе, получают missing_since; через 30 дней без появления трек удаляется (лайки / история сохраняются в событиях, в UI — «трек недоступен»).
  7. Новые треки → analysis_jobs, батчевое обновление Meilisearch (по 1000 документов).
  8. Прогресс публикуется в 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, без даунтайма).