feat: rework Lidarr library integration
This commit is contained in:
@@ -50,18 +50,47 @@ def test_perform_youtube_calls_act_youtube(monkeypatch):
|
||||
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)
|
||||
def _release(guid="g1"):
|
||||
return {"guid": guid, "indexerId": 2, "title": "Avril-Under_My_Skin",
|
||||
"size": 312 * 1024 * 1024, "seeders": 24, "indexer": "Redacted",
|
||||
"quality": {"quality": {"name": "FLAC"}}}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def in_library(monkeypatch):
|
||||
"""Lidarr holds the artist and the album."""
|
||||
monkeypatch.setattr(mf, "ensure_album", lambda album, root, search_all, dry_run: {
|
||||
"album": {"id": 5}, "album_id": 5, "artist_name": "Avril Lavigne",
|
||||
"album_title": "Under My Skin", "artist_state": "existing",
|
||||
"album_state": "existing"})
|
||||
|
||||
|
||||
def test_perform_lidarr_album_offers_choices_by_default(monkeypatch, in_library):
|
||||
monkeypatch.setattr(mf, "list_releases", lambda album_id, **kw: [_release("a"), _release("b")])
|
||||
monkeypatch.setattr(mf, "grab_release", lambda *a, **kw: (_ for _ in ()).throw(
|
||||
AssertionError("must not grab before the user picks")))
|
||||
hit = make_lidarr_album_hit()
|
||||
result = actions.perform_fetch(hit, [hit], quality="best", root="/media/music")
|
||||
assert result["outcome"] == "choices"
|
||||
assert [c["n"] for c in result["choices"]] == [1, 2]
|
||||
assert result["pending"] == [_release("a"), _release("b")]
|
||||
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)
|
||||
def test_perform_lidarr_album_auto_grabs_the_best_release(monkeypatch, in_library):
|
||||
grabbed = {}
|
||||
monkeypatch.setattr(mf, "list_releases", lambda album_id, **kw: [_release("best"), _release("b")])
|
||||
monkeypatch.setattr(mf, "grab_release",
|
||||
lambda rel, dry_run=False: grabbed.update(guid=rel["guid"]) or True)
|
||||
hit = make_lidarr_album_hit()
|
||||
result = actions.perform_fetch(hit, [hit], quality="best", root="/media/music", auto=True)
|
||||
assert result["outcome"] == "album_grabbed"
|
||||
assert grabbed["guid"] == "best"
|
||||
assert not result.get("choices")
|
||||
|
||||
|
||||
def test_perform_lidarr_album_fallsthrough_to_youtube(monkeypatch, in_library):
|
||||
monkeypatch.setattr(mf, "list_releases", lambda album_id, **kw: [])
|
||||
yt_calls = {}
|
||||
monkeypatch.setattr(mf, "act_youtube",
|
||||
lambda hit, root, quality, dry_run: yt_calls.update(hit=hit))
|
||||
@@ -70,14 +99,35 @@ def test_perform_lidarr_album_fallsthrough_to_youtube(monkeypatch):
|
||||
result = actions.perform_fetch(lidarr_hit, [lidarr_hit, yt_hit],
|
||||
quality="best", root="/media/music")
|
||||
assert yt_calls["hit"] is yt_hit
|
||||
assert result["outcome"] == "youtube_fallback"
|
||||
assert result["path"] == "/media/music/Avril Lavigne/youtube"
|
||||
|
||||
|
||||
def test_perform_lidarr_album_no_release_no_fallback_raises(monkeypatch):
|
||||
monkeypatch.setattr(mf, "act_lidarr_album",
|
||||
lambda hit, root, search_all, dry_run: False)
|
||||
def test_perform_lidarr_album_reports_no_release_without_a_fallback(monkeypatch, in_library):
|
||||
monkeypatch.setattr(mf, "list_releases", lambda album_id, **kw: [])
|
||||
hit = make_lidarr_album_hit()
|
||||
with pytest.raises(RuntimeError):
|
||||
result = actions.perform_fetch(hit, [hit], quality="best", root="/media/music")
|
||||
assert result["outcome"] == "no_release"
|
||||
|
||||
|
||||
def test_perform_lidarr_album_skips_youtube_when_not_allowed(monkeypatch, in_library):
|
||||
monkeypatch.setattr(mf, "list_releases", lambda album_id, **kw: [])
|
||||
monkeypatch.setattr(mf, "act_youtube", lambda *a, **kw: (_ for _ in ()).throw(
|
||||
AssertionError("source=lidarr must not use YouTube")))
|
||||
lidarr_hit = make_lidarr_album_hit()
|
||||
result = actions.perform_fetch(lidarr_hit, [lidarr_hit, make_yt_hit()],
|
||||
quality="best", root="/media/music",
|
||||
allow_youtube=False)
|
||||
assert result["outcome"] == "no_release"
|
||||
|
||||
|
||||
def test_perform_lidarr_album_raises_when_the_album_is_not_in_lidarr(monkeypatch):
|
||||
monkeypatch.setattr(mf, "ensure_album", lambda album, root, search_all, dry_run: {
|
||||
"album": None, "album_id": None, "artist_name": "Avril Lavigne",
|
||||
"album_title": "Under My Skin", "artist_state": "existing",
|
||||
"album_state": "missing"})
|
||||
hit = make_lidarr_album_hit()
|
||||
with pytest.raises(RuntimeError, match="no album called"):
|
||||
actions.perform_fetch(hit, [hit], quality="best", root="/media/music")
|
||||
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ def test_fetch_returns_job_and_message(client, auth, monkeypatch):
|
||||
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})
|
||||
lambda chosen, hits, quality, root, **kw: {"path": "/media/music/x", "lidarr_album_id": None})
|
||||
|
||||
r = client.post("/fetch", params={"q": "Under My Skin"}, headers=auth)
|
||||
assert r.status_code == 200
|
||||
@@ -55,7 +55,7 @@ def test_job_lifecycle_done(client, auth, monkeypatch):
|
||||
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})
|
||||
lambda chosen, hits, quality, root, **kw: {"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
|
||||
|
||||
@@ -69,7 +69,7 @@ def test_search_query_still_works(client, auth, monkeypatch):
|
||||
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})
|
||||
lambda chosen, hits, quality, root, **kw: {"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"
|
||||
@@ -86,7 +86,7 @@ def test_non_direct_link_resolves_and_fetches(client, auth, monkeypatch):
|
||||
lambda url, limit: ("ODESZA - Bloom", [lid, yt]))
|
||||
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": None, "lidarr_album_id": 9})
|
||||
lambda chosen, hits, quality, root, **kw: {"path": None, "lidarr_album_id": 9})
|
||||
r = client.post("/fetch", params={"q": "https://open.spotify.com/track/abc"}, headers=auth)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
|
||||
@@ -61,3 +61,84 @@ def test_run_job_callable_done_message():
|
||||
|
||||
def teardown_module():
|
||||
jobs.JOBS.clear()
|
||||
|
||||
|
||||
# --- choice jobs -----------------------------------------------------------
|
||||
def _choices_result():
|
||||
return {"artist": "Overgrown", "album": "Lay Cold",
|
||||
"choices": [{"n": 1, "label": "FLAC"}, {"n": 2, "label": "MP3"}],
|
||||
"pending": [{"guid": "a"}, {"guid": "b"}]}
|
||||
|
||||
|
||||
def test_run_choice_job_parks_the_job():
|
||||
job = jobs.create_job(hit=None, message="m")
|
||||
jobs.run_choice_job(job.id, _choices_result,
|
||||
choice_message=lambda res: "Which one?",
|
||||
done_message="done!")
|
||||
j = _wait(job.id, "awaiting_choice")
|
||||
assert j.message == "Which one?"
|
||||
assert j.choices == [{"n": 1, "label": "FLAC"}, {"n": 2, "label": "MP3"}]
|
||||
assert j.expires_at is not None
|
||||
# The raw indexer data stays server-side only.
|
||||
assert j.pending == [{"guid": "a"}, {"guid": "b"}]
|
||||
assert "pending" not in j.result
|
||||
assert "choices" not in j.result
|
||||
|
||||
|
||||
def test_run_choice_job_finishes_when_there_is_nothing_to_choose():
|
||||
job = jobs.create_job(hit=None, message="m")
|
||||
jobs.run_choice_job(job.id, lambda: {"outcome": "no_release", "choices": [], "pending": []},
|
||||
choice_message=lambda res: "never asked",
|
||||
done_message=lambda res: f"nothing: {res['outcome']}")
|
||||
j = _wait(job.id, "done")
|
||||
assert j.message == "nothing: no_release"
|
||||
assert j.choices is None
|
||||
|
||||
|
||||
def test_run_choice_job_records_a_failure_with_the_real_reason():
|
||||
def boom():
|
||||
raise RuntimeError("Lidarr said no")
|
||||
job = jobs.create_job(hit=None, message="m")
|
||||
jobs.run_choice_job(job.id, boom, choice_message=lambda res: "x",
|
||||
done_message="done!", fail_message=lambda e: str(e))
|
||||
j = _wait(job.id, "failed")
|
||||
assert j.message == "Lidarr said no"
|
||||
assert "RuntimeError" in j.error
|
||||
|
||||
|
||||
def test_take_choice_can_only_be_used_once():
|
||||
job = jobs.create_job(hit=None, message="m")
|
||||
jobs.run_choice_job(job.id, _choices_result, choice_message=lambda res: "?",
|
||||
done_message="done!")
|
||||
_wait(job.id, "awaiting_choice")
|
||||
_, item = jobs.take_choice(job.id, 2)
|
||||
assert item == {"guid": "b"}
|
||||
assert jobs.get_job(job.id).status == "queued"
|
||||
try:
|
||||
jobs.take_choice(job.id, 1)
|
||||
except jobs.ChoiceError as e:
|
||||
assert "not waiting for a choice" in str(e)
|
||||
else:
|
||||
raise AssertionError("a second take_choice must be refused")
|
||||
|
||||
|
||||
def test_take_choice_rejects_a_number_out_of_range():
|
||||
job = jobs.create_job(hit=None, message="m")
|
||||
jobs.run_choice_job(job.id, _choices_result, choice_message=lambda res: "?",
|
||||
done_message="done!")
|
||||
_wait(job.id, "awaiting_choice")
|
||||
try:
|
||||
jobs.take_choice(job.id, 3)
|
||||
except jobs.ChoiceError as e:
|
||||
assert "between 1 and 2" in str(e)
|
||||
else:
|
||||
raise AssertionError("out-of-range must be refused")
|
||||
|
||||
|
||||
def test_take_choice_on_an_unknown_job_raises_key_error():
|
||||
try:
|
||||
jobs.take_choice("nope", 1)
|
||||
except KeyError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("unknown job must raise KeyError")
|
||||
|
||||
250
tests/test_lidarr_choose.py
Normal file
250
tests/test_lidarr_choose.py
Normal file
@@ -0,0 +1,250 @@
|
||||
"""The two-step Siri flow on the single entry point: free text at POST /fetch
|
||||
parks a choice list, and POST /choose sends the picked release to the download
|
||||
client. A URL grabs directly instead — see test_api_url.py."""
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from server import actions, jobs as jobs_mod, mf
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_jobs():
|
||||
jobs_mod.JOBS.clear()
|
||||
yield
|
||||
jobs_mod.JOBS.clear()
|
||||
|
||||
|
||||
def _wait(client, auth, job_id, status, timeout=3.0):
|
||||
end = time.time() + timeout
|
||||
while time.time() < end:
|
||||
body = client.get(f"/jobs/{job_id}", headers=auth).json()
|
||||
if body["status"] == status:
|
||||
return body
|
||||
time.sleep(0.01)
|
||||
raise AssertionError(f"job {job_id} never reached {status}")
|
||||
|
||||
|
||||
def _release(guid="g1", seeders=24, quality="FLAC"):
|
||||
return {"guid": guid, "indexerId": 2, "title": f"Overgrown-Lay_Cold-{guid}",
|
||||
"size": 312 * 1024 * 1024, "seeders": seeders, "indexer": "Redacted",
|
||||
"quality": {"quality": {"name": quality}}}
|
||||
|
||||
|
||||
def _album_hit():
|
||||
return mf.Hit(source="lidarr", kind="album", title="Lay Cold", album="Lay Cold",
|
||||
artist="Overgrown",
|
||||
payload={"album": {"title": "Lay Cold",
|
||||
"artist": {"artistName": "Overgrown"}}})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def lidarr(monkeypatch):
|
||||
"""A Lidarr that already holds Overgrown and offers two releases."""
|
||||
state = {"grabbed": [], "releases": [_release("a", 90), _release("b", 10, "MP3-320")],
|
||||
"hits": [_album_hit()]}
|
||||
monkeypatch.setattr("server.app.mf.build_combined_hits",
|
||||
lambda q, limit, yt_first, lidarr_only, yt_only: state["hits"])
|
||||
monkeypatch.setattr(actions.mf, "ensure_album", lambda album, root, search_all, dry_run: {
|
||||
"album": {"id": 5}, "album_id": 5, "artist_name": "Overgrown",
|
||||
"album_title": "Lay Cold", "artist_state": "existing", "album_state": "existing"})
|
||||
monkeypatch.setattr(actions.mf, "list_releases", lambda album_id, **kw: state["releases"])
|
||||
monkeypatch.setattr(actions.mf, "grab_release",
|
||||
lambda rel, dry_run=False: state["grabbed"].append(rel["guid"]) or True)
|
||||
return state
|
||||
|
||||
|
||||
# --- find ------------------------------------------------------------------
|
||||
def test_find_returns_a_job_immediately(client, auth, lidarr):
|
||||
r = client.post("/fetch", params={"q": "Overgrown - Lay Cold"}, headers=auth)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["status"] == "queued"
|
||||
assert body["job_id"]
|
||||
assert "Searching indexers now." in body["message"]
|
||||
assert body["hit"]["album"] == "Lay Cold"
|
||||
assert "choices" not in body
|
||||
|
||||
|
||||
def test_find_parks_the_job_with_a_choice_list(client, auth, lidarr):
|
||||
job_id = client.post("/fetch", params={"q": "Overgrown - Lay Cold"},
|
||||
headers=auth).json()["job_id"]
|
||||
body = _wait(client, auth, job_id, "awaiting_choice")
|
||||
assert "Which one?" in body["message"]
|
||||
labels = [c["label"] for c in body["choices"]]
|
||||
assert labels == ["FLAC, 312 MB, 90 seeders, Redacted",
|
||||
"MP3-320, 312 MB, 10 seeders, Redacted"]
|
||||
assert [c["n"] for c in body["choices"]] == [1, 2]
|
||||
# Shortcuts can only "Choose from List" plain strings, so each choice also
|
||||
# carries a numbered label the client can read the number back off.
|
||||
assert [c["pick_label"] for c in body["choices"]] == [
|
||||
"1. FLAC, 312 MB, 90 seeders, Redacted",
|
||||
"2. MP3-320, 312 MB, 10 seeders, Redacted"]
|
||||
assert lidarr["grabbed"] == [] # nothing sent until the user picks
|
||||
|
||||
|
||||
def test_find_choices_never_leak_the_raw_guid(client, auth, lidarr):
|
||||
job_id = client.post("/fetch", params={"q": "x"}, headers=auth).json()["job_id"]
|
||||
body = _wait(client, auth, job_id, "awaiting_choice")
|
||||
assert all("guid" not in c for c in body["choices"])
|
||||
assert "guid" not in str(body["result"])
|
||||
assert "pending" not in body["result"]
|
||||
|
||||
|
||||
def test_find_caps_the_list(client, auth, lidarr, monkeypatch):
|
||||
lidarr["releases"] = [_release(f"g{i}") for i in range(30)]
|
||||
monkeypatch.setattr(actions, "MAX_CHOICES", 5)
|
||||
job_id = client.post("/fetch", params={"q": "x"}, headers=auth).json()["job_id"]
|
||||
body = _wait(client, auth, job_id, "awaiting_choice")
|
||||
assert len(body["choices"]) == 5
|
||||
|
||||
|
||||
def test_find_auto_grabs_the_best_release_without_asking(client, auth, lidarr):
|
||||
job_id = client.post("/fetch", params={"q": "x", "auto": "true"},
|
||||
headers=auth).json()["job_id"]
|
||||
body = _wait(client, auth, job_id, "done")
|
||||
assert lidarr["grabbed"] == ["a"]
|
||||
assert "download client" in body["message"]
|
||||
|
||||
|
||||
def test_find_reports_no_release_without_pretending_to_download(client, auth, lidarr):
|
||||
lidarr["releases"] = [] # Lidarr-only: no YouTube hit to fall back to
|
||||
job_id = client.post("/fetch", params={"q": "x"}, headers=auth).json()["job_id"]
|
||||
body = _wait(client, auth, job_id, "done")
|
||||
assert "no indexer has a release" in body["message"]
|
||||
|
||||
|
||||
def test_a_url_grabs_directly_and_never_asks(client, auth, lidarr, monkeypatch):
|
||||
""""If a URL, grab directly" — the choice list is for free text only."""
|
||||
monkeypatch.setattr("server.app.mf._is_direct_url", lambda url: False)
|
||||
monkeypatch.setattr("server.app.mf.resolve_link_hits",
|
||||
lambda url, limit: ("Overgrown - Lay Cold", lidarr["hits"]))
|
||||
job_id = client.post("/fetch", params={"q": "https://open.spotify.com/track/abc"},
|
||||
headers=auth).json()["job_id"]
|
||||
body = _wait(client, auth, job_id, "done")
|
||||
assert lidarr["grabbed"] == ["a"]
|
||||
assert "download client" in body["message"]
|
||||
|
||||
|
||||
def test_youtube_source_skips_lidarr(client, auth, lidarr, monkeypatch):
|
||||
yt_hit = mf.Hit(source="youtube", kind="track", title="Lay Cold", artist="Overgrown",
|
||||
album="Lay Cold", payload={"videoId": "abc"})
|
||||
lidarr["hits"] = [yt_hit]
|
||||
monkeypatch.setattr(actions.mf, "act_youtube", lambda *a, **kw: True)
|
||||
job_id = client.post("/fetch", params={"q": "x", "source": "youtube"},
|
||||
headers=auth).json()["job_id"]
|
||||
body = _wait(client, auth, job_id, "done")
|
||||
assert lidarr["grabbed"] == []
|
||||
assert body["message"] == "Finished downloading 'Lay Cold' by Overgrown."
|
||||
|
||||
|
||||
def test_find_adds_a_missing_artist_only(client, auth, lidarr, monkeypatch):
|
||||
added = []
|
||||
lidarr["hits"] = [mf.Hit(source="lidarr", kind="artist", title="Overgrown",
|
||||
artist="Overgrown",
|
||||
payload={"artist": {"artistName": "Overgrown"}})]
|
||||
monkeypatch.setattr(actions.mf, "ensure_artist", lambda meta, root, search_all, dry_run:
|
||||
(added.append(meta["artistName"]),
|
||||
{"artist": {"id": 9}, "state": "added", "name": "Overgrown"})[1])
|
||||
job_id = client.post("/fetch", params={"q": "Overgrown"}, headers=auth).json()["job_id"]
|
||||
body = _wait(client, auth, job_id, "done")
|
||||
assert added == ["Overgrown"]
|
||||
assert "Added Overgrown to Lidarr" in body["message"]
|
||||
assert lidarr["grabbed"] == []
|
||||
|
||||
|
||||
def test_no_hits_is_a_404_not_a_job(client, auth, lidarr):
|
||||
lidarr["hits"] = []
|
||||
r = client.post("/fetch", params={"q": "zzz"}, headers=auth)
|
||||
assert r.status_code == 404
|
||||
assert "zzz" in r.json()["message"]
|
||||
|
||||
|
||||
def test_failure_message_explains_why(client, auth, lidarr, monkeypatch):
|
||||
monkeypatch.setattr(actions.mf, "ensure_album", lambda album, root, search_all, dry_run: {
|
||||
"album": None, "album_id": None, "artist_name": "Overgrown",
|
||||
"album_title": "Lay Cold", "artist_state": "existing", "album_state": "missing"})
|
||||
job_id = client.post("/fetch", params={"q": "x"}, headers=auth).json()["job_id"]
|
||||
body = _wait(client, auth, job_id, "failed")
|
||||
assert "no album called 'Lay Cold'" in body["message"]
|
||||
|
||||
|
||||
def test_find_needs_the_api_key(client, lidarr):
|
||||
assert client.post("/fetch", params={"q": "x"}).status_code == 401
|
||||
|
||||
|
||||
# --- choose ----------------------------------------------------------------
|
||||
def _parked(client, auth, lidarr):
|
||||
job_id = client.post("/fetch", params={"q": "x"}, headers=auth).json()["job_id"]
|
||||
_wait(client, auth, job_id, "awaiting_choice")
|
||||
return job_id
|
||||
|
||||
|
||||
def test_choose_sends_the_picked_release(client, auth, lidarr):
|
||||
job_id = _parked(client, auth, lidarr)
|
||||
r = client.post("/choose", params={"job_id": job_id, "n": 2}, headers=auth)
|
||||
assert r.status_code == 200
|
||||
assert "Sending 'Lay Cold' by Overgrown" in r.json()["message"]
|
||||
body = _wait(client, auth, job_id, "done")
|
||||
assert lidarr["grabbed"] == ["b"]
|
||||
assert body["message"] == ("Sent 'Lay Cold' by Overgrown to the download client. "
|
||||
"MP3-320, 312 MB, 10 seeders, Redacted.")
|
||||
assert "choices" not in body
|
||||
|
||||
|
||||
def test_choose_twice_is_refused(client, auth, lidarr):
|
||||
job_id = _parked(client, auth, lidarr)
|
||||
client.post("/choose", params={"job_id": job_id, "n": 1}, headers=auth)
|
||||
_wait(client, auth, job_id, "done")
|
||||
r = client.post("/choose", params={"job_id": job_id, "n": 1}, headers=auth)
|
||||
assert r.status_code == 409
|
||||
assert lidarr["grabbed"] == ["a"]
|
||||
|
||||
|
||||
def test_choose_out_of_range_is_refused(client, auth, lidarr):
|
||||
job_id = _parked(client, auth, lidarr)
|
||||
r = client.post("/choose", params={"job_id": job_id, "n": 9}, headers=auth)
|
||||
assert r.status_code == 409
|
||||
assert "between 1 and 2" in r.json()["message"]
|
||||
assert lidarr["grabbed"] == []
|
||||
|
||||
|
||||
def test_choose_unknown_job_is_404(client, auth):
|
||||
r = client.post("/choose", params={"job_id": "nope", "n": 1}, headers=auth)
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_the_old_split_endpoints_are_gone(client, auth):
|
||||
assert client.post("/lidarr/find", params={"q": "x"}, headers=auth).status_code == 404
|
||||
assert client.post("/lidarr/choose", params={"job_id": "x", "n": 1},
|
||||
headers=auth).status_code == 404
|
||||
|
||||
|
||||
def test_choose_zero_is_rejected_by_validation(client, auth, lidarr):
|
||||
job_id = _parked(client, auth, lidarr)
|
||||
assert client.post("/choose", params={"job_id": job_id, "n": 0},
|
||||
headers=auth).status_code == 422
|
||||
|
||||
|
||||
def test_choose_needs_the_api_key(client, auth, lidarr):
|
||||
job_id = _parked(client, auth, lidarr)
|
||||
assert client.post("/choose", params={"job_id": job_id, "n": 1}).status_code == 401
|
||||
|
||||
|
||||
def test_a_parked_job_expires(client, auth, lidarr, monkeypatch):
|
||||
job_id = _parked(client, auth, lidarr)
|
||||
jobs_mod.JOBS[job_id].expires_at = time.time() - 1
|
||||
body = client.get(f"/jobs/{job_id}", headers=auth).json()
|
||||
assert body["status"] == "expired"
|
||||
assert "expired" in body["message"]
|
||||
r = client.post("/choose", params={"job_id": job_id, "n": 1}, headers=auth)
|
||||
assert r.status_code == 409
|
||||
assert lidarr["grabbed"] == []
|
||||
|
||||
|
||||
def test_choose_reports_a_lidarr_refusal(client, auth, lidarr, monkeypatch):
|
||||
job_id = _parked(client, auth, lidarr)
|
||||
monkeypatch.setattr(actions.mf, "grab_release", lambda rel, dry_run=False: False)
|
||||
client.post("/choose", params={"job_id": job_id, "n": 1}, headers=auth)
|
||||
body = _wait(client, auth, job_id, "failed")
|
||||
assert "refused the release" in body["message"]
|
||||
268
tests/test_lidarr_library.py
Normal file
268
tests/test_lidarr_library.py
Normal file
@@ -0,0 +1,268 @@
|
||||
"""Library-aware Lidarr behaviour: never add a duplicate artist, never grab a
|
||||
random album, and describe a release in words a person understands."""
|
||||
import server.mf # noqa: F401 — loads musicfetch, registers musicfetch_core
|
||||
import musicfetch_core as mf
|
||||
|
||||
|
||||
JACKO = {"id": 3, "artistName": "Michael Jackson", "foreignArtistId": "mj-mbid"}
|
||||
|
||||
|
||||
def _no_add(*_a, **_kw):
|
||||
raise AssertionError("add_artist must not be called for an existing artist")
|
||||
|
||||
|
||||
# --- find_library_artist / ensure_artist -----------------------------------
|
||||
def test_find_library_artist_matches_name_case_insensitively(monkeypatch):
|
||||
monkeypatch.setattr(mf, "library_artists", lambda: [JACKO])
|
||||
assert mf.find_library_artist(name="michael jackson") == JACKO
|
||||
|
||||
|
||||
def test_find_library_artist_matches_mbid_when_name_differs(monkeypatch):
|
||||
monkeypatch.setattr(mf, "library_artists", lambda: [JACKO])
|
||||
assert mf.find_library_artist(name="Jacko", foreign_id="mj-mbid") == JACKO
|
||||
|
||||
|
||||
def test_find_library_artist_returns_none_when_absent(monkeypatch):
|
||||
monkeypatch.setattr(mf, "library_artists", lambda: [JACKO])
|
||||
assert mf.find_library_artist(name="Overgrown") is None
|
||||
|
||||
|
||||
def test_ensure_artist_never_adds_a_duplicate(monkeypatch):
|
||||
monkeypatch.setattr(mf, "library_artists", lambda: [JACKO])
|
||||
monkeypatch.setattr(mf, "add_artist", _no_add)
|
||||
res = mf.ensure_artist({"artistName": "Michael Jackson"}, "/media/music", False, False)
|
||||
assert res["state"] == "existing"
|
||||
assert res["artist"] == JACKO
|
||||
assert res["name"] == "Michael Jackson"
|
||||
|
||||
|
||||
def test_ensure_artist_adds_when_missing(monkeypatch):
|
||||
monkeypatch.setattr(mf, "library_artists", lambda: [])
|
||||
monkeypatch.setattr(mf, "add_artist",
|
||||
lambda meta, root, search_all, dry_run: {"id": 9, "artistName": "Overgrown"})
|
||||
res = mf.ensure_artist({"artistName": "Overgrown", "foreignArtistId": "og"},
|
||||
"/media/music", False, False)
|
||||
assert res["state"] == "added"
|
||||
assert res["artist"]["id"] == 9
|
||||
|
||||
|
||||
def test_ensure_artist_reports_failure(monkeypatch):
|
||||
monkeypatch.setattr(mf, "library_artists", lambda: [])
|
||||
monkeypatch.setattr(mf, "add_artist", lambda *a, **kw: None)
|
||||
res = mf.ensure_artist({"artistName": "Overgrown"}, "/media/music", False, False)
|
||||
assert res["state"] == "failed"
|
||||
assert res["artist"] is None
|
||||
|
||||
|
||||
def test_act_lidarr_artist_does_not_add_existing(monkeypatch, capsys):
|
||||
monkeypatch.setattr(mf, "library_artists", lambda: [JACKO])
|
||||
monkeypatch.setattr(mf, "add_artist", _no_add)
|
||||
hit = mf.Hit(source="lidarr", kind="artist", title="Michael Jackson",
|
||||
artist="Michael Jackson", payload={"artist": {"artistName": "Michael Jackson"}})
|
||||
assert mf.act_lidarr_artist(hit, "/media/music", False, False) is True
|
||||
assert "already in Lidarr" in capsys.readouterr().out
|
||||
|
||||
|
||||
# --- album matching --------------------------------------------------------
|
||||
LIB_ALBUMS = [
|
||||
{"id": 11, "title": "Off the Wall", "foreignAlbumId": "otw"},
|
||||
{"id": 12, "title": "Thriller (Deluxe Edition)", "foreignAlbumId": "thr-deluxe"},
|
||||
]
|
||||
|
||||
|
||||
def test_match_album_prefers_the_musicbrainz_id():
|
||||
got = mf._match_album(LIB_ALBUMS, {"title": "Something Else", "foreignAlbumId": "otw"})
|
||||
assert got["id"] == 11
|
||||
|
||||
|
||||
def test_match_album_matches_title_ignoring_case_and_punctuation():
|
||||
got = mf._match_album(LIB_ALBUMS, {"title": "off the wall!"})
|
||||
assert got["id"] == 11
|
||||
|
||||
|
||||
def test_match_album_ignores_an_edition_suffix():
|
||||
got = mf._match_album(LIB_ALBUMS, {"title": "Thriller"})
|
||||
assert got["id"] == 12
|
||||
|
||||
|
||||
def test_match_album_returns_none_instead_of_a_random_album():
|
||||
assert mf._match_album(LIB_ALBUMS, {"title": "Bad"}) is None
|
||||
|
||||
|
||||
def test_ensure_album_reports_missing_album_rather_than_guessing(monkeypatch):
|
||||
monkeypatch.setattr(mf, "library_artists", lambda: [JACKO])
|
||||
monkeypatch.setattr(mf, "_artist_albums", lambda artist_id: LIB_ALBUMS)
|
||||
res = mf.ensure_album({"title": "Bad", "artist": {"artistName": "Michael Jackson"}},
|
||||
"/media/music", False, False)
|
||||
assert res["album"] is None
|
||||
assert res["album_state"] == "missing"
|
||||
assert res["artist_state"] == "existing"
|
||||
|
||||
|
||||
def test_ensure_album_monitors_an_unmonitored_album(monkeypatch):
|
||||
album = {"id": 11, "title": "Off the Wall", "monitored": False}
|
||||
puts = []
|
||||
monkeypatch.setattr(mf, "library_artists", lambda: [JACKO])
|
||||
monkeypatch.setattr(mf, "_artist_albums", lambda artist_id: [album])
|
||||
monkeypatch.setattr(mf, "lidarr_put", lambda path, payload, **kw: puts.append(payload) or {})
|
||||
res = mf.ensure_album({"title": "Off the Wall", "artist": {"artistName": "Michael Jackson"}},
|
||||
"/media/music", False, False)
|
||||
assert res["album_state"] == "monitored"
|
||||
assert res["album_id"] == 11
|
||||
assert puts == [{"albumIds": [11], "monitored": True}]
|
||||
|
||||
|
||||
def test_ensure_album_leaves_a_monitored_album_alone(monkeypatch):
|
||||
album = {"id": 11, "title": "Off the Wall", "monitored": True}
|
||||
monkeypatch.setattr(mf, "library_artists", lambda: [JACKO])
|
||||
monkeypatch.setattr(mf, "_artist_albums", lambda artist_id: [album])
|
||||
monkeypatch.setattr(mf, "lidarr_put", lambda *a, **kw: (_ for _ in ()).throw(
|
||||
AssertionError("must not re-monitor")))
|
||||
res = mf.ensure_album({"title": "Off the Wall", "artist": {"artistName": "Michael Jackson"}},
|
||||
"/media/music", False, False)
|
||||
assert res["album_state"] == "existing"
|
||||
|
||||
|
||||
# --- releases --------------------------------------------------------------
|
||||
def _release(**kw):
|
||||
base = {"guid": "g1", "indexerId": 2, "title": "Overgrown-Lay_Cold-WEB-2023",
|
||||
"size": 312 * 1024 * 1024, "seeders": 24, "indexer": "Redacted",
|
||||
"quality": {"quality": {"name": "FLAC"}}}
|
||||
base.update(kw)
|
||||
return base
|
||||
|
||||
|
||||
def test_release_label_is_readable():
|
||||
label = mf.release_label(_release())
|
||||
assert label == "FLAC, 312 MB, 24 seeders, Redacted"
|
||||
|
||||
|
||||
def test_release_label_uses_gigabytes_for_big_files():
|
||||
assert "2.0 GB" in mf.release_label(_release(size=2 * 1024 * 1024 * 1024))
|
||||
|
||||
|
||||
def test_release_label_flags_a_rejected_release():
|
||||
label = mf.release_label(_release(rejected=True, rejections=["Not a preferred word"]))
|
||||
assert "not recommended" in label
|
||||
assert "Not a preferred word" in label
|
||||
|
||||
|
||||
def test_release_label_falls_back_to_the_raw_title():
|
||||
assert mf.release_label({"title": "Weird.Release"}) == "Weird.Release"
|
||||
|
||||
|
||||
def test_sort_releases_puts_rejected_last_and_best_seeded_first():
|
||||
low = _release(guid="low", seeders=2)
|
||||
high = _release(guid="high", seeders=90)
|
||||
bad = _release(guid="bad", seeders=500, rejected=True)
|
||||
order = [r["guid"] for r in mf.sort_releases([low, bad, high])]
|
||||
assert order == ["high", "low", "bad"]
|
||||
|
||||
|
||||
def test_list_releases_skips_a_call_without_a_real_album_id():
|
||||
assert mf.list_releases(0) == []
|
||||
assert mf.list_releases(None) == []
|
||||
|
||||
|
||||
def test_grab_release_posts_the_exact_guid_and_indexer(monkeypatch):
|
||||
posted = {}
|
||||
monkeypatch.setattr(mf, "lidarr_post",
|
||||
lambda path, payload, **kw: posted.update(path=path, payload=payload) or {})
|
||||
assert mf.grab_release(_release()) is True
|
||||
assert posted["path"] == "/api/v1/release"
|
||||
assert posted["payload"] == {"guid": "g1", "indexerId": 2}
|
||||
|
||||
|
||||
def test_grab_release_refuses_an_incomplete_release(monkeypatch):
|
||||
monkeypatch.setattr(mf, "lidarr_post", lambda *a, **kw: (_ for _ in ()).throw(
|
||||
AssertionError("must not post")))
|
||||
assert mf.grab_release({"title": "no guid"}) is False
|
||||
|
||||
|
||||
# --- album dispatch --------------------------------------------------------
|
||||
def _album_hit():
|
||||
return mf.Hit(source="lidarr", kind="album", title="Lay Cold", album="Lay Cold",
|
||||
artist="Overgrown",
|
||||
payload={"album": {"title": "Lay Cold",
|
||||
"artist": {"artistName": "Overgrown"}}})
|
||||
|
||||
|
||||
def test_act_lidarr_album_grabs_the_best_release(monkeypatch, capsys):
|
||||
grabbed = {}
|
||||
monkeypatch.setattr(mf, "ensure_album", lambda *a, **kw: {
|
||||
"album": {"id": 5}, "album_id": 5, "artist_name": "Overgrown",
|
||||
"album_title": "Lay Cold", "artist_state": "existing", "album_state": "existing"})
|
||||
monkeypatch.setattr(mf, "list_releases", lambda album_id, **kw: [_release(guid="best")])
|
||||
monkeypatch.setattr(mf, "grab_release",
|
||||
lambda rel, dry_run=False: grabbed.update(guid=rel["guid"]) or True)
|
||||
assert mf.act_lidarr_album(_album_hit(), "/media/music", False, False) is True
|
||||
assert grabbed["guid"] == "best"
|
||||
out = capsys.readouterr().out
|
||||
assert "Overgrown is already in Lidarr." in out
|
||||
assert "download client" in out
|
||||
|
||||
|
||||
def test_act_lidarr_album_falls_through_when_no_indexer_has_it(monkeypatch, capsys):
|
||||
monkeypatch.setattr(mf, "ensure_album", lambda *a, **kw: {
|
||||
"album": {"id": 5}, "album_id": 5, "artist_name": "Overgrown",
|
||||
"album_title": "Lay Cold", "artist_state": "added", "album_state": "monitored"})
|
||||
monkeypatch.setattr(mf, "list_releases", lambda album_id, **kw: [])
|
||||
assert mf.act_lidarr_album(_album_hit(), "/media/music", False, False) is False
|
||||
out = capsys.readouterr().out
|
||||
assert "Added Overgrown to Lidarr." in out
|
||||
assert "Now monitoring" in out
|
||||
|
||||
|
||||
def test_act_lidarr_album_uses_the_picked_release(monkeypatch):
|
||||
grabbed = {}
|
||||
monkeypatch.setattr(mf, "ensure_album", lambda *a, **kw: {
|
||||
"album": {"id": 5}, "album_id": 5, "artist_name": "Overgrown",
|
||||
"album_title": "Lay Cold", "artist_state": "existing", "album_state": "existing"})
|
||||
monkeypatch.setattr(mf, "list_releases",
|
||||
lambda album_id, **kw: [_release(guid="a"), _release(guid="b")])
|
||||
monkeypatch.setattr(mf, "pick_release", lambda releases: releases[1])
|
||||
monkeypatch.setattr(mf, "grab_release",
|
||||
lambda rel, dry_run=False: grabbed.update(guid=rel["guid"]) or True)
|
||||
assert mf.act_lidarr_album(_album_hit(), "/media/music", False, False,
|
||||
choose_release=True) is True
|
||||
assert grabbed["guid"] == "b"
|
||||
|
||||
|
||||
def test_act_lidarr_album_sends_nothing_when_the_user_quits(monkeypatch):
|
||||
monkeypatch.setattr(mf, "ensure_album", lambda *a, **kw: {
|
||||
"album": {"id": 5}, "album_id": 5, "artist_name": "Overgrown",
|
||||
"album_title": "Lay Cold", "artist_state": "existing", "album_state": "existing"})
|
||||
monkeypatch.setattr(mf, "list_releases", lambda album_id, **kw: [_release()])
|
||||
monkeypatch.setattr(mf, "pick_release", lambda releases: None)
|
||||
monkeypatch.setattr(mf, "grab_release", lambda *a, **kw: (_ for _ in ()).throw(
|
||||
AssertionError("must not grab")))
|
||||
assert mf.act_lidarr_album(_album_hit(), "/media/music", False, False,
|
||||
choose_release=True) is True
|
||||
|
||||
|
||||
# --- guards ----------------------------------------------------------------
|
||||
def test_norm_title_keeps_accented_letters():
|
||||
assert mf._norm_title("Björk") == "björk"
|
||||
assert mf._norm_title("AC/DC") == "ac dc"
|
||||
|
||||
|
||||
def test_base_title_of_an_all_bracket_title_matches_nothing():
|
||||
assert mf._base_title("(Untitled)") == ""
|
||||
assert mf._match_album([{"id": 1, "title": "(Whatever)"}], {"title": "(Untitled)"}) is None
|
||||
|
||||
|
||||
def test_set_album_monitored_refuses_an_album_without_a_library_id(monkeypatch):
|
||||
monkeypatch.setattr(mf, "lidarr_put", lambda *a, **kw: (_ for _ in ()).throw(
|
||||
AssertionError("must not PUT")))
|
||||
assert mf.set_album_monitored({"title": "Lay Cold"}, False) is False
|
||||
|
||||
|
||||
def test_ensure_album_stops_when_lidarr_gives_no_artist_id(monkeypatch):
|
||||
monkeypatch.setattr(mf, "library_artists", lambda: [])
|
||||
monkeypatch.setattr(mf, "add_artist", lambda *a, **kw: {"artistName": "Overgrown"})
|
||||
monkeypatch.setattr(mf, "_artist_albums", lambda artist_id: (_ for _ in ()).throw(
|
||||
AssertionError("must not list every album")))
|
||||
res = mf.ensure_album({"title": "Lay Cold", "artist": {"artistName": "Overgrown"}},
|
||||
"/media/music", False, False)
|
||||
assert res["album"] is None
|
||||
assert res["album_state"] == "missing"
|
||||
120
tests/test_lidarr_messages.py
Normal file
120
tests/test_lidarr_messages.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""The spoken message must match what the server really did."""
|
||||
from server import actions, mf
|
||||
|
||||
|
||||
def _artist_hit(name="Michael Jackson"):
|
||||
return mf.Hit(source="lidarr", kind="artist", title=name, artist=name,
|
||||
payload={"artist": {"artistName": name}})
|
||||
|
||||
|
||||
def _album_hit():
|
||||
return mf.Hit(source="lidarr", kind="album", title="Lay Cold", album="Lay Cold",
|
||||
artist="Overgrown", payload={"album": {"title": "Lay Cold"}})
|
||||
|
||||
|
||||
def _yt_hit():
|
||||
return mf.Hit(source="youtube", kind="track", title="Lay Cold", artist="Overgrown",
|
||||
album="Lay Cold", payload={"videoId": "abc"})
|
||||
|
||||
|
||||
def test_artist_message_never_says_x_by_x():
|
||||
"""The old bug: "Finished downloading 'Michael Jackson' by Michael Jackson."."""
|
||||
hit = _artist_hit()
|
||||
for msg in (actions.started_message(hit),
|
||||
actions.done_message(hit, {"outcome": "artist_added"}),
|
||||
actions.done_message(hit, {"outcome": "artist_existing"}),
|
||||
actions.failed_message(hit)):
|
||||
assert "'Michael Jackson' by Michael Jackson" not in msg
|
||||
|
||||
|
||||
def test_artist_added_message_does_not_claim_a_download():
|
||||
msg = actions.done_message(_artist_hit(), {"outcome": "artist_added"})
|
||||
assert "Added Michael Jackson to Lidarr" in msg
|
||||
assert "Nothing was downloaded" in msg
|
||||
assert "Finished downloading" not in msg
|
||||
|
||||
|
||||
def test_artist_existing_message_says_nothing_was_added():
|
||||
msg = actions.done_message(_artist_hit(), {"outcome": "artist_existing"})
|
||||
assert "already in Lidarr" in msg
|
||||
assert "Nothing was added" in msg
|
||||
|
||||
|
||||
def test_album_grabbed_message_says_download_client():
|
||||
"""Falls back to the hit when the result does not name the album."""
|
||||
msg = actions.done_message(_album_hit(), {"outcome": "album_grabbed"})
|
||||
assert "Sent 'Lay Cold' by Overgrown to the download client." == msg
|
||||
|
||||
|
||||
def test_youtube_fallback_message_names_youtube():
|
||||
"""True whether no indexer had it or Lidarr refused the release."""
|
||||
msg = actions.done_message(_album_hit(), {"outcome": "youtube_fallback"})
|
||||
assert "Lidarr could not get 'Lay Cold' by Overgrown" in msg
|
||||
assert "YouTube Music" in msg
|
||||
|
||||
|
||||
def test_started_message_for_an_artist_does_not_say_downloading():
|
||||
assert actions.started_message(_artist_hit()) == "Looking up Michael Jackson in Lidarr."
|
||||
|
||||
|
||||
def test_started_message_for_a_lidarr_album_says_searching_indexers():
|
||||
msg = actions.started_message(_album_hit())
|
||||
assert "Searching indexers now." in msg
|
||||
|
||||
|
||||
def test_youtube_messages_are_unchanged():
|
||||
assert actions.started_message(_yt_hit()).startswith("Found 'Lay Cold' by Overgrown on YouTube Music")
|
||||
assert actions.done_message(_yt_hit()) == "Finished downloading 'Lay Cold' by Overgrown."
|
||||
|
||||
|
||||
def test_choice_message_asks_which_one():
|
||||
msg = actions.choice_message(_album_hit(), {
|
||||
"artist": "Overgrown", "album": "Lay Cold", "artist_state": "existing",
|
||||
"album_state": "existing", "choices": [{"n": 1}, {"n": 2}]})
|
||||
assert "Overgrown is already in Lidarr." in msg
|
||||
assert "I found 2 releases for 'Lay Cold' by Overgrown. Which one?" in msg
|
||||
|
||||
|
||||
def test_choice_message_mentions_a_new_artist_and_new_monitor():
|
||||
msg = actions.choice_message(_album_hit(), {
|
||||
"artist": "Overgrown", "album": "Lay Cold", "artist_state": "added",
|
||||
"album_state": "monitored", "choices": [{"n": 1}]})
|
||||
assert "Added Overgrown to Lidarr." in msg
|
||||
assert "Now monitoring 'Lay Cold'." in msg
|
||||
assert "I found 1 release for" in msg
|
||||
|
||||
|
||||
def test_choice_message_falls_back_to_the_hit():
|
||||
msg = actions.choice_message(_album_hit(), {"choices": [{"n": 1}]})
|
||||
assert "'Lay Cold' by Overgrown" in msg
|
||||
|
||||
|
||||
def test_no_release_message_is_honest():
|
||||
msg = actions.done_message(_album_hit(), {
|
||||
"outcome": "no_release", "artist": "Overgrown", "album": "Lay Cold",
|
||||
"artist_state": "added", "album_state": "monitored"})
|
||||
assert "no indexer has a release" in msg
|
||||
assert "download" not in msg.lower()
|
||||
|
||||
|
||||
def test_album_grabbed_message_names_the_release_when_known():
|
||||
msg = actions.done_message(_album_hit(), {
|
||||
"outcome": "album_grabbed", "artist": "Overgrown", "album": "Lay Cold",
|
||||
"release": "FLAC, 312 MB, 24 seeders, Redacted"})
|
||||
assert msg == ("Sent 'Lay Cold' by Overgrown to the download client. "
|
||||
"FLAC, 312 MB, 24 seeders, Redacted.")
|
||||
|
||||
|
||||
def test_fail_text_speaks_a_fetch_error_and_hides_anything_else():
|
||||
hit = _album_hit()
|
||||
assert actions.fail_text(hit, actions.FetchError("Overgrown is in Lidarr, but ...")) \
|
||||
== "Overgrown is in Lidarr, but ..."
|
||||
assert actions.fail_text(hit, OSError("[Errno 2] No such file: 'yt-dlp'")) \
|
||||
== "Failed to fetch 'Lay Cold' by Overgrown."
|
||||
|
||||
|
||||
def test_grab_message_names_the_release():
|
||||
msg = actions.grab_message({"artist": "Overgrown", "album": "Lay Cold",
|
||||
"release": "FLAC, 312 MB, 24 seeders, Redacted"})
|
||||
assert msg == ("Sent 'Lay Cold' by Overgrown to the download client. "
|
||||
"FLAC, 312 MB, 24 seeders, Redacted.")
|
||||
Reference in New Issue
Block a user