Add design spec for MusicFetch REST API

Async job-based HTTP wrapper around the musicfetch binary, dockerized for the
Lidarr stack, X-API-Key auth, Siri-friendly human messages, port via
MUSICFETCH_PORT (default 6769).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 19:42:17 -07:00
parent 9fd4c8585b
commit 033bc00ccc

View File

@@ -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 '<q>'."}`.
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.