ci / api (push) Successful in 50s
ci / recommender (push) Successful in 37s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (apps/web/Dockerfile, web, ) (push) Successful in 44s
release / android + release (push) Failing after 53s
ci / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 45s
ci / ts (packages + web) (push) Successful in 37s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/api/Dockerfile, api, api) (push) Successful in 1m30s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/api/Dockerfile, storage-agent, storage-agent) (push) Successful in 52s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/recommender/Dockerfile, recommender, ) (push) Successful in 28s
ci / mobile (Metro-бандл Android) (push) Successful in 49s
release / verify (push) Successful in 1m14s
- deploy/compose.yaml: every app service has `build:` (repo root context), local tags pipu-music-<service>:<version> - deploy/update.sh: git fetch → checkout tag (latest release by default, or vX.Y.Z / main) → docker compose build --pull → up -d → prune → /readyz; re-executes from a temp copy because checkout may replace the script - install.sh / .env.example: no REGISTRY, PIPU_VERSION set by update.sh, .env owned by the invoking user - release.yml: drop image publishing; release ships APK + sha256 only - ci.yml: new `docker` job builds all images without push - docs: install guide uses git clone, deployment/ci-cd updated, ADR-013 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
173 lines
12 KiB
Markdown
173 lines
12 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 о публикации образов.
|