From 5661ccbfd2d381a48ee903be247787ac98ce2036 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Fri, 19 Jun 2026 16:55:53 -0700 Subject: [PATCH] video search: poll the FULL slskd timeout (~60s) + explain audio-only results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- api/video/downloads.py | 16 ++++++----- core/video/slskd_search.py | 23 ++++++++++++---- webui/static/video/video-download-view.js | 33 +++++++++++++++-------- 3 files changed, 49 insertions(+), 23 deletions(-) diff --git a/api/video/downloads.py b/api/video/downloads.py index 63971345..0ec4aa83 100644 --- a/api/video/downloads.py +++ b/api/video/downloads.py @@ -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(): diff --git a/core/video/slskd_search.py b/core/video/slskd_search.py index 89376840..d569f97f 100644 --- a/core/video/slskd_search.py +++ b/core/video/slskd_search.py @@ -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"] diff --git a/webui/static/video/video-download-view.js b/webui/static/video/video-download-view.js index d82ca3ad..ac3b0f51 100644 --- a/webui/static/video/video-download-view.js +++ b/webui/static/video/video-download-view.js @@ -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 - ? '
No matching releases found.
' - : '
Searching ' + esc(scopeWord(params.scope)) + '…
'; + if (!done) { + resultsEl.innerHTML = '
Searching ' + esc(scopeWord(params.scope)) + '…
'; + } else if (totalFiles > 0) { + resultsEl.innerHTML = '
Soulseek returned ' + totalFiles + ' file' + (totalFiles === 1 ? '' : 's') + + ', but none are video releases — likely audio/other for this title. Try a different title or source.
'; + } else { + resultsEl.innerHTML = '
No matching releases found.
'; + } 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();