diff --git a/src/files.rs b/src/files.rs index 0fbda63..beb7916 100644 --- a/src/files.rs +++ b/src/files.rs @@ -1,3 +1,4 @@ +use std::collections::HashSet; use std::fmt::Debug; use std::fs::read_dir; use std::io; @@ -9,14 +10,17 @@ use ::anyhow; use actix::{Handler, Message}; use anyhow::{Context, anyhow}; -use crate::data::{Claims, FilesRequest, FilterMode, PhotosResponse, SortType}; +use crate::data::{Claims, FilesRequest, FilterMode, MediaType, PhotosResponse, SortType}; +use crate::database::ExifDao; +use crate::geo::{gps_bounding_box, haversine_distance}; +use crate::memories::extract_date_from_filename; use crate::{AppState, create_thumbnails}; use actix_web::web::Data; use actix_web::{ HttpRequest, HttpResponse, web::{self, Query}, }; -use log::{debug, error, info, trace}; +use log::{debug, error, info, trace, warn}; use opentelemetry::KeyValue; use opentelemetry::trace::{Span, Status, TraceContextExt, Tracer}; @@ -45,6 +49,7 @@ pub async fn list_photos( app_state: web::Data, file_system: web::Data, tag_dao: web::Data>, + exif_dao: web::Data>>, ) -> HttpResponse { let search_path = &req.path; @@ -67,10 +72,119 @@ pub async fn list_photos( req.exclude_tag_ids.clone().unwrap_or_default().to_string(), ), KeyValue::new("sort", format!("{:?}", &req.sort.unwrap_or(NameAsc))), + // EXIF search parameters + KeyValue::new("camera_make", req.camera_make.clone().unwrap_or_default()), + KeyValue::new("camera_model", req.camera_model.clone().unwrap_or_default()), + KeyValue::new("lens_model", req.lens_model.clone().unwrap_or_default()), + KeyValue::new( + "gps_lat", + req.gps_lat.map(|v| v.to_string()).unwrap_or_default(), + ), + KeyValue::new( + "gps_lon", + req.gps_lon.map(|v| v.to_string()).unwrap_or_default(), + ), + KeyValue::new( + "gps_radius_km", + req.gps_radius_km.map(|v| v.to_string()).unwrap_or_default(), + ), + KeyValue::new( + "date_from", + req.date_from.map(|v| v.to_string()).unwrap_or_default(), + ), + KeyValue::new( + "date_to", + req.date_to.map(|v| v.to_string()).unwrap_or_default(), + ), + KeyValue::new( + "media_type", + req.media_type + .as_ref() + .map(|mt| format!("{:?}", mt)) + .unwrap_or_default(), + ), ]); let span_context = opentelemetry::Context::current_with_span(span); + // Check if EXIF filtering is requested + let has_exif_filters = req.camera_make.is_some() + || req.camera_model.is_some() + || req.lens_model.is_some() + || req.gps_lat.is_some() + || req.date_from.is_some() + || req.date_to.is_some(); + + // Apply EXIF-based filtering if requested + let exif_matched_files: Option> = if has_exif_filters { + // Validate GPS parameters (all 3 must be present together) + if (req.gps_lat.is_some() || req.gps_lon.is_some() || req.gps_radius_km.is_some()) + && !(req.gps_lat.is_some() && req.gps_lon.is_some() && req.gps_radius_km.is_some()) + { + warn!("GPS search requires lat, lon, and radius_km to all be specified"); + span_context + .span() + .set_status(Status::error("Invalid GPS parameters")); + return HttpResponse::BadRequest().body("GPS search requires lat, lon, and radius_km"); + } + + // Calculate GPS bounding box if GPS search is requested + let gps_bounds = if let (Some(lat), Some(lon), Some(radius_km)) = + (req.gps_lat, req.gps_lon, req.gps_radius_km) + { + let (min_lat, max_lat, min_lon, max_lon) = gps_bounding_box(lat, lon, radius_km); + Some((min_lat, max_lat, min_lon, max_lon)) + } else { + None + }; + + // Query EXIF database + let mut exif_dao_guard = exif_dao.lock().expect("Unable to get ExifDao"); + let exif_results = exif_dao_guard + .query_by_exif( + req.camera_make.as_deref(), + req.camera_model.as_deref(), + req.lens_model.as_deref(), + gps_bounds, + req.date_from, + req.date_to, + ) + .unwrap_or_else(|e| { + warn!("EXIF query failed: {:?}", e); + Vec::new() + }); + + // Apply precise GPS distance filtering if GPS search was requested + let filtered_results = if let (Some(lat), Some(lon), Some(radius_km)) = + (req.gps_lat, req.gps_lon, req.gps_radius_km) + { + exif_results + .into_iter() + .filter(|exif| { + if let (Some(photo_lat), Some(photo_lon)) = + (exif.gps_latitude, exif.gps_longitude) + { + let distance = haversine_distance(lat, lon, photo_lat, photo_lon); + distance <= radius_km + } else { + false + } + }) + .map(|exif| exif.file_path) + .collect::>() + } else { + exif_results + .into_iter() + .map(|exif| exif.file_path) + .collect::>() + }; + + info!("EXIF filtering matched {} files", filtered_results.len()); + Some(filtered_results) + } else { + None + }; + let search_recursively = req.recursive.unwrap_or(false); if let Some(tag_ids) = &req.tag_ids && search_recursively @@ -128,6 +242,14 @@ pub async fn list_photos( search_path.strip_suffix('/').unwrap_or_else(|| search_path) )) }) + .filter(|f| { + // Apply EXIF filtering if present + if let Some(ref exif_files) = exif_matched_files { + exif_files.contains(&f.file_name) + } else { + true + } + }) .collect::>() }) .map(|files| sort(files, req.sort.unwrap_or(NameAsc))) @@ -211,21 +333,83 @@ pub async fn list_photos( true }) + .filter(|(file_name, _)| { + // Apply EXIF filtering if present + if let Some(ref exif_files) = exif_matched_files { + exif_files.contains(file_name) + } else { + true + } + }) + .filter(|(file_name, _)| { + // Apply media type filtering if specified + if let Some(ref media_type) = req.media_type { + let path = PathBuf::from(file_name); + matches_media_type(&path, media_type) + } else { + true + } + }) .map(|(file_name, tags)| FileWithTagCount { file_name, tag_count: tags.len() as i64, }) .collect::>(); - let mut response_files = photos - .clone() - .into_iter() - .map(|f| f.file_name) - .collect::>(); - if let Some(sort_type) = req.sort { - debug!("Sorting files: {:?}", sort_type); - response_files = sort(photos, sort_type) - } + // Handle sorting - use FileWithMetadata for date sorting to support EXIF dates + let response_files = if let Some(sort_type) = req.sort { + match sort_type { + SortType::DateTakenAsc | SortType::DateTakenDesc => { + debug!("Date sorting requested, fetching EXIF data"); + + // Collect file paths for batch EXIF query + let file_paths: Vec = + photos.iter().map(|f| f.file_name.clone()).collect(); + + // Batch fetch EXIF data + let mut exif_dao_guard = exif_dao.lock().expect("Unable to get ExifDao"); + let exif_map: std::collections::HashMap = exif_dao_guard + .get_exif_batch(&file_paths) + .unwrap_or_default() + .into_iter() + .filter_map(|exif| exif.date_taken.map(|dt| (exif.file_path, dt))) + .collect(); + drop(exif_dao_guard); + + // Convert to FileWithMetadata with date fallback logic + let files_with_metadata: Vec = photos + .into_iter() + .map(|f| { + // Try EXIF date first + let date_taken = + exif_map.get(&f.file_name).copied().or_else(|| { + // Fallback to filename extraction + extract_date_from_filename(&f.file_name) + .map(|dt| dt.timestamp()) + }); + + FileWithMetadata { + file_name: f.file_name, + tag_count: f.tag_count, + date_taken, + } + }) + .collect(); + + sort_with_metadata(files_with_metadata, sort_type) + } + _ => { + // Use regular sort for non-date sorting + sort(photos, sort_type) + } + } + } else { + // No sorting requested + photos + .into_iter() + .map(|f| f.file_name) + .collect::>() + }; let dirs = files .iter() @@ -369,6 +553,31 @@ pub fn is_image_or_video(path: &Path) -> bool { || extension == "avif" } +/// Check if a file matches the media type filter +fn matches_media_type(path: &Path, media_type: &MediaType) -> bool { + let extension = path + .extension() + .and_then(|p| p.to_str()) + .map_or(String::from(""), |p| p.to_lowercase()); + + match media_type { + MediaType::All => true, + MediaType::Photo => { + extension == "png" + || extension == "jpg" + || extension == "jpeg" + || extension == "nef" + || extension == "webp" + || extension == "tiff" + || extension == "tif" + || extension == "heif" + || extension == "heic" + || extension == "avif" + } + MediaType::Video => extension == "mp4" || extension == "mov", + } +} + pub fn is_valid_full_path + Debug + AsRef>( base: &P, path: &P, @@ -571,6 +780,104 @@ mod tests { } } + struct MockExifDao; + + impl crate::database::ExifDao for MockExifDao { + fn store_exif( + &mut self, + data: crate::database::models::InsertImageExif, + ) -> Result { + // Return a dummy ImageExif for tests + Ok(crate::database::models::ImageExif { + id: 1, + file_path: data.file_path.to_string(), + camera_make: data.camera_make.map(|s| s.to_string()), + camera_model: data.camera_model.map(|s| s.to_string()), + lens_model: data.lens_model.map(|s| s.to_string()), + width: data.width, + height: data.height, + orientation: data.orientation, + gps_latitude: data.gps_latitude, + gps_longitude: data.gps_longitude, + gps_altitude: data.gps_altitude, + focal_length: data.focal_length, + aperture: data.aperture, + shutter_speed: data.shutter_speed, + iso: data.iso, + date_taken: data.date_taken, + created_time: data.created_time, + last_modified: data.last_modified, + }) + } + + fn get_exif( + &mut self, + _: &str, + ) -> Result, crate::database::DbError> { + Ok(None) + } + + fn update_exif( + &mut self, + data: crate::database::models::InsertImageExif, + ) -> Result { + // Return a dummy ImageExif for tests + Ok(crate::database::models::ImageExif { + id: 1, + file_path: data.file_path.to_string(), + camera_make: data.camera_make.map(|s| s.to_string()), + camera_model: data.camera_model.map(|s| s.to_string()), + lens_model: data.lens_model.map(|s| s.to_string()), + width: data.width, + height: data.height, + orientation: data.orientation, + gps_latitude: data.gps_latitude, + gps_longitude: data.gps_longitude, + gps_altitude: data.gps_altitude, + focal_length: data.focal_length, + aperture: data.aperture, + shutter_speed: data.shutter_speed, + iso: data.iso, + date_taken: data.date_taken, + created_time: data.created_time, + last_modified: data.last_modified, + }) + } + + fn delete_exif(&mut self, _: &str) -> Result<(), crate::database::DbError> { + Ok(()) + } + + fn get_all_with_date_taken( + &mut self, + ) -> Result, crate::database::DbError> { + Ok(Vec::new()) + } + + fn get_exif_batch( + &mut self, + _: &[String], + ) -> Result, crate::database::DbError> { + Ok(Vec::new()) + } + + fn query_by_exif( + &mut self, + _: Option<&str>, + _: Option<&str>, + _: Option<&str>, + _: Option<(f64, f64, f64, f64)>, + _: Option, + _: Option, + ) -> Result, crate::database::DbError> { + Ok(Vec::new()) + } + + fn get_camera_makes(&mut self) -> Result, crate::database::DbError> { + Ok(Vec::new()) + } + } + mod api { use super::*; use actix_web::{HttpResponse, web::Query}; @@ -581,6 +888,7 @@ mod tests { testhelpers::BodyReader, }; + use crate::database::SqliteExifDao; use crate::database::test::in_memory_db_connection; use crate::tags::SqliteTagDao; use actix_web::test::TestRequest; @@ -619,6 +927,9 @@ mod tests { Data::new(AppState::test_state()), Data::new(RealFileSystem::new(String::from("/tmp"))), Data::new(Mutex::new(SqliteTagDao::default())), + Data::new(Mutex::new( + Box::new(MockExifDao) as Box + )), ) .await; let status = response.status(); @@ -658,6 +969,9 @@ mod tests { Data::new(AppState::test_state()), Data::new(RealFileSystem::new(String::from("./"))), Data::new(Mutex::new(SqliteTagDao::default())), + Data::new(Mutex::new( + Box::new(MockExifDao) as Box + )), ) .await; @@ -711,6 +1025,9 @@ mod tests { Data::new(AppState::test_state()), Data::new(FakeFileSystem::new(files)), Data::new(Mutex::new(tag_dao)), + Data::new(Mutex::new( + Box::new(MockExifDao) as Box + )), ) .await; @@ -781,6 +1098,9 @@ mod tests { Data::new(AppState::test_state()), Data::new(FakeFileSystem::new(files)), Data::new(Mutex::new(tag_dao)), + Data::new(Mutex::new( + Box::new(MockExifDao) as Box + )), ) .await; diff --git a/src/tags.rs b/src/tags.rs index 22bbcf9..5cf0ed2 100644 --- a/src/tags.rs +++ b/src/tags.rs @@ -87,6 +87,7 @@ async fn get_tags( async fn get_all_tags( _: Claims, tag_dao: web::Data>, + exif_dao: web::Data>>, request: HttpRequest, query: web::Query, ) -> impl Responder { @@ -97,17 +98,34 @@ async fn get_all_tags( let mut tag_dao = tag_dao.lock().expect("Unable to get TagDao"); tag_dao .get_all_tags(&span_context, query.path.clone()) - .map(|tags| { + .and_then(|tags| { span_context.span().set_status(Status::Ok); - HttpResponse::Ok().json( - tags.iter() - .map(|(tag_count, tag)| TagWithTagCount { - tag: tag.clone(), - tag_count: *tag_count, - }) - .collect::>(), - ) + let tags_response = tags + .iter() + .map(|(tag_count, tag)| TagWithTagCount { + tag: tag.clone(), + tag_count: *tag_count, + }) + .collect::>(); + + // Get camera makes from EXIF database + let camera_makes = exif_dao + .lock() + .expect("Unable to get ExifDao") + .get_camera_makes() + .unwrap_or_else(|e| { + log::warn!("Failed to get camera makes: {:?}", e); + Vec::new() + }) + .into_iter() + .map(|(make, count)| CameraMakeCount { make, count }) + .collect::>(); + + Ok(HttpResponse::Ok().json(AllTagsResponse { + tags: tags_response, + camera_makes, + })) }) .into_http_internal_err() } @@ -208,6 +226,18 @@ pub struct TagWithTagCount { pub tag: Tag, } +#[derive(Serialize, Debug)] +pub struct CameraMakeCount { + pub make: String, + pub count: i64, +} + +#[derive(Serialize, Debug)] +pub struct AllTagsResponse { + pub tags: Vec, + pub camera_makes: Vec, +} + #[derive(Insertable, Clone, Debug)] #[diesel(table_name = tags)] pub struct InsertTag {