ci / api / api (push) Successful in 56s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m23s
ci / db / db (push) Successful in 1m8s
ci / recommender / recommender (push) Failing after 40s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m25s
ci / ts / ts (packages + web) (push) Successful in 7m20s
197 lines
19 KiB
Markdown
197 lines
19 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 — чтение тегов).
|
||
|
||
В редакторе хранилища поле **«Не сканировать папки»** задаёт до 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-фона, цветов кнопки волны и плейсхолдеров.
|
||
|
||
## Поиск
|
||
|
||
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`, без даунтайма).
|