- Migration 00008: upload_batches, upload_files, upload_quota. - Quota reserved under a row lock when a batch is created; every status change goes through one transition that frees space in the same transaction. SHA-256 duplicates are rejected before any upload. - tus 1.0.0 core (HEAD/PATCH) for the author only; offset is the real staging file size, data beyond the declared size is refused (ADR-015). - Processing worker: ffprobe checks, SHA-256 verification, exact and Chromaprint duplicates against library and queue, metadata from tags or the dropped folder path, possible_duplicate on metadata match, and the target path from write_template reusing existing folder spelling. - Import worker: edited tags written with ffmpeg stream copy, file written by storage-agent, point scan of the new file, imported status; idempotent and resumed after restart. Stale uploads expire after 24 h, orphan staging files are removed at startup. - API: quota, precheck, batches, my uploads, withdraw, admin list, metadata edit, bulk review, retry and queue settings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
206 lines
16 KiB
Markdown
206 lines
16 KiB
Markdown
# Архитектурные решения (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. Подписанные ссылки на стрим
|
||
|
||
**Контекст.** `<audio>` в браузере и ExoPlayer не всегда умеют передавать заголовок `Authorization`.
|
||
|
||
**Решение.** В DTO трека отдаётся `stream_url` с параметрами `exp` и `sig` = HMAC-SHA256(user_id, track_id, quality, exp).
|
||
Срок жизни — 12 ч. Для офлайн-загрузки — отдельная ссылка со сроком 1 ч.
|
||
|
||
**Последствия.** Ссылки нельзя использовать после блокировки пользователя дольше срока жизни — при блокировке
|
||
дополнительно проверяется статус пользователя в `api` (кэш в Redis, 60 с).
|
||
|
||
---
|
||
|
||
## ADR-007. Поиск — Meilisearch
|
||
|
||
**Контекст.** Мгновенный автокомплит по трём типам сущностей, опечатки, русский и английский, 11k–100k документов.
|
||
|
||
**Решение.** Meilisearch, три индекса (`artists`, `albums`, `tracks`), `multi-search` для выпадающего списка.
|
||
Postgres остаётся источником истины, индексы перестраиваемы.
|
||
|
||
**Альтернатива.** `pg_trgm` в Postgres — меньше компонентов, но хуже автокомплит и ранжирование. Оставлено как fallback.
|
||
|
||
---
|
||
|
||
## ADR-008. Подключение NFS из админки через storage-agent
|
||
|
||
**Контекст.** Админ должен подключать хранилища из UI. Монтирование требует `CAP_SYS_ADMIN`,
|
||
давать его публичному `api` небезопасно.
|
||
|
||
**Решение.** Отдельный минимальный сервис `storage-agent` с `CAP_SYS_ADMIN`, без публичных портов.
|
||
Монтирует в `/library/<source_id>` внутри общего bind-тома с `propagation: rshared`;
|
||
`api`, `nginx`, `analyzer` видят точки монтирования через `rslave`. Поддерживаются типы
|
||
`nfs` (монтирует агент) и `local` (папка, уже смонтированная на хосте).
|
||
|
||
**Последствия.** Требуется Linux-хост (mount propagation) и `nfs-common` в образе агента.
|
||
При рестарте агент перемонтирует все активные источники из БД. Дополнено ADR-012 (запись).
|
||
|
||
---
|
||
|
||
## ADR-009. Хранение векторов — pgvector
|
||
|
||
**Контекст.** Эмбеддинги треков (~11k–100k × 256 float) и векторы вкусов (~100). Нужен kNN.
|
||
|
||
**Решение.** pgvector, HNSW-индекс, косинусное расстояние. Отдельная векторная БД не нужна при таком объёме.
|
||
|
||
---
|
||
|
||
## ADR-010. Аутентификация — свои JWT + ротируемые refresh-токены
|
||
|
||
**Решение.** Пароли — argon2id. Access JWT (EdDSA, 15 мин), refresh — непрозрачный токен (хэш в БД), 30 дней,
|
||
ротация при каждом использовании с детекцией повторного использования (отзыв всей «семьи»).
|
||
Веб хранит refresh в `HttpOnly; Secure; SameSite=Strict` cookie, мобильное — в `expo-secure-store` (Android Keystore).
|
||
Подробно — [auth-and-roles.md](auth-and-roles.md).
|
||
|
||
---
|
||
|
||
## ADR-011. Ошибки API — RFC 9457 + коды для i18n
|
||
|
||
**Решение.** Ответы об ошибках — `application/problem+json` с машинным полем `code` (`auth.invalid_credentials`,
|
||
`validation.password_too_short`, …). Клиенты переводят `code` через словари; сервер не возвращает локализованных текстов.
|
||
|
||
---
|
||
|
||
## ADR-012. Очередь на добавление треков и запись в хранилище
|
||
|
||
**Контекст.** Модераторы предлагают новые треки; очередь ограничена по размеру (настраивает админ); дубли отсекаются;
|
||
одобренные файлы должны лечь на файловый сервер в принятой там структуре папок. До этого библиотека была строго read-only.
|
||
|
||
**Решение.**
|
||
- Загрузка по протоколу **tus** (resumable) в локальный staging, квота — счётчик в Postgres, резервируется до загрузки.
|
||
- Дубли — три уровня: SHA-256 (предпроверка на клиенте), нормализованные метаданные, акустический отпечаток **Chromaprint**.
|
||
- Запись в хранилище — **только `storage-agent`**, только в источники с `accepts_uploads`, через отдельную `rw`-точку
|
||
монтирования, невидимую другим контейнерам; только создание новых файлов (tmp → fsync → hash → rename).
|
||
- Путь — по `write_template` хранилища; шаблон автоподбирается по существующей структуре и проверяется на обратимость
|
||
регэкспами хранилища.
|
||
|
||
**Последствия.** Публичный `api` по-прежнему не может изменить библиотеку. Нужны права на запись в NFS-экспорт
|
||
для UID агента. Подробно — [uploads.md](uploads.md).
|
||
|
||
---
|
||
|
||
## ADR-013. Образы собираются на сервере, реестр не используется
|
||
|
||
**Контекст.** Публикация образов в Gitea Container Registry требует настройки реестра, токенов на push и pull,
|
||
`docker login` на сервере. Сервер один, и он сам может собрать образы.
|
||
|
||
**Решение.** На сервере клонируется репозиторий; `deploy/update.sh [vX.Y.Z]` переключается на тег и выполняет
|
||
`docker compose build` + `up -d` (`build:` у сервисов в `deploy/compose.yaml`). Релиз в Gitea содержит только APK.
|
||
CI на каждый PR собирает все образы без push, чтобы сборка на сервере не ломалась.
|
||
|
||
**Последствия.** Серверу нужен доступ к репозиторию и интернет на время сборки; первая сборка — 5–15 минут,
|
||
нужно ~5–10 ГБ под кэш сборки. Откат — `update.sh` на предыдущий тег (пересборка из кэша). Отпадают секреты
|
||
`REGISTRY_USER` / `REGISTRY_TOKEN`. Заменяет часть ADR-001 / ci-cd о публикации образов.
|
||
|
||
---
|
||
|
||
## ADR-014. Анализатор v1 — признаки на numpy, Essentia позже
|
||
|
||
**Контекст.** Нейросетевые модели Essentia (Discogs-EffNet, жанры, настроения) требуют essentia-tensorflow:
|
||
тяжёлый образ, ограниченный набор платформ и версий Python. Волне v1 нужны звучание и базовые признаки уже сейчас.
|
||
|
||
**Решение.** Анализатор `basic-1` (`services/recommender/pipu_rec/analysis/features.py`) считает признаки сам,
|
||
на numpy поверх декодирования ffmpeg: 3 фрагмента по 30 с; эмбеддинг 256 = форма спектра, разброс, динамика
|
||
и ритмичность по 64 mel-полосам (рекомендатель стандартизирует его по библиотеке); темп по автокорреляции онсетов;
|
||
тональность по хромаграмме; громкость, энергия, танцевальность, акустичность, длина вступления.
|
||
Valence — грубая оценка (лад + яркость); `mood_*`, `arousal`, жанры анализа не заполняются.
|
||
|
||
**Последствия.** Анализ работает на любом CPU без моделей. Контексты используют энергию, темп, танцевальность
|
||
и valence; настроения «весёлое» / «грустное» пока приблизительны. Переход на Essentia — новая версия
|
||
`analyzer_ver`: воркер сам ставит пересчёт всех треков.
|
||
|
||
---
|
||
|
||
## ADR-015. Приём загрузок — ядро tus своими силами, без tusd
|
||
|
||
**Контекст.** ADR-012 предполагал `tusd` как библиотеку. Но файл очереди создаёт `POST /uploads` вместе с резервом
|
||
квоты и проверкой дублей, а права (только автор, только `uploading`) проверяет `api`. От tus остаётся только ядро
|
||
протокола: `HEAD` (сколько принято) и `PATCH` (следующий кусок с `Upload-Offset`).
|
||
|
||
**Решение.** Ядро tus 1.0.0 — в `internal/uploads/tus.go` (~200 строк): смещение — фактический размер файла в staging,
|
||
один `PATCH` на файл одновременно, данные сверх заявленного размера не принимаются, куски до 64 МБ. Расширения
|
||
creation / termination / checksum не нужны. Клиент — `tus-js-client` с готовым `uploadUrl` из ответа `POST /uploads`.
|
||
|
||
**Последствия.** Нет новой зависимости и отдельного хранилища состояния tus; квота и права — в одной транзакции
|
||
с созданием файла. Целостность проверяется после приёма: SHA-256 файла сверяется с заявленным клиентом.
|
||
|