feat: initial backend-first music orchestrator MVP
Some checks failed
CI / backend (push) Failing after 1m53s

This commit is contained in:
Kisskin-Mister 2026-07-10 10:02:23 +03:00
commit 8d8076bd3f
25 changed files with 1568 additions and 0 deletions

21
.env.example Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"

View file

@ -0,0 +1 @@
"""Music Orchestrator backend package."""

View 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))

View 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()

View 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

View 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)

View 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)

View 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}

View 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

View 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
View 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"}

View 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"]

View 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"

View 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