From 0f7ddd76970c6defaf120e00f25b7590d7231fa3 Mon Sep 17 00:00:00 2001 From: zebra Date: Mon, 8 Jun 2026 23:58:37 -0700 Subject: [PATCH] feat(server): route URL/playlist /fetch to download jobs Co-Authored-By: Claude Sonnet 4.6 --- server/actions.py | 35 +++++++++++++++++++++++ server/app.py | 15 ++++++++++ tests/test_api_url.py | 65 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 115 insertions(+) create mode 100644 tests/test_api_url.py diff --git a/server/actions.py b/server/actions.py index 47a4fb5..bb08859 100644 --- a/server/actions.py +++ b/server/actions.py @@ -61,3 +61,38 @@ def perform_fetch(chosen, hits: list, quality: str, root: str) -> dict: if not ok: raise RuntimeError("Failed to add artist to Lidarr.") return {"path": None, "lidarr_album_id": None} + + +def url_started_message(kind: str, title: str = "") -> str: + if kind == "playlist": + return (f"Fetching playlist '{title}'. Downloading tracks now." + if title else "Fetching playlist. Downloading tracks now.") + return f"Fetching '{title}'. Downloading now." if title else "Fetching track. Downloading now." + + +def playlist_done_message(result: dict) -> str: + ok, total = result.get("ok", 0), result.get("total", 0) + failed = total - ok + return f"Downloaded {ok}/{total} tracks" + (f" ({failed} failed)." if failed else ".") + + +def url_done_message(result: dict) -> str: + title = result.get("title", "") + return f"Downloaded '{title}'." if title else "Download complete." + + +def perform_url_fetch(url: str, quality: str, root: str) -> dict: + """Download a URL (playlist -> batch, else single). Raises if nothing + downloaded so the job is marked failed.""" + if mf.is_playlist_url(url): + ok, total, title = mf.download_playlist(url, root, quality, False) + if ok == 0: + raise RuntimeError(f"No tracks downloaded from playlist '{title}'." if title + else "No tracks downloaded from playlist.") + return {"kind": "playlist", "title": title, "ok": ok, "total": total, + "path": None, "lidarr_album_id": None} + info = mf.download_single(url, root, quality, False) + if not info.get("ok"): + raise RuntimeError("Download failed.") + return {"kind": "track", "title": info["title"], "artist": info["artist"], + "ok": 1, "total": 1, "path": None, "lidarr_album_id": None} diff --git a/server/app.py b/server/app.py index bf39010..7907d19 100644 --- a/server/app.py +++ b/server/app.py @@ -51,6 +51,21 @@ def fetch(q: str = Query(..., min_length=1), source: str = Query("auto")): if quality not in mf.QUALITY_CHOICES: raise HTTPException(status_code=422, detail=f"Invalid quality '{quality}'.") + + if mf.is_url(q): + kind = "playlist" if mf.is_playlist_url(q) else "track" + syn = mf.Hit(source="youtube", kind=kind, title="", artist="") + job = jobs.create_job(hit=syn, message=actions.url_started_message(kind)) + response = _job_public(job) + done_msg = actions.playlist_done_message if kind == "playlist" else actions.url_done_message + jobs.run_job( + job.id, + lambda: actions.perform_url_fetch(q, quality, ROOT), + done_message=done_msg, + fail_message="Download failed.", + ) + return response + if source not in ("auto", "lidarr", "youtube"): raise HTTPException(status_code=422, detail=f"Invalid source '{source}'.") diff --git a/tests/test_api_url.py b/tests/test_api_url.py new file mode 100644 index 0000000..a2ab27e --- /dev/null +++ b/tests/test_api_url.py @@ -0,0 +1,65 @@ +import time +import pytest +from server import jobs as jobs_mod + + +@pytest.fixture(autouse=True) +def _clear_jobs(): + jobs_mod.JOBS.clear() + yield + jobs_mod.JOBS.clear() + + +def _wait_done(client, auth, job_id, timeout=2.0): + end = time.time() + timeout + while time.time() < end: + b = client.get(f"/jobs/{job_id}", headers=auth).json() + if b["status"] in ("done", "failed"): + return b + time.sleep(0.01) + raise AssertionError("job never finished") + + +def test_playlist_url_batch_job(client, auth, monkeypatch): + monkeypatch.setattr("server.app.mf.download_playlist", + lambda url, root, quality, dry_run: (2, 3, "My Mix")) + r = client.post("/fetch", params={"q": "https://music.youtube.com/playlist?list=PLx"}, headers=auth) + assert r.status_code == 200 + body = r.json() + assert body["status"] == "queued" + assert body["hit"]["kind"] == "playlist" + done = _wait_done(client, auth, body["job_id"]) + assert done["status"] == "done" + assert "2/3" in done["message"] + assert done["result"]["ok"] == 2 + + +def test_playlist_zero_success_fails(client, auth, monkeypatch): + monkeypatch.setattr("server.app.mf.download_playlist", + lambda url, root, quality, dry_run: (0, 3, "Dead Mix")) + body = client.post("/fetch", params={"q": "https://www.youtube.com/playlist?list=PLy"}, headers=auth).json() + done = _wait_done(client, auth, body["job_id"]) + assert done["status"] == "failed" + + +def test_single_video_url_download(client, auth, monkeypatch): + monkeypatch.setattr("server.app.mf.download_single", + lambda url, root, quality, dry_run: {"title": "Song", "artist": "A", "ok": True}) + body = client.post("/fetch", params={"q": "https://music.youtube.com/watch?v=abc"}, headers=auth).json() + assert body["hit"]["kind"] == "track" + done = _wait_done(client, auth, body["job_id"]) + assert done["status"] == "done" + assert "Song" in done["message"] + + +def test_search_query_still_works(client, auth, monkeypatch): + from server import mf + hit = mf.Hit(source="youtube", kind="track", title="T", artist="A", payload={"videoId": "x"}) + 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, ni, yf: hits[0]) + monkeypatch.setattr("server.app.actions.perform_fetch", + lambda chosen, hits, quality, root: {"path": "/x", "lidarr_album_id": None}) + r = client.post("/fetch", params={"q": "Daft Punk - Discovery"}, headers=auth) + assert r.status_code == 200 + assert r.json()["status"] == "queued"