Add FFmpeg-based video integrity check

This commit is contained in:
2026-07-11 11:32:07 +02:00
parent 97a39709e8
commit 4c4180f28c
8 changed files with 422 additions and 4 deletions
+14
View File
@@ -21,6 +21,20 @@ Each version entry uses these sections (include only those that apply):
## Unreleased ## Unreleased
## 1.10.0 — 2026-07-07
### Added
- **Video integrity check** (`src/core/integrity.py`, *Testy → "Kontrola
integrity videa…"*): scans movies for corrupted/unreadable video data via
FFmpeg. Two depths — **Důkladná** (full `ffmpeg` decode to `-f null`, catches
mid-file data corruption, slow) and **Rychlá** (`ffprobe` container/stream
probe, catches truncated/unreadable files, fast). Runs on the selected movies
or the whole pool, with a cancelable progress dialog, and reports the bad
files (with the first decoder error) separately from those that couldn't be
checked. `FileManager.scan_video_integrity` drives the scan;
`check_video_integrity` checks one file and degrades gracefully when FFmpeg
isn't installed.
## 1.9.0 — 2026-07-07 ## 1.9.0 — 2026-07-07
### Fixed ### Fixed
+3
View File
@@ -152,6 +152,9 @@ movie table, and one-click Filmotéka generation.
## Done ## Done
- Video integrity check (`integrity.py`, Testy → "Kontrola integrity videa"):
FFmpeg-based scan for corrupted/unreadable video data, deep (full decode) or
quick (ffprobe), over selected movies or the whole pool
- Pool-root and Filmotéka-output folder settings in the global config - Pool-root and Filmotéka-output folder settings in the global config
- Filmy / Seriály top-level folder handling in the pool - Filmy / Seriály top-level folder handling in the pool
- "Import movie" dialog (Title + ČSFD link), copy into pool/Filmy as Title.ext - "Import movie" dialog (Title + ČSFD link), copy into pool/Filmy as Title.ext
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "curator" name = "curator"
version = "1.9.0" version = "1.10.0"
description = "" description = ""
authors = [ authors = [
{name = "jan.doubravsky@gmail.com"} {name = "jan.doubravsky@gmail.com"}
+1 -1
View File
@@ -1,2 +1,2 @@
"""Auto-generated — do not edit manually.""" """Auto-generated — do not edit manually."""
__version__ = "1.9.0" __version__ = "1.10.0"
+45 -1
View File
@@ -6,7 +6,8 @@ from .file import File
from .tag_manager import TagManager from .tag_manager import TagManager
from .pool_index import PoolIndex from .pool_index import PoolIndex
from .utils import list_files from .utils import list_files
from typing import Iterable from .integrity import check_video_integrity, ffmpeg_available, IntegrityResult
from typing import Callable, Iterable
from src.core.config import ( from src.core.config import (
load_global_config, save_global_config, DEFAULT_TAG_SCHEMA load_global_config, save_global_config, DEFAULT_TAG_SCHEMA
) )
@@ -226,6 +227,49 @@ class FileManager:
"disk_count": len(disk_keys), "disk_count": len(disk_keys),
} }
def scan_video_integrity(
self,
files: list[File],
deep: bool = True,
on_progress: Callable[[int, int, File], None] | None = None,
should_cancel: Callable[[], bool] | None = None,
) -> dict:
"""Check a list of movies for corrupted/unreadable video data.
Runs :func:`check_video_integrity` on each file (deep = full ffmpeg
decode, otherwise a quick ffprobe probe). ``on_progress(done, total,
file)`` is called after each file and ``should_cancel()`` is polled
before each — so a GUI can show progress and stop between files.
Returns a dict with:
ok — False if ffmpeg/ffprobe isn't available (with 'error')
problems — IntegrityResult list for files that failed (bad or
unable-to-check), in scan order
checked — how many files were actually processed
cancelled — True if the scan stopped early
"""
if not ffmpeg_available(deep):
tool = "ffmpeg" if deep else "ffprobe"
return {"ok": False, "error": f"{tool} není k dispozici (nainstaluj FFmpeg).",
"problems": [], "checked": 0, "cancelled": False}
problems: list[IntegrityResult] = []
total = len(files)
checked = 0
cancelled = False
for i, f in enumerate(files, 1):
if should_cancel is not None and should_cancel():
cancelled = True
break
result = check_video_integrity(f.file_path, deep=deep)
checked += 1
if not result.ok:
problems.append(result)
if on_progress is not None:
on_progress(i, total, f)
return {"ok": True, "problems": problems, "checked": checked,
"cancelled": cancelled}
def statistics(self) -> dict: def statistics(self) -> dict:
"""Aggregate stats over the loaded pool movies for the stats view. """Aggregate stats over the loaded pool movies for the stats view.
+114
View File
@@ -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 ""
+85 -1
View File
@@ -20,7 +20,7 @@ from PySide6.QtWidgets import (
QApplication, QMainWindow, QWidget, QSplitter, QTreeWidget, QTreeWidgetItem, QApplication, QMainWindow, QWidget, QSplitter, QTreeWidget, QTreeWidgetItem,
QTableWidget, QTableWidgetItem, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QTableWidget, QTableWidgetItem, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
QPushButton, QFileDialog, QMessageBox, QInputDialog, QDialog, QDialogButtonBox, QPushButton, QFileDialog, QMessageBox, QInputDialog, QDialog, QDialogButtonBox,
QHeaderView, QMenu, QAbstractItemView, QCheckBox, QComboBox, QHeaderView, QMenu, QAbstractItemView, QCheckBox, QComboBox, QProgressDialog,
) )
from src.core.file_manager import FileManager from src.core.file_manager import FileManager
@@ -518,6 +518,7 @@ class QtApp(QMainWindow):
tests_menu = bar.addMenu("&Testy") tests_menu = bar.addMenu("&Testy")
self._add_action(tests_menu, "Kontrola konzistence dat…", self.check_consistency) self._add_action(tests_menu, "Kontrola konzistence dat…", self.check_consistency)
self._add_action(tests_menu, "Kontrola integrity videa…", self.check_video_integrity)
def _add_action(self, menu: QMenu, text: str, slot, shortcut: str | None = None) -> QAction: def _add_action(self, menu: QMenu, text: str, slot, shortcut: str | None = None) -> QAction:
action = QAction(text, self) action = QAction(text, self)
@@ -1130,6 +1131,89 @@ class QtApp(QMainWindow):
box.setDetailedText("\n".join(detail)) box.setDetailedText("\n".join(detail))
box.exec() box.exec()
def check_video_integrity(self) -> None:
"""Test: scan movies for corrupted / unreadable video data (FFmpeg)."""
if not self.filehandler.movies_dir:
QMessageBox.information(self, "Testy", "Nejprve nastavte pool.")
return
# Scope: selected movies, or the whole pool when nothing is selected.
files = self._selected_movies() or self.filehandler.filelist
if not files:
QMessageBox.information(self, "Kontrola integrity", "Pool je prázdný.")
return
scope = "vybrané filmy" if self._selected_movies() else "celý pool"
# Depth: deep = full decode (thorough, slow), quick = ffprobe (fast).
box = QMessageBox(self)
box.setIcon(QMessageBox.Question)
box.setWindowTitle("Kontrola integrity videa")
box.setText(
f"Zkontrolovat {scope} ({len(files)}).\n\n"
"Důkladná: plné dekódování přes ffmpeg — odhalí i poškozená data "
"uvnitř souboru, ale čte celý soubor (pomalé).\n"
"Rychlá: ffprobe — odhalí nečitelné/uříznuté soubory (rychlé)."
)
deep_btn = box.addButton("Důkladná", QMessageBox.AcceptRole)
quick_btn = box.addButton("Rychlá", QMessageBox.AcceptRole)
box.addButton("Zrušit", QMessageBox.RejectRole)
box.exec()
clicked = box.clickedButton()
if clicked not in (deep_btn, quick_btn):
return
deep = clicked is deep_btn
progress = QProgressDialog(
"Kontroluji integritu videa…", "Zrušit", 0, len(files), self)
progress.setWindowTitle("Kontrola integrity videa")
progress.setWindowModality(Qt.WindowModal)
progress.setMinimumDuration(0)
def on_progress(done: int, total: int, f: File) -> None:
progress.setValue(done)
progress.setLabelText(f"[{done}/{total}] {f.filename}")
QApplication.processEvents()
result = self.filehandler.scan_video_integrity(
files, deep=deep, on_progress=on_progress,
should_cancel=progress.wasCanceled)
progress.setValue(len(files))
if not result["ok"]:
QMessageBox.warning(self, "Kontrola integrity", result["error"])
return
problems = result["problems"]
checked = result["checked"]
suffix = " (zrušeno)" if result["cancelled"] else ""
if not problems:
QMessageBox.information(
self, "Kontrola integrity videa",
f"✅ Bez problémů — zkontrolováno {checked} souborů{suffix}.")
return
bad = [r for r in problems if r.checked]
unchecked = [r for r in problems if not r.checked]
detail: list[str] = []
if bad:
detail.append(f"■ Poškozené / vadné ({len(bad)}):")
detail += [f"{r.path.name}{r.error}" for r in bad]
detail.append("")
if unchecked:
detail.append(f"■ Nešlo zkontrolovat ({len(unchecked)}):")
detail += [f"{r.path.name}{r.error}" for r in unchecked]
msg = QMessageBox(self)
msg.setIcon(QMessageBox.Warning)
msg.setWindowTitle("Kontrola integrity — nalezeny problémy")
msg.setText(
f"Zkontrolováno: {checked}{suffix}\n"
f"Poškozené / vadné: {len(bad)}\n"
f"Nešlo zkontrolovat: {len(unchecked)}\n\n"
"Podrobnosti zobrazíš přes „Show Details…\".")
msg.setDetailedText("\n".join(detail))
msg.exec()
def closeEvent(self, event) -> None: # noqa: N802 — Qt override def closeEvent(self, event) -> None: # noqa: N802 — Qt override
self.filehandler.global_config["window_geometry"] = f"{self.width()}x{self.height()}" self.filehandler.global_config["window_geometry"] = f"{self.width()}x{self.height()}"
from src.core.config import save_global_config from src.core.config import save_global_config
+159
View File
@@ -0,0 +1,159 @@
"""Tests for video integrity checking (src.core.integrity).
The FFmpeg subprocess call is monkeypatched so the tests don't need ffmpeg
installed and don't touch real video data.
"""
import subprocess
from pathlib import Path
from types import SimpleNamespace
import pytest
from src.core import integrity
from src.core.integrity import IntegrityResult, check_video_integrity
@pytest.fixture
def video_file(tmp_path) -> Path:
f = tmp_path / "movie.mkv"
f.write_bytes(b"not really a video, but a real file")
return f
def _fake_run(returncode=0, stdout="", stderr=""):
def run(cmd, **kwargs):
return SimpleNamespace(returncode=returncode, stdout=stdout, stderr=stderr)
return run
def test_deep_clean_file_is_ok(monkeypatch, video_file):
monkeypatch.setattr(integrity.shutil, "which", lambda _: "/usr/bin/ffmpeg")
monkeypatch.setattr(integrity.subprocess, "run", _fake_run(0, "", ""))
result = check_video_integrity(video_file, deep=True)
assert result.ok and result.checked and result.error == ""
def test_deep_reports_decode_errors(monkeypatch, video_file):
monkeypatch.setattr(integrity.shutil, "which", lambda _: "/usr/bin/ffmpeg")
monkeypatch.setattr(
integrity.subprocess, "run",
_fake_run(1, "", "[matroska @ 0x..] Read error\nmore noise"))
result = check_video_integrity(video_file, deep=True)
assert not result.ok and result.checked
assert result.error == "[matroska @ 0x..] Read error" # first line only
def test_deep_stderr_without_nonzero_rc_still_fails(monkeypatch, video_file):
"""ffmpeg can print decode errors while returning rc 0 — must still fail."""
monkeypatch.setattr(integrity.shutil, "which", lambda _: "/usr/bin/ffmpeg")
monkeypatch.setattr(
integrity.subprocess, "run", _fake_run(0, "", "corrupt packet"))
result = check_video_integrity(video_file, deep=True)
assert not result.ok
def test_quick_ok_when_video_stream_present(monkeypatch, video_file):
monkeypatch.setattr(integrity.shutil, "which", lambda _: "/usr/bin/ffprobe")
monkeypatch.setattr(integrity.subprocess, "run", _fake_run(0, "video\n", ""))
result = check_video_integrity(video_file, deep=False)
assert result.ok
def test_quick_fails_without_video_stream(monkeypatch, video_file):
monkeypatch.setattr(integrity.shutil, "which", lambda _: "/usr/bin/ffprobe")
monkeypatch.setattr(integrity.subprocess, "run", _fake_run(0, "", ""))
result = check_video_integrity(video_file, deep=False)
assert not result.ok and "video stream" in result.error.lower()
def test_missing_file_is_unchecked_not_corrupt(monkeypatch, tmp_path):
monkeypatch.setattr(integrity.shutil, "which", lambda _: "/usr/bin/ffmpeg")
result = check_video_integrity(tmp_path / "nope.mkv", deep=True)
assert not result.ok and not result.checked and "neexistuje" in result.error
def test_missing_tool_is_unchecked(monkeypatch, video_file):
monkeypatch.setattr(integrity.shutil, "which", lambda _: None)
result = check_video_integrity(video_file, deep=True)
assert not result.ok and not result.checked
assert "ffmpeg" in result.error
def test_timeout_reports_failure(monkeypatch, video_file):
monkeypatch.setattr(integrity.shutil, "which", lambda _: "/usr/bin/ffmpeg")
def raise_timeout(cmd, **kwargs):
raise subprocess.TimeoutExpired(cmd, 1)
monkeypatch.setattr(integrity.subprocess, "run", raise_timeout)
result = check_video_integrity(video_file, deep=True, timeout=1)
assert not result.ok and result.checked and "timeout" in result.error.lower()
def test_ffmpeg_available_reflects_which(monkeypatch):
monkeypatch.setattr(integrity.shutil, "which", lambda _: "/usr/bin/ffmpeg")
assert integrity.ffmpeg_available(deep=True)
monkeypatch.setattr(integrity.shutil, "which", lambda _: None)
assert not integrity.ffmpeg_available(deep=True)
def test_scan_collects_problems_and_progress(monkeypatch, tmp_path):
"""FileManager.scan_video_integrity aggregates per-file results."""
from src.core.tag_manager import TagManager
from src.core.file_manager import FileManager
from src.core.file import File
good = tmp_path / "good.mkv"
bad = tmp_path / "bad.mkv"
good.write_bytes(b"x")
bad.write_bytes(b"y")
tm = TagManager()
fm = FileManager(tm)
files = [File(good, tm), File(bad, tm)]
def fake_check(path, deep=True):
ok = Path(path).name == "good.mkv"
return IntegrityResult(Path(path), ok=ok, error="" if ok else "broken")
monkeypatch.setattr("src.core.file_manager.ffmpeg_available", lambda deep: True)
monkeypatch.setattr("src.core.file_manager.check_video_integrity", fake_check)
seen: list[int] = []
result = fm.scan_video_integrity(
files, deep=True, on_progress=lambda d, t, f: seen.append(d))
assert result["ok"] and result["checked"] == 2
assert [r.path.name for r in result["problems"]] == ["bad.mkv"]
assert seen == [1, 2]
def test_scan_stops_on_cancel(monkeypatch, tmp_path):
from src.core.tag_manager import TagManager
from src.core.file_manager import FileManager
from src.core.file import File
files = []
for i in range(3):
p = tmp_path / f"m{i}.mkv"
p.write_bytes(b"x")
files.append(File(p, TagManager()))
monkeypatch.setattr("src.core.file_manager.ffmpeg_available", lambda deep: True)
monkeypatch.setattr(
"src.core.file_manager.check_video_integrity",
lambda path, deep=True: IntegrityResult(Path(path), ok=True))
fm = FileManager(TagManager())
result = fm.scan_video_integrity(files, should_cancel=lambda: True)
assert result["cancelled"] and result["checked"] == 0
def test_scan_reports_missing_ffmpeg(monkeypatch, tmp_path):
from src.core.tag_manager import TagManager
from src.core.file_manager import FileManager
monkeypatch.setattr("src.core.file_manager.ffmpeg_available", lambda deep: False)
fm = FileManager(TagManager())
result = fm.scan_video_integrity([], deep=True)
assert not result["ok"] and "ffmpeg" in result["error"]