use std::collections::HashMap;
use chrono::Utc;
use serde::{Deserialize, Serialize};
use crate::models::AssetResponse;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AspectRatio {
FourThree,
SixteenNine,
}
const RATIO_TOLERANCE: f64 = 0.01;
const RATIO_4_3: f64 = 4.0 / 3.0;
const RATIO_16_9: f64 = 16.0 / 9.0;
pub fn detect_aspect_ratio(width: u32, height: u32) -> Option<AspectRatio> {
if width == 0 || height == 0 {
return None;
}
let max_dim = width.max(height) as f64;
let min_dim = width.min(height) as f64;
let ratio = max_dim / min_dim;
if (ratio - RATIO_4_3).abs() < RATIO_TOLERANCE {
Some(AspectRatio::FourThree)
} else if (ratio - RATIO_16_9).abs() < RATIO_TOLERANCE {
Some(AspectRatio::SixteenNine)
} else {
None
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LetterboxPair {
pub keeper: AssetResponse,
pub delete: AssetResponse,
pub timestamp: String,
pub camera: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct PairingKey {
timestamp_second: String,
make: String,
model: String,
gps_key: Option<String>,
}
impl PairingKey {
fn from_asset(asset: &AssetResponse) -> Option<Self> {
let exif = asset.exif_info.as_ref()?;
let timestamp = exif.date_time_original.as_ref()?;
let timestamp_second = if let Some(dot_pos) = timestamp.find('.') {
timestamp[..dot_pos].to_string()
} else if let Some(z_pos) = timestamp.find('Z') {
timestamp[..z_pos].to_string()
} else {
timestamp.clone()
};
let make = exif.make.clone()?;
let model = exif.model.clone()?;
let gps_key = match (exif.latitude, exif.longitude) {
(Some(lat), Some(lon)) => {
Some(format!("{:.4},{:.4}", lat, lon))
}
_ => None,
};
Some(Self {
timestamp_second,
make,
model,
gps_key,
})
}
}
fn is_iphone_asset(asset: &AssetResponse) -> bool {
let Some(exif) = &asset.exif_info else {
return false;
};
let is_apple = exif
.make
.as_ref()
.is_some_and(|make| make.to_lowercase().contains("apple"));
let is_iphone = exif
.model
.as_ref()
.is_some_and(|model| model.to_lowercase().contains("iphone"));
is_apple && is_iphone
}
fn get_asset_aspect_ratio(asset: &AssetResponse) -> Option<AspectRatio> {
let exif = asset.exif_info.as_ref()?;
let width = exif.exif_image_width?;
let height = exif.exif_image_height?;
detect_aspect_ratio(width, height)
}
pub fn find_letterbox_pairs(assets: &[AssetResponse]) -> Vec<LetterboxPair> {
let mut groups: HashMap<PairingKey, Vec<&AssetResponse>> = HashMap::new();
for asset in assets {
if !is_iphone_asset(asset) {
continue;
}
if asset.is_trashed {
continue;
}
if get_asset_aspect_ratio(asset).is_none() {
continue;
}
if let Some(key) = PairingKey::from_asset(asset) {
groups.entry(key).or_default().push(asset);
}
}
let mut pairs = Vec::new();
for (key, group_assets) in groups {
let mut four_three: Vec<&AssetResponse> = Vec::new();
let mut sixteen_nine: Vec<&AssetResponse> = Vec::new();
for asset in group_assets {
match get_asset_aspect_ratio(asset) {
Some(AspectRatio::FourThree) => four_three.push(asset),
Some(AspectRatio::SixteenNine) => sixteen_nine.push(asset),
None => {}
}
}
if four_three.len() == 1 && sixteen_nine.len() == 1 {
let keeper = four_three[0];
let delete = sixteen_nine[0];
pairs.push(LetterboxPair {
keeper: keeper.clone(),
delete: delete.clone(),
timestamp: key.timestamp_second.clone(),
camera: format!("{} {}", key.make, key.model),
});
}
}
pairs
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LetterboxAnalysis {
pub pairs: Vec<LetterboxPair>,
pub total_pairs: usize,
pub total_space_recoverable: u64,
pub skipped_ambiguous: usize,
pub skipped_non_iphone: usize,
pub analyzed_at: String,
}
impl LetterboxAnalysis {
pub fn from_assets(assets: &[AssetResponse]) -> Self {
let skipped_non_iphone = assets
.iter()
.filter(|a| !is_iphone_asset(a))
.count();
let mut groups: HashMap<PairingKey, Vec<&AssetResponse>> = HashMap::new();
for asset in assets {
if !is_iphone_asset(asset) {
continue;
}
if asset.is_trashed {
continue;
}
if get_asset_aspect_ratio(asset).is_none() {
continue;
}
if let Some(key) = PairingKey::from_asset(asset) {
groups.entry(key).or_default().push(asset);
}
}
let skipped_ambiguous = groups
.values()
.filter(|group| {
let four_three_count = group
.iter()
.filter(|a| get_asset_aspect_ratio(a) == Some(AspectRatio::FourThree))
.count();
let sixteen_nine_count = group
.iter()
.filter(|a| get_asset_aspect_ratio(a) == Some(AspectRatio::SixteenNine))
.count();
(four_three_count > 1 && sixteen_nine_count > 0)
|| (sixteen_nine_count > 1 && four_three_count > 0)
})
.count();
let pairs = find_letterbox_pairs(assets);
let total_space_recoverable = pairs
.iter()
.filter_map(|pair| {
pair.delete
.exif_info
.as_ref()
.and_then(|e| e.file_size_in_byte)
})
.sum();
Self {
total_pairs: pairs.len(),
pairs,
total_space_recoverable,
skipped_ambiguous,
skipped_non_iphone,
analyzed_at: Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(),
}
}
pub fn delete_ids(&self) -> Vec<&str> {
self.pairs.iter().map(|p| p.delete.id.as_str()).collect()
}
pub fn keeper_ids(&self) -> Vec<&str> {
self.pairs.iter().map(|p| p.keeper.id.as_str()).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::{AssetType, ExifInfo};
fn mock_asset(
id: &str,
width: Option<u32>,
height: Option<u32>,
make: Option<&str>,
model: Option<&str>,
timestamp: Option<&str>,
lat: Option<f64>,
lon: Option<f64>,
) -> AssetResponse {
let exif = ExifInfo {
exif_image_width: width,
exif_image_height: height,
make: make.map(String::from),
model: model.map(String::from),
date_time_original: timestamp.map(String::from),
latitude: lat,
longitude: lon,
city: None,
state: None,
country: None,
time_zone: None,
lens_model: None,
exposure_time: None,
f_number: None,
focal_length: None,
iso: None,
file_size_in_byte: None,
description: None,
rating: None,
orientation: None,
modify_date: None,
projection_type: None,
};
AssetResponse {
id: id.to_string(),
original_file_name: format!("{}.HEIC", id),
file_created_at: "2024-12-23T10:30:45Z".to_string(),
local_date_time: "2024-12-23T10:30:45".to_string(),
asset_type: AssetType::Image,
exif_info: Some(exif),
checksum: "abc123".to_string(),
is_trashed: false,
is_favorite: false,
is_archived: false,
has_metadata: true,
duration: "0:00:00.000000".to_string(),
owner_id: "owner-1".to_string(),
original_mime_type: Some("image/heic".to_string()),
duplicate_id: None,
thumbhash: None,
}
}
#[test]
fn test_detect_4_3_landscape() {
assert_eq!(
detect_aspect_ratio(5712, 4284),
Some(AspectRatio::FourThree)
);
}
#[test]
fn test_detect_4_3_portrait() {
assert_eq!(
detect_aspect_ratio(4284, 5712),
Some(AspectRatio::FourThree)
);
}
#[test]
fn test_detect_16_9_landscape() {
assert_eq!(
detect_aspect_ratio(5712, 3213),
Some(AspectRatio::SixteenNine)
);
}
#[test]
fn test_detect_16_9_portrait() {
assert_eq!(
detect_aspect_ratio(3213, 5712),
Some(AspectRatio::SixteenNine)
);
}
#[test]
fn test_detect_other_ratio_1_1() {
assert_eq!(detect_aspect_ratio(1000, 1000), None);
}
#[test]
fn test_detect_other_ratio_3_2() {
assert_eq!(detect_aspect_ratio(3000, 2000), None);
}
#[test]
fn test_detect_with_tolerance_4_3_edge() {
assert_eq!(
detect_aspect_ratio(1000, 745),
Some(AspectRatio::FourThree)
);
}
#[test]
fn test_detect_with_tolerance_16_9_edge() {
assert_eq!(
detect_aspect_ratio(1778, 1000),
Some(AspectRatio::SixteenNine)
);
}
#[test]
fn test_detect_zero_dimension() {
assert_eq!(detect_aspect_ratio(0, 100), None);
assert_eq!(detect_aspect_ratio(100, 0), None);
assert_eq!(detect_aspect_ratio(0, 0), None);
}
#[test]
fn test_detect_hd_16_9() {
assert_eq!(
detect_aspect_ratio(1920, 1080),
Some(AspectRatio::SixteenNine)
);
}
#[test]
fn test_detect_4k_16_9() {
assert_eq!(
detect_aspect_ratio(3840, 2160),
Some(AspectRatio::SixteenNine)
);
}
#[test]
fn test_find_pair_basic() {
let assets = vec![
mock_asset(
"asset-4-3",
Some(5712),
Some(4284),
Some("Apple"),
Some("iPhone 15 Pro Max"),
Some("2024-12-23T10:30:45.123Z"),
Some(51.5074),
Some(-0.1278),
),
mock_asset(
"asset-16-9",
Some(5712),
Some(3213),
Some("Apple"),
Some("iPhone 15 Pro Max"),
Some("2024-12-23T10:30:45.456Z"),
Some(51.5074),
Some(-0.1278),
),
];
let pairs = find_letterbox_pairs(&assets);
assert_eq!(pairs.len(), 1);
assert_eq!(pairs[0].keeper.id, "asset-4-3");
assert_eq!(pairs[0].delete.id, "asset-16-9");
assert_eq!(pairs[0].camera, "Apple iPhone 15 Pro Max");
}
#[test]
fn test_skip_non_iphone() {
let assets = vec![
mock_asset(
"asset-4-3",
Some(4000),
Some(3000),
Some("Samsung"),
Some("Galaxy S23"),
Some("2024-12-23T10:30:45Z"),
None,
None,
),
mock_asset(
"asset-16-9",
Some(4000),
Some(2250),
Some("Samsung"),
Some("Galaxy S23"),
Some("2024-12-23T10:30:45Z"),
None,
None,
),
];
let pairs = find_letterbox_pairs(&assets);
assert!(pairs.is_empty());
}
#[test]
fn test_skip_missing_timestamp() {
let assets = vec![
mock_asset(
"asset-4-3",
Some(5712),
Some(4284),
Some("Apple"),
Some("iPhone 15 Pro Max"),
None, None,
None,
),
mock_asset(
"asset-16-9",
Some(5712),
Some(3213),
Some("Apple"),
Some("iPhone 15 Pro Max"),
None, None,
None,
),
];
let pairs = find_letterbox_pairs(&assets);
assert!(pairs.is_empty());
}
#[test]
fn test_skip_ambiguous_two_4_3() {
let assets = vec![
mock_asset(
"asset-4-3-a",
Some(5712),
Some(4284),
Some("Apple"),
Some("iPhone 15 Pro Max"),
Some("2024-12-23T10:30:45Z"),
None,
None,
),
mock_asset(
"asset-4-3-b",
Some(5712),
Some(4284),
Some("Apple"),
Some("iPhone 15 Pro Max"),
Some("2024-12-23T10:30:45Z"),
None,
None,
),
mock_asset(
"asset-16-9",
Some(5712),
Some(3213),
Some("Apple"),
Some("iPhone 15 Pro Max"),
Some("2024-12-23T10:30:45Z"),
None,
None,
),
];
let pairs = find_letterbox_pairs(&assets);
assert!(pairs.is_empty()); }
#[test]
fn test_skip_ambiguous_two_16_9() {
let assets = vec![
mock_asset(
"asset-4-3",
Some(5712),
Some(4284),
Some("Apple"),
Some("iPhone 15 Pro Max"),
Some("2024-12-23T10:30:45Z"),
None,
None,
),
mock_asset(
"asset-16-9-a",
Some(5712),
Some(3213),
Some("Apple"),
Some("iPhone 15 Pro Max"),
Some("2024-12-23T10:30:45Z"),
None,
None,
),
mock_asset(
"asset-16-9-b",
Some(5712),
Some(3213),
Some("Apple"),
Some("iPhone 15 Pro Max"),
Some("2024-12-23T10:30:45Z"),
None,
None,
),
];
let pairs = find_letterbox_pairs(&assets);
assert!(pairs.is_empty()); }
#[test]
fn test_multiple_pairs_different_timestamps() {
let assets = vec![
mock_asset(
"pair1-4-3",
Some(5712),
Some(4284),
Some("Apple"),
Some("iPhone 15 Pro Max"),
Some("2024-12-23T10:30:45Z"),
None,
None,
),
mock_asset(
"pair1-16-9",
Some(5712),
Some(3213),
Some("Apple"),
Some("iPhone 15 Pro Max"),
Some("2024-12-23T10:30:45Z"),
None,
None,
),
mock_asset(
"pair2-4-3",
Some(5712),
Some(4284),
Some("Apple"),
Some("iPhone 15 Pro Max"),
Some("2024-12-23T11:00:00Z"),
None,
None,
),
mock_asset(
"pair2-16-9",
Some(5712),
Some(3213),
Some("Apple"),
Some("iPhone 15 Pro Max"),
Some("2024-12-23T11:00:00Z"),
None,
None,
),
];
let pairs = find_letterbox_pairs(&assets);
assert_eq!(pairs.len(), 2);
}
#[test]
fn test_gps_disambiguation() {
let assets = vec![
mock_asset(
"loc1-4-3",
Some(5712),
Some(4284),
Some("Apple"),
Some("iPhone 15 Pro Max"),
Some("2024-12-23T10:30:45Z"),
Some(51.5074), Some(-0.1278),
),
mock_asset(
"loc2-16-9",
Some(5712),
Some(3213),
Some("Apple"),
Some("iPhone 15 Pro Max"),
Some("2024-12-23T10:30:45Z"),
Some(40.7128), Some(-74.0060),
),
];
let pairs = find_letterbox_pairs(&assets);
assert!(pairs.is_empty());
}
#[test]
fn test_gps_same_location_pairs() {
let assets = vec![
mock_asset(
"asset-4-3",
Some(5712),
Some(4284),
Some("Apple"),
Some("iPhone 15 Pro Max"),
Some("2024-12-23T10:30:45Z"),
Some(51.5074),
Some(-0.1278),
),
mock_asset(
"asset-16-9",
Some(5712),
Some(3213),
Some("Apple"),
Some("iPhone 15 Pro Max"),
Some("2024-12-23T10:30:45Z"),
Some(51.5074), Some(-0.1278),
),
];
let pairs = find_letterbox_pairs(&assets);
assert_eq!(pairs.len(), 1);
}
#[test]
fn test_skip_trashed_assets() {
let mut asset_4_3 = mock_asset(
"asset-4-3",
Some(5712),
Some(4284),
Some("Apple"),
Some("iPhone 15 Pro Max"),
Some("2024-12-23T10:30:45Z"),
None,
None,
);
asset_4_3.is_trashed = true;
let asset_16_9 = mock_asset(
"asset-16-9",
Some(5712),
Some(3213),
Some("Apple"),
Some("iPhone 15 Pro Max"),
Some("2024-12-23T10:30:45Z"),
None,
None,
);
let assets = vec![asset_4_3, asset_16_9];
let pairs = find_letterbox_pairs(&assets);
assert!(pairs.is_empty()); }
#[test]
fn test_skip_missing_dimensions() {
let assets = vec![
mock_asset(
"asset-4-3",
None, None, Some("Apple"),
Some("iPhone 15 Pro Max"),
Some("2024-12-23T10:30:45Z"),
None,
None,
),
mock_asset(
"asset-16-9",
Some(5712),
Some(3213),
Some("Apple"),
Some("iPhone 15 Pro Max"),
Some("2024-12-23T10:30:45Z"),
None,
None,
),
];
let pairs = find_letterbox_pairs(&assets);
assert!(pairs.is_empty());
}
#[test]
fn test_different_iphone_models_no_pair() {
let assets = vec![
mock_asset(
"asset-4-3",
Some(5712),
Some(4284),
Some("Apple"),
Some("iPhone 15 Pro Max"),
Some("2024-12-23T10:30:45Z"),
None,
None,
),
mock_asset(
"asset-16-9",
Some(5712),
Some(3213),
Some("Apple"),
Some("iPhone 14 Pro"), Some("2024-12-23T10:30:45Z"),
None,
None,
),
];
let pairs = find_letterbox_pairs(&assets);
assert!(pairs.is_empty()); }
#[test]
fn test_only_4_3_no_pair() {
let assets = vec![mock_asset(
"asset-4-3",
Some(5712),
Some(4284),
Some("Apple"),
Some("iPhone 15 Pro Max"),
Some("2024-12-23T10:30:45Z"),
None,
None,
)];
let pairs = find_letterbox_pairs(&assets);
assert!(pairs.is_empty());
}
#[test]
fn test_only_16_9_no_pair() {
let assets = vec![mock_asset(
"asset-16-9",
Some(5712),
Some(3213),
Some("Apple"),
Some("iPhone 15 Pro Max"),
Some("2024-12-23T10:30:45Z"),
None,
None,
)];
let pairs = find_letterbox_pairs(&assets);
assert!(pairs.is_empty());
}
#[test]
fn test_subsecond_timestamp_handling() {
let assets = vec![
mock_asset(
"asset-4-3",
Some(5712),
Some(4284),
Some("Apple"),
Some("iPhone 15 Pro Max"),
Some("2024-12-23T10:30:45.123Z"),
None,
None,
),
mock_asset(
"asset-16-9",
Some(5712),
Some(3213),
Some("Apple"),
Some("iPhone 15 Pro Max"),
Some("2024-12-23T10:30:45.999Z"), None,
None,
),
];
let pairs = find_letterbox_pairs(&assets);
assert_eq!(pairs.len(), 1); }
fn mock_asset_with_size(
id: &str,
width: Option<u32>,
height: Option<u32>,
make: Option<&str>,
model: Option<&str>,
timestamp: Option<&str>,
file_size: Option<u64>,
) -> AssetResponse {
let exif = ExifInfo {
exif_image_width: width,
exif_image_height: height,
make: make.map(String::from),
model: model.map(String::from),
date_time_original: timestamp.map(String::from),
latitude: None,
longitude: None,
city: None,
state: None,
country: None,
time_zone: None,
lens_model: None,
exposure_time: None,
f_number: None,
focal_length: None,
iso: None,
file_size_in_byte: file_size,
description: None,
rating: None,
orientation: None,
modify_date: None,
projection_type: None,
};
AssetResponse {
id: id.to_string(),
original_file_name: format!("{}.HEIC", id),
file_created_at: "2024-12-23T10:30:45Z".to_string(),
local_date_time: "2024-12-23T10:30:45".to_string(),
asset_type: AssetType::Image,
exif_info: Some(exif),
checksum: "abc123".to_string(),
is_trashed: false,
is_favorite: false,
is_archived: false,
has_metadata: true,
duration: "0:00:00.000000".to_string(),
owner_id: "owner-1".to_string(),
original_mime_type: Some("image/heic".to_string()),
duplicate_id: None,
thumbhash: None,
}
}
#[test]
fn test_letterbox_analysis_from_assets() {
let assets = vec![
mock_asset_with_size(
"keeper-1",
Some(5712),
Some(4284), Some("Apple"),
Some("iPhone 15 Pro Max"),
Some("2024-12-23T10:30:45Z"),
Some(10_000_000), ),
mock_asset_with_size(
"delete-1",
Some(5712),
Some(3213), Some("Apple"),
Some("iPhone 15 Pro Max"),
Some("2024-12-23T10:30:45Z"),
Some(8_000_000), ),
mock_asset_with_size(
"android-1",
Some(4000),
Some(3000),
Some("Samsung"),
Some("Galaxy S23"),
Some("2024-12-23T11:00:00Z"),
Some(5_000_000),
),
];
let analysis = LetterboxAnalysis::from_assets(&assets);
assert_eq!(analysis.total_pairs, 1);
assert_eq!(analysis.pairs.len(), 1);
assert_eq!(analysis.total_space_recoverable, 8_000_000);
assert_eq!(analysis.skipped_non_iphone, 1);
assert_eq!(analysis.skipped_ambiguous, 0);
assert!(!analysis.analyzed_at.is_empty());
}
#[test]
fn test_letterbox_analysis_delete_ids() {
let assets = vec![
mock_asset_with_size(
"keeper-1",
Some(5712),
Some(4284),
Some("Apple"),
Some("iPhone 15 Pro Max"),
Some("2024-12-23T10:30:45Z"),
Some(10_000_000),
),
mock_asset_with_size(
"delete-1",
Some(5712),
Some(3213),
Some("Apple"),
Some("iPhone 15 Pro Max"),
Some("2024-12-23T10:30:45Z"),
Some(8_000_000),
),
];
let analysis = LetterboxAnalysis::from_assets(&assets);
let delete_ids = analysis.delete_ids();
assert_eq!(delete_ids.len(), 1);
assert_eq!(delete_ids[0], "delete-1");
}
#[test]
fn test_letterbox_analysis_keeper_ids() {
let assets = vec![
mock_asset_with_size(
"keeper-1",
Some(5712),
Some(4284),
Some("Apple"),
Some("iPhone 15 Pro Max"),
Some("2024-12-23T10:30:45Z"),
Some(10_000_000),
),
mock_asset_with_size(
"delete-1",
Some(5712),
Some(3213),
Some("Apple"),
Some("iPhone 15 Pro Max"),
Some("2024-12-23T10:30:45Z"),
Some(8_000_000),
),
];
let analysis = LetterboxAnalysis::from_assets(&assets);
let keeper_ids = analysis.keeper_ids();
assert_eq!(keeper_ids.len(), 1);
assert_eq!(keeper_ids[0], "keeper-1");
}
#[test]
fn test_letterbox_analysis_serialization() {
let assets = vec![
mock_asset_with_size(
"keeper-1",
Some(5712),
Some(4284),
Some("Apple"),
Some("iPhone 15 Pro Max"),
Some("2024-12-23T10:30:45Z"),
Some(10_000_000),
),
mock_asset_with_size(
"delete-1",
Some(5712),
Some(3213),
Some("Apple"),
Some("iPhone 15 Pro Max"),
Some("2024-12-23T10:30:45Z"),
Some(8_000_000),
),
];
let analysis = LetterboxAnalysis::from_assets(&assets);
let json = serde_json::to_string_pretty(&analysis).expect("should serialize to JSON");
assert!(json.contains("total_pairs"));
assert!(json.contains("total_space_recoverable"));
assert!(json.contains("keeper"));
assert!(json.contains("delete"));
let parsed: LetterboxAnalysis =
serde_json::from_str(&json).expect("should deserialize from JSON");
assert_eq!(parsed.total_pairs, analysis.total_pairs);
assert_eq!(
parsed.total_space_recoverable,
analysis.total_space_recoverable
);
}
#[test]
fn test_letterbox_analysis_empty() {
let assets: Vec<AssetResponse> = vec![];
let analysis = LetterboxAnalysis::from_assets(&assets);
assert_eq!(analysis.total_pairs, 0);
assert_eq!(analysis.pairs.len(), 0);
assert_eq!(analysis.total_space_recoverable, 0);
assert_eq!(analysis.skipped_non_iphone, 0);
assert_eq!(analysis.skipped_ambiguous, 0);
}
}