From bc4041e8f2012516be4e93c9653197d58f63b3b4 Mon Sep 17 00:00:00 2001 From: fyulita Date: Thu, 28 May 2026 19:32:58 -0300 Subject: [PATCH] Add optional verification for write-dates metadata writes --- docs/USER-GUIDE.md | 6 +++++ tests/test_write_dates.py | 57 +++++++++++++++++++++++++++++++++++++++ write_dates.py | 27 +++++++++++++++++++ 3 files changed, 90 insertions(+) diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index 4a1be5c..5d98e6a 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -523,6 +523,11 @@ Without `--if-missing`, matching date tags may be overwritten. `--set-filetime` additionally updates filesystem create and modification timestamps to the selected date. +`--verify` reads the expected date tags back after ExifTool writes them. It is +off by default because it adds another ExifTool read per file. If verification +does not find the expected date, the CSV row is left with `metadata_ok=False` +so a later `--resume-csv` run retries that file. + ### CSV Fields The `write-dates` CSV records: @@ -544,6 +549,7 @@ sidecar, or only evaluated under dry-run. | `--dry-run` | Do not change files | | `--if-missing` | Only fill tags that are empty | | `--set-filetime` | Also set filesystem timestamps | +| `--verify` | Read date tags back after writing and retry failed verification on resume | | `--exiftool PATH` | Select ExifTool binary | | `--timeout N` | Limit ExifTool time per media file | | `--checkpoint-seconds N` | Periodically write a resume checkpoint | diff --git a/tests/test_write_dates.py b/tests/test_write_dates.py index 9755f56..28fa714 100644 --- a/tests/test_write_dates.py +++ b/tests/test_write_dates.py @@ -24,6 +24,7 @@ def writeArgs(**overrides): "exiftool": "exiftool", "timeout": 90, "workers": 1, + "verify": False, "quiet": True, } args.update(overrides) @@ -90,6 +91,62 @@ def testWriteDatesDryRunRecordsActionWithoutCompletingMetadata(tmp_path): assert stats.summary()["dry_run"] == 1 +def testWriteDatesVerifyReadsBackWrittenMetadata(tmp_path, monkeypatch): + image = tmp_path / "2026-03-02T10-20-30.JPG" + image.write_bytes(b"fake image") + verified = {} + + def fakeExiftool(**kwargs): + return 0, "" + + def fakeVerify(path, expectedDate, exiftoolPath, timeout, printLock): + verified.update( + { + "path": path, + "expectedDate": expectedDate, + "exiftoolPath": exiftoolPath, + "timeout": timeout, + } + ) + return True, "" + + monkeypatch.setattr(write_dates, "runExiftool", fakeExiftool) + monkeypatch.setattr(write_dates, "verifyWrittenDate", fakeVerify) + + stats = write_dates.Stats() + write_dates.processOne(image, writeArgs(verify=True), stats) + + row = stats.getCsvRows()[0] + assert verified["path"] == image + assert verified["expectedDate"] == "2026:03:02 10:20:30" + assert row["metadata_ok"] is True + assert row["error"] == "" + assert stats.summary()["metadata_verified"] == 1 + + +def testWriteDatesVerifyFailureKeepsRowPending(tmp_path, monkeypatch): + image = tmp_path / "2026-03-02T10-20-30.JPG" + image.write_bytes(b"fake image") + + def fakeExiftool(**kwargs): + return 0, "" + + def fakeVerify(path, expectedDate, exiftoolPath, timeout, printLock): + return False, "expected date not found after write" + + monkeypatch.setattr(write_dates, "runExiftool", fakeExiftool) + monkeypatch.setattr(write_dates, "verifyWrittenDate", fakeVerify) + + stats = write_dates.Stats() + write_dates.processOne(image, writeArgs(verify=True), stats) + + row = stats.getCsvRows()[0] + assert row["metadata_ok"] is False + assert row["error"] == "expected date not found after write" + assert not write_dates.isCompletedCsvRow(row) + assert stats.summary()["metadata_verify_failed"] == 1 + + def testWriteDatesResumeSkipsCompletedSource(tmp_path, monkeypatch): completed = tmp_path / "2026-03-02T10-20-30.JPG" pending = tmp_path / "2026-03-02T10-20-31.JPG" diff --git a/write_dates.py b/write_dates.py index de21e5a..28dd31f 100644 --- a/write_dates.py +++ b/write_dates.py @@ -31,6 +31,7 @@ saveRunLog, truthyCsvValue, ) +from media_tools.metadata_writer import verifyWrittenDate # ---------------------- @@ -50,6 +51,7 @@ "run_exiftool", "run_timeout", "run_workers", + "run_verify", "run_quiet", "run_interrupted", ] @@ -57,6 +59,8 @@ SUMMARY_COUNT_KEYS = [ "written", "written_sidecars", + "metadata_verified", + "metadata_verify_failed", "dry_run", "skipped_resume_completed", "skipped_sidecar", @@ -171,6 +175,7 @@ def buildRunContext(args, src): "run_exiftool": args.exiftool, "run_timeout": args.timeout, "run_workers": args.workers, + "run_verify": args.verify, "run_quiet": args.quiet, "run_interrupted": False, "run_effective_command": buildEffectiveCommand(args, src), @@ -198,6 +203,9 @@ def buildEffectiveCommand(args, src): if args.set_filetime: parts.append("--set-filetime") + if args.verify: + parts.append("--verify") + if args.quiet: parts.append("--quiet") @@ -224,6 +232,9 @@ def applyRunDefaults(args, resumeContext, inheritInputMode): if args.set_filetime is None: args.set_filetime = metadataBool(resumeContext.get("run_set_filetime"), False) + if args.verify is None: + args.verify = metadataBool(resumeContext.get("run_verify"), False) + if args.dry_run is None: args.dry_run = metadataBool(resumeContext.get("run_dry_run"), False) @@ -262,6 +273,8 @@ def parseArgs(): parser.add_argument("--overwrite-tags", dest="if_missing", action="store_false", help="Overwrite date tags when resuming.") parser.add_argument("--set-filetime", dest="set_filetime", action="store_true", default=None, help="Also set filesystem dates (FileModifyDate/CreateDate).") parser.add_argument("--no-set-filetime", dest="set_filetime", action="store_false", help="Do not set filesystem dates when resuming.") + parser.add_argument("--verify", dest="verify", action="store_true", default=None, help="Read metadata back after writing and keep the row pending if the expected date is not found.") + parser.add_argument("--no-verify", dest="verify", action="store_false", help="Disable metadata verification when resuming.") parser.add_argument("--exiftool", default=None, help="Path to exiftool binary. Default: exiftool in PATH.") parser.add_argument("--timeout", type=positiveInt, default=None, help=f"Per-file ExifTool timeout in seconds. Default: {DEFAULT_TIMEOUT}.") parser.add_argument("--workers", type=int, default=None, help=f"Max threads. 0 = auto. Default: {DEFAULT_WORKERS}.") @@ -433,6 +446,20 @@ def processOne(path, args, stats): stats.inc("written") metadataOk = True + if args.verify and not args.dry_run: + verified, verifyError = verifyWrittenDate(path, dt, args.exiftool, args.timeout, printLock) + + if verified: + stats.inc("metadata_verified") + else: + stats.inc("metadata_verify_failed") + stats.addFailed(str(path)) + stats.addCsvRow(path, displayDate, dateOffset, dateSource, False, writeTarget, verifyError) + if not args.quiet: + with printLock: + print(f"[verify failed:{writeTarget}] {path} <- {dt}: {verifyError}") + return + stats.addCsvRow(path, displayDate, dateOffset, dateSource, metadataOk, writeTarget, "") if not args.quiet: with printLock: