Files
Curator/src/core/integrity.py
T

128 lines
4.0 KiB
Python

"""Video integrity checking via FFmpeg.
Detects corrupted or unreadable video files. Two depths:
- **deep** (default): a full ``ffmpeg`` decode to the null muxer
(``-f null``). It actually decodes every frame, so it catches mid-file data
corruption (broken GOPs, truncated streams, bad packets). Thorough but slow —
it reads and decodes the whole file.
- **quick**: an ``ffprobe`` container/stream probe. Fast but shallow — it catches
an unreadable container, a truncated header or a missing video stream, but not
corruption buried inside otherwise-parseable data.
Both tools ship with FFmpeg; ``ffmpeg_available`` reports whether they are on the
PATH so callers can degrade gracefully.
"""
import shutil
import subprocess
from dataclasses import dataclass
from pathlib import Path
FFMPEG = "ffmpeg"
FFPROBE = "ffprobe"
# Cap a single stderr excerpt so a chatty decoder can't flood the report.
_MAX_ERROR_LEN = 300
@dataclass
class IntegrityResult:
"""Outcome of checking one file.
``ok`` — the file decoded/probed cleanly.
``checked`` — the check actually ran; False means it couldn't (missing tool,
missing file), which is *not* the same as "corrupted".
``error`` — a short reason when not ``ok``.
"""
path: Path
ok: bool
error: str = ""
checked: bool = True
def ffmpeg_available(deep: bool = True) -> bool:
"""Is the required FFmpeg tool (ffmpeg for deep, ffprobe for quick) on PATH?"""
return shutil.which(FFMPEG if deep else FFPROBE) is not None
def check_video_integrity(
path: Path, deep: bool = True, timeout: int | None = None
) -> IntegrityResult:
"""Check a single video file for corruption.
Args:
path: the video file to check.
deep: True → full ffmpeg decode (catches data corruption, slow);
False → ffprobe probe (catches unreadable/truncated files, fast).
timeout: per-file timeout in seconds (None = no limit).
Returns:
An :class:`IntegrityResult`. ``checked=False`` when the check could not
be run at all (tool or file missing) — distinct from a real failure.
"""
path = Path(path)
if not path.is_file():
return IntegrityResult(path, ok=False, error="Soubor neexistuje", checked=False)
tool = FFMPEG if deep else FFPROBE
if shutil.which(tool) is None:
return IntegrityResult(
path, ok=False, error=f"{tool} není k dispozici", checked=False
)
if deep:
cmd = [FFMPEG, "-v", "error", "-xerror", "-i", str(path), "-f", "null", "-"]
else:
cmd = [
FFPROBE,
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=codec_type",
"-of",
"csv=p=0",
str(path),
]
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
errors="replace",
timeout=timeout,
check=False,
)
except subprocess.TimeoutExpired:
return IntegrityResult(path, ok=False, error="Kontrola vypršela (timeout)")
except OSError as exc:
return IntegrityResult(path, ok=False, error=str(exc), checked=False)
stderr = (result.stderr or "").strip()
if deep:
ok = result.returncode == 0 and not stderr
else:
# Quick probe: a healthy file returns rc 0 and a "video" codec_type row.
ok = result.returncode == 0 and "video" in (result.stdout or "")
if not ok and not stderr:
stderr = "Žádný video stream / nečitelný kontejner"
if ok:
return IntegrityResult(path, ok=True)
return IntegrityResult(
path, ok=False, error=_first_error_line(stderr) or "Neznámá chyba dekódování"
)
def _first_error_line(stderr: str) -> str:
"""First non-empty stderr line, truncated — a compact reason for the report."""
for line in stderr.splitlines():
line = line.strip()
if line:
return line[:_MAX_ERROR_LEN]
return ""