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:
@@ -0,0 +1,2 @@
|
||||
-- Remove date_taken index
|
||||
DROP INDEX IF EXISTS idx_image_exif_date_taken;
|
||||
2
migrations/2025-12-18-120000_add_date_taken_index/up.sql
Normal file
2
migrations/2025-12-18-120000_add_date_taken_index/up.sql
Normal 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);
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
58
src/files.rs
58
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<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
120
src/geo.rs
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,7 @@ mod database;
|
||||
mod error;
|
||||
mod exif;
|
||||
mod files;
|
||||
mod geo;
|
||||
mod state;
|
||||
mod tags;
|
||||
mod video;
|
||||
|
||||
@@ -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: ®ex::Captures| -> Option<DateTime<FixedOffset>> {
|
||||
let year = captures.get(1)?.as_str().parse::<i32>().ok()?;
|
||||
|
||||
Reference in New Issue
Block a user