diff --git a/api/video/downloads.py b/api/video/downloads.py index f96a8073..065d6638 100644 --- a/api/video/downloads.py +++ b/api/video/downloads.py @@ -108,6 +108,7 @@ def register_routes(bp): 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: @@ -117,9 +118,23 @@ def register_routes(bp): want_season, want_episode = _int(body.get("season")), _int(body.get("episode")) profile = load_profile(get_video_db()) - raw = mock_search(scope, title, year=body.get("year"), season=want_season, - episode=want_episode, season_end=_int(body.get("season_end")), - source=body.get("source")) + + 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)) + if not sres.get("configured"): + return jsonify({"scope": scope, "results": [], "error": "slskd isn't configured — set its URL on Settings → Downloads."}) + if sres.get("error"): + return jsonify({"scope": scope, "results": [], "error": "slskd: " + str(sres["error"])}) + 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) results = [] for hit in raw: @@ -127,8 +142,12 @@ def register_routes(bp): 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, "seeders": hit.get("seeders", 0), + "title": hit.get("title"), "size_gb": size_gb, + "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"), @@ -136,9 +155,11 @@ def register_routes(bp): "audio": parsed.get("audio"), "group": parsed.get("group"), "repack": parsed.get("repack") or parsed.get("proper"), }) - # accepted first, then best score, then most seeders. - results.sort(key=lambda r: (r["accepted"], r["score"], r["seeders"]), reverse=True) - return jsonify({"scope": scope, "results": results}) + # 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/youtube-quality", methods=["GET"]) def video_youtube_quality(): diff --git a/core/video/slskd_search.py b/core/video/slskd_search.py new file mode 100644 index 00000000..ae8dbe5e --- /dev/null +++ b/core/video/slskd_search.py @@ -0,0 +1,146 @@ +"""Real Soulseek (slskd) search for the video Download view. + +Replaces the mock for the Soulseek source: it POSTs a search to the slskd instance +(the shared ``soulseek.*`` config used by the music side too), polls for responses, +keeps the video files, and GROUPS them by release folder so each card is one release +(with a peer count) rather than one row per user. The parse → evaluate → rank pipeline +downstream is unchanged — this just returns the same ``{title, size_bytes, …}`` shape. + +Isolated: imports only stdlib + requests + the shared ``config_manager`` (app config, +not music code). The pure helpers (build_query / group_video_files) are unit-tested; +the HTTP poll is thin I/O glue. +""" + +from __future__ import annotations + +import time +from typing import Any + +import requests + +# Container extensions we treat as the actual video (everything else — subs, nfo, +# art, samples — is ignored for quality purposes). +VIDEO_EXTS = frozenset(( + "mkv", "mp4", "avi", "m4v", "mov", "wmv", "ts", "m2ts", "mpg", "mpeg", + "webm", "flv", "vob", "divx", "mk3d", +)) + + +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).""" + t = str(title or "").strip() + scope = (scope or "movie").lower() + try: + s_i = int(season) if season is not None else None + e_i = int(episode) if episode is not None else None + except (TypeError, ValueError): + s_i = e_i = None + if scope == "episode" and s_i is not None and e_i is not None: + return "%s S%02dE%02d" % (t, s_i, e_i) + if scope == "season" and s_i is not None: + return "%s S%02d" % (t, s_i) + if scope == "movie" and year: + return ("%s %s" % (t, year)).strip() + return t # series / fallback + + +def _is_video(filename: str) -> bool: + fn = str(filename or "").replace("\\", "/") + base = fn.rsplit("/", 1)[-1] + if "sample" in base.lower(): + return False + ext = base.rsplit(".", 1)[-1].lower() if "." in base else "" + return ext in VIDEO_EXTS + + +def _release_name(filename: str) -> str: + """The release a file belongs to — its parent folder (where scene/p2p put the + quality), falling back to the bare filename (sans extension).""" + fn = str(filename or "").replace("\\", "/").strip("/") + parts = [p for p in fn.split("/") if p] + if len(parts) >= 2: + return parts[-2] + base = parts[-1] if parts else "" + return base.rsplit(".", 1)[0] if "." in base else base + + +def group_video_files(responses: Any) -> list: + """Flatten slskd responses → one hit per release folder, with a peer count and the + best (fastest, most-available) source. Pure — drives the unit tests.""" + groups: dict = {} + for resp in (responses if isinstance(responses, list) else []): + if not isinstance(resp, dict): + continue + user = resp.get("username") + speed = resp.get("uploadSpeed", 0) or 0 + slots = resp.get("freeUploadSlots", 0) or 0 + for f in (resp.get("files") or []): + fn = f.get("filename", "") + if not _is_video(fn): + continue + rel = _release_name(fn) + g = groups.get(rel) + if g is None: + g = groups[rel] = {"title": rel, "size_bytes": 0, "users": set(), + "best_speed": -1, "username": None, "slots": 0, "filename": fn} + g["size_bytes"] = max(g["size_bytes"], f.get("size", 0) or 0) + if user: + g["users"].add(user) + if speed > g["best_speed"]: + g["best_speed"] = speed + g["username"] = user + g["slots"] = slots + g["filename"] = fn + out = [] + for g in groups.values(): + out.append({"title": g["title"], "size_bytes": g["size_bytes"], "peers": len(g["users"]), + "username": g["username"], "slots": g["slots"], "filename": g["filename"]}) + out.sort(key=lambda h: (h["peers"], h["size_bytes"]), reverse=True) + return out + + +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``.""" + from config.settings import config_manager + base = str(config_manager.get("soulseek.slskd_url", "") or "").rstrip("/") + if not base: + return {"configured": False, "hits": []} + key = config_manager.get("soulseek.api_key", "") or "" + headers = {"X-API-Key": key} if key else {} + try: + min_mbps = int(config_manager.get("soulseek.min_peer_upload_speed", 0) or 0) + except (TypeError, ValueError): + min_mbps = 0 + payload = {"searchText": query, "timeout": slskd_timeout_ms, "filterResponses": True, + "minimumResponseFileCount": 1, "minimumPeerUploadSpeed": min_mbps * 125000} + try: + r = requests.post(base + "/api/v0/searches", json=payload, headers=headers, timeout=10) + r.raise_for_status() + data = r.json() + except Exception as e: # noqa: BLE001 - surface any slskd/network failure to the UI + return {"configured": True, "hits": [], "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, "hits": [], "error": "slskd returned no search id"} + + responses: list = [] + deadline = time.monotonic() + max_seconds + while time.monotonic() < deadline: + try: + rr = requests.get(base + "/api/v0/searches/%s/responses" % sid, headers=headers, timeout=10) + if rr.ok: + body = rr.json() + if isinstance(body, list): + responses = body + except Exception: # noqa: BLE001, S110 - keep polling through transient errors + pass + if len(responses) >= 25: + break + time.sleep(1) + return {"configured": True, "hits": group_video_files(responses)} + + +__all__ = ["VIDEO_EXTS", "build_query", "group_video_files", "slskd_search"] diff --git a/tests/test_video_slskd_search.py b/tests/test_video_slskd_search.py new file mode 100644 index 00000000..c5d3052c --- /dev/null +++ b/tests/test_video_slskd_search.py @@ -0,0 +1,51 @@ +"""Real slskd search — the pure, testable seams (query building + grouping slskd +responses into per-release hits). The HTTP poll itself is thin I/O glue, not tested +here. Isolated from music.""" + +from __future__ import annotations + +from core.video.slskd_search import build_query, group_video_files + + +def test_build_query_per_scope(): + assert build_query("movie", "The Matrix", year=1999) == "The Matrix 1999" + assert build_query("movie", "The Matrix") == "The Matrix" + assert build_query("episode", "The Wire", season=2, episode=3) == "The Wire S02E03" + assert build_query("season", "The Wire", season=2) == "The Wire S02" + assert build_query("series", "The Wire") == "The Wire" + + +def test_group_video_files_groups_by_release_folder(): + responses = [ + {"username": "alice", "uploadSpeed": 900, "freeUploadSlots": 1, "files": [ + {"filename": r"@@x\The.Wire.S02.1080p.BluRay.x265-GRP\the.wire.s02e01.mkv", "size": 3_000_000_000}, + {"filename": r"@@x\The.Wire.S02.1080p.BluRay.x265-GRP\the.wire.s02e02.mkv", "size": 3_200_000_000}, + {"filename": r"@@x\The.Wire.S02.1080p.BluRay.x265-GRP\readme.nfo", "size": 1024}, + ]}, + {"username": "bob", "uploadSpeed": 1500, "freeUploadSlots": 2, "files": [ + {"filename": "The.Wire.S02.1080p.BluRay.x265-GRP/the.wire.s02e01.mkv", "size": 3_000_000_000}, + ]}, + ] + hits = group_video_files(responses) + assert len(hits) == 1 # both users → one release + h = hits[0] + assert h["title"] == "The.Wire.S02.1080p.BluRay.x265-GRP" + assert h["peers"] == 2 # alice + bob + assert h["username"] == "bob" # bob is faster → the chosen source + assert h["slots"] == 2 + assert h["size_bytes"] == 3_200_000_000 # largest video file in the folder + + +def test_group_skips_non_video_and_samples(): + responses = [{"username": "u", "uploadSpeed": 1, "freeUploadSlots": 0, "files": [ + {"filename": "Movie.2020.1080p/sample.mkv", "size": 50_000_000}, # sample dropped + {"filename": "Movie.2020.1080p/Movie.2020.1080p.srt", "size": 40000}, # subs dropped + {"filename": "Movie.2020.1080p/movie.mp4", "size": 8_000_000_000}, + ]}] + hits = group_video_files(responses) + assert len(hits) == 1 and hits[0]["size_bytes"] == 8_000_000_000 + + +def test_group_handles_garbage(): + assert group_video_files(None) == [] + assert group_video_files([{"nope": 1}, "junk"]) == []