pinakes/crates/pinakes-core/src/events.rs
NotAShelf b8ff35acea
various: inherit workspace lints in all crates; eliminate unwrap()
throughout

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Id8de9d65139ec4cf4cdeaee14c8c95b06a6a6964
2026-03-08 00:43:16 +03:00

222 lines
6.5 KiB
Rust

//! 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<f64>,
/// 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<Utc>,
/// End time of the event
pub end_time: DateTime<Utc>,
/// Media items in this event
pub items: Vec<MediaId>,
/// 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<MediaItem>,
config: &EventDetectionConfig,
) -> Result<Vec<DetectedEvent>> {
// 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<DetectedEvent> = Vec::new();
let Some(first_date) = items[0].date_taken else {
return Ok(Vec::new());
};
let mut current_event_items: Vec<MediaId> = 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<MediaItem>,
max_gap_secs: i64,
min_burst_size: usize,
) -> Result<Vec<Vec<MediaId>>> {
// 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<MediaId>> = Vec::new();
let Some(first_date) = items[0].date_taken else {
return Ok(Vec::new());
};
let mut current_burst: Vec<MediaId> = 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)
}