From 5c410b09d9614339f278a038bfa9deb9e50bb4b3 Mon Sep 17 00:00:00 2001 From: Cameron Cordes Date: Sat, 17 Oct 2026 00:03:17 -0400 Subject: [PATCH] fix(dates): resolve IFD0 ModifyDate as a last-resort capture date MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scans, edited exports and some downloads carry only EXIF DateTime (exiftool: IFD0:ModifyDate). The kamadak ingest path already takes it as the capture date, but the exiftool resolver didn't ask for it, so the date_utc drain resolved those rows to fs_time and then (rightly) refused to overwrite their tag-sourced legacy date. 147 prod rows sat on the legacy convention forever, re-running exiftool every tick. ModifyDate is now the lowest-priority tag, and only from IFD0 — XMP / QuickTime ModifyDate is an edit or mux time, not capture. Co-Authored-By: Claude Opus 5.5 --- src/date_resolver.rs | 65 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/src/date_resolver.rs b/src/date_resolver.rs index c72209a..0f46554 100644 --- a/src/date_resolver.rs +++ b/src/date_resolver.rs @@ -322,6 +322,13 @@ pub fn resolve_dates_batch( /// timestamps. `ContentCreateDate` is Apple's iOS-set tag; it often /// reflects local capture time on iPhone exports better than the others. /// +/// `ModifyDate` comes last and only from `IFD0` (see +/// [`MODIFY_DATE_GROUP`]): that's EXIF `DateTime`, which the kamadak +/// ingest path already takes as the capture date when nothing better +/// exists. Scans, edits and some downloads carry only that tag, and +/// without it here the drain resolved them to fs_time and then refused +/// to overwrite their tag-sourced legacy date — stuck forever. +/// /// Notably absent: `FileModifyDate` / `FileAccessDate` — those are /// filesystem-derived and the resolver covers them via the `fs_time` /// fallback. Letting exiftool pull them here would mask "no real EXIF @@ -333,8 +340,14 @@ const EXIFTOOL_DATE_TAGS: &[&str] = &[ "MediaCreateDate", "TrackCreateDate", "ContentCreateDate", + "ModifyDate", ]; +/// The only group whose `ModifyDate` counts as a date: `IFD0`, i.e. EXIF +/// `DateTime`. XMP / QuickTime `ModifyDate` record when an editor or +/// muxer last touched the file, which isn't a capture time. +const MODIFY_DATE_GROUP: &str = "IFD0"; + /// Offset + GPS evidence tags requested in the same exiftool pass. They /// feed the camera-offset cascade for naive wall-clock readings that come /// from photo-EXIF groups (ADR-0001). @@ -735,6 +748,9 @@ fn resolve_exiftool_payload( }); for cand in candidates { + if cand.tag == "ModifyDate" && cand.group != MODIFY_DATE_GROUP { + continue; + } let Some(reading) = parse_exiftool_date_string(&cand.value) else { continue; }; @@ -1063,6 +1079,55 @@ mod tests { assert_eq!(resolved.date_utc, Some(1_720_108_800)); } + #[test] + fn ifd0_modify_date_is_a_last_resort() { + // Scans / edits with only EXIF DateTime (exiftool: IFD0:ModifyDate) + // resolve from it as a camera wall clock, like the kamadak path. + let resolved = resolve_exiftool_payload( + &tags(&[ + ("IFD0", "ModifyDate", "2009:03:04 14:55:48"), + ("ExifIFD", "DateTimeOriginal", ""), + ]), + &ExiftoolEvidence { + offset_time: Some(-300), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(resolved.source, DateSource::Exiftool); + assert_eq!(resolved.date_utc, Some(1_236_196_548)); + assert_eq!(resolved.camera_offset_minutes, Some(-300)); + + // Any capture tag outranks it. + let resolved = resolve_exiftool_payload( + &tags(&[ + ("IFD0", "ModifyDate", "2022:11:08 15:18:20"), + ("ExifIFD", "CreateDate", "2022:11:01 09:00:00"), + ]), + &ExiftoolEvidence { + offset_time: Some(-240), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(resolved.date_utc, Some(1_667_307_600)); + } + + #[test] + fn non_exif_modify_date_is_ignored() { + // XMP / QuickTime ModifyDate is an edit or mux time, not capture. + for group in ["XMP-xmp", "QuickTime"] { + assert!( + resolve_exiftool_payload( + &tags(&[(group, "ModifyDate", "2024:01:01 00:00:00")]), + &ExiftoolEvidence::default(), + ) + .is_none(), + "{group}" + ); + } + } + #[test] fn zero_date_falls_through_to_next_tag() { // exiftool emits "0000:00:00 00:00:00" for missing-but-allocated