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>
165 lines
7.0 KiB
Markdown
165 lines
7.0 KiB
Markdown
# 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.
|