Merge pull request 'fix(dates): resolve IFD0 ModifyDate as a last-resort capture date' (#181) from fix/modifydate-fallback into master

Reviewed-on: #181
This commit was merged in pull request #181.
This commit is contained in:
cameron committed 2026-10-17 04:10:56 +00:00
commit a605ccd6f8
1 file changed
+65
+65
View File
@@ -322,6 +322,13 @@ pub fn resolve_dates_batch(
/// timestamps. `ContentCreateDate` is Apple's iOS-set tag; it often /// timestamps. `ContentCreateDate` is Apple's iOS-set tag; it often
/// reflects local capture time on iPhone exports better than the others. /// 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 /// Notably absent: `FileModifyDate` / `FileAccessDate` — those are
/// filesystem-derived and the resolver covers them via the `fs_time` /// filesystem-derived and the resolver covers them via the `fs_time`
/// fallback. Letting exiftool pull them here would mask "no real EXIF /// fallback. Letting exiftool pull them here would mask "no real EXIF
@@ -333,8 +340,14 @@ const EXIFTOOL_DATE_TAGS: &[&str] = &[
"MediaCreateDate", "MediaCreateDate",
"TrackCreateDate", "TrackCreateDate",
"ContentCreateDate", "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 /// Offset + GPS evidence tags requested in the same exiftool pass. They
/// feed the camera-offset cascade for naive wall-clock readings that come /// feed the camera-offset cascade for naive wall-clock readings that come
/// from photo-EXIF groups (ADR-0001). /// from photo-EXIF groups (ADR-0001).
@@ -735,6 +748,9 @@ fn resolve_exiftool_payload(
}); });
for cand in candidates { for cand in candidates {
if cand.tag == "ModifyDate" && cand.group != MODIFY_DATE_GROUP {
continue;
}
let Some(reading) = parse_exiftool_date_string(&cand.value) else { let Some(reading) = parse_exiftool_date_string(&cand.value) else {
continue; continue;
}; };
@@ -1063,6 +1079,55 @@ mod tests {
assert_eq!(resolved.date_utc, Some(1_720_108_800)); 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] #[test]
fn zero_date_falls_through_to_next_tag() { fn zero_date_falls_through_to_next_tag() {
// exiftool emits "0000:00:00 00:00:00" for missing-but-allocated // exiftool emits "0000:00:00 00:00:00" for missing-but-allocated