ci / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 3m55s
ci / api (push) Successful in 51s
ci / mobile (Metro-бандл Android) (push) Canceled after 0s
release / images (services/api/Dockerfile, storage-agent, storage-agent) (push) Canceled after 0s
release / images (services/recommender/Dockerfile, recommender, ) (push) Canceled after 0s
ci / recommender (push) Successful in 1m11s
ci / ts (packages + web) (push) Canceled after 3s
release / verify (push) Successful in 1m16s
release / images (apps/web/Dockerfile, web, ) (push) Failing after 2m40s
release / android + release (push) Skipped
release / images (services/api/Dockerfile, api, api) (push) Canceled after 1s
- api/openapi.yaml: contract with /healthz, /readyz, /api/v1/server-info - services/api (Go): api with oapi-codegen strict server, goose migrations, readiness checks for Postgres/Redis; storage-agent stub with internal token - services/recommender (Python, uv): FastAPI health/readiness/internal status, `pipu-rec serve` and `pipu-rec analyze` entrypoints - packages: generated api-client, core (server URL + server check), i18n (ru/en + dictionary check), design-tokens (CSS vars + TS from tokens.json) - apps/web: Vite + React shell showing server status, language switch - apps/mobile: Expo (SDK 53) Android app with server connect screen - deploy: Dockerfiles, compose (prod + dev), nginx template, install/update scripts - .gitea/workflows: ci.yml and tag-based release.yml (images, signed APK, release) - scripts/version.mjs: version and Android versionCode from git tag - tests/fixtures/music/generate.sh: synthetic test library via ffmpeg - docs: versionCode formula, OpenAPI 3.0.3, dev instructions in README Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
165 lines
12 KiB
Markdown
165 lines
12 KiB
Markdown
# REST API
|
||
|
||
Источник истины — `api/openapi.yaml` (OpenAPI 3.0.3 — лучше всего поддерживается oapi-codegen). Этот документ — обзор и соглашения.
|
||
|
||
## Соглашения
|
||
|
||
- Базовый путь: `/api/v1`. Формат — JSON, `snake_case`.
|
||
- Аутентификация: `Authorization: Bearer <access_jwt>` (кроме `/auth/login`, `/auth/refresh`, `/healthz`,
|
||
`/stream/*` и `/covers/*` — у них подписанные ссылки).
|
||
- Пагинация — курсорная: `?limit=50&cursor=<opaque>` → `{ "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`) и тем, что сервисы не публикуют порты наружу.
|