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:
parent
48f6cc5e3c
commit
5661ccbfd2
3 changed files with 49 additions and 23 deletions
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue