# MusicFetch REST API Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Wrap the existing `musicfetch` binary in an authenticated, dockerized async REST API so a remote client (esp. iOS Siri Shortcuts) can POST a query, grab the top hit non-interactively, and poll a job for completion. **Architecture:** A FastAPI app in `server/` imports the unchanged `musicfetch` script as a module (via importlib) and reuses its `Hit` model + `build_combined_hits`, `pick`, and `act_*` functions. `POST /fetch` selects the top hit, creates an in-memory job, and runs the blocking download on a `ThreadPoolExecutor`; the client polls `GET /jobs/{id}`. Every response carries a human-readable `message` for Siri to speak. **Tech Stack:** Python 3.12, FastAPI, uvicorn, pytest + FastAPI `TestClient`, Docker / docker-compose. Reuses musicfetch's deps (requests, ytmusicapi, rich, yt-dlp, ffmpeg). --- ## File Structure ``` server/ ├── __init__.py # marks package (empty) ├── mf.py # importlib loader: loads ../musicfetch, re-exports symbols ├── actions.py # perform_fetch() + human message builders (pure-ish glue) ├── jobs.py # Job dataclass, in-memory store, ThreadPoolExecutor, run_job() ├── app.py # FastAPI app: auth dependency, /health, /fetch, /jobs/{id} ├── requirements.txt # fastapi, uvicorn[standard], requests, ytmusicapi, rich, pytest, httpx ├── Dockerfile └── docker-compose.yml tests/ ├── __init__.py ├── conftest.py # TestClient fixture, env setup, monkeypatch helpers ├── test_auth.py ├── test_jobs.py ├── test_actions.py └── test_api.py ``` **Responsibilities:** - `mf.py` — the only seam to the CLI. Loads the no-extension `musicfetch` file and re-exports `Hit, build_combined_hits, pick, act_youtube, act_lidarr_album, act_lidarr_artist`. Tests/app monkeypatch attributes on this module. - `actions.py` — turns a chosen hit (+ full hit list, for Lidarr→YouTube fallthrough) into a side-effecting download and a structured `result` dict; builds the speakable `message` strings. Calls into `mf`. - `jobs.py` — generic job store; `run_job(job_id, fn)` runs `fn()` on the executor and records `running`/`done`/`failed`. Knows nothing about musicfetch. - `app.py` — HTTP surface only: validation, auth, wiring `actions` + `jobs`. **Setup note:** all work happens from repo root `/home/zhering/Documents/musicfetch`. Install deps once before starting: ```bash pip install fastapi "uvicorn[standard]" httpx pytest requests ytmusicapi rich ``` --- ### Task 1: Package scaffold + musicfetch module loader **Files:** - Create: `server/__init__.py` (empty) - Create: `tests/__init__.py` (empty) - Create: `server/mf.py` - Test: `tests/test_mf_loader.py` - [ ] **Step 1: Write the failing test** Create `tests/test_mf_loader.py`: ```python def test_mf_reexports_musicfetch_symbols(): from server import mf assert hasattr(mf, "Hit") assert callable(mf.build_combined_hits) assert callable(mf.pick) assert callable(mf.act_youtube) assert callable(mf.act_lidarr_album) assert callable(mf.act_lidarr_artist) def test_mf_hit_constructs(): from server import mf h = mf.Hit(source="youtube", kind="track", title="x", artist="y") assert h.source == "youtube" assert h.artist == "y" ``` - [ ] **Step 2: Run test to verify it fails** Run: `pytest tests/test_mf_loader.py -v` Expected: FAIL — `ModuleNotFoundError: No module named 'server.mf'` - [ ] **Step 3: Create the package files and loader** Create empty `server/__init__.py` and `tests/__init__.py`. Create `server/mf.py`: ```python """Loads the sibling standalone `musicfetch` script (no .py extension) as a module and re-exports the symbols the API reuses. This is the single seam between the REST API and the CLI; musicfetch itself is unchanged.""" import importlib.util import os _HERE = os.path.dirname(os.path.abspath(__file__)) _MF_PATH = os.environ.get("MUSICFETCH_BIN", os.path.join(_HERE, "..", "musicfetch")) _spec = importlib.util.spec_from_file_location("musicfetch_core", _MF_PATH) _mod = importlib.util.module_from_spec(_spec) _spec.loader.exec_module(_mod) # safe: musicfetch guards main() behind __main__ Hit = _mod.Hit build_combined_hits = _mod.build_combined_hits pick = _mod.pick act_youtube = _mod.act_youtube act_lidarr_album = _mod.act_lidarr_album act_lidarr_artist = _mod.act_lidarr_artist QUALITY_CHOICES = _mod.QUALITY_CHOICES ``` - [ ] **Step 4: Run test to verify it passes** Run: `pytest tests/test_mf_loader.py -v` Expected: PASS (2 passed) - [ ] **Step 5: Commit** ```bash git add server/__init__.py server/mf.py tests/__init__.py tests/test_mf_loader.py git commit -m "feat(server): load musicfetch binary as importable module" ``` --- ### Task 2: Job store + worker **Files:** - Create: `server/jobs.py` - Test: `tests/test_jobs.py` - [ ] **Step 1: Write the failing test** Create `tests/test_jobs.py`: ```python import time from server import jobs def _wait(job_id, status, timeout=2.0): end = time.time() + timeout while time.time() < end: j = jobs.get_job(job_id) if j and j.status == status: return j time.sleep(0.01) raise AssertionError(f"job {job_id} never reached {status}") def test_create_job_is_queued(): job = jobs.create_job(hit={"artist": "A"}, message="queued msg") assert job.status == "queued" assert job.hit == {"artist": "A"} assert jobs.get_job(job.id) is job def test_run_job_success_sets_done(): job = jobs.create_job(hit={}, message="m") jobs.run_job(job.id, lambda: {"path": "/x", "lidarr_album_id": None}, done_message="done!") j = _wait(job.id, "done") assert j.result == {"path": "/x", "lidarr_album_id": None} assert j.message == "done!" assert j.error is None def test_run_job_failure_sets_failed(): job = jobs.create_job(hit={}, message="m") def boom(): raise RuntimeError("kaboom") jobs.run_job(job.id, boom, done_message="done!", fail_message="it broke") j = _wait(job.id, "failed") assert j.error and "kaboom" in j.error assert j.message == "it broke" def test_get_unknown_job_returns_none(): assert jobs.get_job("nope") is None ``` - [ ] **Step 2: Run test to verify it fails** Run: `pytest tests/test_jobs.py -v` Expected: FAIL — `ModuleNotFoundError: No module named 'server.jobs'` - [ ] **Step 3: Write minimal implementation** Create `server/jobs.py`: ```python """In-memory async job store. Personal-scale: jobs are lost on restart. Generic — knows nothing about musicfetch; callers pass a no-arg `fn`.""" import time import uuid from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass, field from typing import Any, Callable, Optional _EXECUTOR = ThreadPoolExecutor(max_workers=2) JOBS: "dict[str, Job]" = {} _MAX_JOBS = 200 # cap to bound memory @dataclass class Job: id: str status: str # queued | running | done | failed hit: Any message: str result: Optional[dict] = None error: Optional[str] = None created_at: float = field(default_factory=time.time) updated_at: float = field(default_factory=time.time) def _touch(job: "Job", **changes): for k, v in changes.items(): setattr(job, k, v) job.updated_at = time.time() def _evict_if_needed(): if len(JOBS) <= _MAX_JOBS: return for jid in sorted(JOBS, key=lambda j: JOBS[j].created_at)[: len(JOBS) - _MAX_JOBS]: JOBS.pop(jid, None) def create_job(hit: Any, message: str) -> "Job": job = Job(id=uuid.uuid4().hex[:8], status="queued", hit=hit, message=message) JOBS[job.id] = job _evict_if_needed() return job def get_job(job_id: str) -> Optional["Job"]: return JOBS.get(job_id) def run_job(job_id: str, fn: Callable[[], dict], done_message: str, fail_message: str = "Something went wrong while fetching.") -> None: def _task(): job = JOBS[job_id] _touch(job, status="running") try: result = fn() _touch(job, status="done", result=result, message=done_message) except Exception as e: # noqa: BLE001 — record any failure on the job _touch(job, status="failed", error=f"{type(e).__name__}: {e}", message=fail_message) _EXECUTOR.submit(_task) ``` - [ ] **Step 4: Run test to verify it passes** Run: `pytest tests/test_jobs.py -v` Expected: PASS (4 passed) - [ ] **Step 5: Commit** ```bash git add server/jobs.py tests/test_jobs.py git commit -m "feat(server): in-memory async job store with thread-pool worker" ``` --- ### Task 3: Actions — perform fetch + speakable messages **Files:** - Create: `server/actions.py` - Test: `tests/test_actions.py` This module decides what to do with the chosen hit and produces the result dict + human messages. It mirrors musicfetch's `main()` action dispatch (incl. the Lidarr-album → YouTube fallthrough) but returns structured data instead of printing. - [ ] **Step 1: Write the failing test** Create `tests/test_actions.py`: ```python from server import actions, mf def make_yt_hit(): return mf.Hit(source="youtube", kind="track", title="Together", artist="Avril Lavigne", album="Under My Skin", year="2004", payload={"videoId": "abc"}) def make_lidarr_album_hit(): return mf.Hit(source="lidarr", kind="album", title="Under My Skin", artist="Avril Lavigne", album="Under My Skin", year="2004", payload={"album": {"id": 5, "title": "Under My Skin"}}) def test_started_message_mentions_source_and_title(): msg = actions.started_message(make_yt_hit()) assert "Under My Skin" in msg assert "Avril Lavigne" in msg assert "YouTube" in msg def test_done_message_mentions_title(): msg = actions.done_message(make_yt_hit()) assert "Under My Skin" in msg assert "Avril Lavigne" in msg def test_perform_youtube_calls_act_youtube(monkeypatch): calls = {} monkeypatch.setattr(mf, "act_youtube", lambda hit, root, quality, dry_run: calls.update(hit=hit, root=root, quality=quality)) hit = make_yt_hit() result = actions.perform_fetch(hit, [hit], quality="best", root="/media/music") assert calls["quality"] == "best" assert result["path"] == "/media/music/Avril Lavigne/youtube" assert result["lidarr_album_id"] is None def test_perform_lidarr_album_handled(monkeypatch): monkeypatch.setattr(mf, "act_lidarr_album", lambda hit, root, search_all, dry_run: True) hit = make_lidarr_album_hit() result = actions.perform_fetch(hit, [hit], quality="best", root="/media/music") assert result["lidarr_album_id"] == 5 assert result["path"] is None def test_perform_lidarr_album_fallsthrough_to_youtube(monkeypatch): monkeypatch.setattr(mf, "act_lidarr_album", lambda hit, root, search_all, dry_run: False) yt_calls = {} monkeypatch.setattr(mf, "act_youtube", lambda hit, root, quality, dry_run: yt_calls.update(hit=hit)) lidarr_hit = make_lidarr_album_hit() yt_hit = make_yt_hit() result = actions.perform_fetch(lidarr_hit, [lidarr_hit, yt_hit], quality="best", root="/media/music") assert yt_calls["hit"] is yt_hit assert result["path"] == "/media/music/Avril Lavigne/youtube" ``` - [ ] **Step 2: Run test to verify it fails** Run: `pytest tests/test_actions.py -v` Expected: FAIL — `ModuleNotFoundError: No module named 'server.actions'` - [ ] **Step 3: Write minimal implementation** Create `server/actions.py`: ```python """Glue between a chosen Hit and a side-effecting download. Mirrors musicfetch's main() dispatch but returns a structured result dict and speakable messages.""" import os from typing import Optional from . import mf def _source_label(hit) -> str: return "YouTube Music" if hit.source == "youtube" else "Lidarr" def _title(hit) -> str: return hit.album if hit.kind == "album" else (hit.title or hit.album or hit.artist) def started_message(hit) -> str: return f"Found '{_title(hit)}' by {hit.artist or 'unknown artist'} on {_source_label(hit)}. Downloading now." def done_message(hit) -> str: return f"Finished downloading '{_title(hit)}' by {hit.artist or 'unknown artist'}." def failed_message(hit) -> str: return f"Failed to download '{_title(hit)}' by {hit.artist or 'unknown artist'}." def _yt_path(hit, root: str) -> str: artist_dir = (hit.artist.split(",")[0].strip() if hit.artist else "") or "Unknown Artist" return os.path.join(root, artist_dir, "youtube") def _download_youtube(hit, quality: str, root: str) -> dict: mf.act_youtube(hit, root, quality, False) return {"path": _yt_path(hit, root), "lidarr_album_id": None} def perform_fetch(chosen, hits: list, quality: str, root: str) -> dict: """Run the download for the chosen hit. Returns {"path", "lidarr_album_id"}. Raises on unrecoverable failure (recorded by the job worker).""" if chosen.source == "youtube": return _download_youtube(chosen, quality, root) if chosen.kind == "album": handled = mf.act_lidarr_album(chosen, root, False, False) if handled: return {"path": None, "lidarr_album_id": chosen.payload.get("album", {}).get("id")} # No indexer release -> fall through to the top YouTube hit, like the CLI. yt = next((h for h in hits if h.source == "youtube"), None) if yt is None: raise RuntimeError("No Lidarr release and no YouTube fallback available.") return _download_youtube(yt, quality, root) # Lidarr artist pick. ok = mf.act_lidarr_artist(chosen, root, False, False) if not ok: raise RuntimeError("Failed to add artist to Lidarr.") return {"path": None, "lidarr_album_id": None} ``` - [ ] **Step 4: Run test to verify it passes** Run: `pytest tests/test_actions.py -v` Expected: PASS (5 passed) - [ ] **Step 5: Commit** ```bash git add server/actions.py tests/test_actions.py git commit -m "feat(server): action dispatch with structured result and messages" ``` --- ### Task 4: FastAPI app — auth + /health **Files:** - Create: `server/app.py` - Create: `server/requirements.txt` - Create: `tests/conftest.py` - Test: `tests/test_auth.py` - [ ] **Step 1: Write the failing test** Create `tests/conftest.py`: ```python import os import pytest os.environ.setdefault("MUSICFETCH_API_KEY", "test-key") @pytest.fixture def client(): from fastapi.testclient import TestClient from server.app import app return TestClient(app) @pytest.fixture def auth(): return {"X-API-Key": "test-key"} ``` Create `tests/test_auth.py`: ```python def test_health_no_auth(client): r = client.get("/health") assert r.status_code == 200 assert r.json() == {"status": "ok"} def test_fetch_requires_key(client): r = client.post("/fetch", params={"q": "anything"}) assert r.status_code == 401 assert "message" in r.json() def test_fetch_rejects_wrong_key(client): r = client.post("/fetch", params={"q": "anything"}, headers={"X-API-Key": "wrong"}) assert r.status_code == 401 ``` - [ ] **Step 2: Run test to verify it fails** Run: `pytest tests/test_auth.py -v` Expected: FAIL — `ModuleNotFoundError: No module named 'server.app'` - [ ] **Step 3: Write minimal implementation** Create `server/requirements.txt`: ``` fastapi uvicorn[standard] requests ytmusicapi rich ``` Create `server/app.py`: ```python """MusicFetch REST API. Plain HTTP behind an upstream TLS reverse proxy.""" import os from fastapi import Depends, FastAPI, Header, HTTPException from fastapi.responses import JSONResponse from . import actions, jobs, mf API_KEY = os.environ.get("MUSICFETCH_API_KEY", "") ROOT = os.environ.get("MUSICFETCH_ROOT", "/media/music") app = FastAPI(title="MusicFetch API") def require_key(x_api_key: str = Header(default="")): if not API_KEY or x_api_key != API_KEY: raise HTTPException(status_code=401, detail="Invalid API key.") @app.exception_handler(HTTPException) async def _http_exc(_req, exc: HTTPException): # Always return a Siri-speakable {"message": ...} body. return JSONResponse(status_code=exc.status_code, content={"message": exc.detail}) @app.get("/health") def health(): return {"status": "ok"} ``` - [ ] **Step 4: Run test to verify it passes** Run: `pytest tests/test_auth.py -v` Expected: PASS (3 passed) - [ ] **Step 5: Commit** ```bash git add server/app.py server/requirements.txt tests/conftest.py tests/test_auth.py git commit -m "feat(server): FastAPI app with API-key auth and health check" ``` --- ### Task 5: /fetch and /jobs/{id} endpoints **Files:** - Modify: `server/app.py` - Test: `tests/test_api.py` - [ ] **Step 1: Write the failing test** Create `tests/test_api.py`: ```python import time import pytest from server import mf, jobs as jobs_mod @pytest.fixture(autouse=True) def _clear_jobs(): jobs_mod.JOBS.clear() yield jobs_mod.JOBS.clear() def _yt_hit(): return mf.Hit(source="youtube", kind="track", title="Together", artist="Avril Lavigne", album="Under My Skin", year="2004", payload={"videoId": "abc"}) def test_fetch_returns_job_and_message(client, auth, monkeypatch): hit = _yt_hit() monkeypatch.setattr("server.app.mf.build_combined_hits", lambda q, limit, yt_first, lidarr_only, yt_only: [hit]) monkeypatch.setattr("server.app.mf.pick", lambda hits, q, noninteractive, yt_first: hits[0]) # Don't actually download. monkeypatch.setattr("server.app.actions.perform_fetch", lambda chosen, hits, quality, root: {"path": "/media/music/x", "lidarr_album_id": None}) r = client.post("/fetch", params={"q": "Under My Skin"}, headers=auth) assert r.status_code == 200 body = r.json() assert body["status"] == "queued" assert "Under My Skin" in body["message"] assert body["hit"]["artist"] == "Avril Lavigne" assert body["job_id"] def test_fetch_no_hits_returns_404(client, auth, monkeypatch): monkeypatch.setattr("server.app.mf.build_combined_hits", lambda q, limit, yt_first, lidarr_only, yt_only: []) r = client.post("/fetch", params={"q": "zzzz"}, headers=auth) assert r.status_code == 404 assert "zzzz" in r.json()["message"] def test_fetch_missing_q_returns_422(client, auth): r = client.post("/fetch", headers=auth) assert r.status_code == 422 def test_job_lifecycle_done(client, auth, monkeypatch): hit = _yt_hit() monkeypatch.setattr("server.app.mf.build_combined_hits", lambda q, limit, yt_first, lidarr_only, yt_only: [hit]) monkeypatch.setattr("server.app.mf.pick", lambda hits, q, noninteractive, yt_first: hits[0]) monkeypatch.setattr("server.app.actions.perform_fetch", lambda chosen, hits, quality, root: {"path": "/media/music/x", "lidarr_album_id": None}) job_id = client.post("/fetch", params={"q": "x"}, headers=auth).json()["job_id"] end = time.time() + 2 status = None while time.time() < end: body = client.get(f"/jobs/{job_id}", headers=auth).json() status = body["status"] if status == "done": break time.sleep(0.01) assert status == "done" assert body["result"]["path"] == "/media/music/x" assert "Finished" in body["message"] def test_unknown_job_404(client, auth): r = client.get("/jobs/deadbeef", headers=auth) assert r.status_code == 404 assert "message" in r.json() def test_jobs_requires_key(client): r = client.get("/jobs/whatever") assert r.status_code == 401 ``` - [ ] **Step 2: Run test to verify it fails** Run: `pytest tests/test_api.py -v` Expected: FAIL — 404s/`AttributeError` (routes not defined yet) - [ ] **Step 3: Add the endpoints** Append to `server/app.py`: ```python from typing import Optional from fastapi import Query def _hit_public(hit) -> dict: return {"source": hit.source, "kind": hit.kind, "artist": hit.artist, "album": hit.album, "title": hit.title, "year": hit.year} def _job_public(job) -> dict: return {"message": job.message, "job_id": job.id, "status": job.status, "hit": _hit_public(job.hit) if job.hit is not None else None, "result": job.result, "error": job.error} @app.post("/fetch", dependencies=[Depends(require_key)]) def fetch(q: str = Query(..., min_length=1), quality: str = Query("best"), source: str = Query("auto")): if quality not in mf.QUALITY_CHOICES: raise HTTPException(status_code=422, detail=f"Invalid quality '{quality}'.") if source not in ("auto", "lidarr", "youtube"): raise HTTPException(status_code=422, detail=f"Invalid source '{source}'.") yt_first = source == "youtube" hits = mf.build_combined_hits(q, 10, yt_first, lidarr_only=(source == "lidarr"), yt_only=(source == "youtube")) if not hits: raise HTTPException(status_code=404, detail=f"No results found for '{q}'.") chosen = mf.pick(hits, q, True, yt_first) if chosen is None: raise HTTPException(status_code=404, detail=f"No results found for '{q}'.") job = jobs.create_job(hit=chosen, message=actions.started_message(chosen)) jobs.run_job( job.id, lambda: actions.perform_fetch(chosen, hits, quality, ROOT), done_message=actions.done_message(chosen), fail_message=actions.failed_message(chosen), ) return _job_public(job) @app.get("/jobs/{job_id}", dependencies=[Depends(require_key)]) def job_status(job_id: str): job = jobs.get_job(job_id) if job is None: raise HTTPException(status_code=404, detail="No such job.") return _job_public(job) ``` Note: `/fetch` accepts params from the query string (Siri can also send a JSON body via Shortcuts, but query params keep both the `curl` and Shortcuts setup simplest). `_job_public` handles both dataclass `Hit` (from real flow) — access attributes — so keep `chosen` a `Hit`; tests pass real `Hit` objects. - [ ] **Step 4: Run test to verify it passes** Run: `pytest tests/test_api.py -v` Expected: PASS (6 passed) - [ ] **Step 5: Run the full suite** Run: `pytest -v` Expected: all green (mf loader, jobs, actions, auth, api) - [ ] **Step 6: Commit** ```bash git add server/app.py tests/test_api.py git commit -m "feat(server): /fetch and /jobs endpoints with async download jobs" ``` --- ### Task 6: Docker + compose **Files:** - Create: `server/Dockerfile` - Create: `server/docker-compose.yml` - Create: `server/.dockerignore` - [ ] **Step 1: Write the Dockerfile** Create `server/Dockerfile` (build context = repo root): ```dockerfile FROM python:3.12-slim RUN apt-get update \ && apt-get install -y --no-install-recommends ffmpeg \ && rm -rf /var/lib/apt/lists/* WORKDIR /app COPY server/requirements.txt /app/server/requirements.txt RUN pip install --no-cache-dir -r /app/server/requirements.txt yt-dlp COPY musicfetch /app/musicfetch COPY server /app/server EXPOSE 6769 CMD ["sh", "-c", "uvicorn server.app:app --host 0.0.0.0 --port ${MUSICFETCH_PORT:-6769}"] ``` - [ ] **Step 2: Write the compose file** Create `server/docker-compose.yml`: ```yaml services: musicfetch-api: build: context: .. dockerfile: server/Dockerfile container_name: musicfetch-api restart: unless-stopped ports: - "6769:6769" environment: LIDARR_URL: "http://lidarr:8686" LIDARR_API_KEY: "${LIDARR_API_KEY}" MUSICFETCH_API_KEY: "${MUSICFETCH_API_KEY}" MUSICFETCH_ROOT: "/media/music" MUSICFETCH_PORT: "6769" volumes: - /media/music:/media/music networks: - lidarr_net networks: lidarr_net: external: true # Set to the actual network name of your existing Lidarr stack, e.g.: # name: media_default ``` - [ ] **Step 3: Write .dockerignore** Create `server/.dockerignore`: ``` __pycache__/ *.pyc tests/ docs/ .git/ ``` - [ ] **Step 4: Verify the image builds** Run: `docker compose -f server/docker-compose.yml build` Expected: builds successfully (no push/run yet — networks/secrets are env-specific). - [ ] **Step 5: Commit** ```bash git add server/Dockerfile server/docker-compose.yml server/.dockerignore git commit -m "feat(server): Dockerfile and compose for the Lidarr stack" ``` --- ### Task 7: README — API usage + Siri Shortcuts walkthrough **Files:** - Modify: `README.md` (append a new "## 🌐 REST API" section before "## 🛠️ Contributing") - [ ] **Step 1: Add the API + Siri section to README** Insert this block into `README.md` directly above the `## 🛠️ Contributing` heading: ```markdown ## 🌐 REST API (Docker) Run MusicFetch as an authenticated HTTP service inside your Lidarr Docker stack. A client POSTs a query; the server grabs the top hit non-interactively and runs the download as a background job you can poll. Every response includes a human-readable `message` (handy for Siri). ### Configure & run Set the network name in `server/docker-compose.yml` to your existing Lidarr stack network, then: ```bash export LIDARR_API_KEY="your-lidarr-key" export MUSICFETCH_API_KEY="a-long-random-secret" docker compose -f server/docker-compose.yml up -d --build ``` | Env var | Default | Purpose | |---|---|---| | `MUSICFETCH_API_KEY` | *(required)* | Shared secret clients send as `X-API-Key`. | | `MUSICFETCH_PORT` | `6769` | Listen port. | | `LIDARR_URL` | `http://lidarr:8686` | Lidarr base URL (stack network). | | `LIDARR_API_KEY` | *(required for Lidarr)* | Lidarr API key. | | `MUSICFETCH_ROOT` | `/media/music` | Music output root (bind-mounted). | TLS is expected to be handled by your upstream reverse proxy; the container serves plain HTTP on `6769`. ### Endpoints | Method | Path | Auth | Purpose | |---|---|---|---| | `GET` | `/health` | no | Liveness check. | | `POST` | `/fetch?q=...` | yes | Grab top hit; returns a `job_id`. | | `GET` | `/jobs/{id}` | yes | Poll job status. | `POST /fetch` params: `q` (required), `quality` (`best,320,m4a,opus,flac`), `source` (`auto,lidarr,youtube`). ### curl examples ```bash # Kick off a fetch curl -X POST 'https://mf.izebra.net/fetch?q=Under%20My%20Skin' \ -H 'X-API-Key: a-long-random-secret' # -> {"message":"Found 'Under My Skin' ... Downloading now.","job_id":"a1b2c3","status":"queued","hit":{...}} # Poll the job curl 'https://mf.izebra.net/jobs/a1b2c3' -H 'X-API-Key: a-long-random-secret' # -> {"message":"Finished downloading ...","status":"done","result":{...}} ``` ### 🗣️ Siri Shortcuts integration Make a shortcut that fetches music by voice ("Hey Siri, fetch music"). 1. **Shortcuts app → New Shortcut.** 2. Add **Ask for Input** → Input Type **Text**, prompt "What should I fetch?". (Or use **Dictate Text** for fully spoken input.) 3. Add **Text** action, set it to: `https://mf.izebra.net/fetch?q=` then insert the **Provided Input** variable at the end. (Shortcuts URL-encodes query variables automatically.) 4. Add **Get Contents of URL**: - **URL:** the Text variable from step 3. - **Method:** `POST`. - **Headers:** add one — key `X-API-Key`, value your `MUSICFETCH_API_KEY`. - **Request Body:** leave as is (the query is in the URL). 5. Add **Get Dictionary Value** → Get Value for **message** in **Contents of URL**. 6. Add **Speak Text** → the Dictionary Value. Siri reads back "Found '…' … Downloading now." 7. (Optional) To confirm completion: add **Get Dictionary Value** for `job_id`, **Wait** ~20 seconds, **Get Contents of URL** on `https://mf.izebra.net/jobs/` (same `X-API-Key` header), then **Get Dictionary Value** `message` → **Speak Text** again. Rename the shortcut (e.g. "Fetch Music") — that phrase becomes the Siri trigger. ``` - [ ] **Step 2: Commit** ```bash git add README.md git commit -m "docs: REST API usage and Siri Shortcuts walkthrough" ``` --- ## Self-Review **Spec coverage:** - Layout `server/` + import-as-module → Task 1. ✅ - Async jobs + worker → Task 2. ✅ - Action dispatch incl. Lidarr→YouTube fallthrough → Task 3. ✅ - Auth (`X-API-Key`, 401) + `/health` → Task 4. ✅ - `/fetch`, `/jobs/{id}`, error codes (401/404/422), Siri `message` field → Task 5. ✅ - Docker / compose / port `6769` via `MUSICFETCH_PORT` → Task 6. ✅ - README API + Siri walkthrough (researched) → Task 7. ✅ - Out-of-scope items intentionally omitted. ✅ **Placeholder scan:** No TBD/TODO; all code shown in full; the only `# Set to...` note is a genuine env-specific value the operator must fill (network name), with an example given. **Type consistency:** `Hit` attribute access (`hit.source/kind/artist/album/title/ year/payload`) matches musicfetch's dataclass. `Job` fields (`id,status,hit, message,result,error`) consistent across `jobs.py`, `_job_public`, and tests. `perform_fetch(chosen, hits, quality, root)` signature identical in `actions.py` and its call site in `app.py`. `build_combined_hits(q, limit, yt_first, lidarr_only, yt_only)` and `pick(hits, q, noninteractive, yt_first)` match musicfetch's real signatures. **Note for implementer:** musicfetch's `pick(noninteractive=True)` returns the top hit of the primary source without reading stdin — safe to call server-side. ```