From eb8e08b9ffad57f596bbd952470d9d9501a42cf3 Mon Sep 17 00:00:00 2001 From: Cameron Date: Thu, 18 Dec 2025 09:34:07 -0500 Subject: [PATCH] Add EXIF search infrastructure (Phase 1 & 2) Implements foundation for EXIF-based photo search capabilities: - Add geo.rs module with GPS distance calculations (Haversine + bounding box) - Extend FilesRequest with EXIF search parameters (camera, GPS, date, media type) - Add MediaType enum and DateTakenAsc/DateTakenDesc sort options - Create date_taken index migration for efficient date queries - Implement ExifDao methods: get_exif_batch, query_by_exif, get_camera_makes - Add FileWithMetadata struct for date-aware sorting - Implement date sorting with filename extraction fallback - Make extract_date_from_filename public for reuse Next: Integrate EXIF filtering into list_photos() and enhance get_all_tags() --- .../down.sql | 2 + .../up.sql | 2 + src/data/mod.rs | 27 ++++ src/database/mod.rs | 103 +++++++++++++++ src/files.rs | 58 +++++++++ src/geo.rs | 120 ++++++++++++++++++ src/main.rs | 1 + src/memories.rs | 2 +- 8 files changed, 314 insertions(+), 1 deletion(-) create mode 100644 migrations/2025-12-18-120000_add_date_taken_index/down.sql create mode 100644 migrations/2025-12-18-120000_add_date_taken_index/up.sql create mode 100644 src/geo.rs diff --git a/migrations/2025-12-18-120000_add_date_taken_index/down.sql b/migrations/2025-12-18-120000_add_date_taken_index/down.sql new file mode 100644 index 0000000..4b0b0f6 --- /dev/null +++ b/migrations/2025-12-18-120000_add_date_taken_index/down.sql @@ -0,0 +1,2 @@ +-- Remove date_taken index +DROP INDEX IF EXISTS idx_image_exif_date_taken; diff --git a/migrations/2025-12-18-120000_add_date_taken_index/up.sql b/migrations/2025-12-18-120000_add_date_taken_index/up.sql new file mode 100644 index 0000000..d29931a --- /dev/null +++ b/migrations/2025-12-18-120000_add_date_taken_index/up.sql @@ -0,0 +1,2 @@ +-- Add index on date_taken for efficient date range queries +CREATE INDEX IF NOT EXISTS idx_image_exif_date_taken ON image_exif(date_taken); diff --git a/src/data/mod.rs b/src/data/mod.rs index 7692287..cf279b7 100644 --- a/src/data/mod.rs +++ b/src/data/mod.rs @@ -109,6 +109,8 @@ pub enum SortType { NameDesc, TagCountAsc, TagCountDesc, + DateTakenAsc, + DateTakenDesc, } #[derive(Deserialize)] @@ -120,6 +122,23 @@ pub struct FilesRequest { pub tag_filter_mode: Option, pub recursive: Option, pub sort: Option, + + // EXIF-based search parameters + pub camera_make: Option, + pub camera_model: Option, + pub lens_model: Option, + + // GPS location search + pub gps_lat: Option, + pub gps_lon: Option, + pub gps_radius_km: Option, + + // Date range filtering (Unix timestamps) + pub date_from: Option, + pub date_to: Option, + + // Media type filtering + pub media_type: Option, } #[derive(Copy, Clone, Deserialize, PartialEq, Debug)] @@ -128,6 +147,14 @@ pub enum FilterMode { All, } +#[derive(Copy, Clone, Deserialize, PartialEq, Debug)] +#[serde(rename_all = "lowercase")] +pub enum MediaType { + Photo, + Video, + All, +} + #[derive(Copy, Clone, Deserialize, PartialEq, Debug)] #[serde(rename_all = "lowercase")] pub enum PhotoSize { diff --git a/src/database/mod.rs b/src/database/mod.rs index f8f678c..018a0ea 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -191,6 +191,23 @@ pub trait ExifDao: Sync + Send { fn update_exif(&mut self, exif_data: InsertImageExif) -> Result; fn delete_exif(&mut self, file_path: &str) -> Result<(), DbError>; fn get_all_with_date_taken(&mut self) -> Result, DbError>; + + /// Batch load EXIF data for multiple file paths (single query) + fn get_exif_batch(&mut self, file_paths: &[String]) -> Result, DbError>; + + /// Query files by EXIF criteria with optional filters + fn query_by_exif( + &mut self, + camera_make: Option<&str>, + camera_model: Option<&str>, + lens_model: Option<&str>, + gps_bounds: Option<(f64, f64, f64, f64)>, // (min_lat, max_lat, min_lon, max_lon) + date_from: Option, + date_to: Option, + ) -> Result, DbError>; + + /// Get distinct camera makes with counts + fn get_camera_makes(&mut self) -> Result, DbError>; } pub struct SqliteExifDao { @@ -295,4 +312,90 @@ impl ExifDao for SqliteExifDao { }) .map_err(|_| DbError::new(DbErrorKind::QueryError)) } + + fn get_exif_batch(&mut self, file_paths: &[String]) -> Result, DbError> { + use schema::image_exif::dsl::*; + + if file_paths.is_empty() { + return Ok(Vec::new()); + } + + let mut connection = self.connection.lock().expect("Unable to get ExifDao"); + + image_exif + .filter(file_path.eq_any(file_paths)) + .load::(connection.deref_mut()) + .map_err(|_| DbError::new(DbErrorKind::QueryError)) + } + + fn query_by_exif( + &mut self, + camera_make_filter: Option<&str>, + camera_model_filter: Option<&str>, + lens_model_filter: Option<&str>, + gps_bounds: Option<(f64, f64, f64, f64)>, + date_from: Option, + date_to: Option, + ) -> Result, DbError> { + use schema::image_exif::dsl::*; + + let mut connection = self.connection.lock().expect("Unable to get ExifDao"); + let mut query = image_exif.into_boxed(); + + // Camera filters (case-insensitive partial match) + if let Some(make) = camera_make_filter { + query = query.filter(camera_make.like(format!("%{}%", make))); + } + if let Some(model) = camera_model_filter { + query = query.filter(camera_model.like(format!("%{}%", model))); + } + if let Some(lens) = lens_model_filter { + query = query.filter(lens_model.like(format!("%{}%", lens))); + } + + // GPS bounding box + if let Some((min_lat, max_lat, min_lon, max_lon)) = gps_bounds { + query = query + .filter(gps_latitude.between(min_lat, max_lat)) + .filter(gps_longitude.between(min_lon, max_lon)) + .filter(gps_latitude.is_not_null()) + .filter(gps_longitude.is_not_null()); + } + + // Date range + if let Some(from) = date_from { + query = query.filter(date_taken.ge(from)); + } + if let Some(to) = date_to { + query = query.filter(date_taken.le(to)); + } + if date_from.is_some() || date_to.is_some() { + query = query.filter(date_taken.is_not_null()); + } + + query + .load::(connection.deref_mut()) + .map_err(|_| DbError::new(DbErrorKind::QueryError)) + } + + fn get_camera_makes(&mut self) -> Result, DbError> { + use diesel::dsl::count; + use schema::image_exif::dsl::*; + + let mut connection = self.connection.lock().expect("Unable to get ExifDao"); + + image_exif + .filter(camera_make.is_not_null()) + .group_by(camera_make) + .select((camera_make, count(id))) + .order(count(id).desc()) + .load::<(Option, i64)>(connection.deref_mut()) + .map(|records| { + records + .into_iter() + .filter_map(|(make, cnt)| make.map(|m| (m, cnt))) + .collect() + }) + .map_err(|_| DbError::new(DbErrorKind::QueryError)) + } } diff --git a/src/files.rs b/src/files.rs index a589362..0fbda63 100644 --- a/src/files.rs +++ b/src/files.rs @@ -28,6 +28,14 @@ use crate::video::actors::StreamActor; use path_absolutize::*; use rand::prelude::SliceRandom; use rand::thread_rng; + +/// File metadata for sorting and filtering +/// Includes tag count and optional date for date-based sorting +pub struct FileWithMetadata { + pub file_name: String, + pub tag_count: i64, + pub date_taken: Option, // Unix timestamp from EXIF or filename extraction +} use serde::Deserialize; pub async fn list_photos( @@ -264,6 +272,56 @@ fn sort(mut files: Vec, sort_type: SortType) -> Vec { SortType::TagCountDesc => { files.sort_by(|l, r| r.tag_count.cmp(&l.tag_count)); } + SortType::DateTakenAsc | SortType::DateTakenDesc => { + // Date sorting not yet implemented for FileWithTagCount + // Will be implemented when integrating with FileWithMetadata + // For now, fall back to name sorting + files.sort_by(|l, r| l.file_name.cmp(&r.file_name)); + } + } + + files + .iter() + .map(|f| f.file_name.clone()) + .collect::>() +} + +/// Sort files with metadata support (including date sorting) +fn sort_with_metadata(mut files: Vec, sort_type: SortType) -> Vec { + match sort_type { + SortType::Shuffle => files.shuffle(&mut thread_rng()), + SortType::NameAsc => { + files.sort_by(|l, r| l.file_name.cmp(&r.file_name)); + } + SortType::NameDesc => { + files.sort_by(|l, r| r.file_name.cmp(&l.file_name)); + } + SortType::TagCountAsc => { + files.sort_by(|l, r| l.tag_count.cmp(&r.tag_count)); + } + SortType::TagCountDesc => { + files.sort_by(|l, r| r.tag_count.cmp(&l.tag_count)); + } + SortType::DateTakenAsc => { + files.sort_by(|l, r| { + match (l.date_taken, r.date_taken) { + (Some(a), Some(b)) => a.cmp(&b), + (Some(_), None) => std::cmp::Ordering::Less, // Dated photos first + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => l.file_name.cmp(&r.file_name), // Fallback to name + } + }); + } + SortType::DateTakenDesc => { + files.sort_by(|l, r| { + match (l.date_taken, r.date_taken) { + (Some(a), Some(b)) => b.cmp(&a), // Reverse for descending + (Some(_), None) => std::cmp::Ordering::Less, // Dated photos first + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => r.file_name.cmp(&l.file_name), // Fallback reversed + } + }); + } } files diff --git a/src/geo.rs b/src/geo.rs new file mode 100644 index 0000000..eea7e0a --- /dev/null +++ b/src/geo.rs @@ -0,0 +1,120 @@ +/// Geographic calculation utilities for GPS-based search +use std::f64; + +/// Calculate distance between two GPS coordinates using the Haversine formula. +/// Returns distance in kilometers. +/// +/// # Arguments +/// * `lat1` - Latitude of first point in decimal degrees +/// * `lon1` - Longitude of first point in decimal degrees +/// * `lat2` - Latitude of second point in decimal degrees +/// * `lon2` - Longitude of second point in decimal degrees +/// +/// # Example +/// ``` +/// let distance = haversine_distance(37.7749, -122.4194, 34.0522, -118.2437); +/// // Distance between San Francisco and Los Angeles (~559 km) +/// ``` +pub fn haversine_distance(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 { + const EARTH_RADIUS_KM: f64 = 6371.0; + + let lat1_rad = lat1.to_radians(); + let lat2_rad = lat2.to_radians(); + let delta_lat = (lat2 - lat1).to_radians(); + let delta_lon = (lon2 - lon1).to_radians(); + + let a = (delta_lat / 2.0).sin().powi(2) + + lat1_rad.cos() * lat2_rad.cos() * (delta_lon / 2.0).sin().powi(2); + let c = 2.0 * a.sqrt().atan2((1.0 - a).sqrt()); + + EARTH_RADIUS_KM * c +} + +/// Calculate bounding box for GPS radius query. +/// Returns (min_lat, max_lat, min_lon, max_lon) that encompasses the search radius. +/// +/// This is used as a fast first-pass filter for GPS queries, narrowing down +/// candidates before applying the more expensive Haversine distance calculation. +/// +/// # Arguments +/// * `lat` - Center latitude in decimal degrees +/// * `lon` - Center longitude in decimal degrees +/// * `radius_km` - Search radius in kilometers +/// +/// # Returns +/// A tuple of (min_lat, max_lat, min_lon, max_lon) in decimal degrees +pub fn gps_bounding_box(lat: f64, lon: f64, radius_km: f64) -> (f64, f64, f64, f64) { + const EARTH_RADIUS_KM: f64 = 6371.0; + + // Calculate latitude delta (same at all latitudes) + let lat_delta = (radius_km / EARTH_RADIUS_KM) * (180.0 / f64::consts::PI); + + // Calculate longitude delta (varies with latitude) + let lon_delta = lat_delta / lat.to_radians().cos(); + + ( + lat - lat_delta, // min_lat + lat + lat_delta, // max_lat + lon - lon_delta, // min_lon + lon + lon_delta, // max_lon + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_haversine_distance_sf_to_la() { + // San Francisco to Los Angeles + let distance = haversine_distance(37.7749, -122.4194, 34.0522, -118.2437); + // Should be approximately 559 km + assert!( + (distance - 559.0).abs() < 10.0, + "Distance should be ~559km, got {}", + distance + ); + } + + #[test] + fn test_haversine_distance_same_point() { + // Same point should have zero distance + let distance = haversine_distance(37.7749, -122.4194, 37.7749, -122.4194); + assert!( + distance < 0.001, + "Same point should have ~0 distance, got {}", + distance + ); + } + + #[test] + fn test_gps_bounding_box() { + // Test bounding box calculation for 10km radius around San Francisco + let (min_lat, max_lat, min_lon, max_lon) = gps_bounding_box(37.7749, -122.4194, 10.0); + + // Verify the bounds are reasonable + assert!(min_lat < 37.7749, "min_lat should be less than center"); + assert!(max_lat > 37.7749, "max_lat should be greater than center"); + assert!(min_lon < -122.4194, "min_lon should be less than center"); + assert!(max_lon > -122.4194, "max_lon should be greater than center"); + + // Verify bounds span roughly the right distance + let lat_span = max_lat - min_lat; + assert!( + lat_span > 0.1 && lat_span < 0.3, + "Latitude span should be reasonable for 10km" + ); + } + + #[test] + fn test_haversine_distance_across_equator() { + // Test across equator + let distance = haversine_distance(1.0, 0.0, -1.0, 0.0); + // Should be approximately 222 km + assert!( + (distance - 222.0).abs() < 5.0, + "Distance should be ~222km, got {}", + distance + ); + } +} diff --git a/src/main.rs b/src/main.rs index 232c8cc..948c2dd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -57,6 +57,7 @@ mod database; mod error; mod exif; mod files; +mod geo; mod state; mod tags; mod video; diff --git a/src/memories.rs b/src/memories.rs index e3acfd0..e723200 100644 --- a/src/memories.rs +++ b/src/memories.rs @@ -232,7 +232,7 @@ fn extract_metadata_timestamps( (created, modified) } -fn extract_date_from_filename(filename: &str) -> Option> { +pub fn extract_date_from_filename(filename: &str) -> Option> { let build_date_from_ymd_capture = |captures: ®ex::Captures| -> Option> { let year = captures.get(1)?.as_str().parse::().ok()?;