"""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 ""