ci / api / api (push) Successful in 50s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Failing after 10s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Failing after 31m43s
ci / db / db (push) Failing after 19s
- Imported tracks are created directly under their final name with O_EXCL (never overwriting; " (2)" on conflict), fsync'ed and checked against SHA-256 — no hidden .pipu-tmp file next to them any more. - On any failure the agent removes its own partial file and the empty directories it created for it. - The write check uses access(W_OK) and the read-only mount flag instead of creating and deleting .pipu-write-test in the storage root. - Tests assert the storage tree holds only audio files after successful and failed writes and after the write check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
168 lines
16 KiB
Markdown
168 lines
16 KiB
Markdown
# Библиотека: хранилища, шаблоны путей, сканер, поиск
|
||
|
||
## Хранилища (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` источников.
|
||
|
||
### Запись в хранилище
|
||
|
||
Единственный сценарий записи — импорт одобренных треков из [очереди на добавление](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` для такого источника дополнительно проверяет право записи — без создания файлов.
|
||
- У источника задаётся `write_template` — шаблон пути для новых файлов, с автоподбором по существующей структуре
|
||
и проверкой обратимости через регэкспы источника (подробно — [uploads.md](uploads.md#7-раскладка-в-хранилище-как-принято-на-сервере)).
|
||
|
||
**Реализация.**
|
||
- Агент: `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)/`) |
|
||
|
||
Примеры:
|
||
|
||
```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 -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 — чтение тегов).
|
||
|
||
### Обложки
|
||
|
||
Источники по приоритету: встроенная в файл → `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`, без даунтайма).
|