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:
@@ -1,7 +1,7 @@
|
|||||||
"""MusicFetch REST API. Plain HTTP behind an upstream TLS reverse proxy."""
|
"""MusicFetch REST API. Plain HTTP behind an upstream TLS reverse proxy."""
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from fastapi import Depends, FastAPI, Header, HTTPException
|
from fastapi import Depends, FastAPI, Header, HTTPException, Query
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
from . import actions, jobs, mf
|
from . import actions, jobs, mf
|
||||||
@@ -28,10 +28,51 @@ def health():
|
|||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
# Minimal stub so that auth tests can verify 401 behaviour before the real
|
def _hit_public(hit) -> dict:
|
||||||
# /fetch implementation lands in Task 5. This route MUST carry require_key as
|
return {"source": hit.source, "kind": hit.kind, "artist": hit.artist,
|
||||||
# a dependency so unauthenticated / wrong-key requests are rejected here.
|
"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)])
|
@app.post("/fetch", dependencies=[Depends(require_key)])
|
||||||
def fetch_stub(q: str = ""):
|
def fetch(q: str = Query(..., min_length=1),
|
||||||
# Task 5 will replace this body with the full search-and-download logic.
|
quality: str = Query("best"),
|
||||||
raise HTTPException(status_code=501, detail="Not yet implemented.")
|
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)
|
||||||
|
|||||||
81
tests/test_api.py
Normal file
81
tests/test_api.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
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])
|
||||||
|
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"] or "Together" 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
|
||||||
Reference in New Issue
Block a user