Files
musicfetch/docs/superpowers/plans/2026-06-08-musicfetch-rest-api.md
zebra 11a57bfa67 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 <noreply@anthropic.com>
2026-06-08 19:46:13 -07:00

29 KiB

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:

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:

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:

"""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
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:

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:

"""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
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:

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:

"""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
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:

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:

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:

"""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
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:

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:

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
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):

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:

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

## 🌐 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

# 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/<job_id> (same X-API-Key header), then Get Dictionary Value messageSpeak 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.