diff --git a/api/video/downloads.py b/api/video/downloads.py index ae04cc14..f96a8073 100644 --- a/api/video/downloads.py +++ b/api/video/downloads.py @@ -118,7 +118,8 @@ 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"))) + episode=want_episode, season_end=_int(body.get("season_end")), + source=body.get("source")) results = [] for hit in raw: diff --git a/core/video/mock_search.py b/core/video/mock_search.py index ad734ac1..fccef60b 100644 --- a/core/video/mock_search.py +++ b/core/video/mock_search.py @@ -1,12 +1,13 @@ """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. +Given a scope (movie / episode / season / series), a title, and a SOURCE, it returns +plausible raw 'indexer hits' ({title, size_bytes, seeders}) — and each source returns +a DIFFERENT set (different release groups, seeder counts, and which qualities show up), +the way real indexers do. The real parse → evaluate → rank pipeline (release_parse + +quality_eval) runs on these exactly as it will on real hits. 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. """ @@ -17,34 +18,45 @@ from typing import Any _GB = 1024 ** 3 -# (quality suffix, size in GB). Ordered best→worst-ish; the evaluator re-ranks anyway. +# Quality strings (WITHOUT a release group — the group is appended per source below) +# and an approximate size in GB. Ordered best→worst; 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), + ("2160p.UHD.BluRay.REMUX.HDR.DV.TrueHD.Atmos", 58), + ("2160p.WEB-DL.DDP5.1.HDR.HEVC", 19), + ("1080p.BluRay.x265.10bit.DTS-HD.MA.5.1", 11), + ("1080p.WEB-DL.DDP5.1.H264", 7), + ("1080p.WEBRip.x264.AAC", 4), + ("720p.HDTV.x264", 2), + ("HDCAM.x264", 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), + ("2160p.WEB-DL.DDP5.1.HDR.HEVC", 5), + ("1080p.BluRay.x265", 3), + ("1080p.WEB-DL.H264", 2), + ("720p.HDTV.x264", 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), + ("2160p.WEB-DL.DDP5.1.HDR.HEVC", 48), + ("1080p.BluRay.x265.10bit", 26), + ("1080p.WEB-DL.H264", 18), + ("720p.HDTV.x264", 9), ] _SERIES = [ - ("COMPLETE.1080p.BluRay.x265.10bit-GROUP", 120), - ("COMPLETE.1080p.WEB-DL.H264-NTb", 88), - ("COMPLETE.720p.WEB-DL.x264-GROUP", 40), + ("COMPLETE.1080p.BluRay.x265.10bit", 120), + ("COMPLETE.1080p.WEB-DL.H264", 88), + ("COMPLETE.720p.WEB-DL.x264", 40), ] +# Per-source "flavour": which slice of the quality list shows up, a seeder multiplier, +# and a release-group pool. Makes Soulseek vs Torrent vs Usenet return distinct hits. +_SRC_FLAVOR = { + "soulseek": {"slice": (1, None), "seed": 0.5, "groups": ["YIFY", "GalaxyRG", "RARBG"]}, + "torrent": {"slice": (0, None), "seed": 1.0, "groups": ["FraMeSToR", "FLUX", "NTb", "RARBG"]}, + "usenet": {"slice": (0, -1), "seed": 1.5, "groups": ["NTb", "FLUX", "TEPES", "playWEB"]}, + "youtube": {"slice": (2, None), "seed": 1.0, "groups": ["YT"]}, +} +_DEFAULT_FLAVOR = _SRC_FLAVOR["torrent"] + def _slug(title: Any) -> str: s = "".join(c if (c.isalnum() or c == " ") else " " for c in str(title or "").strip()) @@ -67,32 +79,32 @@ def _ss(n) -> str: 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.""" + episode: Any = None, season_end: Any = None, source: Any = "torrent") -> list: + """Return plausible raw hits for a scope + source. Replace with a real indexer later.""" slug = _slug(title) scope = (scope or "movie").lower() if scope == "movie": - prefix = slug + ("." + str(year) if year else "") - rows = _MOVIE + prefix, base = slug + ("." + str(year) if year else ""), _MOVIE elif scope == "episode": prefix = slug + "." + _ss(season) + ("E%02d" % int(episode) if episode is not None else "E01") - rows = _EPISODE + base = _EPISODE elif scope == "season": - prefix = slug + "." + _ss(season) - rows = _SEASON + prefix, base = slug + "." + _ss(season), _SEASON elif scope == "series": - end = season_end or 5 - prefix = slug + ".S01-" + _ss(end) - rows = _SERIES + prefix, base = slug + ".S01-" + _ss(season_end or 5), _SERIES else: return [] + fl = _SRC_FLAVOR.get(str(source or "").lower(), _DEFAULT_FLAVOR) + lo, hi = fl["slice"] + chosen = list(enumerate(base))[lo:hi] hits = [] - for i, (suffix, gb) in enumerate(rows): + for pos, (i, (suffix, gb)) in enumerate(chosen): + group = fl["groups"][pos % len(fl["groups"])] hits.append({ - "title": prefix + "." + suffix, + "title": prefix + "." + suffix + "-" + group, "size_bytes": int(gb * _GB), - "seeders": _seeders(slug, i), + "seeders": max(1, int(_seeders(slug, i) * fl["seed"])), }) return hits diff --git a/tests/test_video_search_pipeline.py b/tests/test_video_search_pipeline.py index 257c6059..4c269616 100644 --- a/tests/test_video_search_pipeline.py +++ b/tests/test_video_search_pipeline.py @@ -64,6 +64,16 @@ def test_mock_search_scopes_are_shaped_right(): def test_mock_search_is_deterministic(): - a = mock_search("episode", "Severance", season=1, episode=4) - b = mock_search("episode", "Severance", season=1, episode=4) + a = mock_search("episode", "Severance", season=1, episode=4, source="torrent") + b = mock_search("episode", "Severance", season=1, episode=4, source="torrent") assert a == b # no RNG → stable across calls/reloads + + +def test_mock_search_differs_by_source(): + sl = mock_search("movie", "The Matrix", year=1999, source="soulseek") + to = mock_search("movie", "The Matrix", year=1999, source="usenet") + # different sources → different release sets (not the identical list) + assert [h["title"] for h in sl] != [h["title"] for h in to] + # soulseek drops the top remux; usenet drops the cam — both realistic + assert not any("REMUX" in h["title"] for h in sl) + assert not any("HDCAM" in h["title"] for h in to)