Files
pipu-music/docs/library-and-storage.md
T
mrleo1nid f1528de948
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m17s
ci / ts / ts (packages + web) (push) Successful in 3m13s
ci / api / api (push) Successful in 59s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m31s
ci / db / db (push) Successful in 2m50s
feat(admin): transfer user accounts and show local cover coverage
2026-10-23 02:17:06 +03:00

21 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 — чтение тегов).

В редакторе хранилища поле «Не сканировать папки» задаёт до 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 раздаёт файл из кэша.

В карточке источника показано «Локальные обложки: X из Y файлов» и число альбомов, ожидающих обработки. Статистика обновляется каждые 10 секунд через GET /admin/sources/{id}/cover-stats. Считаются аудиофайлы каталога без ошибок чтения, пропавших файлов и исключённых папок, включая альтернативные форматы одного трека. Числитель — файлы, у альбома которых есть готовая обложка из тегов или папки; внешние URL Genius не учитываются. Это покрытие файлов обложками альбомов, а не количество отдельных cover.jpg на диске.

Поиск

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, без даунтайма).