Two over-rejections that filtered out legit releases as "unknown quality": - the source parser only matched WEB-DL/WEBDL, not plain "WEB" (very common) - a release with a known resolution but no recognized source had no tier Now plain WEB parses as web-dl, and a resolution-only release assumes web so it lands on a tier instead of being rejected (ffprobe verifies the real quality after download). Truly quality-less packs still reject. Tests added.
215 lines
9.1 KiB
Python
215 lines
9.1 KiB
Python
"""Evaluate a video file/release against the quality profile.
|
|
|
|
Two consumers, one source of truth:
|
|
- the Download modal — to tell the user whether the copy they already own meets
|
|
their quality target (or is eligible for an upgrade), and
|
|
- the (later-phase) download engine — to filter/score search results.
|
|
|
|
Pure functions (no DB, no network) so they're unit-tested in isolation. Isolated —
|
|
imports only the sibling video ``quality_profile`` constants; nothing from music.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
# Resolution ranking (higher = better). The loose cutoff and the owned-vs-target
|
|
# check both compare on this rank, so "1920x1080", "1080p" and "1080" all agree.
|
|
_RES_RANK = (("2160", 4), ("4k", 4), ("1440", 3), ("1080", 3),
|
|
("720", 2), ("576", 1), ("480", 1), ("sd", 1))
|
|
_RES_LABEL = {4: "4K", 3: "1080p", 2: "720p", 1: "SD", 0: ""}
|
|
|
|
|
|
def resolution_rank(res: Any) -> int:
|
|
"""Map a raw resolution token to a rank int (4=4K … 1=SD, 0=unknown)."""
|
|
s = str(res or "").strip().lower()
|
|
for token, rank in _RES_RANK:
|
|
if token in s:
|
|
return rank
|
|
return 0
|
|
|
|
|
|
def resolution_label(res: Any) -> str:
|
|
"""A friendly resolution label ('4K' / '1080p' / '720p' / 'SD' / '')."""
|
|
return _RES_LABEL.get(resolution_rank(res), "")
|
|
|
|
|
|
def _cutoff_label(cutoff: str) -> str:
|
|
return _RES_LABEL.get(resolution_rank(cutoff), "best")
|
|
|
|
|
|
def _codec_family(codec: Any) -> str:
|
|
"""Normalise a stored video codec to a reject-list key ('x264'/'hevc'/'av1')."""
|
|
s = str(codec or "").strip().lower()
|
|
if not s:
|
|
return ""
|
|
if "av1" in s:
|
|
return "av1"
|
|
if "265" in s or "hevc" in s:
|
|
return "hevc"
|
|
if "264" in s or "avc" in s:
|
|
return "x264"
|
|
return ""
|
|
|
|
|
|
def meets_cutoff(resolution: Any, profile: dict) -> bool:
|
|
"""Does an owned item's resolution already satisfy the loose cutoff target?
|
|
An empty cutoff ('always upgrade') is never 'good enough'."""
|
|
cut = (profile or {}).get("cutoff_resolution", "")
|
|
if not cut:
|
|
return False
|
|
return resolution_rank(resolution) >= resolution_rank(cut)
|
|
|
|
|
|
def evaluate_owned(file: Any, profile: Any) -> dict:
|
|
"""Verdict for a copy the user already owns, vs their quality profile.
|
|
|
|
Returns ``{"meets": bool, "resolution_label": str, "reasons": [{ok, text}]}``
|
|
— ``meets`` False means it's eligible for an upgrade. ``reasons`` is an ordered,
|
|
render-ready list of the checks (ok=True is reassuring, ok=False explains why
|
|
an upgrade would help)."""
|
|
file = file if isinstance(file, dict) else {}
|
|
profile = profile if isinstance(profile, dict) else {}
|
|
reasons: list = []
|
|
meets = True
|
|
|
|
res = file.get("resolution")
|
|
cut = profile.get("cutoff_resolution", "")
|
|
if not cut:
|
|
meets = False
|
|
reasons.append({"ok": False, "text": "You're set to always chase the best — eligible for an upgrade."})
|
|
elif resolution_rank(res) >= resolution_rank(cut):
|
|
reasons.append({"ok": True, "text": "Meets your " + _cutoff_label(cut) + " target."})
|
|
else:
|
|
meets = False
|
|
reasons.append({"ok": False, "text": "Below your " + _cutoff_label(cut) + " target — eligible for an upgrade."})
|
|
|
|
fam = _codec_family(file.get("video_codec"))
|
|
if fam and fam in (profile.get("rejects") or []):
|
|
meets = False
|
|
reasons.append({"ok": False, "text": "Its " + fam + " codec is on your reject list."})
|
|
|
|
return {
|
|
"meets": meets,
|
|
"resolution_label": resolution_label(res),
|
|
"reasons": reasons,
|
|
}
|
|
|
|
|
|
# ── release (search hit) evaluation ───────────────────────────────────────────
|
|
# Map a parsed source → the tier-key prefix used in the quality profile's ladder.
|
|
_SRC_TIER = {"remux": "remux", "bluray": "bluray", "web-dl": "web",
|
|
"webrip": "webrip", "hdtv": "hdtv", "dvd": "dvd"}
|
|
_RES_SCORE = {"2160p": 400, "1080p": 300, "720p": 200, "480p": 100}
|
|
_SRC_SCORE = {"remux": 90, "bluray": 70, "web-dl": 55, "webrip": 40, "hdtv": 25, "dvd": 10}
|
|
|
|
|
|
def tier_key(source, resolution) -> str:
|
|
"""The quality-ladder key for a parsed (source, resolution), or '' if it isn't a
|
|
ladder tier (junk sources like cam/screener have no tier)."""
|
|
pre = _SRC_TIER.get(source)
|
|
if not pre:
|
|
# A loosely-named release with a known resolution but NO recognised source
|
|
# (very common — lots of releases tag '1080p' but not the source) → assume web
|
|
# so it still lands on a tier instead of being rejected as 'unknown quality'.
|
|
# ffprobe verifies the real quality after download.
|
|
if resolution and not source:
|
|
pre = "web"
|
|
else:
|
|
return ""
|
|
if pre == "dvd":
|
|
return "dvd"
|
|
return (pre + "-" + resolution) if resolution else ""
|
|
|
|
|
|
def _scope_ok(parsed, scope, want_season, want_episode):
|
|
"""Validate a hit actually matches what was searched (Sonarr-style): an episode
|
|
search wants SxxExx, a season search wants the whole season PACK, a show search
|
|
wants a complete-series pack."""
|
|
season, episode = parsed.get("season"), parsed.get("episode")
|
|
if scope == "movie":
|
|
return (None, None) if season is None else (None, "This is a TV release, not the movie")
|
|
if scope == "episode":
|
|
if episode is None:
|
|
return None, "Not a single episode"
|
|
if want_season is not None and season != want_season:
|
|
return None, "Wrong season"
|
|
if want_episode is not None and episode != want_episode:
|
|
return None, "Wrong episode"
|
|
return None, None
|
|
if scope == "season":
|
|
if not parsed.get("is_season_pack"):
|
|
return None, "Not a full-season pack"
|
|
if want_season is not None and season != want_season:
|
|
return None, "Wrong season"
|
|
return None, None
|
|
if scope == "series":
|
|
return (None, None) if parsed.get("is_series_pack") else (None, "Not a complete-series pack")
|
|
return None, None
|
|
|
|
|
|
def evaluate_release(parsed, profile, *, scope="movie", want_season=None,
|
|
want_episode=None, size_gb=None) -> dict:
|
|
"""Judge a parsed search hit against the quality profile + the search scope.
|
|
|
|
Returns ``{accepted, score, rejected, tier, quality_label}`` — ``accepted`` False
|
|
means it's filtered out (``rejected`` says why); ``score`` ranks the keepers."""
|
|
parsed = parsed if isinstance(parsed, dict) else {}
|
|
profile = profile if isinstance(profile, dict) else {}
|
|
res, source = parsed.get("resolution"), parsed.get("source")
|
|
rejects = profile.get("rejects") or []
|
|
rejected = None
|
|
|
|
# 1) hard rejects — junk source / 3D / rejected codec
|
|
if source in ("cam", "screener", "workprint") and source in rejects:
|
|
rejected = source + " is on your reject list"
|
|
fam = _codec_family(parsed.get("codec"))
|
|
if not rejected and fam and fam in rejects:
|
|
rejected = fam + " codec is on your reject list"
|
|
if not rejected and parsed.get("three_d") and "3d" in rejects:
|
|
rejected = "3D is on your reject list"
|
|
|
|
# 2) must be an enabled ladder tier
|
|
tier = tier_key(source, res)
|
|
if not rejected:
|
|
enabled = {t.get("key") for t in (profile.get("tiers") or []) if t.get("enabled")}
|
|
if not tier:
|
|
rejected = "Unknown / unsupported quality"
|
|
elif tier not in enabled:
|
|
rejected = (resolution_label(res) or "This quality") + " " + (source or "") + " isn't in your enabled tiers"
|
|
|
|
# 3) HDR required (a real filter when set)
|
|
if not rejected and profile.get("prefer_hdr") == "require" and not parsed.get("hdr"):
|
|
rejected = "HDR required but this is SDR"
|
|
|
|
# 4) scope validation (episode vs season pack vs series pack)
|
|
if not rejected:
|
|
_, scope_reason = _scope_ok(parsed, scope, want_season, want_episode)
|
|
if scope_reason:
|
|
rejected = scope_reason
|
|
|
|
# 5) size guard (movie/episode only — packs are legitimately large)
|
|
if not rejected and size_gb:
|
|
cap = profile.get("max_movie_gb") if scope == "movie" else (profile.get("max_episode_gb") if scope == "episode" else 0)
|
|
if cap and size_gb > cap:
|
|
rejected = "Over your " + str(cap) + " GB size cap"
|
|
|
|
# score the keepers (higher = better)
|
|
score = _RES_SCORE.get(res, 0) + _SRC_SCORE.get(source, 0)
|
|
if profile.get("prefer_codec") not in (None, "any") and fam == profile.get("prefer_codec"):
|
|
score += 40
|
|
if parsed.get("hdr") and profile.get("prefer_hdr") in ("prefer", "require"):
|
|
score += 30
|
|
if parsed.get("audio") in ("atmos", "truehd", "dts-hd"):
|
|
score += 15
|
|
if profile.get("prefer_repack") and (parsed.get("repack") or parsed.get("proper")):
|
|
score += 10
|
|
|
|
label = " · ".join([x for x in [resolution_label(res),
|
|
(source or "").upper() if source else "", fam.upper() if fam else ""] if x])
|
|
return {"accepted": rejected is None, "score": score, "rejected": rejected,
|
|
"tier": tier, "quality_label": label}
|
|
|
|
|
|
__all__ = ["resolution_rank", "resolution_label", "meets_cutoff", "evaluate_owned",
|
|
"tier_key", "evaluate_release"]
|