video quality profile: rebuild as rich-curated Radarr-class model (logic + tests)
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.
This commit is contained in:
parent
975f81f476
commit
23cefd3c15
3 changed files with 189 additions and 99 deletions
|
|
@ -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`` = ``<source>-<resolution>`` (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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in a new issue