Add FFmpeg-based video integrity check
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
"""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
|
||||
from typing import Optional
|
||||
|
||||
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: Optional[int] = 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
|
||||
)
|
||||
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 ""
|
||||
Reference in New Issue
Block a user