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:
164
docs/superpowers/specs/2026-06-08-musicfetch-rest-api-design.md
Normal file
164
docs/superpowers/specs/2026-06-08-musicfetch-rest-api-design.md
Normal 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.
|
||||
Reference in New Issue
Block a user