video search: REAL slskd search for the Soulseek source (was mocked)
The Soulseek ⌕ now actually queries your slskd instance instead of fabricating
results. core/video/slskd_search.py (isolated): build_query(scope,…) → POST
/api/v0/searches (shared soulseek.* URL+key) → poll /responses (bounded ~8s,
early-exit at 25) → keep video files → group_video_files() folds them into one hit
per release folder with a peer count + the fastest available source. Same
{title,size_bytes,…} shape, so parse→evaluate→rank/cards are unchanged.
Endpoint routes source=='soulseek' to slskd (torrent/usenet stay mocked), surfaces
'slskd not configured' / network errors, carries username/peers/slots/filename
through for the cards + a future real Grab, and caps to 40. Pure helpers tested (4);
isolation guard + ruff clean.
This commit is contained in:
parent
bf17ef3fab
commit
305020f61e
3 changed files with 225 additions and 7 deletions
|
|
@ -108,6 +108,7 @@ def register_routes(bp):
|
||||||
body = request.get_json(silent=True) or {}
|
body = request.get_json(silent=True) or {}
|
||||||
scope = str(body.get("scope") or "movie").lower()
|
scope = str(body.get("scope") or "movie").lower()
|
||||||
title = body.get("title") or ""
|
title = body.get("title") or ""
|
||||||
|
source = str(body.get("source") or "").lower()
|
||||||
|
|
||||||
def _int(v):
|
def _int(v):
|
||||||
try:
|
try:
|
||||||
|
|
@ -117,9 +118,23 @@ def register_routes(bp):
|
||||||
|
|
||||||
want_season, want_episode = _int(body.get("season")), _int(body.get("episode"))
|
want_season, want_episode = _int(body.get("season")), _int(body.get("episode"))
|
||||||
profile = load_profile(get_video_db())
|
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")),
|
live = False
|
||||||
source=body.get("source"))
|
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 = []
|
results = []
|
||||||
for hit in raw:
|
for hit in raw:
|
||||||
|
|
@ -127,8 +142,12 @@ def register_routes(bp):
|
||||||
size_gb = round((hit.get("size_bytes") or 0) / (1024 ** 3), 1)
|
size_gb = round((hit.get("size_bytes") or 0) / (1024 ** 3), 1)
|
||||||
verdict = evaluate_release(parsed, profile, scope=scope, want_season=want_season,
|
verdict = evaluate_release(parsed, profile, scope=scope, want_season=want_season,
|
||||||
want_episode=want_episode, size_gb=size_gb)
|
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({
|
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"],
|
"quality_label": verdict["quality_label"], "accepted": verdict["accepted"],
|
||||||
"rejected": verdict["rejected"], "score": verdict["score"],
|
"rejected": verdict["rejected"], "score": verdict["score"],
|
||||||
"resolution": parsed.get("resolution"), "source": parsed.get("source"),
|
"resolution": parsed.get("resolution"), "source": parsed.get("source"),
|
||||||
|
|
@ -136,9 +155,11 @@ def register_routes(bp):
|
||||||
"audio": parsed.get("audio"), "group": parsed.get("group"),
|
"audio": parsed.get("audio"), "group": parsed.get("group"),
|
||||||
"repack": parsed.get("repack") or parsed.get("proper"),
|
"repack": parsed.get("repack") or parsed.get("proper"),
|
||||||
})
|
})
|
||||||
# accepted first, then best score, then most seeders.
|
# accepted first, then best score, then most availability (seeders/peers).
|
||||||
results.sort(key=lambda r: (r["accepted"], r["score"], r["seeders"]), reverse=True)
|
results.sort(key=lambda r: (r["accepted"], r["score"], r["_avail"]), reverse=True)
|
||||||
return jsonify({"scope": scope, "results": results})
|
for r in results:
|
||||||
|
r.pop("_avail", None)
|
||||||
|
return jsonify({"scope": scope, "results": results[:40], "live": live})
|
||||||
|
|
||||||
@bp.route("/downloads/youtube-quality", methods=["GET"])
|
@bp.route("/downloads/youtube-quality", methods=["GET"])
|
||||||
def video_youtube_quality():
|
def video_youtube_quality():
|
||||||
|
|
|
||||||
146
core/video/slskd_search.py
Normal file
146
core/video/slskd_search.py
Normal file
|
|
@ -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"]
|
||||||
51
tests/test_video_slskd_search.py
Normal file
51
tests/test_video_slskd_search.py
Normal file
|
|
@ -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"]) == []
|
||||||
Loading…
Reference in a new issue