diff --git a/api/video/downloads.py b/api/video/downloads.py index f99b92db..627bd8af 100644 --- a/api/video/downloads.py +++ b/api/video/downloads.py @@ -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(): diff --git a/core/video/slskd_search.py b/core/video/slskd_search.py index ae8dbe5e..89376840 100644 --- a/core/video/slskd_search.py +++ b/core/video/slskd_search.py @@ -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``.""" diff --git a/webui/static/video/video-download-view.js b/webui/static/video/video-download-view.js index 2f5471e8..d34ad2b4 100644 --- a/webui/static/video/video-download-view.js +++ b/webui/static/video/video-download-view.js @@ -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 + ? '
No matching releases found.
' + : '
Searching ' + esc(scopeWord(params.scope)) + '…
'; + 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 ? 'demo data' + : done ? '● live' + : 'searching…'; + resultsEl.innerHTML = + '
' + rows.length + ' result' + (rows.length === 1 ? '' : 's') + + ' · ' + okN + ' meet your profile' + badge + '
' + + 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 = '
Searching ' + esc(scopeWord(params.scope)) + '…
'; - postJSON('/api/video/downloads/search', params).then(function (d) { - _setScanning(triggerRows, false); - if (!resultsEl.isConnected) return; - if (d && d.error) { resultsEl.innerHTML = '
⚠ ' + esc(d.error) + '
'; return; } - var rows = (d && d.results) || []; - if (!rows.length) { resultsEl.innerHTML = '
No matching releases found.
'; 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 ? '● live' : 'demo data'; - resultsEl.innerHTML = - '
' + rows.length + ' result' + (rows.length === 1 ? '' : 's') + - ' · ' + okN + ' meet your profile' + live + '
' + - 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 = '
⚠ ' + esc(d.error) + '
'; 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) { diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index 7e98b3b6..4a8efd0f 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -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; }