Files
pipu-music/docs/library-and-storage.md
T
mrleo1nidandClaude Opus 5.5 9eb1c25147 docs: add product idea, design prototypes and development documentation
- IDEA.md: product vision from the user's perspective (input for design)
- Architecture, ADRs, data model, REST API overview
- Auth and roles, library storage (NFS, path regexes, scanner, search),
  playback, recommendation system ("My Wave"), track upload queue
- Web, Android (offline, self-update), i18n, design system
- Testing strategy, Gitea Actions CI/CD, deployment, conventions, roadmap
- Clickable web/Android design prototypes and wave button variants
- .claude/launch.json to serve the prototypes locally

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 23:21:31 +03:00

13 KiB
Raw Blame History

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

Хранилища (sources)

Источник — корень, из которого берутся файлы. Управляет только admin.

Тип Как подключается
nfs Админ указывает server, export, опции. storage-agent выполняет mount -t nfs -o <opts> server:export /library/<id>.
local Папка, уже смонтированная на хосте в /srv/pipu/library-host (например, через fstab). Админ выбирает подпапку. Нужен как запасной путь и для dev.

Монтирование (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 для такого источника дополнительно проверяет запись: создаёт и удаляет .pipu-write-test в корне.
  • У источника задаётся write_template — шаблон пути для новых файлов, с автоподбором по существующей структуре и проверкой обратимости через регэкспы источника (подробно — uploads.md).

Шаблоны путей (регэкспы)

Для каждого источника — упорядоченный список регулярных выражений (синтаксис 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) — для проверки дублей при загрузке; низкий приоритет
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 — чтение тегов).

Обложки

Источники по приоритету: встроенная в файл → 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).
  • Персонализация (опционально, этап 2): треки, которые пользователь лайкал / часто слушал, поднимаются выше в выдаче suggest (re-rank первых 20 на стороне api).
  • Синхронизация: api после изменения каталога ставит документы в батч-очередь; полная переиндексация — задача reindex_search (индекс собирается во временном индексе и подменяется через swap-indexes, без даунтайма).