Skip to main content

smugmug_cli/dated/
date.rs

1//! The day a photo or video was taken, which decides its album.
2//!
3//! In order of preference:
4//! 1. EXIF `DateTimeOriginal` (or `DateTimeDigitized`): JPEG, HEIC, PNG,
5//!    TIFF and RAW files. Camera wall-clock time, used as is.
6//! 2. QuickTime/MP4 metadata: Apple's `com.apple.quicktime.creationdate`
7//!    (local time), else the movie header's creation time (UTC, shown in
8//!    the local time zone, i.e. `TZ`).
9//! 3. A date in the file name (`IMG_20140712_…`, `2014-07-12 …`,
10//!    `IMG-20140712-WA0001`, `FB_IMG_1405171391000`).
11//! 4. EXIF `DateTime` (when the file was last written, often by an editor or
12//!    scanner, so a weaker hint than the name).
13//! 5. The file's modification time.
14
15use 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/// Where a file's date came from.
22#[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    /// EXIF `DateTime`: when the file was last written.
31    #[serde(rename = "w")]
32    ExifModified,
33    #[serde(rename = "m")]
34    Modified,
35    /// A Live Photo video, dated by its photo.
36    #[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
53/// `path`'s capture date. `data` is the whole file when the caller already
54/// has it in memory (RAW files, which are read whole anyway); otherwise only
55/// the parts holding metadata are read.
56pub 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
76/// The date from metadata alone (EXIF or video), for dating a Live Photo
77/// video by its photo.
78pub 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
90/// A date that could be real: not before photography went digital-ish
91/// (scans can be older, but their EXIF date is the scan's), and not in the
92/// future (a camera with its clock unset or wrong).
93fn 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
104/// "YYYY:MM:DD HH:MM:SS" (EXIF); the time is ignored. Some software
105/// writes "-", "/" or "." between the date's parts instead of ":" (RapidRAW
106/// exports "2026-09-12 16:55:47"), which is accepted too.
107fn 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    // RAW containers kamadak-exif doesn't read (CR3) or that we already
120    // hold in memory go through the RAW reader.
121    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
160/// How much of a RAW file to read for its date when it isn't in memory
161/// already: its metadata sits near the start (CR3's movie header, the
162/// first IFDs of TIFF-based formats).
163const 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
189// ---------------------------------------------------------------------------
190// QuickTime / MP4
191
192/// Seconds from 1904-01-01 (QuickTime's epoch) to 1970-01-01.
193const QUICKTIME_EPOCH_OFFSET: i64 = 2_082_844_800;
194
195/// `moov` boxes bigger than this aren't metadata worth reading.
196const 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
208/// The date in a `moov` box's payload: Apple's local creation date if
209/// present, else the movie header's.
210fn 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        // Zero (1904) or 1970 means the camera didn't set it.
221        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
229/// Walk the file's top-level boxes (seeking past the media data) and return
230/// the payload of the first one of type `wanted`.
231fn 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
264/// Child boxes of a box payload: (type, payload).
265fn 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
290/// `moov/meta` (QuickTime metadata: `hdlr`, `keys`, `ilst`): the value of
291/// `com.apple.quicktime.creationdate`, e.g. "2014-07-12T14:23:11-0500".
292fn 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    // type indicator (4) + locale (4), then the UTF-8 value
313    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
318// ---------------------------------------------------------------------------
319// File names
320
321/// A date in a file name: eight digits (`20140712`) or year, month and day
322/// split by `-`, `_` or `.` (`2014-07-12`), not part of a longer number; or
323/// a Unix timestamp in milliseconds or seconds (`1405171391000`), as
324/// Facebook and some messengers name files.
325pub 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        // 20140712
345        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        // 2014-07-12, 2014_07_12, 2014.07.12
353        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        // 1405171391000 (ms) or 1405171391 (s)
366        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        // No date, or digits that aren't one
417        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        // Nonstandard separators some software writes
436        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    /// A minimal JPEG whose EXIF holds the given IFD0 DateTime and EXIF
448    /// DateTimeOriginal.
449    fn jpeg_with_dates(modified: Option<&str>, original: Option<&str>) -> Vec<u8> {
450        // Little-endian TIFF: IFD0 at 8 with DateTime and an EXIF pointer;
451        // EXIF IFD with DateTimeOriginal; values after.
452        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        // The fake RAW carries a DateTimeOriginal.
558        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]; // version 0, flags
570        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}