Add FFmpeg-based video integrity check
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "curator"
|
||||
version = "1.9.0"
|
||||
version = "1.10.0"
|
||||
description = ""
|
||||
authors = [
|
||||
{name = "jan.doubravsky@gmail.com"}
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
"""Auto-generated — do not edit manually."""
|
||||
__version__ = "1.9.0"
|
||||
__version__ = "1.10.0"
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user