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.
This commit is contained in:
parent
8fe73ac924
commit
eb8498a792
4 changed files with 32 additions and 8 deletions
|
|
@ -119,9 +119,10 @@ def _default_target_dir(media_type: str) -> str:
|
||||||
return db.get_setting("tv_path") or ""
|
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 +
|
"""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 api.video.downloads import _evaluate_hits
|
||||||
from core.video.download_monitor import _search_for_retry
|
from core.video.download_monitor import _search_for_retry
|
||||||
from core.video.quality_profile import load as load_profile
|
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"),
|
query = build_query(ctx["scope"], ctx["title"], year=ctx.get("year"),
|
||||||
season=ctx.get("season"), episode=ctx.get("episode"))
|
season=ctx.get("season"), episode=ctx.get("episode"))
|
||||||
res = _search_for_retry(query) or {}
|
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())
|
profile = load_profile(get_video_db())
|
||||||
return _evaluate_hits(res.get("hits") or [], profile, ctx["scope"],
|
return _evaluate_hits(res.get("hits") or [], profile, ctx["scope"],
|
||||||
ctx.get("season"), ctx.get("episode"))
|
ctx.get("season"), ctx.get("episode"))
|
||||||
|
|
@ -215,21 +218,25 @@ def auto_video_process_wishlist(
|
||||||
searched = [0]
|
searched = [0]
|
||||||
noresults = [0] # search came back empty (the source had nothing)
|
noresults = [0] # search came back empty (the source had nothing)
|
||||||
rejected = [0] # source had hits, but none passed the quality profile
|
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)
|
total = len(todo)
|
||||||
lock = threading.Lock()
|
lock = threading.Lock()
|
||||||
|
|
||||||
def _one(it):
|
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)
|
best = pick_best(cands)
|
||||||
ok = bool(best) and bool(enqueue(it, best, cands, media_type, root))
|
ok = bool(best) and bool(enqueue(it, best, cands, media_type, root))
|
||||||
name = it.get('title') or it.get('show_title') or '?'
|
name = it.get('title') or it.get('show_title') or '?'
|
||||||
if media_type == 'episode':
|
if media_type == 'episode':
|
||||||
name = "%s S%02dE%02d" % (name, int(it.get('season_number') or 0),
|
name = "%s S%02dE%02d" % (name, int(it.get('season_number') or 0),
|
||||||
int(it.get('episode_number') or 0))
|
int(it.get('episode_number') or 0))
|
||||||
# distinguish "source returned nothing" from "returned hits but all rejected"
|
# tell apart: grabbed / search-didn't-run / source-empty / hits-but-all-rejected.
|
||||||
# (the rejected case carries the reason on the top-ranked hit) so the log is useful.
|
|
||||||
if ok:
|
if ok:
|
||||||
msg, lt = "Grabbed '%s'" % name, 'success'
|
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:
|
elif not cands:
|
||||||
msg, lt = "No search results for '%s'" % name, 'info'
|
msg, lt = "No search results for '%s'" % name, 'info'
|
||||||
else:
|
else:
|
||||||
|
|
@ -239,6 +246,8 @@ def auto_video_process_wishlist(
|
||||||
searched[0] += 1
|
searched[0] += 1
|
||||||
if ok:
|
if ok:
|
||||||
grabbed[0] += 1
|
grabbed[0] += 1
|
||||||
|
elif didnt_run:
|
||||||
|
notrun[0] += 1
|
||||||
elif not cands:
|
elif not cands:
|
||||||
noresults[0] += 1
|
noresults[0] += 1
|
||||||
else:
|
else:
|
||||||
|
|
@ -254,6 +263,8 @@ def auto_video_process_wishlist(
|
||||||
# Headline with the WHY breakdown: it's the difference between "the source has
|
# 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).
|
# nothing" (noresults) and "it has stuff but your quality profile rejects it" (rejected).
|
||||||
tail = []
|
tail = []
|
||||||
|
if notrun[0]:
|
||||||
|
tail.append("%d search(es) didn't run (slskd?)" % notrun[0])
|
||||||
if noresults[0]:
|
if noresults[0]:
|
||||||
tail.append('%d had no results' % noresults[0])
|
tail.append('%d had no results' % noresults[0])
|
||||||
if rejected[0]:
|
if rejected[0]:
|
||||||
|
|
@ -264,7 +275,8 @@ def auto_video_process_wishlist(
|
||||||
deps.update_progress(automation_id, status='finished', progress=100, phase='Complete',
|
deps.update_progress(automation_id, status='finished', progress=100, phase='Complete',
|
||||||
log_line=done, log_type='success' if grabbed[0] else 'info')
|
log_line=done, log_type='success' if grabbed[0] else 'info')
|
||||||
return {'status': 'completed', 'searched': searched[0], 'grabbed': grabbed[0],
|
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
|
except Exception as e: # noqa: BLE001
|
||||||
deps.update_progress(automation_id, status='error', phase='Error', log_line=str(e), log_type='error')
|
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}
|
return {'status': 'error', 'error': str(e), '_manages_own_progress': True}
|
||||||
|
|
|
||||||
|
|
@ -276,7 +276,9 @@ def _search_for_retry(query, max_seconds=55):
|
||||||
res = start_search(query)
|
res = start_search(query)
|
||||||
sid = res.get("id")
|
sid = res.get("id")
|
||||||
if not sid:
|
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
|
deadline = time.monotonic() + max_seconds
|
||||||
last = {"hits": [], "total_files": 0}
|
last = {"hits": [], "total_files": 0}
|
||||||
prev, settle = 0, 0 # track hit growth; settle = consecutive no-growth polls (~1.5s each)
|
prev, settle = 0, 0 # track hit growth; settle = consecutive no-growth polls (~1.5s each)
|
||||||
|
|
|
||||||
|
|
@ -136,6 +136,15 @@ def test_breakdown_distinguishes_no_results_from_quality_rejection():
|
||||||
assert "none accepted — Unknown / unsupported quality" in logs # reason surfaced
|
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():
|
def test_missing_library_folder_is_a_quiet_skip():
|
||||||
res, enq, _, deps = _run([{"tmdb_id": 1, "title": "A"}], root="")
|
res, enq, _, deps = _run([{"tmdb_id": 1, "title": "A"}], root="")
|
||||||
assert res["status"] == "completed" and res.get("skipped") == "no_folder"
|
assert res["status"] == "completed" and res.get("skipped") == "no_folder"
|
||||||
|
|
|
||||||
|
|
@ -90,5 +90,6 @@ def test_search_for_retry_no_id_skips_stop(monkeypatch):
|
||||||
stopped = []
|
stopped = []
|
||||||
monkeypatch.setattr(ss, "start_search", lambda q: {"configured": True, "error": "boom"})
|
monkeypatch.setattr(ss, "start_search", lambda q: {"configured": True, "error": "boom"})
|
||||||
monkeypatch.setattr(ss, "stop_search", lambda sid: stopped.append(sid))
|
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
|
assert stopped == [] # nothing started → nothing to stop
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue