1use chrono::{DateTime, Datelike, Local, NaiveDate, TimeZone, Utc};
16use serde::{Deserialize, Serialize};
17use std::fs::File;
18use std::io::{BufReader, Read, Seek, SeekFrom};
19use std::path::Path;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
23pub enum DateSource {
24 #[serde(rename = "e")]
25 Exif,
26 #[serde(rename = "v")]
27 Video,
28 #[serde(rename = "n")]
29 FileName,
30 #[serde(rename = "w")]
32 ExifModified,
33 #[serde(rename = "m")]
34 Modified,
35 #[serde(rename = "l")]
37 LivePhoto,
38}
39
40impl DateSource {
41 pub fn describe(self) -> &'static str {
42 match self {
43 DateSource::Exif => "photo metadata",
44 DateSource::Video => "video metadata",
45 DateSource::FileName => "file name",
46 DateSource::ExifModified => "photo's last-edited date",
47 DateSource::Modified => "file modified time",
48 DateSource::LivePhoto => "Live Photo's photo",
49 }
50 }
51}
52
53pub fn capture_date(path: &Path, data: Option<&[u8]>, mtime_ns: i64) -> (NaiveDate, DateSource) {
57 let exif = exif_dates(path, data);
58 if let Some(date) = exif.original {
59 return (date, DateSource::Exif);
60 }
61 if let Some(date) = video_date(path) {
62 return (date, DateSource::Video);
63 }
64 if let Some(date) = path
65 .file_name()
66 .and_then(|n| file_name_date(&n.to_string_lossy()))
67 {
68 return (date, DateSource::FileName);
69 }
70 if let Some(date) = exif.modified {
71 return (date, DateSource::ExifModified);
72 }
73 (modified_date(mtime_ns), DateSource::Modified)
74}
75
76pub fn metadata_date(path: &Path) -> Option<NaiveDate> {
79 exif_dates(path, None).original.or_else(|| video_date(path))
80}
81
82fn modified_date(mtime_ns: i64) -> NaiveDate {
83 Local
84 .timestamp_opt(mtime_ns.div_euclid(1_000_000_000), 0)
85 .single()
86 .map(|t| t.date_naive())
87 .unwrap_or_else(|| Local::now().date_naive())
88}
89
90fn plausible(date: NaiveDate, earliest_year: i32) -> Option<NaiveDate> {
94 let latest = Local::now().date_naive() + chrono::Duration::days(1);
95 (date.year() >= earliest_year && date <= latest).then_some(date)
96}
97
98#[derive(Default)]
99struct ExifDates {
100 original: Option<NaiveDate>,
101 modified: Option<NaiveDate>,
102}
103
104fn parse_exif_datetime(bytes: &[u8]) -> Option<NaiveDate> {
108 let s = std::str::from_utf8(bytes.get(..10)?).ok()?;
109 let sep = s.as_bytes()[4];
110 if !matches!(sep, b':' | b'-' | b'/' | b'.') || s.as_bytes()[7] != sep {
111 return None;
112 }
113 let normalized = s.replace(sep as char, ":");
114 let date = NaiveDate::parse_from_str(&normalized, "%Y:%m:%d").ok()?;
115 plausible(date, 1900)
116}
117
118fn exif_dates(path: &Path, data: Option<&[u8]>) -> ExifDates {
119 if crate::scanner::is_raw_file(path) {
122 let owned;
123 let data = match data {
124 Some(data) => data,
125 None => {
126 let Some(head) = read_head(path, RAW_METADATA_HEAD) else {
127 return ExifDates::default();
128 };
129 owned = head;
130 &owned
131 }
132 };
133 return raw_exif_dates(data).unwrap_or_default();
134 }
135
136 let parsed = match data {
137 Some(data) => exif::Reader::new().read_from_container(&mut std::io::Cursor::new(data)),
138 None => match File::open(path) {
139 Ok(file) => exif::Reader::new().read_from_container(&mut BufReader::new(file)),
140 Err(_) => return ExifDates::default(),
141 },
142 };
143 let Ok(parsed) = parsed else {
144 return ExifDates::default();
145 };
146 let ascii = |tag: exif::Tag| -> Option<NaiveDate> {
147 let field = parsed.get_field(tag, exif::In::PRIMARY)?;
148 match &field.value {
149 exif::Value::Ascii(values) => parse_exif_datetime(values.first()?),
150 _ => None,
151 }
152 };
153 ExifDates {
154 original: ascii(exif::Tag::DateTimeOriginal)
155 .or_else(|| ascii(exif::Tag::DateTimeDigitized)),
156 modified: ascii(exif::Tag::DateTime),
157 }
158}
159
160const RAW_METADATA_HEAD: u64 = 4 * 1024 * 1024;
164
165fn read_head(path: &Path, len: u64) -> Option<Vec<u8>> {
166 let mut data = Vec::new();
167 File::open(path)
168 .ok()?
169 .take(len)
170 .read_to_end(&mut data)
171 .ok()?;
172 Some(data)
173}
174
175fn raw_exif_dates(data: &[u8]) -> Option<ExifDates> {
176 let tags = crate::raw::exif::read_raw(data)?;
177 let find = |entries: &[crate::raw::exif::Entry], tag: u16| {
178 entries
179 .iter()
180 .find(|e| e.tag == tag)
181 .and_then(|e| parse_exif_datetime(&e.value))
182 };
183 Some(ExifDates {
184 original: find(&tags.exif, 0x9003).or_else(|| find(&tags.exif, 0x9004)),
185 modified: find(&tags.ifd0, 0x0132),
186 })
187}
188
189const QUICKTIME_EPOCH_OFFSET: i64 = 2_082_844_800;
194
195const MAX_MOOV: u64 = 64 * 1024 * 1024;
197
198fn video_date(path: &Path) -> Option<NaiveDate> {
199 let ext = path.extension()?.to_string_lossy().to_lowercase();
200 if !matches!(ext.as_str(), "mov" | "mp4" | "m4v" | "3gp") {
201 return None;
202 }
203 let mut file = File::open(path).ok()?;
204 let moov = read_top_level_box(&mut file, b"moov")?;
205 quicktime_date(&moov)
206}
207
208fn quicktime_date(moov: &[u8]) -> Option<NaiveDate> {
211 apple_creation_date(moov).or_else(|| {
212 let mvhd = find_box(moov, b"mvhd")?;
213 let version = *mvhd.first()?;
214 let seconds = if version == 1 {
215 u64::from_be_bytes(mvhd.get(4..12)?.try_into().ok()?) as i64
216 } else {
217 u32::from_be_bytes(mvhd.get(4..8)?.try_into().ok()?) as i64
218 };
219 let unix = seconds - QUICKTIME_EPOCH_OFFSET;
220 if unix < 365 * 24 * 3600 {
222 return None;
223 }
224 let utc: DateTime<Utc> = Utc.timestamp_opt(unix, 0).single()?;
225 plausible(utc.with_timezone(&Local).date_naive(), 1971)
226 })
227}
228
229fn read_top_level_box<R: Read + Seek>(reader: &mut R, wanted: &[u8; 4]) -> Option<Vec<u8>> {
232 let len = reader.seek(SeekFrom::End(0)).ok()?;
233 let mut pos = 0u64;
234 while pos + 8 <= len {
235 reader.seek(SeekFrom::Start(pos)).ok()?;
236 let mut header = [0u8; 16];
237 reader.read_exact(&mut header[..8]).ok()?;
238 let mut size = u32::from_be_bytes(header[..4].try_into().ok()?) as u64;
239 let mut header_len = 8;
240 if size == 1 {
241 reader.read_exact(&mut header[8..16]).ok()?;
242 size = u64::from_be_bytes(header[8..16].try_into().ok()?);
243 header_len = 16;
244 } else if size == 0 {
245 size = len - pos;
246 }
247 if size < header_len {
248 return None;
249 }
250 if &header[4..8] == wanted {
251 let payload = size - header_len;
252 if payload > MAX_MOOV {
253 return None;
254 }
255 let mut buf = vec![0u8; payload as usize];
256 reader.read_exact(&mut buf).ok()?;
257 return Some(buf);
258 }
259 pos = pos.checked_add(size)?;
260 }
261 None
262}
263
264fn boxes(data: &[u8]) -> impl Iterator<Item = (&[u8], &[u8])> {
266 let mut pos = 0usize;
267 std::iter::from_fn(move || {
268 let header = data.get(pos..pos + 8)?;
269 let size = u32::from_be_bytes(header[..4].try_into().ok()?) as usize;
270 let (start, end) = match size {
271 0 => (pos + 8, data.len()),
272 1 => {
273 let large = u64::from_be_bytes(data.get(pos + 8..pos + 16)?.try_into().ok()?);
274 (pos + 16, pos.checked_add(large as usize)?)
275 }
276 n if n < 8 => return None,
277 n => (pos + 8, pos.checked_add(n)?),
278 };
279 let payload = data.get(start..end)?;
280 let typ = &header[4..8];
281 pos = end;
282 Some((typ, payload))
283 })
284}
285
286fn find_box<'a>(data: &'a [u8], typ: &[u8; 4]) -> Option<&'a [u8]> {
287 boxes(data).find(|(t, _)| t == typ).map(|(_, p)| p)
288}
289
290fn apple_creation_date(moov: &[u8]) -> Option<NaiveDate> {
293 let meta = find_box(moov, b"meta")?;
294 let keys = find_box(meta, b"keys")?;
295 let count = u32::from_be_bytes(keys.get(4..8)?.try_into().ok()?);
296 let mut pos = 8usize;
297 let mut index = None;
298 for i in 1..=count {
299 let size = u32::from_be_bytes(keys.get(pos..pos + 4)?.try_into().ok()?) as usize;
300 let name = keys.get(pos + 8..pos.checked_add(size)?)?;
301 if name == b"com.apple.quicktime.creationdate" {
302 index = Some(i);
303 break;
304 }
305 pos += size.max(8);
306 }
307 let index = index?;
308 let ilst = find_box(meta, b"ilst")?;
309 let (_, item) =
310 boxes(ilst).find(|(t, _)| u32::from_be_bytes((*t).try_into().unwrap()) == index)?;
311 let data = find_box(item, b"data")?;
312 let value = std::str::from_utf8(data.get(8..)?).ok()?;
314 let date = NaiveDate::parse_from_str(value.get(..10)?, "%Y-%m-%d").ok()?;
315 plausible(date, 1971)
316}
317
318pub fn file_name_date(name: &str) -> Option<NaiveDate> {
326 let bytes = name.as_bytes();
327 let digit_at = |i: usize| bytes.get(i).is_some_and(u8::is_ascii_digit);
328 let number = |from: usize, len: usize| -> Option<u32> {
329 let s = name.get(from..from + len)?;
330 s.bytes()
331 .all(|b| b.is_ascii_digit())
332 .then(|| s.parse().ok())?
333 };
334 let check = |y: u32, m: u32, d: u32| {
335 NaiveDate::from_ymd_opt(y as i32, m, d).and_then(|date| plausible(date, 1990))
336 };
337
338 for start in 0..bytes.len() {
339 if !digit_at(start) || (start > 0 && digit_at(start - 1)) {
340 continue;
341 }
342 let run = (start..bytes.len()).take_while(|&i| digit_at(i)).count();
343
344 if run == 8
346 && let (Some(y), Some(m), Some(d)) =
347 (number(start, 4), number(start + 4, 2), number(start + 6, 2))
348 && let Some(date) = check(y, m, d)
349 {
350 return Some(date);
351 }
352 if run == 4 {
354 let sep = bytes.get(start + 4).copied();
355 if matches!(sep, Some(b'-' | b'_' | b'.'))
356 && bytes.get(start + 7).copied() == sep
357 && !digit_at(start + 10)
358 && let (Some(y), Some(m), Some(d)) =
359 (number(start, 4), number(start + 5, 2), number(start + 8, 2))
360 && let Some(date) = check(y, m, d)
361 {
362 return Some(date);
363 }
364 }
365 if run == 13 || run == 10 {
367 let digits: i64 = name.get(start..start + run)?.parse().ok()?;
368 let secs = if run == 13 { digits / 1000 } else { digits };
369 if let Some(t) = Local.timestamp_opt(secs, 0).single()
370 && let Some(date) = plausible(t.date_naive(), 2004)
371 {
372 return Some(date);
373 }
374 }
375 }
376 None
377}
378
379#[cfg(test)]
380mod tests {
381 use super::*;
382 use std::io::Cursor;
383 use tempfile::TempDir;
384
385 fn d(y: i32, m: u32, day: u32) -> NaiveDate {
386 NaiveDate::from_ymd_opt(y, m, day).unwrap()
387 }
388
389 #[test]
390 fn file_names() {
391 assert_eq!(
392 file_name_date("IMG_20140712_142311.jpg"),
393 Some(d(2014, 7, 12))
394 );
395 assert_eq!(
396 file_name_date("PXL_20230101_101010123.jpg"),
397 Some(d(2023, 1, 1))
398 );
399 assert_eq!(
400 file_name_date("IMG-20140712-WA0001.jpg"),
401 Some(d(2014, 7, 12))
402 );
403 assert_eq!(
404 file_name_date("2014-07-12 14.23.11.jpg"),
405 Some(d(2014, 7, 12))
406 );
407 assert_eq!(
408 file_name_date("Screenshot_2019_03_04.png"),
409 Some(d(2019, 3, 4))
410 );
411 assert_eq!(
412 file_name_date("VID_20160101_000000.mp4"),
413 Some(d(2016, 1, 1))
414 );
415 assert!(file_name_date("FB_IMG_1405171391000.jpg").is_some());
416 assert_eq!(file_name_date("DSC01234.JPG"), None);
418 assert_eq!(file_name_date("IMG_1234.HEIC"), None);
419 assert_eq!(file_name_date("123456789012.jpg"), None);
420 assert_eq!(file_name_date("20141399.jpg"), None);
421 assert_eq!(file_name_date("19850101.jpg"), None);
422 assert_eq!(file_name_date("2014-07-123.jpg"), None);
423 assert_eq!(file_name_date("2014-07_12.jpg"), None);
424 }
425
426 #[test]
427 fn exif_datetime_strings() {
428 assert_eq!(
429 parse_exif_datetime(b"2014:07:12 14:23:11\0"),
430 Some(d(2014, 7, 12))
431 );
432 assert_eq!(parse_exif_datetime(b"0000:00:00 00:00:00"), None);
433 assert_eq!(parse_exif_datetime(b" : : : : "), None);
434 assert_eq!(parse_exif_datetime(b"2999:01:01 00:00:00"), None);
435 assert_eq!(
437 parse_exif_datetime(b"2026-09-12 16:55:47"),
438 Some(d(2026, 9, 12))
439 );
440 assert_eq!(
441 parse_exif_datetime(b"2026/09/12 16:55:47"),
442 Some(d(2026, 9, 12))
443 );
444 assert_eq!(parse_exif_datetime(b"2026:09-12 16:55:47"), None);
445 }
446
447 fn jpeg_with_dates(modified: Option<&str>, original: Option<&str>) -> Vec<u8> {
450 let mut tiff = Vec::new();
453 tiff.extend_from_slice(b"II*\0");
454 tiff.extend_from_slice(&8u32.to_le_bytes());
455 let ifd0_entries = 1 + modified.is_some() as u16;
456 let ifd0_len = 2 + 12 * ifd0_entries as u32 + 4;
457 let exif_ifd_at = 8 + ifd0_len;
458 let exif_len = 2 + 12 * original.is_some() as u32 + 4;
459 let mut values_at = exif_ifd_at + exif_len;
460 let mut values = Vec::new();
461
462 tiff.extend_from_slice(&ifd0_entries.to_le_bytes());
463 if let Some(m) = modified {
464 tiff.extend_from_slice(&0x0132u16.to_le_bytes());
465 tiff.extend_from_slice(&2u16.to_le_bytes());
466 tiff.extend_from_slice(&20u32.to_le_bytes());
467 tiff.extend_from_slice(&values_at.to_le_bytes());
468 values.extend_from_slice(m.as_bytes());
469 values.push(0);
470 values_at += 20;
471 }
472 tiff.extend_from_slice(&0x8769u16.to_le_bytes());
473 tiff.extend_from_slice(&4u16.to_le_bytes());
474 tiff.extend_from_slice(&1u32.to_le_bytes());
475 tiff.extend_from_slice(&exif_ifd_at.to_le_bytes());
476 tiff.extend_from_slice(&0u32.to_le_bytes());
477
478 tiff.extend_from_slice(&(original.is_some() as u16).to_le_bytes());
479 if let Some(o) = original {
480 tiff.extend_from_slice(&0x9003u16.to_le_bytes());
481 tiff.extend_from_slice(&2u16.to_le_bytes());
482 tiff.extend_from_slice(&20u32.to_le_bytes());
483 tiff.extend_from_slice(&values_at.to_le_bytes());
484 values.extend_from_slice(o.as_bytes());
485 values.push(0);
486 }
487 tiff.extend_from_slice(&0u32.to_le_bytes());
488 tiff.extend_from_slice(&values);
489
490 let mut app1 = b"Exif\0\0".to_vec();
491 app1.extend_from_slice(&tiff);
492 let mut jpeg = vec![0xFF, 0xD8, 0xFF, 0xE1];
493 jpeg.extend_from_slice(&((app1.len() + 2) as u16).to_be_bytes());
494 jpeg.extend_from_slice(&app1);
495 jpeg.extend_from_slice(&[0xFF, 0xD9]);
496 jpeg
497 }
498
499 fn write(dir: &TempDir, name: &str, data: &[u8]) -> std::path::PathBuf {
500 let path = dir.path().join(name);
501 std::fs::write(&path, data).unwrap();
502 path
503 }
504
505 #[test]
506 fn exif_original_wins() {
507 let dir = TempDir::new().unwrap();
508 let path = write(
509 &dir,
510 "IMG_20200101_000000.jpg",
511 &jpeg_with_dates(Some("2021:05:05 10:00:00"), Some("2014:07:12 14:23:11")),
512 );
513 assert_eq!(
514 capture_date(&path, None, 0),
515 (d(2014, 7, 12), DateSource::Exif)
516 );
517 assert_eq!(metadata_date(&path), Some(d(2014, 7, 12)));
518 }
519
520 #[test]
521 fn file_name_beats_exif_modified_which_beats_mtime() {
522 let dir = TempDir::new().unwrap();
523 let jpeg = jpeg_with_dates(Some("2021:05:05 10:00:00"), None);
524 let named = write(&dir, "IMG_20200101_000000.jpg", &jpeg);
525 assert_eq!(
526 capture_date(&named, None, 0),
527 (d(2020, 1, 1), DateSource::FileName)
528 );
529 let unnamed = write(&dir, "scan.jpg", &jpeg);
530 assert_eq!(
531 capture_date(&unnamed, None, 0),
532 (d(2021, 5, 5), DateSource::ExifModified)
533 );
534 let bare = write(&dir, "bare.png", b"not really a png");
535 let mtime = Local
536 .with_ymd_and_hms(2015, 3, 4, 12, 0, 0)
537 .unwrap()
538 .timestamp()
539 * 1_000_000_000;
540 assert_eq!(
541 capture_date(&bare, None, mtime),
542 (d(2015, 3, 4), DateSource::Modified)
543 );
544 }
545
546 #[test]
547 fn exif_from_memory() {
548 let jpeg = jpeg_with_dates(None, Some("2014:07:12 14:23:11"));
549 let (date, source) = capture_date(Path::new("x.jpg"), Some(&jpeg), 0);
550 assert_eq!((date, source), (d(2014, 7, 12), DateSource::Exif));
551 }
552
553 #[test]
554 fn raw_exif() {
555 let raw = crate::raw::exif::tests::fake_tiff_raw_be();
556 let (_, source) = capture_date(Path::new("x.cr2"), Some(&raw), 0);
557 assert_eq!(source, DateSource::Exif);
559 }
560
561 fn mp4_box(typ: &[u8; 4], payload: &[u8]) -> Vec<u8> {
562 let mut b = ((payload.len() + 8) as u32).to_be_bytes().to_vec();
563 b.extend_from_slice(typ);
564 b.extend_from_slice(payload);
565 b
566 }
567
568 fn mvhd(unix: i64) -> Vec<u8> {
569 let mut p = vec![0u8; 4]; p.extend_from_slice(&((unix + QUICKTIME_EPOCH_OFFSET) as u32).to_be_bytes());
571 p.extend_from_slice(&[0u8; 92]);
572 mp4_box(b"mvhd", &p)
573 }
574
575 fn apple_meta(value: &str) -> Vec<u8> {
576 let mut keys = vec![0u8; 4];
577 keys.extend_from_slice(&2u32.to_be_bytes());
578 for name in [
579 "com.apple.quicktime.make",
580 "com.apple.quicktime.creationdate",
581 ] {
582 keys.extend_from_slice(&((name.len() + 8) as u32).to_be_bytes());
583 keys.extend_from_slice(b"mdta");
584 keys.extend_from_slice(name.as_bytes());
585 }
586 let mut data = 1u32.to_be_bytes().to_vec();
587 data.extend_from_slice(&0u32.to_be_bytes());
588 data.extend_from_slice(value.as_bytes());
589 let item = mp4_box(&2u32.to_be_bytes(), &mp4_box(b"data", &data));
590 let mut meta = mp4_box(b"hdlr", &[0u8; 24]);
591 meta.extend(mp4_box(b"keys", &keys));
592 meta.extend(mp4_box(b"ilst", &item));
593 mp4_box(b"meta", &meta)
594 }
595
596 fn movie(moov_children: &[Vec<u8>]) -> Vec<u8> {
597 let mut file = mp4_box(b"ftyp", b"qt \0\0\0\0qt ");
598 file.extend(mp4_box(b"mdat", &[0u8; 1000]));
599 file.extend(mp4_box(b"moov", &moov_children.concat()));
600 file
601 }
602
603 #[test]
604 fn video_prefers_apple_local_date() {
605 let noon_utc = Utc
606 .with_ymd_and_hms(2014, 7, 12, 12, 0, 0)
607 .unwrap()
608 .timestamp();
609 let file = movie(&[mvhd(noon_utc), apple_meta("2014-07-11T23:30:00-0500")]);
610 let moov = read_top_level_box(&mut Cursor::new(&file), b"moov").unwrap();
611 assert_eq!(quicktime_date(&moov), Some(d(2014, 7, 11)));
612 }
613
614 #[test]
615 fn video_falls_back_to_movie_header() {
616 let noon_utc = Utc
617 .with_ymd_and_hms(2014, 7, 12, 12, 0, 0)
618 .unwrap()
619 .timestamp();
620 let file = movie(&[mvhd(noon_utc)]);
621 let moov = read_top_level_box(&mut Cursor::new(&file), b"moov").unwrap();
622 let expected = Utc
623 .timestamp_opt(noon_utc, 0)
624 .unwrap()
625 .with_timezone(&Local)
626 .date_naive();
627 assert_eq!(quicktime_date(&moov), Some(expected));
628
629 let dir = TempDir::new().unwrap();
630 let path = write(&dir, "clip.mov", &file);
631 assert_eq!(capture_date(&path, None, 0), (expected, DateSource::Video));
632 }
633
634 #[test]
635 fn unset_video_dates_are_ignored() {
636 let file = movie(&[mvhd(-QUICKTIME_EPOCH_OFFSET)]);
637 let moov = read_top_level_box(&mut Cursor::new(&file), b"moov").unwrap();
638 assert_eq!(quicktime_date(&moov), None);
639 }
640
641 #[test]
642 fn truncated_movies_dont_panic() {
643 let file = movie(&[mvhd(1_400_000_000)]);
644 for len in 0..file.len() {
645 let _ = read_top_level_box(&mut Cursor::new(&file[..len]), b"moov")
646 .map(|m| quicktime_date(&m));
647 }
648 }
649}