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 @@
-
Upgrade until once the library holds this tier (or better) stop grabbing upgrades
- +
Upgrade until once you have this resolution (or better) stop chasing upgrades
+
Never grab hard rejects — skipped no matter what else matches
@@ -6159,14 +6165,17 @@ Prefer REPACK / PROPER re-releases
-
-
-
Min size per item No limit
- -
-
-
Max size per item No limit
- +
+
Max size skip anything bigger — set per type so a movie and an episode aren't judged the same
+
+
+
Movie No limit
+ +
+
+
TV episode No limit
+ +
diff --git a/webui/static/video/video-settings.js b/webui/static/video/video-settings.js index 0e9086f9..c2af74c0 100644 --- a/webui/static/video/video-settings.js +++ b/webui/static/video/video-settings.js @@ -474,28 +474,21 @@ var host = document.getElementById('vq-tier-rows'); if (host) { host.innerHTML = tiers.map(function (t, i) { - var cut = (p.cutoff === t.key); - return '
' + + return '
' + '' + '' + '' + '' + - '' + (TIER_LABEL[t.key] || t.key) + (cut ? ' cutoff' : '') + '' + + '' + (TIER_LABEL[t.key] || t.key) + '' + '' + (i + 1) + '' + '' + '
'; }).join(''); } - // Cutoff dropdown — only the enabled tiers are sensible upgrade targets. + // Cutoff — a loose resolution target (static '; - }).join(''); - if (!enabled.length) cut.innerHTML = ''; - } + if (cut) cut.value = (p.cutoff_resolution == null ? '1080p' : p.cutoff_resolution); // Hard rejects — toggle chips (on = blocked). var rj = document.getElementById('vq-rejects'); @@ -513,11 +506,11 @@ _vqSeg('vq-audio', 'data-vq-audio', p.prefer_audio); var rep = document.getElementById('vq-prefer-repack'); if (rep) rep.checked = !!p.prefer_repack; - // Size guard. - var mn = document.getElementById('vq-min-size'); if (mn) mn.value = p.min_size_gb || 0; - var mx = document.getElementById('vq-max-size'); if (mx) mx.value = p.max_size_gb || 0; - _vqSizeLabel('vq-min-label', p.min_size_gb || 0); - _vqSizeLabel('vq-max-label', p.max_size_gb || 0); + // Size guard — split by runtime so a movie and an episode aren't judged the same. + var mv = document.getElementById('vq-movie-size'); if (mv) mv.value = p.max_movie_gb || 0; + var ep = document.getElementById('vq-episode-size'); if (ep) ep.value = p.max_episode_gb || 0; + _vqSizeLabel('vq-movie-label', p.max_movie_gb || 0); + _vqSizeLabel('vq-episode-label', p.max_episode_gb || 0); } function moveTier(k, dir) { @@ -577,14 +570,14 @@ for (var n = 0; n < arr.length; n++) { if (arr[n].key === key) { arr[n].enabled = tt.checked; break; } } renderQuality(); saveQuality(true); return; } - if (e.target.id === 'vq-cutoff') { _videoQuality.cutoff = e.target.value; renderQuality(); saveQuality(true); return; } + if (e.target.id === 'vq-cutoff') { _videoQuality.cutoff_resolution = e.target.value; saveQuality(true); return; } if (e.target.id === 'vq-prefer-repack') { _videoQuality.prefer_repack = e.target.checked; saveQuality(true); return; } - if (e.target.id === 'vq-min-size') { _videoQuality.min_size_gb = parseInt(e.target.value, 10) || 0; saveQuality(true); return; } - if (e.target.id === 'vq-max-size') { _videoQuality.max_size_gb = parseInt(e.target.value, 10) || 0; saveQuality(true); return; } + if (e.target.id === 'vq-movie-size') { _videoQuality.max_movie_gb = parseInt(e.target.value, 10) || 0; saveQuality(true); return; } + if (e.target.id === 'vq-episode-size') { _videoQuality.max_episode_gb = parseInt(e.target.value, 10) || 0; saveQuality(true); return; } }); card.addEventListener('input', function (e) { - if (e.target.id === 'vq-min-size') { _vqSizeLabel('vq-min-label', parseInt(e.target.value, 10) || 0); return; } - if (e.target.id === 'vq-max-size') { _vqSizeLabel('vq-max-label', parseInt(e.target.value, 10) || 0); return; } + if (e.target.id === 'vq-movie-size') { _vqSizeLabel('vq-movie-label', parseInt(e.target.value, 10) || 0); return; } + if (e.target.id === 'vq-episode-size') { _vqSizeLabel('vq-episode-label', parseInt(e.target.value, 10) || 0); return; } }); } diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index 8f07f3b3..e3faf8e1 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -2971,12 +2971,7 @@ body[data-side="video"] #soulsync-toggle { display: none; } color: rgba(255,255,255,0.4); } /* Quality ladder rows reuse music's .hybrid-source-item styling (set in the shared style.css) for parity with the Download Source list — no custom row CSS. The - cutoff tier gets a subtle accent rail + tag. */ -.hybrid-source-item.vq-cut { border-left: 3px solid rgb(var(--accent-rgb)); - background: rgba(var(--accent-rgb),0.07); } -.vq-cut-tag { display: inline-block; margin-left: 9px; padding: 2px 8px; border-radius: 999px; - font-size: 10px; font-weight: 800; letter-spacing: 0.05em; text-transform: uppercase; - background: rgb(var(--accent-rgb)); color: #06210f; vertical-align: middle; } + cutoff is a separate loose resolution dropdown (not a per-row marker). */ /* cutoff dropdown */ .vq-select { width: 100%; max-width: 360px; padding: 10px 13px; border-radius: 10px; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); color: #fff;