From 3f049591cf2b4447683dbf7f3b636c8596a66680 Mon Sep 17 00:00:00 2001 From: owenpkent <20529132+owenpkent@users.noreply.github.com> Date: Sun, 9 Aug 2026 12:20:58 -0400 Subject: [PATCH 01/19] Verify the ASC MHL directory hashes, not just the file hashes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit They were written from the first release and never read back. A rename or a moved file leaves every individual file hashing exactly as recorded, so no file-level check can object to it; the structure hash exists precisely to catch that. Content matching while structure does not is now reported as RENAMED, which is a far stronger statement than the "not in manifest" line it used to produce. Recomputing means hashing the files the manifest does not list — that is what proves a rename is only a rename — while honouring the manifest's own ignore patterns, or a deliberately ignored file would fail every directory above it. A directory whose mismatch is already accounted for by a file that failed on its own hash says so, rather than repeating itself once per level up to the root; a directory that gained an unexpected file never counts as accounted for, because no file verdict can report an arrival. The hashing itself is the writer's own, exposed as `ascmhl.directory_hashes` and pinned to the reference implementation's published values. --- docs/ascmhl.md | 30 ++++- src/offloader/ascmhl.py | 35 ++++- src/offloader/verify.py | 290 +++++++++++++++++++++++++++++++++++++++- tests/test_ascmhl.py | 129 +++++++++++++++++- 4 files changed, 471 insertions(+), 13 deletions(-) diff --git a/docs/ascmhl.md b/docs/ascmhl.md index c997cda..0ba8a3f 100644 --- a/docs/ascmhl.md +++ b/docs/ascmhl.md @@ -52,6 +52,28 @@ in `processinfo/roothash`. Both are built by Appendix G: sort the hashes lexicographically, write their raw bytes into a fresh generator, digest. +`offloader verify` recomputes both from what is on disk and compares. The two +answers separate two different failures: + +| content | structure | verdict | what happened | +| --- | --- | --- | --- | +| matches | matches | `ok` | — | +| matches | differs | `RENAMED` | every byte is intact; a name changed or a file moved | +| differs | differs | `CHANGED` | the bytes under this directory are not what was recorded | + +This is the only check that can see a rename. Every file involved still hashes +correctly, so no file hash — and no amount of re-reading — will ever object. + +Recomputing means hashing the files the manifest does *not* list, since a +renamed file is unlisted under its new name and its hash is what proves the +rename is all that happened. Files matching a recorded `ignore` pattern are left +out, exactly as the writer left them out. + +A directory whose mismatch is already accounted for by a file that failed on its +own hash says so, rather than reporting a fresh problem for every directory +between that file and the root. A directory that gained an unexpected file is +never counted as accounted for — no file verdict can report an arrival. + ## C4 The chain file identifies each manifest by its C4 ID (SMPTE ST 2114): a SHA-512 @@ -105,10 +127,10 @@ the evidence the format exists to carry. ## Limits -- **Directory hashes are written but not verified.** `offloader verify` checks - file hashes; it does not recompute directory hashes, so a pure rename inside - an already-verified tree is reported through the "not in manifest" list rather - than as a structure-hash mismatch. +- **A renamed directory is reported as two facts, not one.** The old name reads + as `MISSING` and its parent as `RENAMED`; nothing states that the one became + the other. `previousPath` is what the format has for that, and it is not + written. - **No nested histories.** The spec allows an `ascmhl` folder further down the tree with its own history, and permits a parent to take a child's root hash as its directory hash. One history per destination root is written here. diff --git a/src/offloader/ascmhl.py b/src/offloader/ascmhl.py index 61e3934..13c9345 100644 --- a/src/offloader/ascmhl.py +++ b/src/offloader/ascmhl.py @@ -108,19 +108,25 @@ def _build_tree(entries: list[tuple[Path, str, str]]) -> _Node: return root -def _directory_hashes(node: _Node, algorithm_key: str) -> tuple[str, str]: +def _directory_hashes(node: _Node, algorithm_key: str, prefix: str = ".", + sink: dict[str, tuple[str, str]] | None = None, + ) -> tuple[str, str]: """(content, structure) for a directory, computed bottom-up. Only hashes that stand as evidence contribute: a `failed` hash means the file is not what it was, so folding it in would produce a directory hash that certifies a known-bad tree. + + `sink`, when given, collects every directory's pair on the way back up, + keyed by relative POSIX path. """ content_inputs: list[str] = [] structure_inputs: list[str] = [] for name in sorted(node.directories): child_content, child_structure = _directory_hashes( - node.directories[name], algorithm_key) + node.directories[name], algorithm_key, + name if prefix == "." else f"{prefix}/{name}", sink) content_inputs.append(child_content) structure_inputs.append( _structure_entry(name, child_structure, algorithm_key)) @@ -132,8 +138,31 @@ def _directory_hashes(node: _Node, algorithm_key: str) -> tuple[str, str]: content_inputs.append(digest) structure_inputs.append(_structure_entry(name, digest, algorithm_key)) - return (hash_of_hashes(content_inputs, algorithm_key), + pair = (hash_of_hashes(content_inputs, algorithm_key), hash_of_hashes(structure_inputs, algorithm_key)) + if sink is not None: + sink[prefix] = pair + return pair + + +def directory_hashes(entries: list[tuple[Path, str]], + algorithm_key: str) -> dict[str, tuple[str, str]]: + """Every directory's (content, structure) pair, keyed by relative POSIX + path, with `"."` for the root of the managed data. + + Exposed so a verifier can recompute from what is on disk what the writer + recorded. Only directories holding at least one file appear, which is + exactly the set the writer emits a `directoryhash` for. + + Pass only hashes that stand as evidence — excluding a failed one is the + caller's job here, the writer having already done it through the `action` + it recorded. + """ + sink: dict[str, tuple[str, str]] = {} + tree = _build_tree([(relative, digest, ACTION_ORIGINAL) + for relative, digest in entries]) + _directory_hashes(tree, algorithm_key, ".", sink) + return sink # ------------------------------------------------------------------ history diff --git a/src/offloader/verify.py b/src/offloader/verify.py index 3975139..ece70d1 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,6 +27,7 @@ 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 @@ -61,6 +68,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 +127,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 +138,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 +157,17 @@ 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" 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 +237,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 +314,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 +331,175 @@ 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)) + + 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: + 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 `` inside + `` rather than as one of the `` elements, so it + is keyed `"."` here to line up with what `ascmhl.directory_hashes` returns. + """ + prefix = f"{{{ASCMHL_NAMESPACE}}}" + recorded: dict[str, tuple[str, str]] = {} + algorithm_key: str | None = None + + def pair(element: ET.Element) -> tuple[str, str] | None: + nonlocal algorithm_key + sides: list[str] = [] + for side in ("content", "structure"): + holder = element.find(f"{prefix}{side}") + if holder is None: + return None + for child in holder: + name = child.tag.split("}")[-1] + if name in _TAG_TO_ALGORITHM and child.text: + sides.append(child.text.strip()) + algorithm_key = algorithm_key or _TAG_TO_ALGORITHM[name] + break + return (sides[0], sides[1]) if len(sides) == 2 else None + + for element in root.iter(f"{prefix}roothash"): + values = pair(element) + if values: + recorded["."] = values + + for element in root.iter(f"{prefix}directoryhash"): + path_element = element.find(f"{prefix}path") + if path_element is None or not path_element.text: + continue + values = pair(element) + if values: + recorded[path_element.text.strip().strip("/")] = values + + return recorded, algorithm_key + + +def _ignore_patterns(root: ET.Element) -> list[str]: + """What the writer recorded as excluded from the managed data. + + Honouring it matters more here than for the unlisted list: a file the + manifest deliberately ignored is not evidence, and folding it into a + recomputed directory hash would make every directory above it mismatch. + """ + prefix = f"{{{ASCMHL_NAMESPACE}}}" + return [element.text.strip() + for info in root.iter(f"{prefix}processinfo") + for element in info.iter(f"{prefix}pattern") + if element.text and element.text.strip()] + + +def _ignored(relative: str, patterns: list[str]) -> bool: + """A pattern matches the whole relative path or any one component of it.""" + parts = relative.split("/") + for pattern in patterns: + if fnmatch.fnmatch(relative, pattern): + return True + if any(fnmatch.fnmatch(part, pattern) for part in parts): + return True + return False + + +def _within(relative: str, directory: str) -> bool: + return (directory == "." + or relative == directory + or relative.startswith(f"{directory}/")) + + +def _directory_verdicts(base: Path, recorded: dict[str, tuple[str, str]], + algorithm_key: str, on_disk: dict[str, str], + unsound: set[str], + unexpected: set[str]) -> list[DirectoryVerdict]: + """Every recorded directory hash against the tree as it stands now.""" + computed = ascmhl_directory_hashes( + [(Path(relative), digest) for relative, digest in on_disk.items()], + algorithm_key) + + verdicts: list[DirectoryVerdict] = [] + for relative in sorted(recorded): + expected_content, expected_structure = recorded[relative] + path = base if relative == "." else base / relative + # Only fully explained when every difference underneath was already + # reported file by file. A file that arrived was not. + explained = (any(_within(bad, relative) for bad in unsound) + and not any(_within(extra, relative) for extra in unexpected)) + found = computed.get(relative) + + if found is None: + # Nothing hashable is left underneath. Whether the folder itself + # survives is the difference between emptied and gone. + verdicts.append(DirectoryVerdict( + path, relative, + DirectoryResult.CHANGED if path.is_dir() else DirectoryResult.MISSING, + expected_content=expected_content, + expected_structure=expected_structure, + explained_by_files=explained)) + continue + + actual_content, actual_structure = found + if actual_content != expected_content: + result = DirectoryResult.CHANGED + elif actual_structure != expected_structure: + result = DirectoryResult.RENAMED + else: + result = DirectoryResult.OK + + verdicts.append(DirectoryVerdict( + path, relative, result, + expected_content=expected_content, actual_content=actual_content, + expected_structure=expected_structure, actual_structure=actual_structure, + explained_by_files=explained and result is not DirectoryResult.OK)) + return verdicts + + def _ascmhl_entries(manifest: Path, root: ET.Element): """ASC MHL paths are relative to the root of the managed data, which is the parent of the `ascmhl` folder the manifest sits in.""" diff --git a/tests/test_ascmhl.py b/tests/test_ascmhl.py index 80094f8..41ba384 100644 --- a/tests/test_ascmhl.py +++ b/tests/test_ascmhl.py @@ -97,6 +97,19 @@ def test_directory_hashes_match_the_reference_implementation(): assert ascmhl.hash_of_hashes(structure, "xxh64") == REF_CLIPS_STRUCTURE +def test_directory_hashes_is_keyed_by_path_and_agrees_with_the_reference(): + """The verifier recomputes through this entry point, so it has to produce + what the writer records — including for the root, which the manifest keeps + under `roothash` rather than as a `directoryhash`.""" + hashes = ascmhl.directory_hashes( + [(Path(name), digest) for name, digest in REF_DIGESTS.items()], "xxh64") + + assert hashes["Clips"] == (REF_CLIPS_CONTENT, REF_CLIPS_STRUCTURE) + assert set(hashes) == {".", "Clips"} + # The root folds in `Clips`, so it is not the same pair. + assert hashes["."] != hashes["Clips"] + + def test_hash_of_hashes_is_order_independent(): """The list is sorted before hashing, so discovery order cannot change it.""" digests = ["ffffffffffffffff", "0000000000000000", "aaaaaaaaaaaaaaaa"] @@ -328,7 +341,121 @@ def test_classic_mhl_still_verifies(tmp_path: Path): job, destination = _offload(tmp_path, {"a.mov": b"aaaa"}) manifest = write_mhl(job, destination / "A001_Reports" / "JobReport.mhl") - assert verify.verify_manifest(manifest).passed + report = verify.verify_manifest(manifest) + assert report.passed + # MHL 1.1 has no directory hashes, so there is nothing to re-check. + assert report.directories == [] + + +# ------------------------------------------------- verifying directory hashes + + +def _directory(report: verify.VerifyReport, relative: str): + return next(v for v in report.directories if v.relative == relative) + + +def test_verify_rechecks_the_recorded_directory_hashes(history): + _job, destination, _manifest = history + report = verify.verify_manifest(verify.find_manifests(destination)[0]) + + # The root, written as `roothash`, and `Clips`, the one subdirectory. + assert sorted(v.relative for v in report.directories) == [".", "Clips"] + assert all(v.result is verify.DirectoryResult.OK for v in report.directories) + assert "directory hashes" in report.summary() + + +def test_a_rename_is_a_structure_mismatch_not_a_content_one(history): + """The whole reason the structure hash exists: every file is individually + fine, and the tree is still not what was recorded.""" + _job, destination, _manifest = history + clips = destination / "Clips" + (clips / "A002C006_141024_R2EC.mov").rename(clips / "A002C099_141024_R2EC.mov") + + report = verify.verify_manifest(verify.find_manifests(destination)[0]) + assert not report.passed + + verdict = _directory(report, "Clips") + assert verdict.result is verify.DirectoryResult.RENAMED + # Nothing was corrupted — the bytes under `Clips` hash exactly as recorded. + assert verdict.actual_content == verdict.expected_content + assert verdict.actual_structure != verdict.expected_structure + # And it propagates: the root cannot certify a tree it no longer describes. + assert _directory(report, ".").result is verify.DirectoryResult.RENAMED + # The `MISSING` line for the old name does not account for this. Something + # arrived under a new one, which no file verdict can say. + assert not verdict.explained_by_files + + +def test_a_file_moved_between_directories_is_caught(history): + """A move keeps the bytes but changes which directory owns them, so the + content hash moves with it.""" + _job, destination, _manifest = history + (destination / "Sidecar.txt").rename(destination / "Clips" / "Sidecar.txt") + + report = verify.verify_manifest(verify.find_manifests(destination)[0]) + assert not report.passed + assert _directory(report, "Clips").result is verify.DirectoryResult.CHANGED + assert _directory(report, ".").result is verify.DirectoryResult.CHANGED + + +def test_a_new_file_changes_the_directory_that_gained_it(history): + _job, destination, _manifest = history + (destination / "Clips" / "extra.mov").write_bytes(b"not in the manifest\n") + + report = verify.verify_manifest(verify.find_manifests(destination)[0]) + assert _directory(report, "Clips").result is verify.DirectoryResult.CHANGED + assert any(p.name == "extra.mov" for p in report.unlisted) + + +def test_a_deleted_directory_reads_as_missing(history): + _job, destination, _manifest = history + clips = destination / "Clips" + for child in clips.iterdir(): + child.unlink() + clips.rmdir() + + report = verify.verify_manifest(verify.find_manifests(destination)[0]) + assert _directory(report, "Clips").result is verify.DirectoryResult.MISSING + + +def test_a_corrupt_file_is_not_re_reported_for_every_directory_above_it(history): + """A flipped bit invalidates every directory hash up to the root. Saying so + three times over would bury the one line that matters.""" + _job, destination, _manifest = history + victim = destination / "Clips" / "A002C006_141024_R2EC.mov" + payload = bytearray(victim.read_bytes()) + payload[0] ^= 0x01 + victim.write_bytes(bytes(payload)) + + report = verify.verify_manifest(verify.find_manifests(destination)[0]) + verdict = _directory(report, "Clips") + assert verdict.result is verify.DirectoryResult.CHANGED + assert verdict.explained_by_files + assert "accounted for by the file failures" in verdict.describe() + + +def test_an_ignored_file_is_kept_out_of_the_recomputation(tmp_path: Path): + """A file the manifest was told to ignore is not evidence. Folding it in + would fail every directory above it for a file nobody claimed to have + copied.""" + job, destination = _offload(tmp_path, dict(PLACEHOLDER_FILES)) + (destination / "Clips" / ".DS_Store").write_bytes(b"finder droppings\n") + ascmhl.write_manifest(job, destination, when=WHEN, + ignore_patterns=["ascmhl", ".DS_Store"]) + + report = verify.verify_manifest(verify.find_manifests(destination)[0]) + assert report.passed + assert not report.unlisted + + +def test_directory_checking_can_be_turned_off(history): + _job, destination, _manifest = history + (destination / "Clips" / "A002C006_141024_R2EC.mov").rename( + destination / "Clips" / "renamed.mov") + + report = verify.verify_manifest(verify.find_manifests(destination)[0], + check_directories=False) + assert report.directories == [] # --------------------------------------------------------------- reference From 5cace9aa3ac8b4dc74099e67d2471cfd004c8e9d Mon Sep 17 00:00:00 2001 From: owenpkent <20529132+owenpkent@users.noreply.github.com> Date: Sun, 9 Aug 2026 12:20:58 -0400 Subject: [PATCH 02/19] Read the source twice on request, retry per chunk, group companion files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three engine changes that share the copy loop, so they land together. --paranoid reads every source file a second time and compares. The gap it closes is a read that returns wrong bytes without raising: the checksum is taken from whatever came back, so the destination faithfully matches a corrupted source and verifies clean at every level. A disagreement is not adjudicated — there is no basis for deciding which read was true — so it is retried, and a source that will not read the same twice fails that file and leaves nothing behind. The page cache is dropped first, and the job says so when it could not be, since a second read served from memory compares the first read against itself. It is opt-in because it costs a full extra pass. A transient read failure is now retried at the chunk that failed rather than by restarting the file: recovering a bad sector near the end of a 79 GB clip cost 79 GB and now costs 8 MiB. This needed no hasher rewind after all — a chunk is only hashed once it has arrived whole, so a failed read has produced no state to unwind. The source is reopened and sought back to the offset, because a reader that dropped off the bus needs its handle re-established. Writes still restart the whole file: a partial write leaves the destination at a length the copy loop does not know. retry.Exhausted stops the outer retry from repeating the same attempts against the same fault. Sidecars and proxies are matched to their clip by stem. A .sidecar carries a BRAW's grade; delivered without its clip it is nothing, and the clip delivered without it has silently lost the grade. An ambiguous stem — two takes of the same name in different folders — is left unlinked rather than guessed at. A clip that copies while a file belonging to it does not is now a job warning instead of two rows twenty lines apart. Also fixes a deadlock introduced while doing the above: closing the source moved out of a `with` block into a `finally`, and a close that raised skipped the end-of-file sentinel and hung the consumer forever. Closing now swallows everything, and there is a regression test for a source whose close() raises. --- docs/data-safety.md | 80 +++++++++- src/offloader/companions.py | 64 ++++++++ src/offloader/engine.py | 223 +++++++++++++++++++++++++--- src/offloader/models.py | 9 ++ src/offloader/presets.py | 5 + src/offloader/reports/csv_report.py | 9 +- src/offloader/reports/html.py | 17 ++- src/offloader/retry.py | 24 +++ tests/test_braw.py | 60 ++++++++ tests/test_data_safety.py | 143 +++++++++++++++++- tests/test_engine.py | 99 +++++++++++- tests/test_presets.py | 15 ++ tests/test_retry.py | 188 ++++++++++++++++++++++- 13 files changed, 900 insertions(+), 36 deletions(-) diff --git a/docs/data-safety.md b/docs/data-safety.md index b91965e..5eb7e6a 100644 --- a/docs/data-safety.md +++ b/docs/data-safety.md @@ -131,6 +131,27 @@ checksum finds that. It also reports files present on disk that the manifest does not list, and it evicts each file before reading so a freshly written tree is read off the device. +Where the manifest is an ASC MHL history, the directory hashes are recomputed +too, and they catch the one class of change no checksum can. Rename a clip and +every file still hashes exactly as recorded — the bytes did not move. The +structure hash folds each name in with its hash, so it does not agree: + +``` + 3 checked: 1 missing, 2 ok; 2 of 2 directory hashes differ + MISSING ...\Clips\A001_C001.mov + RENAMED (root) + every file still hashes as recorded, so a name changed or a file moved + RENAMED Clips/ + every file still hashes as recorded, so a name changed or a file moved + not in manifest: ...\Clips\A001_C001_take2.mov + +NOT VERIFIED — do not erase the source +``` + +Whether that matters depends on the delivery. A tree whose bytes are intact but +whose names are not is still wrong to hand to an archive that will look for them +by path. + ### The manifest has to travel An MHL that records absolute paths is useless the moment the drive gets a @@ -164,8 +185,10 @@ known. - **Controller and drive caches.** As above: `full` verification proves the operating system is not lying. It cannot prove the drive is not. -- **Source read errors that return garbage instead of raising.** Very rare, and - only a second independent read of the source would catch it. Not implemented. +- **Source read errors that return garbage instead of raising** — unless + `--paranoid` is on, which reads the source a second time and compares. Off by + default because it costs a full extra pass over the card. See "Reading the + source twice" below. - **`skip_existing` compares size, not checksum.** It is a speed option, not a safety one, and should not be used on a tree whose integrity is in question. - **Concurrent instances.** One app instance serialises its queue. Two instances @@ -185,14 +208,61 @@ delay, so only errors with a plausible transient cause qualify: `EIO`, `EBUSY`, `ERROR_SHARING_VIOLATION` (usually antivirus, usually brief) and `ERROR_IO_DEVICE`. `ENOENT` and `ENOSPC` fail immediately. -A retry restarts the whole file rather than resuming, because a partial read -leaves the running checksum meaningless. The partial is discarded and the -progress it claimed is given back, so a retry cannot push the job past 100 %. +A failed *read* is retried at the chunk it failed on, not by restarting the +file. A chunk is only hashed once it has arrived whole, so a read that failed +produced no checksum state to unwind — recovering a bad sector near the end of a +79 GB clip costs one 8 MiB re-read rather than 79 GB. The source is reopened and +sought back to the offset, because a reader that dropped off the bus needs its +handle re-established. Once a chunk has had every attempt the policy allows, the +file is not started again from the top: that would only repeat the same attempts +against the same fault. + +A failed *write* does restart the whole file, because a write that fails +part-way leaves the destination at a length the copy loop does not know. The +partial is discarded and the progress it claimed is given back, so a retry +cannot push the job past 100 %. **A recovered file is still reported.** A card that reads on the third attempt today is a card to stop using, so the job carries a warning naming it. Silent recovery would be the wrong outcome. +## Reading the source twice + +Everything above compares the destination against the source's checksum. That +checksum is computed from whatever the read returned — so a read that hands back +wrong bytes *without raising* produces a destination which faithfully matches a +corrupted source, and verifies clean at every level: file hashes, directory +hashes, read-back off the platter, all of it. The copy is a perfect reproduction +of something that was never on the card. + +Nothing detects that except reading the source again: + +```sh +offloader offload --source E:\ --dest D:\A001 --verify full --paranoid +``` + +The page cache is dropped before the second read, or it would compare the first +read against itself; where the platform cannot drop it the job says so rather +than claim the guarantee. A disagreement is not adjudicated — there is no basis +for deciding which read was true — so it is retried, and a source that will not +read the same twice fails that file and leaves nothing behind. + +It costs a second full pass over the card, which is why it is opt-in rather than +the default. For irreplaceable material it is the strongest statement available. + +## Files that belong together + +A BRAW `.sidecar` carries the clip's grade. Delivered without its clip it is +nothing; the clip delivered without it has silently lost the grade. The same +goes for the proxy a camera writes beside the original. + +These are matched to their clip by stem — the only relationship cameras actually +record — and a clip that copies while a file belonging to it does not is a job +warning, not two rows twenty lines apart in a table nobody reads to the end. An +ambiguous stem, two takes of the same name in different folders, is left +unlinked: naming the wrong clip would be worse than saying nothing, because the +only value of the link is that it can be trusted. + ## Long paths Windows caps a path at 260 characters unless the caller opts out with the diff --git a/src/offloader/companions.py b/src/offloader/companions.py index b1f1723..f062ff2 100644 --- a/src/offloader/companions.py +++ b/src/offloader/companions.py @@ -7,10 +7,18 @@ Blackmagic's layout is `A001/A001_08041254_C001.braw` beside `A001/Proxy/A001_08041254_C001.mp4` — same stem, sibling directory. + +The same stem-matching answers a second question: which files have no meaning +on their own. A `.sidecar` is a clip's grade; delivered without its clip it is +nothing, and a clip delivered without it has silently lost the grade. Copying +both and reporting them as two unrelated files is how that goes unnoticed, so +`group` links them and the engine refuses to let them end up with different +verdicts quietly. """ from __future__ import annotations +from collections.abc import Iterable from pathlib import Path #: Camera originals ffmpeg cannot decode without a vendor SDK. @@ -24,6 +32,16 @@ #: Container suffixes a proxy might use, in preference order. PROXY_SUFFIXES = (".mp4", ".mov", ".m4v", ".mxf") +#: Suffixes worn by a file that describes a clip rather than being one. Each is +#: a format a camera or a grading tool writes beside the original, matched to it +#: by stem. Deliberately short: a file wrongly called a companion is reported as +#: belonging to something it does not. +COMPANION_SUFFIXES = { + ".sidecar", # Blackmagic RAW — colour metadata, written when a grade is set + ".rmd", # RED metadata + ".xmp", # Adobe sidecar metadata +} + def needs_proxy(path: Path) -> bool: """Whether this file needs a stand-in to produce a thumbnail.""" @@ -73,3 +91,49 @@ def thumbnail_source(source: Path, if proxy is not None: return proxy, True return Path(source), False + + +def is_companion(path: Path) -> bool: + """Whether this file describes a clip rather than being one.""" + return Path(path).suffix.lower() in COMPANION_SUFFIXES + + +def in_proxy_directory(path: Path) -> bool: + return Path(path).parent.name in PROXY_DIRECTORIES + + +def group(paths: Iterable[Path]) -> dict[Path, Path]: + """Map each companion file to the clip it belongs to. + + Two kinds qualify: a sidecar carrying a clip's metadata, and a proxy the + camera filed in its own directory. Both are matched by stem, which is the + only relationship cameras actually record. + + An ambiguous stem — two clips of the same name in different folders, one + sidecar — is left unlinked rather than guessed at. Claiming a `.sidecar` + belongs to the wrong take would be worse than saying nothing, because the + whole point of the link is that someone trusts it. + """ + files = [Path(p) for p in paths] + clips = [p for p in files if not is_companion(p) and not in_proxy_directory(p)] + + by_stem: dict[str, list[Path]] = {} + for clip in clips: + by_stem.setdefault(clip.stem, []).append(clip) + + linked: dict[Path, Path] = {} + for candidate in files: + if not (is_companion(candidate) or in_proxy_directory(candidate)): + continue + matches = by_stem.get(candidate.stem, []) + if not matches: + continue + # A clip in the same folder wins; a proxy's clip is the folder above. + near = [c for c in matches + if c.parent == candidate.parent + or c.parent == candidate.parent.parent] + if len(near) == 1: + linked[candidate] = near[0] + elif not near and len(matches) == 1: + linked[candidate] = matches[0] + return linked diff --git a/src/offloader/engine.py b/src/offloader/engine.py index 6660ceb..3995e93 100644 --- a/src/offloader/engine.py +++ b/src/offloader/engine.py @@ -184,6 +184,10 @@ class OffloadOptions: #: How hard to try again when a read fails for a transient-looking reason. #: Marginal cards and readers routinely succeed on a second attempt. retry: retry_mod.RetryPolicy = field(default_factory=retry_mod.RetryPolicy) + #: Read every source file a second time and compare. Costs a full extra + #: pass over the card, and is the only thing that catches a read which + #: returned wrong bytes without the operating system noticing. + paranoid: bool = False def __post_init__(self) -> None: # The data profile is defined by the absence of media work, so enforce @@ -231,9 +235,37 @@ def _destination_for(source: Path, source_root: Path, dest_root: Path, return dest_root / source.name +def _close_quietly(handle: object | None) -> None: + """Close a file handle, swallowing anything it raises. + + Deliberately not just `OSError`. This runs on the way out of a failure and + must never *become* the failure: a raise from here would skip the sentinel + the reader thread owes its consumer, and the copy would hang rather than + report the error that actually happened. + """ + if handle is None: + return + try: + handle.close() + except Exception: + pass + + +@dataclass +class _CopyResult: + """What one pass of `_copy_fanout` produced.""" + + source_checksum: str + destination_checksums: list[str] + #: (offset, attempts) for every chunk that did not read first time. The copy + #: succeeded, but a card that needs these is a card on its way out. + recovered_reads: list[tuple[int, int]] = field(default_factory=list) + + def _copy_fanout(source: Path, targets: Sequence[Path], algorithm: str, on_chunk: Callable[[int], None], - control: JobControl | None = None) -> tuple[str, list[str]]: + control: JobControl | None = None, + retry: retry_mod.RetryPolicy = retry_mod.NO_RETRY) -> _CopyResult: """Stream `source` into every target at once. `targets` are the *in-flight* paths — the caller renames them into place @@ -243,6 +275,11 @@ def _copy_fanout(source: Path, targets: Sequence[Path], algorithm: str, Returns the source checksum plus one checksum per target, computed from the bytes actually handed to each write() call. + + `retry` applies to *source reads only*, chunk by chunk. Writes are left to + the caller's whole-file retry: a write that fails part-way leaves the + destination at a length nothing here knows, whereas a failed read has + produced nothing at all. """ source = Path(source) src_hasher = new_hasher(algorithm) @@ -261,27 +298,64 @@ def _copy_fanout(source: Path, targets: Sequence[Path], algorithm: str, chunks: queue.Queue = queue.Queue(maxsize=READ_AHEAD) stop = threading.Event() failure: list[BaseException] = [] + recovered: list[tuple[int, int]] = [] def read_ahead() -> None: - """Keep the queue fed so the next read overlaps the current write.""" + """Keep the queue fed so the next read overlaps the current write. + + A transient read failure is retried *here*, at the chunk that failed, + rather than by restarting the file. Nothing has been hashed yet — the + hashers only ever see a chunk once it has been delivered whole — so + there is no checksum state to unwind, and recovering a bad sector costs + one 8 MiB re-read instead of a re-read of everything before it. On a + 79 GB clip that is the difference between seconds and a quarter of an + hour. + """ + reader = None + offset = 0 try: - with longpath.open_binary(source, "rb") as reader: + reader = longpath.open_binary(source, "rb") + + def read_one() -> bytes: + return reader.read(CHUNK_SIZE) + + def recover() -> None: + # Reopen rather than seek alone: a reader that dropped off the + # bus needs its handle re-established, which restarting the + # whole file used to get for free. + nonlocal reader + _close_quietly(reader) + reader = longpath.open_binary(source, "rb") + reader.seek(offset) + + while not stop.is_set(): + if control is not None: + control.checkpoint() + try: + chunk, attempts = retry_mod.call(read_one, retry, + before_retry=recover) + except OSError as exc: + if retry.enabled and retry_mod.is_transient(exc): + raise retry_mod.Exhausted( + f"read failed at offset {offset} after " + f"{retry.attempts} attempts: {exc}") from exc + raise + if attempts > 1: + recovered.append((offset, attempts)) + if not chunk: + break + offset += len(chunk) + # Time-boxed so a consumer that died still lets us exit. while not stop.is_set(): - if control is not None: - control.checkpoint() - chunk = reader.read(CHUNK_SIZE) - if not chunk: + try: + chunks.put(chunk, timeout=0.2) break - # Time-boxed so a consumer that died still lets us exit. - while not stop.is_set(): - try: - chunks.put(chunk, timeout=0.2) - break - except queue.Full: - continue + except queue.Full: + continue except BaseException as exc: # re-raised on the calling thread failure.append(exc) finally: + _close_quietly(reader) # The sentinel must be delivered, not attempted: if the queue # happens to be full at EOF a dropped sentinel leaves the consumer # blocked on get() forever. Only give up once `stop` is set, which @@ -334,7 +408,65 @@ def read_ahead() -> None: for handle in handles: handle.close() - return src_hasher.hexdigest(), [h.hexdigest() for h in dst_hashers] + return _CopyResult(src_hasher.hexdigest(), + [h.hexdigest() for h in dst_hashers], + recovered) + + +def _confirm_source(source: Path, expected: str, algorithm: str) -> bool: + """Read `source` a second time and insist it hashes the same. + + The gap this closes: a read that returns wrong bytes *without raising*. The + checksum is computed from whatever was read, so a bad read produces a + destination that faithfully matches a corrupted source and verifies clean at + every level — file hashes, directory hashes, the lot. Nothing but reading + twice can see it. + + Raises `UnstableRead` on a disagreement rather than choosing a winner: there + is no basis for deciding which of the two reads was the true one. + + Returns whether the page cache was actually dropped first. A second read + served out of memory compares the first read against itself, so a caller + that cannot evict has to say so rather than claim the guarantee. + """ + evicted = integrity.evict_from_cache(source) + again = hash_file(source, algorithm) + if again != expected: + raise retry_mod.UnstableRead( + f"two reads of {source.name} disagreed ({expected} then {again}) — " + "the source did not return the same bytes twice" + ) + return evicted + + +def _invert_companions(belongs_to: dict[Path, Path]) -> dict[Path, list[Path]]: + """clip -> its companions, from companion -> its clip.""" + owns: dict[Path, list[Path]] = {} + for companion, clip in belongs_to.items(): + owns.setdefault(clip, []).append(companion) + for paths in owns.values(): + paths.sort() + return owns + + +def _warn_on_split_companions(job: Job) -> None: + """A clip and the files that belong to it have to share a fate. + + A graded BRAW delivered without its `.sidecar` has lost the grade, and a + per-file table showing one Verified row and one Failed row twenty lines + apart is not how anyone finds that out. + """ + by_source = {entry.source: entry for entry in job.files} + for entry in job.files: + if entry.companion_of is None or entry.status is not FileStatus.FAILED: + continue + clip = by_source.get(entry.companion_of) + if clip is None or clip.status is FileStatus.FAILED: + continue + job.warnings.append( + f"{entry.name} did not copy but {clip.name} did — the clip has " + "been separated from a file that belongs with it" + ) def _discard(targets: Iterable[Path]) -> None: @@ -381,12 +513,20 @@ def run(source_root: Path, options: OffloadOptions, os_version=host.os_version, processors=host.processors, system_ram=host.system_ram, + paranoid=options.paranoid, ) thumb_dir = options.thumbnail_dir or (dest_roots[0] / f"{job.name}_Reports" / "thumbs") + belongs_to = companions.group(files) + owns = _invert_companions(belongs_to) + #: Whether the "cache could not be evicted" limitation has been reported. + #: Said once per job, not once per clip: where the platform has no eviction + #: call at all (macOS has no posix_fadvise), repeating it per file would + #: bury the warnings that are about actual media. evict_noted = False + reread_noted = False def emit(event: ProgressEvent) -> None: if progress: @@ -408,6 +548,8 @@ def emit(event: ProgressEvent) -> None: size=stat.st_size, created=getattr(stat, "st_birthtime", stat.st_ctime), modified=stat.st_mtime, + companion_of=belongs_to.get(source), + companions=owns.get(source, []), ) targets = [ @@ -470,17 +612,50 @@ def note_retry(attempt: int, exc: BaseException, pause: float, f"{_src.name}: read failed ({exc}); " f"attempt {attempt} of {options.retry.attempts}") - (src_sum, dst_sums), used = retry_mod.call( - lambda _src=source, _partials=partials: _copy_fanout( - _src, _partials, options.algorithm, on_chunk, control), - options.retry, on_retry=note_retry, before_retry=rewind, + def copy_once(_src=source, _partials=partials, _idx=index, + _st=stat) -> _CopyResult: + nonlocal reread_noted + result = _copy_fanout(_src, _partials, options.algorithm, + on_chunk, control, options.retry) + if not options.paranoid: + return result + emit(ProgressEvent(_idx, len(files), _src.name, "reread", + 0, _st.st_size, + counters.job_bytes_done, + counters.job_bytes_total)) + # Raises UnstableRead on a disagreement, which the retry around + # this call treats as transient: the honest response to a source + # that read differently twice is to read it again, not to guess + # which of the two was right. + evicted = _confirm_source(_src, result.source_checksum, + options.algorithm) + if not evicted and not reread_noted: + reread_noted = True + job.warnings.append( + "could not evict files from the page cache on this " + "platform, so the second read may have come from memory " + "rather than the device — --paranoid proved less than " + "it appears to" + ) + return result + + result, used = retry_mod.call( + copy_once, options.retry, + on_retry=note_retry, before_retry=rewind, ) + src_sum, dst_sums = result.source_checksum, result.destination_checksums if used > 1: # Not a failure, but a card that needs retries today is a card # to stop using. job.warnings.append( f"{source.name} copied on attempt {used} of " f"{options.retry.attempts} — the source may be failing") + for offset, attempts in result.recovered_reads: + # Recovered without restarting the file, which is why the copy + # succeeded at all — but the sector that needed it is real. + job.warnings.append( + f"{source.name}: recovered a failed read at byte {offset} " + f"on attempt {attempts} — the source may be failing") entry.checksum = src_sum or None except JobCancelled: _discard(partials) @@ -521,10 +696,6 @@ def note_retry(attempt: int, exc: BaseException, pause: float, # Evict first, or the read-back is served from the page # cache and verifies our own memory against itself. if not integrity.evict_from_cache(partial) and not evict_noted: - # Said once per job, not once per clip: where the - # platform has no eviction call at all (macOS has no - # posix_fadvise), repeating it per file would bury the - # warnings that are about actual media. evict_noted = True job.warnings.append( "could not evict files from the page cache on this " @@ -633,6 +804,7 @@ def note_retry(attempt: int, exc: BaseException, pause: float, if not_attempted > 0: counters.errors.append( f"cancelled — {not_attempted} file(s) not attempted") + _warn_on_split_companions(job) job.notes = "; ".join(counters.errors) return job @@ -666,6 +838,9 @@ def rescan(source_root: Path, destination_roots: Sequence[Path], total = sum(p.stat().st_size for p in files) done = 0 + belongs_to = companions.group(files) + owns = _invert_companions(belongs_to) + for index, source in enumerate(files): stat = source.stat() entry = FileEntry( @@ -674,6 +849,8 @@ def rescan(source_root: Path, destination_roots: Sequence[Path], size=stat.st_size, created=getattr(stat, "st_birthtime", stat.st_ctime), modified=stat.st_mtime, + companion_of=belongs_to.get(source), + companions=owns.get(source, []), ) if progress: progress(ProgressEvent(index, len(files), source.name, "verify", diff --git a/src/offloader/models.py b/src/offloader/models.py index e07f3c4..0c9da07 100644 --- a/src/offloader/models.py +++ b/src/offloader/models.py @@ -176,6 +176,11 @@ class FileEntry: #: original could not be decoded. thumbnail_source: Path | None = None destinations: list[Destination] = field(default_factory=list) + #: The clip this file belongs to, when it is a sidecar or a proxy rather + #: than a take in its own right. + companion_of: Path | None = None + #: The sidecars and proxies that belong to this clip. + companions: list[Path] = field(default_factory=list) @property def name(self) -> str: @@ -221,6 +226,10 @@ class Job: system_ram: str = "" notes: str = "" cancelled: bool = False + #: Every source file was read a second time and the two reads compared. + #: Recorded because it changes what "Verified" is worth, and a delivery + #: should be able to say which one it got. + paranoid: bool = False #: Things that did not fail the job but that a human should see before #: erasing a card — empty files, verifications that may have been served #: from cache. diff --git a/src/offloader/presets.py b/src/offloader/presets.py index edaaf70..b4089b1 100644 --- a/src/offloader/presets.py +++ b/src/offloader/presets.py @@ -45,6 +45,8 @@ class Preset: reports: list[str] = field(default_factory=lambda: ["pdf"]) preserve_structure: bool = True skip_existing: bool = False + #: Read every source file twice and compare. For irreplaceable material. + paranoid: bool = False excludes: list[str] = field(default_factory=list) naming_template: str = DEFAULT_TEMPLATE retry_attempts: int = 3 @@ -110,6 +112,7 @@ def to_options(self, job_name: str | None = None) -> OffloadOptions: profile=self.profile, retry=RetryPolicy(attempts=max(1, self.retry_attempts), delay=max(0.0, self.retry_wait)), + paranoid=self.paranoid, ) # ---------------------------------------------------------------- codec @@ -124,6 +127,7 @@ def to_dict(self) -> dict: "reports": list(self.reports), "preserve_structure": self.preserve_structure, "skip_existing": self.skip_existing, + "paranoid": self.paranoid, "excludes": list(self.excludes), "naming_template": self.naming_template, "retry_attempts": self.retry_attempts, @@ -192,6 +196,7 @@ def as_list(key): if data.get("reports") is not None else ["pdf"]), preserve_structure=bool(value("preserve_structure", True)), skip_existing=bool(value("skip_existing", False)), + paranoid=bool(value("paranoid", False)), excludes=[e for e in as_list("excludes") if isinstance(e, str)], naming_template=str(value("naming_template", DEFAULT_TEMPLATE)), retry_attempts=as_int("retry_attempts", 3), diff --git a/src/offloader/reports/csv_report.py b/src/offloader/reports/csv_report.py index 3ebb994..466eb80 100644 --- a/src/offloader/reports/csv_report.py +++ b/src/offloader/reports/csv_report.py @@ -37,6 +37,9 @@ "Good Take", "Colour Science", "Error", + # Appended rather than slotted in beside the file columns, so an existing + # consumer reading by index is unaffected. + "Companion Of", ] @@ -85,11 +88,13 @@ def write_csv(job: Job, path: Path, *, delimiter: str = ",", **_options) -> Path media.camera.colour_science or "", ] + belongs_to = entry.companion_of.name if entry.companion_of else "" + if not entry.destinations: writer.writerow(base + ["", "", "", "Skipped", format_file_datetime(entry.created), format_file_datetime(entry.modified)] - + tail + [""]) + + tail + ["", belongs_to]) continue for number, destination in enumerate(entry.destinations, start=1): @@ -104,6 +109,6 @@ def write_csv(job: Job, path: Path, *, delimiter: str = ",", **_options) -> Path format_file_datetime(entry.modified), ] + tail - + [destination.error or ""] + + [destination.error or "", belongs_to] ) return path diff --git a/src/offloader/reports/html.py b/src/offloader/reports/html.py index f26bb05..c2f1525 100644 --- a/src/offloader/reports/html.py +++ b/src/offloader/reports/html.py @@ -50,8 +50,8 @@ .clip .meta b { color: var(--fg); font-weight: bold; } .strip { display: flex; flex: 1 1 auto; gap: 0; min-width: 0; } .strip img { width: 25%; height: auto; object-fit: contain; background: #000; } -.proxy-note { font-size: 10px; color: var(--muted); font-style: italic; - margin-top: 3px; } +.proxy-note, .companion-note { font-size: 10px; color: var(--muted); + font-style: italic; margin-top: 3px; } .noimg { flex: 1 1 auto; min-height: 78px; background: #1c1c1c; border-radius: 4px; color: #f0a92b; display: flex; align-items: center; justify-content: center; font-size: 12px; letter-spacing: 2px; } @@ -153,7 +153,10 @@ def write_html(job: Job, path: Path, *, thumbnails: bool = True, **_options) -> ("Size of offload", format_size(job.total_bytes)), ("Offload Finish Date", format_job_datetime(finished)), ("Processors", str(job.processors) if job.processors else ""), - ("Verification Type", job.verification_label), + # The PDF's header string is pinned to the reference report's wording, + # so the extra pass is said here rather than folded into it. + ("Verification Type", job.verification_label + + (" + second source read" if job.paranoid else "")), ("Total Time", format_elapsed(job.elapsed_sec)), ("System Ram", job.system_ram), ("Total Files", str(job.total_files)), @@ -176,6 +179,14 @@ def write_html(job: Job, path: Path, *, thumbnails: bool = True, **_options) -> if entry.thumbnail_source is not None: provenance = (f'
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'
With: {names}
' + elif entry.companion_of is not None: + provenance += (f'
Belongs to: ' + f'{_esc(entry.companion_of.name)}
') clips.append( f'
{_clip_meta(job, entry)}' f'{provenance}
{strip}
' 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/tests/test_braw.py b/tests/test_braw.py index e25775b..524b441 100644 --- a/tests/test_braw.py +++ b/tests/test_braw.py @@ -348,6 +348,66 @@ def test_needs_proxy_covers_the_undecodable_formats(): assert not companions.needs_proxy(Path("clip.mp4")) +# ------------------------------------------------------------- grouping + + +def test_a_sidecar_is_grouped_with_its_clip(tmp_path: Path): + clip = write_braw(tmp_path / "A001_C001.braw") + sidecar = tmp_path / "A001_C001.sidecar" + sidecar.write_bytes(b"colour metadata") + + assert companions.group([clip, sidecar]) == {sidecar: clip} + + +def test_a_proxy_is_grouped_with_the_clip_a_folder_up(tmp_path: Path): + card = tmp_path / "A001" + (card / "Proxy").mkdir(parents=True) + clip = write_braw(card / "A001_C001.braw") + proxy = card / "Proxy" / "A001_C001.mp4" + proxy.write_bytes(b"proxy") + + assert companions.group([clip, proxy]) == {proxy: clip} + + +def test_an_ambiguous_stem_is_left_ungrouped(tmp_path: Path): + """Two takes of the same name in different folders, one sidecar between + them. Naming the wrong clip would be worse than saying nothing, because the + only value of the link is that it can be trusted.""" + first = write_braw(tmp_path / "Day1" / "A001_C001.braw") + second = write_braw(tmp_path / "Day2" / "A001_C001.braw") + sidecar = tmp_path / "A001_C001.sidecar" + sidecar.write_bytes(b"whose?") + + assert companions.group([first, second, sidecar]) == {} + + +def test_a_sidecar_beside_one_of_two_takes_picks_the_near_one(tmp_path: Path): + first = write_braw(tmp_path / "Day1" / "A001_C001.braw") + second = write_braw(tmp_path / "Day2" / "A001_C001.braw") + sidecar = tmp_path / "Day2" / "A001_C001.sidecar" + sidecar.write_bytes(b"day two") + + assert companions.group([first, second, sidecar]) == {sidecar: second} + + +def test_an_orphan_sidecar_is_not_invented_a_clip(tmp_path: Path): + sidecar = tmp_path / "A001_C009.sidecar" + sidecar.write_bytes(b"no clip here") + clip = write_braw(tmp_path / "A001_C001.braw") + + assert companions.group([clip, sidecar]) == {} + + +def test_clips_are_never_companions_of_each_other(tmp_path: Path): + """Two ordinary takes sharing a stem are two takes, not a pair.""" + one = tmp_path / "A001_C001.mov" + two = tmp_path / "A001_C001.mp4" + for path in (one, two): + path.write_bytes(b"a take") + + assert companions.group([one, two]) == {} + + # ------------------------------------------------------------------ real file #: A real Blackmagic PYXIS 6K still, if one happens to be around. The synthetic diff --git a/tests/test_data_safety.py b/tests/test_data_safety.py index ae60845..8ecf39c 100644 --- a/tests/test_data_safety.py +++ b/tests/test_data_safety.py @@ -16,7 +16,7 @@ import pytest -from offloader import engine, integrity +from offloader import engine, integrity, retry from offloader.models import FileStatus, VerificationMode PAYLOAD = b"IRREPLACEABLE FOOTAGE " * 5000 @@ -34,6 +34,11 @@ def _options(tmp_path: Path, **overrides) -> engine.OffloadOptions: return engine.OffloadOptions(**defaults) +def _fast() -> retry.RetryPolicy: + """The real retry counts, without sitting out the real backoff.""" + return retry.RetryPolicy(attempts=3, delay=0) + + def _card(tmp_path: Path, name: str = "A001_C001.mov") -> Path: root = tmp_path / "card" root.mkdir(parents=True, exist_ok=True) @@ -303,3 +308,139 @@ def test_eviction_does_not_damage_the_file(tmp_path: Path): target.write_bytes(PAYLOAD) integrity.evict_from_cache(target) assert target.read_bytes() == PAYLOAD + + +# ------------------------------------------------------------ reading twice + + +class _AlternatingReader: + """A source that returns different bytes on alternate opens. + + Simulates the fault nothing else here can catch: a read that returns wrong + bytes and reports no error at all. The checksum is computed from whatever + came back, so the destination faithfully matches a corrupted source and + verifies clean at every level. + """ + + def __init__(self, handle, opens: dict): + self._handle = handle + opens["n"] += 1 + self._lie = opens["n"] % 2 == 0 + + def read(self, size=-1): + data = self._handle.read(size) + return bytes(len(data)) if self._lie else data + + def seek(self, offset, whence=0): + return self._handle.seek(offset, whence) + + def close(self): + self._handle.close() + + def __enter__(self): + return self + + def __exit__(self, *args): + self._handle.close() + + +def _patch_alternating_source(monkeypatch, card: Path, opens: dict) -> None: + real_open = builtins.open + + def fake_open(path, mode="r", *args, **kwargs): + handle = real_open(path, mode, *args, **kwargs) + try: + inside = Path(path).resolve().is_relative_to(card.resolve()) + except (OSError, ValueError): + inside = False + if inside and "r" in str(mode) and "b" in str(mode): + return _AlternatingReader(handle, opens) + return handle + + monkeypatch.setattr(builtins, "open", fake_open) + + +def test_paranoid_catches_a_source_that_does_not_read_the_same_twice( + tmp_path: Path, monkeypatch +): + """The gap --paranoid exists to close. Every layer below agrees the copy is + perfect, because every layer is comparing against the same bad read.""" + card = _card(tmp_path) + _patch_alternating_source(monkeypatch, card, {"n": 0}) + job = engine.run(card, _options(tmp_path, paranoid=True, retry=_fast())) + monkeypatch.undo() + + assert job.final_status == "Failed" + assert not (tmp_path / "dest" / "A001_C001.mov").exists() + + +def test_without_paranoid_the_same_source_verifies_clean(tmp_path: Path, + monkeypatch): + """The point of the test above: this is what happens today. A copy that + matches its source's checksum exactly, of bytes that were never on the + card. Only reading twice can tell.""" + card = _card(tmp_path) + _patch_alternating_source(monkeypatch, card, {"n": 0}) + job = engine.run(card, _options(tmp_path)) + monkeypatch.undo() + + assert job.final_status == "Verified" + + +def test_paranoid_leaves_nothing_behind_when_it_fails(tmp_path: Path, + monkeypatch): + card = _card(tmp_path) + _patch_alternating_source(monkeypatch, card, {"n": 0}) + engine.run(card, _options(tmp_path, paranoid=True, retry=_fast())) + monkeypatch.undo() + + assert list((tmp_path / "dest").rglob(f"*{engine.PARTIAL_SUFFIX}")) == [] + + +def test_a_sound_source_passes_paranoid_and_says_so(tmp_path: Path): + card = _card(tmp_path) + job = engine.run(card, _options(tmp_path, paranoid=True)) + + assert job.final_status == "Verified" + assert job.paranoid + assert (tmp_path / "dest" / "A001_C001.mov").read_bytes() == PAYLOAD + + +def test_paranoid_is_off_by_default_and_reads_the_source_once(tmp_path: Path, + monkeypatch): + """It costs a second full pass over the card, which is only worth paying + deliberately.""" + card = _card(tmp_path) + reads: list[Path] = [] + real_open = builtins.open + + def counting_open(path, mode="r", *args, **kwargs): + if "r" in str(mode) and "b" in str(mode): + try: + if Path(path).resolve().is_relative_to(card.resolve()): + reads.append(Path(path)) + except (OSError, ValueError): + pass + return real_open(path, mode, *args, **kwargs) + + assert engine.OffloadOptions(destinations=[tmp_path / "d"]).paranoid is False + + monkeypatch.setattr(builtins, "open", counting_open) + job = engine.run(card, _options(tmp_path)) + monkeypatch.undo() + + assert job.final_status == "Verified" + assert len(reads) == 1, f"the source was opened for reading {len(reads)} times" + + +def test_paranoid_says_so_when_it_could_not_drop_the_cache(tmp_path: Path, + monkeypatch): + """A second read served out of memory compares the first read against + itself. That is worth nothing, and claiming otherwise is the failure mode + this whole file exists to prevent.""" + card = _card(tmp_path) + monkeypatch.setattr(integrity, "evict_from_cache", lambda _p: False) + job = engine.run(card, _options(tmp_path, paranoid=True)) + + assert any("second read" in w and "memory" in w for w in job.warnings), \ + job.warnings diff --git a/tests/test_engine.py b/tests/test_engine.py index 9c19897..c8d56ae 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -262,4 +262,101 @@ def run() -> None: assert not worker.is_alive(), "copy deadlocked waiting for the sentinel" assert (tmp_path / "out.bin").stat().st_size == engine.CHUNK_SIZE * 3 - assert result["digest"][0] == hashers.hash_file(source, "xxh3-64") + assert result["digest"].source_checksum == hashers.hash_file(source, "xxh3-64") + + +def test_a_reader_that_will_not_close_still_delivers_the_sentinel(tmp_path: Path, + monkeypatch): + """REGRESSION. The reader thread owes its consumer an end-of-file sentinel. + Closing the source runs on the way out, including out of a failure, so a + close that raises must not skip it — the consumer would block on get() + forever and the copy would hang instead of reporting the real error.""" + import builtins + import threading + + real_open = builtins.open + source = tmp_path / "clip.mov" + source.write_bytes(b"payload " * 500) + + class WontClose: + def __init__(self, handle): + self._handle = handle + + def read(self, size=-1): + return self._handle.read(size) + + def close(self): + raise RuntimeError("close is broken") + + def fake_open(path, mode="r", *args, **kwargs): + handle = real_open(path, mode, *args, **kwargs) + if Path(path).name == source.name and "b" in str(mode): + return WontClose(handle) + return handle + + monkeypatch.setattr(builtins, "open", fake_open) + + done = threading.Event() + + def run() -> None: + try: + engine._copy_fanout(source, [tmp_path / "out.bin"], "xxh3-64", + lambda _n: None) + finally: + done.set() + + worker = threading.Thread(target=run, daemon=True) + worker.start() + assert done.wait(timeout=20), "copy hung after the source failed to close" + monkeypatch.undo() + + +# ------------------------------------------------------------- companions + + +def _card_with_sidecar(tmp_path: Path) -> Path: + card = tmp_path / "card" + card.mkdir() + (card / "A001_C001.braw").write_bytes(b"a clip " * 400) + (card / "A001_C001.sidecar").write_bytes(b"the grade") + return card + + +def test_a_sidecar_is_linked_to_its_clip_in_the_job(tmp_path: Path): + job = engine.run(_card_with_sidecar(tmp_path), _options(tmp_path)) + + clip = next(f for f in job.files if f.name == "A001_C001.braw") + sidecar = next(f for f in job.files if f.name == "A001_C001.sidecar") + + assert sidecar.companion_of == clip.source + assert clip.companions == [sidecar.source] + assert clip.companion_of is None + + +def test_a_clip_separated_from_its_sidecar_is_a_warning(tmp_path: Path, + monkeypatch): + """A graded BRAW delivered without its .sidecar has lost the grade. One + Verified row and one Failed row twenty lines apart is not how anyone finds + that out.""" + import builtins + import errno + + card = _card_with_sidecar(tmp_path) + real_open = builtins.open + + def fake_open(path, mode="r", *args, **kwargs): + if Path(path).suffix == ".sidecar" and "b" in str(mode): + raise OSError(errno.EACCES, "permission denied") + return real_open(path, mode, *args, **kwargs) + + monkeypatch.setattr(builtins, "open", fake_open) + job = engine.run(card, _options(tmp_path)) + monkeypatch.undo() + + assert any("A001_C001.sidecar" in w and "belongs with it" in w + for w in job.warnings), job.warnings + + +def test_a_clean_offload_says_nothing_about_companions(tmp_path: Path): + job = engine.run(_card_with_sidecar(tmp_path), _options(tmp_path)) + assert not any("belongs with it" in w for w in job.warnings) diff --git a/tests/test_presets.py b/tests/test_presets.py index cc79c02..5b437cb 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -120,6 +120,7 @@ def test_to_options_carries_settings_through(tmp_path: Path): excludes=["*.tmp"], preserve_structure=False, skip_existing=True, + paranoid=True, ) options = preset.to_options(job_name="A001") @@ -130,6 +131,7 @@ def test_to_options_carries_settings_through(tmp_path: Path): assert options.job_name == "A001" assert options.preserve_structure is False assert options.skip_existing is True + assert options.paranoid is True assert "*.tmp" in options.excludes assert ".DS_Store" in options.excludes # defaults still applied @@ -192,3 +194,16 @@ def test_an_explicitly_empty_report_list_is_respected(): key should fall back to the default.""" assert Preset.from_dict({"reports": []}).reports == [] assert Preset.from_dict({}).reports == ["pdf"] + + +def test_paranoid_survives_a_save_and_reload(tmp_path: Path): + """It changes what "Verified" is worth, so it has to persist with the + preset rather than being re-chosen each time.""" + store = PresetStore(tmp_path / "presets.json") + store.presets = [Preset(name="Irreplaceable", destinations=[tmp_path / "d"], + paranoid=True)] + store.save() + + assert PresetStore(tmp_path / "presets.json").presets[0].paranoid is True + # And a preset written before the option existed still loads. + assert Preset.from_dict({"name": "old"}).paranoid is False diff --git a/tests/test_retry.py b/tests/test_retry.py index e169bf3..b98732a 100644 --- a/tests/test_retry.py +++ b/tests/test_retry.py @@ -172,7 +172,12 @@ def _options(tmp_path: Path, **overrides) -> engine.OffloadOptions: class _FlakyReader: - """A reader that fails the first N whole-file attempts, then works.""" + """A reader that fails its first N reads, then works. + + Stands in for what `open` returns, so it has to carry the parts of a binary + file the engine actually uses — `seek` and `close` as well as `read`, since + recovering a bad chunk reopens the source and seeks back to it. + """ def __init__(self, handle, failures: dict, limit: int): self._handle = handle @@ -185,6 +190,12 @@ def read(self, size=-1): raise _os_error(errno.EIO, winerror=1117) return self._handle.read(size) + def seek(self, offset, whence=0): + return self._handle.seek(offset, whence) + + def close(self): + self._handle.close() + def __enter__(self): return self @@ -272,6 +283,12 @@ def read(self, size=-1): raise _os_error(errno.EIO, winerror=1117) return self._handle.read(size) + def seek(self, offset, whence=0): + return self._handle.seek(offset, whence) + + def close(self): + self._handle.close() + def __enter__(self): return self @@ -351,3 +368,172 @@ def test_retry_is_configurable_from_a_preset(tmp_path: Path): restored = Preset.from_dict(preset.to_dict()) assert restored.retry_attempts == 7 assert restored.retry_wait == pytest.approx(0.5) + + +# --------------------------------------------------------- chunk-level retry + + +class _BadSector: + """A reader that fails every read starting at one offset, `times` times. + + Records the offset of every read attempted, across reopens, which is what + lets a test tell a chunk-level retry from a restart of the whole file: a + restart reads offset 0 again, a chunk-level retry does not. + """ + + def __init__(self, handle, log: list, failures: dict, offset: int, times: int): + self._handle = handle + self._log = log + self._failures = failures + self._offset = offset + self._times = times + + def read(self, size=-1): + at = self._handle.tell() + self._log.append(at) + if at == self._offset and self._failures["n"] < self._times: + self._failures["n"] += 1 + raise _os_error(errno.EIO, winerror=1117) + return self._handle.read(size) + + def seek(self, offset, whence=0): + return self._handle.seek(offset, whence) + + def close(self): + self._handle.close() + + def __enter__(self): + return self + + def __exit__(self, *args): + self._handle.close() + + +def _patch_bad_sector(monkeypatch, card: Path, log: list, failures: dict, + offset: int, times: int) -> None: + real_open = builtins.open + + def flaky_open(path, mode="r", *args, **kwargs): + handle = real_open(path, mode, *args, **kwargs) + try: + inside = Path(path).resolve().is_relative_to(card.resolve()) + except (OSError, ValueError): + inside = False + if inside and "r" in str(mode) and "b" in str(mode): + return _BadSector(handle, log, failures, offset, times) + return handle + + monkeypatch.setattr(builtins, "open", flaky_open) + + +def _chunked_card(tmp_path: Path, chunks: int) -> tuple[Path, bytes]: + card = tmp_path / "card" + card.mkdir() + payload = bytes(range(256)) * (engine.CHUNK_SIZE * chunks // 256) + (card / "A001_C001.mov").write_bytes(payload) + return card, payload + + +def test_a_bad_sector_is_recovered_without_re_reading_the_file( + tmp_path: Path, monkeypatch +): + """The point of retrying per chunk. Restarting a 79 GB clip to recover a + few bytes near the end is most of an hour; re-reading the chunk is a + moment.""" + monkeypatch.setattr(engine, "CHUNK_SIZE", 4096) + card, payload = _chunked_card(tmp_path, 3) + log: list[int] = [] + _patch_bad_sector(monkeypatch, card, log, {"n": 0}, offset=4096, times=1) + + job = engine.run(card, _options(tmp_path)) + monkeypatch.undo() + + assert job.final_status == "Verified" + assert (tmp_path / "dest" / "A001_C001.mov").read_bytes() == payload + assert log.count(0) == 1, f"the file was restarted: {log}" + + +def test_a_recovered_chunk_is_reported_with_where_it_was(tmp_path: Path, + monkeypatch): + monkeypatch.setattr(engine, "CHUNK_SIZE", 4096) + card, _payload = _chunked_card(tmp_path, 3) + _patch_bad_sector(monkeypatch, card, [], {"n": 0}, offset=8192, times=1) + + job = engine.run(card, _options(tmp_path)) + monkeypatch.undo() + + assert any("byte 8192" in w and "may be failing" in w for w in job.warnings), \ + job.warnings + + +def test_a_sector_that_never_reads_does_not_restart_the_whole_file( + tmp_path: Path, monkeypatch +): + """Once the chunk has had every attempt the policy allows, running the same + attempts again from byte zero only repeats them against the same fault.""" + monkeypatch.setattr(engine, "CHUNK_SIZE", 4096) + card, _payload = _chunked_card(tmp_path, 3) + log: list[int] = [] + _patch_bad_sector(monkeypatch, card, log, {"n": 0}, offset=4096, times=99) + + job = engine.run(card, _options(tmp_path)) + monkeypatch.undo() + + assert job.final_status == "Failed" + assert log.count(0) == 1, f"the file was restarted: {log}" + assert log.count(4096) == 3, f"the chunk got {log.count(4096)} attempts: {log}" + + +def test_the_failure_still_names_the_offset_that_could_not_be_read( + tmp_path: Path, monkeypatch +): + monkeypatch.setattr(engine, "CHUNK_SIZE", 4096) + card, _payload = _chunked_card(tmp_path, 3) + _patch_bad_sector(monkeypatch, card, [], {"n": 0}, offset=4096, times=99) + + job = engine.run(card, _options(tmp_path)) + monkeypatch.undo() + + assert "offset 4096" in job.notes, job.notes + + +def test_writes_are_still_retried_at_the_whole_file(tmp_path: Path, monkeypatch): + """A read that fails produced nothing, so it can be resumed. A write that + fails part-way leaves the destination at a length the copy loop does not + know, so it starts over.""" + monkeypatch.setattr(engine, "CHUNK_SIZE", 4096) + card, payload = _chunked_card(tmp_path, 2) + real_open = builtins.open + state = {"failed": False} + + class FailingWrite: + def __init__(self, handle): + self._handle = handle + + def write(self, data): + if not state["failed"]: + state["failed"] = True + raise _os_error(errno.EIO, winerror=1117) + return self._handle.write(data) + + def flush(self): + return self._handle.flush() + + def fileno(self): + return self._handle.fileno() + + def close(self): + self._handle.close() + + def flaky_open(path, mode="r", *args, **kwargs): + handle = real_open(path, mode, *args, **kwargs) + if "w" in str(mode) and "b" in str(mode): + return FailingWrite(handle) + return handle + + monkeypatch.setattr(builtins, "open", flaky_open) + job = engine.run(card, _options(tmp_path)) + monkeypatch.undo() + + assert job.final_status == "Verified" + assert (tmp_path / "dest" / "A001_C001.mov").read_bytes() == payload From 6559704c5b5a87d304abc1f004ca456b047ba894 Mon Sep 17 00:00:00 2001 From: owenpkent <20529132+owenpkent@users.noreply.github.com> Date: Sun, 9 Aug 2026 12:21:24 -0400 Subject: [PATCH 03/19] Group the preset editor, and expose the second read in both modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The editor had sixteen fields in one flat column, four of them checkboxes sitting on blank labels. The two or three that bear on any given change were never next to each other. They are now three sections — Preset, Copying, Reports — with the checkboxes stacked under a single Options label. "Job name" is now "Job name template", because Simple mode has a field of the same name that takes a literal one. "Skip files already present at matching size" gained a tooltip saying what it does not compare, since it is the one place the tool takes something on trust. --paranoid was reachable from the CLI and from Preset.to_options but exposed nowhere in the interface, which makes it a field nobody can use. It is now a checkbox in both the preset editor and Simple mode, worded and explained the same way in each, and it persists with the preset. --- src/offloader/gui/preset_editor.py | 88 ++++++++++++++++++++++-------- src/offloader/gui/simple_mode.py | 9 ++- src/offloader/gui/widgets.py | 12 ++++ tests/test_gui.py | 43 +++++++++++++++ 4 files changed, 127 insertions(+), 25 deletions(-) diff --git a/src/offloader/gui/preset_editor.py b/src/offloader/gui/preset_editor.py index 68499fa..e753214 100644 --- a/src/offloader/gui/preset_editor.py +++ b/src/offloader/gui/preset_editor.py @@ -26,7 +26,7 @@ from ..presets import PRESET_COLORS, Preset from ..reports import WRITERS from . import theme -from .widgets import DestinationList, button, label, row +from .widgets import DestinationList, button, column, label, row VERIFICATION_LABELS = { VerificationMode.NONE: "None — copy only", @@ -34,6 +34,37 @@ VerificationMode.FULL: "Full — re-read each destination from disk", } +#: Said the same way in the preset editor and in Simple mode, because it is the +#: one option here whose cost is not obvious from its name. +PARANOID_LABEL = "Read each source file twice and compare" +PARANOID_TOOLTIP = ( + "Catches a read that returned the wrong bytes without reporting an error — " + "the one fault no checksum can see, because the checksum is taken from what " + "the read returned.\n\nCosts a second full pass over the card." +) + + +def _form() -> QFormLayout: + form = QFormLayout() + form.setSpacing(10) + form.setLabelAlignment(Qt.AlignRight | Qt.AlignVCenter) + return form + + +def _section(title: str, form: QFormLayout) -> QWidget: + """A titled block of rows. + + There are sixteen fields here. In one flat list they read as a wall, and the + two or three that bear on any given change are never next to each other. + """ + box = QWidget() + layout = QVBoxLayout(box) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(8) + layout.addWidget(label(title, "heading")) + layout.addLayout(form) + return box + def _color_icon(color: str, size: int = 14) -> QIcon: pixmap = QPixmap(size, size) @@ -121,6 +152,12 @@ def __init__(self, preset: Preset | None = None, parent: QWidget | None = None) self._preserve.setChecked(self._source.preserve_structure) self._skip = QCheckBox("Skip files already present at matching size") self._skip.setChecked(self._source.skip_existing) + self._skip.setToolTip( + "Compares size only, never contents. A speed option, not a safety " + "one — do not use it on a tree whose integrity is in question.") + self._paranoid = QCheckBox(PARANOID_LABEL) + self._paranoid.setChecked(self._source.paranoid) + self._paranoid.setToolTip(PARANOID_TOOLTIP) self._excludes = QLineEdit(", ".join(self._source.excludes)) self._excludes.setPlaceholderText("*.tmp, *.thm") @@ -133,25 +170,29 @@ def __init__(self, preset: Preset | None = None, parent: QWidget | None = None) self._footer = QLineEdit(self._source.footer or "") self._footer.setPlaceholderText("Offloader Version 0.1.0") - form = QFormLayout() - form.setSpacing(10) - form.setLabelAlignment(Qt.AlignRight | Qt.AlignVCenter) - form.addRow("Name", self._name) - form.addRow("Colour", self._color) - form.addRow("Destinations", self._destinations) - form.addRow("", row(add, remove, None)) - form.addRow("Profile", self._profile) - form.addRow("Checksum", self._algorithm) - form.addRow("Verification", self._verification) - form.addRow("Thumbnails", self._thumbnails) - form.addRow("Reports", row(*report_row)) - form.addRow("Job name", self._naming) - form.addRow("", tokens) - form.addRow("Exclude", self._excludes) - form.addRow("PDF logo", row(self._logo, browse_logo)) - form.addRow("PDF footer", self._footer) - form.addRow("", self._preserve) - form.addRow("", self._skip) + identity = _form() + identity.addRow("Name", self._name) + identity.addRow("Colour", self._color) + identity.addRow("Destinations", self._destinations) + identity.addRow("", row(add, remove, None)) + + # Profile leads: it decides whether there is any media work to do at + # all, which is what every control under it is then qualified by. + copying = _form() + copying.addRow("Profile", self._profile) + copying.addRow("Checksum", self._algorithm) + copying.addRow("Verification", self._verification) + copying.addRow("Exclude", self._excludes) + copying.addRow("Options", + column(self._preserve, self._skip, self._paranoid)) + + paperwork = _form() + paperwork.addRow("Reports", row(*report_row)) + paperwork.addRow("Thumbnails", self._thumbnails) + paperwork.addRow("Job name template", self._naming) + paperwork.addRow("", tokens) + paperwork.addRow("PDF logo", row(self._logo, browse_logo)) + paperwork.addRow("PDF footer", self._footer) buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel) buttons.accepted.connect(self._accept) @@ -159,8 +200,10 @@ def __init__(self, preset: Preset | None = None, parent: QWidget | None = None) layout = QVBoxLayout(self) layout.setContentsMargins(18, 18, 18, 18) - layout.setSpacing(14) - layout.addLayout(form) + layout.setSpacing(18) + layout.addWidget(_section("Preset", identity)) + layout.addWidget(_section("Copying", copying)) + layout.addWidget(_section("Reports", paperwork)) layout.addWidget(buttons) def _on_profile_changed(self) -> None: @@ -193,6 +236,7 @@ def _accept(self) -> None: naming_template=self._naming.text().strip() or "{card}", preserve_structure=self._preserve.isChecked(), skip_existing=self._skip.isChecked(), + paranoid=self._paranoid.isChecked(), excludes=excludes, logo=Path(logo_text) if logo_text else None, footer=footer_text or None, diff --git a/src/offloader/gui/simple_mode.py b/src/offloader/gui/simple_mode.py index 9253dc8..91b1c24 100644 --- a/src/offloader/gui/simple_mode.py +++ b/src/offloader/gui/simple_mode.py @@ -23,8 +23,8 @@ from ..models import Profile, VerificationMode from ..presets import Preset from ..reports import WRITERS -from .preset_editor import VERIFICATION_LABELS -from .widgets import DestinationList, SourceDropZone, button, label, row +from .preset_editor import PARANOID_LABEL, PARANOID_TOOLTIP, VERIFICATION_LABELS +from .widgets import DestinationList, SourceDropZone, button, column, label, row class SimpleModePanel(QWidget): @@ -84,6 +84,8 @@ def __init__(self, parent: QWidget | None = None) -> None: self._preserve = QCheckBox("Recreate the source folder structure") self._preserve.setChecked(True) + self._paranoid = QCheckBox(PARANOID_LABEL) + self._paranoid.setToolTip(PARANOID_TOOLTIP) form = QFormLayout() form.setSpacing(10) @@ -94,7 +96,7 @@ def __init__(self, parent: QWidget | None = None) -> None: form.addRow("Verification", self._verification) form.addRow("Thumbnails", self._thumbnails) form.addRow("Reports", row(*report_row)) - form.addRow("", self._preserve) + form.addRow("Options", column(self._preserve, self._paranoid)) self._start = button("Start offload", accent=True) self._start.clicked.connect(self._start_clicked) @@ -171,6 +173,7 @@ def build_preset(self) -> Preset: thumbnail_count=self._thumbnails.value(), reports=[key for key, box in self._reports.items() if box.isChecked()], preserve_structure=self._preserve.isChecked(), + paranoid=self._paranoid.isChecked(), ) def _start_clicked(self) -> None: diff --git a/src/offloader/gui/widgets.py b/src/offloader/gui/widgets.py index 97a9873..1f8a3bb 100644 --- a/src/offloader/gui/widgets.py +++ b/src/offloader/gui/widgets.py @@ -56,6 +56,18 @@ def row(*children, spacing: int = 8) -> QWidget: return widget +def column(*children: QWidget, spacing: int = 6) -> QWidget: + """A vertical stack, for grouping several checkboxes under one form label + rather than giving each its own blank one.""" + widget = QWidget() + layout = QVBoxLayout(widget) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(spacing) + for child in children: + layout.addWidget(child) + return widget + + def _directories_from(event) -> list[Path]: """Directories in a drag payload. Files are mapped to their parent, so dropping a clip on a destination means "put it in that folder".""" diff --git a/tests/test_gui.py b/tests/test_gui.py index 48b88fd..85ec956 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -289,3 +289,46 @@ def test_source_drop_zone_reports_its_path(qapp, tmp_path): assert zone.path is None zone.set_path(tmp_path / "A001") assert zone.path == tmp_path / "A001" + + +# ------------------------------------------------------------- the UI sweep + + +def test_simple_mode_offers_the_second_read_and_it_reaches_the_engine(qapp, + tmp_path): + """A preset field the interface never exposes is a field nobody can use.""" + panel = SimpleModePanel() + assert panel.build_preset().paranoid is False + + panel._paranoid.setChecked(True) + assert panel.build_preset().to_options().paranoid is True + + +def test_the_preset_editor_round_trips_the_second_read(qapp): + from offloader.gui.preset_editor import PresetEditor + + editor = PresetEditor(Preset(name="Irreplaceable", paranoid=True)) + assert editor._paranoid.isChecked() + + editor._paranoid.setChecked(False) + editor._accept() + assert editor.result_preset.paranoid is False + + +def test_the_preset_editor_is_grouped_rather_than_one_flat_list(qapp): + """Sixteen fields in a single column read as a wall. The sections are the + difference between scanning for a setting and hunting for it.""" + from PySide6.QtWidgets import QLabel + + from offloader.gui.preset_editor import PresetEditor + + editor = PresetEditor(Preset(name="p")) + headings = [w.text() for w in editor.findChildren(QLabel) + if w.property("role") == "heading"] + assert headings == ["Preset", "Copying", "Reports"] + + # Regrouping a form is exactly the change that silently drops a field. + for name in ("_name", "_color", "_destinations", "_algorithm", + "_verification", "_thumbnails", "_naming", "_excludes", + "_logo", "_footer", "_preserve", "_skip", "_paranoid"): + assert getattr(editor, name).parent() is not None, f"{name} is orphaned" From 7f91f2af25a4a32e2470d10ac057da2deabae15f Mon Sep 17 00:00:00 2001 From: owenpkent <20529132+owenpkent@users.noreply.github.com> Date: Sun, 9 Aug 2026 12:21:24 -0400 Subject: [PATCH 04/19] Surface the new options in the CLI, and update the docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `offloader verify` prints directory-hash failures alongside the file ones, and `offloader offload` takes --paranoid. The run summary says when a second source read was done, kept out of the PDF's header string because that is pinned to the reference report's wording. The roadmap's Next section is emptied by this branch, so three gaps already documented in docs/ are promoted into it rather than new ideas invented: --skip-existing by checksum, previousPath, and a lock file between instances. docs/data-safety.md had a paragraph stating that a retry restarts the whole file "because a partial read leaves the running checksum meaningless" — the opposite of what the copy loop now does for reads. Corrected, and the limit it listed for unrepeatable source reads now points at --paranoid. 434 tests, 86% line coverage. --- CHANGELOG.md | 50 +++++++++++++++++++++++++++++++++ CONTRIBUTING.md | 2 +- README.md | 29 +++++++++++++++---- ROADMAP.md | 67 ++++++++++++++++++-------------------------- src/offloader/cli.py | 10 ++++++- 5 files changed, 112 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ee5eea..d0ea344 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,56 @@ project uses [semantic versioning][semver]. profile is a first-class field on `OffloadOptions`, `Job` and saved presets, and is selectable in the desktop app's Simple mode and preset editor. This is a one-way verified transfer, not two-way sync — see `ROADMAP.md`. +- **`--paranoid` reads every source file twice and compares.** The gap it + closes: a read that returns wrong bytes *without raising*. The checksum is + computed from whatever came back, so the destination faithfully matches a + corrupted source and verifies clean at every level — file hashes, directory + hashes, the lot. Nothing but reading twice can see it. A disagreement is + retried rather than adjudicated, because there is no basis for deciding which + read was the true one; a source that will not read the same twice fails the + file and leaves nothing behind. The page cache is dropped before the second + read, and the job says so when it could not be, since a re-read served from + memory compares the first read against itself. Costs a full second pass, which + is why it is opt-in. +- **Sidecars and proxies are grouped with the clip they belong to.** A + `.sidecar` carries a BRAW's grade; delivered without its clip it is nothing, + and a clip delivered without it has silently lost the grade. Matching is by + stem, reusing what proxy pairing already did, and an ambiguous stem is left + unlinked rather than guessed at. A clip that copies while a file belonging to + it does not is now a job warning instead of two rows twenty lines apart. The + HTML report shows them together and the CSV gains a `Companion Of` column. +- **`offloader verify` now re-checks the ASC MHL directory hashes**, which were + written from the start and never read back. A rename or a moved file leaves + every individual file hashing exactly as recorded, so no file-level check can + object to it; the structure hash exists precisely to catch that, and now does. + Content matching while structure does not is reported as `RENAMED`, which is a + much stronger statement than the "not in manifest" line it used to produce. + + Verifying this way means hashing files the manifest does not list — that is + what proves a rename is only a rename — while honouring the manifest's own + `ignore` patterns. Directory hashes that a failed file already accounts for + say so rather than repeating themselves up to the root. + +### Changed + +- **The preset editor is grouped into Preset, Copying and Reports.** Sixteen + fields in one flat column read as a wall, and the two or three bearing on any + given change were never next to each other. Checkboxes now sit together under + one label instead of each taking a blank one, `Job name` is called `Job name + template` to distinguish it from Simple mode's literal job name, and + `Skip files already present at matching size` carries a tooltip saying what it + does not compare. +- **A transient read failure is retried at the chunk that failed, not by + restarting the file.** Recovering a bad sector near the end of a 79 GB clip + used to mean re-reading all 79 GB; it now costs one 8 MiB re-read. This turned + out not to need the hasher rewind it looked like it would: a chunk is only + hashed once it has been delivered whole, so a failed read has produced no + state to unwind. The source is reopened and sought back to the failed offset, + since a reader that dropped off the bus needs its handle re-established. + Writes still restart the whole file — a write that fails part-way leaves the + destination at a length the copy loop does not know. Once a chunk has had + every attempt the policy allows, the whole-file retry no longer repeats them + against the same fault. ### Fixed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c08f130..53968f4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,7 +37,7 @@ pip install -e ".[dev]" `ffmpeg` and `ffprobe` on `PATH` are optional — the suite runs without them. ```sh -pytest # ~400 tests, about 33s +pytest # ~434 tests, about 50s pytest --fuzz # property tests at 3000 examples each, about 2 min ruff check src tests pytest --cov=offloader --cov-report=term-missing diff --git a/README.md b/README.md index 291b30b..a2fd384 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,7 @@ offloader verify D:\video\080426\A001 | `--exclude GLOB` | extra filename pattern to skip; repeatable | | `--flat` | do not recreate the source folder structure | | `--skip-existing` | skip files already present at matching size | +| `--paranoid` | read each source file twice and compare (offload only) | | `--retries N` | attempts per file on a transient read failure (default 3, 1 disables) | | `--retry-wait SECONDS` | pause before the first retry, backing off after (default 2) | | `--no-probe` | skip ffprobe metadata and thumbnails | @@ -121,6 +122,11 @@ manifest lists and exits non-zero if anything is off, so a format script can gat on it. `--allow-cache` skips the page-cache eviction — faster, and may verify memory rather than the device. +For an ASC MHL history it also recomputes the directory content and structure +hashes, which is the only check that catches a rename or a moved file — every +file involved still hashes exactly as recorded. See +[`docs/ascmhl.md`](docs/ascmhl.md#directory-hashes). + ### Verification modes | Mode | What it does | Catches | @@ -132,6 +138,14 @@ memory rather than the device. `full` is the honest one: it is the only mode that proves what is actually on the destination, at the cost of reading everything twice. +`--paranoid` is orthogonal to all three. Every mode above compares against the +source's checksum, which is computed from whatever the read returned — so a read +that hands back wrong bytes *without raising* produces a destination that +faithfully matches a corrupted source and verifies clean everywhere. Reading the +source a second time is the only thing that sees it. It costs a full extra pass, +which is why it is opt-in. See +[`docs/data-safety.md`](docs/data-safety.md#reading-the-source-twice). + ## Reports - **PDF** — the parity target. Header summary, one banded row per clip with a @@ -150,6 +164,11 @@ the destination, at the cost of reading everything twice. - **HTML** — self-contained; thumbnails inlined as data URIs, light and dark themes, no external requests. +Sidecars and proxies are shown with the clip they belong to rather than as +unrelated files, matched by stem. A clip that copies while a file belonging to it +does not is a job warning: a BRAW delivered without its `.sidecar` has silently +lost its grade. + ## Generic data transfers The copy engine has never been camera-specific: it streams the source once, @@ -316,13 +335,13 @@ what makes the report layer testable without moving bytes. ```sh pip install -e ".[dev]" -pytest # 409 tests, ~33s +pytest # 434 tests, ~50s pytest --fuzz # same suite, 3000 examples per property (~2 min) ruff check src tests pytest --cov=offloader --cov-report=term-missing ``` -409 tests at 82% line coverage. They cover formatting against the reference's +434 tests at 86% line coverage. They cover formatting against the reference's exact strings, checksum vectors and streaming equivalence, copy/verify behaviour including simulated destination corruption, pause/resume/cancel concurrency, retry discrimination, BRAW container parsing, ffprobe parsing, @@ -373,9 +392,9 @@ that file. Names are now sanitised into the XML character range. already documented in `docs/`, not from a wishlist. It also says what this deliberately will **not** become. -Nearest up: verifying the ASC MHL directory hashes that are already written (so -a rename is a mismatch rather than a footnote), an optional second read of the -source, and chunk-level rather than whole-file retry for marginal cards. +Nearest up: making `--skip-existing` compare checksums rather than sizes, +writing `previousPath` so a rename survives a generation, and a lock file so two +instances pointed at one destination know about each other. ## Contributing diff --git a/ROADMAP.md b/ROADMAP.md index c6e964b..55853b8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -24,48 +24,44 @@ streams. ASC MHL is diffed against the reference implementation's own worked example. BRAW metadata comes out of the container because ffprobe cannot read the format at all, and has been run over 510 real clips from two camera bodies. -## Next - -### Verify what is already written - -`offloader verify` checks file hashes. ASC MHL also records **directory content -and structure hashes**, and those are written but never re-checked. The -structure hash exists precisely to catch a rename or a moved file — a change no -file hash can see, because every file is individually fine. +`offloader verify` re-checks the ASC MHL directory content and structure hashes, +not only the file hashes — so a rename or a moved file, which every file hash +agrees is fine, is reported as the structure-hash mismatch it is. See +[`docs/ascmhl.md`](docs/ascmhl.md#directory-hashes). -Today a rename shows up only as a "not in manifest" line. It should be a -structure-hash mismatch, which is a much stronger statement. +`--paranoid` reads every source file a second time and compares, which is the +only thing that catches a read returning wrong bytes without reporting an error. +Retry works at the chunk that failed rather than restarting the file. Sidecars +and proxies are grouped with the clip they belong to, so a clip separated from +its grade is a warning rather than two unrelated rows. -*Where:* `verify.py`, using the directory-hash code already in `ascmhl.py`. - -### Read the source twice, optionally +## Next -A source read that returns wrong bytes without raising is rare, but nothing -currently catches it: the checksum is computed from what was read, so a bad read -produces a destination that faithfully matches a corrupted source and verifies -clean. +### `--skip-existing` by checksum, not size -A `--paranoid` mode reading the source twice and comparing would close it, at -the cost of a second pass. Worth having as an option for irreplaceable material, -not as a default. +Today it is explicitly a speed option and says so: a destination file of the +right length is assumed to be the right file. That is the one place the tool +takes something on trust, and it is listed under "What is still not protected" +in [`docs/data-safety.md`](docs/data-safety.md) for that reason. A checksum +variant would make it a safe option rather than a fast one. -*Where:* `engine.py`, alongside the existing verification modes. +*Where:* `engine.py`, in the `skip_existing` branch. -### Retry the source, not just the read +### `previousPath`, so a rename survives a generation -Retry currently restarts the whole file on a transient error. For a marginal -card that fails at one sector, re-reading the entire 79 GB clip to recover a few -bytes is expensive. Retrying at the chunk level would need the hasher state -rewound to a chunk boundary — doable, and worth it on failing media. +Verifying directory hashes made a rename visible; it did not make it +*explicable*. A renamed directory reports as one `MISSING` line and one +`RENAMED` parent, with nothing saying the first became the second. The format +has `previousPath` for exactly this and it is not written. -*Where:* `engine.py` `_copy_fanout`, with `retry.py` unchanged. +*Where:* `ascmhl.py`, and `verify.py` to read it back. -### `.sidecar` and companion grouping +### Coordination between instances -BRAW `.sidecar` files carry colour metadata. They are copied like any other -file, but nothing links them to their clip, so a missing one is not flagged and -a report does not show them together. The proxy pairing in `companions.py` -already does the stem-matching this needs. +One app instance serialises its queue. Two pointed at the same destination do +not know about each other, which is a documented gap in +[`docs/data-safety.md`](docs/data-safety.md). A lock file in the destination +would close it. ## Later @@ -85,8 +81,6 @@ already does the stem-matching this needs. - **Nested histories** — an `ascmhl` folder further down the tree with its own chain, and a parent taking a child's root hash as its directory hash. -- **`previousPath`** so a rename is tracked across generations rather than - reading as a new `original` plus a missing path. - **The flatten operation**, consolidating a history into one manifest. - **Several hash formats per manifest**, which the format allows. @@ -94,11 +88,6 @@ already does the stem-matching this needs. - **Email or SMS on completion.** ShotPut Pro has it; a DIT running a long offload wants to leave the cart. -- **Coordination between instances.** One app instance serialises its queue; two - pointed at the same destination do not know about each other. A lock file in - the destination would do it. -- **`--skip-existing` by checksum**, not size. Today it is explicitly a speed - option and says so; a checksum variant would make it a safe one. - **Windows installer and code signing**, so it can be handed to someone who does not have Python. - **Per-job report templates and custom branding.** diff --git a/src/offloader/cli.py b/src/offloader/cli.py index 6773f22..6784244 100644 --- a/src/offloader/cli.py +++ b/src/offloader/cli.py @@ -128,7 +128,8 @@ def _summarize(job: Job, reports: list[Path]) -> None: video = f" ({job.video_files} video)" if job.profile.probes_media else "" print(f" {job.total_files} files, {format_size(job.total_bytes)}" f" in {format_elapsed(job.elapsed_sec)}{video}") - print(f" Verification: {job.verification_label}") + print(f" Verification: {job.verification_label}" + f"{' + second source read' if job.paranoid else ''}") for destination in job.destination_roots: print(f" -> {destination}") for report in reports: @@ -208,6 +209,10 @@ def build_parser() -> argparse.ArgumentParser: help="do not recreate the source folder structure") offload.add_argument("--skip-existing", action="store_true", help="skip files already present with a matching size") + offload.add_argument("--paranoid", action="store_true", + help="read each source file twice and compare, to " + "catch a read that returned wrong bytes without " + "reporting an error (costs a second pass)") _common_options(offload) report = sub.add_parser( @@ -252,6 +257,7 @@ def _options_from(args: argparse.Namespace, destinations: list[Path]) -> engine. profile=profile, retry=retry.RetryPolicy(attempts=max(1, args.retries), delay=max(0.0, args.retry_wait)), + paranoid=getattr(args, "paranoid", False), ) @@ -335,6 +341,8 @@ def progress(index: int, total: int, path: Path) -> None: print(f" {report.summary()}") for verdict in report.failures: print(f" {verdict.describe()}") + for verdict in report.directory_failures: + print(f" {verdict.describe()}") for extra in report.unlisted[:20]: print(f" not in manifest: {extra}") if len(report.unlisted) > 20: From 2fa8a35b21e3cd003f671aa2374896aaf7e3e742 Mon Sep 17 00:00:00 2001 From: owenpkent <20529132+owenpkent@users.noreply.github.com> Date: Sun, 9 Aug 2026 13:31:42 -0400 Subject: [PATCH 05/19] Record the report directory as ignored, so a fresh copy verifies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verifying the directory hashes recomputes them from what is on disk, which means hashing the files the manifest does not list — that is what proves a rename is only a rename. But the job's own reports are written into the destination after the manifest, so they are on disk when a verifier recomputes and were never in what it recomputes against. A card that had just been copied reported its own JobReport.pdf as a change to the tree, and `offloader verify` exited non-zero on the path the README says a format script can gate on. The format already has the mechanism: `ignore`, which the verifier honoured and the writer only ever used for `ascmhl`. The report directory goes in the same list for the same reason — neither is managed data. The path is recorded rather than the conventional name, because `--report-dir` moves it, and both are recorded because they can differ: thumbnails land in `_Reports` wherever the PDF goes. A history written before this says nothing about its reports, so `*_Reports` is allowed for when recomputing, keyed off the absence of any recorded pattern but `ascmhl` — it stops applying the moment a manifest describes its own layout. Deliberately not applied to the unlisted list, which still names those files, and scoped to that one directory: a stray file anywhere else still moves the hash it belongs to. The tests that missed this built their destination with `write_manifest` directly, so it never had a reports folder in it. The new ones go through the CLI, which is what writes the reports. --- CHANGELOG.md | 8 ++++ docs/ascmhl.md | 10 +++++ src/offloader/ascmhl.py | 39 ++++++++++++++-- src/offloader/verify.py | 13 ++++++ tests/test_ascmhl.py | 98 ++++++++++++++++++++++++++++++++++++++++- 5 files changed, 164 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0ea344..8bb0894 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,14 @@ project uses [semantic versioning][semver]. `ignore` patterns. Directory hashes that a failed file already accounts for say so rather than repeating themselves up to the root. + A manifest now records where the job's reports went, alongside `ascmhl`. They + are written into the destination after it, so they are on disk when a verifier + recomputes but were never in what it recomputes against — without the pattern, + a card that had just been copied reported its own `JobReport.pdf` as a change + to the tree. The path is recorded rather than the conventional name, since + `--report-dir` moves it; histories written before it was recorded are read with + `*_Reports` allowed for. + ### Changed - **The preset editor is grouped into Preset, Copying and Reports.** Sixteen diff --git a/docs/ascmhl.md b/docs/ascmhl.md index 0ba8a3f..89e51ab 100644 --- a/docs/ascmhl.md +++ b/docs/ascmhl.md @@ -69,6 +69,16 @@ renamed file is unlisted under its new name and its hash is what proves the rename is all that happened. Files matching a recorded `ignore` pattern are left out, exactly as the writer left them out. +That is why the writer records where the job's own paperwork went. The PDF, CSV +and thumbnails are written into the destination *after* the manifest, so they +are on disk when a verifier recomputes but were never in what it recomputes +against — and folding them in reports the tool's own output as a change to the +tree. The manifest carries the report directory as an `ignore` pattern for the +same reason it carries `ascmhl`: neither is managed data. A path rather than an +assumed name, because `--report-dir` moves it. A history written before this was +recorded is read with the conventional `*_Reports` allowed for, which is a name +and not a fact — a current manifest states its own layout. + A directory whose mismatch is already accounted for by a file that failed on its own hash says so, rather than reporting a fresh problem for every directory between that file and the root. A directory that gained an unexpected file is diff --git a/src/offloader/ascmhl.py b/src/offloader/ascmhl.py index 13c9345..09ffd17 100644 --- a/src/offloader/ascmhl.py +++ b/src/offloader/ascmhl.py @@ -172,6 +172,34 @@ def ascmhl_dir(root: Path) -> Path: return Path(root) / ASCMHL_DIRNAME +def _default_ignores(job: Job, root: Path, report_dir: Path | None) -> list[str]: + """The history's own folder, plus the job's paperwork. + + Reports are written into the destination *after* this manifest, and they + are not managed data: nothing lists them, and the directory hashes here do + not cover them. Recording them as ignored is what stops a verifier folding + the tool's own output back into a recomputed hash and reporting the report + it just wrote as a change to the tree. + + Two entries rather than one because they can differ: `--report-dir` moves + the PDF and CSV, while thumbnails always land in `_Reports`. A report + directory outside this copy needs no pattern, and one that *is* the copy + cannot have one — ignoring `.` would ignore everything. + """ + root = Path(root) + patterns = [ASCMHL_DIRNAME] + for candidate in (report_dir, root / f"{job.name}_Reports"): + if candidate is None: + continue + try: + relative = Path(candidate).resolve().relative_to(root.resolve()).as_posix() + except (ValueError, OSError): + continue + if relative not in (".", "") and relative not in patterns: + patterns.append(relative) + return patterns + + def existing_manifests(root: Path) -> list[Path]: """Manifests already in this history, in sequence order.""" directory = ascmhl_dir(root) @@ -261,6 +289,7 @@ def write_manifest(job: Job, root: Path, *, destination_index: int = 0, process: str = PROCESS_TRANSFER, algorithm_key: str | None = None, ignore_patterns: list[str] | None = None, + report_dir: Path | None = None, directory_hashes: bool = True, when: _dt.datetime | None = None) -> Path: """Write one ASC MHL generation for the copy at `root`, and update the chain. @@ -321,7 +350,8 @@ def write_manifest(job: Job, root: Path, *, destination_index: int = 0, root_hash = ET.SubElement(info, "roothash") _hash_pair(root_hash, tag, content, structure, moment) - patterns = ignore_patterns if ignore_patterns is not None else [ASCMHL_DIRNAME] + patterns = (ignore_patterns if ignore_patterns is not None + else _default_ignores(job, root, report_dir)) if patterns: ignore = ET.SubElement(info, "ignore") for pattern in patterns: @@ -404,14 +434,17 @@ def write_ascmhl(job: Job, path: Path, *, destination_index: int = 0, `path` is the conventional report location; ASC MHL ignores it and writes into `ascmhl/` at the root of the copy, which is where the format requires - a history to live. + a history to live. Its *folder* is still worth knowing: that is where the + rest of the paperwork lands, and the manifest records it as ignored so a + verifier does not mistake it for managed data. """ roots = job.destination_roots or [job.source_root] index = min(destination_index, len(roots) - 1) # The writer interface is shared with the PDF, which takes logo/footer. # Accept and drop anything that does not apply here. - accepted = {"process", "algorithm_key", "ignore_patterns", + accepted = {"process", "algorithm_key", "ignore_patterns", "report_dir", "directory_hashes", "when"} + options.setdefault("report_dir", Path(path).parent) return write_manifest( job, roots[index], destination_index=index, **{k: v for k, v in options.items() if k in accepted}, diff --git a/src/offloader/verify.py b/src/offloader/verify.py index ece70d1..cce75fe 100644 --- a/src/offloader/verify.py +++ b/src/offloader/verify.py @@ -31,6 +31,11 @@ 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" @@ -338,6 +343,12 @@ def note(path: Path, digest: str | None) -> str | None: 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": @@ -354,6 +365,8 @@ def note(path: Path, digest: str | None) -> str | None: 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: diff --git a/tests/test_ascmhl.py b/tests/test_ascmhl.py index 41ba384..4ea0344 100644 --- a/tests/test_ascmhl.py +++ b/tests/test_ascmhl.py @@ -15,7 +15,7 @@ import pytest -from offloader import ascmhl, engine, verify +from offloader import ascmhl, cli, engine, verify from offloader.hashers import c4_of_bytes, hash_file from offloader.models import VerificationMode @@ -458,6 +458,102 @@ def test_directory_checking_can_be_turned_off(history): assert report.directories == [] +# --------------------------------------------------- the job's own paperwork + + +def _cli_offload(tmp_path: Path, reports: str, extra: list[str] | None = None) -> Path: + """A real offload through the CLI, which is what writes the reports. + + The fixtures above call `write_manifest` directly, so the destination they + build has no `_Reports` folder in it — which is exactly why nothing + caught the tool's own output being counted as a change to the tree. + """ + source = tmp_path / "card" / "Clips" + source.mkdir(parents=True) + (source / "A001_C001.mov").write_bytes(b"footage " * 500) + (tmp_path / "card" / "readme.txt").write_bytes(b"shot notes\n") + + destination = tmp_path / "dest" + assert cli.main(["offload", "--source", str(tmp_path / "card"), + "--dest", str(destination), "--name", "A001", + "--report", reports, "--quiet", *(extra or [])]) == 0 + return destination + + +def test_a_freshly_offloaded_card_verifies_clean(tmp_path: Path): + """REGRESSION. The reports land inside the destination after the manifest + is written, so recomputing the root hash over everything on disk folded the + tool's own paperwork in and reported it as a change. A card that was just + copied has to verify.""" + destination = _cli_offload(tmp_path, "ascmhl,csv,pdf") + + report = verify.verify_manifest(verify.find_manifests(destination)[0]) + assert report.passed, report.summary() + assert all(v.ok for v in report.directories), \ + [v.describe() for v in report.directories] + + +def test_the_manifest_records_where_the_paperwork_went(tmp_path: Path): + destination = _cli_offload(tmp_path, "ascmhl,csv") + text = verify.find_manifests(destination)[0].read_text(encoding="utf-8") + + assert "ascmhl" in text + assert "A001_Reports" in text + + +def test_a_relocated_report_directory_is_recorded_too(tmp_path: Path): + """`--report-dir` is why the path is recorded rather than assumed: the + conventional name is not where these went.""" + destination = _cli_offload(tmp_path, "ascmhl,csv", + ["--report-dir", str(tmp_path / "dest" / "paperwork")]) + text = verify.find_manifests(destination)[0].read_text(encoding="utf-8") + assert "paperwork" in text + + report = verify.verify_manifest(verify.find_manifests(destination)[0]) + assert report.passed, report.summary() + + +def test_reports_sent_outside_the_copy_need_no_pattern(tmp_path: Path): + destination = _cli_offload(tmp_path, "ascmhl,csv", + ["--report-dir", str(tmp_path / "elsewhere")]) + text = verify.find_manifests(destination)[0].read_text(encoding="utf-8") + + assert "elsewhere" not in text + assert verify.verify_manifest(verify.find_manifests(destination)[0]).passed + + +def test_paperwork_beside_a_manifest_that_never_recorded_it_is_tolerated( + tmp_path: Path +): + """A history written before the writer recorded its own report folder. The + files are still reported as unlisted — that much was always true — but they + are not counted as a change to a directory the manifest never covered.""" + job, destination = _offload(tmp_path, dict(PLACEHOLDER_FILES)) + ascmhl.write_manifest(job, destination, when=WHEN, + ignore_patterns=[ascmhl.ASCMHL_DIRNAME]) + reports = destination / "A002R2EC_Reports" + reports.mkdir() + (reports / "JobReport.pdf").write_bytes(b"%PDF-1.4 paperwork\n") + (reports / "thumbs").mkdir() + (reports / "thumbs" / "A002C006.jpg").write_bytes(b"jpeg") + + report = verify.verify_manifest(verify.find_manifests(destination)[0]) + assert report.passed, report.summary() + assert any(p.name == "JobReport.pdf" for p in report.unlisted) + + +def test_the_tolerance_stops_at_anything_that_is_not_paperwork(tmp_path: Path): + """It is scoped to the one directory the tool writes itself. A stray file + anywhere else still moves the hash it belongs to.""" + job, destination = _offload(tmp_path, dict(PLACEHOLDER_FILES)) + ascmhl.write_manifest(job, destination, when=WHEN, + ignore_patterns=[ascmhl.ASCMHL_DIRNAME]) + (destination / "Clips" / "extra.mov").write_bytes(b"not in the manifest\n") + + report = verify.verify_manifest(verify.find_manifests(destination)[0]) + assert _directory(report, "Clips").result is verify.DirectoryResult.CHANGED + + # --------------------------------------------------------------- reference From 23c0ca9c15b207b28393a27ad76f67f6b08b07fa Mon Sep 17 00:00:00 2001 From: owenpkent <20529132+owenpkent@users.noreply.github.com> Date: Sun, 9 Aug 2026 13:42:58 -0400 Subject: [PATCH 06/19] Show the desktop app in the README, from a script that regenerates it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app section described a drive panel, a preset list and a queue without showing any of them. Three screenshots: preset mode with a job running, simple mode, and the preset editor. Generated rather than captured, because a screenshot taken by hand is wrong the next time the interface moves and nobody notices. `tools/screenshots.py` drives the real app — nothing is mocked but the two things that would otherwise leak this machine into a public README: the config directory is a throwaway, so real presets, settings and history are neither read nor written, and the drive panel is fed invented volumes rather than whatever is mounted. The queue items are built directly instead of enqueued, so no job runs and nothing is copied. The window is opened wider than it starts and the queue splitter pushed down: the default split leaves the queue a row and a half tall, which is the one part of that screen a reader needs to see. --- CONTRIBUTING.md | 10 ++ README.md | 10 ++ docs/images/app-preset-editor.png | Bin 0 -> 62966 bytes docs/images/app-preset-mode.png | Bin 0 -> 115471 bytes docs/images/app-simple-mode.png | Bin 0 -> 117635 bytes tools/screenshots.py | 185 ++++++++++++++++++++++++++++++ 6 files changed, 205 insertions(+) create mode 100644 docs/images/app-preset-editor.png create mode 100644 docs/images/app-preset-mode.png create mode 100644 docs/images/app-simple-mode.png create mode 100644 tools/screenshots.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 53968f4..a7c7a8d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -50,6 +50,16 @@ test files, but if you run Qt code by hand: QT_QPA_PLATFORM=offscreen python -m pytest tests/test_gui.py ``` +The README's screenshots are generated, not captured, so a change to the +interface can bring them along with it: + +```sh +python tools/screenshots.py # rewrites docs/images/ +``` + +It runs the real app against a throwaway config directory and invented volumes, +so it neither reads your presets nor puts your drive labels in the README. + ## Testing without a camera card Almost nobody has a 27 GB BRAW clip and a failing card reader to hand, so the diff --git a/README.md b/README.md index a2fd384..0611a5f 100644 --- a/README.md +++ b/README.md @@ -208,6 +208,8 @@ partial-file updates. See [`ROADMAP.md`](ROADMAP.md). offloader-gui # or: offloader gui ``` +![Preset mode: the drive panel, saved presets and the job queue](docs/images/app-preset-mode.png) + Two modes, switched from the header: - **Preset mode** — saved workflows, each with its own destinations, checksum, @@ -217,6 +219,14 @@ Two modes, switched from the header: - **Simple mode** — source, destinations and options on one screen, for a one-off where building a preset would be more work than the job. +![Simple mode: source, destinations and options on one screen](docs/images/app-simple-mode.png) + +A preset is edited in three blocks — what it is, how it copies, what paperwork +it leaves: + +The preset editor, grouped into Preset, Copying and Reports + Down the left is the **drive panel**: every mounted volume with a capacity bar (amber past 80 %, red past 95 %) and one-click *Source* / *Destination* buttons. Volumes that look like camera media are badged `CARD` and sorted to the top — diff --git a/docs/images/app-preset-editor.png b/docs/images/app-preset-editor.png new file mode 100644 index 0000000000000000000000000000000000000000..4ec38505cc58cea1e5129ff27220b31090cbd843 GIT binary patch literal 62966 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 literal 0 HcmV?d00001 diff --git a/docs/images/app-preset-mode.png b/docs/images/app-preset-mode.png new file mode 100644 index 0000000000000000000000000000000000000000..992f11b020e081864aabeb95aeb9b5fd82f3ef42 GIT binary patch literal 115471 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

ywR$9rduIzEr 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%0Dj=faew#qJ>;C$@aZ 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

neAU_ zoG+;8e{FktV5 zGb~mVC<}ReE9}L!IR-pZ2|%{v3)R)}E)ns;nPii)J_dn2%aAZ#2iE0egy24m7QIVA zP3O7i;0=c zZr^zf%&dN*t<~ic9eCpwj_g0L%>(i(ztKl_+(3qjLWr4>aTQpI#1&+s9tkxc-ZYbq zRD2E|3J2COx94;v;l56F>+XZluxz^P*xdC48jhlzs4#7Y9&IA7$-$SqQ=OzwK1j_t zR{l@03M8x%cP$F8pMw8asJ8zPG3UQw@&1=)O(=D-Z7tx)+7o*CJdipJcKNeQW#DIc z%%PBT+7Is<`=x!OXwg3;^TP{YXzKoH(Dtl1=gRnCp2Ued?58swo5V>Y9EFY=Lig{_ zsSr}A20W5HPsyLE{92zTq5?PXEoh4V99=Higzz4j<%{iqZy0bk-?%VRqm`@!+){LF z6EocCfbf7k&9yOy79W#6`BUSF<<1m$!-kO1nECnj_-l?v68i$U4~pNOCT$Lz3V;L7 zmhMFtNh)Q0da8nvetDuc5RHFe<9H(p(nfPNFN~o1aXO_LHKYuZbm%w}xEx1CMauQA z=6mtCXrS95N}U5xD_>B^qr|T3^=$F(*#23fSWfY6Ik`D5Qx|uG?K{=oAYbM;yjJ7; z4El?cRkI&uy>>2D@+41Q>RQ9|R&IMglvv8-+t}R3JNSrh>@Qc1@Nc@(U^i!+BWBAm zwjY9)=I$hql@U3UwR7a!m^9UBU2XcSq_-`#n#N-O+#?|6mBR|06Dr^m2E9dDS>Gpb zh$KZNdxyt9hs?H1jQEMY;&b>}nyv+Q1V6a?gsvShNWIm0$O}!k+^`jb{#ZOGLdW7} zWYD?fI>Kh4t%Q=Ss(D+(!SXSG&Q&0WX^}zb-SY&Q@iA2EHTi|4!PAjBQD-jr72Z6f zhhwT99;zN(GUcIbmm}5Y`98bnb3rhCOG6L-XkKcLG{J9`mLsN&KDf4CV1aj5Rqg|7 z#&7F}%U%r}uJ0vnkJdnTlNm2>Es#t0SIH|~nm+4xOz(U(F$O7|HEjVqrm6+c{-HYL zcy6iyKxQ}ZY1W}!p0`*py6j6vXe!@+muECU>N_x0QzNn zKkjLU#MGZo4Y2o-F71)S&Syt}3-QS3u#p2_?O#t~3^~tKiPnjip%^Jm|8K@tUz;^f z9wd?I3{wqu^tH-1{p|5(9x0e zZYQ@`#_n>>^LcH!;v5xghrI#QhXI)hmTT^xa?7P791W!*UxQYEEl3sT-&q(Z+5COY zqZac8bVHrA!}kU9`4JjT--tW>n$~l?-knDX_Ni>S+v}G*Hl0q%#E;6z^%blu$w`O_ z@pC{nOHb*i2i3Y-i-tE&KPxb8&Y7A%QBzLt ze;=?f`bHlW{mtihe^Z<9o#gB19Z{_m_A*a@f=zEo zEYu#L%_lK&dp1+U_JkYzsyRUt{7;9jxy8ZPYMBk;i&#omv%kcIZO{>wgz>UApL1nW zrAi>!FHgrwdgt1EO*H(n1CVuL2(CUlV|=+W@tg{GvFoF%B&c8WLQUy7E~*cr%Z@Bl zO>bRpgfy`aS6TXiLzpT3cHFADeA{H-`FDt5hG)S8Jdemvs13|6(BT9AI~xp?OLXd{ z4I66s!bSRLmXy2rRmWMToi8LQRA}n^l_th3Xm~d=_0)Ms27CfW{Ad>{1D2JxakACoo_VfO%#14+XAUomLkY=Uv<_wI{DkN(+x9Xd?TOgC_=_EX9`2*u} zn>8!CO7Eq2K{Fspz~#pSt)|H0!>NO+4wHQyMs21~1>-~1ymn`^eEv=P-H_%nZ_uHA zxWw4r{9Dp66@;3m2CambK~v4>c}MVAQ+X48*-OpPmJM@M*CX|r9S?pQ?Zue0>2SjT2g#uE?V<|NhbkgnI0pY#tt5qc8d>NuZy3m?;nj z2^g6ty_Ay@p)#DEsov;o<)9;t@ZP$|e_52R$$sJ|X(EP;w@)bQwe6jpZKc|U*DPZo z2__=^;jMLbl&@8)3KLnK|9-cQa*HE-AjEK6AXW9vQk}+MexIhzCu*8ug z{<{t4xUo6vs6b9`9ow%&cI&|)UEj2=YW8+d7E*vbW;1tS@r*LIVh%&voA31wN$_fi z4T69Uv|cf83|#9KXTvaWtZ6gTrXu^?eJ;uJUx~v~KP;)pIEzxkp1xun52htibj*<0 zXjuBj5Ze%Y$?#zr{V|1;%xUr70sEQ+9zAiSEft0ff#5_eTZ&@_2nBk9LpwHQLH#7RaMN% ztS!lPDjd8mUYfUQj>{+$cRi#@p@$uIr!H=|3Hr8q?25O&9J#^2G_M7t|Gk*6>Tir0 z;PhNK9UE}nS+ri9vYdl;O{3V(zB|2i>_xW4^U$oyaJI}Yv7c`cY?sZyY^}bHc=yUh=buT0NW}eCVsAF}4 zJ_yfjH6b??8{ITGRW#FiP4vJSl@29mcTL>kSKPc7Jxjw=N&;3}b{~MF17Zgb_Cb_e z(5u-IyvnEL6R$!wlUJsuHqvAq!YEl>RTZine^XY6HmM9Hr%E_0E7hx|i5G0Ny1k`i zp);3_ehwOWw8jZ#jB_Ms7=DUo#cx`~qF-X4*MN)kbnQ~X_JWN3#+9i&&eKo_RRQUH z^E-^vLwIHCq!I4?=hV^j&5Ub#8{SH7_44%sQsjWrekQQB!2`<~=HZz?LB&K?%dhQy z!O7md$#u5#x{p!&xI)9y@@F2z%y2Wt-RSXDN`?TBNnU&X`dfq?DSHz1ILrke)D4(4 z;JP}au1~S_32E;nJcjupw0cD4>dZ9U{@Ii$@%N{(E{ecy3D#fdzdicpm1lNFSX&yD zEvD*a+WGY_@z$%0u;tlNL5a21=7KGhFh(oY6@D7gcZB=hXN2>N$+u@@@fxo(SvXCI z6h{0yJp#m-k<`HMJEl_BDE_%IdO?R|56fN37WrI_U(cMf#4-JPYtMULRwdj29GAE? z?{8TBd`NcXrfbMT+DW-yIe2DFFvm*sP?%xaW#D89Dauq0FSrx_Eu`w1%9I(y?byY8aW=@b93iqI`#jY^|%Kz;W9h;rJ@eMdwl zBFdmG{F||$H-FQO&b3IhYNypSl{5MY;evh=Z4Tq~%%K%He!rgG|60pC!75b#@L4G6 z1x_?pf?_j%MZyPY3WX$V|C*N^Yf^H#UYoY5jG#u0)A@Sg43d3r)TsZ|B}vAh_?8YM z&Wn#Hl>BMmZUbs1yi-D4Iw}brQ4MQ8O<7Ixs^F%Vvxy^5oO1_A@@MDqQ%0NCrXc+T zFSd9yPt{#8#7^y{*Mc@KDLyURPaw*J1N6+Pg=k_1U0KnXV)mDvYE-3_)d~W%~zV>$QUt zW-0-;ueKbKl0$Hyc?d}jx31d#&q4b`zaItdQG2gNv)p;J2$%pk1_A9Aj$W1@ozkSn zf)?evj@WVMrCDBUpjE3M7t8;p>O6)h0;x&J?7TfCmP{Pb2c1eyJuR8~7sI2qS`^|k zDkIMshy-_s0z0|=-^T#v!co?)HVRV~(S{MMsDumh`zpmlC~U z#y*To66am_<#?+N1<9Jh2L|TwzH_*BH#)&KD%5Rv;+n2puceTbc>?xvkq}Lf6_>;x zJAE06@x>gpB?-xWsf1=_dY0$NTqFc=Aja*70dWr{E#z3{*l|9Hfg_2xrZ)a;4-Z%? z9hq~#K8cN?`gg6fPIvp*z{y>n>cS= z7n&YsaiQO>*1I5y$6>FzK0sZ13Z~h7rznX>)I!I=Lbl5McZsk0Xo23~)}W#tquNL{ zVDQ?o(L&10ac?R=lsqR_fiOEfD5Y@FFOE91dgOrjyY0E8!Pq zTraahDiw48&P3zMiI51#aei;>+n7i>SPBW3dOZs|b9lGEeke(HWXCI&fx~o0c^k6` zM)5EK!1a)w9!NO2US9I zdI=86ZbQIv{NqvOaFk}>p5E5~An=LGLmRvKnLl7+wfZFQRl|lbg_O~*S6$7VmTq9G zqC&^S!^Y`ck)TPZR+vP%aJ`&g}$C>l*`b2jQmDTN`@qxiO773 z<9b5Pg*9KjHedx~ly?ubrO!iIj5({T@QuX>S`po!BV-ocV20GOGgjb(Lt|`SYU4Od z9B;Q?Ie8j zMjOq$o2z!fIHY2m*E2x??gYrIg+p-XW`0#&C({GKFs#rq0xa{24bh2>$to79ru>1* zISu0`%TZpXTJfz^&k7sBeUD$IJxS0!QFUu!kmOhK^7;iRd4(;Rq4&^@*>lK~4Yir- z)ivrFtrJYaa70W}-$>27ZLx-Vxd}!1@U=F2EfzHP z7;%i{lYCH(Q`b!SQ+oK2lbc?a0pyE^1seSj4mL7IBZm>#`J|d3!S*!V4wZBpojN@pK}_^6W)9J(L*o~{LUifp%C1ZuF+XAcuv9a zKJSccT%E;-#slo1IVPooN>K&zBWF;Zxyl~jy`j#D?%h9{MKaqIbBwrhL$3XK`(OWD ze@JlSpK4a%v)LQ&{{pE2;mQoDf7P@C|LJw}zxyiAe>CU>On0XLXe;_Z`(YU3DkR_n zelCFxq{Q3|uP_x?7f6ILX2Kta!)}nnj+xo&fHeU%)`+O7cNL?8SC&U=`=(dsv;V}A zu>#}~$_Vf?wo>?~H+D`ZT6@0fT>Rncm&nb|Hg7AW;*9Z?+DYND&c2vp4*M#2J2&95 zU%tsgmnmb~)Vwlja4d?># z5q*`(xjivz^DlmUjG}U}4&DvjF-IRFXRL~IA=NY)p--d_lgww9!1&blCu`QNQOBb8 z0sa|+-8{dIV3PJ$5MJSiF{Zm|Gt;47*5pk85dL||#w{C+PdIt8ZJ=R9I%|JNe4m3u zUQw;CsJ49XoWSj>XQE+MpJIM=Ygek|t-Lzo5$2`mwc4D=_)^~$IEbVywxz9$qy3RM zyq_=2JuP00k_Qp@X#LJuK=@wGt_P4nrj?12aHAB)0~M8W(K4 zxvQZId9mSp-N|+Mj)HaPa5L1XPs`6^tGIqiiMqp-ic#Fz!GG%Iv9{64*_@%zkrn|e!)IT&H{5_dG3V<&x8_9Eze8nw)7=banDI`UAElo8 z_&1FLyWea+uz@lt?ysF>JdKC;i&C5OR*c*@y7(?1jE3)WYB8RPkPV%bEt`+prM}5` zpG;uUVNV%o*WJXgY>RzRSWKUKA#FBc7CG&Il`S_r!oKDGT%A)^K|+9@ccafGIVV{0 zB~nmVnY(>lls-kHe;!7V;y&a@El@x9a4X6zVe}W?2DvG#^Qbrv{oL_PF15>96@~&- z#g&t1(jsW)cs<6=BHgeJPcQ243~?mDYfTouA276?IlrO`c0tuc$XI1yCijKtT#-dxr?2$SZks|(*5Timj$kctrwIhA zvb&A8+OnB8vC1T)HYl1&-K=bT@=J+`1HMt57az00hP7j!4CI<@^@I)(1}$R5l8T3k*6R-&oyt&?nfRkFBQLnAYCK}lb=C86wcy> zetK=Qxs|rK?%3}Cj6EYpe^nO~f^F16Gohjv)FR4K!ZM{#vWs@zcr#4nEZG zV{ekHEp~o3&_@5y0s3jvbpL_C37>3h0SJ^_m8v|I#_;VcR?(c@4E1H&F6ap{qsHW` zk4A*UwZ%kt+6i+cR8G%)uDK3#gj=_71`)4p^&B5EvcGtUaUB~Tv-9Mn%)ddMQO#GE ztiQ_&_TL~rLi{*;d4U5N7K8=jV~5?1E^3*0|5W{X2N6p)t+ef0*YRgpUfS?G($lRP zs-7iGOO72vnY^J+cUe1=Ub4rUo!2uyvIwl?dWH)md&0~TxSzscKxt$Ak< zU1Jm$T6k5O39pY#3^>M#e#-{S%oH}p+QN075qPE6@ofGFe2{uO!d#-$)BLi(HXZ-aTM;` zvA1e|d{j~W$>7;}Nh@;-K+4BUvlM1?Q{w(9_XCFT;D_!CovHEPiniAgZb9w%cmJsFX#Iy0A z;^Wv4EPl6rJd?qsW`fZs-j9;a zYV6C+7^v5^tV%T^|D{}*$Yso*QA6UZ{HfLZ?Xyz2iG|*ez0NoZr6!0)8V#y&{TJKWCqJV^d#XppYHa-uYF843nn z&;N<1gxk`!Tk+qzy7p)${5I|l^%Ii2G!}|V{p22o%$?#@8On$-<(6BR+pBWRbuKfx z=Gxd?W>);nlA50yBG(ym+Z0+_6UMyjee1mEyx)Jm=Q-#5{pWd}^EsdAIiK@9?qX^5 zQUT*^$BYM2F%PUmA2k@tOJVvEGCQdAfN{9!5Q_) zhV0`**;hXmM-WN)dvODcJ9ks9?tDYRE;u$_{|LYG6dPpvzB6i)vPp+;!{0LP>*`$3 zGN;r4A2=u3&Aq+XK|C;~I+~kIdR*(nmaML?ZEVyg{uu-5{}18iIR|7T$?uQdEO@8? zx@!?J+V~n>ywf57xVMMTiD{*DSkmc)9Km$W`5Wix8Cb6Eg?_w49Zdl-2ugSce z)`U!(Hzwa0tAL{>%%2#(cS~Tt$t{1}jy4^hj77Z9D<`$hSH`Noh~Hss&1^FSnT-y? zX|_+`wmQZgEYF~km|*dRiu&M~@92_tSxrKG-RB|O)k|uhb(5+7dxos}0OwQKJ`kQV z{PCHi1%KgHb)mdqusyO7ac+Jos8BC`7R12Q7sr;|fSYFzLQns_5@QGy zIVu|5@z-dxp^Eu={<5=n<1HQ715u4Kf}g$;tS3~zO(WAHLfR@@javihf*&3d8@GxWR3?$bSshp9^Lr)MCpUwc{ylVj>p3#_=TD+ zkZ>eid$tAfPf%ql`)d77N_YUGZ`@mj^HDQjA zGx|d( zz?+E9)#N<;W;Y5bp)vrOU19+^_=!M<#OgQwJBsmSTQ`%ks{v+Tf$R2sx9E_W&KbOt*m*UK1KaHK@* z&0{w1m^iLDX#LWc)J(~6fik4FT6YyT$*=4Ujk_23cMaRD5TOC4iWCRuMnyZ-zf2p{(5Pz3VG}d%Da)9z-Vi2BIW;`#jM> z{>SPWle|r+wf>oHA`Z(}A^hlwh)&xnR(NL2I&?hXZaNDuxvMheneWpf&Ev%{y11o* z^y1K@z~Dxuop|(x)&9i%^!cjHWqZL-4c6{jzQv|Zr(U65gcc9m^UB1b{A|DEx$Lk_ zTY_j|y7f=l;m`9zEkPQPUUY|%2bWn72{R!d8;}UOJJEs78t8f)LALk40#R>}7A^?FhN3n~aO+R>gQ^P^ zE!u1UD9oSv+<8NSCpbB9?B`s7_GL_Ixao9AatWp-xIkX)W^B9VQrF(K*8A7cvajJu zY3}w!0}r!_P_;D^F+IS^!!=f^dRZRAs>E5FoBQ3lv0!Rs8LoJJVEDvvMDzDViYdXm zY&f6&1PpV-XSvRCXr|IR54~rjp($A6!=C039VU>y^U14Q%!X1f|ve}-GV=h1VrB3?SAvzQJDzTdx{=$@3 z)4M*YYH+>jxCSv0=i9x6z_biTDl%^BI?1qF(M5Z|BeJEmYgCz(mMKGb%+VY#6+hoOFzi#Ik0sfH3B0(xH=- zEy(gN1^<>iz9=&HtI_pw9?WMfRH9yo^0*fFm2}r-@cyGVj!AlI-5wsm5$ z&ips4_@bi~e<(ltW$>Y6-C&*t{d{9<>0r?WzV+bWD_XGO literal 0 HcmV?d00001 diff --git a/docs/images/app-simple-mode.png b/docs/images/app-simple-mode.png new file mode 100644 index 0000000000000000000000000000000000000000..59089845e41db79d5456d3450d4fa66658e3890c GIT binary patch literal 117635 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

6n5;Yo=_a* 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

Z1J8Laj-OYZY| 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 cqpmIvcTAz^sP{HmasOkZyASlyxBv6vzvjQ$ None: + (config_dir() / "presets.json").write_text( + json.dumps([p.to_dict() for p in PRESETS], indent=2), encoding="utf-8") + + +def fill_queue(window: MainWindow) -> None: + """One job running, one waiting, one done. + + Built by hand rather than enqueued: `enqueue` would start a real offload, + and there is nothing here to copy. + """ + controller = window.controller + controller._auto_start = False + now = time.monotonic() + + controller.items = [ + QueueItem(identifier=1, source=Path("E:\\"), name="A001", + preset=PRESETS[1], state=JobState.RUNNING, fraction=0.62, + stage="copy", current_file="A001_08041254_C007.braw", + bytes_done=int(283.4 * GB), bytes_total=int(457.0 * GB), + started_at=now - 512), + QueueItem(identifier=2, source=Path("F:\\"), name="B002_080426", + preset=PRESETS[2], state=JobState.QUEUED, + bytes_total=int(198.0 * GB)), + QueueItem(identifier=3, source=Path("E:\\"), name="A002", + preset=PRESETS[0], state=JobState.DONE, fraction=1.0, + stage="verify", bytes_done=int(129.7 * GB), + bytes_total=int(129.7 * GB), + started_at=now - 940, finished_at=now - 512), + ] + controller.itemsChanged.emit() + + +def settle(app: QApplication, rounds: int = 12) -> None: + for _ in range(rounds): + app.processEvents() + + +def shoot(widget, out: Path, name: str) -> None: + pixmap = widget.grab() + pixmap.save(str(out / name), "PNG") + print(f"{name} {pixmap.width()}x{pixmap.height()}") + + +def main(argv: list[str] | None = None) -> int: + argv = sys.argv[1:] if argv is None else argv + out = Path(argv[0]).resolve() if argv else DEFAULT_OUT + out.mkdir(parents=True, exist_ok=True) + seed_config() + + # The panel scans real volumes on a worker thread; give it ours instead. + drives.list_volumes = lambda: list(VOLUMES) + + app = QApplication([]) + theme.apply(app) + + window = MainWindow() + window.resize(1280, 900) + window.show() + settle(app) + + # The queue is the part a reader most needs to see, and the default split + # leaves it a row and a half tall. + for splitter in window.findChildren(QSplitter): + if splitter.orientation() == Qt.Vertical: + splitter.setSizes([500, 400]) + + window.drives._rebuild(list(VOLUMES)) + fill_queue(window) + settle(app) + + window._set_mode(0) + settle(app) + shoot(window, out, "app-preset-mode.png") + + window._set_mode(1) + # A named folder rather than the bare drive root: the drop zone shows the + # name above the full path, and for a root both lines read "E:\". + window.simple.set_source(Path(r"E:\A001_PYXIS")) + window.simple.destinations.set_paths([Path(r"D:\Archive\2026"), + Path(r"N:\cold\2026")]) + settle(app) + shoot(window, out, "app-simple-mode.png") + + editor = PresetEditor(PRESETS[2]) + editor.show() + settle(app) + shoot(editor, out, "app-preset-editor.png") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 72c5df6a22546ecf7873cd6c9570530be9b3d300 Mon Sep 17 00:00:00 2001 From: owenpkent <20529132+owenpkent@users.noreply.github.com> Date: Sun, 9 Aug 2026 13:52:16 -0400 Subject: [PATCH 07/19] Say what failed first, and once per file rather than per sector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things the directory-hash and chunk-retry work reported badly. A report where every file matched and a directory hash did not opened "3 checked: 3 ok", which reads as a pass to anyone scanning — on the one verdict where the file hashes agreeing is the point rather than the reassurance. That case now leads with the directories and says what the combination means. A report with file failures is unchanged: it already opened with them. Recovered reads were one warning each, so a card failing over a contiguous stretch produced one line per 8 MiB and buried every other warning in the job. Now one line per file: a single bad sector still names its offset exactly, because there the byte is the useful fact; a run of them is bounded by first and last, because there it is not. Also documents what recomputing directory hashes actually reads. Proving a rename is only a rename means hashing what the manifest does not list, so a destination root holding anything besides this job is read too. --- docs/data-safety.md | 8 ++++ src/offloader/engine.py | 28 ++++++++++++-- src/offloader/verify.py | 8 ++++ tests/test_ascmhl.py | 7 ++++ tests/test_retry.py | 82 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 129 insertions(+), 4 deletions(-) diff --git a/docs/data-safety.md b/docs/data-safety.md index 5eb7e6a..b11b36f 100644 --- a/docs/data-safety.md +++ b/docs/data-safety.md @@ -152,6 +152,14 @@ Whether that matters depends on the delivery. A tree whose bytes are intact but whose names are not is still wrong to hand to an archive that will look for them by path. +Recomputing costs more than checking the listed files. Proving a rename is only +a rename means hashing what the manifest does *not* list, so a destination root +holding anything besides this job is read too — and that happens whether or not +the unlisted files were asked for, since the hashes are what the comparison +needs. The job's own reports are excluded, because the manifest records where +they went, but a shared archive drive is not. `--allow-cache` removes the +eviction, not the reading. + ### The manifest has to travel An MHL that records absolute paths is useless the moment the drive gets a diff --git a/src/offloader/engine.py b/src/offloader/engine.py index 3995e93..5d7eea1 100644 --- a/src/offloader/engine.py +++ b/src/offloader/engine.py @@ -439,6 +439,23 @@ def _confirm_source(source: Path, expected: str, algorithm: str) -> bool: return evicted +def _describe_recovery(recovered: list[tuple[int, int]]) -> str: + """What a file's recovered reads amount to, in one line. + + A single bad sector is worth naming exactly; a run of them is worth + bounding, because the useful fact stops being *which* byte and becomes how + much of the file would not read first time. + """ + if len(recovered) == 1: + offset, attempts = recovered[0] + return f"recovered a failed read at byte {offset} on attempt {attempts}" + offsets = [offset for offset, _ in recovered] + worst = max(attempts for _, attempts in recovered) + return (f"recovered {len(recovered)} failed reads between byte " + f"{min(offsets)} and byte {max(offsets)}, the worst on " + f"attempt {worst}") + + def _invert_companions(belongs_to: dict[Path, Path]) -> dict[Path, list[Path]]: """clip -> its companions, from companion -> its clip.""" owns: dict[Path, list[Path]] = {} @@ -650,12 +667,15 @@ def copy_once(_src=source, _partials=partials, _idx=index, job.warnings.append( f"{source.name} copied on attempt {used} of " f"{options.retry.attempts} — the source may be failing") - for offset, attempts in result.recovered_reads: + if result.recovered_reads: # Recovered without restarting the file, which is why the copy - # succeeded at all — but the sector that needed it is real. + # succeeded at all — but the sectors that needed it are real. + # Said once per file: a card failing over a contiguous stretch + # produces one of these every 8 MiB, and a warning list that + # long is one nobody reads to the end. job.warnings.append( - f"{source.name}: recovered a failed read at byte {offset} " - f"on attempt {attempts} — the source may be failing") + f"{source.name}: {_describe_recovery(result.recovered_reads)}" + " — the source may be failing") entry.checksum = src_sum or None except JobCancelled: _discard(partials) diff --git a/src/offloader/verify.py b/src/offloader/verify.py index cce75fe..794a27d 100644 --- a/src/offloader/verify.py +++ b/src/offloader/verify.py @@ -167,6 +167,14 @@ def summary(self) -> str: 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())] line = f"{self.checked} checked: " + ", ".join(parts) if self.directory_failures: diff --git a/tests/test_ascmhl.py b/tests/test_ascmhl.py index 4ea0344..eec4fab 100644 --- a/tests/test_ascmhl.py +++ b/tests/test_ascmhl.py @@ -406,6 +406,13 @@ def test_a_new_file_changes_the_directory_that_gained_it(history): assert _directory(report, "Clips").result is verify.DirectoryResult.CHANGED assert any(p.name == "extra.mov" for p in report.unlisted) + # Every listed file still matches, so the summary must not open with the + # file tally: "3 checked: 3 ok" leading a report that did not pass is how + # an arrival gets waved through. + summary = report.summary() + assert summary.startswith("2 of 2 directory hashes differ"), summary + assert "the bytes are intact and the tree is not" in summary + def test_a_deleted_directory_reads_as_missing(history): _job, destination, _manifest = history diff --git a/tests/test_retry.py b/tests/test_retry.py index b98732a..849ad7e 100644 --- a/tests/test_retry.py +++ b/tests/test_retry.py @@ -537,3 +537,85 @@ def flaky_open(path, mode="r", *args, **kwargs): assert job.final_status == "Verified" assert (tmp_path / "dest" / "A001_C001.mov").read_bytes() == payload + + +class _FlakySectors: + """Fails the first read at each of several offsets, then lets it through. + + A card failing over a stretch rather than at one sector, which is the case + that decides how the recovery is reported. + """ + + def __init__(self, handle, offsets: set[int], seen: set[int]): + self._handle = handle + self._offsets = offsets + self._seen = seen + + def read(self, size=-1): + at = self._handle.tell() + if at in self._offsets and at not in self._seen: + self._seen.add(at) + raise _os_error(errno.EIO, winerror=1117) + return self._handle.read(size) + + def seek(self, offset, whence=0): + return self._handle.seek(offset, whence) + + def close(self): + self._handle.close() + + def __enter__(self): + return self + + def __exit__(self, *args): + self._handle.close() + + +def test_a_run_of_recovered_sectors_is_one_warning_not_one_each( + tmp_path: Path, monkeypatch +): + """One warning per 8 MiB is how a dying card buries every other warning in + the job. The useful fact stops being which byte and becomes how much of the + file would not read first time.""" + monkeypatch.setattr(engine, "CHUNK_SIZE", 4096) + card, payload = _chunked_card(tmp_path, 6) + real_open = builtins.open + seen: set[int] = set() + + def flaky_open(path, mode="r", *args, **kwargs): + handle = real_open(path, mode, *args, **kwargs) + try: + inside = Path(path).resolve().is_relative_to(card.resolve()) + except (OSError, ValueError): + inside = False + if inside and "r" in str(mode) and "b" in str(mode): + return _FlakySectors(handle, {4096, 8192, 12288}, seen) + return handle + + monkeypatch.setattr(builtins, "open", flaky_open) + job = engine.run(card, _options(tmp_path)) + monkeypatch.undo() + + assert job.final_status == "Verified" + assert (tmp_path / "dest" / "A001_C001.mov").read_bytes() == payload + + recovered = [w for w in job.warnings if "recovered" in w] + assert len(recovered) == 1, recovered + assert "3 failed reads between byte 4096 and byte 12288" in recovered[0] + assert "may be failing" in recovered[0] + + +def test_a_single_recovered_sector_is_still_named_exactly(tmp_path: Path, + monkeypatch): + """Bounding a range is only worth it when there is a range. One bad sector + keeps the offset that found it.""" + monkeypatch.setattr(engine, "CHUNK_SIZE", 4096) + card, _payload = _chunked_card(tmp_path, 3) + _patch_bad_sector(monkeypatch, card, [], {"n": 0}, offset=8192, times=1) + + job = engine.run(card, _options(tmp_path)) + monkeypatch.undo() + + recovered = [w for w in job.warnings if "recovered" in w] + assert len(recovered) == 1, recovered + assert "at byte 8192 on attempt 2" in recovered[0] From 50d9e120599ec49f76125506eb02a2ff49c660ec Mon Sep 17 00:00:00 2001 From: owenpkent <20529132+owenpkent@users.noreply.github.com> Date: Sun, 9 Aug 2026 13:56:40 -0400 Subject: [PATCH 08/19] Do not group companions under the data profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neither branch could see this on its own. Companion grouping matches a sidecar to its clip by stem, which is the only relationship a camera records. The data profile's whole claim is that nothing is treated as a clip — so on a dataset it would announce that `capture.xmp` belongs to `capture.h5` on no evidence beyond a shared name, and warn when one copied without the other. Gated on the same `probes_media` the CLI summary already uses, at both call sites: `run` and `rescan`. --- README.md | 4 ++-- src/offloader/engine.py | 12 ++++++++++-- tests/test_profile.py | 28 ++++++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 0611a5f..3e66a75 100644 --- a/README.md +++ b/README.md @@ -345,13 +345,13 @@ what makes the report layer testable without moving bytes. ```sh pip install -e ".[dev]" -pytest # 434 tests, ~50s +pytest # 453 tests pytest --fuzz # same suite, 3000 examples per property (~2 min) ruff check src tests pytest --cov=offloader --cov-report=term-missing ``` -434 tests at 86% line coverage. They cover formatting against the reference's +453 tests at 86% line coverage. They cover formatting against the reference's exact strings, checksum vectors and streaming equivalence, copy/verify behaviour including simulated destination corruption, pause/resume/cancel concurrency, retry discrimination, BRAW container parsing, ffprobe parsing, diff --git a/src/offloader/engine.py b/src/offloader/engine.py index 5d7eea1..d45bf47 100644 --- a/src/offloader/engine.py +++ b/src/offloader/engine.py @@ -535,7 +535,11 @@ def run(source_root: Path, options: OffloadOptions, thumb_dir = options.thumbnail_dir or (dest_roots[0] / f"{job.name}_Reports" / "thumbs") - belongs_to = companions.group(files) + # A sidecar belongs to a *clip*. Under the data profile nothing is a clip, + # so stem-matching a dataset would announce that `run_1440.xmp` belongs to + # `run_1440.h5` on no evidence beyond a shared name. + belongs_to = (companions.group(files) if options.profile.probes_media + else {}) owns = _invert_companions(belongs_to) #: Whether the "cache could not be evicted" limitation has been reported. @@ -858,7 +862,11 @@ def rescan(source_root: Path, destination_roots: Sequence[Path], total = sum(p.stat().st_size for p in files) done = 0 - belongs_to = companions.group(files) + # A sidecar belongs to a *clip*. Under the data profile nothing is a clip, + # so stem-matching a dataset would announce that `run_1440.xmp` belongs to + # `run_1440.h5` on no evidence beyond a shared name. + belongs_to = (companions.group(files) if options.profile.probes_media + else {}) owns = _invert_companions(belongs_to) for index, source in enumerate(files): diff --git a/tests/test_profile.py b/tests/test_profile.py index 816ade8..6792774 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -114,3 +114,31 @@ def test_preset_defaults_to_media_and_tolerates_missing_key(): assert Preset.from_dict({"name": "legacy"}).profile is Profile.MEDIA # A garbage value must never brick a load. assert Preset.from_dict({"name": "bad", "profile": "nonsense"}).profile is Profile.MEDIA + + +def test_the_data_profile_does_not_invent_companions(tmp_path: Path): + """Stem-matching says a `.sidecar` belongs to a clip. Under the data + profile nothing is a clip, so a dataset that happens to share a stem with + its metadata file would be linked on no evidence but the name.""" + card = tmp_path / "run_1440" + card.mkdir() + (card / "capture.h5").write_bytes(b"instrument data " * 200) + (card / "capture.xmp").write_bytes(b"") + + job = engine.run(card, _options(tmp_path, profile=Profile.DATA)) + + assert all(f.companion_of is None for f in job.files) + assert all(f.companions == [] for f in job.files) + + +def test_the_media_profile_still_groups_them(tmp_path: Path): + card = tmp_path / "A001" + card.mkdir() + (card / "A001_C001.braw").write_bytes(b"a clip " * 400) + (card / "A001_C001.sidecar").write_bytes(b"the grade") + + job = engine.run(card, _options(tmp_path, profile=Profile.MEDIA, + extra_probe=False)) + + sidecar = next(f for f in job.files if f.name == "A001_C001.sidecar") + assert sidecar.companion_of is not None From 9677448784348c4b4c3254a97caac97943103b61 Mon Sep 17 00:00:00 2001 From: owenpkent <20529132+owenpkent@users.noreply.github.com> Date: Sun, 9 Aug 2026 14:07:35 -0400 Subject: [PATCH 09/19] Bring the docs up to what the merged branch actually does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The data profile and this branch landed independently, so three things were describing a tool that no longer exists in that shape. `CONTRIBUTING.md` still claimed 434 tests; the merged suite collects 453. Both files now say so, and neither claims a wall-clock figure any more — two runs of identical code here differed by three and a half minutes, so the number was measuring the machine rather than the suite. The changelog gained the two reporting changes it was missing, and the sidecar entry now says the grouping is media-profile only. The README's generic-transfer section claimed nothing is treated as a clip while companion grouping was still doing exactly that; it now says what that means for a dataset. --- CHANGELOG.md | 13 +++++++++++++ CONTRIBUTING.md | 2 +- README.md | 4 +++- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bb0894..8f42490 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,9 @@ project uses [semantic versioning][semver]. unlinked rather than guessed at. A clip that copies while a file belonging to it does not is now a job warning instead of two rows twenty lines apart. The HTML report shows them together and the CSV gains a `Companion Of` column. + Media profile only: a companion is a file belonging to a *clip*, and under + `--profile data` nothing is a clip, so a dataset is not told that + `capture.xmp` belongs to `capture.h5` on the strength of a shared stem. - **`offloader verify` now re-checks the ASC MHL directory hashes**, which were written from the start and never read back. A rename or a moved file leaves every individual file hashing exactly as recorded, so no file-level check can @@ -79,6 +82,16 @@ project uses [semantic versioning][semver]. destination at a length the copy loop does not know. Once a chunk has had every attempt the policy allows, the whole-file retry no longer repeats them against the same fault. +- **A verify report that failed only on its directory hashes says so first.** + It used to open with the file tally — `3 checked: 3 ok` — on a report that did + not pass, which reads as a pass to anyone scanning. That combination is now + stated as what it is: the bytes are intact and the tree is not. Reports with + file failures are unchanged; they already led with them. +- **Recovered reads are reported once per file, not once per chunk.** A card + failing over a contiguous stretch produced one warning every 8 MiB, burying + every other warning in the job. A single bad sector still names its offset + exactly, because there the byte is the useful fact; a run of them is bounded + by the first and the last, because there it is not. ### Fixed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a7c7a8d..2cb1dfc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,7 +37,7 @@ pip install -e ".[dev]" `ffmpeg` and `ffprobe` on `PATH` are optional — the suite runs without them. ```sh -pytest # ~434 tests, about 50s +pytest # 453 tests pytest --fuzz # property tests at 3000 examples each, about 2 min ruff check src tests pytest --cov=offloader --cov-report=term-missing diff --git a/README.md b/README.md index 3e66a75..60ea0f6 100644 --- a/README.md +++ b/README.md @@ -191,7 +191,9 @@ offloader offload \ ``` Nothing is treated as a clip, ffmpeg is never invoked, and the run does not need -it installed. What you still get is the whole point of the tool: every byte +it installed — including the sidecar and proxy grouping above, which links a +file to the clip it belongs to by stem and would otherwise announce that +`capture.xmp` belongs to `capture.h5` on no more evidence than a shared name. What you still get is the whole point of the tool: every byte read once and fanned out, both copies verified off disk, a checksum manifest beside each one, and `offloader verify` to re-check the archive months later for bit rot. The PDF, CSV, MHL, ASC MHL and HTML reports all render a plain file From 9fa61c077a24574e752bc77de4e614a1d8b6f33d Mon Sep 17 00:00:00 2001 From: owenpkent <20529132+owenpkent@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:17:46 -0400 Subject: [PATCH 10/19] Measure queue throughput over the last five seconds, not the whole job --- src/offloader/gui/worker.py | 42 +++++++++++++++++-- tests/test_gui.py | 84 ++++++++++++++++++++++++++++++++++++- 2 files changed, 120 insertions(+), 6 deletions(-) diff --git a/src/offloader/gui/worker.py b/src/offloader/gui/worker.py index 8786498..aa1f93e 100644 --- a/src/offloader/gui/worker.py +++ b/src/offloader/gui/worker.py @@ -1,6 +1,6 @@ """Job queue and the worker thread that drains it. -Jobs run one at a time. That is not a simplification — offloads are I/O bound, +Jobs run one at a time. That is not a simplification — offloads are I/O bound, and running two at once against the same bus makes both slower while making the progress readout meaningless. """ @@ -8,6 +8,7 @@ from __future__ import annotations import time +from collections import deque from dataclasses import dataclass, field from enum import Enum from pathlib import Path @@ -19,6 +20,10 @@ from ..presets import Preset from ..reports import WRITERS +#: Trailing window over which throughput is measured. Long enough to smooth +#: per-chunk jitter, short enough that a stall shows up within seconds. +RATE_WINDOW_SEC = 5.0 + REPORT_FILENAMES = { "pdf": "JobReport.pdf", "csv": "JobReport.csv", @@ -62,6 +67,8 @@ class QueueItem: reports: list[Path] = field(default_factory=list) error: str | None = None control: engine.JobControl = field(default_factory=engine.JobControl) + #: (monotonic time, job bytes done) samples inside the trailing window. + _samples: deque = field(default_factory=deque, repr=False) @property def elapsed(self) -> float: @@ -69,10 +76,36 @@ def elapsed(self) -> float: return 0.0 return (self.finished_at or time.monotonic()) - self.started_at + def record_progress(self, bytes_done: int) -> None: + """Feed the throughput window. Called on every progress event.""" + now = time.monotonic() + if self._samples and bytes_done < self._samples[-1][1]: + # The counter went backwards — a new stage started counting from + # zero. A delta across that boundary would be negative garbage. + self._samples.clear() + self._samples.append((now, bytes_done)) + while self._samples and now - self._samples[0][0] > RATE_WINDOW_SEC: + self._samples.popleft() + @property def rate_bytes_per_sec(self) -> float: - elapsed = self.elapsed - return self.bytes_done / elapsed if elapsed > 0.5 else 0.0 + """Throughput over the trailing window, not the life of the job. + + A lifetime average (`bytes_done / elapsed`) folds the pre-copy scan and + every between-file probe stall into the number forever: a slow first + minute reads as a slow job for the rest of the offload, and the ETA + derived from it is wrong in the same direction. The window forgets. + Measured against `now` rather than the newest sample, so a stall shows + as a rate falling toward zero instead of freezing at its last value. + """ + if not self._samples: + return 0.0 + now = time.monotonic() + oldest_time, oldest_bytes = self._samples[0] + span = now - oldest_time + if span < 0.5 or now - self._samples[-1][0] > RATE_WINDOW_SEC: + return 0.0 + return max(0, self.bytes_done - oldest_bytes) / span @property def eta_seconds(self) -> float | None: @@ -227,7 +260,7 @@ def clear_finished(self) -> None: self.itemsChanged.emit() def move(self, identifier: int, offset: int) -> None: - """Reorder a pending job — the queue's priority control.""" + """Reorder a pending job — the queue's priority control.""" item = self.find(identifier) if item is None or item.state is not JobState.QUEUED: return @@ -312,6 +345,7 @@ def _on_progress(self, identifier: int, fraction: float, stage: str, item.current_file = filename item.bytes_done = done item.bytes_total = total + item.record_progress(done) self.itemChanged.emit(identifier) def _on_completed(self, identifier: int, job: Job | None, diff --git a/tests/test_gui.py b/tests/test_gui.py index 85ec956..f32165f 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -1,7 +1,7 @@ """GUI tests, run against Qt's offscreen platform. -These drive the real widgets and the real queue controller — the worker thread -actually copies files — so they cover the wiring between the interface and the +These drive the real widgets and the real queue controller — the worker thread +actually copies files — so they cover the wiring between the interface and the engine, not just that the modules import. """ @@ -332,3 +332,83 @@ def test_the_preset_editor_is_grouped_rather_than_one_flat_list(qapp): "_verification", "_thumbnails", "_naming", "_excludes", "_logo", "_footer", "_preserve", "_skip", "_paranoid"): assert getattr(editor, name).parent() is not None, f"{name} is orphaned" + + +# ---------------------------------------------------------------- throughput + +def _running_item(**kwargs): + from offloader.gui.worker import QueueItem + + item = QueueItem(identifier=1, source=Path("card"), name="job", + preset=Preset(name="p"), **kwargs) + item.state = JobState.RUNNING + return item + + +def test_rate_is_windowed_not_a_lifetime_average(monkeypatch): + """A slow first minute must not read as a slow job forever. The regression + this pins: a card scan plus early probe stalls dragged the lifetime average + to 3.5 MB/s while clips were demonstrably flying past.""" + from offloader.gui import worker as worker_mod + + clock = {"now": 1000.0} + monkeypatch.setattr(worker_mod.time, "monotonic", lambda: clock["now"]) + + item = _running_item() + item.started_at = clock["now"] + + # Ten dead seconds of scanning, then a steady 100 MB/s. + clock["now"] += 10.0 + for _ in range(10): + clock["now"] += 1.0 + item.bytes_done += 100_000_000 + item.record_progress(item.bytes_done) + + lifetime = item.bytes_done / item.elapsed # 50 MB/s — the old lie + windowed = item.rate_bytes_per_sec + assert windowed == pytest.approx(100_000_000, rel=0.05) + assert windowed > 1.8 * lifetime + + +def test_rate_decays_during_a_stall_instead_of_freezing(monkeypatch): + from offloader.gui import worker as worker_mod + + clock = {"now": 0.0} + monkeypatch.setattr(worker_mod.time, "monotonic", lambda: clock["now"]) + + item = _running_item() + for _ in range(5): + clock["now"] += 1.0 + item.bytes_done += 100_000_000 + item.record_progress(item.bytes_done) + flowing = item.rate_bytes_per_sec + + clock["now"] += 3.0 # stall: no new bytes + assert item.rate_bytes_per_sec < flowing + clock["now"] += 10.0 # window fully drained + assert item.rate_bytes_per_sec == 0.0 + assert item.eta_seconds is None + + +def test_rate_survives_a_counter_reset_between_stages(monkeypatch): + """Copy and verify each count job bytes from zero; a delta computed across + that boundary would be negative garbage.""" + from offloader.gui import worker as worker_mod + + clock = {"now": 0.0} + monkeypatch.setattr(worker_mod.time, "monotonic", lambda: clock["now"]) + + item = _running_item() + for _ in range(3): + clock["now"] += 1.0 + item.bytes_done += 100_000_000 + item.record_progress(item.bytes_done) + + item.bytes_done = 0 # verify stage begins + item.record_progress(0) + for _ in range(2): + clock["now"] += 1.0 + item.bytes_done += 50_000_000 + item.record_progress(item.bytes_done) + assert item.rate_bytes_per_sec == pytest.approx(50_000_000, rel=0.05) + From 26e86189ef5773c4f808a9f016af5993571e4edd Mon Sep 17 00:00:00 2001 From: owenpkent <20529132+owenpkent@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:17:46 -0400 Subject: [PATCH 11/19] Say what each checksum choice costs wherever one is picked --- src/offloader/cli.py | 19 +++++++++------ src/offloader/gui/preset_editor.py | 2 +- src/offloader/gui/simple_mode.py | 16 ++++++------ src/offloader/hashers.py | 39 +++++++++++++++++++++++------- tests/test_gui.py | 21 ++++++++++++++++ 5 files changed, 72 insertions(+), 25 deletions(-) diff --git a/src/offloader/cli.py b/src/offloader/cli.py index 6784244..9026cd9 100644 --- a/src/offloader/cli.py +++ b/src/offloader/cli.py @@ -151,7 +151,10 @@ def _summarize(job: Job, reports: list[Path]) -> None: def _common_options(parser: argparse.ArgumentParser) -> None: parser.add_argument("--hash", default=hashers.DEFAULT_ALGORITHM, choices=sorted(hashers.algorithm_keys()), - help="checksum algorithm (default: %(default)s)") + help="checksum algorithm (default: %(default)s; the " + "engine hashes every byte on the copy path, so a " + "slow choice caps copy speed — md5 is ~40x slower " + "than the default; see 'offloader info')") parser.add_argument("--report", type=_parse_reports, default=DEFAULT_REPORTS, metavar="FMT[,FMT...]", help=f"report formats: {', '.join(WRITERS)} (default: pdf)") @@ -189,7 +192,7 @@ def _common_options(parser: argparse.ArgumentParser) -> None: def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="offloader", - description=f"{PRODUCT_NAME} — verified copy for large data transfers, " + description=f"{PRODUCT_NAME} — verified copy for large data transfers, " f"with camera-card offload and job reports built in.", ) parser.add_argument("--version", action="version", @@ -228,7 +231,7 @@ def build_parser() -> argparse.ArgumentParser: verify = sub.add_parser( "verify", - help="re-check an offloaded tree against its MHL — run this before " + help="re-check an offloaded tree against its MHL — run this before " "erasing a card, and again later to catch bit rot") verify.add_argument("path", type=Path, help="an .mhl file, or a folder to search for them") @@ -351,8 +354,8 @@ def progress(index: int, total: int, path: Path) -> None: worst = max(worst, 1) print() - print("VERIFIED — safe to erase the source" if worst == 0 - else "NOT VERIFIED — do not erase the source") + print("VERIFIED — safe to erase the source" if worst == 0 + else "NOT VERIFIED — do not erase the source") return worst @@ -368,7 +371,7 @@ def cmd_info(_args: argparse.Namespace) -> int: print(f" ffprobe: {probe.ffprobe_path() or 'NOT FOUND (metadata disabled)'}") print(f" ffmpeg: {thumbs.ffmpeg_path() or 'NOT FOUND (thumbnails disabled)'}") print(f" report font: {fonts.describe()}" - f"{'' if fonts.using_reference_fonts() else ' (Verdana missing — metrics differ)'}") + f"{'' if fonts.using_reference_fonts() else ' (Verdana missing — metrics differ)'}") enabled = longpath.os_long_paths_enabled() if enabled is not None: prefix = "\\\\?\\" @@ -376,7 +379,9 @@ def cmd_info(_args: argparse.Namespace) -> int: else "required for destinations past 260 characters") print(f" long paths: Windows support {'on' if enabled else 'off'};" f" {prefix} prefix {note}") - print(f" checksums: {', '.join(sorted(hashers.algorithm_keys()))}") + print(" checksums: " + "; ".join( + f"{key} ({alg.speed})" if alg.speed else key + for key, alg in sorted(hashers.ALGORITHMS.items()))) print(f" reports: {', '.join(WRITERS)}") print(f" profiles: {', '.join(p.value for p in Profile)} " f"(--profile; 'data' skips media probing for generic transfers)") diff --git a/src/offloader/gui/preset_editor.py b/src/offloader/gui/preset_editor.py index e753214..d161b45 100644 --- a/src/offloader/gui/preset_editor.py +++ b/src/offloader/gui/preset_editor.py @@ -106,7 +106,7 @@ def __init__(self, preset: Preset | None = None, parent: QWidget | None = None) self._algorithm = QComboBox() for key, algorithm in ALGORITHMS.items(): - self._algorithm.addItem(algorithm.label, key) + self._algorithm.addItem(algorithm.picker_label, key) self._algorithm.setCurrentIndex( max(0, self._algorithm.findData(self._source.algorithm))) diff --git a/src/offloader/gui/simple_mode.py b/src/offloader/gui/simple_mode.py index 91b1c24..0bf0b00 100644 --- a/src/offloader/gui/simple_mode.py +++ b/src/offloader/gui/simple_mode.py @@ -1,6 +1,6 @@ """Simple mode: source, destinations, go. -Everything is on one screen with no saved state — for the one-off offload where +Everything is on one screen with no saved state — for the one-off offload where building a preset would be more work than the job itself. """ @@ -40,7 +40,7 @@ def __init__(self, parent: QWidget | None = None) -> None: self.destinations = DestinationList() self.destinations.changed.connect(self._sync) - add = button("Add…", flat=True) + add = button("Add…", flat=True) add.clicked.connect(self.destinations.browse_and_add) remove = button("Remove", flat=True) remove.clicked.connect(self.destinations.remove_selected) @@ -51,7 +51,7 @@ def __init__(self, parent: QWidget | None = None) -> None: self._algorithm = QComboBox() for key, algorithm in ALGORITHMS.items(): - self._algorithm.addItem(algorithm.label, key) + self._algorithm.addItem(algorithm.picker_label, key) self._algorithm.setCurrentIndex(max(0, self._algorithm.findData("xxh3-64"))) self._verification = QComboBox() @@ -61,8 +61,8 @@ def __init__(self, parent: QWidget | None = None) -> None: max(0, self._verification.findData(VerificationMode.SOURCE_ONLY.value))) self._profile = QComboBox() - self._profile.addItem("Media — camera card", Profile.MEDIA.value) - self._profile.addItem("Data — any large transfer", Profile.DATA.value) + self._profile.addItem("Media — camera card", Profile.MEDIA.value) + self._profile.addItem("Data — any large transfer", Profile.DATA.value) self._profile.setCurrentIndex( max(0, self._profile.findData(Profile.MEDIA.value))) self._profile.currentIndexChanged.connect(self._on_profile_changed) @@ -141,10 +141,10 @@ def _sync(self) -> None: elif not destinations: self._hint.setText("Add at least one destination.") elif overlapping: - self._hint.setText("A destination sits inside the source — pick another.") + self._hint.setText("A destination sits inside the source — pick another.") else: copies = f"{len(destinations)} cop{'ies' if len(destinations) > 1 else 'y'}" - self._hint.setText(f"Ready: {source} → {copies}") + self._hint.setText(f"Ready: {source} → {copies}") @staticmethod def _overlaps(source: Path, destination: Path) -> bool: @@ -157,7 +157,7 @@ def _overlaps(source: Path, destination: Path) -> bool: return source == destination or source in destination.parents def _on_profile_changed(self) -> None: - # Thumbnails are contact-sheet frames from a clip — meaningless for a + # Thumbnails are contact-sheet frames from a clip — meaningless for a # generic data transfer, which never decodes a file. Grey the control # so the disabled state explains itself. is_media = self._profile.currentData() == Profile.MEDIA.value diff --git a/src/offloader/hashers.py b/src/offloader/hashers.py index c9a5290..7ff001d 100644 --- a/src/offloader/hashers.py +++ b/src/offloader/hashers.py @@ -28,10 +28,22 @@ class Algorithm: factory: Callable[[], Hasher] | None #: MHL/ASC-MHL element name, or None if the format has no slot for it. mhl_tag: str | None = None + #: What choosing this costs, shown wherever the algorithm is picked. The + #: ratios are single-thread throughput against XXHash3-64, measured with + #: 8 MiB blocks; exact numbers vary by CPU, the ordering does not. They + #: matter because the engine hashes every byte once per stream — source + #: plus each destination — on the copy path, so a slow hash is a ceiling + #: on copy speed, not an afterthought. + speed: str = "" def new(self) -> Hasher | None: return self.factory() if self.factory else None + @property + def picker_label(self) -> str: + """Label with the cost attached, e.g. "MD5 — ~40x slower".""" + return f"{self.label} — {self.speed}" if self.speed else self.label + #: Base58 alphabet used by C4 (SMPTE ST 2114) — no 0, O, I or l. _C4_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" @@ -74,15 +86,24 @@ def hexdigest(self) -> str: # noqa: D102 ALGORITHMS: dict[str, Algorithm] = { - "xxh3-64": Algorithm("xxh3-64", "XXHash3-64", xxhash.xxh3_64, "xxh3"), - "xxh3-128": Algorithm("xxh3-128", "XXHash3-128", xxhash.xxh3_128, "xxh3-128"), - "xxh64": Algorithm("xxh64", "XXHash-64", xxhash.xxh64, "xxh64"), - "xxh64be": Algorithm("xxh64be", "XXHash-64BE", xxhash.xxh64, "xxh64be"), - "md5": Algorithm("md5", "MD5", hashlib.md5, "md5"), - "sha1": Algorithm("sha1", "SHA-1", hashlib.sha1, "sha1"), - "sha256": Algorithm("sha256", "SHA-256", hashlib.sha256, "sha256"), - "c4": Algorithm("c4", "C4", C4Hasher, "c4"), - "none": Algorithm("none", "None", None, None), + "xxh3-64": Algorithm("xxh3-64", "XXHash3-64", xxhash.xxh3_64, "xxh3", + speed="fastest"), + "xxh3-128": Algorithm("xxh3-128", "XXHash3-128", xxhash.xxh3_128, "xxh3-128", + speed="fastest"), + "xxh64": Algorithm("xxh64", "XXHash-64", xxhash.xxh64, "xxh64", + speed="fast"), + "xxh64be": Algorithm("xxh64be", "XXHash-64BE", xxhash.xxh64, "xxh64be", + speed="fast"), + "md5": Algorithm("md5", "MD5", hashlib.md5, "md5", + speed="~40x slower, legacy compatibility only"), + "sha1": Algorithm("sha1", "SHA-1", hashlib.sha1, "sha1", + speed="~14x slower"), + "sha256": Algorithm("sha256", "SHA-256", hashlib.sha256, "sha256", + speed="~15x slower, tamper-evident"), + "c4": Algorithm("c4", "C4", C4Hasher, "c4", + speed="~40x slower, tamper-evident"), + "none": Algorithm("none", "None", None, None, + speed="no checksum, nothing verified"), } DEFAULT_ALGORITHM = "xxh3-64" diff --git a/tests/test_gui.py b/tests/test_gui.py index f32165f..54e6d84 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -412,3 +412,24 @@ def test_rate_survives_a_counter_reset_between_stages(monkeypatch): item.record_progress(item.bytes_done) assert item.rate_bytes_per_sec == pytest.approx(50_000_000, rel=0.05) + +def test_checksum_pickers_say_what_the_choice_costs(qapp, tmp_path): + """MD5 sits in the same list as XXHash3-64; without the cost attached they + read as equals, and the difference is 40x on the copy path.""" + from offloader.gui.preset_editor import PresetEditor + + panel = SimpleModePanel() + texts = [panel._algorithm.itemText(i) + for i in range(panel._algorithm.count())] + md5 = next(t for t in texts if t.startswith("MD5")) + assert "slower" in md5 + assert any(t.startswith("XXHash3-64") and "fastest" in t for t in texts) + + editor = PresetEditor(Preset(name="p")) + texts = [editor._algorithm.itemText(i) + for i in range(editor._algorithm.count())] + assert any("slower" in t for t in texts) + + # The stored key must stay the bare algorithm id, not the display text. + assert panel._algorithm.currentData() in {"xxh3-64"} + From 60d2d9a88c0a6097b920679e6728b8933772737d Mon Sep 17 00:00:00 2001 From: owenpkent <20529132+owenpkent@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:17:47 -0400 Subject: [PATCH 12/19] Probe volumes concurrently and deliver local drives before network shares --- src/offloader/gui/drives.py | 67 +++++++++++--- src/offloader/volumes.py | 173 +++++++++++++++++++++++------------- tests/test_gui_drives.py | 96 ++++++++++++++++++-- 3 files changed, 255 insertions(+), 81 deletions(-) diff --git a/src/offloader/gui/drives.py b/src/offloader/gui/drives.py index 42a6a5b..d088bc9 100644 --- a/src/offloader/gui/drives.py +++ b/src/offloader/gui/drives.py @@ -17,15 +17,33 @@ ) from ..util import format_size -from ..volumes import Volume, list_volumes +from ..volumes import Volume, list_roots, order_volumes, probe_many from . import theme from .widgets import CapacityBar, button, label, row REFRESH_MS = 4000 +def scan_batches(): + """Yield (volumes, final) — local drives first, network shares after. + + A network probe is a synchronous SMB round-trip that can take seconds; the + fixed and removable drives — the ones an offload actually uses — must not + wait behind it. Each phase probes its roots concurrently, so the wait per + batch is the slowest probe, not the sum. + """ + roots = list_roots() + local = [(root, kind) for root, kind in roots if kind != "network"] + remote = [(root, kind) for root, kind in roots if kind == "network"] + found = probe_many(local) + if remote: + yield order_volumes(found), False + found = found + probe_many(remote) + yield order_volumes(found), True + + class _ScanSignals(QObject): - done = Signal(list) + batch = Signal(list, bool) # volumes, final class _ScanTask(QRunnable): @@ -42,11 +60,14 @@ def __init__(self, signals: _ScanSignals) -> None: def run(self) -> None: # noqa: D102 - QRunnable entry point try: - volumes = list_volumes() + for volumes, final in scan_batches(): + self._emit(volumes, final) except Exception: - volumes = [] + self._emit([], True) + + def _emit(self, volumes: list, final: bool) -> None: try: - self._signals.done.emit(volumes) + self._signals.batch.emit(volumes, final) except RuntimeError: # The window closed while this scan was in flight; nothing to tell. pass @@ -56,6 +77,7 @@ class VolumeWatcher(QObject): """Polls for mounted volumes and reports changes.""" volumesChanged = Signal(list) + scanningChanged = Signal(bool) def __init__(self, parent: QObject | None = None) -> None: super().__init__(parent) @@ -64,7 +86,7 @@ def __init__(self, parent: QObject | None = None) -> None: self._stopped = False # Parented, so its lifetime is the watcher's rather than a task's. self._signals = _ScanSignals(self) - self._signals.done.connect(self._on_scanned) + self._signals.batch.connect(self._on_batch) self._timer = QTimer(self) self._timer.setInterval(REFRESH_MS) self._timer.timeout.connect(self.refresh) @@ -86,14 +108,30 @@ def refresh(self) -> None: if self._busy or self._stopped: return self._busy = True + self.scanningChanged.emit(True) QThreadPool.globalInstance().start(_ScanTask(self._signals)) - def _on_scanned(self, volumes: list) -> None: - self._busy = False + def _on_batch(self, volumes: list, final: bool) -> None: + if final: + self._busy = False if self._stopped: return + if not final: + # The local half of a scan whose network shares are still being + # probed. Keep the shares from the previous scan rather than + # tearing their rows down for a few seconds every poll. Dedup by + # root, not resolved root — resolving a network path is itself a + # round-trip, and this runs on the UI thread. + known = {v.root: v for v in self._volumes + if v.drive_type == "network"} + for volume in volumes: + known[volume.root] = volume + volumes = sorted(known.values(), + key=lambda v: (not v.is_camera_card, str(v.root))) self._volumes = volumes self.volumesChanged.emit(volumes) + if final: + self.scanningChanged.emit(False) class VolumeRow(QFrame): @@ -164,10 +202,11 @@ def __init__(self, parent: QWidget | None = None) -> None: self.watcher = VolumeWatcher(self) self.watcher.volumesChanged.connect(self._rebuild) + self.watcher.scanningChanged.connect(self._on_scanning) - refresh = button("Refresh", flat=True) - refresh.clicked.connect(self.watcher.refresh) - header = row(label("Drives", "heading"), None, refresh) + self._refresh = button("Refresh", flat=True) + self._refresh.clicked.connect(self.watcher.refresh) + header = row(label("Drives", "heading"), None, self._refresh) self._container = QWidget() self._container_layout = QVBoxLayout(self._container) @@ -193,6 +232,12 @@ def start(self) -> None: def stop(self) -> None: self.watcher.stop() + def _on_scanning(self, scanning: bool) -> None: + # The busy state the panel was missing: without it a slow network + # share made "Refresh" look like a button that does nothing. + self._refresh.setEnabled(not scanning) + self._refresh.setText("Scanning…" if scanning else "Refresh") + def _rebuild(self, volumes: list) -> None: same_set = ( len(volumes) == len(self._rows) diff --git a/src/offloader/volumes.py b/src/offloader/volumes.py index 324b677..29fe05c 100644 --- a/src/offloader/volumes.py +++ b/src/offloader/volumes.py @@ -10,6 +10,7 @@ import platform import shutil import string +from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass from pathlib import Path @@ -21,7 +22,7 @@ "brawcontents", "pana_grp", } -#: Camera originals. Some cameras — Blackmagic among them — write clips +#: Camera originals. Some cameras — Blackmagic among them — write clips #: straight to the root with no marker directory at all, so a volume holding #: several of these is treated as a card even without one. CAMERA_EXTENSIONS = { @@ -48,7 +49,7 @@ class Volume: total_bytes: int free_bytes: int drive_type: str = "fixed" - #: Computed once when the volume is listed — the check touches the disk, + #: Computed once when the volume is listed — the check touches the disk, #: and the drive panel refreshes on a timer. is_camera_card: bool = False @@ -83,7 +84,7 @@ def detect_camera_card(root: Path, drive_type: str) -> bool: """Whether a volume root looks like camera media rather than a disk. Two signals: a marker directory the camera wrote, or a root full of camera - originals. Removability is deliberately not required — a reader in a + originals. Removability is deliberately not required — a reader in a Thunderbolt dock usually reports as a fixed disk. """ if drive_type in ("network", "optical", "ramdisk", "no-root", "unknown"): @@ -131,50 +132,62 @@ def _usage(root: Path) -> tuple[int, int]: return 0, 0 -def _windows_volumes() -> list[Volume]: +def _windows_roots() -> list[tuple[Path, str]]: + """Every drive letter and its type. Cheap — `GetDriveTypeW` reads a flag + the mount manager already holds, it does not touch the volume.""" import ctypes kernel32 = ctypes.windll.kernel32 - # Stop Windows popping "insert a disk" dialogs for empty card readers. - previous = kernel32.SetErrorMode(0x0001 | 0x0002) - found: list[Volume] = [] + roots: list[tuple[Path, str]] = [] + bitmask = kernel32.GetLogicalDrives() + for index, letter in enumerate(string.ascii_uppercase): + if not bitmask & (1 << index): + continue + root = f"{letter}:\\" + drive_type = _WINDOWS_DRIVE_TYPES.get(kernel32.GetDriveTypeW(root), "unknown") + if drive_type in ("no-root", "unknown"): + continue + roots.append((Path(root), drive_type)) + return roots + + +def _windows_probe(root: Path, drive_type: str) -> Volume | None: + """Label, filesystem, usage and card detection for one root — the part + that actually talks to the device, and for a network share is a synchronous + SMB round-trip. Runs on a pool thread; error mode is set per-thread so an + empty card reader cannot pop an "insert a disk" dialog.""" + import ctypes + + kernel32 = ctypes.windll.kernel32 + previous = ctypes.c_uint(0) + kernel32.SetThreadErrorMode(0x0001 | 0x0002, ctypes.byref(previous)) try: - bitmask = kernel32.GetLogicalDrives() - for index, letter in enumerate(string.ascii_uppercase): - if not bitmask & (1 << index): - continue - root = f"{letter}:\\" - drive_type = _WINDOWS_DRIVE_TYPES.get(kernel32.GetDriveTypeW(root), "unknown") - if drive_type in ("no-root", "unknown"): - continue + label_buffer = ctypes.create_unicode_buffer(261) + fs_buffer = ctypes.create_unicode_buffer(261) + ok = kernel32.GetVolumeInformationW( + ctypes.c_wchar_p(str(root)), label_buffer, 261, + None, None, None, fs_buffer, 261, + ) + if not ok and drive_type == "optical": + return None # empty drive - label_buffer = ctypes.create_unicode_buffer(261) - fs_buffer = ctypes.create_unicode_buffer(261) - ok = kernel32.GetVolumeInformationW( - ctypes.c_wchar_p(root), label_buffer, 261, - None, None, None, fs_buffer, 261, - ) - if not ok and drive_type == "optical": - continue # empty drive - - total, free = _usage(Path(root)) - if total == 0 and drive_type != "removable": - continue - found.append(Volume( - root=Path(root), - label=label_buffer.value, - filesystem=fs_buffer.value, - total_bytes=total, - free_bytes=free, - drive_type=drive_type, - is_camera_card=detect_camera_card(Path(root), drive_type), - )) + total, free = _usage(root) + if total == 0 and drive_type != "removable": + return None + return Volume( + root=root, + label=label_buffer.value, + filesystem=fs_buffer.value, + total_bytes=total, + free_bytes=free, + drive_type=drive_type, + is_camera_card=detect_camera_card(root, drive_type), + ) finally: - kernel32.SetErrorMode(previous) - return found + kernel32.SetThreadErrorMode(previous.value, None) -def _posix_volumes() -> list[Volume]: +def _posix_roots() -> list[tuple[Path, str]]: candidates: list[Path] = [Path("/")] for parent in (Path("/Volumes"), Path("/media"), Path("/mnt"), Path("/run/media") / (Path.home().name)): @@ -183,40 +196,67 @@ def _posix_volumes() -> list[Volume]: except OSError: continue - found: list[Volume] = [] + roots: list[tuple[Path, str]] = [] seen: set[Path] = set() for root in candidates: if root in seen: continue seen.add(root) - total, free = _usage(root) - if total == 0: - continue - drive_type = "fixed" if root == Path("/") else "removable" - found.append(Volume( - root=root, - label=root.name or str(root), - filesystem="", - total_bytes=total, - free_bytes=free, - drive_type=drive_type, - is_camera_card=detect_camera_card(root, drive_type), - )) - return found - + roots.append((root, "fixed" if root == Path("/") else "removable")) + return roots + + +def _posix_probe(root: Path, drive_type: str) -> Volume | None: + total, free = _usage(root) + if total == 0: + return None + return Volume( + root=root, + label=root.name or str(root), + filesystem="", + total_bytes=total, + free_bytes=free, + drive_type=drive_type, + is_camera_card=detect_camera_card(root, drive_type), + ) + + +def list_roots() -> list[tuple[Path, str]]: + """Every mount point and its drive type, without touching any volume.""" + try: + return (_windows_roots() if platform.system() == "Windows" + else _posix_roots()) + except Exception: + return [] -def list_volumes() -> list[Volume]: - """Every mounted volume, cards first so they are easy to spot. - Deduplicated by resolved root: macOS reaches the boot volume through both - `/` and `/Volumes/Macintosh HD`, and listing it twice would be noise. - """ +def probe_volume(root: Path, drive_type: str) -> Volume | None: + """Full details for one root, or None if it should not be listed.""" try: - volumes = (_windows_volumes() if platform.system() == "Windows" - else _posix_volumes()) + return (_windows_probe(root, drive_type) + if platform.system() == "Windows" + else _posix_probe(root, drive_type)) except Exception: - volumes = [] - + return None + + +def probe_many(roots: list[tuple[Path, str]]) -> list[Volume]: + """Probe roots concurrently, so a sleeping USB drive or a slow network + share costs its own probe rather than delaying every drive after it — + serial probing made the panel's refresh wait the *sum* of every + round-trip; this waits only the slowest.""" + if not roots: + return [] + with ThreadPoolExecutor(max_workers=min(len(roots), 12), + thread_name_prefix="volscan") as pool: + results = pool.map(lambda pair: probe_volume(*pair), roots) + return [volume for volume in results if volume is not None] + + +def order_volumes(volumes: list[Volume]) -> list[Volume]: + """Cards first so they are easy to spot; deduplicated by resolved root, + because macOS reaches the boot volume through both `/` and + `/Volumes/Macintosh HD`, and listing it twice would be noise.""" seen: set[Path] = set() unique: list[Volume] = [] for volume in sorted(volumes, key=lambda v: len(str(v.root))): @@ -232,6 +272,11 @@ def list_volumes() -> list[Volume]: return sorted(unique, key=lambda v: (not v.is_camera_card, str(v.root))) +def list_volumes() -> list[Volume]: + """Every mounted volume, cards first so they are easy to spot.""" + return order_volumes(probe_many(list_roots())) + + def find_volume(path: Path) -> Volume | None: """The volume a path sits on, for labelling a chosen source.""" path = Path(path).resolve() diff --git a/tests/test_gui_drives.py b/tests/test_gui_drives.py index 7a6660b..94b332d 100644 --- a/tests/test_gui_drives.py +++ b/tests/test_gui_drives.py @@ -38,8 +38,13 @@ def _pump(app, predicate, timeout_ms: int = 10_000) -> bool: return True +def _one_batch(*volumes): + """A scan_batches stand-in delivering a single, final batch.""" + return lambda: iter([(list(volumes), True)]) + + def test_watcher_reports_volumes_from_a_pool_thread(qapp, monkeypatch): - monkeypatch.setattr(drives, "list_volumes", lambda: [_volume("E:/")]) + monkeypatch.setattr(drives, "scan_batches", _one_batch(_volume("E:/"))) watcher = drives.VolumeWatcher() received: list[list] = [] watcher.volumesChanged.connect(received.append) @@ -56,7 +61,7 @@ def test_scan_survives_its_task_wrapper_being_collected(qapp, monkeypatch): a deleted object and the drive panel silently stops updating.""" import gc - monkeypatch.setattr(drives, "list_volumes", lambda: [_volume("E:/")]) + monkeypatch.setattr(drives, "scan_batches", _one_batch(_volume("E:/"))) watcher = drives.VolumeWatcher() received: list[list] = [] watcher.volumesChanged.connect(received.append) @@ -71,7 +76,7 @@ def test_a_failing_scan_is_reported_as_empty(qapp, monkeypatch): def boom(): raise OSError("drive not ready") - monkeypatch.setattr(drives, "list_volumes", boom) + monkeypatch.setattr(drives, "scan_batches", boom) watcher = drives.VolumeWatcher() received: list[list] = [] watcher.volumesChanged.connect(received.append) @@ -83,7 +88,7 @@ def boom(): def test_stop_suppresses_a_late_result(qapp, monkeypatch): - monkeypatch.setattr(drives, "list_volumes", lambda: [_volume("E:/")]) + monkeypatch.setattr(drives, "scan_batches", _one_batch(_volume("E:/"))) watcher = drives.VolumeWatcher() received: list[list] = [] watcher.volumesChanged.connect(received.append) @@ -97,8 +102,12 @@ def test_stop_suppresses_a_late_result(qapp, monkeypatch): def test_refresh_is_not_reentrant(qapp, monkeypatch): calls = [] - monkeypatch.setattr(drives, "list_volumes", - lambda: calls.append(1) or [_volume("E:/")]) + + def scan(): + calls.append(1) + yield [_volume("E:/")], True + + monkeypatch.setattr(drives, "scan_batches", scan) watcher = drives.VolumeWatcher() watcher.refresh() watcher.refresh() # ignored while the first is in flight @@ -161,3 +170,78 @@ def test_row_buttons_emit_the_volume_root(qapp): row.useAsDestination.emit(row.volume.root) assert sources == [Path("E:/")] assert destinations == [Path("E:/")] + + +# ------------------------------------------------------------------ batching + + +def _network(root: str, label: str = "NAS") -> Volume: + return Volume(root=Path(root), label=label, filesystem="NTFS", + total_bytes=100_000_000_000, free_bytes=50_000_000_000, + drive_type="network", is_camera_card=False) + + +def test_local_drives_are_delivered_before_network_shares(monkeypatch): + """The batch split is the fix for a panel that waited on the slowest SMB + share before showing the card reader plugged in next to the machine.""" + local_root = (Path("E:/"), "removable") + remote_root = (Path("H:/"), "network") + monkeypatch.setattr(drives, "list_roots", lambda: [local_root, remote_root]) + monkeypatch.setattr( + drives, "probe_many", + lambda roots: [_volume("E:/") if kind != "network" else _network("H:/") + for _, kind in roots]) + + batches = list(drives.scan_batches()) + assert len(batches) == 2 + first, final = batches + assert first[1] is False and final[1] is True + assert [v.drive_type for v in first[0]] == ["removable"] + assert sorted(v.drive_type for v in final[0]) == ["network", "removable"] + + +def test_no_network_shares_means_a_single_final_batch(monkeypatch): + monkeypatch.setattr(drives, "list_roots", + lambda: [(Path("E:/"), "removable")]) + monkeypatch.setattr(drives, "probe_many", lambda roots: [_volume("E:/")]) + batches = list(drives.scan_batches()) + assert len(batches) == 1 + assert batches[0][1] is True + + +def test_partial_batch_keeps_the_known_network_shares(qapp): + """While a poll's slow network probes are still in flight, the local-only + partial batch must not tear the share rows down for a few seconds.""" + watcher = drives.VolumeWatcher() + received: list[list] = [] + watcher.volumesChanged.connect(received.append) + + watcher._volumes = [_volume("E:/"), _network("H:/")] + watcher._on_batch([_volume("E:/")], False) + + assert received, "partial batch was not reported" + roots = [v.root for v in received[-1]] + assert Path("H:/") in roots and Path("E:/") in roots + + +def test_scanning_state_wraps_a_refresh(qapp, monkeypatch): + monkeypatch.setattr(drives, "scan_batches", _one_batch(_volume("E:/"))) + watcher = drives.VolumeWatcher() + states: list[bool] = [] + watcher.scanningChanged.connect(states.append) + + watcher.refresh() + assert states == [True] + assert _pump(qapp, lambda: len(states) == 2), "scan never finished" + assert states == [True, False] + watcher.stop() + + +def test_refresh_button_reports_the_scan(qapp): + panel = drives.DrivesPanel() + panel._on_scanning(True) + assert panel._refresh.text() == "Scanning…" + assert not panel._refresh.isEnabled() + panel._on_scanning(False) + assert panel._refresh.text() == "Refresh" + assert panel._refresh.isEnabled() From adb5b8f07bda95ca17a15de49238ab36650e14c1 Mon Sep 17 00:00:00 2001 From: owenpkent <20529132+owenpkent@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:17:48 -0400 Subject: [PATCH 13/19] Make a running job unmistakable in the queue --- src/offloader/gui/main_window.py | 6 ++ src/offloader/gui/queue_view.py | 113 ++++++++++++++++++++++++++++--- src/offloader/gui/simple_mode.py | 17 ++++- src/offloader/gui/theme.py | 7 ++ tests/test_gui.py | 41 +++++++++++ 5 files changed, 172 insertions(+), 12 deletions(-) diff --git a/src/offloader/gui/main_window.py b/src/offloader/gui/main_window.py index de8ad90..8eab0ff 100644 --- a/src/offloader/gui/main_window.py +++ b/src/offloader/gui/main_window.py @@ -52,6 +52,8 @@ def __init__(self) -> None: self.controller.jobStarted.connect(lambda _: self._update_status()) self.controller.itemsChanged.connect(self._update_status) self.controller.itemChanged.connect(lambda _: self._update_status()) + self.controller.itemsChanged.connect(self._sync_queue_busy) + self.controller.itemChanged.connect(lambda _: self._sync_queue_busy()) # ----------------------------------------------------------- panels self.simple = SimpleModePanel() @@ -295,6 +297,10 @@ def _check_duplicate(self, source: Path) -> bool: return answer == QMessageBox.Yes # ---------------------------------------------------------------- events + def _sync_queue_busy(self) -> None: + self.simple.set_queue_busy( + any(not i.state.is_terminal for i in self.controller.items)) + def _update_status(self) -> None: running = [i for i in self.controller.items if i.state is JobState.RUNNING] queued = [i for i in self.controller.items if i.state is JobState.QUEUED] diff --git a/src/offloader/gui/queue_view.py b/src/offloader/gui/queue_view.py index 4e59983..0c9abd9 100644 --- a/src/offloader/gui/queue_view.py +++ b/src/offloader/gui/queue_view.py @@ -6,11 +6,12 @@ import sys from pathlib import Path -from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt +from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt, QTimer from PySide6.QtGui import QColor, QPainter from PySide6.QtWidgets import ( QAbstractItemView, QHeaderView, + QStyle, QStyledItemDelegate, QTableView, QVBoxLayout, @@ -25,6 +26,15 @@ COLUMNS = ("Job", "Source", "Preset", "Status", "Progress", "Throughput") COL_STATUS = 3 COL_PROGRESS = 4 +COL_THROUGHPUT = 5 + +#: What each engine stage is doing, in the operator's words. +STAGE_VERBS = { + "copy": "Copying", + "verify": "Verifying", + "probe": "Reading metadata", + "thumbs": "Extracting thumbnails", +} def _throughput(item: QueueItem) -> str: @@ -45,6 +55,19 @@ def _throughput(item: QueueItem) -> str: return "" +def _active_summary(item: QueueItem) -> str: + """The running job in one line: what, on which file, how fast.""" + if item.state is JobState.PAUSED: + return f"Paused — {item.name} at {item.fraction * 100:.0f}%" + verb = STAGE_VERBS.get(item.stage, item.stage.capitalize() or "Running") + text = f"{verb} {item.current_file}" if item.current_file else verb + text += f" — {item.fraction * 100:.0f}%" + rate = _throughput(item) + if rate: + text += f" · {rate}" + return text + + class QueueModel(QAbstractTableModel): def __init__(self, controller: QueueController, parent=None) -> None: super().__init__(parent) @@ -117,6 +140,15 @@ def _refresh_one(self, identifier: int) -> None: self.dataChanged.emit(self.index(index, 0), self.index(index, len(COLUMNS) - 1)) + def refresh_throughput(self) -> None: + """Repaint the rate column without a progress event. During a stall no + events arrive, which is exactly when the displayed rate must be seen + to fall rather than freeze at its last healthy value.""" + rows = len(self.controller.items) + if rows: + self.dataChanged.emit(self.index(0, COL_THROUGHPUT), + self.index(rows - 1, COL_THROUGHPUT)) + def item_at(self, index: QModelIndex) -> QueueItem | None: if not index.isValid(): return None @@ -124,7 +156,16 @@ def item_at(self, index: QModelIndex) -> QueueItem | None: class ProgressDelegate(QStyledItemDelegate): - """Draws the progress column as a bar rather than a number.""" + """Draws the progress column as a bar with its percentage beside it. + + The colours are chosen against both grounds the cell can have: on the + normal row the old accent-on-near-black bar read fine, but the running row + is auto-selected, and an accent bar on the accent selection colour was + invisible — the operator's own job was the one row without a readable bar. + """ + + TEXT_WIDTH = 40 + BAR_HEIGHT = 10 def paint(self, painter: QPainter, option, index) -> None: fraction = index.data(Qt.UserRole) @@ -132,22 +173,34 @@ def paint(self, painter: QPainter, option, index) -> None: super().paint(painter, option, index) return + selected = bool(option.state & QStyle.State_Selected) + painter.save() + if selected: + painter.fillRect(option.rect, option.palette.highlight()) + rect = option.rect.adjusted(6, 0, -6, 0) - height = 8 - bar = rect.adjusted(0, (rect.height() - height) // 2, 0, - -(rect.height() - height) // 2) + bar = rect.adjusted(0, (rect.height() - self.BAR_HEIGHT) // 2, + -self.TEXT_WIDTH, + -(rect.height() - self.BAR_HEIGHT) // 2) - painter.save() painter.setRenderHint(QPainter.Antialiasing) painter.setPen(Qt.NoPen) - painter.setBrush(QColor(theme.BG)) + painter.setBrush(QColor(0, 0, 0, 90) if selected + else QColor(theme.PROGRESS_TRACK)) painter.drawRoundedRect(bar, 4, 4) - width = int(bar.width() * max(0.0, min(1.0, float(fraction)))) + clamped = max(0.0, min(1.0, float(fraction))) + width = int(bar.width() * clamped) if width > 0: filled = bar.adjusted(0, 0, width - bar.width(), 0) - painter.setBrush(QColor(theme.ACCENT)) + painter.setBrush(QColor("#ffffff") if selected + else QColor(theme.ACCENT)) painter.drawRoundedRect(filled, 4, 4) + + text_rect = rect.adjusted(rect.width() - self.TEXT_WIDTH + 6, 0, 0, 0) + painter.setPen(QColor("#ffffff") if selected else QColor(theme.FG)) + painter.drawText(text_rect, Qt.AlignRight | Qt.AlignVCenter, + f"{clamped * 100:.0f}%") painter.restore() @@ -190,8 +243,12 @@ def __init__(self, controller: QueueController, parent=None) -> None: header.setSectionResizeMode(2, QHeaderView.ResizeToContents) header.setSectionResizeMode(3, QHeaderView.ResizeToContents) header.setSectionResizeMode(4, QHeaderView.Fixed) - header.setSectionResizeMode(5, QHeaderView.ResizeToContents) - header.resizeSection(4, 150) + header.resizeSection(4, 170) + # Fixed, not ResizeToContents: the rate string changes width on every + # update, and letting it drive the layout shoved the column — the one + # being read — around as values grew and shrank. + header.setSectionResizeMode(COL_THROUGHPUT, QHeaderView.Fixed) + header.resizeSection(COL_THROUGHPUT, 190) self._pause = button("Pause", flat=True) self._cancel = button("Cancel", flat=True) @@ -212,10 +269,24 @@ def __init__(self, controller: QueueController, parent=None) -> None: self._empty = label("Nothing queued. Drop a card on a preset to start.", "muted") + # The running job promoted to where the eye already is: the queue rows + # are 30 px tall at the bottom of the window, and a job in flight + # looked almost identical to an idle queue. + self._active = label("", "heading") + self._active.setStyleSheet(f"color: {theme.ACCENT};") + self._active.setVisible(False) + + # Repaints the decaying rate during stalls, when no progress events + # arrive to do it. Runs only while a job is active. + self._ticker = QTimer(self) + self._ticker.setInterval(1000) + self._ticker.timeout.connect(self._tick) + layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(8) layout.addWidget(row(label("Queue", "heading"), None, self._clear)) + layout.addWidget(self._active) layout.addWidget(self._empty) layout.addWidget(self.table, 1) layout.addWidget(row(self._pause, self._cancel, 12, self._up, self._down, @@ -227,6 +298,25 @@ def __init__(self, controller: QueueController, parent=None) -> None: controller.jobStarted.connect(self._select_job) self._sync_buttons() + def _active_item(self) -> QueueItem | None: + return next((i for i in self.controller.items + if i.state in (JobState.RUNNING, JobState.PAUSED)), None) + + def _update_active(self) -> None: + item = self._active_item() + if item is None: + self._active.setVisible(False) + self._ticker.stop() + return + self._active.setText(_active_summary(item)) + self._active.setVisible(True) + if not self._ticker.isActive(): + self._ticker.start() + + def _tick(self) -> None: + self._update_active() + self.model.refresh_throughput() + def _select_job(self, identifier: int) -> None: """Follow the running job, so the transport controls act on it without the operator having to click the row first.""" @@ -249,6 +339,7 @@ def _sync_buttons(self) -> None: has_rows = bool(self.controller.items) self._empty.setVisible(not has_rows) self.table.setVisible(has_rows) + self._update_active() item = self._selected() running = item is not None and item.state is JobState.RUNNING diff --git a/src/offloader/gui/simple_mode.py b/src/offloader/gui/simple_mode.py index 0bf0b00..850ce20 100644 --- a/src/offloader/gui/simple_mode.py +++ b/src/offloader/gui/simple_mode.py @@ -34,6 +34,7 @@ class SimpleModePanel(QWidget): def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) + self._queue_busy = False self.drop_zone = SourceDropZone() self.drop_zone.pathChosen.connect(self._on_source_chosen) @@ -128,6 +129,17 @@ def _on_source_chosen(self, path: Path) -> None: self._name.setPlaceholderText(Path(path).name or "Offload") self._sync() + def set_queue_busy(self, busy: bool) -> None: + """Tell the panel whether the queue is already working. Jobs run one + at a time, so while one is running the button cannot start anything — + it enqueues. It should say so, rather than promise an immediate + offload it cannot deliver.""" + if busy == self._queue_busy: + return + self._queue_busy = busy + self._start.setText("Add to queue" if busy else "Start offload") + self._sync() + def _sync(self) -> None: source = self.drop_zone.path destinations = self.destinations.paths() @@ -144,7 +156,10 @@ def _sync(self) -> None: self._hint.setText("A destination sits inside the source — pick another.") else: copies = f"{len(destinations)} cop{'ies' if len(destinations) > 1 else 'y'}" - self._hint.setText(f"Ready: {source} → {copies}") + ready = f"Ready: {source} → {copies}" + if self._queue_busy: + ready += " — runs after the current job" + self._hint.setText(ready) @staticmethod def _overlaps(source: Path, destination: Path) -> bool: diff --git a/src/offloader/gui/theme.py b/src/offloader/gui/theme.py index a45dcbb..9072bd2 100644 --- a/src/offloader/gui/theme.py +++ b/src/offloader/gui/theme.py @@ -17,6 +17,9 @@ FG_MUTED = "#9aa0a6" ACCENT = "#5577b0" ACCENT_HOVER = "#6688c4" +#: Unfilled part of a progress bar. Distinct from BG, which vanished against +#: the table's own background. +PROGRESS_TRACK = "#31363f" OK = "#4caf7d" WARN = "#d8a13c" BAD = "#e0645c" @@ -131,6 +134,9 @@ def apply(app) -> None: border-radius: 6px; padding: 5px 8px; selection-background-color: {ACCENT}; + /* Without this the size hint under-reports the styled height and text + clips at its bottom edge at fractional DPI scales (125%). */ + min-height: 18px; }} QLineEdit:focus, QComboBox:focus, QSpinBox:focus, QPlainTextEdit:focus {{ border-color: {ACCENT}; @@ -179,6 +185,7 @@ def apply(app) -> None: QTabBar::tab:selected {{ color: {FG}; border-bottom-color: {ACCENT}; }} QTabWidget::pane {{ border: none; }} +QCheckBox, QRadioButton {{ min-height: 20px; }} QCheckBox::indicator, QRadioButton::indicator {{ width: 15px; height: 15px; border: 1px solid {BORDER}; diff --git a/tests/test_gui.py b/tests/test_gui.py index 54e6d84..b7ab281 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -433,3 +433,44 @@ def test_checksum_pickers_say_what_the_choice_costs(qapp, tmp_path): # The stored key must stay the bare algorithm id, not the display text. assert panel._algorithm.currentData() in {"xxh3-64"} + +def test_start_offload_says_add_to_queue_while_the_queue_is_busy(qapp, tmp_path): + """Jobs run one at a time; while one runs, the button enqueues rather than + starts, and promising "Start offload" was a small lie.""" + panel = SimpleModePanel() + (tmp_path / "card").mkdir() + panel.set_source(tmp_path / "card") + panel.add_destination(tmp_path / "out") + + assert panel._start.text() == "Start offload" + panel.set_queue_busy(True) + assert panel._start.text() == "Add to queue" + assert "after the current job" in panel._hint.text() + panel.set_queue_busy(False) + assert panel._start.text() == "Start offload" + assert "after the current job" not in panel._hint.text() + + +def test_active_summary_promotes_stage_file_and_rate(monkeypatch): + from offloader.gui import queue_view + from offloader.gui import worker as worker_mod + + clock = {"now": 0.0} + monkeypatch.setattr(worker_mod.time, "monotonic", lambda: clock["now"]) + + item = _running_item() + item.stage = "copy" + item.current_file = "A003_C001.braw" + item.fraction = 0.30 + item.bytes_total = 1_000_000_000 + for _ in range(3): + clock["now"] += 1.0 + item.bytes_done += 100_000_000 + item.record_progress(item.bytes_done) + + text = queue_view._active_summary(item) + assert text.startswith("Copying A003_C001.braw") + assert "30%" in text and "/s" in text + + item.state = JobState.PAUSED + assert queue_view._active_summary(item).startswith("Paused") From 4c337c4ca33d416888cc91b6401c15d69ebeae82 Mon Sep 17 00:00:00 2001 From: owenpkent <20529132+owenpkent@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:17:49 -0400 Subject: [PATCH 14/19] Default to full verification --- src/offloader/cli.py | 16 ++++++------ src/offloader/engine.py | 42 +++++++++++++++++--------------- src/offloader/gui/simple_mode.py | 2 +- src/offloader/presets.py | 6 ++--- tests/test_gui.py | 8 +++--- tests/test_presets.py | 2 +- 6 files changed, 40 insertions(+), 36 deletions(-) diff --git a/src/offloader/cli.py b/src/offloader/cli.py index 9026cd9..dee3e95 100644 --- a/src/offloader/cli.py +++ b/src/offloader/cli.py @@ -153,7 +153,7 @@ def _common_options(parser: argparse.ArgumentParser) -> None: choices=sorted(hashers.algorithm_keys()), help="checksum algorithm (default: %(default)s; the " "engine hashes every byte on the copy path, so a " - "slow choice caps copy speed — md5 is ~40x slower " + "slow choice caps copy speed — md5 is ~40x slower " "than the default; see 'offloader info')") parser.add_argument("--report", type=_parse_reports, default=DEFAULT_REPORTS, metavar="FMT[,FMT...]", @@ -192,7 +192,7 @@ def _common_options(parser: argparse.ArgumentParser) -> None: def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="offloader", - description=f"{PRODUCT_NAME} — verified copy for large data transfers, " + description=f"{PRODUCT_NAME} — verified copy for large data transfers, " f"with camera-card offload and job reports built in.", ) parser.add_argument("--version", action="version", @@ -205,7 +205,7 @@ def build_parser() -> argparse.ArgumentParser: offload.add_argument("--dest", type=Path, action="append", required=True, dest="destinations", metavar="PATH", help="destination root (repeat for multiple copies)") - offload.add_argument("--verify", default=VerificationMode.SOURCE_ONLY.value, + offload.add_argument("--verify", default=VerificationMode.FULL.value, choices=[m.value for m in VerificationMode], help="verification depth (default: %(default)s)") offload.add_argument("--flat", action="store_true", @@ -231,7 +231,7 @@ def build_parser() -> argparse.ArgumentParser: verify = sub.add_parser( "verify", - help="re-check an offloaded tree against its MHL — run this before " + help="re-check an offloaded tree against its MHL — run this before " "erasing a card, and again later to catch bit rot") verify.add_argument("path", type=Path, help="an .mhl file, or a folder to search for them") @@ -250,7 +250,7 @@ def _options_from(args: argparse.Namespace, destinations: list[Path]) -> engine. return engine.OffloadOptions( destinations=destinations, algorithm=args.hash, - verification=VerificationMode(getattr(args, "verify", "source-only")), + verification=VerificationMode(getattr(args, "verify", "full")), thumbnail_count=0 if args.no_probe else max(0, args.thumbs), excludes=tuple(engine.DEFAULT_EXCLUDES) + tuple(args.exclude), preserve_structure=not args.flat, @@ -354,8 +354,8 @@ def progress(index: int, total: int, path: Path) -> None: worst = max(worst, 1) print() - print("VERIFIED — safe to erase the source" if worst == 0 - else "NOT VERIFIED — do not erase the source") + print("VERIFIED — safe to erase the source" if worst == 0 + else "NOT VERIFIED — do not erase the source") return worst @@ -371,7 +371,7 @@ def cmd_info(_args: argparse.Namespace) -> int: print(f" ffprobe: {probe.ffprobe_path() or 'NOT FOUND (metadata disabled)'}") print(f" ffmpeg: {thumbs.ffmpeg_path() or 'NOT FOUND (thumbnails disabled)'}") print(f" report font: {fonts.describe()}" - f"{'' if fonts.using_reference_fonts() else ' (Verdana missing — metrics differ)'}") + f"{'' if fonts.using_reference_fonts() else ' (Verdana missing — metrics differ)'}") enabled = longpath.os_long_paths_enabled() if enabled is not None: prefix = "\\\\?\\" diff --git a/src/offloader/engine.py b/src/offloader/engine.py index d45bf47..9eb11b0 100644 --- a/src/offloader/engine.py +++ b/src/offloader/engine.py @@ -38,7 +38,7 @@ ".Trashes", ".fseventsd", "$RECYCLE.BIN", "System Volume Information", ) -CHUNK_SIZE = 8 << 20 # 8 MiB — large enough to keep spinning disks streaming. +CHUNK_SIZE = 8 << 20 # 8 MiB — large enough to keep spinning disks streaming. #: Chunks the reader may run ahead of the writer. A sequential read-then-write #: loop never overlaps the two, so it settles at the harmonic mean of read and @@ -51,8 +51,8 @@ #: Extension worn by a copy that is still in flight. A destination file only -#: takes its real name once it is complete — and, under full verification, once -#: it has been proven — so an interrupted offload can never leave something that +#: takes its real name once it is complete — and, under full verification, once +#: it has been proven — so an interrupted offload can never leave something that #: looks like finished media. PARTIAL_SUFFIX = ".offloader-partial" @@ -72,7 +72,7 @@ def assert_safe_destinations(source_root: Path, destinations: Sequence[Path]) -> * A destination equal to, or inside, the source. Opening the target for writing truncates it, and if that target *is* a source file the original - is gone before it is ever read — with the checksum of an empty file + is gone before it is ever read — with the checksum of an empty file dutifully recorded. * Two destinations resolving to the same directory, which would have two writers fighting over one file. @@ -108,7 +108,7 @@ class JobControl: """Cooperative pause/resume/cancel for a running offload. Checked once per chunk, so a pause takes effect within one 8 MiB read and a - cancel never leaves a half-written file behind — `run()` deletes partial + cancel never leaves a half-written file behind — `run()` deletes partial destinations on the way out. """ @@ -167,7 +167,11 @@ class ProgressEvent: class OffloadOptions: destinations: Sequence[Path] algorithm: str = "xxh3-64" - verification: VerificationMode = VerificationMode.SOURCE_ONLY + # FULL by default: the read-back is the only mode that proves what is on + # the destination device, and an offload tool's default should be the one + # whose "Verified" means the most. The cost is one extra read of each copy + # at the destination's own speed; anyone racing a deadline can opt down. + verification: VerificationMode = VerificationMode.FULL thumbnail_count: int = 4 excludes: Sequence[str] = DEFAULT_EXCLUDES #: Preserve the source tree under each destination root. @@ -268,7 +272,7 @@ def _copy_fanout(source: Path, targets: Sequence[Path], algorithm: str, retry: retry_mod.RetryPolicy = retry_mod.NO_RETRY) -> _CopyResult: """Stream `source` into every target at once. - `targets` are the *in-flight* paths — the caller renames them into place + `targets` are the *in-flight* paths — the caller renames them into place once it is satisfied. Nothing here ever opens a final destination name, so a copy that fails or is interrupted cannot damage a good file already sitting there. @@ -304,8 +308,8 @@ def read_ahead() -> None: """Keep the queue fed so the next read overlaps the current write. A transient read failure is retried *here*, at the chunk that failed, - rather than by restarting the file. Nothing has been hashed yet — the - hashers only ever see a chunk once it has been delivered whole — so + rather than by restarting the file. Nothing has been hashed yet — the + hashers only ever see a chunk once it has been delivered whole — so there is no checksum state to unwind, and recovering a bad sector costs one 8 MiB re-read instead of a re-read of everything before it. On a 79 GB clip that is the difference between seconds and a quarter of an @@ -419,7 +423,7 @@ def _confirm_source(source: Path, expected: str, algorithm: str) -> bool: The gap this closes: a read that returns wrong bytes *without raising*. The checksum is computed from whatever was read, so a bad read produces a destination that faithfully matches a corrupted source and verifies clean at - every level — file hashes, directory hashes, the lot. Nothing but reading + every level — file hashes, directory hashes, the lot. Nothing but reading twice can see it. Raises `UnstableRead` on a disagreement rather than choosing a winner: there @@ -433,7 +437,7 @@ def _confirm_source(source: Path, expected: str, algorithm: str) -> bool: again = hash_file(source, algorithm) if again != expected: raise retry_mod.UnstableRead( - f"two reads of {source.name} disagreed ({expected} then {again}) — " + f"two reads of {source.name} disagreed ({expected} then {again}) — " "the source did not return the same bytes twice" ) return evicted @@ -481,7 +485,7 @@ def _warn_on_split_companions(job: Job) -> None: if clip is None or clip.status is FileStatus.FAILED: continue job.warnings.append( - f"{entry.name} did not copy but {clip.name} did — the clip has " + f"{entry.name} did not copy but {clip.name} did — the clip has " "been separated from a file that belongs with it" ) @@ -608,7 +612,7 @@ def emit(event: ProgressEvent) -> None: try: # These close over the loop variables and are all invoked inside - # retry_mod.call below, before the loop advances — but bind them + # retry_mod.call below, before the loop advances — but bind them # anyway, so the safety is visible here rather than depending on # when the callee happens to call back. def on_chunk(n: int, _idx=index, _src=source, _st=stat) -> None: @@ -655,7 +659,7 @@ def copy_once(_src=source, _partials=partials, _idx=index, job.warnings.append( "could not evict files from the page cache on this " "platform, so the second read may have come from memory " - "rather than the device — --paranoid proved less than " + "rather than the device — --paranoid proved less than " "it appears to" ) return result @@ -670,16 +674,16 @@ def copy_once(_src=source, _partials=partials, _idx=index, # to stop using. job.warnings.append( f"{source.name} copied on attempt {used} of " - f"{options.retry.attempts} — the source may be failing") + f"{options.retry.attempts} — the source may be failing") if result.recovered_reads: # Recovered without restarting the file, which is why the copy - # succeeded at all — but the sectors that needed it are real. + # succeeded at all — but the sectors that needed it are real. # Said once per file: a card failing over a contiguous stretch # produces one of these every 8 MiB, and a warning list that # long is one nobody reads to the end. job.warnings.append( f"{source.name}: {_describe_recovery(result.recovered_reads)}" - " — the source may be failing") + " — the source may be failing") entry.checksum = src_sum or None except JobCancelled: _discard(partials) @@ -734,7 +738,7 @@ def copy_once(_src=source, _partials=partials, _idx=index, if verify_attempts > 1: job.warnings.append( f"{target.name} verified on attempt " - f"{verify_attempts} — the destination may be " + f"{verify_attempts} — the destination may be " "failing") destination.checksum = dst_sum or None except OSError as exc: @@ -827,7 +831,7 @@ def copy_once(_src=source, _partials=partials, _idx=index, not_attempted = len(files) - len(job.files) if not_attempted > 0: counters.errors.append( - f"cancelled — {not_attempted} file(s) not attempted") + f"cancelled — {not_attempted} file(s) not attempted") _warn_on_split_companions(job) job.notes = "; ".join(counters.errors) return job diff --git a/src/offloader/gui/simple_mode.py b/src/offloader/gui/simple_mode.py index 850ce20..5d9a142 100644 --- a/src/offloader/gui/simple_mode.py +++ b/src/offloader/gui/simple_mode.py @@ -59,7 +59,7 @@ def __init__(self, parent: QWidget | None = None) -> None: for mode, text in VERIFICATION_LABELS.items(): self._verification.addItem(text, mode.value) self._verification.setCurrentIndex( - max(0, self._verification.findData(VerificationMode.SOURCE_ONLY.value))) + max(0, self._verification.findData(VerificationMode.FULL.value))) self._profile = QComboBox() self._profile.addItem("Media — camera card", Profile.MEDIA.value) diff --git a/src/offloader/presets.py b/src/offloader/presets.py index b4089b1..698840a 100644 --- a/src/offloader/presets.py +++ b/src/offloader/presets.py @@ -39,7 +39,7 @@ class Preset: name: str destinations: list[Path] = field(default_factory=list) algorithm: str = "xxh3-64" - verification: VerificationMode = VerificationMode.SOURCE_ONLY + verification: VerificationMode = VerificationMode.FULL profile: Profile = Profile.MEDIA thumbnail_count: int = 4 reports: list[str] = field(default_factory=lambda: ["pdf"]) @@ -169,9 +169,9 @@ def as_list(key): return list(got) if isinstance(got, (list, tuple)) else [] try: - verification = VerificationMode(value("verification", "source-only")) + verification = VerificationMode(value("verification", "full")) except (ValueError, TypeError): - verification = VerificationMode.SOURCE_ONLY + verification = VerificationMode.FULL try: profile = Profile(value("profile", "media")) diff --git a/tests/test_gui.py b/tests/test_gui.py index b7ab281..cd81ea0 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -1,7 +1,7 @@ """GUI tests, run against Qt's offscreen platform. -These drive the real widgets and the real queue controller — the worker thread -actually copies files — so they cover the wiring between the interface and the +These drive the real widgets and the real queue controller — the worker thread +actually copies files — so they cover the wiring between the interface and the engine, not just that the modules import. """ @@ -249,7 +249,7 @@ def test_simple_mode_builds_a_preset_from_its_controls(qapp, tmp_path): assert preset.destinations == [tmp_path / "dest"] assert preset.reports == ["pdf"] - assert preset.verification is VerificationMode.SOURCE_ONLY + assert preset.verification is VerificationMode.FULL def test_preset_panel_disables_run_for_a_preset_without_destinations(qapp, tmp_path): @@ -364,7 +364,7 @@ def test_rate_is_windowed_not_a_lifetime_average(monkeypatch): item.bytes_done += 100_000_000 item.record_progress(item.bytes_done) - lifetime = item.bytes_done / item.elapsed # 50 MB/s — the old lie + lifetime = item.bytes_done / item.elapsed # 50 MB/s — the old lie windowed = item.rate_bytes_per_sec assert windowed == pytest.approx(100_000_000, rel=0.05) assert windowed > 1.8 * lifetime diff --git a/tests/test_presets.py b/tests/test_presets.py index 5b437cb..510946d 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -186,7 +186,7 @@ def test_nonsense_types_fall_back_rather_than_raise(): assert preset.retry_attempts == 3 assert preset.excludes == [] assert preset.algorithm in ALGORITHMS - assert preset.verification is VerificationMode.SOURCE_ONLY + assert preset.verification is VerificationMode.FULL def test_an_explicitly_empty_report_list_is_respected(): From 6b7ee700a2fcd190b815859b754879887ce2db68 Mon Sep 17 00:00:00 2001 From: owenpkent <20529132+owenpkent@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:17:49 -0400 Subject: [PATCH 15/19] Probe a decoder ffmpeg lacks once per job, not four spawns per clip --- src/offloader/engine.py | 9 ++++++- src/offloader/thumbs.py | 35 ++++++++++++++++++++++++++- tests/test_config_thumbs.py | 47 +++++++++++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 2 deletions(-) diff --git a/src/offloader/engine.py b/src/offloader/engine.py index 9eb11b0..89db123 100644 --- a/src/offloader/engine.py +++ b/src/offloader/engine.py @@ -557,6 +557,10 @@ def emit(event: ProgressEvent) -> None: if progress: progress(event) + # One memo per job: the first clip of a suffix this ffmpeg cannot decode + # pays the failed extraction, the remaining clips skip it. + thumb_memo = thumbs.DecoderMemo() + for index, source in enumerate(files): # Between files is the cheapest place to honour a pause or cancel. if control is not None: @@ -822,6 +826,7 @@ def copy_once(_src=source, _partials=partials, _idx=index, entry.thumbnails = thumbs.extract( picture, entry.media, thumb_dir, options.thumbnail_count, + memo=thumb_memo, ) job.files.append(entry) @@ -863,6 +868,7 @@ def rescan(source_root: Path, destination_roots: Sequence[Path], system_ram=host.system_ram, ) thumb_dir = options.thumbnail_dir or (source_root / f"{job.name}_Reports" / "thumbs") + thumb_memo = thumbs.DecoderMemo() total = sum(p.stat().st_size for p in files) done = 0 @@ -912,7 +918,8 @@ def rescan(source_root: Path, destination_roots: Sequence[Path], entry.media = probe_mod.probe(source) if options.thumbnail_count > 0 and entry.media.is_video: entry.thumbnails = thumbs.extract( - source, entry.media, thumb_dir, options.thumbnail_count + source, entry.media, thumb_dir, options.thumbnail_count, + memo=thumb_memo, ) job.files.append(entry) 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/tests/test_config_thumbs.py b/tests/test_config_thumbs.py index 4037650..dbe0c0e 100644 --- a/tests/test_config_thumbs.py +++ b/tests/test_config_thumbs.py @@ -133,3 +133,50 @@ def test_collect_returns_usable_host_facts(): def test_ram_reading_is_plausible(): total = sysinfo._ram_bytes() assert total == 0 or 256 * 1024**2 < total < 8 * 1024**4 + + +def test_memo_skips_a_suffix_that_never_decoded(tmp_path: Path, monkeypatch): + """BRAW without the Blackmagic SDK fails identically for every clip; the + first clip pays the failed probe, the rest of the job skips the four + doomed spawns each.""" + monkeypatch.setattr(thumbs, "ffmpeg_path", lambda: "ffmpeg") + calls: list = [] + + class _Failed: + returncode = 1 + stdout = "" + stderr = "decoder not found" + + monkeypatch.setattr(thumbs.subprocess, "run", + lambda cmd, **kwargs: calls.append(cmd) or _Failed()) + media = MediaInfo(width=4096, height=2160, duration_sec=10.0) + memo = thumbs.DecoderMemo() + + assert thumbs.extract(tmp_path / "A001.braw", media, tmp_path / "out", + 4, memo=memo) == [] + assert len(calls) == 4 + + # Same suffix, case-insensitive: not one more spawn. + assert thumbs.extract(tmp_path / "A002.BRAW", media, tmp_path / "out", + 4, memo=memo) == [] + assert len(calls) == 4 + + # A different suffix still gets its chance. + thumbs.extract(tmp_path / "C001.mov", media, tmp_path / "out", 4, memo=memo) + assert len(calls) == 8 + + +def test_memo_learns_nothing_from_a_skip(tmp_path: Path, monkeypatch): + """No ffmpeg and no video stream never attempted a decode, so they prove + nothing about the decoder.""" + media = MediaInfo(width=1920, height=1080, duration_sec=10.0) + memo = thumbs.DecoderMemo() + + monkeypatch.setattr(thumbs, "ffmpeg_path", lambda: None) + thumbs.extract(tmp_path / "clip.braw", media, tmp_path / "out", memo=memo) + assert not memo.is_dead(tmp_path / "clip.braw") + + monkeypatch.setattr(thumbs, "ffmpeg_path", lambda: "ffmpeg") + audio = MediaInfo(duration_sec=10.0) + thumbs.extract(tmp_path / "take.wav", audio, tmp_path / "out", memo=memo) + assert not memo.is_dead(tmp_path / "take.wav") From 60c7365f7b7d97b607cba0789c0086e603fcf78d Mon Sep 17 00:00:00 2001 From: owenpkent <20529132+owenpkent@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:17:50 -0400 Subject: [PATCH 16/19] Name a root offload after its volume label instead of Offload --- src/offloader/engine.py | 48 ++++++++++++++++++-------------- src/offloader/gui/simple_mode.py | 30 ++++++++++++-------- src/offloader/gui/worker.py | 10 ++++--- src/offloader/naming.py | 6 +++- src/offloader/volumes.py | 33 ++++++++++++++++++---- tests/test_naming.py | 16 +++++++++++ 6 files changed, 100 insertions(+), 43 deletions(-) diff --git a/src/offloader/engine.py b/src/offloader/engine.py index 89db123..cfc309c 100644 --- a/src/offloader/engine.py +++ b/src/offloader/engine.py @@ -18,7 +18,7 @@ from pathlib import Path from . import braw as braw_mod -from . import companions, integrity, longpath, sysinfo, thumbs +from . import companions, integrity, longpath, sysinfo, thumbs, volumes from . import probe as probe_mod from . import retry as retry_mod from .hashers import get_algorithm, hash_file, new_hasher @@ -38,7 +38,7 @@ ".Trashes", ".fseventsd", "$RECYCLE.BIN", "System Volume Information", ) -CHUNK_SIZE = 8 << 20 # 8 MiB — large enough to keep spinning disks streaming. +CHUNK_SIZE = 8 << 20 # 8 MiB — large enough to keep spinning disks streaming. #: Chunks the reader may run ahead of the writer. A sequential read-then-write #: loop never overlaps the two, so it settles at the harmonic mean of read and @@ -51,8 +51,8 @@ #: Extension worn by a copy that is still in flight. A destination file only -#: takes its real name once it is complete — and, under full verification, once -#: it has been proven — so an interrupted offload can never leave something that +#: takes its real name once it is complete — and, under full verification, once +#: it has been proven — so an interrupted offload can never leave something that #: looks like finished media. PARTIAL_SUFFIX = ".offloader-partial" @@ -72,7 +72,7 @@ def assert_safe_destinations(source_root: Path, destinations: Sequence[Path]) -> * A destination equal to, or inside, the source. Opening the target for writing truncates it, and if that target *is* a source file the original - is gone before it is ever read — with the checksum of an empty file + is gone before it is ever read — with the checksum of an empty file dutifully recorded. * Two destinations resolving to the same directory, which would have two writers fighting over one file. @@ -108,7 +108,7 @@ class JobControl: """Cooperative pause/resume/cancel for a running offload. Checked once per chunk, so a pause takes effect within one 8 MiB read and a - cancel never leaves a half-written file behind — `run()` deletes partial + cancel never leaves a half-written file behind — `run()` deletes partial destinations on the way out. """ @@ -272,7 +272,7 @@ def _copy_fanout(source: Path, targets: Sequence[Path], algorithm: str, retry: retry_mod.RetryPolicy = retry_mod.NO_RETRY) -> _CopyResult: """Stream `source` into every target at once. - `targets` are the *in-flight* paths — the caller renames them into place + `targets` are the *in-flight* paths — the caller renames them into place once it is satisfied. Nothing here ever opens a final destination name, so a copy that fails or is interrupted cannot damage a good file already sitting there. @@ -308,8 +308,8 @@ def read_ahead() -> None: """Keep the queue fed so the next read overlaps the current write. A transient read failure is retried *here*, at the chunk that failed, - rather than by restarting the file. Nothing has been hashed yet — the - hashers only ever see a chunk once it has been delivered whole — so + rather than by restarting the file. Nothing has been hashed yet — the + hashers only ever see a chunk once it has been delivered whole — so there is no checksum state to unwind, and recovering a bad sector costs one 8 MiB re-read instead of a re-read of everything before it. On a 79 GB clip that is the difference between seconds and a quarter of an @@ -423,7 +423,7 @@ def _confirm_source(source: Path, expected: str, algorithm: str) -> bool: The gap this closes: a read that returns wrong bytes *without raising*. The checksum is computed from whatever was read, so a bad read produces a destination that faithfully matches a corrupted source and verifies clean at - every level — file hashes, directory hashes, the lot. Nothing but reading + every level — file hashes, directory hashes, the lot. Nothing but reading twice can see it. Raises `UnstableRead` on a disagreement rather than choosing a winner: there @@ -437,7 +437,7 @@ def _confirm_source(source: Path, expected: str, algorithm: str) -> bool: again = hash_file(source, algorithm) if again != expected: raise retry_mod.UnstableRead( - f"two reads of {source.name} disagreed ({expected} then {again}) — " + f"two reads of {source.name} disagreed ({expected} then {again}) — " "the source did not return the same bytes twice" ) return evicted @@ -485,7 +485,7 @@ def _warn_on_split_companions(job: Job) -> None: if clip is None or clip.status is FileStatus.FAILED: continue job.warnings.append( - f"{entry.name} did not copy but {clip.name} did — the clip has " + f"{entry.name} did not copy but {clip.name} did — the clip has " "been separated from a file that belongs with it" ) @@ -524,7 +524,10 @@ def run(source_root: Path, options: OffloadOptions, host = sysinfo.collect() job = Job( - name=options.job_name or source_root.name, + # A card offloaded from its root has no folder name; its volume + # label is what the operator calls it. + name=(options.job_name or source_root.name + or volumes.volume_label(source_root) or "Offload"), source_root=source_root, destination_roots=dest_roots, verification=options.verification, @@ -616,7 +619,7 @@ def emit(event: ProgressEvent) -> None: try: # These close over the loop variables and are all invoked inside - # retry_mod.call below, before the loop advances — but bind them + # retry_mod.call below, before the loop advances — but bind them # anyway, so the safety is visible here rather than depending on # when the callee happens to call back. def on_chunk(n: int, _idx=index, _src=source, _st=stat) -> None: @@ -663,7 +666,7 @@ def copy_once(_src=source, _partials=partials, _idx=index, job.warnings.append( "could not evict files from the page cache on this " "platform, so the second read may have come from memory " - "rather than the device — --paranoid proved less than " + "rather than the device — --paranoid proved less than " "it appears to" ) return result @@ -678,16 +681,16 @@ def copy_once(_src=source, _partials=partials, _idx=index, # to stop using. job.warnings.append( f"{source.name} copied on attempt {used} of " - f"{options.retry.attempts} — the source may be failing") + f"{options.retry.attempts} — the source may be failing") if result.recovered_reads: # Recovered without restarting the file, which is why the copy - # succeeded at all — but the sectors that needed it are real. + # succeeded at all — but the sectors that needed it are real. # Said once per file: a card failing over a contiguous stretch # produces one of these every 8 MiB, and a warning list that # long is one nobody reads to the end. job.warnings.append( f"{source.name}: {_describe_recovery(result.recovered_reads)}" - " — the source may be failing") + " — the source may be failing") entry.checksum = src_sum or None except JobCancelled: _discard(partials) @@ -742,7 +745,7 @@ def copy_once(_src=source, _partials=partials, _idx=index, if verify_attempts > 1: job.warnings.append( f"{target.name} verified on attempt " - f"{verify_attempts} — the destination may be " + f"{verify_attempts} — the destination may be " "failing") destination.checksum = dst_sum or None except OSError as exc: @@ -836,7 +839,7 @@ def copy_once(_src=source, _partials=partials, _idx=index, not_attempted = len(files) - len(job.files) if not_attempted > 0: counters.errors.append( - f"cancelled — {not_attempted} file(s) not attempted") + f"cancelled — {not_attempted} file(s) not attempted") _warn_on_split_companions(job) job.notes = "; ".join(counters.errors) return job @@ -856,7 +859,10 @@ def rescan(source_root: Path, destination_roots: Sequence[Path], host = sysinfo.collect() job = Job( - name=options.job_name or source_root.name, + # A card offloaded from its root has no folder name; its volume + # label is what the operator calls it. + name=(options.job_name or source_root.name + or volumes.volume_label(source_root) or "Offload"), source_root=source_root, destination_roots=[Path(d) for d in destination_roots] or [source_root], verification=options.verification, diff --git a/src/offloader/gui/simple_mode.py b/src/offloader/gui/simple_mode.py index 5d9a142..ca9f227 100644 --- a/src/offloader/gui/simple_mode.py +++ b/src/offloader/gui/simple_mode.py @@ -1,6 +1,6 @@ """Simple mode: source, destinations, go. -Everything is on one screen with no saved state — for the one-off offload where +Everything is on one screen with no saved state — for the one-off offload where building a preset would be more work than the job itself. """ @@ -23,6 +23,7 @@ from ..models import Profile, VerificationMode from ..presets import Preset from ..reports import WRITERS +from ..volumes import volume_label from .preset_editor import PARANOID_LABEL, PARANOID_TOOLTIP, VERIFICATION_LABELS from .widgets import DestinationList, SourceDropZone, button, column, label, row @@ -41,7 +42,7 @@ def __init__(self, parent: QWidget | None = None) -> None: self.destinations = DestinationList() self.destinations.changed.connect(self._sync) - add = button("Add…", flat=True) + add = button("Add…", flat=True) add.clicked.connect(self.destinations.browse_and_add) remove = button("Remove", flat=True) remove.clicked.connect(self.destinations.remove_selected) @@ -62,8 +63,8 @@ def __init__(self, parent: QWidget | None = None) -> None: max(0, self._verification.findData(VerificationMode.FULL.value))) self._profile = QComboBox() - self._profile.addItem("Media — camera card", Profile.MEDIA.value) - self._profile.addItem("Data — any large transfer", Profile.DATA.value) + self._profile.addItem("Media — camera card", Profile.MEDIA.value) + self._profile.addItem("Data — any large transfer", Profile.DATA.value) self._profile.setCurrentIndex( max(0, self._profile.findData(Profile.MEDIA.value))) self._profile.currentIndexChanged.connect(self._on_profile_changed) @@ -126,12 +127,19 @@ def add_destination(self, path: Path) -> None: def _on_source_chosen(self, path: Path) -> None: if not self._name.text().strip(): - self._name.setPlaceholderText(Path(path).name or "Offload") + # Preview what the job will actually be called: folder name, or + # for a card offloaded from its root, the volume label ("A003"). + self._name.setPlaceholderText(self._default_name(path)) self._sync() + @staticmethod + def _default_name(source: Path) -> str: + source = Path(source) + return source.name or volume_label(source) or "Offload" + def set_queue_busy(self, busy: bool) -> None: """Tell the panel whether the queue is already working. Jobs run one - at a time, so while one is running the button cannot start anything — + at a time, so while one is running the button cannot start anything — it enqueues. It should say so, rather than promise an immediate offload it cannot deliver.""" if busy == self._queue_busy: @@ -153,12 +161,12 @@ def _sync(self) -> None: elif not destinations: self._hint.setText("Add at least one destination.") elif overlapping: - self._hint.setText("A destination sits inside the source — pick another.") + self._hint.setText("A destination sits inside the source — pick another.") else: copies = f"{len(destinations)} cop{'ies' if len(destinations) > 1 else 'y'}" - ready = f"Ready: {source} → {copies}" + ready = f"Ready: {source} → {copies}" if self._queue_busy: - ready += " — runs after the current job" + ready += " — runs after the current job" self._hint.setText(ready) @staticmethod @@ -172,7 +180,7 @@ def _overlaps(source: Path, destination: Path) -> bool: return source == destination or source in destination.parents def _on_profile_changed(self) -> None: - # Thumbnails are contact-sheet frames from a clip — meaningless for a + # Thumbnails are contact-sheet frames from a clip — meaningless for a # generic data transfer, which never decodes a file. Grey the control # so the disabled state explains itself. is_media = self._profile.currentData() == Profile.MEDIA.value @@ -195,5 +203,5 @@ def _start_clicked(self) -> None: source = self.drop_zone.path if source is None: return - name = self._name.text().strip() or (Path(source).name or "Offload") + name = self._name.text().strip() or self._default_name(source) self.runRequested.emit(source, self.build_preset(), name) diff --git a/src/offloader/gui/worker.py b/src/offloader/gui/worker.py index aa1f93e..770b3e4 100644 --- a/src/offloader/gui/worker.py +++ b/src/offloader/gui/worker.py @@ -1,6 +1,6 @@ """Job queue and the worker thread that drains it. -Jobs run one at a time. That is not a simplification — offloads are I/O bound, +Jobs run one at a time. That is not a simplification — offloads are I/O bound, and running two at once against the same bus makes both slower while making the progress readout meaningless. """ @@ -19,6 +19,7 @@ from ..models import Job from ..presets import Preset from ..reports import WRITERS +from ..volumes import volume_label #: Trailing window over which throughput is measured. Long enough to smooth #: per-chunk jitter, short enough that a stall shows up within seconds. @@ -80,7 +81,7 @@ def record_progress(self, bytes_done: int) -> None: """Feed the throughput window. Called on every progress event.""" now = time.monotonic() if self._samples and bytes_done < self._samples[-1][1]: - # The counter went backwards — a new stage started counting from + # The counter went backwards — a new stage started counting from # zero. A delta across that boundary would be negative garbage. self._samples.clear() self._samples.append((now, bytes_done)) @@ -223,7 +224,8 @@ def __init__(self, parent: QObject | None = None) -> None: def enqueue(self, source: Path, preset: Preset, name: str | None = None) -> QueueItem: source = Path(source) resolved = name or naming.build( - preset.naming_template, source, taken=self._taken_names() + preset.naming_template, source, + volume_label=volume_label(source), taken=self._taken_names() ) item = QueueItem( identifier=self._next_id, @@ -260,7 +262,7 @@ def clear_finished(self) -> None: self.itemsChanged.emit() def move(self, identifier: int, offset: int) -> None: - """Reorder a pending job — the queue's priority control.""" + """Reorder a pending job — the queue's priority control.""" item = self.find(identifier) if item is None or item.state is not JobState.QUEUED: return diff --git a/src/offloader/naming.py b/src/offloader/naming.py index 0d670c9..208d475 100644 --- a/src/offloader/naming.py +++ b/src/offloader/naming.py @@ -45,7 +45,11 @@ def context(source: Path, volume_label: str | None = None, except Exception: # pragma: no cover - no login name in some containers user = "unknown" return { - "card": source.name or source.anchor.strip("\\/:") or "Offload", + # A source with no folder name is a card offloaded from its root, and + # what the operator calls that card is its volume label — "A003", not + # "E". The drive letter stays as the last resort. + "card": source.name or volume_label or source.anchor.strip("\\/:") + or "Offload", "volume": volume_label or source.name or "", "date": f"{moment:%Y%m%d}", "time": f"{moment:%H%M%S}", diff --git a/src/offloader/volumes.py b/src/offloader/volumes.py index 29fe05c..78ab2d3 100644 --- a/src/offloader/volumes.py +++ b/src/offloader/volumes.py @@ -22,7 +22,7 @@ "brawcontents", "pana_grp", } -#: Camera originals. Some cameras — Blackmagic among them — write clips +#: Camera originals. Some cameras — Blackmagic among them — write clips #: straight to the root with no marker directory at all, so a volume holding #: several of these is treated as a card even without one. CAMERA_EXTENSIONS = { @@ -49,7 +49,7 @@ class Volume: total_bytes: int free_bytes: int drive_type: str = "fixed" - #: Computed once when the volume is listed — the check touches the disk, + #: Computed once when the volume is listed — the check touches the disk, #: and the drive panel refreshes on a timer. is_camera_card: bool = False @@ -84,7 +84,7 @@ def detect_camera_card(root: Path, drive_type: str) -> bool: """Whether a volume root looks like camera media rather than a disk. Two signals: a marker directory the camera wrote, or a root full of camera - originals. Removability is deliberately not required — a reader in a + originals. Removability is deliberately not required — a reader in a Thunderbolt dock usually reports as a fixed disk. """ if drive_type in ("network", "optical", "ramdisk", "no-root", "unknown"): @@ -133,7 +133,7 @@ def _usage(root: Path) -> tuple[int, int]: def _windows_roots() -> list[tuple[Path, str]]: - """Every drive letter and its type. Cheap — `GetDriveTypeW` reads a flag + """Every drive letter and its type. Cheap — `GetDriveTypeW` reads a flag the mount manager already holds, it does not touch the volume.""" import ctypes @@ -152,7 +152,7 @@ def _windows_roots() -> list[tuple[Path, str]]: def _windows_probe(root: Path, drive_type: str) -> Volume | None: - """Label, filesystem, usage and card detection for one root — the part + """Label, filesystem, usage and card detection for one root — the part that actually talks to the device, and for a network share is a synchronous SMB round-trip. Runs on a pool thread; error mode is set per-thread so an empty card reader cannot pop an "insert a disk" dialog.""" @@ -242,7 +242,7 @@ def probe_volume(root: Path, drive_type: str) -> Volume | None: def probe_many(roots: list[tuple[Path, str]]) -> list[Volume]: """Probe roots concurrently, so a sleeping USB drive or a slow network - share costs its own probe rather than delaying every drive after it — + share costs its own probe rather than delaying every drive after it — serial probing made the panel's refresh wait the *sum* of every round-trip; this waits only the slowest.""" if not roots: @@ -277,6 +277,27 @@ def list_volumes() -> list[Volume]: return order_volumes(probe_many(list_roots())) +def volume_label(path: Path) -> str | None: + """The label of the volume holding `path`, probing only that volume. + + Exists for job naming: a card offloaded from its root has no folder name + to be named after, and the volume label — A003 — is what the operator + calls the card. `find_volume` would answer too, but it probes every + mounted volume including network shares; this touches one. + """ + if platform.system() != "Windows": + return None # POSIX mounts carry their label as the directory name + import ctypes + + kernel32 = ctypes.windll.kernel32 + buffer = ctypes.create_unicode_buffer(261) + ok = kernel32.GetVolumeInformationW( + ctypes.c_wchar_p(Path(path).anchor or str(path)), buffer, 261, + None, None, None, None, 0, + ) + return (buffer.value or None) if ok else None + + def find_volume(path: Path) -> Volume | None: """The volume a path sits on, for labelling a chosen source.""" path = Path(path).resolve() diff --git a/tests/test_naming.py b/tests/test_naming.py index 0f96aae..5851f33 100644 --- a/tests/test_naming.py +++ b/tests/test_naming.py @@ -52,3 +52,19 @@ def test_volume_label_falls_back_to_folder_name(): values = naming.context(Path("/Volumes/A001"), volume_label="CARD_A", when=WHEN) assert values["volume"] == "CARD_A" assert naming.context(Path("/Volumes/A001"), when=WHEN)["volume"] == "A001" + + +def test_card_token_prefers_the_volume_label_for_a_bare_root(): + """A card offloaded from its root has no folder name; what the operator + calls it is the volume label — A003, not E.""" + from offloader import naming + + values = naming.context(Path("E:/"), volume_label="A003") + assert values["card"] == "A003" + # A real folder name still wins; the label describes the volume, the + # folder describes the selection. + values = naming.context(Path("E:/DCIM"), volume_label="A003") + assert values["card"] == "DCIM" + # No label falls back to the drive letter, never to nothing. + values = naming.context(Path("E:/")) + assert values["card"] == "E" From 87fc71f03fa39f2bebcd1a3202a768472edb1fc4 Mon Sep 17 00:00:00 2001 From: owenpkent <20529132+owenpkent@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:17:50 -0400 Subject: [PATCH 17/19] Title the PDF so a stack of reports can be told apart --- src/offloader/reports/pdf.py | 9 ++++++++- tests/test_reports.py | 12 ++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) 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/tests/test_reports.py b/tests/test_reports.py index 540b42d..73982ed 100644 --- a/tests/test_reports.py +++ b/tests/test_reports.py @@ -265,3 +265,15 @@ def test_mhl_preserves_ordinary_unicode(sample_job: Job, tmp_path: Path): ) root = ET.parse(write_mhl(sample_job, tmp_path / "j.mhl")).getroot() assert any("café_日本" in (n.findtext("file") or "") for n in root.findall("hash")) + + +def test_pdf_document_title_carries_route_and_date(sample_job: Job, tmp_path: Path): + """A stack of reports is told apart by this title in a file manager or a + browser tab; "Offload Job Report" identified nothing.""" + path = write_pdf(sample_job, tmp_path / "JobReport.pdf") + with fitz.open(path) as document: + title = document.metadata["title"] + assert sample_job.name in title + assert str(sample_job.source_root) in title + assert str(sample_job.destination_roots[0]) in title + assert f"{sample_job.started:%Y-%m-%d}" in title From a5beec9db03bf7cc6a2d54c2aeb66fa2c932cad3 Mon Sep 17 00:00:00 2001 From: owenpkent <20529132+owenpkent@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:17:50 -0400 Subject: [PATCH 18/19] Record the offload-day round of fixes in the docs --- CHANGELOG.md | 63 +++++++++++++++++++++++++++++++++++++++++++++ README.md | 13 ++++++---- ROADMAP.md | 6 ++++- docs/data-safety.md | 2 +- 4 files changed, 77 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f42490..1cf965e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,71 @@ project uses [semantic versioning][semver]. ## [Unreleased] +### Changed + +- **Full verification is the default.** The read-back is the only mode that + proves what is on the destination device, and the default should be the one + whose "Verified" means the most. Applies to the engine's options, new + presets, Simple mode and the CLI's `--verify`; `source-only` remains one + flag or one dropdown away for the run that is racing a deadline. The cost + is one extra read of each copy at the destination's own read speed. +- **Jobs offloaded from a card's root are named after the volume label.** A + root has no folder name, so the job — and every report named after it — + was called "Offload". It is now called what the operator calls the card: + the label, e.g. "A003". Applies to the engine, the queue's naming + templates (`{card}`), and Simple mode's placeholder. +- **The PDF's document title carries the route and the date.** A stack of + reports in a file manager all read "Offload Job Report"; the title is now + "A003 Job Report — E:\ → D:\skate video — 2026-08-09". + +### Fixed + +- **A decoder ffmpeg lacks is probed once per job, not per clip.** Extracting + thumbnails from BRAW with a stock ffmpeg fails identically for every clip; + each one still paid four doomed process spawns. The first clip of a suffix + that produces no frames now marks that suffix dead for the rest of the job + (camera proxies, being ordinary MP4/MOV, are unaffected). + +- **The queue's throughput and ETA measure the last five seconds, not the life + of the job.** The old figure was `bytes / total elapsed`, which folds the + pre-copy card scan and every between-file probe stall into the number + forever — a real offload read 3.5 MB/s while clips were demonstrably flying + past, and the ETA was wrong in the same direction. The rate now comes from a + trailing window, decays visibly during a stall instead of freezing, and + survives the copy→verify counter reset. + +- **The drive panel no longer waits on the slowest network share.** Volume + probes run concurrently instead of serially — the refresh costs the slowest + probe, not the sum — and local drives are delivered before network shares, + so the card reader next to the machine never queues behind an SMB + round-trip. While the shares are still answering, the rows from the last + scan stay up rather than flickering out, and the Refresh button says + "Scanning…" instead of looking like a button that does nothing. +- **A running job is visible as one.** The queue panel now carries a summary + line — stage, current file, percent, live rate and ETA — instead of leaving + the evidence in a thin strip of 30 px rows. The progress bar gained a + percent label and colours that survive the row being selected (the running + row is auto-selected, and an accent bar on the accent selection was + invisible on exactly the row that mattered). The rate and progress columns + are fixed-width, so updating values no longer shove the numbers being read. + A once-a-second repaint lets the displayed rate visibly decay during a + stall instead of freezing at its last healthy value. +- **Simple mode's form rows no longer clip.** Inputs and checkboxes declare + the height their styling actually needs; at fractional display scales + (125%) the computed hint fell short and every field's text was sliced at + the bottom. + ### Added +- **"Start offload" says "Add to queue" when that is what it does.** Jobs run + one at a time; while one is running the button enqueues, and the ready line + says the job runs after the current one. +- **Checksum pickers say what the choice costs.** MD5 sat in the same list as + XXHash3-64 looking like an equal choice; on the copy path, where every byte + is hashed once per stream, it is ~40x slower and can cap copy speed. The + desktop pickers, `--hash` help and `offloader info` now carry a speed note + per algorithm ("fastest", "~40x slower, legacy compatibility only", …). + - **A `data` profile for generic large-data transfers.** The verified copy engine was never camera-specific — it reads every byte once, checksums it, fans it out to N destinations and reads it back — but the metadata layer diff --git a/README.md b/README.md index 60ea0f6..3eaeb7f 100644 --- a/README.md +++ b/README.md @@ -90,13 +90,13 @@ offloader verify D:\video\080426\A001 | `--source PATH` | card or folder to offload | | `--dest PATH` | destination root; repeat for multiple copies | | `--hash ALGO` | `xxh3-64` (default), `xxh3-128`, `xxh64`, `xxh64be`, `md5`, `sha1`, `sha256`, `c4`, `none` | -| `--verify MODE` | `source-only` (default), `full`, `none` | +| `--verify MODE` | `full` (default), `source-only`, `none` | | `--profile P` | `media` (default: ffprobe, thumbnails, BRAW) or `data` (generic transfer, no media probing) | | `--generic` | shorthand for `--profile data` | | `--report FMT[,FMT]` | `pdf` (default), `csv`, `mhl`, `ascmhl`, `html` | | `--report-dir PATH` | override the report location | | `--thumbs N` | frames per clip, 0 to disable (default 4) | -| `--name NAME` | job name; defaults to the source folder name | +| `--name NAME` | job name; defaults to the source folder name, or the volume label for a card offloaded from its root | | `--logo PATH` | image for the PDF header | | `--footer TEXT` | footer line for the PDF | | `--exclude GLOB` | extra filename pattern to skip; repeatable | @@ -133,10 +133,13 @@ file involved still hashes exactly as recorded. See | --- | --- | --- | | `none` | copy only | nothing | | `source-only` | hashes the source as it is read and the bytes as they are written | corruption in transit | -| `full` | additionally re-reads each destination file off disk and hashes it | the above, plus bad media and lying write caches | +| `full` (default) | additionally re-reads each destination file off disk and hashes it | the above, plus bad media and lying write caches | -`full` is the honest one: it is the only mode that proves what is actually on -the destination, at the cost of reading everything twice. +`full` is the honest one — the only mode that proves what is actually on the +destination — which is why it is the default. The cost is one extra read of +each copy at the destination's own speed: a fast SSD destination adds a few +percent to the job, a spinning disk can approach doubling it. `source-only` +is there for the run that is racing a deadline. `--paranoid` is orthogonal to all three. Every mode above compares against the source's checksum, which is computed from whatever the read returned — so a read diff --git a/ROADMAP.md b/ROADMAP.md index 55853b8..277e04f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -17,7 +17,11 @@ an oversight. ## Done Engine, CLI, five report formats (PDF, CSV, MHL 1.1, ASC MHL v2.0, HTML), the -desktop app, and cross-platform CI. +desktop app, and cross-platform CI. A first live card offload then drove a +round of fixes: full verification as the default, windowed throughput/ETA, +checksum-cost labels in every picker, a running-job summary line, the +local-first parallel drive scan, jobs named after the volume label, and a PDF +title that tells reports apart. The PDF matches a real ShotPut Pro report's geometry, measured from its content streams. ASC MHL is diffed against the reference implementation's own worked diff --git a/docs/data-safety.md b/docs/data-safety.md index b11b36f..8eea8cd 100644 --- a/docs/data-safety.md +++ b/docs/data-safety.md @@ -71,7 +71,7 @@ Three modes, and it is worth being precise about what each proves. | --- | --- | --- | | `none` | source once | nothing | | `source-only` | source once | the bytes written matched the bytes read | -| `full` | source once, destination again | the bytes **on the destination** match the source | +| `full` (default) | source once, destination again | the bytes **on the destination** match the source | `source-only` hashes the source as it is read and hashes each buffer as it is handed to `write()`. It catches corruption in transit. It cannot catch anything From 07188eb2dbc8fc0e2eb125b8e694f7b4d7667b77 Mon Sep 17 00:00:00 2001 From: owenpkent <20529132+owenpkent@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:26:12 -0400 Subject: [PATCH 19/19] Test the volume-label naming against each platform's own root --- tests/test_naming.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/tests/test_naming.py b/tests/test_naming.py index 5851f33..f081603 100644 --- a/tests/test_naming.py +++ b/tests/test_naming.py @@ -1,8 +1,11 @@ from __future__ import annotations import datetime as _dt +import os from pathlib import Path +import pytest + from offloader import naming WHEN = _dt.datetime(2026, 8, 7, 16, 41, 26) @@ -56,15 +59,20 @@ def test_volume_label_falls_back_to_folder_name(): def test_card_token_prefers_the_volume_label_for_a_bare_root(): """A card offloaded from its root has no folder name; what the operator - calls it is the volume label — A003, not E.""" - from offloader import naming + calls it is the volume label — A003, not a drive letter. - values = naming.context(Path("E:/"), volume_label="A003") + Uses each platform's own root: "E:/" is only a root on Windows — on POSIX + it is a relative path whose *name* is "E:", which is exactly the case the + folder name should win.""" + root = Path("C:/") if os.name == "nt" else Path("/") + values = naming.context(root, volume_label="A003") assert values["card"] == "A003" # A real folder name still wins; the label describes the volume, the # folder describes the selection. - values = naming.context(Path("E:/DCIM"), volume_label="A003") + values = naming.context(root / "DCIM", volume_label="A003") assert values["card"] == "DCIM" - # No label falls back to the drive letter, never to nothing. - values = naming.context(Path("E:/")) - assert values["card"] == "E" + + +@pytest.mark.skipif(os.name != "nt", reason="drive letters are a Windows thing") +def test_card_token_falls_back_to_the_drive_letter_without_a_label(): + assert naming.context(Path("E:/"))["card"] == "E"