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:
parent
cd13b1185e
commit
9f38c34900
6 changed files with 76 additions and 75 deletions
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -6117,8 +6117,14 @@
|
|||
<div class="hybrid-source-list" id="vq-tier-rows"></div>
|
||||
</div>
|
||||
<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>
|
||||
<select id="vq-cutoff" class="vq-select"></select>
|
||||
<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">
|
||||
<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 class="vq-block">
|
||||
<div class="vq-label">Never grab <span class="vq-hint">hard rejects — skipped no matter what else matches</span></div>
|
||||
|
|
@ -6159,14 +6165,17 @@
|
|||
<span>Prefer REPACK / PROPER re-releases</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="vq-grid">
|
||||
<div class="vq-block" style="margin:0;">
|
||||
<div class="vq-label">Min size per item <span class="vq-hint" id="vq-min-label">No limit</span></div>
|
||||
<input type="range" id="vq-min-size" class="vq-slider" min="0" max="200" step="1" value="0">
|
||||
</div>
|
||||
<div class="vq-block" style="margin:0;">
|
||||
<div class="vq-label">Max size per item <span class="vq-hint" id="vq-max-label">No limit</span></div>
|
||||
<input type="range" id="vq-max-size" class="vq-slider" min="0" max="200" step="1" value="0">
|
||||
<div class="vq-block">
|
||||
<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-grid">
|
||||
<div class="vq-block" style="margin:0;">
|
||||
<div class="vq-label">Movie <span class="vq-hint" id="vq-movie-label">No limit</span></div>
|
||||
<input type="range" id="vq-movie-size" class="vq-slider" min="0" max="200" step="1" value="0">
|
||||
</div>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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 '<div class="hybrid-source-item' + (t.enabled ? '' : ' disabled') + (cut ? ' vq-cut' : '') + '">' +
|
||||
return '<div class="hybrid-source-item' + (t.enabled ? '' : ' disabled') + '">' +
|
||||
'<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 === tiers.length - 1 ? ' disabled' : '') + ' title="Move down">▼</button>' +
|
||||
'</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>' +
|
||||
'<label class="hybrid-source-toggle"><input type="checkbox" data-vq-tier-toggle="' + t.key + '"' + (t.enabled ? ' checked' : '') + '><span class="toggle-track"></span></label>' +
|
||||
'</div>';
|
||||
}).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');
|
||||
if (cut) {
|
||||
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>';
|
||||
}
|
||||
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; }
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue