From 9eb1c25147e5bcb2b1ec8ff85f075961967f5435 Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Mon, 19 Oct 2026 23:21:31 +0300 Subject: [PATCH] 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 --- .claude/launch.json | 11 + README.md | 6 + docs/IDEA.md | 169 ++ docs/README.md | 34 + docs/api.md | 164 ++ docs/architecture.md | 138 ++ docs/auth-and-roles.md | 88 + docs/ci-cd.md | 78 + docs/conventions.md | 42 + docs/data-model.md | 411 ++++ docs/decisions.md | 157 ++ docs/deployment.md | 144 ++ docs/design.md | 165 ++ docs/i18n.md | 36 + docs/library-and-storage.md | 152 ++ docs/mobile.md | 94 + docs/playback.md | 92 + docs/recommendations.md | 239 +++ docs/roadmap.md | 59 + docs/testing.md | 52 + docs/uploads.md | 181 ++ docs/web.md | 103 + docs/Разработка дизайна приложения/.thumbnail | Bin 0 -> 15404 bytes .../Pipu Android.dc.html | 790 +++++++ .../Pipu Web.dc.html | 1614 ++++++++++++++ .../Wave Options.dc.html | 66 + .../android-frame.jsx | 229 ++ .../pipu-data.js | 104 + docs/Разработка дизайна приложения/support.js | 1911 +++++++++++++++++ docs/Разработка дизайна приложения/wave.js | 209 ++ 30 files changed, 7538 insertions(+) create mode 100644 .claude/launch.json create mode 100644 docs/IDEA.md create mode 100644 docs/README.md create mode 100644 docs/api.md create mode 100644 docs/architecture.md create mode 100644 docs/auth-and-roles.md create mode 100644 docs/ci-cd.md create mode 100644 docs/conventions.md create mode 100644 docs/data-model.md create mode 100644 docs/decisions.md create mode 100644 docs/deployment.md create mode 100644 docs/design.md create mode 100644 docs/i18n.md create mode 100644 docs/library-and-storage.md create mode 100644 docs/mobile.md create mode 100644 docs/playback.md create mode 100644 docs/recommendations.md create mode 100644 docs/roadmap.md create mode 100644 docs/testing.md create mode 100644 docs/uploads.md create mode 100644 docs/web.md create mode 100644 docs/Разработка дизайна приложения/.thumbnail create mode 100644 docs/Разработка дизайна приложения/Pipu Android.dc.html create mode 100644 docs/Разработка дизайна приложения/Pipu Web.dc.html create mode 100644 docs/Разработка дизайна приложения/Wave Options.dc.html create mode 100644 docs/Разработка дизайна приложения/android-frame.jsx create mode 100644 docs/Разработка дизайна приложения/pipu-data.js create mode 100644 docs/Разработка дизайна приложения/support.js create mode 100644 docs/Разработка дизайна приложения/wave.js diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..3e23e7a --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "design", + "runtimeExecutable": "python", + "runtimeArgs": ["-m", "http.server", "8765", "--bind", "127.0.0.1", "--directory", "docs"], + "port": 8765 + } + ] +} diff --git a/README.md b/README.md index 2105d43..40ccfef 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,8 @@ # pipu-music +Self-hosted музыкальный сервис: веб и Android-приложение, библиотека на NFS, +умная бесконечная лента «Моя волна» с учётом вкуса, звучания треков и настроения. + +- Идея продукта: [docs/IDEA.md](docs/IDEA.md) +- Документация для разработки: [docs/README.md](docs/README.md) +- План: [docs/roadmap.md](docs/roadmap.md) diff --git a/docs/IDEA.md b/docs/IDEA.md new file mode 100644 index 0000000..d7d5790 --- /dev/null +++ b/docs/IDEA.md @@ -0,0 +1,169 @@ +# Pipu Music — исходная идея продукта + +> Документ описывает **что** должно быть в приложении с точки зрения пользователя. +> Используется как входные данные для создания дизайна. Технические решения — в [README документации](README.md). + +## Суть + +Закрытое (self-hosted) музыкальное приложение для прослушивания музыки с собственного сервера. +Есть веб-версия и мобильное приложение для Android. Главная фишка — **«Моя волна»**: бесконечный умный поток +музыки, который подстраивается под вкус пользователя и под выбранное настроение или занятие. + +Масштаб: до ~100 пользователей, библиотека от 11 000 треков и больше. + +Интерфейс двуязычный — **русский и английский**, с возможностью добавить другие языки позже. +Переключатель языка — в профиле / настройках (и на экране входа). + +## Доступ и учётные записи + +- При первом открытии приложение показывает **экран входа** (логин + пароль). + В мобильном приложении на этом экране также указывается **адрес сервера**. +- **Регистрации нет.** Учётные записи создаёт администрация. +- Роли: + - **Администратор** — полный доступ; единственный, кто может создавать и назначать модераторов + и управлять хранилищами музыки. + - **Модератор** (заместитель администратора) — доступ к админке, может создавать обычных пользователей + и предлагать новые треки на добавление в библиотеку. + - **Пользователь** — слушает музыку. +- **Первый вход администратора:** система сразу предлагает сменить стандартные логин и пароль администратора + (обязательный шаг, пока он не пройден, дальше не пустит). +- **Любой пользователь** может сменить свой пароль (в профиле / настройках). +- Если администратор сбросил пароль пользователю, тот при следующем входе обязан задать новый. + +## Каркас интерфейса (на всех экранах) + +``` +┌──────────────────────────────────────────────────────────────┐ +│ [Логотип] [ 🔍 Поиск ...................... ] [👤] │ ← верхняя панель +├────────────┬─────────────────────────────────────────────────┤ +│ Главная │ │ +│ Коллекция │ Основной контент │ +│ История │ │ +│ Лайки │ │ +│ Дизлайки │ │ +│ Скачанное│ │ +│ │ │ +│ Админка │ │ +├────────────┴─────────────────────────────────────────────────┤ +│ [обл.] Трек — Исполнитель ⏮ ⏯ ⏭ ━━━━●━━━━ 1:23/3:45 🔊 ♥ 👎 │ ← плеер +└──────────────────────────────────────────────────────────────┘ +``` + +### Поиск (всегда виден вверху) +- При вводе появляется **выпадающий список** результатов, сгруппированный по типам: + **исполнители, альбомы, треки**. Клик по результату сразу открывает исполнителя / альбом / запускает трек. +- По нажатию **Enter** открывается **полная страница поиска** со всеми результатами. +- Поиск прощает опечатки и неправильную раскладку. + +### Левая панель навигации +- **Главная** +- **Коллекция** — с подкатегориями: + - **История** — прослушанные треки; + - **Лайки** — понравившиеся треки; + - **Дизлайки** — треки, которые пользователь не хочет слышать (можно убрать дизлайк); + - **Скачанное** — только в мобильном приложении, треки, сохранённые для офлайна. +- **Админка** — видна только администраторам и модераторам. +- Список будет расширяться в будущем (плейлисты и т.п.), дизайн должен это допускать. + +На мобильном вместо левой панели — нижняя навигация (Главная / Поиск / Коллекция / Профиль), +плеер — мини-полоска над навигацией, разворачивается в полноэкранный плеер. + +### Плеер (всегда внизу) +- Полноценный плеер в виде полоски: обложка, название трека, исполнитель, назад / пауза / вперёд, + прогресс-бар с перемоткой, время, громкость, **лайк** и **дизлайк**, открыть очередь. +- **Запоминает последний трек и момент**, на котором остановились: после перезахода / перезагрузки + и даже на другом устройстве можно продолжить с того же места. +- Если играет «Моя волна», плеер показывает это (и выбранное настроение / занятие). +- Дизлайк в волне сразу переключает на следующий трек. + +## Главная страница + +- В центре — **большая кнопка «Моя волна»**. Это главный элемент приложения: + - **анимированная** — живая, «дышащая» волновая анимация; во время воспроизведения пульсирует + в такт музыке (темп и энергия текущего трека); + - **интерактивная** — реагирует на наведение и нажатие; видно, играет волна или нет; повторное нажатие — пауза. +- Рядом с волной — выбор **занятия** и **настроения**, по которым волна корректируется: + - занятие: бегаю, просыпаюсь, работаю / фокус, тренировка, в дороге, отдыхаю, засыпаю; + - настроение: энергичное, весёлое, спокойное, грустное; + - можно не выбирать ничего — тогда волна просто «под вас». +- Волна постоянно учится: лайки, дизлайки, пропуски, сколько раз и насколько долго слушался трек, + жанры и звучание — всё влияет на то, что будет дальше. + +## Страницы + +| Страница | Содержимое | +|---|---| +| Вход | (мобильное: адрес сервера), логин, пароль, «Войти», выбор языка. Без регистрации. | +| Смена учётных данных админа | Обязательный экран при первом входе: новый логин, новый пароль, подтверждение. | +| Обязательная смена пароля | После сброса пароля администрацией. | +| Главная | Кнопка «Моя волна», выбор занятия / настроения. | +| Коллекция → История | Прослушанные треки, сгруппированные по дням; можно запустить любой. | +| Коллекция → Лайки | Понравившиеся треки, можно запустить все / перемешать. | +| Коллекция → Дизлайки | Скрытые из рекомендаций треки, можно снять дизлайк. | +| Коллекция → Скачанное | (мобильное) офлайн-треки, занимаемое место, удалить. | +| Поиск | Полные результаты: исполнители, альбомы, треки. | +| Исполнитель | Фото / обложка, альбомы, популярные треки, «волна по исполнителю». | +| Альбом | Обложка, год, список треков, «скачать альбом» (мобильное). | +| Профиль / настройки | Смена пароля, язык, качество звука (мобильное: для сети и для скачивания), проверка обновлений (мобильное), выход. | +| Админка → Пользователи | Список, создание, роли, сброс пароля, блокировка. | +| Админка → Хранилища | (только админ) Подключение хранилищ музыки, шаблоны структуры папок, запуск сканирования, статус. | +| Админка → Библиотека | Статистика: число треков, ход сканирования и анализа звука, ошибки. | +| Админка → Предложить треки | (модератор и админ) Загрузка файлов и папок перетаскиванием, индикатор заполненности очереди, список своих предложений со статусами. | +| Админка → Очередь на добавление | (только админ) Рассмотрение предложенных треков: прослушать, поправить данные, увидеть, куда файл ляжет, одобрить / отклонить. | +| Админка → Настройки | (только админ) Лимит очереди на добавление, хранилище для новых треков. | + +## Админка + +- **Модератор**: видит пользователей, создаёт обычных пользователей, сбрасывает им пароль, блокирует. +- **Администратор**: всё то же + создание модераторов, назначение / снятие роли модератора, + **управление хранилищами**. +- **Хранилища** (только админ): + - добавить хранилище: сетевая папка NFS (адрес сервера + путь) или уже подключённая папка на сервере; + - проверить подключение; + - задать **один или несколько шаблонов (регулярных выражений)**, по которым из пути к файлу + извлекаются исполнитель, альбом, год, номер трека, название — например + `Исполнитель/Год - Альбом/01 - Название.flac`; + - **предпросмотр**: показать, как шаблоны разберут несколько реальных файлов, до сохранения; + - выбрать, что важнее — теги файла или структура папок; + - запустить пересканирование, видеть прогресс и ошибки. +- **Предложение треков** (модератор и админ): + - модератор загружает новые треки (файлы или целые папки альбомов) в **очередь на добавление**; + - у очереди есть **общий лимит размера** (например, 10 ГБ), его задаёт админ; виден индикатор «занято X из Y ГБ»; + - треки, которые **уже есть** в библиотеке (или уже лежат в очереди), **сразу отклоняются** — ещё до загрузки, + если это можно понять заранее, — и **не занимают место** в очереди; модератор видит, с каким треком совпало; + - модератор видит свои предложения и их статус (загружается, на рассмотрении, одобрено, добавлено, отклонено + причина), + может отозвать предложение до рассмотрения. +- **Очередь на добавление** (только админ): + - список предложенных треков, сгруппированных по альбомам: можно прослушать, посмотреть качество, + поправить исполнителя / альбом / название / год / обложку; + - пометка «возможно, уже есть» для похожих треков (другая версия, ремастер) — решает админ; + - показан **путь, по которому файл ляжет** в хранилище, — в том же виде, как уже разложена музыка на сервере; + - одобрить / отклонить (с причиной), в том числе пачкой; + - после одобрения файл сам укладывается на файловый сервер и появляется в библиотеке. +- Небольшая, утилитарная — не главная часть продукта. + +## Рекомендации («Моя волна») + +Максимально умная система, которая учитывает: +- жанры, а также **звучание** трека (темп, энергия, настроение) — даже если трек никто не слушал; +- лайки и дизлайки; +- сколько раз и как долго слушался каждый трек, пропуски; +- выбранное занятие / настроение; +- вкусы похожих пользователей; +- постепенно обучается на поведении пользователей. + +## Мобильное приложение (Android) + +- Тот же функционал, что и веб: вход, главная с волной, поиск, коллекция, плеер. +- Фоновое воспроизведение, управление с экрана блокировки / шторки / наушников. +- **Офлайн по выбору пользователя**: скачать трек / альбом / лайки; прослушивания без сети + учитываются и отправляются на сервер позже. +- **Самообновление**: приложение проверяет новые релизы в репозитории, предлагает обновиться + и скачивает новую версию. + +## Инфраструктура (для контекста) + +- Музыка хранится на сервере, подключение к хранилищу по NFS; хранилища подключает админ из админки. +- Одобренные новые треки приложение само записывает на файловый сервер, соблюдая принятую там структуру папок. +- Один репозиторий: веб, мобильное приложение, бэкенд, тесты, CI/CD. +- CI/CD на Gitea: по тегу собирается релиз. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..1608099 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,34 @@ +# Документация Pipu Music + +Pipu Music — self-hosted музыкальный сервис (веб + Android) с умной бесконечной лентой «Моя волна». + +## Карта документов + +| Документ | О чём | +|---|---| +| [IDEA.md](IDEA.md) | Исходная идея продукта глазами пользователя (вход для дизайна) | +| [design.md](design.md) | Дизайн-система (токены, шрифты, анимация), кнопка волны, решения по вариантам прототипа, ссылки на макеты | +| [architecture.md](architecture.md) | Общая архитектура, компоненты, потоки данных, структура репозитория | +| [decisions.md](decisions.md) | ADR — принятые архитектурные решения и их обоснование | +| [data-model.md](data-model.md) | Схема БД PostgreSQL | +| [api.md](api.md) | REST API: соглашения, эндпоинты, ошибки | +| [auth-and-roles.md](auth-and-roles.md) | Аутентификация, токены, роли и права, первый запуск | +| [library-and-storage.md](library-and-storage.md) | Хранилища (NFS), шаблоны путей, сканер, метаданные, поиск | +| [uploads.md](uploads.md) | Предложение треков модераторами: очередь, квота, дубли, одобрение, раскладка в хранилище | +| [playback.md](playback.md) | Стриминг, транскодинг, состояние плеера, офлайн | +| [recommendations.md](recommendations.md) | Рекомендательная система и «Моя волна» | +| [web.md](web.md) | Веб-клиент | +| [mobile.md](mobile.md) | Android-приложение, офлайн, самообновление | +| [i18n.md](i18n.md) | Локализация (ru / en, расширяемость) | +| [testing.md](testing.md) | Стратегия тестирования | +| [ci-cd.md](ci-cd.md) | Gitea Actions: проверки и релиз по тегу | +| [deployment.md](deployment.md) | Docker Compose, конфигурация, бэкапы | +| [conventions.md](conventions.md) | Соглашения по коду, git, версиям | +| [roadmap.md](roadmap.md) | Этапы разработки и критерии готовности | + +## Стек в одном абзаце + +Backend API — **Go**; рекомендации и аудиоанализ — **Python**; БД — **PostgreSQL + pgvector**; +поиск — **Meilisearch**; кэш / очереди событий — **Redis**; раздача аудио — **nginx (X-Accel-Redirect)**; +веб — **React + TypeScript + Vite**; мобильное — **React Native (Expo, Android)**; +монорепо — **pnpm + Turborepo**; CI/CD — **Gitea Actions**; деплой — **Docker Compose**. diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..b1974c4 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,164 @@ +# REST API + +Источник истины — `api/openapi.yaml` (OpenAPI 3.1). Этот документ — обзор и соглашения. + +## Соглашения + +- Базовый путь: `/api/v1`. Формат — JSON, `snake_case`. +- Аутентификация: `Authorization: Bearer ` (кроме `/auth/login`, `/auth/refresh`, `/healthz`, + `/stream/*` и `/covers/*` — у них подписанные ссылки). +- Пагинация — курсорная: `?limit=50&cursor=` → `{ "items": [...], "next_cursor": "..." | null }`. +- Ошибки — `application/problem+json` (RFC 9457): + ```json + { "type": "about:blank", "status": 422, "code": "validation.password_too_short", + "detail": "password must be at least 8 characters", "params": { "min": 8 }, + "errors": [{ "field": "new_password", "code": "validation.password_too_short" }] } + ``` + Клиент показывает перевод `code` (с подстановкой `params`), `detail` — только для логов. +- Мутирующие запросы с клиента могут нести `Idempotency-Key` (UUID); повтор в течение 24 ч вернёт тот же ответ. +- Время — ISO 8601 UTC. Длительности — миллисекунды (`*_ms`). +- Rate limit: `429` с `Retry-After`. `/auth/login` — 5 попыток / мин на IP+логин. + +## Основные DTO + +```jsonc +// TrackDto +{ "id": "…", "title": "…", "duration_ms": 215000, + "artists": [{ "id": "…", "name": "…" }], + "album": { "id": "…", "title": "…", "year": 2019, "cover_url": "/api/v1/covers/…?sig=…", + "cover_colors": ["#F2A23A", "#1C3B6E", "#35D3FF"] }, + "genres": ["indie rock"], + "reaction": "like" | "dislike" | null, + "stream_url": "/api/v1/stream/…?q=auto&exp=…&sig=…", + "features": { "bpm": 128, "energy": 0.72 } // для анимации волны, опционально +} +``` + +## Эндпоинты + +### Служебные +| Метод | Путь | Описание | +|---|---|---| +| GET | `/healthz`, `/readyz` | Живость / готовность | +| GET | `/api/v1/server-info` | Версия сервера, мин. версия клиента, поддерживаемые локали, `bootstrap_required` | + +### Аутентификация — [auth-and-roles.md](auth-and-roles.md) +| Метод | Путь | Кто | Описание | +|---|---|---|---| +| POST | `/auth/login` | все | `{username, password, client_type, device_name}` → `{access_token, expires_in, refresh_token?, user}`; веб получает refresh в cookie | +| POST | `/auth/refresh` | все | Ротация refresh → новая пара | +| POST | `/auth/logout` | auth | Отзыв текущей семьи токенов | +| POST | `/auth/logout-all` | auth | Отзыв всех сессий пользователя | +| GET | `/me` | auth | Текущий пользователь, флаги `must_change_*` | +| PATCH | `/me` | auth | `display_name`, `locale`, `ui_prefs` (тема, вид волны и плеера, ambient, кроссфейд, нормализация) | +| POST | `/me/password` | auth | `{current_password, new_password}`; отзывает прочие сессии | +| POST | `/me/bootstrap-credentials` | admin c `must_change_credentials` | `{new_username, new_password}` | +| GET | `/me/sessions` · DELETE `/me/sessions/{id}` | auth | Активные устройства | + +### Каталог +| Метод | Путь | Описание | +|---|---|---| +| GET | `/artists/{id}` | Исполнитель + счётчики | +| GET | `/artists/{id}/albums` | Альбомы | +| GET | `/artists/{id}/top-tracks` | Популярные (по прослушиваниям всех пользователей) | +| GET | `/albums/{id}` | Альбом с треками | +| GET | `/tracks/{id}` | Трек | +| POST | `/tracks/batch` | `{ids:[]}` → треки (для восстановления очереди) | +| GET | `/covers/{albumId}?size=64|300|1200&sig=` | Обложка (WebP, X-Accel) | + +### Поиск — [library-and-storage.md](library-and-storage.md#поиск) +| Метод | Путь | Описание | +|---|---|---| +| GET | `/search/suggest?q=` | Выпадающий список: до 3 исполнителей, 3 альбомов, 5 треков; < 50 мс | +| GET | `/search?q=&type=all|artists|albums|tracks&limit=&cursor=` | Полная страница | +| POST | `/search/click` | `{q, entity_type, entity_id}` — сигнал для ранжирования / рекомендаций | + +### Воспроизведение — [playback.md](playback.md) +| Метод | Путь | Описание | +|---|---|---| +| GET | `/stream/{trackId}?q=auto|original|high|medium|low&exp&sig` | Аудио (X-Accel, Range) | +| GET | `/tracks/{id}/download-url?q=` | Подписанная ссылка для офлайн-загрузки | +| GET | `/me/playback-state` | Последний трек, позиция, очередь | +| PUT | `/me/playback-state` | Сохранить (клиент шлёт раз в 10 с и на паузе / закрытии; `If-Unmodified-Since` для конфликтов) | +| POST | `/events` | Пачка событий `{events:[{id, type, track_id, occurred_at, ...}]}` до 500 шт. → `{accepted, duplicates}` | + +### Коллекция +| Метод | Путь | Описание | +|---|---|---| +| GET | `/me/history?cursor=` | История (сгруппирована по дням на клиенте) | +| DELETE | `/me/history` | Очистить историю (события остаются для статистики, помечаются скрытыми) | +| GET | `/me/likes?cursor=` · `/me/dislikes?cursor=` | Списки | +| PUT | `/me/reactions/tracks/{id}` | `{reaction: "like"|"dislike"}` | +| DELETE | `/me/reactions/tracks/{id}` | Снять реакцию | +| PUT/DELETE | `/me/reactions/artists/{id}` | Реакция на исполнителя | + +### Моя волна — [recommendations.md](recommendations.md) +| Метод | Путь | Описание | +|---|---|---| +| GET | `/wave/options` | Доступные занятия / настроения (ключи для i18n, иконки) | +| POST | `/wave/sessions` | `{activity?, mood?, seed?: {type, id}}` → `{session_id, tracks:[5 TrackDto]}` | +| POST | `/wave/sessions/{id}/next` | `{feedback:[{track_id, outcome}]}` → следующая пачка | +| PATCH | `/wave/sessions/{id}` | Сменить занятие / настроение на лету | +| DELETE | `/wave/sessions/{id}` | Завершить | + +### Мобильное обновление — [mobile.md](mobile.md#самообновление) +| Метод | Путь | Описание | +|---|---|---| +| GET | `/app/android/latest` | `{version, version_code, notes_md, size, sha256, download_url, min_supported}` | +| GET | `/app/android/download/{version}` | APK (прокси / кэш релиза Gitea) | + +### Админка — права см. [auth-and-roles.md](auth-and-roles.md#матрица-прав) +| Метод | Путь | Кто | +|---|---|---| +| GET | `/admin/users?q=&role=&cursor=` | admin, moderator | +| POST | `/admin/users` `{username, display_name, role}` → `{user, temporary_password}` (пароль генерирует сервер, `must_change_password = true`) | moderator: только `role=user`; admin: любые | +| PATCH | `/admin/users/{id}` `{display_name?, role?, is_blocked?}` | role — только admin | +| POST | `/admin/users/{id}/reset-password` → `{temporary_password}` | moderator — только для user | +| DELETE | `/admin/users/{id}` | admin | +| GET/POST | `/admin/sources` | admin | +| GET/PATCH/DELETE | `/admin/sources/{id}` | admin | +| POST | `/admin/sources/{id}/test` | admin — проверить монтирование и доступ | +| PUT | `/admin/sources/{id}/patterns` `{patterns:[{regex, is_enabled}]}` | admin | +| POST | `/admin/sources/{id}/patterns/preview` `{patterns, sample?:50}` | admin — разбор реальных путей без сохранения | +| POST | `/admin/sources/{id}/scan` `{full?: bool}` | admin | +| GET | `/admin/library/status` | admin, moderator — счётчики, прогресс скана / анализа, ошибки | +| GET | `/admin/library/errors?cursor=` | admin | +| POST | `/admin/search/reindex` | admin | +| GET | `/admin/recommender/status` | admin — версии моделей, метрики | +| GET | `/admin/settings` · PATCH `/admin/settings` | admin — лимиты очереди, хранилище по умолчанию и пр. | +| POST | `/admin/sources/{id}/write-template/suggest` → `{template, matched_ratio}` | admin — автоподбор шаблона раскладки | +| POST | `/admin/sources/{id}/write-template/verify` `{template}` → `{ok, roundtrip_ratio, mismatches[]}` | admin — проверка обратимости | + +### Предложение треков — [uploads.md](uploads.md) +| Метод | Путь | Кто | Описание | +|---|---|---|---| +| GET | `/uploads/quota` | moderator, admin | `{limit_bytes, used_bytes, per_user_limit_bytes, user_used_bytes, max_file_bytes}` | +| POST | `/uploads/precheck` | moderator, admin | `{files:[{client_id, size, sha256, tags:{artist,title,album,duration_ms}}]}` → для каждого `accepted` / `duplicate` (+ `track`) / `too_large`; ничего не резервирует | +| POST | `/uploads` | moderator, admin | Создать пачку `{comment, files:[{client_id, name, rel_path, size, sha256}]}` → резерв квоты; `{batch_id, files:[{client_id, upload_file_id, tus_url} | {client_id, rejected: code}]}`; при нехватке места — `uploads.queue_full` для не поместившихся | +| tus | `/uploads/files/{upload_file_id}` | автор | Resumable-загрузка (`HEAD`/`PATCH`) | +| GET | `/uploads/mine?status=&cursor=` | moderator, admin | Мои предложения | +| DELETE | `/uploads/files/{id}` | автор | Отозвать (`withdrawn`), только до решения | +| GET | `/admin/uploads?status=&author=&possible_duplicate=&cursor=` | admin | Очередь, сгруппированная по пачкам | +| GET | `/admin/uploads/files/{id}/stream?sig=` | admin | Прослушивание из staging | +| PATCH | `/admin/uploads/files/{id}` | admin | Правка `metadata`, `target_source_id`; ответ содержит пересчитанный `target_rel_path` | +| PATCH | `/admin/uploads/batches/{id}` | admin | Правка общих полей для всех файлов пачки (исполнитель, альбом, год, обложка) | +| POST | `/admin/uploads/review` | admin | `{file_ids:[], decision: "approve"|"reject", reason?}` — массовое решение | +| POST | `/admin/uploads/files/{id}/retry` | admin | Повторить импорт после `import_failed` | +| GET | `/admin/audit?cursor=` | admin | + +## Внутренние API + +`api → recommender` (`internal-recommender.yaml`): +- `POST /internal/wave/batch` `{user_id, session_id, activity, mood, seed, exclude_ids, feedback, n}` → `{tracks:[{id, source, score}], model_ver}` +- `GET /internal/similar/tracks/{id}`, `GET /internal/similar/artists/{id}` +- `GET /internal/status` + +`api → storage-agent` (`internal-storage.yaml`): +- `POST /internal/mounts` `{source_id, type, server, export, options}` → `{status, message}` +- `DELETE /internal/mounts/{source_id}` +- `GET /internal/mounts` — состояние всех точек +- `POST /internal/files` `{source_id, rel_path, staging_path, sha256}` — безопасная запись файла в хранилище + (tmp → fsync → проверка хэша → atomic rename); только для `accepts_uploads` источников +- `GET /internal/files/exists?source_id=&rel_path=` — проверка конфликта пути + +Внутренние вызовы защищены общим секретом (`INTERNAL_TOKEN`) и тем, что сервисы не публикуют порты наружу. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..9af46c2 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,138 @@ +# Архитектура + +## 1. Контекст + +- До ~100 пользователей, библиотека от 11k треков (рассчитываем на рост до 100k без смены архитектуры). +- Всё разворачивается на одном сервере через Docker Compose. +- Музыка лежит на NFS-хранилищах, которые подключает администратор из админки. Для всех сервисов доступ к музыке — + **только чтение**; единственный писатель — `storage-agent`, который раскладывает одобренные треки из очереди на добавление. +- Клиенты: веб (SPA) и Android-приложение. + +## 2. Компоненты + +``` + ┌──────────────┐ ┌────────────────┐ + Web SPA ─────┐ │ │ │ │ + ├──► │ nginx │────►│ api │──► PostgreSQL (+pgvector) + Android ─────┘ │ (TLS, SPA, │ │ (Go) │──► Redis + │ X-Accel) │ │ │──► Meilisearch + └──────┬───────┘ └───┬────────┬───┘ + │ read-only │ HTTP │ HTTP (internal) + ▼ ▼ ▼ + /library/ recommender storage-agent + /cache/transcode (Python) (Go, CAP_SYS_ADMIN) + ▲ │ │ mount -t nfs + │ ▼ ▼ + │ analyzer /library/ + └────────── (Python, (mount propagation + Essentia) rshared → остальным) +``` + +| Сервис | Язык | Ответственность | +|---|---|---| +| `nginx` | — | TLS, раздача SPA, reverse proxy на `api`, отдача аудиофайлов и обложек через `X-Accel-Redirect` (internal locations), gzip/brotli, кэш статики. | +| `api` | Go | Весь публичный REST API: auth, пользователи, каталог, поиск (прокси к Meilisearch), коллекция, состояние плеера, события прослушиваний, админка, фоновые задачи (сканер, транскодинг) через очередь River (на Postgres). | +| `storage-agent` | Go | Единственный привилегированный контейнер: монтирует / размонтирует NFS-шары в `/library/`, проверяет доступность, **записывает одобренные загрузки** в принимающие хранилища (только создание новых файлов). Принимает команды только от `api` по внутренней сети. | +| `recommender` | Python | Онлайн-сервис рекомендаций: генерация кандидатов, ранжирование, пачки для «Моей волны», обработка обратной связи в реальном времени; ночное обучение моделей (ALS, LightGBM). | +| `analyzer` | Python | Воркер аудиоанализа: Essentia — BPM, энергия, танцевальность, настроение, эмбеддинги. Тот же образ, что `recommender`, другая команда запуска. | +| `postgres` | — | Основное хранилище, pgvector для эмбеддингов, очередь задач River. | +| `redis` | — | Rate limiting, кэш, Redis Streams для событий в `recommender`, сессионные векторы волны. | +| `meilisearch` | — | Полнотекстовый поиск с опечатками и автокомплитом. | + +## 3. Ключевые потоки + +### 3.1 Воспроизведение +1. Клиент получает трек (DTO содержит `stream_url` — подписанную ссылку со сроком жизни). +2. `GET /api/v1/stream/{trackId}?q=high&exp=…&sig=…` → nginx → `api`. +3. `api` проверяет подпись / срок / пользователя, при необходимости готовит транскод в кэше, + отвечает `X-Accel-Redirect: /_internal/library//` (или `/_internal/cache/...`). +4. nginx сам отдаёт файл с поддержкой `Range` (перемотка), `sendfile`, без участия Go. + +Подробно — [playback.md](playback.md). + +### 3.2 События и обучение +1. Клиент шлёт события (`play_start`, `progress`, `play_end`, `skip`, `like`, …) пачками в `POST /api/v1/events`. +2. `api` валидирует, пишет в `play_events` (источник истины) и публикует в Redis Stream `events`. +3. `recommender` читает стрим: обновляет сессионный вектор волны и вектор вкуса пользователя онлайн. +4. Ночью `recommender` переобучает ALS и LightGBM по `play_events` и пересчитывает агрегаты. + +Подробно — [recommendations.md](recommendations.md). + +### 3.3 Сканирование библиотеки +1. Админ добавляет хранилище → `api` → `storage-agent` монтирует NFS. +2. `api` ставит задачу `scan_source` в River → обход файлов, чтение тегов (Go, `dhowden/tag` + `ffprobe`), + применение шаблонов путей, upsert в БД, индексирование в Meilisearch. +3. Для новых / изменённых треков создаётся задача анализа (таблица `analysis_jobs`) → `analyzer`. + +Подробно — [library-and-storage.md](library-and-storage.md). + +### 3.4 Предложение треков +1. Модератор перетаскивает файлы → клиент считает SHA-256 и читает теги → `POST /uploads/precheck`: + известные дубли отбрасываются без загрузки. +2. `POST /uploads` резервирует квоту очереди → загрузка по tus в staging (`/srv/pipu/uploads`). +3. Задача `process_upload_file`: ffprobe, хэш, Chromaprint → дубль (удаление, квота освобождена) или `pending_review`. +4. Админ правит метаданные и одобряет → `import_upload_file`: целевой путь по `write_template` хранилища → + `storage-agent` записывает файл → `scan_path` → трек в каталоге, поиске и очереди анализа. + +Подробно — [uploads.md](uploads.md). + +## 4. Структура репозитория + +``` +pipu-music/ +├── apps/ +│ ├── web/ # React + Vite SPA +│ └── mobile/ # React Native (Expo) Android +├── packages/ # общий TypeScript-код (pnpm workspaces) +│ ├── api-client/ # сгенерированный из OpenAPI клиент + типы +│ ├── core/ # бизнес-логика клиентов: очередь плеера, события, сторы +│ ├── i18n/ # словари ru/en и настройка i18next +│ └── design-tokens/ # цвета, типографика, отступы → CSS vars и TS +├── services/ +│ ├── api/ # Go +│ │ ├── cmd/api/ # main сервиса +│ │ ├── cmd/storage-agent/ # main агента хранилищ +│ │ ├── internal/ +│ │ │ ├── auth/ users/ library/ catalog/ search/ +│ │ │ ├── playback/ collection/ events/ wave/ admin/ uploads/ +│ │ │ └── platform/ # db, redis, config, logging, http, jobs +│ │ ├── migrations/ # goose SQL-миграции +│ │ └── queries/ # sqlc SQL +│ └── recommender/ # Python (uv) +│ ├── pipu_rec/ +│ │ ├── api/ # FastAPI +│ │ ├── analysis/ # Essentia +│ │ ├── candidates/ ranking/ wave/ training/ +│ │ └── storage/ # доступ к Postgres / Redis +│ └── tests/ +├── api/openapi.yaml # единый контракт публичного API +├── deploy/ +│ ├── compose.yaml +│ ├── compose.dev.yaml +│ ├── nginx/ +│ └── .env.example +├── tests/ +│ ├── e2e/ # Playwright +│ └── fixtures/music/ # генерируемые тестовые аудиофайлы +├── .gitea/workflows/ # ci.yml, release.yml +├── docs/ +├── turbo.json pnpm-workspace.yaml package.json +└── README.md +``` + +## 5. Архитектурный стиль + +- **Модульный монолит** в `api`: каждый модуль в `internal/` — `handler` (HTTP) → `service` + (бизнес-логика, интерфейсы) → `repository` (sqlc). Модули общаются через интерфейсы сервисов, + не лезут в чужие таблицы напрямую. +- **Contract-first**: `api/openapi.yaml` — источник истины. Go-сервер генерирует типы и интерфейс + хендлеров (`oapi-codegen`, strict server), TS-клиент генерируется в `packages/api-client`. + CI падает, если сгенерированный код не совпадает с контрактом. +- **Внутренний API** `api ↔ recommender` и `api ↔ storage-agent` — HTTP/JSON во внутренней docker-сети, + описан отдельными `internal-*.yaml` спеками. +- **Идемпотентность**: события и мутирующие запросы клиентов принимают `Idempotency-Key` / `event_id` (UUIDv7), + что важно для офлайн-синхронизации мобильного. +- **Деградация**: если `recommender` недоступен, `api` отдаёт волну по упрощённому fallback-алгоритму + (похожие по жанрам + лайки + случайное), приложение продолжает работать. +- **Наблюдаемость**: структурированные JSON-логи (`slog`, `structlog`), `/healthz` и `/readyz` у каждого сервиса, + метрики Prometheus `/metrics` (опционально подключаются Grafana / Prometheus). diff --git a/docs/auth-and-roles.md b/docs/auth-and-roles.md new file mode 100644 index 0000000..ef77527 --- /dev/null +++ b/docs/auth-and-roles.md @@ -0,0 +1,88 @@ +# Аутентификация, роли, первый запуск + +## Роли + +| Роль | Назначение | +|---|---| +| `admin` | Владелец системы. Может быть несколько, но минимум один всегда (последнего админа нельзя понизить / удалить / заблокировать). | +| `moderator` | Заместитель администратора: управление обычными пользователями. | +| `user` | Слушатель. | + +## Матрица прав + +| Действие | user | moderator | admin | +|---|:-:|:-:|:-:| +| Слушать, поиск, коллекция, волна | ✅ | ✅ | ✅ | +| Сменить свой пароль, язык, сессии | ✅ | ✅ | ✅ | +| Открыть админку | — | ✅ | ✅ | +| Видеть список пользователей | — | ✅ | ✅ | +| Создать пользователя с ролью `user` | — | ✅ | ✅ | +| Сбросить пароль / заблокировать `user` | — | ✅ | ✅ | +| Создать `moderator` / `admin`, менять роли | — | — | ✅ | +| Сбросить пароль / заблокировать `moderator` | — | — | ✅ | +| Удалить пользователя | — | — | ✅ | +| Хранилища, сканирование, переиндексация | — | — | ✅ | +| Статус библиотеки (только просмотр) | — | ✅ | ✅ | +| Предлагать треки (загрузка в очередь), видеть свои предложения | — | ✅ | ✅ | +| Рассматривать очередь, одобрять / отклонять, править метаданные | — | — | ✅ | +| Лимит очереди, целевое хранилище, шаблон раскладки | — | — | ✅ | +| Статус рекомендательной системы, аудит | — | — | ✅ | + +Реализация: в Go — политики в `internal/auth/policy.go` вида `CanManageUser(actor, target, action) bool`, +проверяются в сервисном слое (не только в middleware), покрываются табличными тестами по этой матрице. +Все админские действия пишутся в `audit_log`. + +## Первый запуск (bootstrap) + +1. При старте `api` проверяет `app_settings['bootstrap.completed']`. Если нет и в `users` нет ни одного админа — + создаёт пользователя: + - `username = BOOTSTRAP_ADMIN_USERNAME` (по умолчанию `admin`); + - пароль = `BOOTSTRAP_ADMIN_PASSWORD` из env, а если не задан — генерируется случайный (20 символов) + и **печатается в лог один раз**; + - `must_change_credentials = true`. +2. `GET /server-info` возвращает `bootstrap_required: true` — клиент может показать подсказку на экране входа. +3. После входа с `must_change_credentials = true` любые эндпоинты, кроме `/me`, `/me/bootstrap-credentials`, + `/auth/logout`, отвечают `403 { code: "auth.credentials_change_required" }`. Клиент показывает + экран «Смените логин и пароль администратора». +4. `POST /me/bootstrap-credentials` — новый логин (может остаться `admin`, но пароль обязан отличаться), + новый пароль (≥ 12 символов для админа) → флаг снимается, `bootstrap.completed = true`, + все прочие токены отзываются, выдаётся новая пара. + +Аналогично для `must_change_password` (после сброса пароля админом / модератором): разрешён только +`POST /me/password` (без `current_password`, если вход был по временному паролю). + +## Пароли + +- argon2id: `m=64 MiB, t=3, p=2`, соль 16 байт; параметры в хэше (PHC), при изменении — rehash при входе. +- Политика: ≥ 8 символов для `user`, ≥ 12 для `moderator`/`admin`; не совпадает с логином; проверка по + встроенному списку 10k самых частых паролей. +- Временный пароль при создании пользователя и при сбросе генерирует сервер: 12 символов, показывается **один раз**; + у пользователя ставится `must_change_password = true`. +- Защита от перебора: после 5 неудачных попыток — `locked_until = now() + 5 мин` (растёт экспоненциально до 1 ч), + плюс rate limit по IP в Redis. Ответ всегда одинаковый (`auth.invalid_credentials`) — не раскрываем, + существует ли логин. Время ответа выравнивается (хэш с фиктивным паролем для несуществующих). + +## Токены + +| | Access | Refresh | +|---|---|---| +| Формат | JWT, EdDSA (Ed25519) | 32 случайных байта, base64url; в БД — SHA-256 | +| Срок | 15 мин | 30 дней скользящий (каждая ротация продлевает), абсолютный максимум 180 дней | +| Claims | `sub`, `role`, `sid` (family_id), `ver` (token_version пользователя), `exp`, `iat` | — | +| Веб | в памяти (JS) | cookie `pipu_rt`: `HttpOnly; Secure; SameSite=Strict; Path=/api/v1/auth` | +| Android | в памяти | `expo-secure-store` | + +- **Ротация**: каждый `/auth/refresh` помечает старый токен `used_at` и выдаёт новый в той же `family_id`. + Повторное предъявление уже использованного токена ⇒ компрометация ⇒ отзыв всей семьи. +- **Мгновенный отзыв** (блокировка, смена роли, смена пароля): у пользователя инкрементируется `token_version` + (в Redis-кэше), middleware сравнивает с `ver` в access-токене. +- Ключи подписи: `JWT_PRIVATE_KEY` из env / файла; поддержка `kid` для ротации ключей. +- CSRF: refresh-cookie `SameSite=Strict` и путь только `/api/v1/auth`; остальные запросы — Bearer-заголовок, + поэтому CSRF к ним неприменим. + +## Клиентская логика + +- Один общий модуль в `packages/core/auth`: хранит access в памяти, при `401 auth.token_expired` + делает **единственный** параллельный refresh (single-flight) и повторяет запросы. +- Роутинг-гарды: `must_change_credentials` → `/setup/admin`; `must_change_password` → `/setup/password`; + админские разделы — по `role`. diff --git a/docs/ci-cd.md b/docs/ci-cd.md new file mode 100644 index 0000000..1754d14 --- /dev/null +++ b/docs/ci-cd.md @@ -0,0 +1,78 @@ +# CI/CD (Gitea Actions) + +Workflows лежат в `.gitea/workflows/`. Синтаксис совместим с GitHub Actions; runner — существующий `act_runner` +с доступом к Docker. + +## Версионирование + +- SemVer, тег `vMAJOR.MINOR.PATCH` (предрелизы: `v1.2.0-rc.1` — помечаются как pre-release, мобильное их + предлагает только при включённой настройке «бета-версии»). +- Одна версия на весь продукт: образы, APK и веб собираются с одним номером. +- Android `versionCode = MAJOR·10000 + MINOR·100 + PATCH` (для rc: `… ·100 + rc` с отдельной схемой, см. скрипт + `scripts/version.ts`). +- Changelog — `git-cliff` из Conventional Commits. + +## `ci.yml` — на каждый push и PR + +```yaml +on: + push: { branches: [main] } + pull_request: + +jobs: + changes: # фильтр путей: api / recommender / web / mobile / packages / docs + contract: # spectral lint openapi.yaml; регенерация Go + TS клиентов; git diff --exit-code + api: # go vet, golangci-lint, go test -race ./... (testcontainers), покрытие + recommender: # uv sync; ruff; mypy --strict; pytest (вкл. оффлайн-оценку качества) + ts: # pnpm install --frozen-lockfile; turbo lint typecheck test; i18n:check + web-build: # vite build, проверка размера бандла + mobile-check: # tsc, jest, expo prebuild + ./gradlew assembleDebug (только если менялся mobile/) + e2e: # needs: api, web-build; docker compose -f deploy/compose.test.yaml up; playwright test +``` + +- Кэши: Go modules / build cache, pnpm store, uv cache, Gradle, сгенерированные тестовые аудио. +- Ветка `main` защищена: обязательны `contract`, `api`, `recommender`, `ts`, `e2e`. +- Ночью (`schedule`) — Maestro E2E на эмуляторе и полный прогон без фильтров путей. + +## `release.yml` — по тегу + +```yaml +on: + push: + tags: ['v*.*.*'] + +jobs: + verify: # все проверки ci.yml (reusable workflow) + + images: # needs: verify + strategy: { matrix: { service: [api, storage-agent, recommender, web] } } + # docker buildx build --push + # ${REGISTRY}/${OWNER}/pipu-music-${service}:${VERSION} и :latest (кроме pre-release) + # web-образ = nginx + собранная SPA + конфиг + + android: # needs: verify + # setup-java 17, Android SDK, pnpm install + # VERSION / VERSION_CODE из тега → app.config.ts + # expo prebuild --platform android --clean + # ./gradlew assembleRelease (signingConfig из секретов) + # apksigner verify; sha256sum > pipu-music-${VERSION}.apk.sha256 + + release: # needs: [images, android] + # git-cliff --latest > CHANGELOG_RELEASE.md + # создать Gitea Release через API (POST /repos/{owner}/{repo}/releases), prerelease по суффиксу + # загрузить ассеты: APK, .sha256, deploy/compose.yaml, deploy/.env.example +``` + +## Секреты + +| Секрет | Назначение | +|---|---| +| `REGISTRY_USER`, `REGISTRY_TOKEN` | Push в Gitea Container Registry | +| `RELEASE_TOKEN` | Создание релиза и загрузка ассетов | +| `ANDROID_KEYSTORE_BASE64`, `ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD` | Подпись APK. **Ключ один на всю жизнь приложения** — хранить резервную копию вне Gitea. | + +## Деплой + +Автодеплой не обязателен. Рекомендуемый вариант: на сервере `deploy/update.sh ` — +`docker compose pull && docker compose up -d` с `PIPU_VERSION=`; миграции применяются при старте `api`. +Опционально — job `deploy` в `release.yml` по SSH на сервер для не-pre-release тегов (с ручным approval через environment). diff --git a/docs/conventions.md b/docs/conventions.md new file mode 100644 index 0000000..c191d47 --- /dev/null +++ b/docs/conventions.md @@ -0,0 +1,42 @@ +# Соглашения + +## Git + +- Trunk-based: короткие ветки от `main`, merge через PR (squash). +- Имена веток: `feat/wave-moods`, `fix/scan-rename`, `chore/ci-cache`. +- Conventional Commits: `feat(wave): add running profile`, `fix(api): ...`. Скоупы: `api`, `agent`, `rec`, `web`, + `mobile`, `core`, `i18n`, `ci`, `deploy`, `docs`. Breaking change — `!` и раздел `BREAKING CHANGE:`. +- Изменение API = изменение `openapi.yaml` + регенерация клиентов в том же PR. +- Существенные архитектурные решения — новый ADR в [decisions.md](decisions.md). + +## Go (`services/api`) + +- `gofumpt`, `golangci-lint` (конфиг в репо). Ошибки оборачиваются `fmt.Errorf("...: %w", err)`; + доменные ошибки — типизированные (`ErrForbidden`, `ErrNotFound`) и маппятся в problem+json в одном месте. +- Контекст первым аргументом, без глобального состояния, зависимости через конструкторы (`NewService(repo, clock, ...)`). +- `clock` и генератор ID — интерфейсы (детерминированные тесты). +- SQL — только в `queries/*.sql` (sqlc), без ORM. + +## Python (`services/recommender`) + +- `uv`, `ruff` (lint + format), `mypy --strict`, Pydantic v2 для схем. +- Тяжёлые вычисления — `numpy`, без циклов Python по трекам. +- Конфиги контекстов и весов — YAML, валидируются Pydantic при старте. + +## TypeScript + +- `strict`, ESLint (flat config) + Prettier, без `any` (кроме сгенерированного кода). +- Общая логика — в `packages/core`, платформенная — только адаптеры в `apps/*`. +- Все строки UI — через i18n, хардкод текста ловит `eslint-plugin-i18next`. + +## API + +- `snake_case` в JSON, курсорная пагинация, problem+json с `code`. +- Новые поля — только дополнительные; удаление / переименование — через новую версию эндпоинта или депрекацию на один минорный релиз. + +## Безопасность + +- Секреты только через env / docker secrets, никогда в репозитории. +- Любой ввод, попадающий в команды (`mount`, `ffmpeg`), — только как отдельные аргументы `exec.Command`, без shell, + с whitelist-валидацией. +- Пути файлов — проверка, что итоговый путь внутри корня источника (`filepath.Rel` без `..`). diff --git a/docs/data-model.md b/docs/data-model.md new file mode 100644 index 0000000..1640ffd --- /dev/null +++ b/docs/data-model.md @@ -0,0 +1,411 @@ +# Модель данных (PostgreSQL 17) + +Соглашения: +- первичные ключи — `uuid` (UUIDv7, генерируются в приложении — сортируемы по времени); +- время — `timestamptz`, UTC; +- мягкое удаление только там, где явно указано (`deleted_at`); +- миграции — `services/api/migrations`, goose, только вперёд (down-миграции для dev). +- Расширения: `pgvector`, `pg_trgm`, `citext`. + +## Пользователи и доступ + +```sql +CREATE TYPE user_role AS ENUM ('admin', 'moderator', 'user'); + +CREATE TABLE users ( + id uuid PRIMARY KEY, + username citext NOT NULL UNIQUE, -- 3..32, [a-z0-9._-] + display_name text, + password_hash text NOT NULL, -- argon2id PHC string + role user_role NOT NULL DEFAULT 'user', + locale text NOT NULL DEFAULT 'ru', -- BCP 47: ru, en, ... + ui_prefs jsonb NOT NULL DEFAULT '{}', -- {theme: dark|light|system, wave_variant: orb|liquid, + -- web_player: bar|island, mobile_player: cover|wave, + -- ambient: bool, crossfade_s: 0..6, normalize: bool} + must_change_password boolean NOT NULL DEFAULT false, + must_change_credentials boolean NOT NULL DEFAULT false, -- только для bootstrap-админа: логин + пароль + is_blocked boolean NOT NULL DEFAULT false, + token_version int NOT NULL DEFAULT 0, -- инкремент = мгновенный отзыв access-токенов + failed_login_count int NOT NULL DEFAULT 0, + locked_until timestamptz, + created_by uuid REFERENCES users(id), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + last_login_at timestamptz +); + +CREATE TABLE refresh_tokens ( + id uuid PRIMARY KEY, + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + family_id uuid NOT NULL, -- цепочка ротаций одного входа + token_hash bytea NOT NULL UNIQUE, -- SHA-256 от токена + device_name text, -- "Chrome / Windows", "Pixel 8" + client_type text NOT NULL, -- 'web' | 'android' + created_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz NOT NULL, + used_at timestamptz, -- ротирован + revoked_at timestamptz +); +CREATE INDEX ON refresh_tokens (user_id) WHERE revoked_at IS NULL; + +CREATE TABLE audit_log ( + id uuid PRIMARY KEY, + actor_id uuid REFERENCES users(id), + action text NOT NULL, -- 'user.create', 'user.role_change', 'source.create', ... + target_type text, + target_id uuid, + payload jsonb, + created_at timestamptz NOT NULL DEFAULT now() +); +``` + +## Хранилища и файлы + +```sql +CREATE TYPE source_type AS ENUM ('nfs', 'local'); +CREATE TYPE source_status AS ENUM ('pending', 'mounted', 'error', 'disabled'); +CREATE TYPE tag_priority AS ENUM ('tags_first', 'path_first'); + +CREATE TABLE library_sources ( + id uuid PRIMARY KEY, + name text NOT NULL, + type source_type NOT NULL, + nfs_server text, -- host / IP (для type=nfs) + nfs_export text, -- /export/music + nfs_options text NOT NULL DEFAULT 'ro,nfsvers=4.1,soft,timeo=150,retrans=3', + local_path text, -- для type=local (внутри /library-host) + tag_priority tag_priority NOT NULL DEFAULT 'tags_first', + accepts_uploads boolean NOT NULL DEFAULT false, -- монтируется на запись, принимает одобренные загрузки + write_template text, -- шаблон раскладки: '{albumartist}/{year} - {album}/{track:02} - {title}.{ext}' + scan_interval interval, -- NULL = только вручную + status source_status NOT NULL DEFAULT 'pending', + status_message text, + last_scan_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE path_patterns ( -- упорядоченный список регэкспов источника + id uuid PRIMARY KEY, + source_id uuid NOT NULL REFERENCES library_sources(id) ON DELETE CASCADE, + position int NOT NULL, -- порядок применения, первый совпавший побеждает + regex text NOT NULL, -- RE2 с именованными группами + is_enabled boolean NOT NULL DEFAULT true, + UNIQUE (source_id, position) +); + +CREATE TABLE media_files ( + id uuid PRIMARY KEY, + source_id uuid NOT NULL REFERENCES library_sources(id) ON DELETE CASCADE, + rel_path text NOT NULL, -- путь относительно корня источника + size_bytes bigint NOT NULL, + mtime timestamptz NOT NULL, + content_hash bytea, -- xxhash первых/последних 1 МБ + размер: быстро и устойчиво к переименованию + sha256 bytea, -- полный хэш файла (для проверки дублей при загрузке; считается фоном) + upload_file_id uuid, -- если файл попал через очередь на добавление + format text NOT NULL, -- flac, mp3, m4a, ogg, opus, wav + codec text, bitrate int, sample_rate int, bit_depth int, channels int, + duration_ms int NOT NULL, + track_id uuid, -- FK ставится после создания tracks + missing_since timestamptz, -- файл пропал при скане + scan_error text, + UNIQUE (source_id, rel_path) +); +CREATE INDEX ON media_files (content_hash); +CREATE INDEX ON media_files (sha256); + +CREATE TABLE track_fingerprints ( -- Chromaprint, для поиска дублей при загрузке + file_id uuid PRIMARY KEY REFERENCES media_files(id) ON DELETE CASCADE, + track_id uuid, + duration_ms int NOT NULL, + fingerprint int[] NOT NULL, -- raw-отпечаток fpcalc + fp_prefix bigint NOT NULL -- первые 64 бита — грубый индекс для отбора кандидатов +); +CREATE INDEX ON track_fingerprints (fp_prefix); +CREATE INDEX ON track_fingerprints (duration_ms); +``` + +## Очередь на добавление (загрузки) + +```sql +CREATE TYPE upload_status AS ENUM ( + 'uploading', 'processing', 'pending_review', 'approved', 'importing', 'imported', + 'import_failed', 'duplicate', 'invalid', 'rejected', 'withdrawn', 'expired'); + +CREATE TABLE upload_batches ( -- одна «пачка» загрузки (обычно альбом / папка) + id uuid PRIMARY KEY, + author_id uuid NOT NULL REFERENCES users(id), + comment text, -- комментарий автора для админа + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE upload_files ( + id uuid PRIMARY KEY, + batch_id uuid NOT NULL REFERENCES upload_batches(id) ON DELETE CASCADE, + author_id uuid NOT NULL REFERENCES users(id), + status upload_status NOT NULL, + original_name text NOT NULL, + client_rel_path text, -- относительный путь из перетащенной папки + size_bytes bigint NOT NULL, -- заявленный, затем фактический + sha256 bytea, + staging_path text, + format text, codec text, bitrate int, sample_rate int, bit_depth int, + duration_ms int, + tags jsonb, -- исходные теги файла + metadata jsonb, -- итоговые поля {artist, albumartist, album, year, disc, track, title, genre} + metadata_edited boolean NOT NULL DEFAULT false, + matched_artist_id uuid REFERENCES artists(id), + matched_album_id uuid REFERENCES albums(id), + cover_upload_id uuid, -- отдельная картинка обложки из той же пачки + duplicate_of_track_id uuid REFERENCES tracks(id), + duplicate_level text, -- sha256 | metadata | fingerprint + possible_duplicate boolean NOT NULL DEFAULT false, + target_source_id uuid REFERENCES library_sources(id), + target_rel_path text, + status_reason text, -- код причины: uploads.duplicate, uploads.invalid_audio, текст отклонения + reviewed_by uuid REFERENCES users(id), + reviewed_at timestamptz, + imported_file_id uuid REFERENCES media_files(id), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX ON upload_files (status); +CREATE INDEX ON upload_files (author_id, created_at DESC); +CREATE INDEX ON upload_files (sha256); + +-- Счётчик квоты (одна строка). Обновляется в той же транзакции, что и смена статуса; +-- ночная задача сверяет его с SUM(size_bytes) по «занимающим» статусам. +CREATE TABLE upload_quota ( + id boolean PRIMARY KEY DEFAULT true CHECK (id), + used_bytes bigint NOT NULL DEFAULT 0 +); +``` + +## Каталог + +```sql +CREATE TABLE artists ( + id uuid PRIMARY KEY, + name text NOT NULL, + sort_name text NOT NULL, + name_norm text NOT NULL UNIQUE, -- нормализованное имя: lower, ё→е, без пунктуации + image_path text, -- кэш изображения + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE albums ( + id uuid PRIMARY KEY, + title text NOT NULL, + title_norm text NOT NULL, + album_artist_id uuid REFERENCES artists(id), + year int, + cover_path text, -- путь в кэше обложек (/cache/covers/.webp) + cover_colors text[], -- палитра обложки (3 цвета: доминирующий, второй, акцент) — для ambient-фона, + -- цветов кнопки волны и плейсхолдера обложки + UNIQUE (album_artist_id, title_norm) +); + +CREATE TABLE tracks ( + id uuid PRIMARY KEY, + title text NOT NULL, + album_id uuid REFERENCES albums(id), + disc_no int, track_no int, + year int, + duration_ms int NOT NULL, + primary_file_id uuid REFERENCES media_files(id), -- лучший по качеству файл, если дубли + explicit boolean, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +ALTER TABLE media_files ADD FOREIGN KEY (track_id) REFERENCES tracks(id) ON DELETE SET NULL; + +CREATE TABLE track_artists ( + track_id uuid REFERENCES tracks(id) ON DELETE CASCADE, + artist_id uuid REFERENCES artists(id) ON DELETE CASCADE, + role text NOT NULL DEFAULT 'main', -- main | feat + position int NOT NULL, + PRIMARY KEY (track_id, artist_id, role) +); + +CREATE TABLE genres ( + id serial PRIMARY KEY, + name text NOT NULL, + name_norm text NOT NULL UNIQUE, + parent_id int REFERENCES genres(id) -- иерархия: "metal" → "death metal" +); +CREATE TABLE track_genres ( + track_id uuid REFERENCES tracks(id) ON DELETE CASCADE, + genre_id int REFERENCES genres(id), + source text NOT NULL, -- 'tag' | 'analysis' + weight real NOT NULL DEFAULT 1, -- для analysis — вероятность модели + PRIMARY KEY (track_id, genre_id, source) +); +``` + +## Аудиопризнаки (заполняет analyzer) + +```sql +CREATE TABLE track_features ( + track_id uuid PRIMARY KEY REFERENCES tracks(id) ON DELETE CASCADE, + analyzer_ver text NOT NULL, -- версия пайплайна, для пересчёта + bpm real, bpm_confidence real, + key text, scale text, -- 'A', 'minor' + loudness_lufs real, + energy real, -- 0..1 + danceability real, -- 0..1 + valence real, -- 0..1 (грустно → весело) + arousal real, -- 0..1 (спокойно → возбуждённо) + acousticness real, instrumentalness real, + mood_happy real, mood_sad real, mood_relaxed real, mood_aggressive real, mood_party real, + intro_ms int, -- для мягкого старта / кроссфейда + analyzed_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE track_embeddings ( + track_id uuid PRIMARY KEY REFERENCES tracks(id) ON DELETE CASCADE, + audio vector(256), -- аудио-эмбеддинг (Discogs-EffNet → PCA 256) + cf vector(64), -- item-фактор ALS (обновляется ночью) + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX ON track_embeddings USING hnsw (audio vector_cosine_ops); +CREATE INDEX ON track_embeddings USING hnsw (cf vector_cosine_ops); + +CREATE TABLE analysis_jobs ( + track_id uuid PRIMARY KEY REFERENCES tracks(id) ON DELETE CASCADE, + status text NOT NULL DEFAULT 'queued', -- queued | running | done | failed + attempts int NOT NULL DEFAULT 0, + error text, + locked_at timestamptz, + updated_at timestamptz NOT NULL DEFAULT now() +); +``` + +## Пользовательские данные + +```sql +CREATE TYPE reaction AS ENUM ('like', 'dislike'); + +CREATE TABLE track_reactions ( -- Коллекция → Лайки / Дизлайки + user_id uuid REFERENCES users(id) ON DELETE CASCADE, + track_id uuid REFERENCES tracks(id) ON DELETE CASCADE, + reaction reaction NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, track_id) +); +CREATE INDEX ON track_reactions (user_id, reaction, created_at DESC); + +CREATE TABLE artist_reactions ( -- «не рекомендовать исполнителя» и т.п. + user_id uuid REFERENCES users(id) ON DELETE CASCADE, + artist_id uuid REFERENCES artists(id) ON DELETE CASCADE, + reaction reaction NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, artist_id) +); + +-- Сырые события — источник истины для истории и обучения. Партиционирование по месяцам. +CREATE TABLE play_events ( + id uuid NOT NULL, -- event_id от клиента (идемпотентность) + user_id uuid NOT NULL, + track_id uuid NOT NULL, + type text NOT NULL, -- play_start | play_end | skip | seek | like | unlike | dislike | undislike + occurred_at timestamptz NOT NULL, -- время на клиенте (важно для офлайна) + received_at timestamptz NOT NULL DEFAULT now(), + position_ms int, -- где произошло + played_ms int, -- реально прослушано в этой попытке (для play_end/skip) + context_type text, -- wave | album | artist | search | collection | history | queue + context_id text, -- id альбома / wave_session и т.п. + wave_session_id uuid, + client_type text NOT NULL, -- web | android + offline boolean NOT NULL DEFAULT false, + PRIMARY KEY (id, occurred_at) +) PARTITION BY RANGE (occurred_at); +CREATE INDEX ON play_events (user_id, occurred_at DESC); + +-- Агрегаты для быстрого доступа (обновляются триггерно в api и пересчитываются ночью) +CREATE TABLE user_track_stats ( + user_id uuid, track_id uuid, + play_count int NOT NULL DEFAULT 0, -- засчитанные прослушивания (≥ 50% или ≥ 4 мин) + complete_count int NOT NULL DEFAULT 0, + skip_count int NOT NULL DEFAULT 0, + early_skip_count int NOT NULL DEFAULT 0, -- скип < 30 с + total_played_ms bigint NOT NULL DEFAULT 0, + last_played_at timestamptz, + implicit_score real NOT NULL DEFAULT 0, -- см. recommendations.md + PRIMARY KEY (user_id, track_id) +); + +CREATE TABLE playback_state ( -- «запомнить трек и момент» + user_id uuid PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + track_id uuid REFERENCES tracks(id) ON DELETE SET NULL, + position_ms int NOT NULL DEFAULT 0, + queue jsonb NOT NULL DEFAULT '[]', -- [{track_id, source}] до 500 элементов + queue_index int NOT NULL DEFAULT 0, + context jsonb, -- {type:'wave', wave_session_id, activity, mood} + shuffle boolean NOT NULL DEFAULT false, + repeat_mode text NOT NULL DEFAULT 'off', -- off | one | all + volume real NOT NULL DEFAULT 1, + device_id text, -- кто записал последним + updated_at timestamptz NOT NULL DEFAULT now() +); +``` + +## Рекомендации + +```sql +CREATE TABLE wave_sessions ( + id uuid PRIMARY KEY, + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + activity text, -- running | waking_up | focus | workout | commute | chill | sleep + mood text, -- energetic | happy | calm | sad + seed_type text, -- null | artist | track | album + seed_id uuid, + started_at timestamptz NOT NULL DEFAULT now(), + ended_at timestamptz, + tracks_served int NOT NULL DEFAULT 0 +); + +CREATE TABLE wave_impressions ( -- что выдали и откуда (для обучения ранжировщика и бандита) + wave_session_id uuid REFERENCES wave_sessions(id) ON DELETE CASCADE, + position int NOT NULL, + track_id uuid NOT NULL, + candidate_source text NOT NULL, -- audio_sim | cf | artist | genre | fresh | rediscover | popular + score real, + model_ver text, + served_at timestamptz NOT NULL DEFAULT now(), + outcome text, -- complete | skip_early | skip | like | dislike (заполняется по событиям) + PRIMARY KEY (wave_session_id, position) +); + +CREATE TABLE user_profiles_rec ( -- векторы вкуса и параметры бандита + user_id uuid PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + taste_audio vector(256), -- взвешенное среднее эмбеддингов положительных треков + taste_cf vector(64), -- user-фактор ALS + genre_prefs jsonb NOT NULL DEFAULT '{}',-- {genre_id: weight} + bandit jsonb NOT NULL DEFAULT '{}',-- {source: {alpha, beta}} + exploration real NOT NULL DEFAULT 0.15, + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE model_versions ( + id text PRIMARY KEY, -- 'ranker-2026-10-19T03:00' + kind text NOT NULL, -- als | ranker + metrics jsonb, -- ndcg@10, recall@50, ... + is_active boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now() +); +``` + +## Прочее + +```sql +CREATE TABLE app_settings (key text PRIMARY KEY, value jsonb NOT NULL); +-- 'bootstrap.completed', 'transcode.cache_limit_gb', 'update.gitea', +-- 'uploads.queue_limit_bytes' (10 ГБ), 'uploads.per_user_limit_bytes' (null), 'uploads.max_file_bytes' (2 ГБ), +-- 'uploads.default_source_id', 'uploads.allow_quality_upgrade' (false), ... +``` + +## Очистка и хранение + +- `play_events` — хранить бессрочно (объём маленький: 100 польз. × ~300 событий/день ≈ 11 млн/год, ~2 ГБ), + партиции старше 2 лет можно архивировать. +- `wave_impressions` — 180 дней. +- `refresh_tokens` — удалять истёкшие через 7 дней после `expires_at`. diff --git a/docs/decisions.md b/docs/decisions.md new file mode 100644 index 0000000..d1d39da --- /dev/null +++ b/docs/decisions.md @@ -0,0 +1,157 @@ +# Архитектурные решения (ADR) + +Формат: контекст → решение → последствия. Новое решение — новый раздел с номером; старые не переписываются, +а помечаются `Заменено ADR-NNN`. + +--- + +## ADR-001. Монорепозиторий + +**Контекст.** Веб, мобильное, бэкенд, тесты и CI должны жить в одном репозитории; у веба и мобильного много общего +(API-клиент, логика очереди и событий, словари i18n). + +**Решение.** Монорепо: pnpm workspaces + Turborepo для TS-части; Go-модуль в `services/api`; Python-проект +(uv) в `services/recommender`. Единый `api/openapi.yaml`. + +**Последствия.** Атомарные изменения контракта и клиентов в одном PR. CI использует фильтры путей, +чтобы не гонять всё на каждый коммит. + +--- + +## ADR-002. Backend API на Go + +**Контекст.** Нагрузка — IO (стриминг, NFS, БД), десятки одновременных слушателей; важна простота деплоя и +низкое потребление памяти. + +**Решение.** Go 1.23+: роутер `chi`, `oapi-codegen` (strict server), `pgx` + `sqlc`, миграции `goose`, +фоновые задачи `River` (очередь в Postgres), логирование `log/slog`, конфиг через env (`caarlos0/env`). + +**Последствия.** Один статический бинарник на сервис, быстрые холодные старты, тайпсейф SQL. +ML-код в Go не пишем — для этого отдельный сервис (ADR-003). + +--- + +## ADR-003. Рекомендации и аудиоанализ — отдельный Python-сервис + +**Контекст.** Нужны: аудиоанализ (Essentia и её предобученные модели), коллаборативная фильтрация (implicit ALS), +learning-to-rank (LightGBM). Экосистема есть только в Python. + +**Решение.** `services/recommender`: FastAPI (онлайн-инференс), отдельный процесс `analyzer` (тот же образ), +обучение по расписанию (APScheduler внутри сервиса). Python 3.12, `uv`, `numpy`, `implicit`, `lightgbm`, +`essentia-tensorflow`, `psycopg`, `redis`. + +**Последствия.** Два языка на бэкенде. Граница чёткая: Python не обслуживает пользователей напрямую, +только внутренние вызовы `api`. При падении сервиса работает fallback в `api`. + +⚠️ Предобученные модели Essentia распространяются под CC BY-NC-SA 4.0 — допустимо для частного +некоммерческого self-hosted использования. При коммерческом использовании нужно заменить модели. + +--- + +## ADR-004. Мобильное приложение — React Native (Expo), только Android + +**Контекст.** Только Android, самообновление APK из релизов, фоновое воспроизведение, офлайн, анимированная кнопка волны. +Рассматривались Flutter и React Native. + +**Решение.** React Native + Expo SDK (prebuild / dev client, не Expo Go), `expo-router`, +`react-native-track-player` (ExoPlayer под капотом, MediaSession, уведомление, кэш), `@shopify/react-native-skia` ++ `react-native-reanimated` для волны, `expo-sqlite` для офлайн-данных, `expo-file-system` для загрузок. + +**Почему не Flutter.** Flutter не хуже по возможностям, но React Native даёт общий TypeScript-код с вебом: +API-клиент, типы, логику очереди / событий / офлайн-синхронизации, словари i18n, дизайн-токены. +Для команды из одного-двух человек это существенно сокращает объём работы. + +**Последствия.** iOS не поддерживается (самообновление в обход стора там невозможно); при необходимости +iOS добавляется позже через TestFlight без смены стека. + +--- + +## ADR-005. Раздача аудио через nginx X-Accel-Redirect + +**Контекст.** Нужна перемотка (HTTP Range), высокая пропускная способность, минимум нагрузки на бэкенд. + +**Решение.** Клиент обращается к `api`, `api` проверяет права и подпись ссылки и отвечает заголовком +`X-Accel-Redirect` на `internal` location nginx. Файл отдаёт nginx. + +**Последствия.** Go не держит долгих соединений со стримами. Тома библиотеки и кэша транскодов +должны быть смонтированы и в `api`, и в `nginx` по одинаковым путям. + +--- + +## ADR-006. Подписанные ссылки на стрим + +**Контекст.** `