//! Auto-detection of photo events and albums based on time and location //! proximity use chrono::{DateTime, Utc}; use crate::{ error::Result, model::{MediaId, MediaItem}, }; /// Configuration for event detection #[derive(Debug, Clone)] pub struct EventDetectionConfig { /// Maximum time gap between photos in the same event (in seconds) pub max_time_gap_secs: i64, /// Minimum number of photos to form an event pub min_photos: usize, /// Maximum distance between photos in the same event (in kilometers) /// None means location is not considered pub max_distance_km: Option, /// Consider photos on the same day as potentially the same event pub same_day_threshold: bool, } impl Default for EventDetectionConfig { fn default() -> Self { Self { max_time_gap_secs: 2 * 60 * 60, // 2 hours min_photos: 5, max_distance_km: Some(1.0), // 1km same_day_threshold: true, } } } /// A detected photo event/album #[derive(Debug, Clone)] pub struct DetectedEvent { /// Suggested name for the event (e.g., "Photos from 2024-01-15") pub suggested_name: String, /// Start time of the event pub start_time: DateTime, /// End time of the event pub end_time: DateTime, /// Media items in this event pub items: Vec, /// Representative location (if available) pub location: Option<(f64, f64)>, // (latitude, longitude) } /// Calculate Haversine distance between two GPS coordinates in kilometers fn haversine_distance(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 { const EARTH_RADIUS_KM: f64 = 6371.0; let dlat = (lat2 - lat1).to_radians(); let dlon = (lon2 - lon1).to_radians(); let a = (dlat / 2.0).sin().powi(2) + lat1.to_radians().cos() * lat2.to_radians().cos() * (dlon / 2.0).sin().powi(2); let c = 2.0 * a.sqrt().atan2((1.0 - a).sqrt()); EARTH_RADIUS_KM * c } /// Detect photo events from a list of media items pub fn detect_events( mut items: Vec, config: &EventDetectionConfig, ) -> Result> { // Filter to only photos with date_taken items.retain(|item| item.date_taken.is_some()); if items.is_empty() { return Ok(Vec::new()); } // Sort by date_taken (None < Some, but all are Some after retain) items.sort_by_key(|a| a.date_taken); let mut events: Vec = Vec::new(); let Some(first_date) = items[0].date_taken else { return Ok(Vec::new()); }; let mut current_event_items: Vec = vec![items[0].id]; let mut current_start_time = first_date; let mut current_last_time = first_date; let mut current_location = items[0].latitude.zip(items[0].longitude); for item in items.iter().skip(1) { let Some(item_time) = item.date_taken else { continue; }; let time_gap = (item_time - current_last_time).num_seconds(); // Check time gap let time_ok = if config.same_day_threshold { // Same day or within time gap item_time.date_naive() == current_last_time.date_naive() || time_gap <= config.max_time_gap_secs } else { time_gap <= config.max_time_gap_secs }; // Check location proximity if both have GPS data let location_ok = match ( config.max_distance_km, current_location, item.latitude.zip(item.longitude), ) { (Some(max_dist), Some((lat1, lon1)), Some((lat2, lon2))) => { let dist = haversine_distance(lat1, lon1, lat2, lon2); dist <= max_dist }, // If no location constraint or missing GPS, consider location OK _ => true, }; if time_ok && location_ok { // Add to current event current_event_items.push(item.id); current_last_time = item_time; // Update location to average if available if let (Some((lat1, lon1)), Some((lat2, lon2))) = (current_location, item.latitude.zip(item.longitude)) { current_location = Some(((lat1 + lat2) / 2.0, (lon1 + lon2) / 2.0)); } else if item.latitude.is_some() && item.longitude.is_some() { current_location = item.latitude.zip(item.longitude); } } else { // Start new event if current has enough photos if current_event_items.len() >= config.min_photos { let event_name = format!("Event on {}", current_start_time.format("%Y-%m-%d")); events.push(DetectedEvent { suggested_name: event_name, start_time: current_start_time, end_time: current_last_time, items: current_event_items.clone(), location: current_location, }); } // Reset for new event current_event_items = vec![item.id]; current_start_time = item_time; current_last_time = item_time; current_location = item.latitude.zip(item.longitude); } } // Don't forget the last event if current_event_items.len() >= config.min_photos { let event_name = format!("Event on {}", current_start_time.format("%Y-%m-%d")); events.push(DetectedEvent { suggested_name: event_name, start_time: current_start_time, end_time: current_last_time, items: current_event_items, location: current_location, }); } Ok(events) } /// Detect photo bursts (rapid sequences of photos) /// Returns groups of media IDs that are likely burst sequences pub fn detect_bursts( mut items: Vec, max_gap_secs: i64, min_burst_size: usize, ) -> Result>> { // Filter to only photos with date_taken items.retain(|item| item.date_taken.is_some()); if items.is_empty() { return Ok(Vec::new()); } // Sort by date_taken (None < Some, but all are Some after retain) items.sort_by_key(|a| a.date_taken); let mut bursts: Vec> = Vec::new(); let Some(first_date) = items[0].date_taken else { return Ok(Vec::new()); }; let mut current_burst: Vec = vec![items[0].id]; let mut last_time = first_date; for item in items.iter().skip(1) { let Some(item_time) = item.date_taken else { continue; }; let gap = (item_time - last_time).num_seconds(); if gap <= max_gap_secs { current_burst.push(item.id); } else { if current_burst.len() >= min_burst_size { bursts.push(current_burst.clone()); } current_burst = vec![item.id]; } last_time = item_time; } // Don't forget the last burst if current_burst.len() >= min_burst_size { bursts.push(current_burst); } Ok(bursts) }