diff --git a/core/video/quality_profile.py b/core/video/quality_profile.py index e21ccce6..6a01c6a4 100644 --- a/core/video/quality_profile.py +++ b/core/video/quality_profile.py @@ -54,23 +54,29 @@ 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" +# The cutoff is a LOOSE resolution target (Radarr-style "upgrade until"): once the +# library holds an item at this resolution or better, stop chasing upgrades. "" +# (empty) means "best available — always upgrade". Always offered in full, regardless +# of which specific tiers are toggled on. +RESOLUTIONS = ("2160p", "1080p", "720p", "480p") + _TIER_SET = frozenset(TIERS) def default_profile() -> dict: - """A sensible best-in-class default: full 1080p/720p ladder, cutoff at - BluRay-1080p, junk rejected, HEVC + HDR preferred (soft).""" + """A sensible best-in-class default: full 1080p/720p ladder, loose cutoff at + 1080p, junk rejected, HEVC + HDR preferred (soft).""" return { "version": 2, "tiers": [{"key": k, "enabled": k in _DEFAULT_ON} for k in TIERS], - "cutoff": "bluray-1080p", + "cutoff_resolution": "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, + "max_movie_gb": 0, # per-item size guard, split by runtime (0 = no limit) + "max_episode_gb": 0, } @@ -116,9 +122,10 @@ def normalize(raw: Any) -> dict: d["tiers"] = normalize_tiers(raw.get("tiers")) - cut = str(raw.get("cutoff") or "").strip().lower() - if cut in _TIER_SET: - d["cutoff"] = cut + if "cutoff_resolution" in raw: + cr = str(raw.get("cutoff_resolution") or "").strip().lower() + if cr in RESOLUTIONS or cr == "": # "" = best available / always upgrade + d["cutoff_resolution"] = cr rj = raw.get("rejects") if isinstance(rj, list): @@ -133,12 +140,8 @@ def normalize(raw: Any) -> dict: 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 + d["max_movie_gb"] = _clamp_size(raw.get("max_movie_gb")) + d["max_episode_gb"] = _clamp_size(raw.get("max_episode_gb")) return d @@ -161,6 +164,6 @@ def save(db, raw: Any) -> dict: __all__ = [ - "TIERS", "REJECTS", "CODECS", "HDR_MODES", "AUDIO_MODES", "MAX_SIZE_CAP_GB", - "default_profile", "normalize", "normalize_tiers", "load", "save", + "TIERS", "REJECTS", "CODECS", "HDR_MODES", "AUDIO_MODES", "RESOLUTIONS", + "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 3e83835f..737b65f0 100644 --- a/tests/test_video_api.py +++ b/tests/test_video_api.py @@ -395,14 +395,14 @@ def test_quality_profile_endpoint_roundtrips(tmp_path): # Default profile served when unset (rich-curated model: tier ladder + cutoff). d = client.get("/api/video/downloads/quality").get_json() 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. + assert d["cutoff_resolution"] == "1080p" and d["prefer_codec"] == "hevc" + # POST normalizes + persists; bad codec rejected, loose 4K cutoff kept. out = client.post("/api/video/downloads/quality", - 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 + json={"prefer_codec": "bogus", "max_movie_gb": 50, + "cutoff_resolution": "2160p", "prefer_hdr": "require"}).get_json() + assert out["prefer_codec"] == "hevc" and out["max_movie_gb"] == 50 + assert out["cutoff_resolution"] == "2160p" and out["prefer_hdr"] == "require" + assert client.get("/api/video/downloads/quality").get_json()["max_movie_gb"] == 50 finally: videoapi._video_db = None diff --git a/tests/test_video_quality_profile.py b/tests/test_video_quality_profile.py index 4ba79efc..1059adb0 100644 --- a/tests/test_video_quality_profile.py +++ b/tests/test_video_quality_profile.py @@ -13,6 +13,7 @@ from core.video.quality_profile import ( HDR_MODES, MAX_SIZE_CAP_GB, REJECTS, + RESOLUTIONS, TIERS, default_profile, load, @@ -32,11 +33,11 @@ def test_default_shape(): 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 d["cutoff_resolution"] == "1080p" # loose resolution target 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 + assert d["max_movie_gb"] == 0 and d["max_episode_gb"] == 0 def test_normalize_garbage_returns_default(): @@ -61,9 +62,11 @@ def test_normalize_tiers_preserves_order_completes_and_coerces(): assert by["bluray-1080p"] is True # untouched default stays on -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_cutoff_is_a_loose_resolution_target(): + assert normalize({"cutoff_resolution": "2160p"})["cutoff_resolution"] == "2160p" + assert normalize({"cutoff_resolution": ""})["cutoff_resolution"] == "" # best/always-upgrade + assert normalize({"cutoff_resolution": "nonsense"})["cutoff_resolution"] == "1080p" # falls back + assert "2160p" in RESOLUTIONS # 4K always offered def test_normalize_rejects_keep_canonical_order_and_drop_junk(): @@ -82,13 +85,11 @@ def test_normalize_soft_prefs_validate(): 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_normalize_size_splits_movie_and_episode_and_clamps(): + assert normalize({"max_movie_gb": -5})["max_movie_gb"] == 0 # negative clamped + assert normalize({"max_movie_gb": 99999})["max_movie_gb"] == MAX_SIZE_CAP_GB # capped + out = normalize({"max_movie_gb": 40, "max_episode_gb": 5}) # independent caps + assert out["max_movie_gb"] == 40 and out["max_episode_gb"] == 5 def test_constants(): @@ -116,10 +117,10 @@ def test_load_default_when_unset(): def test_save_then_load_roundtrips_normalized(): db = _FakeDB() - saved = save(db, {"prefer_codec": "av1", "max_size_gb": 40, "cutoff": "web-720p", + saved = save(db, {"prefer_codec": "av1", "max_movie_gb": 40, "cutoff_resolution": "2160p", "tiers": [{"key": "remux-2160p", "enabled": True}]}) - assert saved["prefer_codec"] == "av1" and saved["max_size_gb"] == 40 - assert saved["cutoff"] == "web-720p" + assert saved["prefer_codec"] == "av1" and saved["max_movie_gb"] == 40 + assert saved["cutoff_resolution"] == "2160p" assert json.loads(db.get_setting("quality_profile"))["prefer_codec"] == "av1" assert load(db) == saved diff --git a/webui/index.html b/webui/index.html index 4950e8eb..3d0b87cf 100644 --- a/webui/index.html +++ b/webui/index.html @@ -6117,8 +6117,14 @@