Frames from proxy: '
f'{_esc(entry.thumbnail_source.name)}
')
+ # Sidecars and proxies are shown with the clip they belong to. Copied
+ # and listed as unrelated files, a missing one is invisible.
+ if entry.companions:
+ names = ", ".join(_esc(p.name) for p in entry.companions)
+ provenance += f'{_clip_meta(job, entry)}'
f'{provenance}
{strip}
'
diff --git a/src/offloader/reports/pdf.py b/src/offloader/reports/pdf.py
index fedad2b..100f431 100644
--- a/src/offloader/reports/pdf.py
+++ b/src/offloader/reports/pdf.py
@@ -69,7 +69,14 @@ def __init__(
fonts.register()
self.canvas = Canvas(str(self.path), pagesize=layout.PAGE_SIZE)
- self.canvas.setTitle(f"{job.name} Job Report")
+ # The document title is what a stack of reports is told apart by in a
+ # file manager or browser tab, so it carries the route and the date,
+ # not just a job name that may be as generic as "Offload".
+ destination = (str(job.destination_roots[0])
+ if job.destination_roots else "")
+ route = f" — {job.source_root} → {destination}" if destination else ""
+ self.canvas.setTitle(
+ f"{job.name} Job Report{route} — {job.started:%Y-%m-%d}")
self.canvas.setAuthor(PRODUCT_NAME)
self.canvas.setSubject("Verified offload report")
diff --git a/src/offloader/retry.py b/src/offloader/retry.py
index 97224fe..c2edb03 100644
--- a/src/offloader/retry.py
+++ b/src/offloader/retry.py
@@ -47,6 +47,24 @@
1167, # ERROR_DEVICE_NOT_CONNECTED
}
+class UnstableRead(OSError):
+ """Two reads of the same bytes disagreed.
+
+ The operating system reported no error at all — this is only visible to a
+ caller that read twice and compared. It is still the marginal-media signal,
+ and deserves the same second attempt, so it is transient by construction.
+ """
+
+
+class Exhausted(OSError):
+ """A failure that has already been retried as far as it is going to be.
+
+ Retrying it again at a coarser level would only repeat the same attempts
+ against the same fault, at the cost of re-reading everything that already
+ succeeded. Raised by a fine-grained retry loop to close the one above it.
+ """
+
+
#: Never retried: retrying cannot help and the delay hides the real fault.
_PERMANENT_ERRNO = {
errno.ENOENT, # the file is gone
@@ -84,6 +102,12 @@ def is_transient(exc: BaseException) -> bool:
"""Whether this failure has a plausible chance of not recurring."""
if not isinstance(exc, OSError):
return False
+ # Both are decided by what raised them, not by an errno, and `Exhausted` is
+ # checked first because it may well be wrapping something transient.
+ if isinstance(exc, Exhausted):
+ return False
+ if isinstance(exc, UnstableRead):
+ return True
winerror = getattr(exc, "winerror", None)
if winerror is not None:
return winerror in _TRANSIENT_WINERROR
diff --git a/src/offloader/thumbs.py b/src/offloader/thumbs.py
index cdf200a..4f5bd11 100644
--- a/src/offloader/thumbs.py
+++ b/src/offloader/thumbs.py
@@ -23,6 +23,28 @@ def ffmpeg_path() -> str | None:
return shutil.which("ffmpeg")
+class DecoderMemo:
+ """Remembers, per file suffix, that this ffmpeg produced no frames.
+
+ A decoder ffmpeg lacks — BRAW without the Blackmagic SDK is the common
+ case — fails identically for every clip, and paying four doomed process
+ spawns per clip is a real cost on a several-hundred-clip card. One memo
+ lives for one job: the first clip of a suffix pays the probe, the rest
+ skip. Scoped to the job rather than the process so a swapped-in ffmpeg
+ gets a fresh chance, and a single corrupt file can mute at most one
+ offload's contact sheet, never the tool's.
+ """
+
+ def __init__(self) -> None:
+ self._dead: set[str] = set()
+
+ def is_dead(self, source: Path) -> bool:
+ return source.suffix.lower() in self._dead
+
+ def record_failure(self, source: Path) -> None:
+ self._dead.add(source.suffix.lower())
+
+
def _sample_offsets(duration: float, count: int) -> list[float]:
"""Evenly spaced sample points, biased off the very start and end so we
don't grab slates or black frames."""
@@ -41,12 +63,18 @@ def extract(
out_dir: Path,
count: int = 4,
timeout: float = 60.0,
+ memo: DecoderMemo | None = None,
) -> list[Path]:
"""Grab `count` thumbnails. Returns [] if ffmpeg is missing, the file has
- no video stream, or extraction fails — thumbnails are never load-bearing."""
+ no video stream, or extraction fails — thumbnails are never load-bearing.
+
+ With a `memo`, a suffix whose every extraction failed is skipped for the
+ rest of that memo's lifetime instead of re-spawning ffmpeg per clip."""
exe = ffmpeg_path()
if exe is None or not media.is_video or not media.duration_sec:
return []
+ if memo is not None and memo.is_dead(source):
+ return []
out_dir.mkdir(parents=True, exist_ok=True)
stem = source.stem
@@ -81,4 +109,9 @@ def extract(
if proc.returncode == 0 and target.exists() and target.stat().st_size > 0:
results.append(target)
+ if memo is not None and not results:
+ # Every sample offset failed after real attempts (ffmpeg present, a
+ # video stream, a duration). The overwhelmingly likely cause is a
+ # decoder this ffmpeg does not have, which the next clip has too.
+ memo.record_failure(source)
return results
diff --git a/src/offloader/verify.py b/src/offloader/verify.py
index 3975139..794a27d 100644
--- a/src/offloader/verify.py
+++ b/src/offloader/verify.py
@@ -8,10 +8,16 @@
the moment it was copied. Re-hashing later and comparing is the only way to
detect corruption that happened *after* the offload — bit rot, a failing drive,
a bad cable on the way to the archive.
+
+An ASC MHL history records more than file hashes: every directory carries a
+content hash and a structure hash. Those are re-checked here too, because they
+catch what no file hash can — a rename, or a file moved between folders, where
+every individual file is still perfectly intact.
"""
from __future__ import annotations
+import fnmatch
import os
from collections.abc import Callable, Iterator
from dataclasses import dataclass, field
@@ -21,9 +27,15 @@
from .ascmhl import ASCMHL_DIRNAME
from .ascmhl import NAMESPACE as ASCMHL_NAMESPACE
+from .ascmhl import directory_hashes as ascmhl_directory_hashes
from .hashers import ALGORITHMS, hash_file
from .integrity import evict_from_cache
+#: Where this tool files its own paperwork, as a pattern. Only used to read
+#: histories written before the writer recorded the directory itself; a current
+#: manifest carries the real path, which handles `--report-dir` as this cannot.
+REPORT_DIRECTORY_GLOB = "*_Reports"
+
class EntryResult(str, Enum):
OK = "ok"
@@ -61,6 +73,58 @@ def describe(self) -> str:
f" actual {self.actual}")
+class DirectoryResult(str, Enum):
+ OK = "ok"
+ RENAMED = "renamed" # the same bytes under a different name or layout
+ CHANGED = "changed" # the content itself no longer hashes the same
+ MISSING = "missing" # nothing of the directory is left on disk
+
+
+@dataclass
+class DirectoryVerdict:
+ """One directory's recorded hashes against what its contents hash to now.
+
+ The two hashes answer different questions. Content covers the file hashes
+ alone, so it survives a rename. Structure folds each name in with its hash,
+ so it does not. Content matching while structure does not is therefore a
+ precise statement: nothing was corrupted, something was renamed or moved.
+ """
+
+ path: Path
+ #: Relative to the root of the managed data; `"."` is that root.
+ relative: str
+ result: DirectoryResult
+ expected_content: str | None = None
+ actual_content: str | None = None
+ expected_structure: str | None = None
+ actual_structure: str | None = None
+ #: A file inside already failed on its own hash, which is enough to account
+ #: for this. Without it a single corrupt file reads as one failure per
+ #: directory between it and the root.
+ explained_by_files: bool = False
+
+ @property
+ def ok(self) -> bool:
+ return self.result is DirectoryResult.OK
+
+ def describe(self) -> str:
+ label = f"{self.relative}/" if self.relative != "." else "(root)"
+ if self.result is DirectoryResult.OK:
+ return f"ok {label}"
+ if self.result is DirectoryResult.MISSING:
+ return f"MISSING {label}"
+ if self.result is DirectoryResult.RENAMED:
+ return (f"RENAMED {label}\n"
+ " every file still hashes as recorded, so a "
+ "name changed or a file moved")
+ tail = ("\n (accounted for by the file failures above)"
+ if self.explained_by_files else "")
+ actual = self.actual_content or "(no files remain)"
+ return (f"CHANGED {label}\n"
+ f" expected {self.expected_content}\n"
+ f" actual {actual}{tail}")
+
+
@dataclass
class VerifyReport:
manifest: Path
@@ -68,6 +132,8 @@ class VerifyReport:
verdicts: list[FileVerdict] = field(default_factory=list)
#: Files present on disk that the manifest does not mention.
unlisted: list[Path] = field(default_factory=list)
+ #: One per directory hash the manifest recorded. Only ASC MHL has these.
+ directories: list[DirectoryVerdict] = field(default_factory=list)
@property
def checked(self) -> int:
@@ -77,9 +143,14 @@ def checked(self) -> int:
def failures(self) -> list[FileVerdict]:
return [v for v in self.verdicts if not v.ok]
+ @property
+ def directory_failures(self) -> list[DirectoryVerdict]:
+ return [v for v in self.directories if not v.ok]
+
@property
def passed(self) -> bool:
- return bool(self.verdicts) and not self.failures
+ return (bool(self.verdicts) and not self.failures
+ and not self.directory_failures)
def counts(self) -> dict[str, int]:
tally: dict[str, int] = {}
@@ -91,9 +162,25 @@ def summary(self) -> str:
if not self.verdicts:
return "manifest listed no files with checksums"
if self.passed:
+ if self.directories:
+ return (f"all {self.checked} files and all "
+ f"{len(self.directories)} directory hashes match the "
+ "manifest")
return f"all {self.checked} files match the manifest"
+ if not self.failures and self.directory_failures:
+ # Every file matched and the report still did not pass. Listing the
+ # file tally first would open "N checked: N ok", which reads as a
+ # pass to anyone scanning — and this is the one verdict where the
+ # file hashes agreeing is the point rather than the reassurance.
+ return (f"{len(self.directory_failures)} of {len(self.directories)} "
+ f"directory hashes differ; all {self.checked} files match, "
+ "so the bytes are intact and the tree is not")
parts = [f"{count} {name}" for name, count in sorted(self.counts().items())]
- return f"{self.checked} checked: " + ", ".join(parts)
+ line = f"{self.checked} checked: " + ", ".join(parts)
+ if self.directory_failures:
+ line += (f"; {len(self.directory_failures)} of "
+ f"{len(self.directories)} directory hashes differ")
+ return line
#: MHL element name -> our algorithm key.
@@ -163,25 +250,72 @@ def verify_manifest(
progress: Callable[[int, int, Path], None] | None = None,
bypass_cache: bool = True,
find_unlisted: bool = True,
+ check_directories: bool = True,
) -> VerifyReport:
"""Re-hash everything an MHL describes and compare.
`bypass_cache` evicts each file before reading it, so a freshly written tree
is read off the device rather than out of memory.
+
+ `check_directories` recomputes an ASC MHL manifest's directory hashes from
+ what is on disk. That means hashing the files the manifest does *not* list
+ as well: a renamed file is unlisted under its new name, and hashing it is
+ what turns "one file missing, one file unexpected" into the far stronger
+ "these are the same bytes, the name changed".
"""
manifest = Path(manifest)
entries = list(_entries(manifest))
report = VerifyReport(manifest=manifest,
algorithm=next((e[1] for e in entries if e[1]), "unknown"))
+ # ASC MHL keeps its manifests in `ascmhl/` at the root of the managed data,
+ # so the folder above is what the recorded paths are relative to.
+ managed_root = manifest.parent.parent
+ ascmhl_root = _ascmhl_root(manifest) if check_directories else None
+ recorded: dict[str, tuple[str, str]] = {}
+ if ascmhl_root is not None:
+ recorded, directory_algorithm = _ascmhl_directory_hashes(ascmhl_root)
+ # Recomputing is only meaningful when the directory hashes and the file
+ # hashes were taken with the same algorithm.
+ if not recorded or directory_algorithm != report.algorithm:
+ ascmhl_root, recorded = None, {}
+
+ #: Relative POSIX path -> what it hashes to now, feeding the directory pass.
+ on_disk: dict[str, str] = {}
+ #: Paths that can no longer stand as evidence, so that one bad file is not
+ #: re-reported as a fresh failure for every directory above it.
+ unsound: set[str] = set()
+ #: Paths on disk the manifest never mentioned. A directory that gained one
+ #: is *not* explained by its file failures, however many it has.
+ unexpected: set[str] = set()
+
+ def note(path: Path, digest: str | None) -> str | None:
+ """Record what a file hashes to now — a `digest` of `None` meaning it
+ cannot stand as evidence at all. Returns the key it was filed under."""
+ if ascmhl_root is None:
+ return None
+ try:
+ relative = path.relative_to(managed_root).as_posix()
+ except ValueError:
+ return None
+ if digest is None:
+ unsound.add(relative)
+ else:
+ on_disk[relative] = digest
+ return relative
+
for index, (path, algorithm_key, expected, expected_size) in enumerate(entries):
if progress:
progress(index, len(entries), path)
if algorithm_key is None or not expected:
+ # Including a hash the manifest itself disowned would contradict the
+ # writer, which left it out of the directory hashes for the same
+ # reason. Deliberately not noted either way.
report.verdicts.append(FileVerdict(path, EntryResult.NO_CHECKSUM))
continue
if not path.exists():
+ note(path, None)
report.verdicts.append(
FileVerdict(path, EntryResult.MISSING, expected=expected,
expected_size=expected_size))
@@ -193,12 +327,16 @@ def verify_manifest(
evict_from_cache(path)
actual = hash_file(path, algorithm_key)
except OSError as exc:
+ note(path, None)
report.verdicts.append(
FileVerdict(path, EntryResult.UNREADABLE, expected=expected,
detail=str(exc)))
continue
matched = actual == expected
+ relative = note(path, actual)
+ if not matched and relative is not None:
+ unsound.add(relative)
report.verdicts.append(FileVerdict(
path,
EntryResult.OK if matched else EntryResult.MISMATCH,
@@ -206,20 +344,183 @@ def verify_manifest(
expected_size=expected_size, actual_size=actual_size,
))
- if find_unlisted:
+ # The directory pass needs the unexpected files hashed, so it scans even
+ # when the caller did not ask for them to be reported.
+ if find_unlisted or ascmhl_root is not None:
listed = {p.resolve() for p, _, _, _ in entries}
- for candidate in _described_root(manifest, listed).rglob("*"):
+ patterns = _ignore_patterns(ascmhl_root) if ascmhl_root is not None else []
+ scan_root = (managed_root if ascmhl_root is not None
+ else _described_root(manifest, listed))
+ # Manifests written before the report directory was recorded as ignored
+ # say nothing about it, and folding the tool's own paperwork into a
+ # recomputed hash reports the report as a change to the tree. Keyed off
+ # the absence of any recorded pattern but the history's own folder, so
+ # it stops applying the moment a manifest describes its own layout.
+ legacy_paperwork = not [p for p in patterns if p != ASCMHL_DIRNAME]
+
+ for candidate in scan_root.rglob("*"):
if not candidate.is_file() or candidate.suffix.lower() == ".mhl":
continue
# The history's own bookkeeping is not managed data.
if ASCMHL_DIRNAME in candidate.parts:
continue
- if candidate.resolve() not in listed:
+ if candidate.resolve() in listed:
+ continue
+ relative = candidate.relative_to(scan_root).as_posix()
+ if _ignored(relative, patterns):
+ continue
+
+ if find_unlisted:
report.unlisted.append(candidate)
+ if ascmhl_root is not None:
+ if legacy_paperwork and _ignored(relative, [REPORT_DIRECTORY_GLOB]):
+ continue
+ unexpected.add(relative)
+ try:
+ if bypass_cache:
+ evict_from_cache(candidate)
+ on_disk[relative] = hash_file(candidate, report.algorithm)
+ except OSError:
+ unsound.add(relative)
+
+ if recorded:
+ report.directories = _directory_verdicts(
+ managed_root, recorded, report.algorithm, on_disk,
+ unsound, unexpected)
return report
+def _ascmhl_root(manifest: Path) -> ET.Element | None:
+ """The parsed manifest, but only if it is an ASC MHL one.
+
+ Classic MHL 1.1 has no concept of a directory hash, so there is nothing to
+ re-check and nothing to read.
+ """
+ root = ET.parse(manifest).getroot()
+ return root if root.tag == f"{{{ASCMHL_NAMESPACE}}}hashlist" else None
+
+
+def _ascmhl_directory_hashes(
+ root: ET.Element) -> tuple[dict[str, tuple[str, str]], str | None]:
+ """The recorded (content, structure) per directory, and their algorithm.
+
+ The root of the managed data is written as `