From 11a57bfa67c48beeead4946ea1a0dba02f7e47c8 Mon Sep 17 00:00:00 2001 From: zebra Date: Mon, 8 Jun 2026 19:46:13 -0700 Subject: [PATCH] Add implementation plan for MusicFetch REST API TDD task breakdown: module loader, job store, action dispatch, FastAPI auth/endpoints, Docker/compose, README + Siri Shortcuts walkthrough. Co-Authored-By: Claude Opus 4.8 --- .../plans/2026-06-08-musicfetch-rest-api.md | 925 ++++++++++++++++++ 1 file changed, 925 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-08-musicfetch-rest-api.md diff --git a/docs/superpowers/plans/2026-06-08-musicfetch-rest-api.md b/docs/superpowers/plans/2026-06-08-musicfetch-rest-api.md new file mode 100644 index 0000000..3adf83a --- /dev/null +++ b/docs/superpowers/plans/2026-06-08-musicfetch-rest-api.md @@ -0,0 +1,925 @@ +# 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. +```