Files
pipu-music/docs/decisions.md
T
mrleo1nidandClaude Opus 5.5 01319c58d7
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
build(deploy): build images on the server instead of publishing to a registry
- 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>
2026-10-20 00:16:05 +03:00

173 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Архитектурные решения (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 о публикации образов.