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>
This commit is contained in:
2026-06-08 19:46:13 -07:00
parent 033bc00ccc
commit 11a57bfa67

View File

@@ -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/<job_id>` (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.
```