feat(server): /fetch and /jobs endpoints with async download jobs

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 20:15:28 -07:00
parent 49a45e6270
commit d4c1b18e58
2 changed files with 129 additions and 7 deletions

View File

@@ -1,7 +1,7 @@
"""MusicFetch REST API. Plain HTTP behind an upstream TLS reverse proxy."""
import os
from fastapi import Depends, FastAPI, Header, HTTPException
from fastapi import Depends, FastAPI, Header, HTTPException, Query
from fastapi.responses import JSONResponse
from . import actions, jobs, mf
@@ -28,10 +28,51 @@ def health():
return {"status": "ok"}
# Minimal stub so that auth tests can verify 401 behaviour before the real
# /fetch implementation lands in Task 5. This route MUST carry require_key as
# a dependency so unauthenticated / wrong-key requests are rejected here.
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_stub(q: str = ""):
# Task 5 will replace this body with the full search-and-download logic.
raise HTTPException(status_code=501, detail="Not yet implemented.")
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))
response = _job_public(job) # snapshot "queued" state before background thread starts
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 response
@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)