# 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.