video search: poll the FULL slskd timeout (~60s) + explain audio-only results

The poll capped at 32s but slskd results trickle in over ~50s (the music side waits
the whole search_timeout), so slow searches like 'Project Hail Mary' returned 'none'
before results landed. Now:
- /search/start returns poll_ms (slskd search_timeout + 8s); the UI polls that long
  (capped 80s), streaming results as they arrive, stopping early only once results
  clearly plateau (≥20s + stable) or hit 25.
- /search/poll returns total_files; when 0 video releases but slskd DID return files,
  the panel says 'returned N files, but none are video — likely audio/other for this
  title' instead of a blank 'none' (Soulseek is audio-heavy; many movie titles are
  audiobooks there). slskd_search.poll_search() returns {hits, total_files}.
Tests green, ruff + balance clean.
This commit is contained in:
BoulderBadgeDad 2026-06-19 16:55:53 -07:00
parent 48f6cc5e3c
commit 5661ccbfd2
3 changed files with 49 additions and 23 deletions

View file

@ -195,14 +195,16 @@ def register_routes(bp):
want_season, want_episode, season_end = _search_ints(body)
if source == "soulseek":
from core.video.slskd_search import build_query, start_search
from core.video.slskd_search import build_query, search_timeout_ms, start_search
res = start_search(build_query(scope, title, year=body.get("year"),
season=want_season, episode=want_episode))
if not res.get("configured"):
return jsonify({"error": "slskd isn't configured — set its URL on Settings → Downloads."})
if res.get("error"):
return jsonify({"error": "slskd: " + str(res["error"])})
return jsonify({"id": res["id"], "live": True, "complete": False})
# how long the client should keep polling (slskd keeps searching this long).
return jsonify({"id": res["id"], "live": True, "complete": False,
"poll_ms": search_timeout_ms() + 8000})
# mock sources resolve in one shot
profile = load_profile(get_video_db())
raw = mock_search(scope, title, year=body.get("year"), season=want_season,
@ -216,16 +218,16 @@ def register_routes(bp):
title, season?, episode?. The client polls until it stops growing or times out."""
from . import get_video_db
from core.video.quality_profile import load as load_profile
from core.video.slskd_search import poll_responses
from core.video.slskd_search import poll_search
sid = request.args.get("id")
scope = str(request.args.get("scope") or "movie").lower()
want_season, want_episode, _ = _search_ints(request.args)
if not sid:
return jsonify({"results": [], "live": True})
return jsonify({"results": [], "live": True, "total_files": 0})
profile = load_profile(get_video_db())
raw = poll_responses(sid)
return jsonify({"live": True,
"results": _evaluate_hits(raw, profile, scope, want_season, want_episode)})
polled = poll_search(sid)
return jsonify({"live": True, "total_files": polled["total_files"],
"results": _evaluate_hits(polled["hits"], profile, scope, want_season, want_episode)})
@bp.route("/downloads/grab", methods=["POST"])
def video_downloads_grab():

View file

@ -149,17 +149,29 @@ def start_search(query: str) -> dict:
def poll_responses(search_id: str) -> list:
"""Current grouped video hits for an in-flight search (cheap; call repeatedly)."""
return poll_search(search_id)["hits"]
def poll_search(search_id: str) -> dict:
"""Poll an in-flight search → {hits (grouped video releases), total_files (every
file slskd returned, incl. non-video)}. total_files lets the UI distinguish
'nothing back yet' from 'plenty back but it's all audio/junk, no video'."""
base, headers = _conn()
if not base or not search_id:
return []
return {"hits": [], "total_files": 0}
try:
r = requests.get(base + "/api/v0/searches/%s/responses" % search_id, headers=headers, timeout=15)
if not r.ok:
return []
return {"hits": [], "total_files": 0}
data = r.json()
except Exception: # noqa: BLE001, S110 - transient error → no new hits this poll
return []
return group_video_files(data)
return {"hits": [], "total_files": 0}
total = 0
for u in (data if isinstance(data, list) else []):
if isinstance(u, dict):
for d in (u.get("directories") or []):
total += len(d.get("files") or [])
return {"hits": group_video_files(data), "total_files": total}
def slskd_search(query: str, *, max_seconds: int = 8, slskd_timeout_ms: int = 4500) -> dict:
@ -205,4 +217,5 @@ def slskd_search(query: str, *, max_seconds: int = 8, slskd_timeout_ms: int = 45
return {"configured": True, "hits": group_video_files(responses)}
__all__ = ["VIDEO_EXTS", "build_query", "group_video_files", "slskd_search"]
__all__ = ["VIDEO_EXTS", "build_query", "group_video_files", "slskd_search",
"start_search", "poll_responses", "poll_search", "search_timeout_ms"]

View file

@ -208,12 +208,17 @@
}
// Render the result cards (shared by the immediate mock path and live polling).
function renderResults(resultsEl, params, rows, live, done) {
function renderResults(resultsEl, params, rows, live, done, totalFiles) {
rows = rows || [];
if (!rows.length) {
resultsEl.innerHTML = done
? '<div class="vdl-res-empty">No matching releases found.</div>'
: '<div class="vdl-res-loading"><span class="vdl-res-spin"></span>Searching ' + esc(scopeWord(params.scope)) + '…</div>';
if (!done) {
resultsEl.innerHTML = '<div class="vdl-res-loading"><span class="vdl-res-spin"></span>Searching ' + esc(scopeWord(params.scope)) + '…</div>';
} else if (totalFiles > 0) {
resultsEl.innerHTML = '<div class="vdl-res-empty">Soulseek returned ' + totalFiles + ' file' + (totalFiles === 1 ? '' : 's') +
', but none are video releases — likely audio/other for this title. Try a different title or source.</div>';
} else {
resultsEl.innerHTML = '<div class="vdl-res-empty">No matching releases found.</div>';
}
return;
}
resultsEl._rows = rows; resultsEl._search = params; // for the Grab button
@ -246,12 +251,16 @@
renderResults(resultsEl, params, d ? d.results : [], !!(d && d.live), true);
return;
}
_pollSearch(resultsEl, params, d.id, triggerRows);
_pollSearch(resultsEl, params, d.id, triggerRows, d.poll_ms);
});
}
function _pollSearch(resultsEl, params, id, triggerRows) {
var started = Date.now(), lastN = -1, stable = 0, MAX_MS = 32000;
function _pollSearch(resultsEl, params, id, triggerRows, pollMs) {
// slskd keeps searching for the whole search_timeout (~60s) and results
// trickle in over ~50s — poll that long (the music side does), streaming
// results as they arrive. Stop early only once results clearly plateau.
var started = Date.now(), lastN = -1, stable = 0, total = 0;
var MAX_MS = Math.min(80000, pollMs || 60000);
function tick() {
if (!resultsEl.isConnected) { _setScanning(triggerRows, false); return; }
var qs = '?id=' + encodeURIComponent(id) + '&scope=' + encodeURIComponent(params.scope || 'movie') +
@ -261,12 +270,14 @@
getJSON('/api/video/downloads/search/poll' + qs).then(function (d) {
if (!resultsEl.isConnected) { _setScanning(triggerRows, false); return; }
var rows = (d && d.results) || [];
total = (d && d.total_files) || total;
if (rows.length === lastN) { stable++; } else { stable = 0; lastN = rows.length; }
// done = timed out, OR results have plateaued for a few polls.
var done = (Date.now() - started >= MAX_MS) || (rows.length > 0 && stable >= 4);
renderResults(resultsEl, params, rows, true, done);
var elapsed = Date.now() - started;
// done = full timeout, OR plenty of results, OR results plateaued after ≥20s.
var done = elapsed >= MAX_MS || rows.length >= 25 || (rows.length > 0 && elapsed > 20000 && stable >= 6);
renderResults(resultsEl, params, rows, true, done, total);
if (done) { _setScanning(triggerRows, false); resultsEl._poll = null; }
else { resultsEl._poll = setTimeout(tick, 1300); }
else { resultsEl._poll = setTimeout(tick, 1500); }
});
}
tick();