feat: initial backend-first music orchestrator MVP
Some checks failed
CI / backend (push) Failing after 1m53s
Some checks failed
CI / backend (push) Failing after 1m53s
This commit is contained in:
commit
8d8076bd3f
25 changed files with 1568 additions and 0 deletions
21
.env.example
Normal file
21
.env.example
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
# Music Orchestrator local development settings
|
||||||
|
# Copy to .env and replace placeholders. Do not commit .env.
|
||||||
|
|
||||||
|
APP_APP_NAME=Music Orchestrator
|
||||||
|
APP_ENVIRONMENT=local
|
||||||
|
APP_DATABASE_URL=sqlite:///./data/music-orchestrator.db
|
||||||
|
APP_API_KEYS=change-me-local-dev-key
|
||||||
|
|
||||||
|
# Safe default: risky extractors are off. This MVP does not implement bypass/cookie/raw-audio extraction.
|
||||||
|
APP_ENABLE_RISKY_EXTRACTORS=false
|
||||||
|
|
||||||
|
# Optional compliant/constrained provider credentials. Leave empty to keep provider search stubs disabled.
|
||||||
|
APP_YOUTUBE_API_KEY=
|
||||||
|
APP_SOUNDCLOUD_CLIENT_ID=
|
||||||
|
|
||||||
|
# Optional future local/Navidrome integration placeholders.
|
||||||
|
APP_NAVIDROME_BASE_URL=
|
||||||
|
APP_NAVIDROME_USERNAME=
|
||||||
|
APP_NAVIDROME_TOKEN=
|
||||||
|
|
||||||
|
APP_CORS_ORIGINS=http://localhost:5173,http://localhost:3000
|
||||||
22
.github/workflows/ci.yml
vendored
Normal file
22
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
backend:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.12'
|
||||||
|
- name: Install
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install -e '.[dev]'
|
||||||
|
- name: Lint
|
||||||
|
run: ruff check .
|
||||||
|
- name: Test
|
||||||
|
run: pytest -q
|
||||||
20
.gitignore
vendored
Normal file
20
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
|
||||||
|
# Virtualenv / local runtime
|
||||||
|
.venv/
|
||||||
|
.env
|
||||||
|
data/
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
|
||||||
|
# Editors / OS
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
20
CONTRIBUTING.md
Normal file
20
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
# Contributing
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
1. Create a virtual environment.
|
||||||
|
2. Install dev dependencies: `pip install -e '.[dev]'`.
|
||||||
|
3. Copy `.env.example` to `.env` for local-only settings.
|
||||||
|
4. Run tests: `pytest -q`.
|
||||||
|
5. Run lint: `ruff check .`.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Keep provider adapters capability-driven.
|
||||||
|
- Do not add risky extraction/download/cache behavior unless it is explicitly gated, disabled by default, documented, and tested.
|
||||||
|
- Do not commit secrets, cookies, local domains, tokens, or real credentials.
|
||||||
|
- Add tests for auth, validation, policy, favorites/playlists, jobs, and search merge behavior when touching those areas.
|
||||||
|
|
||||||
|
## API compatibility
|
||||||
|
|
||||||
|
The frontend contract is the OpenAPI schema at `/openapi.json`. Schema-affecting changes must update `docs/API.en.md` and `docs/API.ru.md`.
|
||||||
14
Dockerfile
Normal file
14
Dockerfile
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
FROM python:3.12-slim AS runtime
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY pyproject.toml README.md ./
|
||||||
|
COPY src ./src
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir .
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
CMD ["uvicorn", "music_orchestrator.main:create_app", "--factory", "--host", "0.0.0.0", "--port", "8080"]
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Music Orchestrator contributors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
117
README.md
Normal file
117
README.md
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
# Music Orchestrator
|
||||||
|
|
||||||
|
Backend-first compliant MVP for a personal/home music orchestration service.
|
||||||
|
|
||||||
|
RU: этот проект — безопасный backend-контракт для будущего Spotify/YouTube Music-подобного фронтенда под личный self-hosted сценарий. Он не скачивает и не проксирует YouTube/SoundCloud audio в compliant mode.
|
||||||
|
|
||||||
|
EN: this project is a safe backend contract for a future self-hosted Spotify/YouTube Music-like frontend. It does not download or proxy YouTube/SoundCloud audio in compliant mode.
|
||||||
|
|
||||||
|
## Scope / Объём MVP
|
||||||
|
|
||||||
|
- Python FastAPI backend only; no frontend in this phase.
|
||||||
|
- Typed OpenAPI REST API at `/openapi.json`.
|
||||||
|
- SQLite for local development; database URL is Postgres-ready.
|
||||||
|
- Provider capability/policy model for frontend decisions.
|
||||||
|
- Local/Navidrome-compatible provider boundary.
|
||||||
|
- YouTube official metadata/embed provider boundary, disabled until API key is configured.
|
||||||
|
- SoundCloud official constrained provider boundary, disabled until client id is configured.
|
||||||
|
- Favorites, playlists, abstract jobs, search merge/dedupe behavior.
|
||||||
|
|
||||||
|
## Non-goals / Не цели
|
||||||
|
|
||||||
|
- No production deployment in this phase.
|
||||||
|
- No public SaaS behavior.
|
||||||
|
- No yt-dlp implementation.
|
||||||
|
- No cookie extraction.
|
||||||
|
- No YouTube raw-audio streaming/proxy/cache/download.
|
||||||
|
- No SoundCloud persistent cache/offline storage.
|
||||||
|
- No source-policy evasion.
|
||||||
|
|
||||||
|
`APP_ENABLE_RISKY_EXTRACTORS=false` is the default and this MVP intentionally contains no risky extractor code.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/ssd/projects/music-orchestrator
|
||||||
|
python3 -m venv .venv
|
||||||
|
. .venv/bin/activate
|
||||||
|
pip install -e '.[dev]'
|
||||||
|
cp .env.example .env
|
||||||
|
pytest -q
|
||||||
|
uvicorn music_orchestrator.main:create_app --factory --host 127.0.0.1 --port 8080
|
||||||
|
```
|
||||||
|
|
||||||
|
Health:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:8080/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Search demo local seed data:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl 'http://127.0.0.1:8080/v1/search?q=demo%20song'
|
||||||
|
```
|
||||||
|
|
||||||
|
Create favorite:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://127.0.0.1:8080/v1/favorites \
|
||||||
|
-H 'X-API-Key: change-me-local-dev-key' \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"track_id":"local:seed-1"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker local development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
docker compose up --build api
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional Postgres profile exists for local development only:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --profile postgres up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Then set `APP_DATABASE_URL` in `.env` manually if you want to use Postgres.
|
||||||
|
|
||||||
|
## API docs
|
||||||
|
|
||||||
|
- English: `docs/API.en.md`
|
||||||
|
- Russian: `docs/API.ru.md`
|
||||||
|
- Machine schema: `GET /openapi.json`
|
||||||
|
|
||||||
|
## Provider model
|
||||||
|
|
||||||
|
Every provider exposes:
|
||||||
|
|
||||||
|
- `risk_level`
|
||||||
|
- `capabilities`
|
||||||
|
- `policy`
|
||||||
|
- attribution/docs notes
|
||||||
|
|
||||||
|
The frontend must not infer stream/cache/download behavior from provider name. It must read capability and policy fields.
|
||||||
|
|
||||||
|
## Development checks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest -q
|
||||||
|
ruff check .
|
||||||
|
python - <<'PY'
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from music_orchestrator.main import create_app
|
||||||
|
c = TestClient(create_app())
|
||||||
|
s = c.get('/openapi.json').json()
|
||||||
|
print(len(s['paths']))
|
||||||
|
PY
|
||||||
|
```
|
||||||
|
|
||||||
|
## Русское резюме
|
||||||
|
|
||||||
|
Лучший безопасный путь: сначала стабилизировать backend API и capability model, затем подключать реальные official credentials и только после этого делать фронтенд. В этом MVP внешние источники не подделываются: если YouTube/SoundCloud credentials не заданы, provider остаётся capability/policy stub, а локальный seed нужен только для тестирования API-контракта.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT.
|
||||||
29
SECURITY.md
Normal file
29
SECURITY.md
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Supported scope
|
||||||
|
|
||||||
|
This repository is a local/personal backend-first MVP. It is not a public SaaS product and should not be exposed to the internet without a separate hardening review.
|
||||||
|
|
||||||
|
## Secrets
|
||||||
|
|
||||||
|
Do not commit `.env`, API keys, SoundCloud credentials, YouTube API keys, Navidrome tokens, cookies, or extractor credentials. Use `.env.example` placeholders only.
|
||||||
|
|
||||||
|
## Provider compliance
|
||||||
|
|
||||||
|
Default mode is compliant-only:
|
||||||
|
|
||||||
|
- `APP_ENABLE_RISKY_EXTRACTORS=false`
|
||||||
|
- no yt-dlp download/cache implementation
|
||||||
|
- no cookie extraction
|
||||||
|
- no YouTube raw-audio proxying/streaming
|
||||||
|
- no SoundCloud persistent cache/offline storage
|
||||||
|
- external providers expose capability and policy flags to the frontend
|
||||||
|
|
||||||
|
## Reporting vulnerabilities
|
||||||
|
|
||||||
|
Open a private report or issue with:
|
||||||
|
|
||||||
|
- affected endpoint
|
||||||
|
- expected vs actual behavior
|
||||||
|
- reproduction steps
|
||||||
|
- whether provider credentials or user data could be exposed
|
||||||
23
docker-compose.yml
Normal file
23
docker-compose.yml
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
services:
|
||||||
|
api:
|
||||||
|
build: .
|
||||||
|
env_file: .env
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Postgres-ready local option. The MVP defaults to SQLite; switch APP_DATABASE_URL manually.
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
profiles: ["postgres"]
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: music_orchestrator
|
||||||
|
POSTGRES_USER: music_orchestrator
|
||||||
|
POSTGRES_PASSWORD: change-me-local-only
|
||||||
|
volumes:
|
||||||
|
- postgres-data:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres-data:
|
||||||
104
docs/API.en.md
Normal file
104
docs/API.en.md
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
# Music Orchestrator API (EN)
|
||||||
|
|
||||||
|
Base URL for local development: `http://localhost:8080`
|
||||||
|
|
||||||
|
This is a backend-first compliant MVP contract for a separate frontend. It does not claim working external music playback unless credentials and a real provider adapter are configured.
|
||||||
|
|
||||||
|
## Auth
|
||||||
|
|
||||||
|
Write endpoints require `X-API-Key`.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H 'X-API-Key: change-me-local-dev-key' http://localhost:8080/v1/favorites
|
||||||
|
```
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
### `GET /health`
|
||||||
|
|
||||||
|
Returns service status, compliant mode, risky extractor flag, and database type.
|
||||||
|
|
||||||
|
### `GET /v1/providers`
|
||||||
|
|
||||||
|
Returns provider capability/policy records:
|
||||||
|
|
||||||
|
- `local`: local/Navidrome-compatible library boundary; local files may stream/cache.
|
||||||
|
- `youtube_official`: YouTube Data API metadata + embed playback only; no raw audio/download/cache.
|
||||||
|
- `soundcloud_official`: constrained official API boundary; attribution required; no persistent cache/offline.
|
||||||
|
|
||||||
|
### `GET /v1/search?q=demo&providers=local,youtube_official,soundcloud_official&limit=20&offset=0`
|
||||||
|
|
||||||
|
Returns normalized merged tracks. Each track includes:
|
||||||
|
|
||||||
|
- `provider_results`
|
||||||
|
- `risk_level`
|
||||||
|
- merged `capabilities`
|
||||||
|
- merged `policy`
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl 'http://localhost:8080/v1/search?q=demo%20song'
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /v1/tracks/{track_id}`
|
||||||
|
|
||||||
|
Returns one normalized track by id, e.g. `local:seed-1`.
|
||||||
|
|
||||||
|
### `GET /v1/playback/{track_id}`
|
||||||
|
|
||||||
|
Returns playback instructions. YouTube official returns `embed` with `embed_url`; local returns `local_stream`; unsupported constrained sources can return `unavailable`.
|
||||||
|
|
||||||
|
### `POST /v1/favorites`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8080/v1/favorites \
|
||||||
|
-H 'X-API-Key: change-me-local-dev-key' \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"track_id":"local:seed-1"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /v1/favorites`
|
||||||
|
|
||||||
|
Lists user favorites.
|
||||||
|
|
||||||
|
### `DELETE /v1/favorites/{track_id}`
|
||||||
|
|
||||||
|
Deletes a favorite.
|
||||||
|
|
||||||
|
### `POST /v1/playlists`
|
||||||
|
|
||||||
|
Creates a playlist.
|
||||||
|
|
||||||
|
### `GET /v1/playlists`
|
||||||
|
|
||||||
|
Lists playlists with `track_count`.
|
||||||
|
|
||||||
|
### `GET /v1/playlists/{playlist_id}`
|
||||||
|
|
||||||
|
Returns playlist tracks.
|
||||||
|
|
||||||
|
### `POST /v1/playlists/{playlist_id}/tracks`
|
||||||
|
|
||||||
|
Adds a track to a playlist.
|
||||||
|
|
||||||
|
### `POST /v1/jobs`
|
||||||
|
|
||||||
|
Creates an abstract background job record. Supported MVP types: `resolve`, `metadata_refresh`, `local_ingest`. The MVP stores queued jobs but does not run unsafe extractors.
|
||||||
|
|
||||||
|
### `GET /v1/jobs` and `GET /v1/jobs/{job_id}`
|
||||||
|
|
||||||
|
Lists and reads job records.
|
||||||
|
|
||||||
|
### `GET /openapi.json`
|
||||||
|
|
||||||
|
Machine-readable OpenAPI schema for frontend integration.
|
||||||
|
|
||||||
|
## Frontend notes
|
||||||
|
|
||||||
|
- Never assume a provider can stream or cache: read `capabilities` and `policy`.
|
||||||
|
- Show provider badges and risk labels in search results.
|
||||||
|
- For YouTube official playback, render the embed URL rather than trying to play raw audio.
|
||||||
|
- Do not show cache/download buttons unless `policy.cache_allowed` or `policy.download_allowed` is true.
|
||||||
104
docs/API.ru.md
Normal file
104
docs/API.ru.md
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
# Music Orchestrator API (RU)
|
||||||
|
|
||||||
|
Локальный base URL: `http://localhost:8080`
|
||||||
|
|
||||||
|
Это backend-first compliant MVP-контракт для отдельного фронтенда. Он не заявляет рабочий внешний музыкальный источник без реальных credentials и реализованного adapter.
|
||||||
|
|
||||||
|
## Auth
|
||||||
|
|
||||||
|
Write endpoints требуют `X-API-Key`.
|
||||||
|
|
||||||
|
Пример:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H 'X-API-Key: change-me-local-dev-key' http://localhost:8080/v1/favorites
|
||||||
|
```
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
### `GET /health`
|
||||||
|
|
||||||
|
Статус сервиса, compliant mode, флаг risky extractors и тип БД.
|
||||||
|
|
||||||
|
### `GET /v1/providers`
|
||||||
|
|
||||||
|
Возвращает capability/policy по провайдерам:
|
||||||
|
|
||||||
|
- `local`: граница локальной/Navidrome-compatible библиотеки; локальные файлы можно stream/cache.
|
||||||
|
- `youtube_official`: metadata через YouTube Data API + embed playback; без raw audio/download/cache.
|
||||||
|
- `soundcloud_official`: constrained official API; нужна attribution; без persistent cache/offline.
|
||||||
|
|
||||||
|
### `GET /v1/search?q=demo&providers=local,youtube_official,soundcloud_official&limit=20&offset=0`
|
||||||
|
|
||||||
|
Возвращает нормализованные и смердженные треки. В каждом треке есть:
|
||||||
|
|
||||||
|
- `provider_results`
|
||||||
|
- `risk_level`
|
||||||
|
- merged `capabilities`
|
||||||
|
- merged `policy`
|
||||||
|
|
||||||
|
Пример:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl 'http://localhost:8080/v1/search?q=demo%20song'
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /v1/tracks/{track_id}`
|
||||||
|
|
||||||
|
Возвращает один нормализованный трек, например `local:seed-1`.
|
||||||
|
|
||||||
|
### `GET /v1/playback/{track_id}`
|
||||||
|
|
||||||
|
Возвращает инструкции playback. YouTube official отдаёт `embed` и `embed_url`; local отдаёт `local_stream`; неподдержанные constrained источники могут вернуть `unavailable`.
|
||||||
|
|
||||||
|
### `POST /v1/favorites`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8080/v1/favorites \
|
||||||
|
-H 'X-API-Key: change-me-local-dev-key' \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"track_id":"local:seed-1"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /v1/favorites`
|
||||||
|
|
||||||
|
Список favorites пользователя.
|
||||||
|
|
||||||
|
### `DELETE /v1/favorites/{track_id}`
|
||||||
|
|
||||||
|
Удаляет favorite.
|
||||||
|
|
||||||
|
### `POST /v1/playlists`
|
||||||
|
|
||||||
|
Создаёт playlist.
|
||||||
|
|
||||||
|
### `GET /v1/playlists`
|
||||||
|
|
||||||
|
Список playlists с `track_count`.
|
||||||
|
|
||||||
|
### `GET /v1/playlists/{playlist_id}`
|
||||||
|
|
||||||
|
Playlist со списком треков.
|
||||||
|
|
||||||
|
### `POST /v1/playlists/{playlist_id}/tracks`
|
||||||
|
|
||||||
|
Добавляет трек в playlist.
|
||||||
|
|
||||||
|
### `POST /v1/jobs`
|
||||||
|
|
||||||
|
Создаёт абстрактную background job. MVP-типы: `resolve`, `metadata_refresh`, `local_ingest`. MVP хранит queued jobs, но не запускает unsafe extractors.
|
||||||
|
|
||||||
|
### `GET /v1/jobs` и `GET /v1/jobs/{job_id}`
|
||||||
|
|
||||||
|
Список и чтение job records.
|
||||||
|
|
||||||
|
### `GET /openapi.json`
|
||||||
|
|
||||||
|
OpenAPI schema для интеграции фронтенда.
|
||||||
|
|
||||||
|
## Заметки для фронтенда
|
||||||
|
|
||||||
|
- Не предполагай, что провайдер умеет stream/cache: читай `capabilities` и `policy`.
|
||||||
|
- Показывай provider badges и risk labels в search results.
|
||||||
|
- Для YouTube official playback рендерь embed URL, а не пытайся играть raw audio.
|
||||||
|
- Не показывай кнопки cache/download, если `policy.cache_allowed` или `policy.download_allowed` не true.
|
||||||
42
pyproject.toml
Normal file
42
pyproject.toml
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "music-orchestrator"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Backend-first compliant music orchestration MVP"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
license = {text = "MIT"}
|
||||||
|
authors = [{ name = "Music Orchestrator contributors" }]
|
||||||
|
dependencies = [
|
||||||
|
"fastapi>=0.111,<1",
|
||||||
|
"uvicorn[standard]>=0.30,<1",
|
||||||
|
"pydantic-settings>=2.3,<3",
|
||||||
|
"sqlmodel>=0.0.21,<1",
|
||||||
|
"httpx>=0.27,<1",
|
||||||
|
"python-multipart>=0.0.9,<1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.2,<9",
|
||||||
|
"pytest-asyncio>=0.23,<1",
|
||||||
|
"ruff>=0.5,<1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
music-orchestrator = "music_orchestrator.main:main"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["src/music_orchestrator"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
addopts = "-q"
|
||||||
|
testpaths = ["tests"]
|
||||||
|
pythonpath = ["src"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py311"
|
||||||
1
src/music_orchestrator/__init__.py
Normal file
1
src/music_orchestrator/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""Music Orchestrator backend package."""
|
||||||
28
src/music_orchestrator/auth.py
Normal file
28
src/music_orchestrator/auth.py
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import Depends, Header, HTTPException, Request, status
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from .config import Settings, get_settings
|
||||||
|
|
||||||
|
USER_ID = "local-user"
|
||||||
|
|
||||||
|
|
||||||
|
def error_response(status_code: int, code: str, message: str, details: dict | None = None) -> JSONResponse:
|
||||||
|
return JSONResponse(status_code=status_code, content={"error": {"code": code, "message": message, "details": details}})
|
||||||
|
|
||||||
|
|
||||||
|
def require_api_key(
|
||||||
|
x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
|
||||||
|
settings: Settings = Depends(get_settings),
|
||||||
|
) -> str:
|
||||||
|
if not x_api_key or x_api_key not in settings.api_key_set:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or missing API key")
|
||||||
|
return USER_ID
|
||||||
|
|
||||||
|
|
||||||
|
async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
|
||||||
|
code = "unauthorized" if exc.status_code == 401 else "http_error"
|
||||||
|
return error_response(exc.status_code, code, str(exc.detail))
|
||||||
33
src/music_orchestrator/config.py
Normal file
33
src/music_orchestrator/config.py
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(env_prefix="APP_", env_file=".env", extra="ignore")
|
||||||
|
|
||||||
|
app_name: str = "Music Orchestrator"
|
||||||
|
environment: str = "local"
|
||||||
|
database_url: str = "sqlite:///./data/music-orchestrator.db"
|
||||||
|
api_keys: str = "dev-local-key-change-me"
|
||||||
|
enable_risky_extractors: bool = False
|
||||||
|
youtube_api_key: str = ""
|
||||||
|
soundcloud_client_id: str = ""
|
||||||
|
navidrome_base_url: str = ""
|
||||||
|
navidrome_username: str = ""
|
||||||
|
navidrome_token: str = ""
|
||||||
|
cors_origins: str = "http://localhost:5173,http://localhost:3000"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def api_key_set(self) -> set[str]:
|
||||||
|
return {item.strip() for item in self.api_keys.split(",") if item.strip()}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cors_origin_list(self) -> list[str]:
|
||||||
|
return [item.strip() for item in self.cors_origins.split(",") if item.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
33
src/music_orchestrator/db.py
Normal file
33
src/music_orchestrator/db.py
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Generator
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
from sqlmodel import Session, SQLModel, create_engine
|
||||||
|
|
||||||
|
from .config import Settings
|
||||||
|
|
||||||
|
|
||||||
|
def make_engine(settings: Settings):
|
||||||
|
if settings.database_url.startswith("sqlite:///"):
|
||||||
|
db_path = settings.database_url.removeprefix("sqlite:///")
|
||||||
|
if db_path and db_path != ":memory:":
|
||||||
|
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {}
|
||||||
|
kwargs = {"connect_args": connect_args}
|
||||||
|
if settings.database_url == "sqlite://":
|
||||||
|
kwargs["poolclass"] = StaticPool
|
||||||
|
return create_engine(settings.database_url, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def init_db(engine) -> None:
|
||||||
|
SQLModel.metadata.create_all(engine)
|
||||||
|
|
||||||
|
|
||||||
|
def session_dependency(engine):
|
||||||
|
def get_session() -> Generator[Session, None, None]:
|
||||||
|
with Session(engine) as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
return get_session
|
||||||
297
src/music_orchestrator/main.py
Normal file
297
src/music_orchestrator/main.py
Normal file
|
|
@ -0,0 +1,297 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import Depends, FastAPI, HTTPException, Query, Response
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from sqlmodel import Session, col, delete, func, select
|
||||||
|
|
||||||
|
from .auth import http_exception_handler, require_api_key
|
||||||
|
from .config import Settings, get_settings
|
||||||
|
from .db import init_db, make_engine, session_dependency
|
||||||
|
from .models import Favorite, Job, Playlist, PlaylistTrack, utcnow
|
||||||
|
from .providers import build_adapters
|
||||||
|
from .schemas import (
|
||||||
|
FavoriteCreate,
|
||||||
|
FavoriteList,
|
||||||
|
FavoriteRead,
|
||||||
|
HealthRead,
|
||||||
|
JobCreate,
|
||||||
|
JobList,
|
||||||
|
JobRead,
|
||||||
|
JobStatus,
|
||||||
|
PlaybackRead,
|
||||||
|
PlaybackType,
|
||||||
|
PlaylistCreate,
|
||||||
|
PlaylistList,
|
||||||
|
PlaylistRead,
|
||||||
|
PlaylistSummary,
|
||||||
|
PlaylistTrackCreate,
|
||||||
|
PlaylistTrackRead,
|
||||||
|
ProviderList,
|
||||||
|
SearchResponse,
|
||||||
|
TrackRead,
|
||||||
|
)
|
||||||
|
from .services import get_track, search_merged
|
||||||
|
|
||||||
|
|
||||||
|
def _count(session: Session, model, *where) -> int:
|
||||||
|
statement = select(func.count()).select_from(model)
|
||||||
|
for clause in where:
|
||||||
|
statement = statement.where(clause)
|
||||||
|
return int(session.exec(statement).one())
|
||||||
|
|
||||||
|
|
||||||
|
def _job_read(job: Job) -> JobRead:
|
||||||
|
return JobRead(
|
||||||
|
id=job.id,
|
||||||
|
type=job.type,
|
||||||
|
status=JobStatus(job.status),
|
||||||
|
track_id=job.track_id,
|
||||||
|
payload=job.payload,
|
||||||
|
result=job.result,
|
||||||
|
error=job.error,
|
||||||
|
created_at=job.created_at,
|
||||||
|
updated_at=job.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _playlist_summary(session: Session, playlist: Playlist) -> PlaylistSummary:
|
||||||
|
track_count = _count(session, PlaylistTrack, PlaylistTrack.playlist_id == playlist.id)
|
||||||
|
return PlaylistSummary(
|
||||||
|
id=playlist.id,
|
||||||
|
name=playlist.name,
|
||||||
|
description=playlist.description,
|
||||||
|
track_count=track_count,
|
||||||
|
created_at=playlist.created_at,
|
||||||
|
updated_at=playlist.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(settings: Settings | None = None) -> FastAPI:
|
||||||
|
settings = settings or get_settings()
|
||||||
|
engine = make_engine(settings)
|
||||||
|
adapters = build_adapters(settings)
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
init_db(engine)
|
||||||
|
yield
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="Music Orchestrator API",
|
||||||
|
version="0.1.0",
|
||||||
|
description=(
|
||||||
|
"Backend-first compliant Music Orchestrator MVP. "
|
||||||
|
"External providers are capability-gated; risky extractors are disabled by default."
|
||||||
|
),
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
app.add_exception_handler(HTTPException, http_exception_handler)
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=settings.cors_origin_list,
|
||||||
|
allow_credentials=False,
|
||||||
|
allow_methods=["GET", "POST", "DELETE"],
|
||||||
|
allow_headers=["X-API-Key", "Content-Type"],
|
||||||
|
)
|
||||||
|
|
||||||
|
get_session = session_dependency(engine)
|
||||||
|
|
||||||
|
@app.get("/health", response_model=HealthRead, tags=["system"])
|
||||||
|
def health() -> HealthRead:
|
||||||
|
return HealthRead(
|
||||||
|
status="ok",
|
||||||
|
mode="compliant",
|
||||||
|
risky_extractors_enabled=settings.enable_risky_extractors,
|
||||||
|
database=settings.database_url.split(":", 1)[0],
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.get("/v1/providers", response_model=ProviderList, tags=["providers"])
|
||||||
|
def providers() -> ProviderList:
|
||||||
|
return ProviderList(items=[adapter.provider for adapter in adapters.values()])
|
||||||
|
|
||||||
|
@app.get("/v1/search", response_model=SearchResponse, tags=["catalog"])
|
||||||
|
def search(
|
||||||
|
q: Annotated[str, Query(min_length=1, max_length=200)],
|
||||||
|
providers: Annotated[str, Query(description="Comma-separated provider ids")] = "local,youtube_official,soundcloud_official",
|
||||||
|
limit: Annotated[int, Query(ge=1, le=50)] = 20,
|
||||||
|
offset: Annotated[int, Query(ge=0)] = 0,
|
||||||
|
) -> SearchResponse:
|
||||||
|
requested = [item.strip() for item in providers.split(",") if item.strip()]
|
||||||
|
items = search_merged(adapters, requested, q, limit + offset)
|
||||||
|
return SearchResponse(query=q, limit=limit, offset=offset, total=len(items), items=items[offset : offset + limit])
|
||||||
|
|
||||||
|
@app.get("/v1/tracks/{track_id}", response_model=TrackRead, tags=["catalog"])
|
||||||
|
def track(track_id: str) -> TrackRead:
|
||||||
|
item = get_track(adapters, track_id)
|
||||||
|
if item is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Track not found")
|
||||||
|
return item
|
||||||
|
|
||||||
|
@app.get("/v1/playback/{track_id}", response_model=PlaybackRead, tags=["playback"])
|
||||||
|
def playback(track_id: str) -> PlaybackRead:
|
||||||
|
item = get_track(adapters, track_id)
|
||||||
|
if item is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Track not found")
|
||||||
|
result = item.provider_results[0]
|
||||||
|
if result.provider_id == "local" and result.source_url:
|
||||||
|
return PlaybackRead(
|
||||||
|
track_id=track_id,
|
||||||
|
provider_id=result.provider_id,
|
||||||
|
playback_type=PlaybackType.LOCAL_STREAM,
|
||||||
|
stream_url=result.source_url,
|
||||||
|
attribution=result.attribution,
|
||||||
|
capabilities=result.capabilities,
|
||||||
|
policy=result.policy,
|
||||||
|
)
|
||||||
|
if result.provider_id == "youtube_official":
|
||||||
|
return PlaybackRead(
|
||||||
|
track_id=track_id,
|
||||||
|
provider_id=result.provider_id,
|
||||||
|
playback_type=PlaybackType.EMBED,
|
||||||
|
embed_url=f"https://www.youtube.com/embed/{result.provider_track_id}",
|
||||||
|
attribution=result.attribution,
|
||||||
|
capabilities=result.capabilities,
|
||||||
|
policy=result.policy,
|
||||||
|
)
|
||||||
|
if result.capabilities.official_stream_url and result.source_url:
|
||||||
|
return PlaybackRead(
|
||||||
|
track_id=track_id,
|
||||||
|
provider_id=result.provider_id,
|
||||||
|
playback_type=PlaybackType.OFFICIAL_STREAM,
|
||||||
|
stream_url=result.source_url,
|
||||||
|
expires_in_seconds=3600,
|
||||||
|
attribution=result.attribution,
|
||||||
|
capabilities=result.capabilities,
|
||||||
|
policy=result.policy,
|
||||||
|
)
|
||||||
|
return PlaybackRead(
|
||||||
|
track_id=track_id,
|
||||||
|
provider_id=result.provider_id,
|
||||||
|
playback_type=PlaybackType.UNAVAILABLE,
|
||||||
|
attribution=result.attribution,
|
||||||
|
capabilities=result.capabilities,
|
||||||
|
policy=result.policy,
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.post("/v1/favorites", response_model=FavoriteRead, status_code=201, tags=["library"])
|
||||||
|
def create_favorite(payload: FavoriteCreate, user_id: str = Depends(require_api_key), session: Session = Depends(get_session)) -> FavoriteRead:
|
||||||
|
favorite = session.get(Favorite, (user_id, payload.track_id))
|
||||||
|
if favorite is None:
|
||||||
|
favorite = Favorite(user_id=user_id, track_id=payload.track_id)
|
||||||
|
session.add(favorite)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(favorite)
|
||||||
|
return FavoriteRead(track_id=favorite.track_id, created_at=favorite.created_at)
|
||||||
|
|
||||||
|
@app.get("/v1/favorites", response_model=FavoriteList, tags=["library"])
|
||||||
|
def list_favorites(
|
||||||
|
user_id: str = Depends(require_api_key),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
limit: Annotated[int, Query(ge=1, le=100)] = 50,
|
||||||
|
offset: Annotated[int, Query(ge=0)] = 0,
|
||||||
|
) -> FavoriteList:
|
||||||
|
total = _count(session, Favorite, Favorite.user_id == user_id)
|
||||||
|
rows = session.exec(
|
||||||
|
select(Favorite).where(Favorite.user_id == user_id).order_by(col(Favorite.created_at).desc()).offset(offset).limit(limit)
|
||||||
|
).all()
|
||||||
|
return FavoriteList(limit=limit, offset=offset, total=total, items=[FavoriteRead(track_id=r.track_id, created_at=r.created_at) for r in rows])
|
||||||
|
|
||||||
|
@app.delete("/v1/favorites/{track_id}", status_code=204, tags=["library"])
|
||||||
|
def delete_favorite(track_id: str, user_id: str = Depends(require_api_key), session: Session = Depends(get_session)) -> Response:
|
||||||
|
session.exec(delete(Favorite).where(Favorite.user_id == user_id, Favorite.track_id == track_id))
|
||||||
|
session.commit()
|
||||||
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
@app.post("/v1/playlists", response_model=PlaylistRead, status_code=201, tags=["library"])
|
||||||
|
def create_playlist(payload: PlaylistCreate, user_id: str = Depends(require_api_key), session: Session = Depends(get_session)) -> PlaylistRead:
|
||||||
|
playlist = Playlist(user_id=user_id, name=payload.name, description=payload.description)
|
||||||
|
session.add(playlist)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(playlist)
|
||||||
|
summary = _playlist_summary(session, playlist)
|
||||||
|
return PlaylistRead(**summary.model_dump(), tracks=[])
|
||||||
|
|
||||||
|
@app.get("/v1/playlists", response_model=PlaylistList, tags=["library"])
|
||||||
|
def list_playlists(
|
||||||
|
user_id: str = Depends(require_api_key),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
limit: Annotated[int, Query(ge=1, le=100)] = 50,
|
||||||
|
offset: Annotated[int, Query(ge=0)] = 0,
|
||||||
|
) -> PlaylistList:
|
||||||
|
total = _count(session, Playlist, Playlist.user_id == user_id)
|
||||||
|
rows = session.exec(
|
||||||
|
select(Playlist).where(Playlist.user_id == user_id).order_by(col(Playlist.created_at).desc()).offset(offset).limit(limit)
|
||||||
|
).all()
|
||||||
|
return PlaylistList(limit=limit, offset=offset, total=total, items=[_playlist_summary(session, row) for row in rows])
|
||||||
|
|
||||||
|
@app.get("/v1/playlists/{playlist_id}", response_model=PlaylistRead, tags=["library"])
|
||||||
|
def get_playlist(playlist_id: str, user_id: str = Depends(require_api_key), session: Session = Depends(get_session)) -> PlaylistRead:
|
||||||
|
playlist = session.get(Playlist, playlist_id)
|
||||||
|
if playlist is None or playlist.user_id != user_id:
|
||||||
|
raise HTTPException(status_code=404, detail="Playlist not found")
|
||||||
|
tracks = session.exec(
|
||||||
|
select(PlaylistTrack).where(PlaylistTrack.playlist_id == playlist.id).order_by(PlaylistTrack.position)
|
||||||
|
).all()
|
||||||
|
summary = _playlist_summary(session, playlist)
|
||||||
|
return PlaylistRead(
|
||||||
|
**summary.model_dump(),
|
||||||
|
tracks=[PlaylistTrackRead(track_id=t.track_id, position=t.position, added_at=t.added_at) for t in tracks],
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.post("/v1/playlists/{playlist_id}/tracks", response_model=PlaylistRead, status_code=201, tags=["library"])
|
||||||
|
def add_playlist_track(
|
||||||
|
playlist_id: str,
|
||||||
|
payload: PlaylistTrackCreate,
|
||||||
|
user_id: str = Depends(require_api_key),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> PlaylistRead:
|
||||||
|
playlist = session.get(Playlist, playlist_id)
|
||||||
|
if playlist is None or playlist.user_id != user_id:
|
||||||
|
raise HTTPException(status_code=404, detail="Playlist not found")
|
||||||
|
existing = session.get(PlaylistTrack, (playlist_id, payload.track_id))
|
||||||
|
if existing is None:
|
||||||
|
position = _count(session, PlaylistTrack, PlaylistTrack.playlist_id == playlist_id) + 1
|
||||||
|
session.add(PlaylistTrack(playlist_id=playlist_id, track_id=payload.track_id, position=position))
|
||||||
|
playlist.updated_at = utcnow()
|
||||||
|
session.add(playlist)
|
||||||
|
session.commit()
|
||||||
|
return get_playlist(playlist_id, user_id, session)
|
||||||
|
|
||||||
|
@app.post("/v1/jobs", response_model=JobRead, status_code=202, tags=["jobs"])
|
||||||
|
def create_job(payload: JobCreate, user_id: str = Depends(require_api_key), session: Session = Depends(get_session)) -> JobRead:
|
||||||
|
job = Job(user_id=user_id, type=payload.type, track_id=payload.track_id, payload=payload.payload)
|
||||||
|
session.add(job)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(job)
|
||||||
|
return _job_read(job)
|
||||||
|
|
||||||
|
@app.get("/v1/jobs", response_model=JobList, tags=["jobs"])
|
||||||
|
def list_jobs(
|
||||||
|
user_id: str = Depends(require_api_key),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
limit: Annotated[int, Query(ge=1, le=100)] = 50,
|
||||||
|
offset: Annotated[int, Query(ge=0)] = 0,
|
||||||
|
) -> JobList:
|
||||||
|
total = _count(session, Job, Job.user_id == user_id)
|
||||||
|
rows = session.exec(
|
||||||
|
select(Job).where(Job.user_id == user_id).order_by(col(Job.created_at).desc()).offset(offset).limit(limit)
|
||||||
|
).all()
|
||||||
|
return JobList(limit=limit, offset=offset, total=total, items=[_job_read(row) for row in rows])
|
||||||
|
|
||||||
|
@app.get("/v1/jobs/{job_id}", response_model=JobRead, tags=["jobs"])
|
||||||
|
def get_job(job_id: str, user_id: str = Depends(require_api_key), session: Session = Depends(get_session)) -> JobRead:
|
||||||
|
job = session.get(Job, job_id)
|
||||||
|
if job is None or job.user_id != user_id:
|
||||||
|
raise HTTPException(status_code=404, detail="Job not found")
|
||||||
|
return _job_read(job)
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
uvicorn.run("music_orchestrator.main:create_app", factory=True, host="0.0.0.0", port=8080)
|
||||||
47
src/music_orchestrator/models.py
Normal file
47
src/music_orchestrator/models.py
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from sqlalchemy import JSON, Column
|
||||||
|
from sqlmodel import Field, SQLModel
|
||||||
|
|
||||||
|
|
||||||
|
def utcnow() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
class Favorite(SQLModel, table=True):
|
||||||
|
user_id: str = Field(primary_key=True)
|
||||||
|
track_id: str = Field(primary_key=True)
|
||||||
|
created_at: str = Field(default_factory=utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class Playlist(SQLModel, table=True):
|
||||||
|
id: str = Field(default_factory=lambda: str(uuid4()), primary_key=True)
|
||||||
|
user_id: str = Field(index=True)
|
||||||
|
name: str
|
||||||
|
description: str | None = None
|
||||||
|
created_at: str = Field(default_factory=utcnow)
|
||||||
|
updated_at: str = Field(default_factory=utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class PlaylistTrack(SQLModel, table=True):
|
||||||
|
playlist_id: str = Field(primary_key=True)
|
||||||
|
track_id: str = Field(primary_key=True)
|
||||||
|
position: int
|
||||||
|
added_at: str = Field(default_factory=utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class Job(SQLModel, table=True):
|
||||||
|
id: str = Field(default_factory=lambda: str(uuid4()), primary_key=True)
|
||||||
|
user_id: str = Field(index=True)
|
||||||
|
type: str
|
||||||
|
status: str = "queued"
|
||||||
|
track_id: str | None = None
|
||||||
|
payload: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
|
||||||
|
result: dict[str, Any] | None = Field(default=None, sa_column=Column(JSON))
|
||||||
|
error: str | None = None
|
||||||
|
created_at: str = Field(default_factory=utcnow)
|
||||||
|
updated_at: str = Field(default_factory=utcnow)
|
||||||
173
src/music_orchestrator/providers.py
Normal file
173
src/music_orchestrator/providers.py
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from re import sub
|
||||||
|
|
||||||
|
from .config import Settings
|
||||||
|
from .schemas import Policy, ProviderCapabilities, ProviderRead, ProviderResult, RiskLevel
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_key(title: str, artist: str | None) -> str:
|
||||||
|
raw = f"{artist or ''}::{title}".lower()
|
||||||
|
return sub(r"[^a-z0-9а-яё]+", " ", raw).strip()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TrackSeed:
|
||||||
|
provider_track_id: str
|
||||||
|
title: str
|
||||||
|
artist: str | None = None
|
||||||
|
album: str | None = None
|
||||||
|
duration_seconds: int | None = None
|
||||||
|
source_url: str | None = None
|
||||||
|
artwork_url: str | None = None
|
||||||
|
attribution: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderAdapter(ABC):
|
||||||
|
provider: ProviderRead
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def search(self, query: str, limit: int) -> list[ProviderResult]: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get(self, provider_track_id: str) -> ProviderResult | None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class LocalProvider(ProviderAdapter):
|
||||||
|
def __init__(self, settings: Settings):
|
||||||
|
self.settings = settings
|
||||||
|
self.provider = ProviderRead(
|
||||||
|
id="local",
|
||||||
|
name="Local / Navidrome-compatible library",
|
||||||
|
kind="local",
|
||||||
|
enabled=True,
|
||||||
|
configured=bool(settings.navidrome_base_url) or True,
|
||||||
|
risk_level=RiskLevel.COMPLIANT,
|
||||||
|
capabilities=ProviderCapabilities(
|
||||||
|
raw_audio_stream=True,
|
||||||
|
persistent_cache=True,
|
||||||
|
offline_playback=True,
|
||||||
|
public_deployment_safe=True,
|
||||||
|
),
|
||||||
|
policy=Policy(
|
||||||
|
cache_allowed=True,
|
||||||
|
download_allowed=True,
|
||||||
|
notes=["Local files only. Navidrome/Subsonic integration is an adapter boundary for future real credentials."],
|
||||||
|
),
|
||||||
|
docs_url="https://www.navidrome.org/docs/developers/subsonic-api/",
|
||||||
|
)
|
||||||
|
self._seeds = [
|
||||||
|
TrackSeed("seed-1", "Demo Song", "Local Artist", "Home Library", 180, "/media/demo-song.opus"),
|
||||||
|
TrackSeed("seed-1-alt", "Demo Song", "Local Artist", "Home Library", 181, "/media/demo-song-alt.opus"),
|
||||||
|
TrackSeed("seed-2", "Another Local Track", "Local Artist", "Home Library", 210, "/media/another-track.opus"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def _to_result(self, seed: TrackSeed) -> ProviderResult:
|
||||||
|
return ProviderResult(
|
||||||
|
provider_id="local",
|
||||||
|
provider_track_id=seed.provider_track_id,
|
||||||
|
title=seed.title,
|
||||||
|
artist=seed.artist,
|
||||||
|
album=seed.album,
|
||||||
|
duration_seconds=seed.duration_seconds,
|
||||||
|
artwork_url=seed.artwork_url,
|
||||||
|
source_url=seed.source_url,
|
||||||
|
attribution="Local library",
|
||||||
|
risk_level=self.provider.risk_level,
|
||||||
|
capabilities=self.provider.capabilities,
|
||||||
|
policy=self.provider.policy,
|
||||||
|
)
|
||||||
|
|
||||||
|
def search(self, query: str, limit: int) -> list[ProviderResult]:
|
||||||
|
q = query.lower().strip()
|
||||||
|
matches = [s for s in self._seeds if q in f"{s.title} {s.artist or ''} {s.album or ''}".lower()]
|
||||||
|
return [self._to_result(seed) for seed in matches[:limit]]
|
||||||
|
|
||||||
|
def get(self, provider_track_id: str) -> ProviderResult | None:
|
||||||
|
return next((self._to_result(seed) for seed in self._seeds if seed.provider_track_id == provider_track_id), None)
|
||||||
|
|
||||||
|
|
||||||
|
class YouTubeOfficialProvider(ProviderAdapter):
|
||||||
|
def __init__(self, settings: Settings):
|
||||||
|
configured = bool(settings.youtube_api_key)
|
||||||
|
self.provider = ProviderRead(
|
||||||
|
id="youtube_official",
|
||||||
|
name="YouTube official metadata/embed",
|
||||||
|
kind="youtube",
|
||||||
|
enabled=configured,
|
||||||
|
configured=configured,
|
||||||
|
risky_enabled=False,
|
||||||
|
risk_level=RiskLevel.COMPLIANT,
|
||||||
|
capabilities=ProviderCapabilities(
|
||||||
|
raw_audio_stream=False,
|
||||||
|
embed_playback=True,
|
||||||
|
persistent_cache=False,
|
||||||
|
offline_playback=False,
|
||||||
|
public_deployment_safe=True,
|
||||||
|
),
|
||||||
|
policy=Policy(
|
||||||
|
cache_allowed=False,
|
||||||
|
download_allowed=False,
|
||||||
|
requires_external_credentials=True,
|
||||||
|
notes=["Metadata/search requires YouTube Data API key. Playback is embed-only. No raw audio, yt-dlp, cache or bypass implementation."],
|
||||||
|
),
|
||||||
|
docs_url="https://developers.google.com/youtube/v3",
|
||||||
|
)
|
||||||
|
|
||||||
|
def search(self, query: str, limit: int) -> list[ProviderResult]:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get(self, provider_track_id: str) -> ProviderResult | None:
|
||||||
|
return ProviderResult(
|
||||||
|
provider_id="youtube_official",
|
||||||
|
provider_track_id=provider_track_id,
|
||||||
|
title="YouTube embedded item",
|
||||||
|
artist=None,
|
||||||
|
source_url=f"https://www.youtube.com/watch?v={provider_track_id}",
|
||||||
|
attribution="YouTube embed playback; metadata not resolved without API key in this MVP.",
|
||||||
|
risk_level=self.provider.risk_level,
|
||||||
|
capabilities=self.provider.capabilities,
|
||||||
|
policy=self.provider.policy,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SoundCloudOfficialProvider(ProviderAdapter):
|
||||||
|
def __init__(self, settings: Settings):
|
||||||
|
configured = bool(settings.soundcloud_client_id)
|
||||||
|
self.provider = ProviderRead(
|
||||||
|
id="soundcloud_official",
|
||||||
|
name="SoundCloud official constrained",
|
||||||
|
kind="soundcloud",
|
||||||
|
enabled=configured,
|
||||||
|
configured=configured,
|
||||||
|
risky_enabled=False,
|
||||||
|
risk_level=RiskLevel.CONSTRAINED,
|
||||||
|
capabilities=ProviderCapabilities(
|
||||||
|
raw_audio_stream=False,
|
||||||
|
official_stream_url=True,
|
||||||
|
persistent_cache=False,
|
||||||
|
offline_playback=False,
|
||||||
|
public_deployment_safe=False,
|
||||||
|
),
|
||||||
|
policy=Policy(
|
||||||
|
cache_allowed=False,
|
||||||
|
download_allowed=False,
|
||||||
|
requires_attribution=True,
|
||||||
|
requires_external_credentials=True,
|
||||||
|
notes=["Official API only; no persistent cache/offline. Custom player must preserve attribution and SoundCloud terms."],
|
||||||
|
),
|
||||||
|
docs_url="https://developers.soundcloud.com/docs/api/guide",
|
||||||
|
)
|
||||||
|
|
||||||
|
def search(self, query: str, limit: int) -> list[ProviderResult]:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get(self, provider_track_id: str) -> ProviderResult | None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def build_adapters(settings: Settings) -> dict[str, ProviderAdapter]:
|
||||||
|
adapters = [LocalProvider(settings), YouTubeOfficialProvider(settings), SoundCloudOfficialProvider(settings)]
|
||||||
|
return {adapter.provider.id: adapter for adapter in adapters}
|
||||||
209
src/music_orchestrator/schemas.py
Normal file
209
src/music_orchestrator/schemas.py
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import StrEnum
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class RiskLevel(StrEnum):
|
||||||
|
COMPLIANT = "compliant"
|
||||||
|
CONSTRAINED = "constrained"
|
||||||
|
RISKY = "risky"
|
||||||
|
|
||||||
|
|
||||||
|
class PlaybackType(StrEnum):
|
||||||
|
LOCAL_STREAM = "local_stream"
|
||||||
|
EMBED = "embed"
|
||||||
|
OFFICIAL_STREAM = "official_stream"
|
||||||
|
UNAVAILABLE = "unavailable"
|
||||||
|
|
||||||
|
|
||||||
|
class JobStatus(StrEnum):
|
||||||
|
QUEUED = "queued"
|
||||||
|
RUNNING = "running"
|
||||||
|
SUCCEEDED = "succeeded"
|
||||||
|
FAILED = "failed"
|
||||||
|
BLOCKED_BY_POLICY = "blocked_by_policy"
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderCapabilities(BaseModel):
|
||||||
|
search_metadata: bool = True
|
||||||
|
raw_audio_stream: bool = False
|
||||||
|
embed_playback: bool = False
|
||||||
|
official_stream_url: bool = False
|
||||||
|
persistent_cache: bool = False
|
||||||
|
offline_playback: bool = False
|
||||||
|
server_favorites: bool = True
|
||||||
|
server_playlists: bool = True
|
||||||
|
multiuser_safe: bool = True
|
||||||
|
public_deployment_safe: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class Policy(BaseModel):
|
||||||
|
compliant_mode: bool = True
|
||||||
|
cache_allowed: bool = False
|
||||||
|
download_allowed: bool = False
|
||||||
|
requires_attribution: bool = False
|
||||||
|
requires_external_credentials: bool = False
|
||||||
|
notes: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderRead(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
kind: Literal["local", "youtube", "soundcloud"]
|
||||||
|
enabled: bool
|
||||||
|
configured: bool
|
||||||
|
risky_enabled: bool = False
|
||||||
|
risk_level: RiskLevel
|
||||||
|
capabilities: ProviderCapabilities
|
||||||
|
policy: Policy
|
||||||
|
docs_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderList(BaseModel):
|
||||||
|
items: list[ProviderRead]
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderResult(BaseModel):
|
||||||
|
provider_id: str
|
||||||
|
provider_track_id: str
|
||||||
|
title: str
|
||||||
|
artist: str | None = None
|
||||||
|
album: str | None = None
|
||||||
|
duration_seconds: int | None = None
|
||||||
|
artwork_url: str | None = None
|
||||||
|
source_url: str | None = None
|
||||||
|
attribution: str | None = None
|
||||||
|
risk_level: RiskLevel
|
||||||
|
capabilities: ProviderCapabilities
|
||||||
|
policy: Policy
|
||||||
|
|
||||||
|
|
||||||
|
class TrackRead(BaseModel):
|
||||||
|
id: str
|
||||||
|
title: str
|
||||||
|
artist: str | None = None
|
||||||
|
album: str | None = None
|
||||||
|
duration_seconds: int | None = None
|
||||||
|
artwork_url: str | None = None
|
||||||
|
canonical_key: str
|
||||||
|
providers: list[str]
|
||||||
|
provider_results: list[ProviderResult]
|
||||||
|
risk_level: RiskLevel
|
||||||
|
capabilities: ProviderCapabilities
|
||||||
|
policy: Policy
|
||||||
|
|
||||||
|
|
||||||
|
class SearchResponse(BaseModel):
|
||||||
|
query: str
|
||||||
|
limit: int
|
||||||
|
offset: int
|
||||||
|
total: int
|
||||||
|
items: list[TrackRead]
|
||||||
|
|
||||||
|
|
||||||
|
class PlaybackRead(BaseModel):
|
||||||
|
track_id: str
|
||||||
|
playback_type: PlaybackType
|
||||||
|
provider_id: str
|
||||||
|
stream_url: str | None = None
|
||||||
|
embed_url: str | None = None
|
||||||
|
expires_in_seconds: int | None = None
|
||||||
|
attribution: str | None = None
|
||||||
|
capabilities: ProviderCapabilities
|
||||||
|
policy: Policy
|
||||||
|
|
||||||
|
|
||||||
|
class FavoriteCreate(BaseModel):
|
||||||
|
track_id: str = Field(min_length=3, max_length=300)
|
||||||
|
|
||||||
|
|
||||||
|
class FavoriteRead(BaseModel):
|
||||||
|
track_id: str
|
||||||
|
created_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class FavoriteList(BaseModel):
|
||||||
|
limit: int
|
||||||
|
offset: int
|
||||||
|
total: int
|
||||||
|
items: list[FavoriteRead]
|
||||||
|
|
||||||
|
|
||||||
|
class PlaylistCreate(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=120)
|
||||||
|
description: str | None = Field(default=None, max_length=500)
|
||||||
|
|
||||||
|
|
||||||
|
class PlaylistTrackCreate(BaseModel):
|
||||||
|
track_id: str = Field(min_length=3, max_length=300)
|
||||||
|
|
||||||
|
|
||||||
|
class PlaylistSummary(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
description: str | None = None
|
||||||
|
track_count: int
|
||||||
|
created_at: str
|
||||||
|
updated_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class PlaylistTrackRead(BaseModel):
|
||||||
|
track_id: str
|
||||||
|
position: int
|
||||||
|
added_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class PlaylistRead(PlaylistSummary):
|
||||||
|
tracks: list[PlaylistTrackRead] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class PlaylistList(BaseModel):
|
||||||
|
limit: int
|
||||||
|
offset: int
|
||||||
|
total: int
|
||||||
|
items: list[PlaylistSummary]
|
||||||
|
|
||||||
|
|
||||||
|
class JobCreate(BaseModel):
|
||||||
|
type: Literal["resolve", "metadata_refresh", "local_ingest"]
|
||||||
|
track_id: str | None = Field(default=None, max_length=300)
|
||||||
|
payload: dict = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class JobRead(BaseModel):
|
||||||
|
id: str
|
||||||
|
type: str
|
||||||
|
status: JobStatus
|
||||||
|
track_id: str | None = None
|
||||||
|
payload: dict
|
||||||
|
result: dict | None = None
|
||||||
|
error: str | None = None
|
||||||
|
created_at: str
|
||||||
|
updated_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class JobList(BaseModel):
|
||||||
|
limit: int
|
||||||
|
offset: int
|
||||||
|
total: int
|
||||||
|
items: list[JobRead]
|
||||||
|
|
||||||
|
|
||||||
|
class HealthRead(BaseModel):
|
||||||
|
status: Literal["ok"]
|
||||||
|
mode: Literal["compliant"]
|
||||||
|
risky_extractors_enabled: bool
|
||||||
|
database: str
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorBody(BaseModel):
|
||||||
|
code: str
|
||||||
|
message: str
|
||||||
|
details: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorResponse(BaseModel):
|
||||||
|
error: ErrorBody
|
||||||
86
src/music_orchestrator/services.py
Normal file
86
src/music_orchestrator/services.py
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .providers import ProviderAdapter, canonical_key
|
||||||
|
from .schemas import Policy, ProviderCapabilities, ProviderResult, RiskLevel, TrackRead
|
||||||
|
|
||||||
|
|
||||||
|
def merge_capabilities(results: list[ProviderResult]) -> ProviderCapabilities:
|
||||||
|
return ProviderCapabilities(
|
||||||
|
search_metadata=any(r.capabilities.search_metadata for r in results),
|
||||||
|
raw_audio_stream=any(r.capabilities.raw_audio_stream for r in results),
|
||||||
|
embed_playback=any(r.capabilities.embed_playback for r in results),
|
||||||
|
official_stream_url=any(r.capabilities.official_stream_url for r in results),
|
||||||
|
persistent_cache=any(r.capabilities.persistent_cache for r in results),
|
||||||
|
offline_playback=any(r.capabilities.offline_playback for r in results),
|
||||||
|
server_favorites=all(r.capabilities.server_favorites for r in results),
|
||||||
|
server_playlists=all(r.capabilities.server_playlists for r in results),
|
||||||
|
multiuser_safe=all(r.capabilities.multiuser_safe for r in results),
|
||||||
|
public_deployment_safe=all(r.capabilities.public_deployment_safe for r in results),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def merge_policy(results: list[ProviderResult]) -> Policy:
|
||||||
|
notes: list[str] = []
|
||||||
|
for result in results:
|
||||||
|
notes.extend(result.policy.notes)
|
||||||
|
return Policy(
|
||||||
|
compliant_mode=all(r.policy.compliant_mode for r in results),
|
||||||
|
cache_allowed=any(r.policy.cache_allowed for r in results),
|
||||||
|
download_allowed=any(r.policy.download_allowed for r in results),
|
||||||
|
requires_attribution=any(r.policy.requires_attribution for r in results),
|
||||||
|
requires_external_credentials=any(r.policy.requires_external_credentials for r in results),
|
||||||
|
notes=sorted(set(notes)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def merged_risk(results: list[ProviderResult]) -> RiskLevel:
|
||||||
|
if any(r.risk_level == RiskLevel.RISKY for r in results):
|
||||||
|
return RiskLevel.RISKY
|
||||||
|
if any(r.risk_level == RiskLevel.CONSTRAINED for r in results):
|
||||||
|
return RiskLevel.CONSTRAINED
|
||||||
|
return RiskLevel.COMPLIANT
|
||||||
|
|
||||||
|
|
||||||
|
def to_track(results: list[ProviderResult]) -> TrackRead:
|
||||||
|
primary = sorted(results, key=lambda r: (not r.capabilities.raw_audio_stream, r.provider_id))[0]
|
||||||
|
provider_result_id = f"{primary.provider_id}:{primary.provider_track_id}"
|
||||||
|
return TrackRead(
|
||||||
|
id=provider_result_id,
|
||||||
|
title=primary.title,
|
||||||
|
artist=primary.artist,
|
||||||
|
album=primary.album,
|
||||||
|
duration_seconds=primary.duration_seconds,
|
||||||
|
artwork_url=primary.artwork_url,
|
||||||
|
canonical_key=canonical_key(primary.title, primary.artist),
|
||||||
|
providers=sorted({r.provider_id for r in results}),
|
||||||
|
provider_results=results,
|
||||||
|
risk_level=merged_risk(results),
|
||||||
|
capabilities=merge_capabilities(results),
|
||||||
|
policy=merge_policy(results),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def search_merged(adapters: dict[str, ProviderAdapter], provider_ids: list[str], query: str, limit: int) -> list[TrackRead]:
|
||||||
|
groups: dict[str, list[ProviderResult]] = {}
|
||||||
|
for provider_id in provider_ids:
|
||||||
|
adapter = adapters.get(provider_id)
|
||||||
|
if adapter is None:
|
||||||
|
continue
|
||||||
|
for result in adapter.search(query, limit):
|
||||||
|
groups.setdefault(canonical_key(result.title, result.artist), []).append(result)
|
||||||
|
tracks = [to_track(results) for results in groups.values()]
|
||||||
|
tracks.sort(key=lambda t: (not t.capabilities.raw_audio_stream, t.title.lower()))
|
||||||
|
return tracks[:limit]
|
||||||
|
|
||||||
|
|
||||||
|
def get_track(adapters: dict[str, ProviderAdapter], track_id: str) -> TrackRead | None:
|
||||||
|
provider_id, sep, provider_track_id = track_id.partition(":")
|
||||||
|
if not sep:
|
||||||
|
return None
|
||||||
|
adapter = adapters.get(provider_id)
|
||||||
|
if adapter is None:
|
||||||
|
return None
|
||||||
|
result = adapter.get(provider_track_id)
|
||||||
|
if result is None:
|
||||||
|
return None
|
||||||
|
return to_track([result])
|
||||||
21
tests/conftest.py
Normal file
21
tests/conftest.py
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from music_orchestrator.main import create_app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def client(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("APP_DATABASE_URL", f"sqlite:///{tmp_path / 'test.db'}")
|
||||||
|
monkeypatch.setenv("APP_API_KEYS", "test-key")
|
||||||
|
monkeypatch.setenv("APP_ENABLE_RISKY_EXTRACTORS", "false")
|
||||||
|
monkeypatch.setenv("APP_YOUTUBE_API_KEY", "")
|
||||||
|
monkeypatch.setenv("APP_SOUNDCLOUD_CLIENT_ID", "")
|
||||||
|
app = create_app()
|
||||||
|
with TestClient(app) as c:
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def auth_headers():
|
||||||
|
return {"X-API-Key": "test-key"}
|
||||||
29
tests/test_auth_validation_policy.py
Normal file
29
tests/test_auth_validation_policy.py
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
def test_write_endpoints_require_api_key(client):
|
||||||
|
response = client.post("/v1/favorites", json={"track_id": "local:1"})
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert response.json()["error"]["code"] == "unauthorized"
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_validates_query_length(client):
|
||||||
|
response = client.get("/v1/search", params={"q": ""})
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_risky_extractors_disabled_by_default(client):
|
||||||
|
response = client.get("/v1/providers")
|
||||||
|
assert response.status_code == 200
|
||||||
|
providers = {item["id"]: item for item in response.json()["items"]}
|
||||||
|
assert providers["youtube_official"]["risk_level"] == "compliant"
|
||||||
|
assert providers["youtube_official"]["capabilities"]["raw_audio_stream"] is False
|
||||||
|
assert providers["soundcloud_official"]["capabilities"]["persistent_cache"] is False
|
||||||
|
assert all(not provider["risky_enabled"] for provider in providers.values())
|
||||||
|
|
||||||
|
|
||||||
|
def test_playback_policy_never_fakes_external_stream(client):
|
||||||
|
response = client.get("/v1/playback/youtube_official:demo-video")
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
assert body["playback_type"] == "embed"
|
||||||
|
assert body["stream_url"] is None
|
||||||
|
assert body["policy"]["cache_allowed"] is False
|
||||||
|
assert "youtube.com/embed/demo-video" in body["embed_url"]
|
||||||
29
tests/test_favorites_playlists.py
Normal file
29
tests/test_favorites_playlists.py
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
def test_favorites_crud(client, auth_headers):
|
||||||
|
created = client.post("/v1/favorites", headers=auth_headers, json={"track_id": "local:seed-1"})
|
||||||
|
assert created.status_code == 201
|
||||||
|
assert created.json()["track_id"] == "local:seed-1"
|
||||||
|
|
||||||
|
listing = client.get("/v1/favorites", headers=auth_headers)
|
||||||
|
assert listing.status_code == 200
|
||||||
|
assert listing.json()["total"] == 1
|
||||||
|
|
||||||
|
deleted = client.delete("/v1/favorites/local:seed-1", headers=auth_headers)
|
||||||
|
assert deleted.status_code == 204
|
||||||
|
assert client.get("/v1/favorites", headers=auth_headers).json()["total"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_playlists_crud_and_track_membership(client, auth_headers):
|
||||||
|
created = client.post("/v1/playlists", headers=auth_headers, json={"name": "Road", "description": "demo"})
|
||||||
|
assert created.status_code == 201
|
||||||
|
playlist_id = created.json()["id"]
|
||||||
|
|
||||||
|
add = client.post(f"/v1/playlists/{playlist_id}/tracks", headers=auth_headers, json={"track_id": "local:seed-1"})
|
||||||
|
assert add.status_code == 201
|
||||||
|
|
||||||
|
listing = client.get("/v1/playlists", headers=auth_headers)
|
||||||
|
assert listing.status_code == 200
|
||||||
|
assert listing.json()["items"][0]["track_count"] == 1
|
||||||
|
|
||||||
|
detail = client.get(f"/v1/playlists/{playlist_id}", headers=auth_headers)
|
||||||
|
assert detail.status_code == 200
|
||||||
|
assert detail.json()["tracks"][0]["track_id"] == "local:seed-1"
|
||||||
45
tests/test_search_jobs_openapi.py
Normal file
45
tests/test_search_jobs_openapi.py
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
def test_search_merges_and_dedupes_provider_results(client):
|
||||||
|
response = client.get("/v1/search", params={"q": "demo song", "providers": "local,youtube_official,soundcloud_official"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
assert body["query"] == "demo song"
|
||||||
|
assert body["total"] >= 1
|
||||||
|
first = body["items"][0]
|
||||||
|
assert first["provider_results"]
|
||||||
|
assert first["capabilities"]["server_favorites"] is True
|
||||||
|
merged = [item for item in body["items"] if item["title"].lower() == "demo song"]
|
||||||
|
assert len(merged) == 1
|
||||||
|
assert len(merged[0]["provider_results"]) == 2
|
||||||
|
assert {r["provider_id"] for r in merged[0]["provider_results"]} == {"local"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_tracks_endpoint_returns_capability_and_policy(client):
|
||||||
|
response = client.get("/v1/tracks/local:seed-1")
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
assert body["id"] == "local:seed-1"
|
||||||
|
assert body["capabilities"]["raw_audio_stream"] is True
|
||||||
|
assert body["policy"]["cache_allowed"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_jobs_abstraction(client, auth_headers):
|
||||||
|
created = client.post("/v1/jobs", headers=auth_headers, json={"type": "resolve", "track_id": "youtube_official:demo-video"})
|
||||||
|
assert created.status_code == 202
|
||||||
|
job_id = created.json()["id"]
|
||||||
|
assert created.json()["status"] == "queued"
|
||||||
|
|
||||||
|
listing = client.get("/v1/jobs", headers=auth_headers)
|
||||||
|
assert listing.status_code == 200
|
||||||
|
assert listing.json()["total"] == 1
|
||||||
|
|
||||||
|
detail = client.get(f"/v1/jobs/{job_id}", headers=auth_headers)
|
||||||
|
assert detail.status_code == 200
|
||||||
|
assert detail.json()["type"] == "resolve"
|
||||||
|
|
||||||
|
|
||||||
|
def test_openapi_contains_expected_routes(client):
|
||||||
|
response = client.get("/openapi.json")
|
||||||
|
assert response.status_code == 200
|
||||||
|
paths = response.json()["paths"]
|
||||||
|
for path in ["/health", "/v1/search", "/v1/tracks/{track_id}", "/v1/playback/{track_id}", "/v1/favorites", "/v1/playlists", "/v1/jobs", "/v1/providers"]:
|
||||||
|
assert path in paths
|
||||||
Loading…
Add table
Reference in a new issue