From 4c4180f28c6a007f4f669e45b8db1164142c7806 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Doubravsk=C3=BD?= Date: Sat, 11 Jul 2026 11:32:07 +0200 Subject: [PATCH] Add FFmpeg-based video integrity check --- CHANGELOG.md | 14 ++++ PROJECT.md | 3 + pyproject.toml | 2 +- src/_version.py | 2 +- src/core/file_manager.py | 46 ++++++++++- src/core/integrity.py | 114 ++++++++++++++++++++++++++++ src/ui/qt_app.py | 86 ++++++++++++++++++++- tests/test_integrity.py | 159 +++++++++++++++++++++++++++++++++++++++ 8 files changed, 422 insertions(+), 4 deletions(-) create mode 100644 src/core/integrity.py create mode 100644 tests/test_integrity.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c6fad6..50b731f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,20 @@ Each version entry uses these sections (include only those that apply): ## 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 ### Fixed diff --git a/PROJECT.md b/PROJECT.md index 46807a4..ede1d97 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -152,6 +152,9 @@ movie table, and one-click Filmotéka generation. ## 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 - Filmy / Seriály top-level folder handling in the pool - "Import movie" dialog (Title + ČSFD link), copy into pool/Filmy as Title.ext diff --git a/pyproject.toml b/pyproject.toml index 5ddcf1b..0eff5d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "curator" -version = "1.9.0" +version = "1.10.0" description = "" authors = [ {name = "jan.doubravsky@gmail.com"} diff --git a/src/_version.py b/src/_version.py index db8ff93..e4c71bb 100644 --- a/src/_version.py +++ b/src/_version.py @@ -1,2 +1,2 @@ """Auto-generated — do not edit manually.""" -__version__ = "1.9.0" +__version__ = "1.10.0" diff --git a/src/core/file_manager.py b/src/core/file_manager.py index f236372..0ab8df7 100644 --- a/src/core/file_manager.py +++ b/src/core/file_manager.py @@ -6,7 +6,8 @@ from .file import File from .tag_manager import TagManager from .pool_index import PoolIndex 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 ( load_global_config, save_global_config, DEFAULT_TAG_SCHEMA ) @@ -226,6 +227,49 @@ class FileManager: "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: """Aggregate stats over the loaded pool movies for the stats view. diff --git a/src/core/integrity.py b/src/core/integrity.py new file mode 100644 index 0000000..5536330 --- /dev/null +++ b/src/core/integrity.py @@ -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 "" diff --git a/src/ui/qt_app.py b/src/ui/qt_app.py index 32144e8..1ccdf3a 100644 --- a/src/ui/qt_app.py +++ b/src/ui/qt_app.py @@ -20,7 +20,7 @@ from PySide6.QtWidgets import ( QApplication, QMainWindow, QWidget, QSplitter, QTreeWidget, QTreeWidgetItem, QTableWidget, QTableWidgetItem, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QFileDialog, QMessageBox, QInputDialog, QDialog, QDialogButtonBox, - QHeaderView, QMenu, QAbstractItemView, QCheckBox, QComboBox, + QHeaderView, QMenu, QAbstractItemView, QCheckBox, QComboBox, QProgressDialog, ) from src.core.file_manager import FileManager @@ -518,6 +518,7 @@ class QtApp(QMainWindow): tests_menu = bar.addMenu("&Testy") 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: action = QAction(text, self) @@ -1130,6 +1131,89 @@ class QtApp(QMainWindow): box.setDetailedText("\n".join(detail)) 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 self.filehandler.global_config["window_geometry"] = f"{self.width()}x{self.height()}" from src.core.config import save_global_config diff --git a/tests/test_integrity.py b/tests/test_integrity.py new file mode 100644 index 0000000..32e52b5 --- /dev/null +++ b/tests/test_integrity.py @@ -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"]