Files
musicfetch/docs/superpowers/specs/2026-06-08-musicfetch-rest-api-design.md
zebra 033bc00ccc 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>
2026-06-08 19:42:17 -07:00

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 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.pyJob 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:

{
  "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: queuedrunningdone | 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.