soulsync/core/video/mediainfo.py
BoulderBadgeDad efa64db04d Video downloads: best-in-class post-process pipeline
End-to-end import for video grabs, mirroring the music side's rigor and the
Radarr/Sonarr standard, fully isolated in core/video + api/video.

- Importer: parse release -> ffprobe-verify (true resolution, reject corrupt/
  samples) -> templated rename into Movie (Year)/ + Show/Season NN/ -> copy or
  move, carry subtitles, upgrade-replace a worse copy.
- Library Organization settings: editable $token path templates + toggles
  (transfer mode, verify, replace, carry subs, save artwork, write NFO,
  download subtitles + langs). Stored in video.db; matches the music File
  Organization section's look.
- Sidecar writer: movie.nfo / tvshow.nfo + full artwork set (poster, fanart,
  clearlogo, season posters) from on-demand TMDB detail, and external .srt from
  OpenSubtitles. Owned re-grabs resolve their library tmdb_id; tmdb_full_detail
  bypasses the owned->library redirect so they enrich too.
- Import page: surfaces import_failed downloads, resolve by hand (library-first
  -> TMDB picker -> force-place) or dismiss; fires a library refresh on place.
- "Grab whole season": episode-level batch (reuses searchInto + _autoPick).
- Brutalist redesign of the download modal sources + result cards.

All new logic has seam-level tests (pure parsers/planners + injected I/O);
sidecars/subtitles are best-effort and never break an import.
2026-06-21 01:44:35 -07:00

137 lines
4.7 KiB
Python

"""Probe a finished video file with ffprobe for its TRUE media info.
We otherwise trust the release NAME for resolution/quality, and names lie: 720p
upscales labelled 1080p, trailers/samples labelled as the feature, broken muxes.
ffprobe reads the real container — duration, dimensions → resolution, codecs — so the
importer can tag the file by its actual quality and reject corrupt / too-short junk.
The parsing (``parse_ffprobe`` / ``resolution_from_dimensions``) is pure and unit-tested
on canned JSON; the subprocess runner is injected, so nothing here needs ffmpeg to be
tested. ffmpeg is OPTIONAL — when ffprobe isn't installed, or it errors, ``probe``
returns None and the caller falls back to the scene name.
Three outcomes, deliberately distinct:
- None → couldn't verify (ffprobe missing / crashed / timed out) → skip
- {"ok": False, …} → ffprobe ran and found NO video stream → corrupt / fake
- {"ok": True, …} → real media info to trust over the name
Isolated: stdlib only; no music imports.
"""
from __future__ import annotations
import json
import shutil
import subprocess
from typing import Any, Callable
_FFPROBE = "ffprobe"
def _int(v: Any) -> int:
try:
return int(v)
except (TypeError, ValueError):
return 0
def _float(v: Any) -> float:
try:
return float(v)
except (TypeError, ValueError):
return 0.0
def resolution_from_dimensions(width: Any, height: Any) -> str | None:
"""Bucket real pixel dimensions into a resolution label. Uses the LARGER axis so
a letterboxed 1920x800 movie reads as 1080p (not 720p by its short side)."""
ref = max(_int(width), _int(height))
if ref <= 0:
return None
if ref >= 3000:
return "2160p"
if ref >= 1700:
return "1080p"
if ref >= 1100:
return "720p"
return "480p"
def _norm_codec(name: Any) -> str | None:
s = str(name or "").strip().lower()
if not s:
return None
if s in ("hevc", "h265", "x265"):
return "hevc"
if s in ("h264", "avc", "x264"):
return "x264"
if s == "av1":
return "av1"
return s
def parse_ffprobe(data: Any) -> dict:
"""Parse ffprobe's ``-show_format -show_streams`` JSON into the fields we use.
``ok`` is True only when a video stream is present (else: corrupt / not a video)."""
data = data if isinstance(data, dict) else {}
streams = data.get("streams") or []
fmt = data.get("format") or {}
video = next((s for s in streams if s.get("codec_type") == "video"), None)
audio = next((s for s in streams if s.get("codec_type") == "audio"), None)
duration = _float(fmt.get("duration")) or _float((video or {}).get("duration"))
width = (video or {}).get("width")
height = (video or {}).get("height")
return {
"ok": video is not None,
"duration_sec": duration,
"width": _int(width),
"height": _int(height),
"resolution": resolution_from_dimensions(width, height) if video else None,
"video_codec": _norm_codec((video or {}).get("codec_name")),
"audio_codec": str((audio or {}).get("codec_name") or "") or None,
}
def ffprobe_available() -> bool:
return shutil.which(_FFPROBE) is not None
def _default_runner(path: str) -> str | None:
"""Run ffprobe and return its JSON stdout, or None on any failure (so a transient
ffprobe error degrades to 'unverified', never to a false 'corrupt')."""
try:
proc = subprocess.run(
[_FFPROBE, "-v", "quiet", "-print_format", "json",
"-show_format", "-show_streams", str(path)],
capture_output=True, text=True, timeout=120, check=False,
)
except Exception: # noqa: BLE001 - missing binary / timeout / OS error → unverified
return None
if proc.returncode != 0 or not (proc.stdout or "").strip():
return None
return proc.stdout
def probe(path: Any, runner: Callable | None = None) -> dict | None:
"""Probe ``path`` and return parsed media info, or None when it can't be verified.
``runner(path)->json_str|None`` is injected (real ffprobe in prod, canned in tests).
When no runner is given and ffprobe isn't installed, returns None (skip verify)."""
use = runner if runner is not None else (_default_runner if ffprobe_available() else None)
if use is None:
return None
try:
raw = use(path)
except Exception: # noqa: BLE001
return None
if not raw:
return None
try:
data = json.loads(raw)
except (ValueError, TypeError):
return None
return parse_ffprobe(data)
__all__ = [
"resolution_from_dimensions", "parse_ffprobe", "ffprobe_available", "probe",
]