1use std::path::Path;
7
8use chrono::{DateTime, Datelike, NaiveDate, NaiveDateTime, TimeZone, Utc};
9use exif::{In, Reader as ExifReader, Tag, Value};
10use lazy_static::lazy_static;
11use regex::Regex;
12
13#[derive(Debug, Clone, Default)]
15pub struct ImageMetadata {
16 pub date_taken: Option<DateTime<Utc>>,
18 pub date_taken_source: Option<String>, pub had_exif_date_field: bool,
21
22 pub gps_latitude: Option<f64>,
24 pub gps_longitude: Option<f64>,
25
26 pub camera_make: Option<String>,
28 pub camera_model: Option<String>,
29
30 pub iso: Option<i32>,
32 pub aperture: Option<String>, pub shutter_speed: Option<String>, pub focal_length: Option<String>, pub lens_model: Option<String>, pub flash: Option<String>, pub gps_altitude: Option<f64>, pub width: Option<u32>,
41 pub height: Option<u32>,
42 pub orientation: Option<u16>,
43}
44
45pub struct ExifExtractor;
47
48impl ExifExtractor {
49 pub fn extract<P: AsRef<Path>>(path: P) -> ImageMetadata {
63 let path = path.as_ref();
64 let mut metadata = ImageMetadata::default();
65 let mut datetime_candidate: Option<DateTime<Utc>> = None;
66
67 if let Some((exif_data, candidate)) = Self::extract_exif(path) {
69 metadata = exif_data;
70 datetime_candidate = candidate;
71 }
72
73 if metadata.date_taken.is_none() {
76 if let Some(date) = Self::parse_date_from_filename(path) {
77 metadata.date_taken = Some(date);
78 metadata.date_taken_source = Some("filename".to_string());
79 }
80 }
81
82 if metadata.date_taken.is_none() {
89 if let Some(candidate) = datetime_candidate {
90 let mtime = Self::get_file_mtime(path);
91 let stale = match mtime {
92 Some(m) => (candidate.year() - m.year()).abs() >= 2,
93 None => false,
94 };
95 if stale {
96 if let Some(m) = mtime {
97 metadata.date_taken = Some(m);
98 metadata.date_taken_source = Some("mtime".to_string());
99 } else {
100 metadata.date_taken = Some(candidate);
101 metadata.date_taken_source = Some("file_meta".to_string());
102 }
103 } else {
104 metadata.date_taken = Some(candidate);
105 metadata.date_taken_source = Some("file_meta".to_string());
106 }
107 }
108 }
109
110 if metadata.date_taken.is_none() {
112 if let Some(date) = Self::get_file_mtime(path) {
113 metadata.date_taken = Some(date);
114 metadata.date_taken_source = Some("mtime".to_string());
115 }
116 }
117
118 if metadata.width.is_none() || metadata.height.is_none() {
120 if let Some((w, h)) = Self::get_image_dimensions(path) {
121 metadata.width = Some(w);
122 metadata.height = Some(h);
123 }
124 }
125
126 metadata
127 }
128
129 fn extract_exif<P: AsRef<Path>>(path: P) -> Option<(ImageMetadata, Option<DateTime<Utc>>)> {
134 let file = std::fs::File::open(path.as_ref()).ok()?;
135 let mut bufreader = std::io::BufReader::new(&file);
136 let exif = ExifReader::new().read_from_container(&mut bufreader).ok()?;
137
138 let mut metadata = ImageMetadata::default();
139 let mut datetime_candidate: Option<DateTime<Utc>> = None;
140
141 if let Some(field) = exif.get_field(Tag::DateTimeOriginal, In::PRIMARY) {
143 metadata.had_exif_date_field = true;
144 let date_str: String = field.display_value().to_string();
145 if let Some(date) = Self::parse_exif_date(&date_str) {
146 metadata.date_taken = Some(date);
147 metadata.date_taken_source = Some("exif".to_string());
148 }
149 }
150
151 if metadata.date_taken.is_none() {
154 if let Some(field) = exif.get_field(Tag::DateTimeDigitized, In::PRIMARY) {
155 metadata.had_exif_date_field = true;
156 let date_str: String = field.display_value().to_string();
157 if let Some(date) = Self::parse_exif_date(&date_str) {
158 metadata.date_taken = Some(date);
159 metadata.date_taken_source = Some("exif_digitized".to_string());
160 }
161 }
162 }
163
164 if let Some(field) = exif.get_field(Tag::DateTime, In::PRIMARY) {
169 let date_str: String = field.display_value().to_string();
170 if let Some(date) = Self::parse_exif_date(&date_str) {
171 datetime_candidate = Some(date);
172 }
173 }
174
175 metadata.gps_latitude =
177 Self::extract_gps_coord(&exif, Tag::GPSLatitude, Tag::GPSLatitudeRef);
178 metadata.gps_longitude =
179 Self::extract_gps_coord(&exif, Tag::GPSLongitude, Tag::GPSLongitudeRef);
180
181 if let Some(field) = exif.get_field(Tag::Make, In::PRIMARY) {
183 metadata.camera_make = Self::clean_exif_text(field);
184 }
185
186 if let Some(field) = exif.get_field(Tag::Model, In::PRIMARY) {
188 metadata.camera_model = Self::clean_exif_text(field);
189 }
190
191 if let Some(field) = exif.get_field(Tag::PixelXDimension, In::PRIMARY) {
193 if let Value::Long(ref vec) = field.value {
194 if let Some(&w) = vec.first() {
195 metadata.width = Some(w);
196 }
197 }
198 }
199 if let Some(field) = exif.get_field(Tag::PixelYDimension, In::PRIMARY) {
200 if let Value::Long(ref vec) = field.value {
201 if let Some(&h) = vec.first() {
202 metadata.height = Some(h);
203 }
204 }
205 }
206
207 if let Some(field) = exif.get_field(Tag::Orientation, In::PRIMARY) {
209 if let Value::Short(ref vec) = field.value {
210 if let Some(&o) = vec.first() {
211 metadata.orientation = Some(o);
212 }
213 }
214 }
215
216 if let Some(field) = exif.get_field(Tag::PhotographicSensitivity, In::PRIMARY) {
218 if let Value::Short(ref vec) = field.value {
219 if let Some(&iso) = vec.first() {
220 metadata.iso = Some(iso as i32);
221 }
222 } else if let Value::Long(ref vec) = field.value {
223 if let Some(&iso) = vec.first() {
224 metadata.iso = Some(iso as i32);
225 }
226 }
227 }
228
229 if let Some(field) = exif.get_field(Tag::FNumber, In::PRIMARY) {
231 if let Value::Rational(ref vec) = field.value {
232 if let Some(r) = vec.first() {
233 let f = r.to_f64();
234 if f > 0.0 {
235 metadata.aperture = Some(format!("f/{:.1}", f));
236 }
237 }
238 }
239 }
240
241 if let Some(field) = exif.get_field(Tag::ExposureTime, In::PRIMARY) {
243 if let Value::Rational(ref vec) = field.value {
244 if let Some(r) = vec.first() {
245 if r.num > 0 && r.denom > 0 {
246 if r.num >= r.denom {
247 metadata.shutter_speed = Some(format!("{}s", r.to_f64()));
248 } else {
249 metadata.shutter_speed = Some(format!("{}/{}", r.num, r.denom));
250 }
251 }
252 }
253 }
254 }
255
256 if let Some(field) = exif.get_field(Tag::FocalLength, In::PRIMARY) {
258 if let Value::Rational(ref vec) = field.value {
259 if let Some(r) = vec.first() {
260 let fl = r.to_f64();
261 if fl > 0.0 {
262 metadata.focal_length = Some(format!("{:.0}mm", fl));
263 }
264 }
265 }
266 }
267
268 if let Some(field) = exif.get_field(Tag::LensModel, In::PRIMARY) {
270 metadata.lens_model = Self::clean_exif_text(field);
271 }
272
273 if let Some(field) = exif.get_field(Tag::Flash, In::PRIMARY) {
275 if let Value::Short(ref vec) = field.value {
276 if let Some(&flash_val) = vec.first() {
277 metadata.flash = Some(if flash_val & 1 == 1 {
278 "Fired".to_string()
279 } else {
280 "Off".to_string()
281 });
282 }
283 }
284 }
285
286 if let Some(field) = exif.get_field(Tag::GPSAltitude, In::PRIMARY) {
288 if let Value::Rational(ref vec) = field.value {
289 if let Some(r) = vec.first() {
290 let mut alt = r.to_f64();
291 if let Some(ref_field) = exif.get_field(Tag::GPSAltitudeRef, In::PRIMARY) {
293 if let Value::Byte(ref bytes) = ref_field.value {
294 if bytes.first() == Some(&1) {
295 alt = -alt;
296 }
297 }
298 }
299 metadata.gps_altitude = Some(alt);
300 }
301 }
302 }
303
304 Some((metadata, datetime_candidate))
305 }
306
307 fn clean_exif_text(field: &exif::Field) -> Option<String> {
308 if let Value::Ascii(chunks) = &field.value {
309 for chunk in chunks {
310 let s = String::from_utf8_lossy(chunk);
311 if let Some(clean) = Self::clean_text_candidate(&s) {
312 return Some(clean);
313 }
314 }
315 }
316
317 Self::clean_text_candidate(&field.display_value().to_string())
318 }
319
320 fn clean_text_candidate(raw: &str) -> Option<String> {
321 let mut best: Option<String> = None;
322 let parts: Vec<&str> = if raw.contains("\",") || raw.contains(", \"") {
323 raw.split(',').collect()
324 } else {
325 vec![raw]
326 };
327 for part in parts {
328 let clean = part
329 .replace('\0', "")
330 .trim()
331 .trim_matches('"')
332 .trim()
333 .to_string();
334 if clean.is_empty() {
335 continue;
336 }
337 best = Some(clean);
338 break;
339 }
340 best
341 }
342
343 fn extract_gps_coord(exif: &exif::Exif, coord_tag: Tag, ref_tag: Tag) -> Option<f64> {
345 let field = exif.get_field(coord_tag, In::PRIMARY)?;
346 let ref_field = exif.get_field(ref_tag, In::PRIMARY)?;
347
348 let coord = match &field.value {
350 Value::Rational(rationals) if rationals.len() >= 3 => {
351 let degrees = rationals[0].to_f64();
352 let minutes = rationals[1].to_f64();
353 let seconds = rationals[2].to_f64();
354 degrees + minutes / 60.0 + seconds / 3600.0
355 }
356 _ => return None,
357 };
358
359 let ref_str: String = ref_field.display_value().to_string();
361 let multiplier = if ref_str.contains('S') || ref_str.contains('W') {
362 -1.0
363 } else {
364 1.0
365 };
366
367 Some(coord * multiplier)
368 }
369
370 pub(crate) fn parse_exif_date(date_str: &str) -> Option<DateTime<Utc>> {
379 let mut clean = date_str
380 .trim()
381 .trim_matches('"')
382 .replace('\0', "")
383 .trim()
384 .to_string();
385
386 if let Some(stripped) = strip_tz_suffix(&clean) {
389 clean = stripped;
390 }
391
392 let formats = [
393 "%Y:%m:%d %H:%M:%S",
394 "%Y:%m:%d %H:%M:%S%.f",
395 "%Y:%m:%d %H:%M",
396 "%Y-%m-%d %H:%M:%S",
397 "%Y-%m-%d %H:%M:%S%.f",
398 "%Y-%m-%dT%H:%M:%S",
399 "%Y-%m-%dT%H:%M:%S%.f",
400 ];
401 for fmt in formats {
402 if let Ok(parsed) = NaiveDateTime::parse_from_str(&clean, fmt) {
403 return Self::plausible_datetime(parsed).map(|d| Utc.from_utc_datetime(&d));
404 }
405 }
406
407 if clean.len() >= 10 {
409 let mut normalized = clean.clone();
410 let bytes = normalized.as_bytes().to_vec();
411 if bytes.get(4) == Some(&b'-') && bytes.get(7) == Some(&b'-') {
412 normalized.replace_range(4..5, ":");
413 normalized.replace_range(7..8, ":");
414 for fmt in formats {
415 if let Ok(parsed) = NaiveDateTime::parse_from_str(&normalized, fmt) {
416 return Self::plausible_datetime(parsed).map(|d| Utc.from_utc_datetime(&d));
417 }
418 }
419 }
420 }
421
422 for fmt in ["%Y:%m:%d", "%Y-%m-%d"] {
423 if let Ok(parsed) = NaiveDate::parse_from_str(&clean, fmt) {
424 let datetime = parsed.and_hms_opt(0, 0, 0)?;
425 return Self::plausible_datetime(datetime).map(|d| Utc.from_utc_datetime(&d));
426 }
427 }
428
429 None
430 }
431
432 pub(crate) fn plausible_datetime(dt: NaiveDateTime) -> Option<NaiveDateTime> {
433 let year = dt.year();
434 let max_year = Utc::now().year() + 1;
435 if (1900..=max_year).contains(&year) {
436 Some(dt)
437 } else {
438 None
439 }
440 }
441
442 pub fn parse_date_from_filename<P: AsRef<Path>>(path: P) -> Option<DateTime<Utc>> {
444 lazy_static! {
445 static ref PATTERNS: Vec<(Regex, &'static str)> = vec").unwrap(), "%Y%m%d%H%M%S"),
455 (Regex::new(r"VID_(\d{8})_(\d{6})").unwrap(), "%Y%m%d%H%M%S"),
457 (Regex::new(r"PXL_(\d{8})_(\d{6})(?:\d{3})?").unwrap(), "%Y%m%d%H%M%S"),
459 (Regex::new(r"(?:IMG|VID|PXL)_(\d{8})_(\d{6})[_\.-]\d+").unwrap(), "%Y%m%d%H%M%S"),
461 (Regex::new(r"(?:IMG|VID)-(\d{8})-WA\d+").unwrap(), "%Y%m%d"),
463 (Regex::new(r"Screenshot[ _](\d{4})-(\d{2})-(\d{2}) at (\d{2})\.(\d{2})\.(\d{2})").unwrap(), "%Y%m%d%H%M%S"),
465 (Regex::new(r"signal-(\d{4})-(\d{2})-(\d{2})-(\d{6})").unwrap(), "%Y%m%d%H%M%S"),
467 (Regex::new(r"(\d{8})-(\d{6})").unwrap(), "%Y%m%d%H%M%S"),
469 (Regex::new(r"(\d{4})-(\d{2})-(\d{2})-(\d{2})-(\d{2})-(\d{2})").unwrap(), "%Y%m%d%H%M%S"),
471 (Regex::new(r"(\d{4})-(\d{2})-(\d{2})[ _](\d{2})[\.\-_](\d{2})[\.\-_](\d{2})").unwrap(), "%Y%m%d%H%M%S"),
473 (Regex::new(r"(?:^|[^\d])(\d{8})\.").unwrap(), "%Y%m%d"),
476 ];
477 }
478
479 let filename = path.as_ref().file_name()?.to_str()?;
480
481 for (regex, format) in PATTERNS.iter() {
482 if let Some(caps) = regex.captures(filename) {
483 let date_str: String = caps
485 .iter()
486 .skip(1)
487 .filter_map(|m| m.map(|m| m.as_str()))
488 .collect();
489
490 if let Ok(parsed) = NaiveDateTime::parse_from_str(&date_str, format) {
492 if let Some(parsed) = Self::plausible_datetime(parsed) {
493 return Some(Utc.from_utc_datetime(&parsed));
494 }
495 }
496
497 if let Ok(parsed) = chrono::NaiveDate::parse_from_str(&date_str, "%Y%m%d") {
499 let datetime = parsed.and_hms_opt(0, 0, 0)?;
500 if let Some(datetime) = Self::plausible_datetime(datetime) {
501 return Some(Utc.from_utc_datetime(&datetime));
502 }
503 }
504 }
505 }
506
507 None
508 }
509
510 fn get_file_mtime<P: AsRef<Path>>(path: P) -> Option<DateTime<Utc>> {
512 let metadata = std::fs::metadata(path.as_ref()).ok()?;
513 let mtime = metadata.modified().ok()?;
514 let duration = mtime.duration_since(std::time::UNIX_EPOCH).ok()?;
515
516 DateTime::from_timestamp(duration.as_secs() as i64, 0)
517 }
518
519 fn get_image_dimensions<P: AsRef<Path>>(path: P) -> Option<(u32, u32)> {
521 let reader = image::ImageReader::open(path.as_ref()).ok()?;
522 let (w, h) = reader.into_dimensions().ok()?;
523 Some((w, h))
524 }
525}
526
527fn strip_tz_suffix(s: &str) -> Option<String> {
532 let trimmed = s.trim_end();
533 if let Some(without_z) = trimmed.strip_suffix('Z') {
534 return Some(without_z.trim_end().to_string());
535 }
536 let bytes = trimmed.as_bytes();
540 let mut tz_start = None;
541 for (i, &b) in bytes.iter().enumerate().rev() {
542 if i < 10 {
543 break;
544 }
545 if b == b'+' || b == b'-' {
546 if i >= 2
549 && bytes[i - 1].is_ascii_digit()
550 && (bytes[i - 2].is_ascii_digit() || bytes[i - 2] == b':')
551 {
552 tz_start = Some(i);
553 break;
554 }
555 }
556 }
557 let start = tz_start?;
558 let suffix = &trimmed[start..];
559 let body = &suffix[1..];
561 let valid = match body.len() {
562 4 => body.chars().all(|c| c.is_ascii_digit()),
563 5 => {
564 let b = body.as_bytes();
565 b[0].is_ascii_digit()
566 && b[1].is_ascii_digit()
567 && b[2] == b':'
568 && b[3].is_ascii_digit()
569 && b[4].is_ascii_digit()
570 }
571 _ => false,
572 };
573 if !valid {
574 return None;
575 }
576 Some(trimmed[..start].trim_end().to_string())
577}
578
579#[cfg(test)]
580mod tests {
581 use super::*;
582 use chrono::{Datelike, Timelike};
583
584 #[test]
585 fn test_filename_date_parsing() {
586 let path = std::path::Path::new("IMG_20190315_143022.jpg");
587 let date = ExifExtractor::parse_date_from_filename(path);
588 assert!(date.is_some());
589
590 let dt = date.unwrap();
591 assert_eq!(dt.year(), 2019);
592 assert_eq!(dt.month(), 3);
593 assert_eq!(dt.day(), 15);
594 }
595
596 #[test]
597 fn test_filename_date_parsing_plain() {
598 let path = std::path::Path::new("20190315_143022.jpg");
599 let date = ExifExtractor::parse_date_from_filename(path);
600 assert!(date.is_some());
601
602 let dt = date.unwrap();
603 assert_eq!(dt.year(), 2019);
604 }
605
606 #[test]
607 fn test_filename_date_parsing_pixel() {
608 let path = std::path::Path::new("PXL_20210801_120000.jpg");
609 let date = ExifExtractor::parse_date_from_filename(path);
610 assert!(date.is_some());
611
612 let dt = date.unwrap();
613 assert_eq!(dt.year(), 2021);
614 assert_eq!(dt.month(), 8);
615 }
616
617 #[test]
618 fn test_filename_date_parsing_pixel_with_millis() {
619 let path = std::path::Path::new("PXL_20210801_120000123.NIGHT.jpg");
620 let dt = ExifExtractor::parse_date_from_filename(path).unwrap();
621 assert_eq!(dt.year(), 2021);
622 assert_eq!(dt.second(), 0);
623 }
624
625 #[test]
626 fn test_filename_date_parsing_social_exports() {
627 let signal = std::path::Path::new("signal-2024-01-15-101600.jpg");
628 let dt = ExifExtractor::parse_date_from_filename(signal).unwrap();
629 assert_eq!(dt.year(), 2024);
630 assert_eq!(dt.hour(), 10);
631
632 let screenshot = std::path::Path::new("Screenshot 2024-01-15 at 10.16.00.png");
633 let dt = ExifExtractor::parse_date_from_filename(screenshot).unwrap();
634 assert_eq!(dt.minute(), 16);
635 }
636
637 #[test]
638 fn test_exif_date_parsing() {
639 let date = ExifExtractor::parse_exif_date("2019:03:15 14:30:22");
640 assert!(date.is_some());
641
642 let dt = date.unwrap();
643 assert_eq!(dt.year(), 2019);
644 }
645
646 #[test]
647 fn test_exif_date_parsing_iso_variants() {
648 let dt = ExifExtractor::parse_exif_date("2024-01-15T10:16:00.123+05:30").unwrap();
649 assert_eq!(dt.year(), 2024);
650 assert_eq!(dt.hour(), 10);
651
652 let date_only = ExifExtractor::parse_exif_date("2024-01-15").unwrap();
653 assert_eq!(date_only.hour(), 0);
654 }
655
656 #[test]
657 fn test_exif_date_rejects_placeholder_years() {
658 assert!(ExifExtractor::parse_exif_date("0000:00:00 00:00:00").is_none());
659 assert!(ExifExtractor::parse_exif_date("1899:12:31 23:59:59").is_none());
660 }
661
662 #[test]
663 fn test_no_date_from_random_filename() {
664 let path = std::path::Path::new("vacation_photo.jpg");
665 let date = ExifExtractor::parse_date_from_filename(path);
666 assert!(date.is_none());
667 }
668
669 #[test]
670 fn test_whatsapp_filename() {
671 let path = std::path::Path::new("IMG-20260307-WA0001.jpg");
672 let date = ExifExtractor::parse_date_from_filename(path);
673 assert!(date.is_some());
674 let dt = date.unwrap();
675 assert_eq!(dt.year(), 2026);
676 assert_eq!(dt.month(), 3);
677 assert_eq!(dt.day(), 7);
678 }
679
680 #[test]
681 fn test_dashed_iso_filename() {
682 let path = std::path::Path::new("2026-03-07-14-30-22.jpg");
683 let date = ExifExtractor::parse_date_from_filename(path);
684 assert!(date.is_some());
685 let dt = date.unwrap();
686 assert_eq!(dt.year(), 2026);
687 assert_eq!(dt.month(), 3);
688 }
689
690 #[test]
691 fn test_loose_8digit_no_longer_hijacks_id_string() {
692 let path = std::path::Path::new("mmexport1234567890.jpg");
696 let date = ExifExtractor::parse_date_from_filename(path);
697 assert!(date.is_none());
700 }
701
702 #[test]
703 fn test_exif_date_strips_tz_offset() {
704 let dt = ExifExtractor::parse_exif_date("2024:01:15 10:16:00+05:30").unwrap();
706 assert_eq!(dt.hour(), 10);
707 assert_eq!(dt.minute(), 16);
708 }
709
710 #[test]
711 fn test_exif_date_strips_z_suffix() {
712 let dt = ExifExtractor::parse_exif_date("2024:01:15 10:16:00Z").unwrap();
713 assert_eq!(dt.hour(), 10);
714 }
715
716 #[test]
717 fn test_exif_date_strips_compact_tz() {
718 let dt = ExifExtractor::parse_exif_date("2024:01:15 22:16:00-0500").unwrap();
719 assert_eq!(dt.hour(), 22);
720 }
721
722 #[test]
723 fn test_loose_8digit_still_works_with_letter_prefix() {
724 let path = std::path::Path::new("Photo20190315.jpg");
725 let date = ExifExtractor::parse_date_from_filename(path);
726 assert!(date.is_some());
727 let dt = date.unwrap();
728 assert_eq!(dt.year(), 2019);
729 }
730}