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>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-19 23:21:31 +03:00
1 parent 36e99fe960
commit 9eb1c25147
30 files changed
+7538

No files matched your search

+152
View File
@@ -0,0 +1,152 @@
# Библиотека: хранилища, шаблоны путей, сканер, поиск
## Хранилища (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`, без даунтайма).