- 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>
153 lines
13 KiB
Markdown
153 lines
13 KiB
Markdown
# Библиотека: хранилища, шаблоны путей, сканер, поиск
|
||
|
||
## Хранилища (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`, без даунтайма).
|