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

153 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Библиотека: хранилища, шаблоны путей, сканер, поиск
## Хранилища (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` источников.
### Запись в хранилище
Единственный сценарий записи — импорт одобренных треков из [очереди на добавление](uploads.md).
- Источник с `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](uploads.md#7-раскладка-в-хранилище-как-принято-на-сервере)).
## Шаблоны путей (регэкспы)
Для каждого источника — упорядоченный список регулярных выражений (синтаксис RE2 / Go `regexp`)
с именованными группами. Применяются к пути **относительно корня источника**, с `/` как разделителем.
Первое совпавшее выражение побеждает.
Поддерживаемые группы:
| Группа | Поле |
|---|---|
| `artist` | Исполнитель трека |
| `albumartist` | Исполнитель альбома |
| `album` | Альбом |
| `year` | Год (4 цифры) |
| `disc` | Номер диска |
| `track` | Номер трека |
| `title` | Название |
| `genre` | Жанр |
| `skip` | Если совпала — файл игнорируется (например, `^(?P<skip>_incoming)/`) |
Примеры:
```regex
# Исполнитель/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`, без даунтайма).