commit 8d8076bd3fb0877135f9a8a0360694b4a7a0d7c2 Author: Kisskin-Mister <143715327+Kisskin-Mister@users.noreply.github.com> Date: Fri Jul 10 10:02:23 2026 +0300 feat: initial backend-first music orchestrator MVP diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7ba1a89 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..92847d2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fc1e346 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..d7d5d9f --- /dev/null +++ b/CONTRIBUTING.md @@ -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`. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c7c6309 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..460f388 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8245cfb --- /dev/null +++ b/README.md @@ -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. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..0090c1c --- /dev/null +++ b/SECURITY.md @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f4ae8b3 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/docs/API.en.md b/docs/API.en.md new file mode 100644 index 0000000..0a2a606 --- /dev/null +++ b/docs/API.en.md @@ -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. diff --git a/docs/API.ru.md b/docs/API.ru.md new file mode 100644 index 0000000..7d0d152 --- /dev/null +++ b/docs/API.ru.md @@ -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. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f38b0dc --- /dev/null +++ b/pyproject.toml @@ -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" diff --git a/src/music_orchestrator/__init__.py b/src/music_orchestrator/__init__.py new file mode 100644 index 0000000..a9437ed --- /dev/null +++ b/src/music_orchestrator/__init__.py @@ -0,0 +1 @@ +"""Music Orchestrator backend package.""" diff --git a/src/music_orchestrator/auth.py b/src/music_orchestrator/auth.py new file mode 100644 index 0000000..12ac49e --- /dev/null +++ b/src/music_orchestrator/auth.py @@ -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)) diff --git a/src/music_orchestrator/config.py b/src/music_orchestrator/config.py new file mode 100644 index 0000000..2736351 --- /dev/null +++ b/src/music_orchestrator/config.py @@ -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() diff --git a/src/music_orchestrator/db.py b/src/music_orchestrator/db.py new file mode 100644 index 0000000..1ddfd67 --- /dev/null +++ b/src/music_orchestrator/db.py @@ -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 diff --git a/src/music_orchestrator/main.py b/src/music_orchestrator/main.py new file mode 100644 index 0000000..215a3ab --- /dev/null +++ b/src/music_orchestrator/main.py @@ -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) diff --git a/src/music_orchestrator/models.py b/src/music_orchestrator/models.py new file mode 100644 index 0000000..63ba926 --- /dev/null +++ b/src/music_orchestrator/models.py @@ -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) diff --git a/src/music_orchestrator/providers.py b/src/music_orchestrator/providers.py new file mode 100644 index 0000000..56ebf33 --- /dev/null +++ b/src/music_orchestrator/providers.py @@ -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} diff --git a/src/music_orchestrator/schemas.py b/src/music_orchestrator/schemas.py new file mode 100644 index 0000000..c706596 --- /dev/null +++ b/src/music_orchestrator/schemas.py @@ -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 diff --git a/src/music_orchestrator/services.py b/src/music_orchestrator/services.py new file mode 100644 index 0000000..7d82384 --- /dev/null +++ b/src/music_orchestrator/services.py @@ -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]) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..37b5531 --- /dev/null +++ b/tests/conftest.py @@ -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"} diff --git a/tests/test_auth_validation_policy.py b/tests/test_auth_validation_policy.py new file mode 100644 index 0000000..9539740 --- /dev/null +++ b/tests/test_auth_validation_policy.py @@ -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"] diff --git a/tests/test_favorites_playlists.py b/tests/test_favorites_playlists.py new file mode 100644 index 0000000..060300e --- /dev/null +++ b/tests/test_favorites_playlists.py @@ -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" diff --git a/tests/test_search_jobs_openapi.py b/tests/test_search_jobs_openapi.py new file mode 100644 index 0000000..f012d72 --- /dev/null +++ b/tests/test_search_jobs_openapi.py @@ -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