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()
This commit is contained in:
Cameron
2025-12-18 09:34:07 -05:00
parent 52e1ced2a2
commit eb8e08b9ff
8 changed files with 314 additions and 1 deletions

View File

@@ -0,0 +1,2 @@
-- Remove date_taken index
DROP INDEX IF EXISTS idx_image_exif_date_taken;

View File

@@ -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);

View File

@@ -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<FilterMode>,
pub recursive: Option<bool>,
pub sort: Option<SortType>,
// EXIF-based search parameters
pub camera_make: Option<String>,
pub camera_model: Option<String>,
pub lens_model: Option<String>,
// GPS location search
pub gps_lat: Option<f64>,
pub gps_lon: Option<f64>,
pub gps_radius_km: Option<f64>,
// Date range filtering (Unix timestamps)
pub date_from: Option<i64>,
pub date_to: Option<i64>,
// Media type filtering
pub media_type: Option<MediaType>,
}
#[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 {

View File

@@ -191,6 +191,23 @@ pub trait ExifDao: Sync + Send {
fn update_exif(&mut self, exif_data: InsertImageExif) -> Result<ImageExif, DbError>;
fn delete_exif(&mut self, file_path: &str) -> Result<(), DbError>;
fn get_all_with_date_taken(&mut self) -> Result<Vec<(String, i64)>, DbError>;
/// Batch load EXIF data for multiple file paths (single query)
fn get_exif_batch(&mut self, file_paths: &[String]) -> Result<Vec<ImageExif>, 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<i64>,
date_to: Option<i64>,
) -> Result<Vec<ImageExif>, DbError>;
/// Get distinct camera makes with counts
fn get_camera_makes(&mut self) -> Result<Vec<(String, i64)>, 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<Vec<ImageExif>, 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::<ImageExif>(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<i64>,
date_to: Option<i64>,
) -> Result<Vec<ImageExif>, 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::<ImageExif>(connection.deref_mut())
.map_err(|_| DbError::new(DbErrorKind::QueryError))
}
fn get_camera_makes(&mut self) -> Result<Vec<(String, i64)>, 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<String>, 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))
}
}

View File

@@ -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<i64>, // Unix timestamp from EXIF or filename extraction
}
use serde::Deserialize;
pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
@@ -264,6 +272,56 @@ fn sort(mut files: Vec<FileWithTagCount>, sort_type: SortType) -> Vec<String> {
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::<Vec<String>>()
}
/// Sort files with metadata support (including date sorting)
fn sort_with_metadata(mut files: Vec<FileWithMetadata>, sort_type: SortType) -> Vec<String> {
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

120
src/geo.rs Normal file
View File

@@ -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
);
}
}

View File

@@ -57,6 +57,7 @@ mod database;
mod error;
mod exif;
mod files;
mod geo;
mod state;
mod tags;
mod video;

View File

@@ -232,7 +232,7 @@ fn extract_metadata_timestamps(
(created, modified)
}
fn extract_date_from_filename(filename: &str) -> Option<DateTime<FixedOffset>> {
pub fn extract_date_from_filename(filename: &str) -> Option<DateTime<FixedOffset>> {
let build_date_from_ymd_capture =
|captures: &regex::Captures| -> Option<DateTime<FixedOffset>> {
let year = captures.get(1)?.as_str().parse::<i32>().ok()?;