122 lines
4.8 KiB
Python
122 lines
4.8 KiB
Python
"""ffprobe-backed validation and measurement helpers.
|
|
|
|
Three free functions:
|
|
- ``ffprobe_validate`` — full stream-info JSON for a file.
|
|
- ``ffprobe_duration`` — duration in seconds (or None).
|
|
- ``_verify_output_resolution``— post-encode resolution check.
|
|
- ``_identify_file_type`` — `file -b` output for a path (v5-03).
|
|
|
|
Pure stdlib (subprocess + json + shutil); no internal package dependencies.
|
|
|
|
v5-03: added ``_identify_file_type`` for invalid-file diagnostics.
|
|
"""
|
|
|
|
import json
|
|
import shutil
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
# ──────────────────────────────────────────────
|
|
# FFPREPBE VALIDATION
|
|
# ──────────────────────────────────────────────
|
|
|
|
def ffprobe_validate(filepath: Path, ffprobe_bin: str) -> dict[str, object] | None:
|
|
"""Returns stream info dict or None if invalid/unreadable."""
|
|
try:
|
|
res = subprocess.run(
|
|
[ffprobe_bin, "-v", "quiet", "-print_format", "json",
|
|
"-show_format", "-show_streams", str(filepath)],
|
|
capture_output=True, text=True, timeout=30,
|
|
)
|
|
if res.returncode != 0:
|
|
return None
|
|
return json.loads(res.stdout)
|
|
except (OSError, subprocess.SubprocessError, ValueError):
|
|
# ValueError covers json.JSONDecodeError
|
|
return None
|
|
|
|
|
|
def ffprobe_duration(filepath: Path, ffprobe_bin: str) -> float | None:
|
|
"""Return media duration in seconds via ffprobe, or None on failure.
|
|
|
|
Used by the EncoderWorker post-encode integrity check to compare source
|
|
and output durations. Modeled after :func:`ffprobe_validate` — every
|
|
failure path returns ``None`` so the caller can treat unverifiable
|
|
durations as "skip the check" rather than crashing the worker thread.
|
|
"""
|
|
try:
|
|
res = subprocess.run(
|
|
[ffprobe_bin, "-v", "quiet", "-print_format", "json",
|
|
"-show_format", "-show_entries", "format=duration",
|
|
str(filepath)],
|
|
capture_output=True, text=True, timeout=10,
|
|
)
|
|
if res.returncode != 0 or not res.stdout:
|
|
return None
|
|
data = json.loads(res.stdout)
|
|
dur_str = (data.get("format") or {}).get("duration")
|
|
if dur_str is None:
|
|
return None
|
|
return float(dur_str)
|
|
except (OSError, subprocess.SubprocessError, ValueError):
|
|
# ValueError covers json.JSONDecodeError and float() parse failures
|
|
return None
|
|
|
|
|
|
def _verify_output_resolution(output_path: Path, ffprobe_bin: str, target_w: int, target_h: int) -> bool:
|
|
"""Verify that an encoded file actually has the requested output resolution.
|
|
|
|
Returns True if the output matches (or is within 2px due to force_divisible_by=2),
|
|
False otherwise.
|
|
"""
|
|
try:
|
|
res = subprocess.run(
|
|
[ffprobe_bin, "-v", "quiet", "-print_format", "json",
|
|
"-show_streams", "-select_streams", "v:0", str(output_path)],
|
|
capture_output=True, text=True, timeout=15,
|
|
)
|
|
if res.returncode != 0:
|
|
return True # can't verify, don't block
|
|
data = json.loads(res.stdout)
|
|
streams = data.get("streams", [])
|
|
if not streams:
|
|
return True
|
|
ow = int(streams[0].get("width", 0) or 0)
|
|
oh = int(streams[0].get("height", 0) or 0)
|
|
# Allow 2px tolerance (force_divisible_by=2 rounding)
|
|
if abs(ow - target_w) <= 2 and abs(oh - target_h) <= 2:
|
|
return True
|
|
return False
|
|
except (OSError, subprocess.SubprocessError, ValueError):
|
|
# ValueError covers json.JSONDecodeError and int() parse failures
|
|
return True # can't verify, don't block
|
|
|
|
|
|
def _identify_file_type(file_path: Path) -> str:
|
|
"""Run `file` on the given path and return the type string.
|
|
|
|
v5-03: Used by _validate_file to tell the user WHAT a file actually is
|
|
when ffprobe can't read it. This immediately reveals:
|
|
- "HTML document" -> failed yt-dlp download (YouTube error page saved as .mp4)
|
|
- "ASCII text" -> same as above (different yt-dlp version)
|
|
- "data" -> truncated, encrypted, or partial download
|
|
- "ISO Media, MP4 Base Media v1" -> valid MP4 that ffprobe just can't parse (rare)
|
|
|
|
Returns the first line of `file` output (minus the filename prefix),
|
|
or an empty string if `file` is not available or fails.
|
|
"""
|
|
file_bin = shutil.which("file")
|
|
if not file_bin:
|
|
return ""
|
|
try:
|
|
res = subprocess.run(
|
|
[file_bin, "-b", str(file_path)],
|
|
capture_output=True, text=True, timeout=5,
|
|
)
|
|
if res.returncode == 0:
|
|
return res.stdout.strip()
|
|
except (OSError, subprocess.SubprocessError):
|
|
pass
|
|
return ""
|
|
|