soulsync/core/video/quality_profile.py
BoulderBadgeDad 23cefd3c15 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.
2026-06-19 08:38:16 -07:00

166 lines
6.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 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).
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']``.
This module is isolated — it imports nothing from the music side.
"""
from __future__ import annotations
import json
from typing import Any
# 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 best-in-class default: full 1080p/720p ladder, cutoff at
BluRay-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",
"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,
}
def _coerce_int(value: Any, default: int) -> int:
try:
return int(value)
except (TypeError, ValueError):
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 dropped; invalid values fall back. Never raises."""
d = default_profile()
if not isinstance(raw, dict):
return d
d["tiers"] = normalize_tiers(raw.get("tiers"))
cut = str(raw.get("cutoff") or "").strip().lower()
if cut in _TIER_SET:
d["cutoff"] = cut
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
def load(db) -> dict:
"""Read + normalize the stored profile, or the default if none/garbage."""
raw = db.get_setting("quality_profile")
if raw:
try:
return normalize(json.loads(raw))
except (ValueError, TypeError):
pass
return default_profile()
def save(db, raw: Any) -> dict:
"""Normalize + persist; returns the normalized profile that was stored."""
prof = normalize(raw)
db.set_setting("quality_profile", json.dumps(prof))
return prof
__all__ = [
"TIERS", "REJECTS", "CODECS", "HDR_MODES", "AUDIO_MODES", "MAX_SIZE_CAP_GB",
"default_profile", "normalize", "normalize_tiers", "load", "save",
]