diff --git a/docs/superpowers/specs/2026-06-08-musicfetch-rest-api-design.md b/docs/superpowers/specs/2026-06-08-musicfetch-rest-api-design.md new file mode 100644 index 0000000..415afd2 --- /dev/null +++ b/docs/superpowers/specs/2026-06-08-musicfetch-rest-api-design.md @@ -0,0 +1,164 @@ +# MusicFetch REST API — Design + +**Date:** 2026-06-08 +**Status:** Approved + +## Context & Goal + +`musicfetch` is a single-file CLI that searches Lidarr + YouTube Music and grabs +music. We want to trigger it remotely (notably from **iOS Siri Shortcuts**) via a +small authenticated HTTP API, dockerized to run inside the existing Plex/Lidarr +Docker stack on the Plex server. + +Vision: a client POSTs a query with an API key; the server runs musicfetch +non-interactively, grabs the top hit, and returns a human-readable response +(speakable by Siri). Downloads run as async background jobs the client can poll — +no fire-and-forget. + +## Constraints & Decisions + +- **musicfetch stays a standalone single-file binary** — no changes to it. The API + imports it as a module (it guards `if __name__ == "__main__"`, so importing is + side-effect free) and reuses its `Hit` model + `build_combined_hits`, `pick`, + `act_youtube`, `act_lidarr_album`, `act_lidarr_artist`. +- **Async jobs** (not synchronous, not fire-and-forget). POST returns immediately + with a `job_id`; client polls `GET /jobs/{id}`. +- **Runs in the existing Lidarr stack:** joins the stack network → reaches + `http://lidarr:8686`; bind-mounts the host `/media/music`. Self-contained image + (musicfetch ships inside it); nothing external required at runtime besides the + volume + Lidarr network. +- **Auth:** shared secret via `X-API-Key` header, compared to `MUSICFETCH_API_KEY`. +- **TLS:** terminated by an upstream reverse proxy already on the user's network. + The container speaks plain HTTP. +- **Port:** configurable via `MUSICFETCH_PORT`, default **6769**. +- **Siri-friendly:** every JSON response carries a top-level human `message` string. +- Personal-scale: in-memory job store (lost on restart, acceptable). No DB/Redis. + +## Architecture + +``` +musicfetch/ +├── musicfetch # unchanged standalone binary +├── README.md # + API usage + Siri Shortcuts walkthrough +├── docs/superpowers/specs/2026-06-08-musicfetch-rest-api-design.md +└── server/ # the REST API + ├── app.py # FastAPI app: routes, auth dependency + ├── jobs.py # Job model, in-memory store, ThreadPoolExecutor worker + ├── mf.py # importlib loader: loads ../musicfetch as a module + ├── requirements.txt # fastapi, uvicorn, requests, ytmusicapi, rich + ├── Dockerfile + └── docker-compose.yml +``` + +### Components + +- **`server/mf.py`** — loads the sibling `musicfetch` file via + `importlib.util.spec_from_file_location` (no `.py` extension). Re-exports the + reused symbols. Single seam between API and CLI. Sets nothing global beyond what + env already provides (`LIDARR_URL`, `LIDARR_API_KEY`, `MUSICFETCH_ROOT`). +- **`server/jobs.py`** — `Job` dataclass (`id, status, hit, result, error, + created_at, updated_at`); `JOBS: dict[str, Job]`; a module-level + `ThreadPoolExecutor(max_workers=2)`. `submit_fetch(hit, quality, root)` enqueues + a task that runs the blocking act_* call and updates the job. Optional cap on + dict size to avoid unbounded growth. +- **`server/app.py`** — FastAPI app. API-key dependency. Routes below. + +### Data flow + +1. `POST /fetch` (authed) → build hits via `build_combined_hits(q, limit=10, + yt_first=(source=="youtube"), lidarr_only=(source=="lidarr"), + yt_only=(source=="youtube"))`; choose top via `pick(hits, q, + noninteractive=True, yt_first=...)`. +2. No hits → `404 {"message": "No results found for ''."}`. +3. Create Job (`queued`), submit worker task, return job + hit + message. +4. Worker sets `running`, calls the right `act_*`, captures result/exception, sets + `done` / `failed`, updates `message`. +5. Client polls `GET /jobs/{id}`. + +## API Contract + +All non-health routes require header `X-API-Key`. Bad/missing → +`401 {"message": "Invalid API key."}`. + +### `POST /fetch` +Params (JSON body or query string): +- `q` (required) — free-form query. +- `quality` (optional) — one of musicfetch's `QUALITY_CHOICES` + (`best,320,m4a,opus,flac`); default `best`. +- `source` (optional) — `auto` (default, Lidarr-first), `lidarr`, `youtube`. + +Success `200`: +```json +{ + "message": "Found 'Under My Skin' by Avril Lavigne on YouTube Music. Downloading now.", + "job_id": "a1b2c3", + "status": "queued", + "hit": { "source": "youtube", "artist": "Avril Lavigne", + "album": "Under My Skin", "title": "Together", "year": "2004" } +} +``` +Errors: `404` no hits; `422` missing `q` (FastAPI default, also wrapped to include +`message`); `401` bad key. + +### `GET /jobs/{id}` +`200`: +```json +{ + "message": "Finished downloading 'Under My Skin' by Avril Lavigne.", + "job_id": "a1b2c3", + "status": "done", + "hit": { "...": "..." }, + "result": { "path": "/media/music/Avril Lavigne/youtube", "lidarr_album_id": null }, + "error": null +} +``` +`status`: `queued` → `running` → `done` | `failed`. Unknown id → `404 +{"message": "No such job."}`. On `failed`, `message` is a speakable explanation +and `error` carries detail. + +### `GET /health` +No auth. `{"status": "ok"}`. + +## Error Handling + +- 401 invalid/missing key; 404 no hits / unknown job; 422 missing `q`; 500 + unexpected — all bodies include a `message` string. +- Download/Lidarr failures surface in the **job** (`status: failed`), never crash + the HTTP request that started them. +- musicfetch's existing Lidarr→YouTube fallthrough is preserved (worker uses the + same `act_lidarr_album` path). + +## Docker + +- **Dockerfile:** `python:3.12-slim`; `apt-get install -y ffmpeg`; `pip install + yt-dlp -r server/requirements.txt`; `COPY musicfetch ./musicfetch` + `COPY + server ./server`; `CMD ["sh","-c","uvicorn server.app:app --host 0.0.0.0 --port + ${MUSICFETCH_PORT:-6769}"]`. Build context = repo root. +- **docker-compose.yml:** service `musicfetch-api`; attach to the existing Lidarr + stack network (declared `external: true`); `volumes: /media/music:/media/music`; + `ports: "6769:6769"`; env `LIDARR_URL=http://lidarr:8686`, `LIDARR_API_KEY`, + `MUSICFETCH_API_KEY`, `MUSICFETCH_ROOT=/media/music`, `MUSICFETCH_PORT=6769`. + +## Testing + +- Unit: API-key dependency (401 paths); `/fetch` with musicfetch core **mocked** + to return a canned `Hit` — assert job created, response shape, `message` + present; job lifecycle `queued→running→done` and `→failed` on worker exception; + `/jobs/{id}` unknown-id 404; `/health`. +- No real network or downloads in tests (mock `build_combined_hits`/`act_*`). +- Manual smoke after deploy: `curl -H 'X-API-Key: ...' -X POST + 'http://host:6769/fetch?q=Under My Skin'` → poll `/jobs/{id}`. + +## README additions + +- API section: env vars, run via docker-compose, all endpoints with `curl` + examples. +- **Siri Shortcuts walkthrough** (research current "Get Contents of URL" UI): + build a shortcut that takes dictated/typed text, POSTs to `/fetch` with the + `X-API-Key` header and `q`, reads back the `message` via "Speak Text", and + (optional) waits then GETs `/jobs/{id}` to confirm completion. + +## Out of Scope (YAGNI) + +Persistent job store, multi-user keys, rate limiting, in-container TLS, a web UI, +download progress streaming.