From 23cefd3c15d93c6dac0822ac0c11abb20748d966 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Fri, 19 Jun 2026 08:38:16 -0700 Subject: [PATCH] video quality profile: rebuild as rich-curated Radarr-class model (logic + tests) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the simplified resolutions+4-sources+codec model with a real quality ladder to actually stand in for Radarr/Sonarr: - tiers: source×resolution ladder (Remux-2160p … SDTV) as one ranked, toggleable list, best→worst, with a cutoff (stop upgrading once the library holds a tier at/above it — no endless re-grabbing). - rejects: hard blocks (cam/screener/workprint/3d, optional x264). - soft preferences (score/tie-break, never reject alone): prefer_codec (any/hevc/av1), prefer_hdr (off/prefer/require), prefer_audio (any/surround/lossless/atmos), prefer_repack. - size guard: min/max GB per item (0 = no limit), min pinned to a real cap. Pure normalize/load/save (no DB/network), isolated from music. API endpoint is an unchanged passthrough. 12 tests green, ruff clean. UI next. --- core/video/quality_profile.py | 161 ++++++++++++++++++---------- tests/test_video_api.py | 13 ++- tests/test_video_quality_profile.py | 114 +++++++++++++------- 3 files changed, 189 insertions(+), 99 deletions(-) diff --git a/core/video/quality_profile.py b/core/video/quality_profile.py index 91741b42..e21ccce6 100644 --- a/core/video/quality_profile.py +++ b/core/video/quality_profile.py @@ -1,15 +1,23 @@ -"""Video quality profile — ONE unified profile applied to every video download -source (slskd / torrent / usenet). +"""Video quality profile — ONE unified, Radarr/Sonarr-class profile applied to +every video download source (slskd / torrent / usenet). -Unlike the music side (where quality is bitrate density), video quality is -**resolution + source + codec**, parsed from a release title or filename. So the -profile is a small, source-agnostic ranking the (later-phase) download engine will -use to pick the best candidate: +Unlike the music side (where quality is bitrate density), video quality is a +**source×resolution tier** parsed from a release title, refined by codec / HDR / +audio preferences. The (later-phase) download engine uses this profile to pick the +best candidate and to decide when a library item is "good enough" (the cutoff). - - resolution tiers (2160p / 1080p / 720p / 480p), each enabled + priority-ordered - - a preferred source order (bluray > web-dl > webrip > hdtv) - - a codec preference (any / x265 / x264) and an HDR preference - - an optional max size cap per item, and a fallback toggle +The model (rich-curated — Radarr-competitive without a full custom-formats engine): + + - ``tiers`` : the source×resolution quality ladder (Remux-2160p … SDTV) + as ONE ranked list; each tier enabled + ordered best→worst. + - ``cutoff`` : once the library holds a tier at/above this rank, stop + upgrading (prevents endless re-grabbing). + - ``rejects`` : hard blocks — never grab these (cam / screener / workprint / + 3d / optionally x264). + - preferences (SOFT — they score/tie-break, they never reject on their own): + ``prefer_codec`` (any|hevc|av1), ``prefer_hdr`` (off|prefer|require), + ``prefer_audio`` (any|surround|lossless|atmos), ``prefer_repack`` (bool). + - ``min_size_gb`` / ``max_size_gb`` : size guard per item (0 = no limit). Pure data + normalize/validate here (no DB, no network) so it's unit-tested in isolation. Persisted as a JSON blob in video.db's ``video_settings['quality_profile']``. @@ -21,28 +29,48 @@ from __future__ import annotations import json from typing import Any -# Ordered best→worst; the UI renders these as the default priority order. -RESOLUTIONS = ("2160p", "1080p", "720p", "480p") -SOURCES = ("bluray", "web-dl", "webrip", "hdtv") -CODECS = ("any", "x265", "x264") -MAX_SIZE_CAP_GB = 200 # slider ceiling; 0 means "no cap" +# The quality ladder, ordered best→worst. ``key`` = ``-`` (plus +# the two resolution-less SD tiers). This is the default ranking the UI renders. +TIERS = ( + "remux-2160p", "bluray-2160p", "web-2160p", + "remux-1080p", "bluray-1080p", "web-1080p", "webrip-1080p", "hdtv-1080p", + "bluray-720p", "web-720p", "hdtv-720p", + "dvd", "sdtv", +) + +# Default-enabled tiers: solid 1080p + 720p coverage. 4K tiers off (size) and the +# SD tiers (dvd/sdtv) off — users opt into those deliberately. +_DEFAULT_ON = frozenset({ + "remux-1080p", "bluray-1080p", "web-1080p", "webrip-1080p", "hdtv-1080p", + "bluray-720p", "web-720p", "hdtv-720p", +}) + +# Hard rejects (never grabbed). x264 is offered but OFF by default (rejecting it +# would drop most releases) — power users who only want HEVC/AV1 can enable it. +REJECTS = ("cam", "screener", "workprint", "3d", "x264") + +CODECS = ("any", "hevc", "av1") # SOFT codec preference (tie-breaker) +HDR_MODES = ("off", "prefer", "require") # require = HDR-only (a real filter) +AUDIO_MODES = ("any", "surround", "lossless", "atmos") +MAX_SIZE_CAP_GB = 200 # slider ceiling; 0 means "no limit" + +_TIER_SET = frozenset(TIERS) def default_profile() -> dict: - """A sensible default: 1080p/720p on, 4K off (size), SD off.""" + """A sensible best-in-class default: full 1080p/720p ladder, cutoff at + BluRay-1080p, junk rejected, HEVC + HDR preferred (soft).""" return { - "version": 1, - "resolutions": { - "2160p": {"enabled": False, "priority": 1}, - "1080p": {"enabled": True, "priority": 2}, - "720p": {"enabled": True, "priority": 3}, - "480p": {"enabled": False, "priority": 4}, - }, - "source_priority": list(SOURCES), - "codec": "any", - "prefer_hdr": False, + "version": 2, + "tiers": [{"key": k, "enabled": k in _DEFAULT_ON} for k in TIERS], + "cutoff": "bluray-1080p", + "rejects": ["cam", "screener", "workprint", "3d"], + "prefer_codec": "hevc", + "prefer_hdr": "prefer", + "prefer_audio": "any", + "prefer_repack": True, + "min_size_gb": 0, "max_size_gb": 0, - "fallback_enabled": True, } @@ -53,39 +81,64 @@ def _coerce_int(value: Any, default: int) -> int: return default +def _clamp_size(value: Any) -> int: + return min(MAX_SIZE_CAP_GB, max(0, _coerce_int(value, 0))) + + +def normalize_tiers(value: Any) -> list: + """Rebuild the ranked tier ladder: keep the caller's order for known tiers, + coerce each ``enabled``, drop junk/dupes, then append any missing tiers in + canonical order so the ladder is always complete. Defaults from ``_DEFAULT_ON``.""" + enabled = {k: (k in _DEFAULT_ON) for k in TIERS} + order: list = [] + if isinstance(value, list): + for item in value: + if isinstance(item, dict): + k = str(item.get("key") or "").strip().lower() + else: + k = str(item or "").strip().lower() + if k in _TIER_SET and k not in order: + order.append(k) + if isinstance(item, dict) and "enabled" in item: + enabled[k] = bool(item.get("enabled")) + for k in TIERS: # complete the ladder, canonical order + if k not in order: + order.append(k) + return [{"key": k, "enabled": enabled[k]} for k in order] + + def normalize(raw: Any) -> dict: """Coerce a stored/posted profile to a valid shape, filling gaps from the - default. Unknown keys are dropped; invalid values fall back. Never raises.""" + default. Unknown keys dropped; invalid values fall back. Never raises.""" d = default_profile() if not isinstance(raw, dict): return d - res = raw.get("resolutions") - if isinstance(res, dict): - for i, key in enumerate(RESOLUTIONS): - r = res.get(key) - if isinstance(r, dict): - d["resolutions"][key] = { - "enabled": bool(r.get("enabled", d["resolutions"][key]["enabled"])), - "priority": _coerce_int(r.get("priority"), i + 1), - } + d["tiers"] = normalize_tiers(raw.get("tiers")) - sp = raw.get("source_priority") - if isinstance(sp, list): - clean = [] - for s in sp: # keep known sources, in order, no dupes/junk - if s in SOURCES and s not in clean: - clean.append(s) - for s in SOURCES: # append any the caller dropped, canonical order - if s not in clean: - clean.append(s) - d["source_priority"] = clean + cut = str(raw.get("cutoff") or "").strip().lower() + if cut in _TIER_SET: + d["cutoff"] = cut - if raw.get("codec") in CODECS: - d["codec"] = raw["codec"] - d["prefer_hdr"] = bool(raw.get("prefer_hdr", d["prefer_hdr"])) - d["max_size_gb"] = min(MAX_SIZE_CAP_GB, max(0, _coerce_int(raw.get("max_size_gb"), 0))) - d["fallback_enabled"] = bool(raw.get("fallback_enabled", True)) + rj = raw.get("rejects") + if isinstance(rj, list): + chosen = {str(x or "").strip().lower() for x in rj} + d["rejects"] = [r for r in REJECTS if r in chosen] # canonical order, valid only + + if raw.get("prefer_codec") in CODECS: + d["prefer_codec"] = raw["prefer_codec"] + if raw.get("prefer_hdr") in HDR_MODES: + d["prefer_hdr"] = raw["prefer_hdr"] + if raw.get("prefer_audio") in AUDIO_MODES: + d["prefer_audio"] = raw["prefer_audio"] + d["prefer_repack"] = bool(raw.get("prefer_repack", d["prefer_repack"])) + + mn = _clamp_size(raw.get("min_size_gb")) + mx = _clamp_size(raw.get("max_size_gb")) + if mx and mn > mx: # a min above a real cap is nonsense — pin it to the cap + mn = mx + d["min_size_gb"] = mn + d["max_size_gb"] = mx return d @@ -108,6 +161,6 @@ def save(db, raw: Any) -> dict: __all__ = [ - "RESOLUTIONS", "SOURCES", "CODECS", "MAX_SIZE_CAP_GB", - "default_profile", "normalize", "load", "save", + "TIERS", "REJECTS", "CODECS", "HDR_MODES", "AUDIO_MODES", "MAX_SIZE_CAP_GB", + "default_profile", "normalize", "normalize_tiers", "load", "save", ] diff --git a/tests/test_video_api.py b/tests/test_video_api.py index bc0f1345..3e83835f 100644 --- a/tests/test_video_api.py +++ b/tests/test_video_api.py @@ -392,13 +392,16 @@ def test_quality_profile_endpoint_roundtrips(tmp_path): app.register_blueprint(videoapi.create_video_blueprint(), url_prefix="/api/video") client = app.test_client() try: - # Default profile served when unset. + # Default profile served when unset (rich-curated model: tier ladder + cutoff). d = client.get("/api/video/downloads/quality").get_json() - assert d["resolutions"]["1080p"]["enabled"] is True and d["codec"] == "any" - # POST normalizes + persists; bad codec rejected. + assert [t["key"] for t in d["tiers"]][0] == "remux-2160p" + assert d["cutoff"] == "bluray-1080p" and d["prefer_codec"] == "hevc" + # POST normalizes + persists; bad codec rejected, valid cutoff kept. out = client.post("/api/video/downloads/quality", - json={"codec": "bogus", "max_size_gb": 50, "prefer_hdr": True}).get_json() - assert out["codec"] == "any" and out["max_size_gb"] == 50 and out["prefer_hdr"] is True + json={"prefer_codec": "bogus", "max_size_gb": 50, + "cutoff": "web-720p", "prefer_hdr": "require"}).get_json() + assert out["prefer_codec"] == "hevc" and out["max_size_gb"] == 50 + assert out["cutoff"] == "web-720p" and out["prefer_hdr"] == "require" assert client.get("/api/video/downloads/quality").get_json()["max_size_gb"] == 50 finally: videoapi._video_db = None diff --git a/tests/test_video_quality_profile.py b/tests/test_video_quality_profile.py index 22f87979..4ba79efc 100644 --- a/tests/test_video_quality_profile.py +++ b/tests/test_video_quality_profile.py @@ -1,30 +1,42 @@ -"""Video quality profile — the pure default/normalize/load/save seam (resolution -tiers + source/codec/HDR + size cap), isolated from the music side.""" +"""Video quality profile — the pure default/normalize/load/save seam for the +rich-curated (Radarr-class) model: a ranked source×resolution tier ladder + +cutoff + hard rejects + soft codec/HDR/audio/repack preferences + size guard, +isolated from the music side.""" from __future__ import annotations import json from core.video.quality_profile import ( + AUDIO_MODES, CODECS, + HDR_MODES, MAX_SIZE_CAP_GB, - RESOLUTIONS, - SOURCES, + REJECTS, + TIERS, default_profile, load, normalize, + normalize_tiers, save, ) +def _keys(tiers): + return [t["key"] for t in tiers] + + def test_default_shape(): d = default_profile() - assert set(d["resolutions"]) == set(RESOLUTIONS) - assert d["resolutions"]["1080p"]["enabled"] is True - assert d["resolutions"]["2160p"]["enabled"] is False # 4K off by default (size) - assert d["source_priority"] == list(SOURCES) - assert d["codec"] == "any" and d["prefer_hdr"] is False - assert d["max_size_gb"] == 0 and d["fallback_enabled"] is True + assert _keys(d["tiers"]) == list(TIERS) # complete ladder, canonical order + on = {t["key"] for t in d["tiers"] if t["enabled"]} + assert "bluray-1080p" in on and "web-720p" in on # 1080p/720p on + assert "remux-2160p" not in on and "sdtv" not in on # 4K + SD off by default + assert d["cutoff"] == "bluray-1080p" + assert "cam" in d["rejects"] and "x264" not in d["rejects"] # junk blocked, x264 allowed + assert d["prefer_codec"] == "hevc" and d["prefer_hdr"] == "prefer" + assert d["prefer_audio"] == "any" and d["prefer_repack"] is True + assert d["min_size_gb"] == 0 and d["max_size_gb"] == 0 def test_normalize_garbage_returns_default(): @@ -33,32 +45,57 @@ def test_normalize_garbage_returns_default(): assert normalize(123) == default_profile() -def test_normalize_fills_gaps_and_coerces(): - out = normalize({ - "resolutions": {"2160p": {"enabled": True, "priority": "1"}}, # str priority coerced - "codec": "x265", - "prefer_hdr": 1, - "max_size_gb": "75", - "fallback_enabled": False, - }) - assert out["resolutions"]["2160p"] == {"enabled": True, "priority": 1} - assert out["resolutions"]["1080p"]["enabled"] is True # untouched tier kept from default - assert out["codec"] == "x265" and out["prefer_hdr"] is True - assert out["max_size_gb"] == 75 and out["fallback_enabled"] is False +def test_normalize_tiers_preserves_order_completes_and_coerces(): + # caller sends a re-ranked subset with a couple toggled; rest must be appended + out = normalize_tiers([ + {"key": "web-1080p", "enabled": False}, + {"key": "bogus", "enabled": True}, # junk dropped + {"key": "remux-2160p", "enabled": True}, + "web-1080p", # dupe ignored + ]) + keys = _keys(out) + assert keys[0] == "web-1080p" and keys[1] == "remux-2160p" # caller order kept + assert set(keys) == set(TIERS) and len(keys) == len(TIERS) # ladder completed + by = {t["key"]: t["enabled"] for t in out} + assert by["web-1080p"] is False and by["remux-2160p"] is True + assert by["bluray-1080p"] is True # untouched default stays on -def test_normalize_rejects_bad_codec_and_clamps_size(): - assert normalize({"codec": "vp9"})["codec"] == "any" # unknown codec rejected - assert normalize({"max_size_gb": -5})["max_size_gb"] == 0 # negative clamped - assert normalize({"max_size_gb": 99999})["max_size_gb"] == MAX_SIZE_CAP_GB # capped +def test_normalize_cutoff_must_be_a_known_tier(): + assert normalize({"cutoff": "web-720p"})["cutoff"] == "web-720p" + assert normalize({"cutoff": "nonsense"})["cutoff"] == "bluray-1080p" # falls back -def test_normalize_source_priority_dedupes_and_completes(): - out = normalize({"source_priority": ["web-dl", "bogus", "web-dl"]}) - # known one first, rest appended in canonical order, no dupes, no junk - assert out["source_priority"][0] == "web-dl" - assert set(out["source_priority"]) == set(SOURCES) - assert len(out["source_priority"]) == len(SOURCES) +def test_normalize_rejects_keep_canonical_order_and_drop_junk(): + out = normalize({"rejects": ["x264", "bogus", "cam", "x264"]}) + assert out["rejects"] == ["cam", "x264"] # canonical order, valid only, deduped + + +def test_normalize_soft_prefs_validate(): + out = normalize({"prefer_codec": "av1", "prefer_hdr": "require", + "prefer_audio": "atmos", "prefer_repack": 0}) + assert out["prefer_codec"] == "av1" and out["prefer_hdr"] == "require" + assert out["prefer_audio"] == "atmos" and out["prefer_repack"] is False + # unknown enum values fall back to the default + bad = normalize({"prefer_codec": "vp9", "prefer_hdr": "maybe", "prefer_audio": "8ch"}) + assert bad["prefer_codec"] == "hevc" and bad["prefer_hdr"] == "prefer" + assert bad["prefer_audio"] == "any" + + +def test_normalize_size_clamps_and_pins_min_to_cap(): + assert normalize({"max_size_gb": -5})["max_size_gb"] == 0 + assert normalize({"max_size_gb": 99999})["max_size_gb"] == MAX_SIZE_CAP_GB + out = normalize({"min_size_gb": 50, "max_size_gb": 20}) # min above the cap + assert out["min_size_gb"] == 20 and out["max_size_gb"] == 20 + keep = normalize({"min_size_gb": 5, "max_size_gb": 0}) # 0 cap = no limit, min stays + assert keep["min_size_gb"] == 5 and keep["max_size_gb"] == 0 + + +def test_constants(): + assert CODECS == ("any", "hevc", "av1") + assert HDR_MODES == ("off", "prefer", "require") + assert AUDIO_MODES == ("any", "surround", "lossless", "atmos") + assert "cam" in REJECTS and "screener" in REJECTS # ── DB round-trip via an injected fake ──────────────────────────────────────── @@ -79,10 +116,11 @@ def test_load_default_when_unset(): def test_save_then_load_roundtrips_normalized(): db = _FakeDB() - saved = save(db, {"codec": "x264", "max_size_gb": 40, - "resolutions": {"480p": {"enabled": True, "priority": 4}}}) - assert saved["codec"] == "x264" and saved["max_size_gb"] == 40 - assert json.loads(db.get_setting("quality_profile"))["codec"] == "x264" + saved = save(db, {"prefer_codec": "av1", "max_size_gb": 40, "cutoff": "web-720p", + "tiers": [{"key": "remux-2160p", "enabled": True}]}) + assert saved["prefer_codec"] == "av1" and saved["max_size_gb"] == 40 + assert saved["cutoff"] == "web-720p" + assert json.loads(db.get_setting("quality_profile"))["prefer_codec"] == "av1" assert load(db) == saved @@ -90,7 +128,3 @@ def test_load_recovers_from_corrupt_json(): db = _FakeDB() db.set_setting("quality_profile", "{not json") assert load(db) == default_profile() - - -def test_codecs_constant(): - assert CODECS == ("any", "x265", "x264")