Skip to main content

immich_lib/
letterbox.rs

1//! Letterbox detection and pairing for iPhone 4:3/16:9 crop duplicates.
2//!
3//! This module identifies duplicate pairs where iPhone photos exist as both:
4//! - 4:3 aspect ratio (full sensor, more pixels)
5//! - 16:9 aspect ratio (cropped version)
6//!
7//! The 4:3 version is always preferred as the "keeper" since it contains the full scene.
8
9use std::collections::HashMap;
10
11use chrono::Utc;
12use serde::{Deserialize, Serialize};
13
14use crate::models::AssetResponse;
15
16/// Aspect ratio classification for iPhone photos.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum AspectRatio {
19    /// 4:3 ratio (1.333) - Full sensor capture
20    FourThree,
21    /// 16:9 ratio (1.778) - Cropped version
22    SixteenNine,
23}
24
25/// Tolerance for aspect ratio matching.
26const RATIO_TOLERANCE: f64 = 0.01;
27
28/// 4:3 aspect ratio value.
29const RATIO_4_3: f64 = 4.0 / 3.0; // 1.333...
30
31/// 16:9 aspect ratio value.
32const RATIO_16_9: f64 = 16.0 / 9.0; // 1.777...
33
34/// Detect the aspect ratio of an image from its dimensions.
35///
36/// Uses orientation-agnostic calculation (max/min) to handle both
37/// portrait and landscape orientations correctly.
38///
39/// # Arguments
40///
41/// * `width` - Image width in pixels
42/// * `height` - Image height in pixels
43///
44/// # Returns
45///
46/// * `Some(AspectRatio::FourThree)` if ratio is 4:3 (within tolerance)
47/// * `Some(AspectRatio::SixteenNine)` if ratio is 16:9 (within tolerance)
48/// * `None` for other aspect ratios
49///
50/// # Examples
51///
52/// ```
53/// use immich_lib::letterbox::detect_aspect_ratio;
54///
55/// // Landscape 4:3
56/// assert!(detect_aspect_ratio(5712, 4284).is_some());
57///
58/// // Portrait 4:3 (same ratio, just rotated)
59/// assert!(detect_aspect_ratio(4284, 5712).is_some());
60/// ```
61pub fn detect_aspect_ratio(width: u32, height: u32) -> Option<AspectRatio> {
62    if width == 0 || height == 0 {
63        return None;
64    }
65
66    // Use max/min for orientation-agnostic ratio
67    let max_dim = width.max(height) as f64;
68    let min_dim = width.min(height) as f64;
69    let ratio = max_dim / min_dim;
70
71    if (ratio - RATIO_4_3).abs() < RATIO_TOLERANCE {
72        Some(AspectRatio::FourThree)
73    } else if (ratio - RATIO_16_9).abs() < RATIO_TOLERANCE {
74        Some(AspectRatio::SixteenNine)
75    } else {
76        None
77    }
78}
79
80/// A detected letterbox pair (4:3 original + 16:9 crop).
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct LetterboxPair {
83    /// The 4:3 version to keep (more pixels, full scene)
84    pub keeper: AssetResponse,
85    /// The 16:9 version to delete (cropped)
86    pub delete: AssetResponse,
87    /// Shared capture timestamp
88    pub timestamp: String,
89    /// Camera identifier (e.g., "Apple iPhone 15 Pro Max")
90    pub camera: String,
91}
92
93/// Internal key for grouping assets by capture moment.
94#[derive(Debug, Clone, PartialEq, Eq, Hash)]
95struct PairingKey {
96    /// dateTimeOriginal truncated to second
97    timestamp_second: String,
98    /// Camera manufacturer (e.g., "Apple")
99    make: String,
100    /// Camera model (e.g., "iPhone 15 Pro Max")
101    model: String,
102    /// GPS coordinates rounded to 4 decimals, if available
103    gps_key: Option<String>,
104}
105
106impl PairingKey {
107    /// Create a pairing key from an asset.
108    ///
109    /// Returns None if required fields are missing.
110    fn from_asset(asset: &AssetResponse) -> Option<Self> {
111        let exif = asset.exif_info.as_ref()?;
112
113        // Require timestamp
114        let timestamp = exif.date_time_original.as_ref()?;
115
116        // Truncate to second (remove sub-second precision)
117        // Format: "2024-12-23T10:30:45.123Z" -> "2024-12-23T10:30:45"
118        let timestamp_second = if let Some(dot_pos) = timestamp.find('.') {
119            timestamp[..dot_pos].to_string()
120        } else if let Some(z_pos) = timestamp.find('Z') {
121            timestamp[..z_pos].to_string()
122        } else {
123            timestamp.clone()
124        };
125
126        // Require make and model
127        let make = exif.make.clone()?;
128        let model = exif.model.clone()?;
129
130        // Optional GPS key for disambiguation
131        let gps_key = match (exif.latitude, exif.longitude) {
132            (Some(lat), Some(lon)) => {
133                // Round to 4 decimal places (~11 meters precision)
134                Some(format!("{:.4},{:.4}", lat, lon))
135            }
136            _ => None,
137        };
138
139        Some(Self {
140            timestamp_second,
141            make,
142            model,
143            gps_key,
144        })
145    }
146}
147
148/// Check if an asset is from an iPhone.
149fn is_iphone_asset(asset: &AssetResponse) -> bool {
150    let Some(exif) = &asset.exif_info else {
151        return false;
152    };
153
154    let is_apple = exif
155        .make
156        .as_ref()
157        .is_some_and(|make| make.to_lowercase().contains("apple"));
158
159    let is_iphone = exif
160        .model
161        .as_ref()
162        .is_some_and(|model| model.to_lowercase().contains("iphone"));
163
164    is_apple && is_iphone
165}
166
167/// Get aspect ratio from asset dimensions.
168fn get_asset_aspect_ratio(asset: &AssetResponse) -> Option<AspectRatio> {
169    let exif = asset.exif_info.as_ref()?;
170    let width = exif.exif_image_width?;
171    let height = exif.exif_image_height?;
172    detect_aspect_ratio(width, height)
173}
174
175/// Find letterbox pairs in a collection of assets.
176///
177/// Identifies pairs of iPhone photos where one is 4:3 (full sensor)
178/// and the other is 16:9 (cropped). These pairs are created when
179/// iPhone users take photos in certain modes.
180///
181/// # Algorithm
182///
183/// 1. Filter to iPhone images only (make="Apple", model contains "iPhone")
184/// 2. Group by pairing key (timestamp + make + model + GPS)
185/// 3. For each group with exactly one 4:3 and one 16:9, create a pair
186/// 4. Skip ambiguous groups (multiple images of same ratio)
187///
188/// # Arguments
189///
190/// * `assets` - Slice of assets to analyze
191///
192/// # Returns
193///
194/// Vector of detected letterbox pairs, with 4:3 as keeper and 16:9 as delete.
195pub fn find_letterbox_pairs(assets: &[AssetResponse]) -> Vec<LetterboxPair> {
196    // Group assets by pairing key
197    let mut groups: HashMap<PairingKey, Vec<&AssetResponse>> = HashMap::new();
198
199    for asset in assets {
200        // Skip non-iPhone assets
201        if !is_iphone_asset(asset) {
202            continue;
203        }
204
205        // Skip trashed assets
206        if asset.is_trashed {
207            continue;
208        }
209
210        // Skip assets without valid aspect ratio
211        if get_asset_aspect_ratio(asset).is_none() {
212            continue;
213        }
214
215        // Group by pairing key
216        if let Some(key) = PairingKey::from_asset(asset) {
217            groups.entry(key).or_default().push(asset);
218        }
219    }
220
221    // Find pairs within each group
222    let mut pairs = Vec::new();
223
224    for (key, group_assets) in groups {
225        // Separate by aspect ratio
226        let mut four_three: Vec<&AssetResponse> = Vec::new();
227        let mut sixteen_nine: Vec<&AssetResponse> = Vec::new();
228
229        for asset in group_assets {
230            match get_asset_aspect_ratio(asset) {
231                Some(AspectRatio::FourThree) => four_three.push(asset),
232                Some(AspectRatio::SixteenNine) => sixteen_nine.push(asset),
233                None => {}
234            }
235        }
236
237        // Only create pair if exactly one of each
238        if four_three.len() == 1 && sixteen_nine.len() == 1 {
239            let keeper = four_three[0];
240            let delete = sixteen_nine[0];
241
242            pairs.push(LetterboxPair {
243                keeper: keeper.clone(),
244                delete: delete.clone(),
245                timestamp: key.timestamp_second.clone(),
246                camera: format!("{} {}", key.make, key.model),
247            });
248        }
249        // Skip ambiguous groups (multiple of same ratio at same timestamp)
250    }
251
252    pairs
253}
254
255/// Analysis report for letterbox duplicates.
256///
257/// This is the serializable output format for letterbox detection,
258/// following the same pattern as `DuplicateAnalysis` for consistency.
259#[derive(Debug, Clone, Serialize, Deserialize)]
260pub struct LetterboxAnalysis {
261    /// Detected letterbox pairs (4:3 keeper + 16:9 to delete)
262    pub pairs: Vec<LetterboxPair>,
263
264    /// Total number of pairs detected
265    pub total_pairs: usize,
266
267    /// Sum of file sizes of assets marked for deletion (bytes)
268    pub total_space_recoverable: u64,
269
270    /// Groups skipped due to ambiguity (multiple pairs at same timestamp)
271    pub skipped_ambiguous: usize,
272
273    /// Non-Apple assets encountered (ignored for letterbox detection)
274    pub skipped_non_iphone: usize,
275
276    /// ISO 8601 timestamp when analysis was performed
277    pub analyzed_at: String,
278}
279
280impl LetterboxAnalysis {
281    /// Build a letterbox analysis from a collection of assets.
282    ///
283    /// Internally calls `find_letterbox_pairs` and computes summary statistics.
284    ///
285    /// # Arguments
286    ///
287    /// * `assets` - Slice of assets to analyze for letterbox pairs
288    ///
289    /// # Returns
290    ///
291    /// Analysis report with detected pairs and statistics.
292    pub fn from_assets(assets: &[AssetResponse]) -> Self {
293        // Count non-iPhone assets
294        let skipped_non_iphone = assets
295            .iter()
296            .filter(|a| !is_iphone_asset(a))
297            .count();
298
299        // Count iPhone assets grouped by pairing key
300        let mut groups: HashMap<PairingKey, Vec<&AssetResponse>> = HashMap::new();
301        for asset in assets {
302            if !is_iphone_asset(asset) {
303                continue;
304            }
305            if asset.is_trashed {
306                continue;
307            }
308            if get_asset_aspect_ratio(asset).is_none() {
309                continue;
310            }
311            if let Some(key) = PairingKey::from_asset(asset) {
312                groups.entry(key).or_default().push(asset);
313            }
314        }
315
316        // Count ambiguous groups (more than one of same ratio)
317        let skipped_ambiguous = groups
318            .values()
319            .filter(|group| {
320                let four_three_count = group
321                    .iter()
322                    .filter(|a| get_asset_aspect_ratio(a) == Some(AspectRatio::FourThree))
323                    .count();
324                let sixteen_nine_count = group
325                    .iter()
326                    .filter(|a| get_asset_aspect_ratio(a) == Some(AspectRatio::SixteenNine))
327                    .count();
328                // Ambiguous if >1 of either ratio with at least one of the other
329                (four_three_count > 1 && sixteen_nine_count > 0)
330                    || (sixteen_nine_count > 1 && four_three_count > 0)
331            })
332            .count();
333
334        // Find pairs
335        let pairs = find_letterbox_pairs(assets);
336
337        // Calculate space recoverable from delete assets
338        let total_space_recoverable = pairs
339            .iter()
340            .filter_map(|pair| {
341                pair.delete
342                    .exif_info
343                    .as_ref()
344                    .and_then(|e| e.file_size_in_byte)
345            })
346            .sum();
347
348        Self {
349            total_pairs: pairs.len(),
350            pairs,
351            total_space_recoverable,
352            skipped_ambiguous,
353            skipped_non_iphone,
354            analyzed_at: Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(),
355        }
356    }
357
358    /// Returns asset IDs of all assets marked for deletion.
359    pub fn delete_ids(&self) -> Vec<&str> {
360        self.pairs.iter().map(|p| p.delete.id.as_str()).collect()
361    }
362
363    /// Returns asset IDs of all keepers.
364    pub fn keeper_ids(&self) -> Vec<&str> {
365        self.pairs.iter().map(|p| p.keeper.id.as_str()).collect()
366    }
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372    use crate::models::{AssetType, ExifInfo};
373
374    /// Helper to create a mock asset with configurable EXIF data.
375    fn mock_asset(
376        id: &str,
377        width: Option<u32>,
378        height: Option<u32>,
379        make: Option<&str>,
380        model: Option<&str>,
381        timestamp: Option<&str>,
382        lat: Option<f64>,
383        lon: Option<f64>,
384    ) -> AssetResponse {
385        let exif = ExifInfo {
386            exif_image_width: width,
387            exif_image_height: height,
388            make: make.map(String::from),
389            model: model.map(String::from),
390            date_time_original: timestamp.map(String::from),
391            latitude: lat,
392            longitude: lon,
393            // Required fields with defaults
394            city: None,
395            state: None,
396            country: None,
397            time_zone: None,
398            lens_model: None,
399            exposure_time: None,
400            f_number: None,
401            focal_length: None,
402            iso: None,
403            file_size_in_byte: None,
404            description: None,
405            rating: None,
406            orientation: None,
407            modify_date: None,
408            projection_type: None,
409        };
410
411        AssetResponse {
412            id: id.to_string(),
413            original_file_name: format!("{}.HEIC", id),
414            file_created_at: "2024-12-23T10:30:45Z".to_string(),
415            local_date_time: "2024-12-23T10:30:45".to_string(),
416            asset_type: AssetType::Image,
417            exif_info: Some(exif),
418            checksum: "abc123".to_string(),
419            is_trashed: false,
420            is_favorite: false,
421            is_archived: false,
422            has_metadata: true,
423            duration: "0:00:00.000000".to_string(),
424            owner_id: "owner-1".to_string(),
425            original_mime_type: Some("image/heic".to_string()),
426            duplicate_id: None,
427            thumbhash: None,
428        }
429    }
430
431    // ============ Aspect Ratio Detection Tests ============
432
433    #[test]
434    fn test_detect_4_3_landscape() {
435        // iPhone 15 Pro Max 4:3 dimensions
436        assert_eq!(
437            detect_aspect_ratio(5712, 4284),
438            Some(AspectRatio::FourThree)
439        );
440    }
441
442    #[test]
443    fn test_detect_4_3_portrait() {
444        // Portrait orientation (rotated 90 degrees)
445        assert_eq!(
446            detect_aspect_ratio(4284, 5712),
447            Some(AspectRatio::FourThree)
448        );
449    }
450
451    #[test]
452    fn test_detect_16_9_landscape() {
453        // iPhone 15 Pro Max 16:9 dimensions
454        assert_eq!(
455            detect_aspect_ratio(5712, 3213),
456            Some(AspectRatio::SixteenNine)
457        );
458    }
459
460    #[test]
461    fn test_detect_16_9_portrait() {
462        // Portrait orientation
463        assert_eq!(
464            detect_aspect_ratio(3213, 5712),
465            Some(AspectRatio::SixteenNine)
466        );
467    }
468
469    #[test]
470    fn test_detect_other_ratio_1_1() {
471        // Square image - not 4:3 or 16:9
472        assert_eq!(detect_aspect_ratio(1000, 1000), None);
473    }
474
475    #[test]
476    fn test_detect_other_ratio_3_2() {
477        // 3:2 ratio (common in DSLRs) - not 4:3 or 16:9
478        assert_eq!(detect_aspect_ratio(3000, 2000), None);
479    }
480
481    #[test]
482    fn test_detect_with_tolerance_4_3_edge() {
483        // Edge case near 4:3 boundary
484        // 4:3 = 1.333..., tolerance = 0.01
485        // 1.333 + 0.009 = 1.342 should still match
486        // 1000 / 745 = 1.342
487        assert_eq!(
488            detect_aspect_ratio(1000, 745),
489            Some(AspectRatio::FourThree)
490        );
491    }
492
493    #[test]
494    fn test_detect_with_tolerance_16_9_edge() {
495        // Edge case near 16:9 boundary
496        // 16:9 = 1.778..., tolerance = 0.01
497        // 1778 / 1000 = 1.778 should match
498        assert_eq!(
499            detect_aspect_ratio(1778, 1000),
500            Some(AspectRatio::SixteenNine)
501        );
502    }
503
504    #[test]
505    fn test_detect_zero_dimension() {
506        assert_eq!(detect_aspect_ratio(0, 100), None);
507        assert_eq!(detect_aspect_ratio(100, 0), None);
508        assert_eq!(detect_aspect_ratio(0, 0), None);
509    }
510
511    #[test]
512    fn test_detect_hd_16_9() {
513        // Standard HD 16:9 dimensions
514        assert_eq!(
515            detect_aspect_ratio(1920, 1080),
516            Some(AspectRatio::SixteenNine)
517        );
518    }
519
520    #[test]
521    fn test_detect_4k_16_9() {
522        // 4K 16:9 dimensions
523        assert_eq!(
524            detect_aspect_ratio(3840, 2160),
525            Some(AspectRatio::SixteenNine)
526        );
527    }
528
529    // ============ Pairing Tests ============
530
531    #[test]
532    fn test_find_pair_basic() {
533        // One 4:3 + one 16:9 at same timestamp from iPhone
534        let assets = vec![
535            mock_asset(
536                "asset-4-3",
537                Some(5712),
538                Some(4284),
539                Some("Apple"),
540                Some("iPhone 15 Pro Max"),
541                Some("2024-12-23T10:30:45.123Z"),
542                Some(51.5074),
543                Some(-0.1278),
544            ),
545            mock_asset(
546                "asset-16-9",
547                Some(5712),
548                Some(3213),
549                Some("Apple"),
550                Some("iPhone 15 Pro Max"),
551                Some("2024-12-23T10:30:45.456Z"),
552                Some(51.5074),
553                Some(-0.1278),
554            ),
555        ];
556
557        let pairs = find_letterbox_pairs(&assets);
558
559        assert_eq!(pairs.len(), 1);
560        assert_eq!(pairs[0].keeper.id, "asset-4-3");
561        assert_eq!(pairs[0].delete.id, "asset-16-9");
562        assert_eq!(pairs[0].camera, "Apple iPhone 15 Pro Max");
563    }
564
565    #[test]
566    fn test_skip_non_iphone() {
567        // Android assets should be ignored
568        let assets = vec![
569            mock_asset(
570                "asset-4-3",
571                Some(4000),
572                Some(3000),
573                Some("Samsung"),
574                Some("Galaxy S23"),
575                Some("2024-12-23T10:30:45Z"),
576                None,
577                None,
578            ),
579            mock_asset(
580                "asset-16-9",
581                Some(4000),
582                Some(2250),
583                Some("Samsung"),
584                Some("Galaxy S23"),
585                Some("2024-12-23T10:30:45Z"),
586                None,
587                None,
588            ),
589        ];
590
591        let pairs = find_letterbox_pairs(&assets);
592        assert!(pairs.is_empty());
593    }
594
595    #[test]
596    fn test_skip_missing_timestamp() {
597        // Assets without dateTimeOriginal should be skipped
598        let assets = vec![
599            mock_asset(
600                "asset-4-3",
601                Some(5712),
602                Some(4284),
603                Some("Apple"),
604                Some("iPhone 15 Pro Max"),
605                None, // No timestamp
606                None,
607                None,
608            ),
609            mock_asset(
610                "asset-16-9",
611                Some(5712),
612                Some(3213),
613                Some("Apple"),
614                Some("iPhone 15 Pro Max"),
615                None, // No timestamp
616                None,
617                None,
618            ),
619        ];
620
621        let pairs = find_letterbox_pairs(&assets);
622        assert!(pairs.is_empty());
623    }
624
625    #[test]
626    fn test_skip_ambiguous_two_4_3() {
627        // Two 4:3 at same timestamp = ambiguous, skip
628        let assets = vec![
629            mock_asset(
630                "asset-4-3-a",
631                Some(5712),
632                Some(4284),
633                Some("Apple"),
634                Some("iPhone 15 Pro Max"),
635                Some("2024-12-23T10:30:45Z"),
636                None,
637                None,
638            ),
639            mock_asset(
640                "asset-4-3-b",
641                Some(5712),
642                Some(4284),
643                Some("Apple"),
644                Some("iPhone 15 Pro Max"),
645                Some("2024-12-23T10:30:45Z"),
646                None,
647                None,
648            ),
649            mock_asset(
650                "asset-16-9",
651                Some(5712),
652                Some(3213),
653                Some("Apple"),
654                Some("iPhone 15 Pro Max"),
655                Some("2024-12-23T10:30:45Z"),
656                None,
657                None,
658            ),
659        ];
660
661        let pairs = find_letterbox_pairs(&assets);
662        assert!(pairs.is_empty()); // Ambiguous, so no pair
663    }
664
665    #[test]
666    fn test_skip_ambiguous_two_16_9() {
667        // Two 16:9 at same timestamp = ambiguous, skip
668        let assets = vec![
669            mock_asset(
670                "asset-4-3",
671                Some(5712),
672                Some(4284),
673                Some("Apple"),
674                Some("iPhone 15 Pro Max"),
675                Some("2024-12-23T10:30:45Z"),
676                None,
677                None,
678            ),
679            mock_asset(
680                "asset-16-9-a",
681                Some(5712),
682                Some(3213),
683                Some("Apple"),
684                Some("iPhone 15 Pro Max"),
685                Some("2024-12-23T10:30:45Z"),
686                None,
687                None,
688            ),
689            mock_asset(
690                "asset-16-9-b",
691                Some(5712),
692                Some(3213),
693                Some("Apple"),
694                Some("iPhone 15 Pro Max"),
695                Some("2024-12-23T10:30:45Z"),
696                None,
697                None,
698            ),
699        ];
700
701        let pairs = find_letterbox_pairs(&assets);
702        assert!(pairs.is_empty()); // Ambiguous, so no pair
703    }
704
705    #[test]
706    fn test_multiple_pairs_different_timestamps() {
707        // Two separate pairs at different timestamps
708        let assets = vec![
709            // Pair 1 at 10:30:45
710            mock_asset(
711                "pair1-4-3",
712                Some(5712),
713                Some(4284),
714                Some("Apple"),
715                Some("iPhone 15 Pro Max"),
716                Some("2024-12-23T10:30:45Z"),
717                None,
718                None,
719            ),
720            mock_asset(
721                "pair1-16-9",
722                Some(5712),
723                Some(3213),
724                Some("Apple"),
725                Some("iPhone 15 Pro Max"),
726                Some("2024-12-23T10:30:45Z"),
727                None,
728                None,
729            ),
730            // Pair 2 at 11:00:00
731            mock_asset(
732                "pair2-4-3",
733                Some(5712),
734                Some(4284),
735                Some("Apple"),
736                Some("iPhone 15 Pro Max"),
737                Some("2024-12-23T11:00:00Z"),
738                None,
739                None,
740            ),
741            mock_asset(
742                "pair2-16-9",
743                Some(5712),
744                Some(3213),
745                Some("Apple"),
746                Some("iPhone 15 Pro Max"),
747                Some("2024-12-23T11:00:00Z"),
748                None,
749                None,
750            ),
751        ];
752
753        let pairs = find_letterbox_pairs(&assets);
754        assert_eq!(pairs.len(), 2);
755    }
756
757    #[test]
758    fn test_gps_disambiguation() {
759        // Same timestamp but different GPS = separate groups (no pairs formed)
760        let assets = vec![
761            mock_asset(
762                "loc1-4-3",
763                Some(5712),
764                Some(4284),
765                Some("Apple"),
766                Some("iPhone 15 Pro Max"),
767                Some("2024-12-23T10:30:45Z"),
768                Some(51.5074), // London
769                Some(-0.1278),
770            ),
771            mock_asset(
772                "loc2-16-9",
773                Some(5712),
774                Some(3213),
775                Some("Apple"),
776                Some("iPhone 15 Pro Max"),
777                Some("2024-12-23T10:30:45Z"),
778                Some(40.7128), // New York
779                Some(-74.0060),
780            ),
781        ];
782
783        let pairs = find_letterbox_pairs(&assets);
784        // Different GPS means different groups, so no pair
785        assert!(pairs.is_empty());
786    }
787
788    #[test]
789    fn test_gps_same_location_pairs() {
790        // Same timestamp AND same GPS = should pair
791        let assets = vec![
792            mock_asset(
793                "asset-4-3",
794                Some(5712),
795                Some(4284),
796                Some("Apple"),
797                Some("iPhone 15 Pro Max"),
798                Some("2024-12-23T10:30:45Z"),
799                Some(51.5074),
800                Some(-0.1278),
801            ),
802            mock_asset(
803                "asset-16-9",
804                Some(5712),
805                Some(3213),
806                Some("Apple"),
807                Some("iPhone 15 Pro Max"),
808                Some("2024-12-23T10:30:45Z"),
809                Some(51.5074), // Same GPS
810                Some(-0.1278),
811            ),
812        ];
813
814        let pairs = find_letterbox_pairs(&assets);
815        assert_eq!(pairs.len(), 1);
816    }
817
818    #[test]
819    fn test_skip_trashed_assets() {
820        let mut asset_4_3 = mock_asset(
821            "asset-4-3",
822            Some(5712),
823            Some(4284),
824            Some("Apple"),
825            Some("iPhone 15 Pro Max"),
826            Some("2024-12-23T10:30:45Z"),
827            None,
828            None,
829        );
830        asset_4_3.is_trashed = true;
831
832        let asset_16_9 = mock_asset(
833            "asset-16-9",
834            Some(5712),
835            Some(3213),
836            Some("Apple"),
837            Some("iPhone 15 Pro Max"),
838            Some("2024-12-23T10:30:45Z"),
839            None,
840            None,
841        );
842
843        let assets = vec![asset_4_3, asset_16_9];
844        let pairs = find_letterbox_pairs(&assets);
845        assert!(pairs.is_empty()); // 4:3 is trashed, no pair
846    }
847
848    #[test]
849    fn test_skip_missing_dimensions() {
850        // Assets without dimensions should be skipped
851        let assets = vec![
852            mock_asset(
853                "asset-4-3",
854                None, // No width
855                None, // No height
856                Some("Apple"),
857                Some("iPhone 15 Pro Max"),
858                Some("2024-12-23T10:30:45Z"),
859                None,
860                None,
861            ),
862            mock_asset(
863                "asset-16-9",
864                Some(5712),
865                Some(3213),
866                Some("Apple"),
867                Some("iPhone 15 Pro Max"),
868                Some("2024-12-23T10:30:45Z"),
869                None,
870                None,
871            ),
872        ];
873
874        let pairs = find_letterbox_pairs(&assets);
875        assert!(pairs.is_empty());
876    }
877
878    #[test]
879    fn test_different_iphone_models_no_pair() {
880        // Same timestamp but different iPhone models = different groups
881        let assets = vec![
882            mock_asset(
883                "asset-4-3",
884                Some(5712),
885                Some(4284),
886                Some("Apple"),
887                Some("iPhone 15 Pro Max"),
888                Some("2024-12-23T10:30:45Z"),
889                None,
890                None,
891            ),
892            mock_asset(
893                "asset-16-9",
894                Some(5712),
895                Some(3213),
896                Some("Apple"),
897                Some("iPhone 14 Pro"), // Different model
898                Some("2024-12-23T10:30:45Z"),
899                None,
900                None,
901            ),
902        ];
903
904        let pairs = find_letterbox_pairs(&assets);
905        assert!(pairs.is_empty()); // Different models, no pair
906    }
907
908    #[test]
909    fn test_only_4_3_no_pair() {
910        // Only 4:3 images, no 16:9 = no pair
911        let assets = vec![mock_asset(
912            "asset-4-3",
913            Some(5712),
914            Some(4284),
915            Some("Apple"),
916            Some("iPhone 15 Pro Max"),
917            Some("2024-12-23T10:30:45Z"),
918            None,
919            None,
920        )];
921
922        let pairs = find_letterbox_pairs(&assets);
923        assert!(pairs.is_empty());
924    }
925
926    #[test]
927    fn test_only_16_9_no_pair() {
928        // Only 16:9 images, no 4:3 = no pair
929        let assets = vec![mock_asset(
930            "asset-16-9",
931            Some(5712),
932            Some(3213),
933            Some("Apple"),
934            Some("iPhone 15 Pro Max"),
935            Some("2024-12-23T10:30:45Z"),
936            None,
937            None,
938        )];
939
940        let pairs = find_letterbox_pairs(&assets);
941        assert!(pairs.is_empty());
942    }
943
944    #[test]
945    fn test_subsecond_timestamp_handling() {
946        // Different sub-second precision should still match (truncated to second)
947        let assets = vec![
948            mock_asset(
949                "asset-4-3",
950                Some(5712),
951                Some(4284),
952                Some("Apple"),
953                Some("iPhone 15 Pro Max"),
954                Some("2024-12-23T10:30:45.123Z"),
955                None,
956                None,
957            ),
958            mock_asset(
959                "asset-16-9",
960                Some(5712),
961                Some(3213),
962                Some("Apple"),
963                Some("iPhone 15 Pro Max"),
964                Some("2024-12-23T10:30:45.999Z"), // Different sub-second
965                None,
966                None,
967            ),
968        ];
969
970        let pairs = find_letterbox_pairs(&assets);
971        assert_eq!(pairs.len(), 1); // Should pair (same second)
972    }
973
974    // ============ LetterboxAnalysis Tests ============
975
976    /// Helper to create mock asset with file size.
977    fn mock_asset_with_size(
978        id: &str,
979        width: Option<u32>,
980        height: Option<u32>,
981        make: Option<&str>,
982        model: Option<&str>,
983        timestamp: Option<&str>,
984        file_size: Option<u64>,
985    ) -> AssetResponse {
986        let exif = ExifInfo {
987            exif_image_width: width,
988            exif_image_height: height,
989            make: make.map(String::from),
990            model: model.map(String::from),
991            date_time_original: timestamp.map(String::from),
992            latitude: None,
993            longitude: None,
994            city: None,
995            state: None,
996            country: None,
997            time_zone: None,
998            lens_model: None,
999            exposure_time: None,
1000            f_number: None,
1001            focal_length: None,
1002            iso: None,
1003            file_size_in_byte: file_size,
1004            description: None,
1005            rating: None,
1006            orientation: None,
1007            modify_date: None,
1008            projection_type: None,
1009        };
1010
1011        AssetResponse {
1012            id: id.to_string(),
1013            original_file_name: format!("{}.HEIC", id),
1014            file_created_at: "2024-12-23T10:30:45Z".to_string(),
1015            local_date_time: "2024-12-23T10:30:45".to_string(),
1016            asset_type: AssetType::Image,
1017            exif_info: Some(exif),
1018            checksum: "abc123".to_string(),
1019            is_trashed: false,
1020            is_favorite: false,
1021            is_archived: false,
1022            has_metadata: true,
1023            duration: "0:00:00.000000".to_string(),
1024            owner_id: "owner-1".to_string(),
1025            original_mime_type: Some("image/heic".to_string()),
1026            duplicate_id: None,
1027            thumbhash: None,
1028        }
1029    }
1030
1031    #[test]
1032    fn test_letterbox_analysis_from_assets() {
1033        let assets = vec![
1034            // Valid pair
1035            mock_asset_with_size(
1036                "keeper-1",
1037                Some(5712),
1038                Some(4284), // 4:3
1039                Some("Apple"),
1040                Some("iPhone 15 Pro Max"),
1041                Some("2024-12-23T10:30:45Z"),
1042                Some(10_000_000), // 10MB
1043            ),
1044            mock_asset_with_size(
1045                "delete-1",
1046                Some(5712),
1047                Some(3213), // 16:9
1048                Some("Apple"),
1049                Some("iPhone 15 Pro Max"),
1050                Some("2024-12-23T10:30:45Z"),
1051                Some(8_000_000), // 8MB
1052            ),
1053            // Non-iPhone asset (should be skipped)
1054            mock_asset_with_size(
1055                "android-1",
1056                Some(4000),
1057                Some(3000),
1058                Some("Samsung"),
1059                Some("Galaxy S23"),
1060                Some("2024-12-23T11:00:00Z"),
1061                Some(5_000_000),
1062            ),
1063        ];
1064
1065        let analysis = LetterboxAnalysis::from_assets(&assets);
1066
1067        assert_eq!(analysis.total_pairs, 1);
1068        assert_eq!(analysis.pairs.len(), 1);
1069        assert_eq!(analysis.total_space_recoverable, 8_000_000);
1070        assert_eq!(analysis.skipped_non_iphone, 1);
1071        assert_eq!(analysis.skipped_ambiguous, 0);
1072        assert!(!analysis.analyzed_at.is_empty());
1073    }
1074
1075    #[test]
1076    fn test_letterbox_analysis_delete_ids() {
1077        let assets = vec![
1078            mock_asset_with_size(
1079                "keeper-1",
1080                Some(5712),
1081                Some(4284),
1082                Some("Apple"),
1083                Some("iPhone 15 Pro Max"),
1084                Some("2024-12-23T10:30:45Z"),
1085                Some(10_000_000),
1086            ),
1087            mock_asset_with_size(
1088                "delete-1",
1089                Some(5712),
1090                Some(3213),
1091                Some("Apple"),
1092                Some("iPhone 15 Pro Max"),
1093                Some("2024-12-23T10:30:45Z"),
1094                Some(8_000_000),
1095            ),
1096        ];
1097
1098        let analysis = LetterboxAnalysis::from_assets(&assets);
1099        let delete_ids = analysis.delete_ids();
1100
1101        assert_eq!(delete_ids.len(), 1);
1102        assert_eq!(delete_ids[0], "delete-1");
1103    }
1104
1105    #[test]
1106    fn test_letterbox_analysis_keeper_ids() {
1107        let assets = vec![
1108            mock_asset_with_size(
1109                "keeper-1",
1110                Some(5712),
1111                Some(4284),
1112                Some("Apple"),
1113                Some("iPhone 15 Pro Max"),
1114                Some("2024-12-23T10:30:45Z"),
1115                Some(10_000_000),
1116            ),
1117            mock_asset_with_size(
1118                "delete-1",
1119                Some(5712),
1120                Some(3213),
1121                Some("Apple"),
1122                Some("iPhone 15 Pro Max"),
1123                Some("2024-12-23T10:30:45Z"),
1124                Some(8_000_000),
1125            ),
1126        ];
1127
1128        let analysis = LetterboxAnalysis::from_assets(&assets);
1129        let keeper_ids = analysis.keeper_ids();
1130
1131        assert_eq!(keeper_ids.len(), 1);
1132        assert_eq!(keeper_ids[0], "keeper-1");
1133    }
1134
1135    #[test]
1136    fn test_letterbox_analysis_serialization() {
1137        let assets = vec![
1138            mock_asset_with_size(
1139                "keeper-1",
1140                Some(5712),
1141                Some(4284),
1142                Some("Apple"),
1143                Some("iPhone 15 Pro Max"),
1144                Some("2024-12-23T10:30:45Z"),
1145                Some(10_000_000),
1146            ),
1147            mock_asset_with_size(
1148                "delete-1",
1149                Some(5712),
1150                Some(3213),
1151                Some("Apple"),
1152                Some("iPhone 15 Pro Max"),
1153                Some("2024-12-23T10:30:45Z"),
1154                Some(8_000_000),
1155            ),
1156        ];
1157
1158        let analysis = LetterboxAnalysis::from_assets(&assets);
1159
1160        // Test JSON serialization
1161        let json = serde_json::to_string_pretty(&analysis).expect("should serialize to JSON");
1162        assert!(json.contains("total_pairs"));
1163        assert!(json.contains("total_space_recoverable"));
1164        assert!(json.contains("keeper"));
1165        assert!(json.contains("delete"));
1166
1167        // Test JSON deserialization round-trip
1168        let parsed: LetterboxAnalysis =
1169            serde_json::from_str(&json).expect("should deserialize from JSON");
1170        assert_eq!(parsed.total_pairs, analysis.total_pairs);
1171        assert_eq!(
1172            parsed.total_space_recoverable,
1173            analysis.total_space_recoverable
1174        );
1175    }
1176
1177    #[test]
1178    fn test_letterbox_analysis_empty() {
1179        let assets: Vec<AssetResponse> = vec![];
1180        let analysis = LetterboxAnalysis::from_assets(&assets);
1181
1182        assert_eq!(analysis.total_pairs, 0);
1183        assert_eq!(analysis.pairs.len(), 0);
1184        assert_eq!(analysis.total_space_recoverable, 0);
1185        assert_eq!(analysis.skipped_non_iphone, 0);
1186        assert_eq!(analysis.skipped_ambiguous, 0);
1187    }
1188}