Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/USER-GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 |
Expand Down
57 changes: 57 additions & 0 deletions tests/test_write_dates.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ def writeArgs(**overrides):
"exiftool": "exiftool",
"timeout": 90,
"workers": 1,
"verify": False,
"quiet": True,
}
args.update(overrides)
Expand Down Expand Up @@ -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"
Expand Down
27 changes: 27 additions & 0 deletions write_dates.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
saveRunLog,
truthyCsvValue,
)
from media_tools.metadata_writer import verifyWrittenDate


# ----------------------
Expand All @@ -50,13 +51,16 @@
"run_exiftool",
"run_timeout",
"run_workers",
"run_verify",
"run_quiet",
"run_interrupted",
]

SUMMARY_COUNT_KEYS = [
"written",
"written_sidecars",
"metadata_verified",
"metadata_verify_failed",
"dry_run",
"skipped_resume_completed",
"skipped_sidecar",
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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")

Expand All @@ -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)

Expand Down Expand Up @@ -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}.")
Expand Down Expand Up @@ -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:
Expand Down
Loading