video quality profile: loose resolution cutoff + movie/episode size split

Addressing UX feedback (Boulder): the cutoff and size guard were confusing for a
library that holds BOTH movies and TV.

  - 'Upgrade until' is now a LOOSE resolution target (4K / 1080p / 720p / SD /
    'best — never stop'), not a specific source×resolution tier. 4K is always in
    the list regardless of which tiers are toggled on (the old dropdown only
    listed enabled tiers, so 4K vanished when off). Stored as cutoff_resolution.
  - Size guard split into Max movie size + Max episode size (runtime-aware, like
    Radarr's MB/min but human-readable) — a flat GB cap was meaningless across a
    2-hour movie and a 25-min episode. Dropped the confusing min slider.

Pure-logic + API + UI in lockstep; removed the now-dead per-row cutoff marker.
12 tests green, ruff clean, JS/HTML balance clean.
This commit is contained in:
BoulderBadgeDad 2026-06-19 10:40:53 -07:00
parent cd13b1185e
commit 9f38c34900
6 changed files with 76 additions and 75 deletions

View file

@ -54,23 +54,29 @@ HDR_MODES = ("off", "prefer", "require") # require = HDR-only (a real filter
AUDIO_MODES = ("any", "surround", "lossless", "atmos") AUDIO_MODES = ("any", "surround", "lossless", "atmos")
MAX_SIZE_CAP_GB = 200 # slider ceiling; 0 means "no limit" 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) _TIER_SET = frozenset(TIERS)
def default_profile() -> dict: def default_profile() -> dict:
"""A sensible best-in-class default: full 1080p/720p ladder, cutoff at """A sensible best-in-class default: full 1080p/720p ladder, loose cutoff at
BluRay-1080p, junk rejected, HEVC + HDR preferred (soft).""" 1080p, junk rejected, HEVC + HDR preferred (soft)."""
return { return {
"version": 2, "version": 2,
"tiers": [{"key": k, "enabled": k in _DEFAULT_ON} for k in TIERS], "tiers": [{"key": k, "enabled": k in _DEFAULT_ON} for k in TIERS],
"cutoff": "bluray-1080p", "cutoff_resolution": "1080p",
"rejects": ["cam", "screener", "workprint", "3d"], "rejects": ["cam", "screener", "workprint", "3d"],
"prefer_codec": "hevc", "prefer_codec": "hevc",
"prefer_hdr": "prefer", "prefer_hdr": "prefer",
"prefer_audio": "any", "prefer_audio": "any",
"prefer_repack": True, "prefer_repack": True,
"min_size_gb": 0, "max_movie_gb": 0, # per-item size guard, split by runtime (0 = no limit)
"max_size_gb": 0, "max_episode_gb": 0,
} }
@ -116,9 +122,10 @@ def normalize(raw: Any) -> dict:
d["tiers"] = normalize_tiers(raw.get("tiers")) d["tiers"] = normalize_tiers(raw.get("tiers"))
cut = str(raw.get("cutoff") or "").strip().lower() if "cutoff_resolution" in raw:
if cut in _TIER_SET: cr = str(raw.get("cutoff_resolution") or "").strip().lower()
d["cutoff"] = cut if cr in RESOLUTIONS or cr == "": # "" = best available / always upgrade
d["cutoff_resolution"] = cr
rj = raw.get("rejects") rj = raw.get("rejects")
if isinstance(rj, list): if isinstance(rj, list):
@ -133,12 +140,8 @@ def normalize(raw: Any) -> dict:
d["prefer_audio"] = raw["prefer_audio"] d["prefer_audio"] = raw["prefer_audio"]
d["prefer_repack"] = bool(raw.get("prefer_repack", d["prefer_repack"])) d["prefer_repack"] = bool(raw.get("prefer_repack", d["prefer_repack"]))
mn = _clamp_size(raw.get("min_size_gb")) d["max_movie_gb"] = _clamp_size(raw.get("max_movie_gb"))
mx = _clamp_size(raw.get("max_size_gb")) d["max_episode_gb"] = _clamp_size(raw.get("max_episode_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 return d
@ -161,6 +164,6 @@ def save(db, raw: Any) -> dict:
__all__ = [ __all__ = [
"TIERS", "REJECTS", "CODECS", "HDR_MODES", "AUDIO_MODES", "MAX_SIZE_CAP_GB", "TIERS", "REJECTS", "CODECS", "HDR_MODES", "AUDIO_MODES", "RESOLUTIONS",
"default_profile", "normalize", "normalize_tiers", "load", "save", "MAX_SIZE_CAP_GB", "default_profile", "normalize", "normalize_tiers", "load", "save",
] ]

View file

@ -395,14 +395,14 @@ def test_quality_profile_endpoint_roundtrips(tmp_path):
# Default profile served when unset (rich-curated model: tier ladder + cutoff). # Default profile served when unset (rich-curated model: tier ladder + cutoff).
d = client.get("/api/video/downloads/quality").get_json() d = client.get("/api/video/downloads/quality").get_json()
assert [t["key"] for t in d["tiers"]][0] == "remux-2160p" assert [t["key"] for t in d["tiers"]][0] == "remux-2160p"
assert d["cutoff"] == "bluray-1080p" and d["prefer_codec"] == "hevc" assert d["cutoff_resolution"] == "1080p" and d["prefer_codec"] == "hevc"
# POST normalizes + persists; bad codec rejected, valid cutoff kept. # POST normalizes + persists; bad codec rejected, loose 4K cutoff kept.
out = client.post("/api/video/downloads/quality", out = client.post("/api/video/downloads/quality",
json={"prefer_codec": "bogus", "max_size_gb": 50, json={"prefer_codec": "bogus", "max_movie_gb": 50,
"cutoff": "web-720p", "prefer_hdr": "require"}).get_json() "cutoff_resolution": "2160p", "prefer_hdr": "require"}).get_json()
assert out["prefer_codec"] == "hevc" and out["max_size_gb"] == 50 assert out["prefer_codec"] == "hevc" and out["max_movie_gb"] == 50
assert out["cutoff"] == "web-720p" and out["prefer_hdr"] == "require" assert out["cutoff_resolution"] == "2160p" and out["prefer_hdr"] == "require"
assert client.get("/api/video/downloads/quality").get_json()["max_size_gb"] == 50 assert client.get("/api/video/downloads/quality").get_json()["max_movie_gb"] == 50
finally: finally:
videoapi._video_db = None videoapi._video_db = None

View file

@ -13,6 +13,7 @@ from core.video.quality_profile import (
HDR_MODES, HDR_MODES,
MAX_SIZE_CAP_GB, MAX_SIZE_CAP_GB,
REJECTS, REJECTS,
RESOLUTIONS,
TIERS, TIERS,
default_profile, default_profile,
load, load,
@ -32,11 +33,11 @@ def test_default_shape():
on = {t["key"] for t in d["tiers"] if t["enabled"]} 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 "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 "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 "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_codec"] == "hevc" and d["prefer_hdr"] == "prefer"
assert d["prefer_audio"] == "any" and d["prefer_repack"] is True 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(): 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 assert by["bluray-1080p"] is True # untouched default stays on
def test_normalize_cutoff_must_be_a_known_tier(): def test_normalize_cutoff_is_a_loose_resolution_target():
assert normalize({"cutoff": "web-720p"})["cutoff"] == "web-720p" assert normalize({"cutoff_resolution": "2160p"})["cutoff_resolution"] == "2160p"
assert normalize({"cutoff": "nonsense"})["cutoff"] == "bluray-1080p" # falls back 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(): 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" assert bad["prefer_audio"] == "any"
def test_normalize_size_clamps_and_pins_min_to_cap(): def test_normalize_size_splits_movie_and_episode_and_clamps():
assert normalize({"max_size_gb": -5})["max_size_gb"] == 0 assert normalize({"max_movie_gb": -5})["max_movie_gb"] == 0 # negative clamped
assert normalize({"max_size_gb": 99999})["max_size_gb"] == MAX_SIZE_CAP_GB assert normalize({"max_movie_gb": 99999})["max_movie_gb"] == MAX_SIZE_CAP_GB # capped
out = normalize({"min_size_gb": 50, "max_size_gb": 20}) # min above the cap out = normalize({"max_movie_gb": 40, "max_episode_gb": 5}) # independent caps
assert out["min_size_gb"] == 20 and out["max_size_gb"] == 20 assert out["max_movie_gb"] == 40 and out["max_episode_gb"] == 5
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(): def test_constants():
@ -116,10 +117,10 @@ def test_load_default_when_unset():
def test_save_then_load_roundtrips_normalized(): def test_save_then_load_roundtrips_normalized():
db = _FakeDB() 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}]}) "tiers": [{"key": "remux-2160p", "enabled": True}]})
assert saved["prefer_codec"] == "av1" and saved["max_size_gb"] == 40 assert saved["prefer_codec"] == "av1" and saved["max_movie_gb"] == 40
assert saved["cutoff"] == "web-720p" assert saved["cutoff_resolution"] == "2160p"
assert json.loads(db.get_setting("quality_profile"))["prefer_codec"] == "av1" assert json.loads(db.get_setting("quality_profile"))["prefer_codec"] == "av1"
assert load(db) == saved assert load(db) == saved

View file

@ -6117,8 +6117,14 @@
<div class="hybrid-source-list" id="vq-tier-rows"></div> <div class="hybrid-source-list" id="vq-tier-rows"></div>
</div> </div>
<div class="vq-block"> <div class="vq-block">
<div class="vq-label">Upgrade until <span class="vq-hint">once the library holds this tier (or better) stop grabbing upgrades</span></div> <div class="vq-label">Upgrade until <span class="vq-hint">once you have this resolution (or better) stop chasing upgrades</span></div>
<select id="vq-cutoff" class="vq-select"></select> <select id="vq-cutoff" class="vq-select">
<option value="2160p">4K · 2160p</option>
<option value="1080p">1080p</option>
<option value="720p">720p</option>
<option value="480p">480p (SD)</option>
<option value="">Best available — never stop upgrading</option>
</select>
</div> </div>
<div class="vq-block"> <div class="vq-block">
<div class="vq-label">Never grab <span class="vq-hint">hard rejects &mdash; skipped no matter what else matches</span></div> <div class="vq-label">Never grab <span class="vq-hint">hard rejects &mdash; skipped no matter what else matches</span></div>
@ -6159,14 +6165,17 @@
<span>Prefer REPACK / PROPER re-releases</span> <span>Prefer REPACK / PROPER re-releases</span>
</label> </label>
</div> </div>
<div class="vq-grid"> <div class="vq-block">
<div class="vq-block" style="margin:0;"> <div class="vq-label">Max size <span class="vq-hint">skip anything bigger — set per type so a movie and an episode aren't judged the same</span></div>
<div class="vq-label">Min size per item <span class="vq-hint" id="vq-min-label">No limit</span></div> <div class="vq-grid">
<input type="range" id="vq-min-size" class="vq-slider" min="0" max="200" step="1" value="0"> <div class="vq-block" style="margin:0;">
</div> <div class="vq-label">Movie <span class="vq-hint" id="vq-movie-label">No limit</span></div>
<div class="vq-block" style="margin:0;"> <input type="range" id="vq-movie-size" class="vq-slider" min="0" max="200" step="1" value="0">
<div class="vq-label">Max size per item <span class="vq-hint" id="vq-max-label">No limit</span></div> </div>
<input type="range" id="vq-max-size" class="vq-slider" min="0" max="200" step="1" value="0"> <div class="vq-block" style="margin:0;">
<div class="vq-label">TV episode <span class="vq-hint" id="vq-episode-label">No limit</span></div>
<input type="range" id="vq-episode-size" class="vq-slider" min="0" max="200" step="1" value="0">
</div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -474,28 +474,21 @@
var host = document.getElementById('vq-tier-rows'); var host = document.getElementById('vq-tier-rows');
if (host) { if (host) {
host.innerHTML = tiers.map(function (t, i) { host.innerHTML = tiers.map(function (t, i) {
var cut = (p.cutoff === t.key); return '<div class="hybrid-source-item' + (t.enabled ? '' : ' disabled') + '">' +
return '<div class="hybrid-source-item' + (t.enabled ? '' : ' disabled') + (cut ? ' vq-cut' : '') + '">' +
'<span class="hybrid-source-arrows">' + '<span class="hybrid-source-arrows">' +
'<button type="button" class="hybrid-arrow-btn" data-vq-tier-move="' + t.key + '" data-dir="-1"' + (i === 0 ? ' disabled' : '') + ' title="Move up">▲</button>' + '<button type="button" class="hybrid-arrow-btn" data-vq-tier-move="' + t.key + '" data-dir="-1"' + (i === 0 ? ' disabled' : '') + ' title="Move up">▲</button>' +
'<button type="button" class="hybrid-arrow-btn" data-vq-tier-move="' + t.key + '" data-dir="1"' + (i === tiers.length - 1 ? ' disabled' : '') + ' title="Move down">▼</button>' + '<button type="button" class="hybrid-arrow-btn" data-vq-tier-move="' + t.key + '" data-dir="1"' + (i === tiers.length - 1 ? ' disabled' : '') + ' title="Move down">▼</button>' +
'</span>' + '</span>' +
'<span class="hybrid-source-name">' + (TIER_LABEL[t.key] || t.key) + (cut ? ' <span class="vq-cut-tag">cutoff</span>' : '') + '</span>' + '<span class="hybrid-source-name">' + (TIER_LABEL[t.key] || t.key) + '</span>' +
'<span class="hybrid-source-priority">' + (i + 1) + '</span>' + '<span class="hybrid-source-priority">' + (i + 1) + '</span>' +
'<label class="hybrid-source-toggle"><input type="checkbox" data-vq-tier-toggle="' + t.key + '"' + (t.enabled ? ' checked' : '') + '><span class="toggle-track"></span></label>' + '<label class="hybrid-source-toggle"><input type="checkbox" data-vq-tier-toggle="' + t.key + '"' + (t.enabled ? ' checked' : '') + '><span class="toggle-track"></span></label>' +
'</div>'; '</div>';
}).join(''); }).join('');
} }
// Cutoff dropdown — only the enabled tiers are sensible upgrade targets. // Cutoff — a loose resolution target (static <option>s); "" = always upgrade.
var cut = document.getElementById('vq-cutoff'); var cut = document.getElementById('vq-cutoff');
if (cut) { if (cut) cut.value = (p.cutoff_resolution == null ? '1080p' : p.cutoff_resolution);
var enabled = tiers.filter(function (t) { return t.enabled; });
cut.innerHTML = enabled.map(function (t) {
return '<option value="' + t.key + '"' + (p.cutoff === t.key ? ' selected' : '') + '>' + (TIER_LABEL[t.key] || t.key) + '</option>';
}).join('');
if (!enabled.length) cut.innerHTML = '<option value="">— enable a tier first —</option>';
}
// Hard rejects — toggle chips (on = blocked). // Hard rejects — toggle chips (on = blocked).
var rj = document.getElementById('vq-rejects'); var rj = document.getElementById('vq-rejects');
@ -513,11 +506,11 @@
_vqSeg('vq-audio', 'data-vq-audio', p.prefer_audio); _vqSeg('vq-audio', 'data-vq-audio', p.prefer_audio);
var rep = document.getElementById('vq-prefer-repack'); if (rep) rep.checked = !!p.prefer_repack; var rep = document.getElementById('vq-prefer-repack'); if (rep) rep.checked = !!p.prefer_repack;
// Size guard. // Size guard — split by runtime so a movie and an episode aren't judged the same.
var mn = document.getElementById('vq-min-size'); if (mn) mn.value = p.min_size_gb || 0; var mv = document.getElementById('vq-movie-size'); if (mv) mv.value = p.max_movie_gb || 0;
var mx = document.getElementById('vq-max-size'); if (mx) mx.value = p.max_size_gb || 0; var ep = document.getElementById('vq-episode-size'); if (ep) ep.value = p.max_episode_gb || 0;
_vqSizeLabel('vq-min-label', p.min_size_gb || 0); _vqSizeLabel('vq-movie-label', p.max_movie_gb || 0);
_vqSizeLabel('vq-max-label', p.max_size_gb || 0); _vqSizeLabel('vq-episode-label', p.max_episode_gb || 0);
} }
function moveTier(k, dir) { 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; } } for (var n = 0; n < arr.length; n++) { if (arr[n].key === key) { arr[n].enabled = tt.checked; break; } }
renderQuality(); saveQuality(true); return; 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-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-movie-size') { _videoQuality.max_movie_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-episode-size') { _videoQuality.max_episode_gb = parseInt(e.target.value, 10) || 0; saveQuality(true); return; }
}); });
card.addEventListener('input', function (e) { 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-movie-size') { _vqSizeLabel('vq-movie-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-episode-size') { _vqSizeLabel('vq-episode-label', parseInt(e.target.value, 10) || 0); return; }
}); });
} }

View file

@ -2971,12 +2971,7 @@ body[data-side="video"] #soulsync-toggle { display: none; }
color: rgba(255,255,255,0.4); } color: rgba(255,255,255,0.4); }
/* Quality ladder rows reuse music's .hybrid-source-item styling (set in the shared /* 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 style.css) for parity with the Download Source list no custom row CSS. The
cutoff tier gets a subtle accent rail + tag. */ cutoff is a separate loose resolution dropdown (not a per-row marker). */
.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 dropdown */ /* cutoff dropdown */
.vq-select { width: 100%; max-width: 360px; padding: 10px 13px; border-radius: 10px; .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; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); color: #fff;