soulsync/tests/test_video_process_wishlist.py
BoulderBadgeDad eb8498a792 video wishlist processor: surface 'search didn't run' vs genuine no-results
'No search results' was masking the case where slskd never accepted the search
(not configured / errored / rate-limited) — that returns instantly, which is why
some show up as no-results FAST instead of after the search window.

_search_for_retry now flags started=False (+ the slskd error) when there's no
search id; the processor logs "Search didn't run for 'X' — slskd not responding?"
(warning) and tallies it separately. so the next run tells us if the fast
no-results are really slskd refusing searches vs the source genuinely being empty.
2026-06-26 10:02:49 -07:00

200 lines
8.9 KiB
Python

"""Soulseek auto-grab for the movie + episode wishlist: search → pick best accepted release
→ enqueue (the monitor finishes it). Pure pick/select/record + the handler with the slow
slskd search + enqueue injected, plus the DB queries that feed it.
"""
from __future__ import annotations
import json
import pytest
from core.automation.handlers.video_process_wishlist import (
active_download_keys,
auto_video_process_wishlist,
build_download_record,
item_key,
pick_best,
)
class _Deps:
def __init__(self):
self.progress = []
def update_progress(self, automation_id, **kw):
self.progress.append(kw)
def _cand(fn, *, accepted=True, score=10, user="u"):
return {"filename": fn, "title": fn, "username": user, "size_bytes": 1000,
"quality_label": "WEBDL-1080p", "accepted": accepted, "score": score}
# ── pure ──────────────────────────────────────────────────────────────────────
def test_pick_best_takes_first_accepted():
cands = [_cand("a", accepted=False), _cand("b", accepted=True), _cand("c", accepted=True)]
assert pick_best(cands)["filename"] == "b"
def test_pick_best_none_when_all_rejected():
assert pick_best([_cand("a", accepted=False), _cand("b", accepted=False)]) is None
assert pick_best([]) is None
def test_item_key_movie_and_episode():
assert item_key({"tmdb_id": 5}, "movie") == ("movie", "5")
assert item_key({"show_tmdb_id": 9, "season_number": 1, "episode_number": 3}, "episode") \
== ("episode", "9", 1, 3)
def test_active_download_keys_reads_ctx_for_episodes():
active = [
{"kind": "movie", "media_id": "5"},
{"kind": "episode", "media_id": "9", "search_ctx": json.dumps({"season": 1, "episode": 3})},
]
keys = active_download_keys(active)
assert ("movie", "5") in keys and ("episode", "9", 1, 3) in keys
def test_build_record_movie_shape():
item = {"tmdb_id": 5, "title": "The Matrix", "year": "1999", "poster_url": "/p.jpg"}
best = _cand("Matrix.1999.1080p.mkv")
rest = [best, _cand("other.mkv")]
rec = build_download_record(item, best, rest, media_type="movie", target_dir="/movies", query="matrix 1999")
assert rec["kind"] == "movie" and rec["title"] == "The Matrix" and rec["media_id"] == "5"
assert rec["source"] == "soulseek" and rec["status"] == "downloading"
assert rec["target_dir"] == "/movies" and rec["filename"] == "Matrix.1999.1080p.mkv"
assert json.loads(rec["search_ctx"]) == {"scope": "movie", "title": "The Matrix", "year": "1999"}
assert [c["filename"] for c in json.loads(rec["candidates"])] == ["other.mkv"] # best excluded
def test_build_record_episode_shape():
item = {"show_tmdb_id": 9, "show_title": "Breaking Bad", "season_number": 1,
"episode_number": 3, "air_date": "2008-02-10"}
best = _cand("BrBa.S01E03.mkv")
rec = build_download_record(item, best, [best], media_type="episode", target_dir="/tv", query="q")
assert rec["kind"] == "episode" and rec["media_id"] == "9" and rec["year"] == "2008"
assert json.loads(rec["search_ctx"]) == {"scope": "episode", "title": "Breaking Bad",
"season": 1, "episode": 3, "year": "2008"}
# ── handler ───────────────────────────────────────────────────────────────────
def _run(items, *, active=None, root="/movies", media_type="movie", searches=None):
enq = []
seen = []
def search(item, mt):
seen.append(item_key(item, mt))
if searches is None:
return [_cand("rel-" + str(item.get("tmdb_id") or item.get("show_tmdb_id")))]
return searches.get(item_key(item, mt), [])
def enqueue(item, best, cands, mt, target):
enq.append((item_key(item, mt), best["filename"], target))
return True
deps = _Deps()
res = auto_video_process_wishlist(
{"_automation_id": "a", "max_concurrent": 2}, deps, media_type=media_type,
fetch_items=lambda mt: items, active_keys=lambda mt: set(active or set()),
target_dir=lambda mt: root, search=search, enqueue=enqueue)
return res, enq, seen, deps
def test_grabs_each_wished_movie():
items = [{"tmdb_id": 1, "title": "A", "year": "2020"}, {"tmdb_id": 2, "title": "B", "year": "2021"}]
res, enq, seen, _ = _run(items)
assert res["status"] == "completed" and res["grabbed"] == 2 and res["searched"] == 2
assert {e[0] for e in enq} == {("movie", "1"), ("movie", "2")}
assert enq[0][2] == "/movies"
def test_skips_items_already_downloading():
items = [{"tmdb_id": 1, "title": "A"}, {"tmdb_id": 2, "title": "B"}]
res, enq, seen, _ = _run(items, active={("movie", "1")})
assert {e[0] for e in enq} == {("movie", "2")} # 1 already in flight
assert ("movie", "1") not in seen # not even searched
def test_no_acceptable_release_grabs_nothing():
items = [{"tmdb_id": 1, "title": "A"}]
res, enq, _, _ = _run(items, searches={("movie", "1"): [_cand("junk", accepted=False)]})
assert res["searched"] == 1 and res["grabbed"] == 0 and enq == []
def test_breakdown_distinguishes_no_results_from_quality_rejection():
# the diagnostic: "the source had nothing" vs "had hits but quality rejected them"
items = [{"tmdb_id": 1, "title": "A"}, {"tmdb_id": 2, "title": "B"}]
res, enq, _, deps = _run(items, searches={
("movie", "1"): [], # source had nothing
("movie", "2"): [dict(_cand("junk", accepted=False), rejected="Unknown / unsupported quality")],
})
assert res["grabbed"] == 0 and res["noresults"] == 1 and res["rejected"] == 1
logs = " ".join(p.get("log_line") or "" for p in deps.progress)
assert "No search results for 'A'" in logs
assert "none accepted — Unknown / unsupported quality" in logs # reason surfaced
def test_breakdown_flags_searches_that_didnt_run():
# a search that never ran (slskd didn't accept it) is NOT a genuine "no results"
items = [{"tmdb_id": 1, "title": "A"}]
res, enq, _, deps = _run(items, searches={("movie", "1"): None})
assert res["grabbed"] == 0 and res["notrun"] == 1 and res["noresults"] == 0
logs = " ".join(p.get("log_line") or "" for p in deps.progress)
assert "Search didn't run for 'A'" in logs and "slskd" in logs
def test_missing_library_folder_is_a_quiet_skip():
res, enq, _, deps = _run([{"tmdb_id": 1, "title": "A"}], root="")
assert res["status"] == "completed" and res.get("skipped") == "no_folder"
assert enq == [] and not any(p.get("status") == "error" for p in deps.progress)
def test_nothing_wished_is_a_clean_noop():
res, enq, _, _ = _run([])
assert res["status"] == "completed" and res["grabbed"] == 0 and enq == []
def test_episode_mode_keys_and_grabs():
items = [{"show_tmdb_id": 9, "show_title": "BrBa", "season_number": 1, "episode_number": 3}]
res, enq, seen, _ = _run(items, root="/tv", media_type="episode")
assert res["grabbed"] == 1 and enq[0][0] == ("episode", "9", 1, 3) and enq[0][2] == "/tv"
def test_top_level_error_is_caught_and_clears_guard():
from core.automation.handlers.video_process_wishlist import is_running
def boom(mt):
raise RuntimeError("db down")
res = auto_video_process_wishlist({"_automation_id": "x"}, _Deps(), media_type="movie",
target_dir=lambda mt: "/m", fetch_items=boom)
assert res["status"] == "error" and "db down" in res["error"]
assert is_running("movie") is False # guard released even on error
# ── DB queries ────────────────────────────────────────────────────────────────
from database.video_database import VideoDatabase # noqa: E402
@pytest.fixture()
def db(tmp_path):
return VideoDatabase(database_path=str(tmp_path / "video_library.db"))
def test_movie_wishlist_to_download_only_wanted(db):
db.add_movie_to_wishlist(1, "Released", year="2020", status="wanted")
db.add_movie_to_wishlist(2, "Upcoming", year="2027", status="monitored")
rows = db.movie_wishlist_to_download()
assert [r["tmdb_id"] for r in rows] == [1] # monitored is skipped
def test_episode_wishlist_to_download_shape(db):
db.add_episodes_to_wishlist(9, "Breaking Bad", [
{"season_number": 1, "episode_number": 1, "air_date": "2008-01-20"},
{"season_number": 1, "episode_number": 2, "air_date": "2008-01-27"}])
rows = db.episode_wishlist_to_download()
assert len(rows) == 2
top = rows[0]
assert top["show_tmdb_id"] == 9 and top["show_title"] == "Breaking Bad"
assert top["season_number"] == 1 and top["episode_number"] == 2 # newest air date first