diff --git a/docs/FRONTEND-GUIDE.md b/docs/FRONTEND-GUIDE.md new file mode 100644 index 0000000..5538a3d --- /dev/null +++ b/docs/FRONTEND-GUIDE.md @@ -0,0 +1,910 @@ +# Music Orchestrator — Гайд для фронтенда (v0.1.0 MVP) + +## Содержание + +1. [Видение продукта](#1-видение-продукта) +2. [Аутентификация](#2-аутентификация) +3. [Модели данных](#3-модели-данных) +4. [Система провайдеров](#4-система-провайдеров) +5. [Полный справочник API](#5-полный-справочник-api) +6. [User Flows](#6-user-flows) +7. [Обработка ошибок](#7-обработка-ошибок) +8. [Пагинация](#8-пагинация) +9. [Real-time и playback](#9-real-time-и-playback) +10. [CORS и конфигурация](#10-cors-и-конфигурация) + +--- + +## 1. Видение продукта + +**Music Orchestrator** — self-hosted сервис в стиле Spotify / YouTube Music, где **интернет = библиотека**. Пользователь ищет музыку через единый UI, а бэкенд оркестрирует несколько провайдеров (YouTube, SoundCloud, локальная библиотека/Navidrome), нормализует метаданные и отдаёт треки с учётом capabilities и policy каждого источника. + +### Ключевые принципы + +- **Единый поиск**: один запрос → результаты из всех подключённых провайдеров, merged и deduplicated. +- **Capabilities-first**: фронтенд **никогда не предполагает** возможности провайдера — всегда читает `capabilities` и `policy` из ответа. +- **Provider badges**: каждый трек показывает, откуда он пришёл и какие ограничения есть. +- **Playback-aware**: YouTube → embed iframe, Local → audio stream, SoundCloud → official stream URL. +- **Безопасный по умолчанию**: cache/download кнопки показываются **только** если `policy.cache_allowed` / `policy.download_allowed` == `true`. + +### MVP scope + +- Поиск по YouTube (metadata + embed), SoundCloud (metadata), Local (полный stream/cache). +- Избранное и плейлисты (серверные, привязаны к API key). +- Background jobs (resolve, metadata refresh, local ingest) — в MVP только хранят статус, не запускают unsafe extractors. +- Single-user model (один API key = один user_id `local-user`). + +--- + +## 2. Аутентификация + +Все **записывающие** (POST, DELETE) эндпоинты требуют заголовок `X-API-Key`. + +``` +X-API-Key: +``` + +- По умолчанию dev key: `dev-local-key-change-me` (настраивается через `APP_API_KEYS` env). +- Можно указать несколько ключей через запятую в env. +- При невалидном/отсутствующем ключе → `401 Unauthorized`. + +### Пример + +```bash +curl -X POST http://localhost:8080/v1/favorites \ + -H 'X-API-Key: dev-local-key-change-me' \ + -H 'Content-Type: application/json' \ + -d '{"track_id": "local:seed-1"}' +``` + +### Для фронтенда + +- GET-эндпоинты (search, providers, tracks, playback, health) **не требуют** API key. +- POST/DELETE (favorites, playlists, jobs) **требуют** API key. +- Фронтенд должен хранить ключ в localStorage/secure storage и передавать в заголовке. + +--- + +## 3. Модели данных + +### 3.1 TrackRead — Трек (результат поиска / получения) + +| Поле | Тип | Описание | +|---|---|---| +| `id` | `string` | Уникальный ID трека, формат `{provider}:{provider_track_id}`, напр. `local:seed-1` | +| `title` | `string` | Название трека | +| `artist` | `string?` | Исполнитель | +| `album` | `string?` | Альбом | +| `duration_seconds` | `int?` | Длительность в секундах | +| `artwork_url` | `string?` | URL обложки | +| `canonical_key` | `string` | Нормализованный ключ для deduplication (lowercase `artist::title`) | +| `providers` | `string[]` | Список ID провайдеров, у которых есть этот трек | +| `provider_results` | `ProviderResult[]` | Детальные результаты от каждого провайдера | +| `risk_level` | `RiskLevel` | Объединённый уровень риска | +| `capabilities` | `Capabilities` | Объединённые capabilities | +| `policy` | `Policy` | Объединённая policy | + +### 3.2 ProviderResult — Результат от одного провайдера + +| Поле | Тип | Описание | +|---|---|---| +| `provider_id` | `string` | ID провайдера (`local`, `youtube_official`, `soundcloud_official`) | +| `provider_track_id` | `string` | ID трека внутри провайдера | +| `title` | `string` | Название | +| `artist` | `string?` | Исполнитель | +| `album` | `string?` | Альбом | +| `duration_seconds` | `int?` | Длительность | +| `artwork_url` | `string?` | Обложка | +| `source_url` | `string?` | Прямая ссылка на источник | +| `attribution` | `string?` | Текст attribution (обязателен для SoundCloud) | +| `risk_level` | `RiskLevel` | Уровень риска провайдера | +| `capabilities` | `Capabilities` | Возможности | +| `policy` | `Policy` | Ограничения | + +### 3.3 PlaybackRead — Инструкции воспроизведения + +| Поле | Тип | Описание | +|---|---|---| +| `track_id` | `string` | ID трека | +| `playback_type` | `PlaybackType` | Тип воспроизведения | +| `provider_id` | `string` | Какой провайдер используется | +| `stream_url` | `string?` | URL для прямого аудио-стрима (local, official_stream) | +| `embed_url` | `string?` | URL для iframe embed (YouTube) | +| `expires_in_seconds` | `int?` | TTL стрим-URL (для official_stream) | +| `attribution` | `string?` | Текст attribution | +| `capabilities` | `Capabilities` | | +| `policy` | `Policy` | | + +#### PlaybackType enum + +| Значение | Описание | Когда | +|---|---|---| +| `local_stream` | Прямой аудиофайл | Local/Navidrome | +| `embed` | iframe embed | YouTube | +| `official_stream` | Официальный стрим URL | SoundCloud | +| `unavailable` | Невоспроизводимый | Нет прав/возможностей | + +### 3.4 Capabilities (ProviderCapabilities) + +| Поле | Тип | Описание | +|---|---|---| +| `search_metadata` | `bool` | Может искать метаданные | +| `raw_audio_stream` | `bool` | Может отдать raw audio | +| `embed_playback` | `bool` | Поддерживает embed-воспроизведение | +| `official_stream_url` | `bool` | Отдаёт official stream URL | +| `persistent_cache` | `bool` | Разрешено кэшировать на диск | +| `offline_playback` | `bool` | Поддерживает offline | +| `server_favorites` | `bool` | Серверные избранные | +| `server_playlists` | `bool` | Серверные плейлисты | +| `multiuser_safe` | `bool` | Безопасно для multiuser | +| `public_deployment_safe` | `bool` | Безопасно для публичного деплоя | + +### 3.5 Policy + +| Поле | Тип | Описание | +|---|---|---| +| `compliant_mode` | `bool` | Работает в compliant режиме | +| `cache_allowed` | `bool` | **Можно ли кэшировать** (→ показывать кнопку "Скачать") | +| `download_allowed` | `bool` | **Можно ли скачивать** (→ показывать кнопку "Сохранить") | +| `requires_attribution` | `bool` | Нужно показывать attribution | +| `requires_external_credentials` | `bool` | Нужны внешние credentials | +| `notes` | `string[]` | Пояснения/предупреждения | + +### 3.6 RiskLevel enum + +| Значение | Описание | UI-цвет (рекомендация) | +|---|---|---| +| `compliant` | Полностью легально | 🟢 зелёный | +| `constrained` | Ограничения (attribution, no cache) | 🟡 жёлтый | +| `risky` | ToS risk, личное использование | 🔴 красный | + +### 3.7 FavoriteRead + +| Поле | Тип | +|---|---| +| `track_id` | `string` | +| `created_at` | `string` (ISO 8601) | + +### 3.8 PlaylistSummary / PlaylistRead + +**PlaylistSummary** (для списков): + +| Поле | Тип | +|---|---| +| `id` | `string` (UUID) | +| `name` | `string` | +| `description` | `string?` | +| `track_count` | `int` | +| `created_at` | `string` | +| `updated_at` | `string` | + +**PlaylistRead** (extends PlaylistSummary): + +| Поле | Тип | +|---|---| +| `tracks` | `PlaylistTrackRead[]` | + +**PlaylistTrackRead**: + +| Поле | Тип | +|---|---| +| `track_id` | `string` | +| `position` | `int` | +| `added_at` | `string` | + +### 3.9 JobRead + +| Поле | Тип | Описание | +|---|---|---| +| `id` | `string` (UUID) | ID задачи | +| `type` | `string` | Тип: `resolve`, `metadata_refresh`, `local_ingest` | +| `status` | `JobStatus` | Статус | +| `track_id` | `string?` | Связанный трек | +| `payload` | `object` | Произвольные параметры | +| `result` | `object?` | Результат | +| `error` | `string?` | Текст ошибки | +| `created_at` | `string` | | +| `updated_at` | `string` | | + +#### JobStatus enum: `queued` → `running` → `succeeded` / `failed` / `blocked_by_policy` + +### 3.10 ProviderRead + +| Поле | Тип | +|---|---| +| `id` | `string` | +| `name` | `string` | +| `kind` | `"local"` \| `"youtube"` \| `"soundcloud"` | +| `enabled` | `bool` | +| `configured` | `bool` | +| `risky_enabled` | `bool` | +| `risk_level` | `RiskLevel` | +| `capabilities` | `Capabilities` | +| `policy` | `Policy` | +| `docs_url` | `string?` | + +### 3.11 HealthRead + +| Поле | Тип | +|---|---| +| `status` | `"ok"` | +| `mode` | `"compliant"` | +| `risky_extractors_enabled` | `bool` | +| `database` | `string` | + +--- + +## 4. Система провайдеров + +### Провайдеры в MVP + +| Провайдер | ID | kind | Что может | Playback | +|---|---|---|---|---| +| Local / Navidrome | `local` | `local` | Поиск, stream, cache, download, offline | `local_stream` (прямой URL к файлу) | +| YouTube official | `youtube_official` | `youtube` | Metadata/search (нужен API key), embed playback | `embed` (iframe `youtube.com/embed/...`) | +| SoundCloud official | `soundcloud_official` | `soundcloud` | Metadata/search (нужен client_id), official stream | `official_stream` (временный URL) | + +### Ключевое правило для UI + +``` +Перед показом любой кнопки воспроизведения / скачивания / кэша: +1. Проверить capabilities нужного типа +2. Проверить policy.cache_allowed / policy.download_allowed +3. Показывать provider badge и risk_level на каждом треке +``` + +### Track ID формат + +``` +{provider_id}:{provider_track_id} +``` + +Примеры: `local:seed-1`, `youtube_official:dQw4w9WgXcQ`, `soundcloud_official:12345` + +--- + +## 5. Полный справочник API + +**Base URL**: `http://localhost:8080` +**OpenAPI schema**: `GET /openapi.json` + +--- + +### 5.1 `GET /health` — Проверка здоровья + +**Auth**: не нужен + +**Ответ 200**: +```json +{ + "status": "ok", + "mode": "compliant", + "risky_extractors_enabled": false, + "database": "sqlite" +} +``` + +```bash +curl http://localhost:8080/health +``` + +--- + +### 5.2 `GET /v1/providers` — Список провайдеров + +**Auth**: не нужен + +**Ответ 200**: +```json +{ + "items": [ + { + "id": "local", + "name": "Local / Navidrome-compatible library", + "kind": "local", + "enabled": true, + "configured": true, + "risky_enabled": false, + "risk_level": "compliant", + "capabilities": { + "search_metadata": true, + "raw_audio_stream": true, + "embed_playback": false, + "official_stream_url": false, + "persistent_cache": true, + "offline_playback": true, + "server_favorites": true, + "server_playlists": true, + "multiuser_safe": true, + "public_deployment_safe": true + }, + "policy": { + "compliant_mode": true, + "cache_allowed": true, + "download_allowed": true, + "requires_attribution": false, + "requires_external_credentials": false, + "notes": ["Local files only. ..."] + }, + "docs_url": "https://www.navidrome.org/docs/developers/subsonic-api/" + }, + { + "id": "youtube_official", + "name": "YouTube official metadata/embed", + "kind": "youtube", + "enabled": false, + "configured": false, + "risk_level": "compliant", + "capabilities": { + "raw_audio_stream": false, + "embed_playback": true, + "persistent_cache": false, + "offline_playback": false, + "public_deployment_safe": true + }, + "policy": { + "cache_allowed": false, + "download_allowed": false, + "requires_external_credentials": true, + "notes": ["Metadata/search requires YouTube Data API key. ..."] + }, + "docs_url": "https://developers.google.com/youtube/v3" + } + ] +} +``` + +```bash +curl http://localhost:8080/v1/providers +``` + +--- + +### 5.3 `GET /v1/search` — Поиск треков + +**Auth**: не нужен + +**Параметры запроса**: + +| Параметр | Тип | По умолчанию | Описание | +|---|---|---|---| +| `q` | `string` | *обязательный* | Поисковый запрос (1–200 символов) | +| `providers` | `string` | `local,youtube_official,soundcloud_official` | Через запятую | +| `limit` | `int` | `20` | 1–50 | +| `offset` | `int` | `0` | ≥ 0 | + +**Ответ 200**: +```json +{ + "query": "demo song", + "limit": 20, + "offset": 0, + "total": 2, + "items": [ + { + "id": "local:seed-1", + "title": "Demo Song", + "artist": "Local Artist", + "album": "Home Library", + "duration_seconds": 180, + "artwork_url": null, + "canonical_key": "local artist::demo song", + "providers": ["local"], + "provider_results": [ + { + "provider_id": "local", + "provider_track_id": "seed-1", + "title": "Demo Song", + "artist": "Local Artist", + "album": "Home Library", + "duration_seconds": 180, + "artwork_url": null, + "source_url": "/media/demo-song.opus", + "attribution": "Local library", + "risk_level": "compliant", + "capabilities": { "...": "..." }, + "policy": { "...": "..." } + } + ], + "risk_level": "compliant", + "capabilities": { + "raw_audio_stream": true, + "persistent_cache": true, + "cache_allowed": true, + "download_allowed": true + }, + "policy": { + "compliant_mode": true, + "cache_allowed": true, + "download_allowed": true, + "requires_attribution": false, + "requires_external_credentials": false, + "notes": ["Local files only. ..."] + } + } + ] +} +``` + +```bash +curl 'http://localhost:8080/v1/search?q=demo%20song&limit=10' +curl 'http://localhost:8080/v1/search?q=rock&providers=local&limit=5&offset=0' +``` + +--- + +### 5.4 `GET /v1/tracks/{track_id}` — Получить трек по ID + +**Auth**: не нужен + +**Path params**: `track_id` — формат `{provider}:{id}`, напр. `local:seed-1` + +**Ответ 200**: объект `TrackRead` (см. §3.1) +**Ответ 404**: если трек не найден + +```bash +curl http://localhost:8080/v1/tracks/local:seed-1 +``` + +--- + +### 5.5 `GET /v1/playback/{track_id}` — Получить инструкции воспроизведения + +**Auth**: не нужен + +**Path params**: `track_id` + +**Ответ 200**: объект `PlaybackRead` (см. §3.3) + +В зависимости от провайдера вернётся один из типов: + +| playback_type | stream_url | embed_url | Пример UI | +|---|---|---|---| +| `local_stream` | ✅ прямой URL файла | — | `