soulsync/core/video/quality_profile.py
BoulderBadgeDad 9f38c34900 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.
2026-06-19 10:40:53 -07:00

169 lines
6.8 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"
# 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, 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_resolution": "1080p",
"rejects": ["cam", "screener", "workprint", "3d"],
"prefer_codec": "hevc",
"prefer_hdr": "prefer",
"prefer_audio": "any",
"prefer_repack": True,
"max_movie_gb": 0, # per-item size guard, split by runtime (0 = no limit)
"max_episode_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"))
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):
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"]))
d["max_movie_gb"] = _clamp_size(raw.get("max_movie_gb"))
d["max_episode_gb"] = _clamp_size(raw.get("max_episode_gb"))
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", "RESOLUTIONS",
"MAX_SIZE_CAP_GB", "default_profile", "normalize", "normalize_tiers", "load", "save",
]