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:
parent
cb56ee2bee
commit
42c67a6d4d
4 changed files with 212 additions and 55 deletions
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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``."""
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue