video search: stream slskd results (start + poll) — fixes 'no results'

The old search did a 4.5s slskd search + 8s wait; slskd responses trickle in over
10-30s, so the window closed before results arrived (you'd see them in slskd but the
panel said none). Now it works like the music side:
- slskd_search.start_search() (uses the shared soulseek.search_timeout) + poll_responses().
- POST /downloads/search/start (mock = immediate; soulseek = returns a search id) +
  GET /downloads/search/poll. Shared _evaluate_hits ranks each poll's hits.
- UI streams: starts the search, polls every 1.3s, renders results live with a
  'searching…' badge, stops when results plateau (4 stable polls) or ~32s. Live
  re-renders suppress per-card entrance so it doesn't blink.
Tests green, ruff clean.
This commit is contained in:
BoulderBadgeDad 2026-06-19 15:23:47 -07:00
parent cb56ee2bee
commit 42c67a6d4d
4 changed files with 212 additions and 55 deletions

View file

@ -54,6 +54,45 @@ _SLSKD_KEYS = {
}
def _evaluate_hits(raw, profile, scope, want_season, want_episode) -> list:
"""Parse → evaluate → rank a list of raw indexer hits against the quality profile.
Shared by the mock search and the live slskd start/poll endpoints."""
from core.video.quality_eval import evaluate_release
from core.video.release_parse import parse_release
results = []
for hit in raw:
parsed = parse_release(hit.get("title"))
size_gb = round((hit.get("size_bytes") or 0) / (1024 ** 3), 1)
verdict = evaluate_release(parsed, profile, scope=scope, want_season=want_season,
want_episode=want_episode, size_gb=size_gb)
avail = hit.get("seeders") if hit.get("seeders") is not None else (hit.get("peers") or 0)
results.append({
"title": hit.get("title"), "size_gb": size_gb, "size_bytes": hit.get("size_bytes") or 0,
"seeders": hit.get("seeders"), "peers": hit.get("peers"),
"username": hit.get("username"), "slots": hit.get("slots"),
"filename": hit.get("filename"), "_avail": avail,
"quality_label": verdict["quality_label"], "accepted": verdict["accepted"],
"rejected": verdict["rejected"], "score": verdict["score"],
"resolution": parsed.get("resolution"), "source": parsed.get("source"),
"codec": parsed.get("codec"), "hdr": parsed.get("hdr"),
"audio": parsed.get("audio"), "group": parsed.get("group"),
"repack": parsed.get("repack") or parsed.get("proper"),
})
results.sort(key=lambda r: (r["accepted"], r["score"], r["_avail"]), reverse=True)
for r in results:
r.pop("_avail", None)
return results[:40]
def _search_ints(body):
def _int(v):
try:
return int(v)
except (TypeError, ValueError):
return None
return _int(body.get("season")), _int(body.get("episode")), _int(body.get("season_end"))
def register_routes(bp):
@bp.route("/downloads/config", methods=["GET"])
def video_downloads_config():
@ -117,28 +156,16 @@ def register_routes(bp):
Body: {scope, title, year?, season?, episode?, season_end?}."""
from . import get_video_db
from core.video.mock_search import mock_search
from core.video.quality_eval import evaluate_release
from core.video.quality_profile import load as load_profile
from core.video.release_parse import parse_release
body = request.get_json(silent=True) or {}
scope = str(body.get("scope") or "movie").lower()
title = body.get("title") or ""
source = str(body.get("source") or "").lower()
def _int(v):
try:
return int(v)
except (TypeError, ValueError):
return None
want_season, want_episode = _int(body.get("season")), _int(body.get("episode"))
want_season, want_episode, season_end = _search_ints(body)
profile = load_profile(get_video_db())
live = False
if source == "soulseek":
# REAL Soulseek search (slskd). Torrent/Usenet stay mocked until those
# indexers are wired. Same {title, size_bytes, …} shape downstream.
from core.video.slskd_search import build_query, slskd_search
sres = slskd_search(build_query(scope, title, year=body.get("year"),
season=want_season, episode=want_episode))
@ -149,33 +176,56 @@ def register_routes(bp):
raw, live = sres["hits"], True
else:
raw = mock_search(scope, title, year=body.get("year"), season=want_season,
episode=want_episode, season_end=_int(body.get("season_end")),
source=source)
episode=want_episode, season_end=season_end, source=source)
return jsonify({"scope": scope, "live": live,
"results": _evaluate_hits(raw, profile, scope, want_season, want_episode)})
results = []
for hit in raw:
parsed = parse_release(hit.get("title"))
size_gb = round((hit.get("size_bytes") or 0) / (1024 ** 3), 1)
verdict = evaluate_release(parsed, profile, scope=scope, want_season=want_season,
want_episode=want_episode, size_gb=size_gb)
avail = hit.get("seeders") if hit.get("seeders") is not None else (hit.get("peers") or 0)
results.append({
"title": hit.get("title"), "size_gb": size_gb, "size_bytes": hit.get("size_bytes") or 0,
"seeders": hit.get("seeders"), "peers": hit.get("peers"),
"username": hit.get("username"), "slots": hit.get("slots"),
"filename": hit.get("filename"), "_avail": avail,
"quality_label": verdict["quality_label"], "accepted": verdict["accepted"],
"rejected": verdict["rejected"], "score": verdict["score"],
"resolution": parsed.get("resolution"), "source": parsed.get("source"),
"codec": parsed.get("codec"), "hdr": parsed.get("hdr"),
"audio": parsed.get("audio"), "group": parsed.get("group"),
"repack": parsed.get("repack") or parsed.get("proper"),
})
# accepted first, then best score, then most availability (seeders/peers).
results.sort(key=lambda r: (r["accepted"], r["score"], r["_avail"]), reverse=True)
for r in results:
r.pop("_avail", None)
return jsonify({"scope": scope, "results": results[:40], "live": live})
@bp.route("/downloads/search/start", methods=["POST"])
def video_downloads_search_start():
"""Begin a search. For mock sources the results come back immediately; for
Soulseek it returns a slskd search id to poll (results trickle in over ~30s,
like the music side) fixes 'no results' from waiting too briefly."""
from . import get_video_db
from core.video.mock_search import mock_search
from core.video.quality_profile import load as load_profile
body = request.get_json(silent=True) or {}
scope = str(body.get("scope") or "movie").lower()
title = body.get("title") or ""
source = str(body.get("source") or "").lower()
want_season, want_episode, season_end = _search_ints(body)
if source == "soulseek":
from core.video.slskd_search import build_query, 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})
# mock sources resolve in one shot
profile = load_profile(get_video_db())
raw = mock_search(scope, title, year=body.get("year"), season=want_season,
episode=want_episode, season_end=season_end, source=source)
return jsonify({"id": None, "live": False, "complete": True,
"results": _evaluate_hits(raw, profile, scope, want_season, want_episode)})
@bp.route("/downloads/search/poll", methods=["GET"])
def video_downloads_search_poll():
"""Current ranked results for an in-flight slskd search. Query: id, scope,
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
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})
profile = load_profile(get_video_db())
raw = poll_responses(sid)
return jsonify({"live": True,
"results": _evaluate_hits(raw, profile, scope, want_season, want_episode)})
@bp.route("/downloads/grab", methods=["POST"])
def video_downloads_grab():

View file

@ -26,6 +26,13 @@ VIDEO_EXTS = frozenset((
))
def _conn():
from config.settings import config_manager
base = str(config_manager.get("soulseek.slskd_url", "") or "").rstrip("/")
key = config_manager.get("soulseek.api_key", "") or ""
return base, ({"X-API-Key": key} if key else {})
def build_query(scope: str, title: Any, *, year: Any = None, season: Any = None,
episode: Any = None) -> str:
"""The text we hand slskd for a given scope (movie / episode / season / series)."""
@ -100,6 +107,61 @@ def group_video_files(responses: Any) -> list:
return out
def _min_speed_bytes() -> int:
from config.settings import config_manager
try:
return int(config_manager.get("soulseek.min_peer_upload_speed", 0) or 0) * 125000
except (TypeError, ValueError):
return 0
def search_timeout_ms() -> int:
"""How long to ask slskd to keep searching — the SAME ``soulseek.search_timeout``
the music side uses (default 60s), so results have time to arrive."""
from config.settings import config_manager
try:
secs = int(config_manager.get("soulseek.search_timeout", 60) or 60)
except (TypeError, ValueError):
secs = 60
return max(10, min(120, secs)) * 1000
def start_search(query: str) -> dict:
"""Kick off a slskd search (don't wait). Returns {configured[, id][, error]}.
The caller polls ``poll_responses(id)`` until satisfied (like the music side)."""
base, headers = _conn()
if not base:
return {"configured": False}
payload = {"searchText": query, "timeout": search_timeout_ms(), "filterResponses": True,
"minimumResponseFileCount": 1, "minimumPeerUploadSpeed": _min_speed_bytes()}
try:
r = requests.post(base + "/api/v0/searches", json=payload, headers=headers, timeout=15)
r.raise_for_status()
data = r.json()
except Exception as e: # noqa: BLE001 - surface any slskd/network failure to the UI
return {"configured": True, "error": str(e)}
sid = data.get("id") if isinstance(data, dict) else (
data[0].get("id") if isinstance(data, list) and data and isinstance(data[0], dict) else None)
if not sid:
return {"configured": True, "error": "slskd returned no search id"}
return {"configured": True, "id": sid}
def poll_responses(search_id: str) -> list:
"""Current grouped video hits for an in-flight search (cheap; call repeatedly)."""
base, headers = _conn()
if not base or not search_id:
return []
try:
r = requests.get(base + "/api/v0/searches/%s/responses" % search_id, headers=headers, timeout=15)
if not r.ok:
return []
data = r.json()
except Exception: # noqa: BLE001, S110 - transient error → no new hits this poll
return []
return group_video_files(data)
def slskd_search(query: str, *, max_seconds: int = 8, slskd_timeout_ms: int = 4500) -> dict:
"""POST a search to slskd, poll responses, return {configured, hits[, error]}.
Thin I/O glue around ``group_video_files``."""

View file

@ -191,8 +191,6 @@
box.innerHTML = list.map(function (s) { return srcBlockHTML(s, false); }).join('');
}
// Scaffold: a satisfying faux-scan (animated) that resolves to "coming soon".
// No backend yet — this is the motion the real engine will drive.
// ── search + results ──────────────────────────────────────────────────────
var SRC_LABEL = { remux: 'Remux', bluray: 'BluRay', 'web-dl': 'WEB-DL', webrip: 'WEBRip',
hdtv: 'HDTV', dvd: 'DVD', cam: 'CAM', screener: 'Screener', workprint: 'Workprint' };
@ -209,29 +207,71 @@
});
}
// Run a search (mock indexer → real parse/evaluate/rank) and render the cards.
// Render the result cards (shared by the immediate mock path and live polling).
function renderResults(resultsEl, params, rows, live, done) {
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>';
return;
}
resultsEl._rows = rows; resultsEl._search = params; // for the Grab button
resultsEl.classList.toggle('vdl-res-noanim', !!live); // live re-renders → no per-card blink
var okN = rows.filter(function (r) { return r.accepted; }).length;
var badge = !live ? '<span class="vdl-res-demo">demo data</span>'
: done ? '<span class="vdl-res-live">● live</span>'
: '<span class="vdl-res-searching"><span class="vdl-res-spin vdl-res-spin--sm"></span>searching…</span>';
resultsEl.innerHTML =
'<div class="vdl-res-head"><strong>' + rows.length + '</strong> result' + (rows.length === 1 ? '' : 's') +
' · <span class="vdl-res-okn">' + okN + ' meet your profile</span>' + badge + '</div>' +
rows.map(resultCardHTML).join('');
}
// Start a search; for Soulseek, stream results in (poll like the music side —
// results trickle in over ~30s, so a single short wait misses them).
function searchInto(container, resultsEl, params, triggerRows) {
if (!resultsEl) return;
triggerRows = (triggerRows || []).filter(Boolean);
if (resultsEl._poll) { clearTimeout(resultsEl._poll); resultsEl._poll = null; }
_setScanning(triggerRows, true);
resultsEl.hidden = false;
resultsEl.classList.remove('vdl-res-noanim');
resultsEl.innerHTML = '<div class="vdl-res-loading"><span class="vdl-res-spin"></span>Searching ' + esc(scopeWord(params.scope)) + '…</div>';
postJSON('/api/video/downloads/search', params).then(function (d) {
_setScanning(triggerRows, false);
if (!resultsEl.isConnected) return;
if (d && d.error) { resultsEl.innerHTML = '<div class="vdl-res-empty vdl-res-err">⚠ ' + esc(d.error) + '</div>'; return; }
var rows = (d && d.results) || [];
if (!rows.length) { resultsEl.innerHTML = '<div class="vdl-res-empty">No matching releases found.</div>'; return; }
resultsEl._rows = rows; resultsEl._search = params; // for the Grab button
var okN = rows.filter(function (r) { return r.accepted; }).length;
var live = d && d.live ? '<span class="vdl-res-live">● live</span>' : '<span class="vdl-res-demo">demo data</span>';
resultsEl.innerHTML =
'<div class="vdl-res-head"><strong>' + rows.length + '</strong> result' + (rows.length === 1 ? '' : 's') +
' · <span class="vdl-res-okn">' + okN + ' meet your profile</span>' + live + '</div>' +
rows.map(resultCardHTML).join('');
postJSON('/api/video/downloads/search/start', params).then(function (d) {
if (!resultsEl.isConnected) { _setScanning(triggerRows, false); return; }
if (d && d.error) { _setScanning(triggerRows, false); resultsEl.innerHTML = '<div class="vdl-res-empty vdl-res-err">⚠ ' + esc(d.error) + '</div>'; return; }
if (!d || !d.id) { // mock / immediate
_setScanning(triggerRows, false);
renderResults(resultsEl, params, d ? d.results : [], !!(d && d.live), true);
return;
}
_pollSearch(resultsEl, params, d.id, triggerRows);
});
}
function _pollSearch(resultsEl, params, id, triggerRows) {
var started = Date.now(), lastN = -1, stable = 0, MAX_MS = 32000;
function tick() {
if (!resultsEl.isConnected) { _setScanning(triggerRows, false); return; }
var qs = '?id=' + encodeURIComponent(id) + '&scope=' + encodeURIComponent(params.scope || 'movie') +
'&title=' + encodeURIComponent(params.title || '') +
(params.season != null ? '&season=' + params.season : '') +
(params.episode != null ? '&episode=' + params.episode : '');
getJSON('/api/video/downloads/search/poll' + qs).then(function (d) {
if (!resultsEl.isConnected) { _setScanning(triggerRows, false); return; }
var rows = (d && d.results) || [];
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);
if (done) { _setScanning(triggerRows, false); resultsEl._poll = null; }
else { resultsEl._poll = setTimeout(tick, 1300); }
});
}
tick();
}
// Grab → start a real download (Soulseek only for now), then it lives on the
// Downloads page. Reads the card's row + the panel's search context.
function doGrab(btn) {

View file

@ -3258,7 +3258,12 @@ body[data-side="video"] #soulsync-toggle { display: none; }
color: rgba(255, 255, 255, 0.6); padding: 9px 2px; }
.vdl-res-spin { width: 14px; height: 14px; border-radius: 50%; flex-shrink: 0;
border: 2px solid rgba(255, 255, 255, 0.2); border-top-color: rgb(var(--accent-rgb)); animation: vdlSpin 0.7s linear infinite; }
.vdl-res-spin--sm { width: 10px; height: 10px; border-width: 1.5px; }
@keyframes vdlSpin { to { transform: rotate(360deg); } }
/* while live results are still streaming in, don't replay each card's entrance */
.vdl-res-noanim .vdl-res { animation: none; }
.vdl-res-searching { margin-left: auto; display: inline-flex; align-items: center; gap: 6px;
font-size: 10.5px; font-weight: 800; color: rgb(var(--accent-rgb)); }
.vdl-res-empty { font-size: 12.5px; color: rgba(255, 255, 255, 0.45); padding: 9px 2px; }
.vdl-res-head { display: flex; align-items: center; gap: 8px; font-size: 11.5px; font-weight: 700;
color: rgba(255, 255, 255, 0.5); margin: 4px 0 10px; }