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