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>
7.0 KiB
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 itsHitmodel +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 pollsGET /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-Keyheader, compared toMUSICFETCH_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
messagestring. - 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 siblingmusicfetchfile viaimportlib.util.spec_from_file_location(no.pyextension). 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—Jobdataclass (id, status, hit, result, error, created_at, updated_at);JOBS: dict[str, Job]; a module-levelThreadPoolExecutor(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
POST /fetch(authed) → build hits viabuild_combined_hits(q, limit=10, yt_first=(source=="youtube"), lidarr_only=(source=="lidarr"), yt_only=(source=="youtube")); choose top viapick(hits, q, noninteractive=True, yt_first=...).- No hits →
404 {"message": "No results found for '<q>'."}. - Create Job (
queued), submit worker task, return job + hit + message. - Worker sets
running, calls the rightact_*, captures result/exception, setsdone/failed, updatesmessage. - 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'sQUALITY_CHOICES(best,320,m4a,opus,flac); defaultbest.source(optional) —auto(default, Lidarr-first),lidarr,youtube.
Success 200:
{
"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:
{
"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 amessagestring. - 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_albumpath).
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 (declaredexternal: true);volumes: /media/music:/media/music;ports: "6769:6769"; envLIDARR_URL=http://lidarr:8686,LIDARR_API_KEY,MUSICFETCH_API_KEY,MUSICFETCH_ROOT=/media/music,MUSICFETCH_PORT=6769.
Testing
- Unit: API-key dependency (401 paths);
/fetchwith musicfetch core mocked to return a cannedHit— assert job created, response shape,messagepresent; job lifecyclequeued→running→doneand→failedon 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
curlexamples. - Siri Shortcuts walkthrough (research current "Get Contents of URL" UI):
build a shortcut that takes dictated/typed text, POSTs to
/fetchwith theX-API-Keyheader andq, reads back themessagevia "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.