From eb8498a792dec9f69ea76efa42e4ad6f6f44cb4e Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Fri, 26 Jun 2026 10:02:49 -0700 Subject: [PATCH] video wishlist processor: surface 'search didn't run' vs genuine no-results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit '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. --- .../handlers/video_process_wishlist.py | 24 ++++++++++++++----- core/video/download_monitor.py | 4 +++- tests/test_video_process_wishlist.py | 9 +++++++ tests/test_video_retry.py | 3 ++- 4 files changed, 32 insertions(+), 8 deletions(-) diff --git a/core/automation/handlers/video_process_wishlist.py b/core/automation/handlers/video_process_wishlist.py index b2f25e46..5abc43df 100644 --- a/core/automation/handlers/video_process_wishlist.py +++ b/core/automation/handlers/video_process_wishlist.py @@ -119,9 +119,10 @@ def _default_target_dir(media_type: str) -> str: return db.get_setting("tv_path") or "" -def _default_search(item: Dict[str, Any], media_type: str) -> List[Dict[str, Any]]: +def _default_search(item: Dict[str, Any], media_type: str): """A bounded blocking Soulseek search → ranked candidates (same path the retry worker + - manual search use). Returns [] if slskd isn't configured / nothing came back.""" + manual search use). Returns [] for a real empty result, or **None** if the search never + ran (slskd not configured / errored / rate-limited) so the caller can say so.""" from api.video.downloads import _evaluate_hits from core.video.download_monitor import _search_for_retry from core.video.quality_profile import load as load_profile @@ -131,6 +132,8 @@ def _default_search(item: Dict[str, Any], media_type: str) -> List[Dict[str, Any query = build_query(ctx["scope"], ctx["title"], year=ctx.get("year"), season=ctx.get("season"), episode=ctx.get("episode")) res = _search_for_retry(query) or {} + if res.get("started") is False: + return None # slskd didn't accept the search profile = load_profile(get_video_db()) return _evaluate_hits(res.get("hits") or [], profile, ctx["scope"], ctx.get("season"), ctx.get("episode")) @@ -215,21 +218,25 @@ def auto_video_process_wishlist( searched = [0] noresults = [0] # search came back empty (the source had nothing) rejected = [0] # source had hits, but none passed the quality profile + notrun = [0] # the search never ran (slskd didn't accept it) total = len(todo) lock = threading.Lock() def _one(it): - cands = search(it, media_type) or [] + cands = search(it, media_type) + didnt_run = cands is None # slskd not configured / errored / rate-limited + cands = cands or [] best = pick_best(cands) ok = bool(best) and bool(enqueue(it, best, cands, media_type, root)) name = it.get('title') or it.get('show_title') or '?' if media_type == 'episode': name = "%s S%02dE%02d" % (name, int(it.get('season_number') or 0), int(it.get('episode_number') or 0)) - # distinguish "source returned nothing" from "returned hits but all rejected" - # (the rejected case carries the reason on the top-ranked hit) so the log is useful. + # tell apart: grabbed / search-didn't-run / source-empty / hits-but-all-rejected. if ok: msg, lt = "Grabbed '%s'" % name, 'success' + elif didnt_run: + msg, lt = "Search didn't run for '%s' — slskd not responding?" % name, 'warning' elif not cands: msg, lt = "No search results for '%s'" % name, 'info' else: @@ -239,6 +246,8 @@ def auto_video_process_wishlist( searched[0] += 1 if ok: grabbed[0] += 1 + elif didnt_run: + notrun[0] += 1 elif not cands: noresults[0] += 1 else: @@ -254,6 +263,8 @@ def auto_video_process_wishlist( # Headline with the WHY breakdown: it's the difference between "the source has # nothing" (noresults) and "it has stuff but your quality profile rejects it" (rejected). tail = [] + if notrun[0]: + tail.append("%d search(es) didn't run (slskd?)" % notrun[0]) if noresults[0]: tail.append('%d had no results' % noresults[0]) if rejected[0]: @@ -264,7 +275,8 @@ def auto_video_process_wishlist( deps.update_progress(automation_id, status='finished', progress=100, phase='Complete', log_line=done, log_type='success' if grabbed[0] else 'info') return {'status': 'completed', 'searched': searched[0], 'grabbed': grabbed[0], - 'noresults': noresults[0], 'rejected': rejected[0], '_manages_own_progress': True} + 'noresults': noresults[0], 'rejected': rejected[0], 'notrun': notrun[0], + '_manages_own_progress': True} except Exception as e: # noqa: BLE001 deps.update_progress(automation_id, status='error', phase='Error', log_line=str(e), log_type='error') return {'status': 'error', 'error': str(e), '_manages_own_progress': True} diff --git a/core/video/download_monitor.py b/core/video/download_monitor.py index a3ed2ece..d092bbb4 100644 --- a/core/video/download_monitor.py +++ b/core/video/download_monitor.py @@ -276,7 +276,9 @@ def _search_for_retry(query, max_seconds=55): res = start_search(query) sid = res.get("id") if not sid: - return {"hits": [], "total_files": 0} + # slskd didn't accept the search (not configured, errored, or rate-limited). Surface + # it as 'not started' so the caller doesn't report it as a genuine "no results". + return {"hits": [], "total_files": 0, "started": False, "error": res.get("error")} deadline = time.monotonic() + max_seconds last = {"hits": [], "total_files": 0} prev, settle = 0, 0 # track hit growth; settle = consecutive no-growth polls (~1.5s each) diff --git a/tests/test_video_process_wishlist.py b/tests/test_video_process_wishlist.py index a08ad054..b5b63076 100644 --- a/tests/test_video_process_wishlist.py +++ b/tests/test_video_process_wishlist.py @@ -136,6 +136,15 @@ def test_breakdown_distinguishes_no_results_from_quality_rejection(): 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" diff --git a/tests/test_video_retry.py b/tests/test_video_retry.py index 6cad2f07..3032ddfc 100644 --- a/tests/test_video_retry.py +++ b/tests/test_video_retry.py @@ -90,5 +90,6 @@ def test_search_for_retry_no_id_skips_stop(monkeypatch): stopped = [] monkeypatch.setattr(ss, "start_search", lambda q: {"configured": True, "error": "boom"}) monkeypatch.setattr(ss, "stop_search", lambda sid: stopped.append(sid)) - assert dm._search_for_retry("x") == {"hits": [], "total_files": 0} + res = dm._search_for_retry("x") + assert res["hits"] == [] and res["started"] is False and res["error"] == "boom" assert stopped == [] # nothing started → nothing to stop