128 lines
4.0 KiB
Python
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 ""
|