From 44b074772b17933c707ee0f866c992a7656716ff Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Fri, 19 Jun 2026 13:39:09 -0700 Subject: [PATCH] video search: evaluate/rank pipeline + mock indexer + /downloads/search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of live search (indexer mocked, pipeline real): - quality_eval.evaluate_release(parsed, profile, scope, want_season/episode, size_gb) → {accepted, score, rejected, tier, quality_label}. Sonarr-style: rejects junk sources/codecs/3D, requires an ENABLED ladder tier, honours HDR-require + size caps, and VALIDATES scope (episode wants SxxExx, season wants the whole-season PACK, show wants a complete-series pack, with season/episode-number checks). Scores keepers by resolution/source/codec-pref/HDR/audio/repack for ranking. - mock_search.py: deterministic stand-in indexer returning scope-shaped raw hits (the single swap-point for real slskd/Prowlarr). - POST /downloads/search: mock → parse → evaluate → rank (accepted, score, seeders). 16 tests, isolation guard + ruff clean. UI wiring next. --- api/video/downloads.py | 47 ++++++++++++ core/video/mock_search.py | 100 +++++++++++++++++++++++++ core/video/quality_eval.py | 111 +++++++++++++++++++++++++++- tests/test_video_api.py | 25 +++++++ tests/test_video_search_pipeline.py | 69 +++++++++++++++++ 5 files changed, 351 insertions(+), 1 deletion(-) create mode 100644 core/video/mock_search.py create mode 100644 tests/test_video_search_pipeline.py diff --git a/api/video/downloads.py b/api/video/downloads.py index e7e253e1..ae04cc14 100644 --- a/api/video/downloads.py +++ b/api/video/downloads.py @@ -92,6 +92,53 @@ def register_routes(bp): profile = load(get_video_db()) return jsonify(evaluate_owned(body.get("file"), profile)) + @bp.route("/downloads/search", methods=["POST"]) + def video_downloads_search(): + """Search a scope (movie / episode / season / series) and return candidates + ranked + filtered against the stored quality profile. The indexer is mocked + for now (core.video.mock_search) — the parse→evaluate→rank pipeline is real, + so swapping in slskd/Prowlarr later needs no change here. + 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 "" + + def _int(v): + try: + return int(v) + except (TypeError, ValueError): + return None + + 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"))) + + 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) + results.append({ + "title": hit.get("title"), "size_gb": size_gb, "seeders": hit.get("seeders", 0), + "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 seeders. + results.sort(key=lambda r: (r["accepted"], r["score"], r["seeders"]), reverse=True) + return jsonify({"scope": scope, "results": results}) + @bp.route("/downloads/youtube-quality", methods=["GET"]) def video_youtube_quality(): # Separate, smaller profile — YouTube is yt-dlp, not scene/p2p releases. diff --git a/core/video/mock_search.py b/core/video/mock_search.py new file mode 100644 index 00000000..ad734ac1 --- /dev/null +++ b/core/video/mock_search.py @@ -0,0 +1,100 @@ +"""Mock indexer — a stand-in for a real slskd/Prowlarr search while the download +engine is being built. + +Given a scope (movie / episode / season / series) and a title, it returns a list of +plausible raw 'indexer hits' ({title, size_bytes, seeders}). The real parse → evaluate +→ rank pipeline (release_parse + quality_eval) runs on these exactly as it will on real +hits, so the search UI is fully exercised. Deterministic (no RNG) so results are stable +for tests and reloads. THIS is the single swap-point: replace ``mock_search`` with a +real indexer client and nothing downstream changes. + +Pure (no DB, no network). Isolated — imports only typing; the music side never imports it. +""" + +from __future__ import annotations + +from typing import Any + +_GB = 1024 ** 3 + +# (quality suffix, size in GB). Ordered best→worst-ish; the evaluator re-ranks anyway. +_MOVIE = [ + ("2160p.UHD.BluRay.REMUX.HDR.DV.TrueHD.Atmos-FraMeSToR", 58), + ("2160p.WEB-DL.DDP5.1.HDR.HEVC-FLUX", 19), + ("1080p.BluRay.x265.10bit.DTS-HD.MA.5.1-GROUP", 11), + ("1080p.WEB-DL.DDP5.1.H264-NTb", 7), + ("1080p.WEBRip.x264.AAC-RARBG", 4), + ("720p.HDTV.x264-GROUP", 2), + ("HDCAM.x264-CRUDE", 2), +] +_EPISODE = [ + ("2160p.WEB-DL.DDP5.1.HDR.HEVC-FLUX", 5), + ("1080p.BluRay.x265-GROUP", 3), + ("1080p.WEB-DL.H264-NTb", 2), + ("720p.HDTV.x264-GROUP", 1), +] +_SEASON = [ + ("2160p.WEB-DL.DDP5.1.HDR.HEVC-NTb", 48), + ("1080p.BluRay.x265.10bit-GROUP", 26), + ("1080p.WEB-DL.H264-FLUX", 18), + ("720p.HDTV.x264-GROUP", 9), +] +_SERIES = [ + ("COMPLETE.1080p.BluRay.x265.10bit-GROUP", 120), + ("COMPLETE.1080p.WEB-DL.H264-NTb", 88), + ("COMPLETE.720p.WEB-DL.x264-GROUP", 40), +] + + +def _slug(title: Any) -> str: + s = "".join(c if (c.isalnum() or c == " ") else " " for c in str(title or "").strip()) + return ".".join(p for p in s.split() if p) or "Unknown" + + +def _seeders(slug: str, i: int) -> int: + # Deterministic spread (no RNG): a stable hash of the title + index. + h = 0 + for ch in slug: + h = (h * 31 + ord(ch)) & 0xFFFFFFFF + return ((h >> (i % 7)) % 240) + 3 + + +def _ss(n) -> str: + try: + return "S%02d" % int(n) + except (TypeError, ValueError): + return "S01" + + +def mock_search(scope: str, title: Any, *, year: Any = None, season: Any = None, + episode: Any = None, season_end: Any = None) -> list: + """Return plausible raw hits for a scope. Replace with a real indexer client later.""" + slug = _slug(title) + scope = (scope or "movie").lower() + if scope == "movie": + prefix = slug + ("." + str(year) if year else "") + rows = _MOVIE + elif scope == "episode": + prefix = slug + "." + _ss(season) + ("E%02d" % int(episode) if episode is not None else "E01") + rows = _EPISODE + elif scope == "season": + prefix = slug + "." + _ss(season) + rows = _SEASON + elif scope == "series": + end = season_end or 5 + prefix = slug + ".S01-" + _ss(end) + rows = _SERIES + else: + return [] + + hits = [] + for i, (suffix, gb) in enumerate(rows): + hits.append({ + "title": prefix + "." + suffix, + "size_bytes": int(gb * _GB), + "seeders": _seeders(slug, i), + }) + return hits + + +__all__ = ["mock_search"] diff --git a/core/video/quality_eval.py b/core/video/quality_eval.py index 961c3ad4..62e750f7 100644 --- a/core/video/quality_eval.py +++ b/core/video/quality_eval.py @@ -96,4 +96,113 @@ def evaluate_owned(file: Any, profile: Any) -> dict: } -__all__ = ["resolution_rank", "resolution_label", "meets_cutoff", "evaluate_owned"] +# ── release (search hit) evaluation ─────────────────────────────────────────── +# Map a parsed source → the tier-key prefix used in the quality profile's ladder. +_SRC_TIER = {"remux": "remux", "bluray": "bluray", "web-dl": "web", + "webrip": "webrip", "hdtv": "hdtv", "dvd": "dvd"} +_RES_SCORE = {"2160p": 400, "1080p": 300, "720p": 200, "480p": 100} +_SRC_SCORE = {"remux": 90, "bluray": 70, "web-dl": 55, "webrip": 40, "hdtv": 25, "dvd": 10} + + +def tier_key(source, resolution) -> str: + """The quality-ladder key for a parsed (source, resolution), or '' if it isn't a + ladder tier (junk sources like cam/screener have no tier).""" + pre = _SRC_TIER.get(source) + if not pre: + return "" + if pre == "dvd": + return "dvd" + return (pre + "-" + resolution) if resolution else "" + + +def _scope_ok(parsed, scope, want_season, want_episode): + """Validate a hit actually matches what was searched (Sonarr-style): an episode + search wants SxxExx, a season search wants the whole season PACK, a show search + wants a complete-series pack.""" + season, episode = parsed.get("season"), parsed.get("episode") + if scope == "movie": + return (None, None) if season is None else (None, "This is a TV release, not the movie") + if scope == "episode": + if episode is None: + return None, "Not a single episode" + if want_season is not None and season != want_season: + return None, "Wrong season" + if want_episode is not None and episode != want_episode: + return None, "Wrong episode" + return None, None + if scope == "season": + if not parsed.get("is_season_pack"): + return None, "Not a full-season pack" + if want_season is not None and season != want_season: + return None, "Wrong season" + return None, None + if scope == "series": + return (None, None) if parsed.get("is_series_pack") else (None, "Not a complete-series pack") + return None, None + + +def evaluate_release(parsed, profile, *, scope="movie", want_season=None, + want_episode=None, size_gb=None) -> dict: + """Judge a parsed search hit against the quality profile + the search scope. + + Returns ``{accepted, score, rejected, tier, quality_label}`` — ``accepted`` False + means it's filtered out (``rejected`` says why); ``score`` ranks the keepers.""" + parsed = parsed if isinstance(parsed, dict) else {} + profile = profile if isinstance(profile, dict) else {} + res, source = parsed.get("resolution"), parsed.get("source") + rejects = profile.get("rejects") or [] + rejected = None + + # 1) hard rejects — junk source / 3D / rejected codec + if source in ("cam", "screener", "workprint") and source in rejects: + rejected = source + " is on your reject list" + fam = _codec_family(parsed.get("codec")) + if not rejected and fam and fam in rejects: + rejected = fam + " codec is on your reject list" + if not rejected and parsed.get("three_d") and "3d" in rejects: + rejected = "3D is on your reject list" + + # 2) must be an enabled ladder tier + tier = tier_key(source, res) + if not rejected: + enabled = {t.get("key") for t in (profile.get("tiers") or []) if t.get("enabled")} + if not tier: + rejected = "Unknown / unsupported quality" + elif tier not in enabled: + rejected = (resolution_label(res) or "This quality") + " " + (source or "") + " isn't in your enabled tiers" + + # 3) HDR required (a real filter when set) + if not rejected and profile.get("prefer_hdr") == "require" and not parsed.get("hdr"): + rejected = "HDR required but this is SDR" + + # 4) scope validation (episode vs season pack vs series pack) + if not rejected: + _, scope_reason = _scope_ok(parsed, scope, want_season, want_episode) + if scope_reason: + rejected = scope_reason + + # 5) size guard (movie/episode only — packs are legitimately large) + if not rejected and size_gb: + cap = profile.get("max_movie_gb") if scope == "movie" else (profile.get("max_episode_gb") if scope == "episode" else 0) + if cap and size_gb > cap: + rejected = "Over your " + str(cap) + " GB size cap" + + # score the keepers (higher = better) + score = _RES_SCORE.get(res, 0) + _SRC_SCORE.get(source, 0) + if profile.get("prefer_codec") not in (None, "any") and fam == profile.get("prefer_codec"): + score += 40 + if parsed.get("hdr") and profile.get("prefer_hdr") in ("prefer", "require"): + score += 30 + if parsed.get("audio") in ("atmos", "truehd", "dts-hd"): + score += 15 + if profile.get("prefer_repack") and (parsed.get("repack") or parsed.get("proper")): + score += 10 + + label = " · ".join([x for x in [resolution_label(res), + (source or "").upper() if source else "", fam.upper() if fam else ""] if x]) + return {"accepted": rejected is None, "score": score, "rejected": rejected, + "tier": tier, "quality_label": label} + + +__all__ = ["resolution_rank", "resolution_label", "meets_cutoff", "evaluate_owned", + "tier_key", "evaluate_release"] diff --git a/tests/test_video_api.py b/tests/test_video_api.py index 8c21247f..d711303e 100644 --- a/tests/test_video_api.py +++ b/tests/test_video_api.py @@ -453,6 +453,31 @@ def test_quality_evaluate_endpoint_judges_owned_copy(tmp_path): videoapi._video_db = None +def test_downloads_search_endpoint_ranks_and_filters(tmp_path): + import api.video as videoapi + from database.video_database import VideoDatabase + + db = VideoDatabase(database_path=str(tmp_path / "video_library.db")) + videoapi._video_db = db + app = Flask(__name__) + app.register_blueprint(videoapi.create_video_blueprint(), url_prefix="/api/video") + client = app.test_client() + try: + d = client.post("/api/video/downloads/search", + json={"scope": "movie", "title": "The Matrix", "year": 1999}).get_json() + assert d["scope"] == "movie" and d["results"] + # accepted hits sort ahead of rejected ones; the cam hit is rejected. + accepted = [r for r in d["results"] if r["accepted"]] + assert accepted and d["results"][0]["accepted"] is True + assert any(r["rejected"] and "reject" in r["rejected"] for r in d["results"]) # the HDCAM + # a season search returns season packs (validated against the profile/scope). + s = client.post("/api/video/downloads/search", + json={"scope": "season", "title": "The Wire", "season": 2}).get_json() + assert s["results"] and all(".S02" in r["title"] for r in s["results"]) + finally: + videoapi._video_db = None + + def test_slskd_config_shared_via_config_manager(tmp_path, monkeypatch): import api.video as videoapi import config.settings as cfg diff --git a/tests/test_video_search_pipeline.py b/tests/test_video_search_pipeline.py new file mode 100644 index 00000000..257c6059 --- /dev/null +++ b/tests/test_video_search_pipeline.py @@ -0,0 +1,69 @@ +"""Search pipeline — evaluate_release (accept/reject/score vs profile + scope) and +the mock indexer. Isolated from music.""" + +from __future__ import annotations + +from core.video.mock_search import mock_search +from core.video.quality_eval import evaluate_release, tier_key +from core.video.quality_profile import default_profile +from core.video.release_parse import parse_release + + +def test_tier_key_mapping(): + assert tier_key("web-dl", "1080p") == "web-1080p" + assert tier_key("remux", "2160p") == "remux-2160p" + assert tier_key("dvd", None) == "dvd" + assert tier_key("cam", "1080p") == "" # junk has no ladder tier + + +def test_evaluate_accepts_an_enabled_tier(): + p = default_profile() # bluray-1080p etc. enabled, cutoff 1080p + v = evaluate_release(parse_release("Movie 2020 1080p BluRay x265-GRP"), p, scope="movie") + assert v["accepted"] is True and v["score"] > 0 + assert "1080p" in v["quality_label"] + + +def test_evaluate_rejects_cam_and_disabled_tier(): + p = default_profile() + cam = evaluate_release(parse_release("Movie 2020 HDCAM x264-CRUDE"), p, scope="movie") + assert cam["accepted"] is False and "reject list" in cam["rejected"] + # 2160p tiers are OFF by default → a 4K hit is filtered out (not enabled) + uhd = evaluate_release(parse_release("Movie 2020 2160p WEB-DL HEVC"), p, scope="movie") + assert uhd["accepted"] is False and "enabled tiers" in uhd["rejected"] + + +def test_evaluate_scope_validation(): + p = default_profile() + # an episode hit fails a SEASON search (not a full-season pack) + ep = parse_release("Show S02E03 1080p WEB-DL x265-GRP") + assert evaluate_release(ep, p, scope="season", want_season=2)["rejected"] == "Not a full-season pack" + # the season PACK passes the season search + pack = parse_release("Show S02 1080p WEB-DL x265-GRP") + assert evaluate_release(pack, p, scope="season", want_season=2)["accepted"] is True + # wrong season is rejected + assert evaluate_release(pack, p, scope="season", want_season=5)["rejected"] == "Wrong season" + + +def test_evaluate_size_cap_movie_only(): + p = default_profile(); p["max_movie_gb"] = 10 + big = evaluate_release(parse_release("Movie 2020 1080p BluRay x265-GRP"), p, scope="movie", size_gb=14) + assert big["accepted"] is False and "size cap" in big["rejected"] + ok = evaluate_release(parse_release("Movie 2020 1080p BluRay x265-GRP"), p, scope="movie", size_gb=8) + assert ok["accepted"] is True + + +def test_mock_search_scopes_are_shaped_right(): + movie = mock_search("movie", "The Matrix", year=1999) + assert movie and all("size_bytes" in h and "seeders" in h for h in movie) + assert "1999" in movie[0]["title"] + season = mock_search("season", "The Wire", season=2) + assert all(".S02" in h["title"] for h in season) + series = mock_search("series", "The Wire", season_end=5) + assert all("S01-S05" in h["title"] for h in series) + assert mock_search("bogus", "x") == [] + + +def test_mock_search_is_deterministic(): + a = mock_search("episode", "Severance", season=1, episode=4) + b = mock_search("episode", "Severance", season=1, episode=4) + assert a == b # no RNG → stable across calls/reloads