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)
|
want_season, want_episode, season_end = _search_ints(body)
|
||||||
|
|
||||||
if source == "soulseek":
|
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"),
|
res = start_search(build_query(scope, title, year=body.get("year"),
|
||||||
season=want_season, episode=want_episode))
|
season=want_season, episode=want_episode))
|
||||||
if not res.get("configured"):
|
if not res.get("configured"):
|
||||||
return jsonify({"error": "slskd isn't configured — set its URL on Settings → Downloads."})
|
return jsonify({"error": "slskd isn't configured — set its URL on Settings → Downloads."})
|
||||||
if res.get("error"):
|
if res.get("error"):
|
||||||
return jsonify({"error": "slskd: " + str(res["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
|
# mock sources resolve in one shot
|
||||||
profile = load_profile(get_video_db())
|
profile = load_profile(get_video_db())
|
||||||
raw = mock_search(scope, title, year=body.get("year"), season=want_season,
|
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."""
|
title, season?, episode?. The client polls until it stops growing or times out."""
|
||||||
from . import get_video_db
|
from . import get_video_db
|
||||||
from core.video.quality_profile import load as load_profile
|
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")
|
sid = request.args.get("id")
|
||||||
scope = str(request.args.get("scope") or "movie").lower()
|
scope = str(request.args.get("scope") or "movie").lower()
|
||||||
want_season, want_episode, _ = _search_ints(request.args)
|
want_season, want_episode, _ = _search_ints(request.args)
|
||||||
if not sid:
|
if not sid:
|
||||||
return jsonify({"results": [], "live": True})
|
return jsonify({"results": [], "live": True, "total_files": 0})
|
||||||
profile = load_profile(get_video_db())
|
profile = load_profile(get_video_db())
|
||||||
raw = poll_responses(sid)
|
polled = poll_search(sid)
|
||||||
return jsonify({"live": True,
|
return jsonify({"live": True, "total_files": polled["total_files"],
|
||||||
"results": _evaluate_hits(raw, profile, scope, want_season, want_episode)})
|
"results": _evaluate_hits(polled["hits"], profile, scope, want_season, want_episode)})
|
||||||
|
|
||||||
@bp.route("/downloads/grab", methods=["POST"])
|
@bp.route("/downloads/grab", methods=["POST"])
|
||||||
def video_downloads_grab():
|
def video_downloads_grab():
|
||||||
|
|
|
||||||
|
|
@ -149,17 +149,29 @@ def start_search(query: str) -> dict:
|
||||||
|
|
||||||
def poll_responses(search_id: str) -> list:
|
def poll_responses(search_id: str) -> list:
|
||||||
"""Current grouped video hits for an in-flight search (cheap; call repeatedly)."""
|
"""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()
|
base, headers = _conn()
|
||||||
if not base or not search_id:
|
if not base or not search_id:
|
||||||
return []
|
return {"hits": [], "total_files": 0}
|
||||||
try:
|
try:
|
||||||
r = requests.get(base + "/api/v0/searches/%s/responses" % search_id, headers=headers, timeout=15)
|
r = requests.get(base + "/api/v0/searches/%s/responses" % search_id, headers=headers, timeout=15)
|
||||||
if not r.ok:
|
if not r.ok:
|
||||||
return []
|
return {"hits": [], "total_files": 0}
|
||||||
data = r.json()
|
data = r.json()
|
||||||
except Exception: # noqa: BLE001, S110 - transient error → no new hits this poll
|
except Exception: # noqa: BLE001, S110 - transient error → no new hits this poll
|
||||||
return []
|
return {"hits": [], "total_files": 0}
|
||||||
return group_video_files(data)
|
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:
|
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)}
|
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).
|
// 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 || [];
|
rows = rows || [];
|
||||||
if (!rows.length) {
|
if (!rows.length) {
|
||||||
resultsEl.innerHTML = done
|
if (!done) {
|
||||||
? '<div class="vdl-res-empty">No matching releases found.</div>'
|
resultsEl.innerHTML = '<div class="vdl-res-loading"><span class="vdl-res-spin"></span>Searching ' + esc(scopeWord(params.scope)) + '…</div>';
|
||||||
: '<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;
|
return;
|
||||||
}
|
}
|
||||||
resultsEl._rows = rows; resultsEl._search = params; // for the Grab button
|
resultsEl._rows = rows; resultsEl._search = params; // for the Grab button
|
||||||
|
|
@ -246,12 +251,16 @@
|
||||||
renderResults(resultsEl, params, d ? d.results : [], !!(d && d.live), true);
|
renderResults(resultsEl, params, d ? d.results : [], !!(d && d.live), true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_pollSearch(resultsEl, params, d.id, triggerRows);
|
_pollSearch(resultsEl, params, d.id, triggerRows, d.poll_ms);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function _pollSearch(resultsEl, params, id, triggerRows) {
|
function _pollSearch(resultsEl, params, id, triggerRows, pollMs) {
|
||||||
var started = Date.now(), lastN = -1, stable = 0, MAX_MS = 32000;
|
// 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() {
|
function tick() {
|
||||||
if (!resultsEl.isConnected) { _setScanning(triggerRows, false); return; }
|
if (!resultsEl.isConnected) { _setScanning(triggerRows, false); return; }
|
||||||
var qs = '?id=' + encodeURIComponent(id) + '&scope=' + encodeURIComponent(params.scope || 'movie') +
|
var qs = '?id=' + encodeURIComponent(id) + '&scope=' + encodeURIComponent(params.scope || 'movie') +
|
||||||
|
|
@ -261,12 +270,14 @@
|
||||||
getJSON('/api/video/downloads/search/poll' + qs).then(function (d) {
|
getJSON('/api/video/downloads/search/poll' + qs).then(function (d) {
|
||||||
if (!resultsEl.isConnected) { _setScanning(triggerRows, false); return; }
|
if (!resultsEl.isConnected) { _setScanning(triggerRows, false); return; }
|
||||||
var rows = (d && d.results) || [];
|
var rows = (d && d.results) || [];
|
||||||
|
total = (d && d.total_files) || total;
|
||||||
if (rows.length === lastN) { stable++; } else { stable = 0; lastN = rows.length; }
|
if (rows.length === lastN) { stable++; } else { stable = 0; lastN = rows.length; }
|
||||||
// done = timed out, OR results have plateaued for a few polls.
|
var elapsed = Date.now() - started;
|
||||||
var done = (Date.now() - started >= MAX_MS) || (rows.length > 0 && stable >= 4);
|
// done = full timeout, OR plenty of results, OR results plateaued after ≥20s.
|
||||||
renderResults(resultsEl, params, rows, true, done);
|
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; }
|
if (done) { _setScanning(triggerRows, false); resultsEl._poll = null; }
|
||||||
else { resultsEl._poll = setTimeout(tick, 1300); }
|
else { resultsEl._poll = setTimeout(tick, 1500); }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
tick();
|
tick();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue