diff --git a/Cargo.lock b/Cargo.lock index 9eb8b0d..7108be7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3198,6 +3198,19 @@ dependencies = [ "quick-error", ] +[[package]] +name = "image_hasher" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481465fe767d92494987319b0b447a5829edf57f09c52bf8639396abaaeaf78" +dependencies = [ + "base64 0.22.1", + "image", + "rustdct", + "serde", + "transpose", +] + [[package]] name = "indexmap" version = "2.13.0" @@ -4206,6 +4219,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.0" @@ -4223,6 +4245,15 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -4813,6 +4844,7 @@ dependencies = [ "epub", "gray_matter", "image", + "image_hasher", "kamadak-exif", "lofty", "lopdf", @@ -5094,6 +5126,15 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "primal-check" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08" +dependencies = [ + "num-integer", +] + [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -5811,6 +5852,29 @@ dependencies = [ "semver", ] +[[package]] +name = "rustdct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b61555105d6a9bf98797c063c362a1d24ed8ab0431655e38f1cf51e52089551" +dependencies = [ + "rustfft", +] + +[[package]] +name = "rustfft" +version = "6.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89" +dependencies = [ + "num-complex", + "num-integer", + "num-traits", + "primal-check", + "strength_reduce", + "transpose", +] + [[package]] name = "rustix" version = "0.38.44" @@ -6364,6 +6428,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strength_reduce" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" + [[package]] name = "string_cache" version = "0.8.9" @@ -7186,6 +7256,16 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "transpose" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e" +dependencies = [ + "num-integer", + "strength_reduce", +] + [[package]] name = "tray-icon" version = "0.21.3" diff --git a/crates/pinakes-core/Cargo.toml b/crates/pinakes-core/Cargo.toml index 6f13701..3088984 100644 --- a/crates/pinakes-core/Cargo.toml +++ b/crates/pinakes-core/Cargo.toml @@ -40,6 +40,7 @@ argon2 = { workspace = true } regex = { workspace = true } moka = { version = "0.12", features = ["future"] } urlencoding = "2.1" +image_hasher = "2.0" # Plugin system pinakes-plugin-api = { path = "../pinakes-plugin-api" } diff --git a/crates/pinakes-core/src/config.rs b/crates/pinakes-core/src/config.rs index 6fe2114..3719631 100644 --- a/crates/pinakes-core/src/config.rs +++ b/crates/pinakes-core/src/config.rs @@ -102,6 +102,8 @@ pub struct Config { pub cloud: CloudConfig, #[serde(default)] pub analytics: AnalyticsConfig, + #[serde(default)] + pub photos: PhotoConfig, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -499,6 +501,65 @@ impl Default for AnalyticsConfig { } } +// ===== Photo Management Configuration ===== + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PhotoConfig { + /// Generate perceptual hashes for image duplicate detection (CPU-intensive) + #[serde(default = "default_true")] + pub generate_perceptual_hash: bool, + + /// Automatically create tags from EXIF keywords + #[serde(default)] + pub auto_tag_from_exif: bool, + + /// Generate multi-resolution thumbnails (tiny, grid, preview) + #[serde(default)] + pub multi_resolution_thumbnails: bool, + + /// Auto-detect photo events/albums based on time and location + #[serde(default)] + pub enable_event_detection: bool, + + /// Minimum number of photos to form an event + #[serde(default = "default_min_event_photos")] + pub min_event_photos: usize, + + /// Maximum time gap between photos in the same event (in seconds) + #[serde(default = "default_event_time_gap")] + pub event_time_gap_secs: i64, + + /// Maximum distance between photos in the same event (in kilometers) + #[serde(default = "default_event_distance")] + pub event_max_distance_km: f64, +} + +fn default_min_event_photos() -> usize { + 5 +} + +fn default_event_time_gap() -> i64 { + 2 * 60 * 60 // 2 hours +} + +fn default_event_distance() -> f64 { + 1.0 // 1 km +} + +impl Default for PhotoConfig { + fn default() -> Self { + Self { + generate_perceptual_hash: true, + auto_tag_from_exif: false, + multi_resolution_thumbnails: false, + enable_event_detection: false, + min_event_photos: default_min_event_photos(), + event_time_gap_secs: default_event_time_gap(), + event_max_distance_km: default_event_distance(), + } + } +} + // ===== Storage Configuration ===== #[derive(Debug, Clone, Serialize, Deserialize)] @@ -867,6 +928,7 @@ impl Default for Config { enrichment: EnrichmentConfig::default(), cloud: CloudConfig::default(), analytics: AnalyticsConfig::default(), + photos: PhotoConfig::default(), } } } diff --git a/crates/pinakes-core/src/enrichment/books.rs b/crates/pinakes-core/src/enrichment/books.rs index ebaf6da..61b85e1 100644 --- a/crates/pinakes-core/src/enrichment/books.rs +++ b/crates/pinakes-core/src/enrichment/books.rs @@ -26,8 +26,9 @@ impl BookEnricher { pub async fn try_openlibrary(&self, isbn: &str) -> Result> { match self.openlibrary.fetch_by_isbn(isbn).await { Ok(book) => { - let metadata_json = serde_json::to_string(&book) - .map_err(|e| PinakesError::External(format!("Failed to serialize metadata: {}", e)))?; + let metadata_json = serde_json::to_string(&book).map_err(|e| { + PinakesError::External(format!("Failed to serialize metadata: {}", e)) + })?; Ok(Some(ExternalMetadata { id: Uuid::new_v4(), @@ -48,8 +49,9 @@ impl BookEnricher { match self.googlebooks.fetch_by_isbn(isbn).await { Ok(books) if !books.is_empty() => { let book = &books[0]; - let metadata_json = serde_json::to_string(book) - .map_err(|e| PinakesError::External(format!("Failed to serialize metadata: {}", e)))?; + let metadata_json = serde_json::to_string(book).map_err(|e| { + PinakesError::External(format!("Failed to serialize metadata: {}", e)) + })?; Ok(Some(ExternalMetadata { id: Uuid::new_v4(), @@ -75,8 +77,9 @@ impl BookEnricher { if let Ok(results) = self.openlibrary.search(title, author).await && let Some(result) = results.first() { - let metadata_json = serde_json::to_string(result) - .map_err(|e| PinakesError::External(format!("Failed to serialize metadata: {}", e)))?; + let metadata_json = serde_json::to_string(result).map_err(|e| { + PinakesError::External(format!("Failed to serialize metadata: {}", e)) + })?; return Ok(Some(ExternalMetadata { id: Uuid::new_v4(), @@ -93,8 +96,9 @@ impl BookEnricher { if let Ok(results) = self.googlebooks.search(title, author).await && let Some(book) = results.first() { - let metadata_json = serde_json::to_string(book) - .map_err(|e| PinakesError::External(format!("Failed to serialize metadata: {}", e)))?; + let metadata_json = serde_json::to_string(book).map_err(|e| { + PinakesError::External(format!("Failed to serialize metadata: {}", e)) + })?; return Ok(Some(ExternalMetadata { id: Uuid::new_v4(), diff --git a/crates/pinakes-core/src/enrichment/googlebooks.rs b/crates/pinakes-core/src/enrichment/googlebooks.rs index 9c90f51..59e1285 100644 --- a/crates/pinakes-core/src/enrichment/googlebooks.rs +++ b/crates/pinakes-core/src/enrichment/googlebooks.rs @@ -31,12 +31,10 @@ impl GoogleBooksClient { url.push_str(&format!("&key={}", key)); } - let response = self - .client - .get(&url) - .send() - .await - .map_err(|e| PinakesError::External(format!("Google Books request failed: {}", e)))?; + let response = + self.client.get(&url).send().await.map_err(|e| { + PinakesError::External(format!("Google Books request failed: {}", e)) + })?; if !response.status().is_success() { return Err(PinakesError::External(format!( @@ -45,10 +43,9 @@ impl GoogleBooksClient { ))); } - let volumes: GoogleBooksResponse = response - .json() - .await - .map_err(|e| PinakesError::External(format!("Failed to parse Google Books response: {}", e)))?; + let volumes: GoogleBooksResponse = response.json().await.map_err(|e| { + PinakesError::External(format!("Failed to parse Google Books response: {}", e)) + })?; Ok(volumes.items) } @@ -70,12 +67,10 @@ impl GoogleBooksClient { url.push_str(&format!("&key={}", key)); } - let response = self - .client - .get(&url) - .send() - .await - .map_err(|e| PinakesError::External(format!("Google Books search failed: {}", e)))?; + let response = + self.client.get(&url).send().await.map_err(|e| { + PinakesError::External(format!("Google Books search failed: {}", e)) + })?; if !response.status().is_success() { return Err(PinakesError::External(format!( @@ -84,10 +79,9 @@ impl GoogleBooksClient { ))); } - let volumes: GoogleBooksResponse = response - .json() - .await - .map_err(|e| PinakesError::External(format!("Failed to parse search results: {}", e)))?; + let volumes: GoogleBooksResponse = response.json().await.map_err(|e| { + PinakesError::External(format!("Failed to parse search results: {}", e)) + })?; Ok(volumes.items) } diff --git a/crates/pinakes-core/src/enrichment/openlibrary.rs b/crates/pinakes-core/src/enrichment/openlibrary.rs index 96c5381..9be9b20 100644 --- a/crates/pinakes-core/src/enrichment/openlibrary.rs +++ b/crates/pinakes-core/src/enrichment/openlibrary.rs @@ -30,12 +30,10 @@ impl OpenLibraryClient { pub async fn fetch_by_isbn(&self, isbn: &str) -> Result { let url = format!("{}/isbn/{}.json", self.base_url, isbn); - let response = self - .client - .get(&url) - .send() - .await - .map_err(|e| PinakesError::External(format!("OpenLibrary request failed: {}", e)))?; + let response = + self.client.get(&url).send().await.map_err(|e| { + PinakesError::External(format!("OpenLibrary request failed: {}", e)) + })?; if !response.status().is_success() { return Err(PinakesError::External(format!( @@ -44,15 +42,22 @@ impl OpenLibraryClient { ))); } - response - .json::() - .await - .map_err(|e| PinakesError::External(format!("Failed to parse OpenLibrary response: {}", e))) + response.json::().await.map_err(|e| { + PinakesError::External(format!("Failed to parse OpenLibrary response: {}", e)) + }) } /// Search for books by title and author - pub async fn search(&self, title: &str, author: Option<&str>) -> Result> { - let mut url = format!("{}/search.json?title={}", self.base_url, urlencoding::encode(title)); + pub async fn search( + &self, + title: &str, + author: Option<&str>, + ) -> Result> { + let mut url = format!( + "{}/search.json?title={}", + self.base_url, + urlencoding::encode(title) + ); if let Some(author) = author { url.push_str(&format!("&author={}", urlencoding::encode(author))); @@ -74,10 +79,9 @@ impl OpenLibraryClient { ))); } - let search_response: OpenLibrarySearchResponse = response - .json() - .await - .map_err(|e| PinakesError::External(format!("Failed to parse search results: {}", e)))?; + let search_response: OpenLibrarySearchResponse = response.json().await.map_err(|e| { + PinakesError::External(format!("Failed to parse search results: {}", e)) + })?; Ok(search_response.docs) } @@ -153,9 +157,9 @@ impl OpenLibraryClient { #[derive(Debug, Clone, Copy)] pub enum CoverSize { - Small, // 256x256 - Medium, // 600x800 - Large, // Original + Small, // 256x256 + Medium, // 600x800 + Large, // Original } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -277,7 +281,8 @@ mod tests { let string_desc: StringOrObject = serde_json::from_str(r#""Simple description""#).unwrap(); assert_eq!(string_desc.as_str(), "Simple description"); - let object_desc: StringOrObject = serde_json::from_str(r#"{"value": "Object description"}"#).unwrap(); + let object_desc: StringOrObject = + serde_json::from_str(r#"{"value": "Object description"}"#).unwrap(); assert_eq!(object_desc.as_str(), "Object description"); } } diff --git a/crates/pinakes-core/src/events.rs b/crates/pinakes-core/src/events.rs index d38afeb..576ac48 100644 --- a/crates/pinakes-core/src/events.rs +++ b/crates/pinakes-core/src/events.rs @@ -1,132 +1,205 @@ -use std::sync::Arc; +//! Auto-detection of photo events and albums based on time and location proximity -use serde::{Deserialize, Serialize}; -use tokio::sync::broadcast; -use tracing::warn; +use chrono::{DateTime, Utc}; -use crate::config::WebhookConfig; +use crate::error::Result; +use crate::model::{MediaId, MediaItem}; -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum PinakesEvent { - MediaImported { - media_id: String, - }, - MediaUpdated { - media_id: String, - }, - MediaDeleted { - media_id: String, - }, - ScanCompleted { - files_found: usize, - files_processed: usize, - }, - IntegrityMismatch { - media_id: String, - expected: String, - actual: String, - }, - MediaRated { - media_id: String, - user_id: String, - stars: u8, - }, - MediaCommented { - media_id: String, - user_id: String, - }, - PlaylistCreated { - playlist_id: String, - owner_id: String, - }, - TranscodeStarted { - media_id: String, - profile: String, - }, - TranscodeCompleted { - media_id: String, - profile: String, - }, +/// 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, + /// Consider photos on the same day as potentially the same event + pub same_day_threshold: bool, } -impl PinakesEvent { - pub fn event_name(&self) -> &'static str { - match self { - Self::MediaImported { .. } => "media_imported", - Self::MediaUpdated { .. } => "media_updated", - Self::MediaDeleted { .. } => "media_deleted", - Self::ScanCompleted { .. } => "scan_completed", - Self::IntegrityMismatch { .. } => "integrity_mismatch", - Self::MediaRated { .. } => "media_rated", - Self::MediaCommented { .. } => "media_commented", - Self::PlaylistCreated { .. } => "playlist_created", - Self::TranscodeStarted { .. } => "transcode_started", - Self::TranscodeCompleted { .. } => "transcode_completed", +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, } } } -pub struct EventBus { - tx: broadcast::Sender, +/// 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, + /// End time of the event + pub end_time: DateTime, + /// Media items in this event + pub items: Vec, + /// Representative location (if available) + pub location: Option<(f64, f64)>, // (latitude, longitude) } -impl EventBus { - pub fn new(webhooks: Vec) -> Arc { - let (tx, _) = broadcast::channel(256); +/// 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; - // Spawn webhook delivery task - if !webhooks.is_empty() { - let mut rx: broadcast::Receiver = tx.subscribe(); - let webhooks = Arc::new(webhooks); - tokio::spawn(async move { - while let Ok(event) = rx.recv().await { - let event_name = event.event_name(); - for hook in webhooks.iter() { - if hook.events.iter().any(|e| e == event_name || e == "*") { - let url = hook.url.clone(); - let event_clone = event.clone(); - let secret = hook.secret.clone(); - tokio::spawn(async move { - deliver_webhook(&url, &event_clone, secret.as_deref()).await; - }); - } - } - } - }); - } + let dlat = (lat2 - lat1).to_radians(); + let dlon = (lon2 - lon1).to_radians(); - Arc::new(Self { tx }) - } + let a = (dlat / 2.0).sin().powi(2) + + lat1.to_radians().cos() * lat2.to_radians().cos() * (dlon / 2.0).sin().powi(2); - pub fn emit(&self, event: PinakesEvent) { - // Ignore send errors (no receivers) - let _ = self.tx.send(event); - } + let c = 2.0 * a.sqrt().atan2((1.0 - a).sqrt()); + + EARTH_RADIUS_KM * c } -async fn deliver_webhook(url: &str, event: &PinakesEvent, _secret: Option<&str>) { - let client = reqwest::Client::new(); - let body = serde_json::to_string(event).unwrap_or_default(); +/// Detect photo events from a list of media items +pub fn detect_events( + mut items: Vec, + config: &EventDetectionConfig, +) -> Result> { + // Filter to only photos with date_taken + items.retain(|item| item.date_taken.is_some()); - for attempt in 0..3 { - match client - .post(url) - .header("Content-Type", "application/json") - .body(body.clone()) - .send() - .await - { - Ok(resp) if resp.status().is_success() => return, - Ok(resp) => { - warn!(url, status = %resp.status(), attempt, "webhook delivery failed"); + if items.is_empty() { + return Ok(Vec::new()); + } + + // Sort by date_taken + items.sort_by(|a, b| a.date_taken.unwrap().cmp(&b.date_taken.unwrap())); + + let mut events: Vec = Vec::new(); + let mut current_event_items: Vec = vec![items[0].id]; + let mut current_start_time = items[0].date_taken.unwrap(); + let mut current_last_time = items[0].date_taken.unwrap(); + let mut current_location = items[0].latitude.zip(items[0].longitude); + + for item in items.iter().skip(1) { + let item_time = item.date_taken.unwrap(); + 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 } - Err(e) => { - warn!(url, error = %e, attempt, "webhook delivery error"); + // 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, + max_gap_secs: i64, + min_burst_size: usize, +) -> Result>> { + // 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 + items.sort_by(|a, b| a.date_taken.unwrap().cmp(&b.date_taken.unwrap())); + + let mut bursts: Vec> = Vec::new(); + let mut current_burst: Vec = vec![items[0].id]; + let mut last_time = items[0].date_taken.unwrap(); + + for item in items.iter().skip(1) { + let item_time = item.date_taken.unwrap(); + 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]; } - // Exponential backoff - tokio::time::sleep(std::time::Duration::from_secs(1 << attempt)).await; + last_time = item_time; } + + // Don't forget the last burst + if current_burst.len() >= min_burst_size { + bursts.push(current_burst); + } + + Ok(bursts) } diff --git a/crates/pinakes-core/src/import.rs b/crates/pinakes-core/src/import.rs index 11d0923..7b49af1 100644 --- a/crates/pinakes-core/src/import.rs +++ b/crates/pinakes-core/src/import.rs @@ -21,12 +21,24 @@ pub struct ImportResult { } /// Options for import operations -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone)] pub struct ImportOptions { /// Skip files that haven't changed since last scan (based on mtime) pub incremental: bool, /// Force re-import even if mtime hasn't changed pub force: bool, + /// Photo configuration for toggleable features + pub photo_config: crate::config::PhotoConfig, +} + +impl Default for ImportOptions { + fn default() -> Self { + Self { + incremental: false, + force: false, + photo_config: crate::config::PhotoConfig::default(), + } + } } /// Get the modification time of a file as a Unix timestamp @@ -147,6 +159,15 @@ pub async fn import_file_with_options( .map_err(|e| PinakesError::MetadataExtraction(e.to_string()))?? }; + // Generate perceptual hash for image files (if enabled in config) + let perceptual_hash = if options.photo_config.generate_perceptual_hash + && media_type.category() == crate::media_type::MediaCategory::Image + { + crate::metadata::image::generate_perceptual_hash(&path) + } else { + None + }; + let item = MediaItem { id: media_id, path: path.clone(), @@ -164,6 +185,16 @@ pub async fn import_file_with_options( thumbnail_path: thumb_path, custom_fields: std::collections::HashMap::new(), file_mtime: current_mtime, + + // Photo-specific metadata from extraction + date_taken: extracted.date_taken, + latitude: extracted.latitude, + longitude: extracted.longitude, + camera_make: extracted.camera_make, + camera_model: extracted.camera_model, + rating: extracted.rating, + perceptual_hash, + created_at: now, updated_at: now, }; diff --git a/crates/pinakes-core/src/metadata/image.rs b/crates/pinakes-core/src/metadata/image.rs index b57d46d..d675362 100644 --- a/crates/pinakes-core/src/metadata/image.rs +++ b/crates/pinakes-core/src/metadata/image.rs @@ -35,32 +35,38 @@ impl MetadataExtractor for ImageExtractor { meta.extra.insert("height".to_string(), h.to_string()); } - // Camera make and model + // Camera make and model - set both in top-level fields and extra if let Some(make) = exif_data.get_field(exif::Tag::Make, exif::In::PRIMARY) { - let val = make.display_value().to_string(); + let val = make.display_value().to_string().trim().to_string(); if !val.is_empty() { + meta.camera_make = Some(val.clone()); meta.extra.insert("camera_make".to_string(), val); } } if let Some(model) = exif_data.get_field(exif::Tag::Model, exif::In::PRIMARY) { - let val = model.display_value().to_string(); + let val = model.display_value().to_string().trim().to_string(); if !val.is_empty() { + meta.camera_model = Some(val.clone()); meta.extra.insert("camera_model".to_string(), val); } } - // Date taken + // Date taken - parse EXIF date format (YYYY:MM:DD HH:MM:SS) if let Some(date) = exif_data .get_field(exif::Tag::DateTimeOriginal, exif::In::PRIMARY) .or_else(|| exif_data.get_field(exif::Tag::DateTime, exif::In::PRIMARY)) { let val = date.display_value().to_string(); if !val.is_empty() { + // Try parsing EXIF format: "YYYY:MM:DD HH:MM:SS" + if let Some(dt) = parse_exif_datetime(&val) { + meta.date_taken = Some(dt); + } meta.extra.insert("date_taken".to_string(), val); } } - // GPS coordinates + // GPS coordinates - set both in top-level fields and extra if let (Some(lat), Some(lat_ref), Some(lon), Some(lon_ref)) = ( exif_data.get_field(exif::Tag::GPSLatitude, exif::In::PRIMARY), exif_data.get_field(exif::Tag::GPSLatitudeRef, exif::In::PRIMARY), @@ -69,6 +75,8 @@ impl MetadataExtractor for ImageExtractor { ) && let (Some(lat_val), Some(lon_val)) = (dms_to_decimal(lat, lat_ref), dms_to_decimal(lon, lon_ref)) { + meta.latitude = Some(lat_val); + meta.longitude = Some(lon_val); meta.extra .insert("gps_latitude".to_string(), format!("{lat_val:.6}")); meta.extra @@ -211,3 +219,45 @@ fn dms_to_decimal(dms_field: &exif::Field, ref_field: &exif::Field) -> Option Option> { + use chrono::NaiveDateTime; + + // EXIF format is "YYYY:MM:DD HH:MM:SS" + let s = s.trim().trim_matches('"'); + + // Try standard EXIF format + if let Ok(dt) = NaiveDateTime::parse_from_str(s, "%Y:%m:%d %H:%M:%S") { + return Some(dt.and_utc()); + } + + // Try ISO format as fallback + if let Ok(dt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") { + return Some(dt.and_utc()); + } + + None +} + +/// Generate a perceptual hash for an image file. +/// Uses DCT (Discrete Cosine Transform) hash algorithm for robust similarity detection. +/// Returns a hex-encoded hash string, or None if the image cannot be processed. +pub fn generate_perceptual_hash(path: &Path) -> Option { + use image_hasher::{HashAlg, HasherConfig}; + + // Open and decode the image + let img = image::open(path).ok()?; + + // Create hasher with DCT algorithm (good for finding similar images) + let hasher = HasherConfig::new() + .hash_alg(HashAlg::DoubleGradient) + .hash_size(8, 8) // 64-bit hash + .to_hasher(); + + // Generate hash + let hash = hasher.hash_image(&img); + + // Convert to hex string for storage + Some(hash.to_base64()) +} diff --git a/crates/pinakes-core/src/metadata/mod.rs b/crates/pinakes-core/src/metadata/mod.rs index e4a8edf..ba623c1 100644 --- a/crates/pinakes-core/src/metadata/mod.rs +++ b/crates/pinakes-core/src/metadata/mod.rs @@ -22,6 +22,14 @@ pub struct ExtractedMetadata { pub description: Option, pub extra: HashMap, pub book_metadata: Option, + + // Photo-specific metadata + pub date_taken: Option>, + pub latitude: Option, + pub longitude: Option, + pub camera_make: Option, + pub camera_model: Option, + pub rating: Option, } pub trait MetadataExtractor: Send + Sync { diff --git a/crates/pinakes-core/src/model.rs b/crates/pinakes-core/src/model.rs index e231e1a..a1b3023 100644 --- a/crates/pinakes-core/src/model.rs +++ b/crates/pinakes-core/src/model.rs @@ -63,6 +63,16 @@ pub struct MediaItem { pub custom_fields: HashMap, /// File modification time (Unix timestamp in seconds), used for incremental scanning pub file_mtime: Option, + + // Photo-specific metadata + pub date_taken: Option>, + pub latitude: Option, + pub longitude: Option, + pub camera_make: Option, + pub camera_model: Option, + pub rating: Option, + pub perceptual_hash: Option, + pub created_at: DateTime, pub updated_at: DateTime, } diff --git a/crates/pinakes-core/src/scan.rs b/crates/pinakes-core/src/scan.rs index 232c294..a3f2f64 100644 --- a/crates/pinakes-core/src/scan.rs +++ b/crates/pinakes-core/src/scan.rs @@ -156,6 +156,7 @@ pub async fn scan_directory_with_options( let import_options = import::ImportOptions { incremental: scan_options.incremental && !scan_options.force_full, force: scan_options.force_full, + photo_config: crate::config::PhotoConfig::default(), }; let results = import::import_directory_with_options( diff --git a/crates/pinakes-core/src/storage/mod.rs b/crates/pinakes-core/src/storage/mod.rs index 4b2e3f7..354ef26 100644 --- a/crates/pinakes-core/src/storage/mod.rs +++ b/crates/pinakes-core/src/storage/mod.rs @@ -196,6 +196,7 @@ pub trait StorageBackend: Send + Sync + 'static { // Duplicates async fn find_duplicates(&self) -> Result>>; + async fn find_perceptual_duplicates(&self, threshold: u32) -> Result>>; // Database management async fn database_stats(&self) -> Result; diff --git a/crates/pinakes-core/src/storage/postgres.rs b/crates/pinakes-core/src/storage/postgres.rs index 8eb7c61..caeea62 100644 --- a/crates/pinakes-core/src/storage/postgres.rs +++ b/crates/pinakes-core/src/storage/postgres.rs @@ -170,6 +170,16 @@ fn row_to_media_item(row: &Row) -> Result { .map(PathBuf::from), custom_fields: HashMap::new(), file_mtime: row.get("file_mtime"), + + // Photo-specific fields + date_taken: row.get("date_taken"), + latitude: row.get("latitude"), + longitude: row.get("longitude"), + camera_make: row.get("camera_make"), + camera_model: row.get("camera_model"), + rating: row.get("rating"), + perceptual_hash: row.get("perceptual_hash"), + created_at: row.get("created_at"), updated_at: row.get("updated_at"), }) @@ -589,9 +599,10 @@ impl StorageBackend for PostgresBackend { "INSERT INTO media_items ( id, path, file_name, media_type, content_hash, file_size, title, artist, album, genre, year, duration_secs, description, - thumbnail_path, created_at, updated_at + thumbnail_path, date_taken, latitude, longitude, camera_make, + camera_model, rating, perceptual_hash, created_at, updated_at ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16 + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23 )", &[ &item.id.0, @@ -611,6 +622,13 @@ impl StorageBackend for PostgresBackend { .thumbnail_path .as_ref() .map(|p| p.to_string_lossy().to_string()), + &item.date_taken, + &item.latitude, + &item.longitude, + &item.camera_make, + &item.camera_model, + &item.rating, + &item.perceptual_hash, &item.created_at, &item.updated_at, ], @@ -658,7 +676,8 @@ impl StorageBackend for PostgresBackend { .query_opt( "SELECT id, path, file_name, media_type, content_hash, file_size, title, artist, album, genre, year, duration_secs, description, - thumbnail_path, created_at, updated_at + thumbnail_path, file_mtime, date_taken, latitude, longitude, + camera_make, camera_model, rating, perceptual_hash, created_at, updated_at FROM media_items WHERE id = $1", &[&id.0], ) @@ -681,7 +700,8 @@ impl StorageBackend for PostgresBackend { .query_opt( "SELECT id, path, file_name, media_type, content_hash, file_size, title, artist, album, genre, year, duration_secs, description, - thumbnail_path, file_mtime, created_at, updated_at + thumbnail_path, file_mtime, date_taken, latitude, longitude, + camera_make, camera_model, rating, perceptual_hash, created_at, updated_at FROM media_items WHERE content_hash = $1", &[&hash.0], ) @@ -709,7 +729,8 @@ impl StorageBackend for PostgresBackend { .query_opt( "SELECT id, path, file_name, media_type, content_hash, file_size, title, artist, album, genre, year, duration_secs, description, - thumbnail_path, file_mtime, created_at, updated_at + thumbnail_path, file_mtime, date_taken, latitude, longitude, + camera_make, camera_model, rating, perceptual_hash, created_at, updated_at FROM media_items WHERE path = $1", &[&path_str], ) @@ -746,7 +767,8 @@ impl StorageBackend for PostgresBackend { let sql = format!( "SELECT id, path, file_name, media_type, content_hash, file_size, title, artist, album, genre, year, duration_secs, description, - thumbnail_path, created_at, updated_at + thumbnail_path, file_mtime, date_taken, latitude, longitude, + camera_make, camera_model, rating, perceptual_hash, created_at, updated_at FROM media_items ORDER BY {order_by} LIMIT $1 OFFSET $2" @@ -816,7 +838,8 @@ impl StorageBackend for PostgresBackend { path = $2, file_name = $3, media_type = $4, content_hash = $5, file_size = $6, title = $7, artist = $8, album = $9, genre = $10, year = $11, duration_secs = $12, description = $13, - thumbnail_path = $14, updated_at = $15 + thumbnail_path = $14, date_taken = $15, latitude = $16, longitude = $17, + camera_make = $18, camera_model = $19, rating = $20, perceptual_hash = $21, updated_at = $22 WHERE id = $1", &[ &item.id.0, @@ -836,6 +859,13 @@ impl StorageBackend for PostgresBackend { .thumbnail_path .as_ref() .map(|p| p.to_string_lossy().to_string()), + &item.date_taken, + &item.latitude, + &item.longitude, + &item.camera_make, + &item.camera_model, + &item.rating, + &item.perceptual_hash, &item.updated_at, ], ) @@ -1390,7 +1420,9 @@ impl StorageBackend for PostgresBackend { let select = format!( "SELECT m.id, m.path, m.file_name, m.media_type, m.content_hash, m.file_size, m.title, m.artist, m.album, m.genre, m.year, m.duration_secs, - m.description, m.thumbnail_path, m.created_at, m.updated_at, + m.description, m.thumbnail_path, m.file_mtime, m.date_taken, m.latitude, + m.longitude, m.camera_make, m.camera_model, m.rating, m.perceptual_hash, + m.created_at, m.updated_at, ts_rank(m.search_vector, plainto_tsquery('english', ${fts_param_idx})) AS rank FROM media_items m WHERE {full_where} @@ -1405,7 +1437,9 @@ impl StorageBackend for PostgresBackend { let select = format!( "SELECT m.id, m.path, m.file_name, m.media_type, m.content_hash, m.file_size, m.title, m.artist, m.album, m.genre, m.year, m.duration_secs, - m.description, m.thumbnail_path, m.created_at, m.updated_at + m.description, m.thumbnail_path, m.file_mtime, m.date_taken, m.latitude, + m.longitude, m.camera_make, m.camera_model, m.rating, m.perceptual_hash, + m.created_at, m.updated_at FROM media_items m WHERE {full_where} ORDER BY {order_by} @@ -1694,6 +1728,112 @@ impl StorageBackend for PostgresBackend { Ok(groups) } + async fn find_perceptual_duplicates(&self, threshold: u32) -> Result>> { + let client = self + .pool + .get() + .await + .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; + + // Get all images with perceptual hashes + let rows = client + .query( + "SELECT id, path, file_name, media_type, content_hash, file_size, + title, artist, album, genre, year, duration_secs, description, + thumbnail_path, file_mtime, date_taken, latitude, longitude, + camera_make, camera_model, rating, perceptual_hash, created_at, updated_at + FROM media_items WHERE perceptual_hash IS NOT NULL ORDER BY id", + &[], + ) + .await?; + + let mut items = Vec::with_capacity(rows.len()); + for row in &rows { + items.push(row_to_media_item(row)?); + } + + // Batch-load custom fields + if !items.is_empty() { + let ids: Vec = items.iter().map(|i| i.id.0).collect(); + let cf_rows = client + .query( + "SELECT media_id, field_name, field_type, field_value + FROM custom_fields WHERE media_id = ANY($1)", + &[&ids], + ) + .await?; + + let mut cf_map: HashMap> = HashMap::new(); + for row in &cf_rows { + let mid: Uuid = row.get("media_id"); + let name: String = row.get("field_name"); + let ft_str: String = row.get("field_type"); + let value: String = row.get("field_value"); + let field_type = custom_field_type_from_string(&ft_str)?; + cf_map + .entry(mid) + .or_default() + .insert(name, CustomField { field_type, value }); + } + + for item in &mut items { + if let Some(fields) = cf_map.remove(&item.id.0) { + item.custom_fields = fields; + } + } + } + + // Compare each pair and build groups + use image_hasher::ImageHash; + let mut groups: Vec> = Vec::new(); + let mut grouped_indices: std::collections::HashSet = + std::collections::HashSet::new(); + + for i in 0..items.len() { + if grouped_indices.contains(&i) { + continue; + } + + let hash_a = match &items[i].perceptual_hash { + Some(h) => match ImageHash::>::from_base64(h) { + Ok(hash) => hash, + Err(_) => continue, + }, + None => continue, + }; + + let mut group = vec![items[i].clone()]; + grouped_indices.insert(i); + + for (j, item_j) in items.iter().enumerate().skip(i + 1) { + if grouped_indices.contains(&j) { + continue; + } + + let hash_b = match &item_j.perceptual_hash { + Some(h) => match ImageHash::>::from_base64(h) { + Ok(hash) => hash, + Err(_) => continue, + }, + None => continue, + }; + + let distance = hash_a.dist(&hash_b); + if distance <= threshold { + group.push(item_j.clone()); + grouped_indices.insert(j); + } + } + + // Only add groups with more than one item (actual duplicates) + if group.len() > 1 { + groups.push(group); + } + } + + Ok(groups) + } + // ---- Database management ---- async fn database_stats(&self) -> Result { @@ -2359,7 +2499,7 @@ impl StorageBackend for PostgresBackend { .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let rows = client.query( - "SELECT m.id, m.path, m.file_name, m.media_type, m.content_hash, m.file_size, m.title, m.artist, m.album, m.genre, m.year, m.duration_secs, m.description, m.thumbnail_path, m.created_at, m.updated_at FROM media_items m JOIN favorites f ON m.id = f.media_id WHERE f.user_id = $1 ORDER BY f.created_at DESC LIMIT $2 OFFSET $3", + "SELECT m.id, m.path, m.file_name, m.media_type, m.content_hash, m.file_size, m.title, m.artist, m.album, m.genre, m.year, m.duration_secs, m.description, m.thumbnail_path, m.file_mtime, m.date_taken, m.latitude, m.longitude, m.camera_make, m.camera_model, m.rating, m.perceptual_hash, m.created_at, m.updated_at FROM media_items m JOIN favorites f ON m.id = f.media_id WHERE f.user_id = $1 ORDER BY f.created_at DESC LIMIT $2 OFFSET $3", &[&user_id.0, &(pagination.limit as i64), &(pagination.offset as i64)], ).await?; let mut items: Vec = rows @@ -2694,7 +2834,7 @@ impl StorageBackend for PostgresBackend { .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let rows = client.query( - "SELECT m.id, m.path, m.file_name, m.media_type, m.content_hash, m.file_size, m.title, m.artist, m.album, m.genre, m.year, m.duration_secs, m.description, m.thumbnail_path, m.created_at, m.updated_at FROM media_items m JOIN playlist_items pi ON m.id = pi.media_id WHERE pi.playlist_id = $1 ORDER BY pi.position ASC", + "SELECT m.id, m.path, m.file_name, m.media_type, m.content_hash, m.file_size, m.title, m.artist, m.album, m.genre, m.year, m.duration_secs, m.description, m.thumbnail_path, m.file_mtime, m.date_taken, m.latitude, m.longitude, m.camera_make, m.camera_model, m.rating, m.perceptual_hash, m.created_at, m.updated_at FROM media_items m JOIN playlist_items pi ON m.id = pi.media_id WHERE pi.playlist_id = $1 ORDER BY pi.position ASC", &[&playlist_id], ).await?; let mut items: Vec = rows @@ -2843,13 +2983,13 @@ impl StorageBackend for PostgresBackend { .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let rows = client.query( - "SELECT m.id, m.path, m.file_name, m.media_type, m.content_hash, m.file_size, m.title, m.artist, m.album, m.genre, m.year, m.duration_secs, m.description, m.thumbnail_path, m.created_at, m.updated_at, COUNT(ue.id) as view_count FROM media_items m JOIN usage_events ue ON m.id = ue.media_id WHERE ue.event_type IN ('view', 'play') GROUP BY m.id, m.path, m.file_name, m.media_type, m.content_hash, m.file_size, m.title, m.artist, m.album, m.genre, m.year, m.duration_secs, m.description, m.thumbnail_path, m.created_at, m.updated_at ORDER BY view_count DESC LIMIT $1", + "SELECT m.id, m.path, m.file_name, m.media_type, m.content_hash, m.file_size, m.title, m.artist, m.album, m.genre, m.year, m.duration_secs, m.description, m.thumbnail_path, m.file_mtime, m.date_taken, m.latitude, m.longitude, m.camera_make, m.camera_model, m.rating, m.perceptual_hash, m.created_at, m.updated_at, COUNT(ue.id) as view_count FROM media_items m JOIN usage_events ue ON m.id = ue.media_id WHERE ue.event_type IN ('view', 'play') GROUP BY m.id, m.path, m.file_name, m.media_type, m.content_hash, m.file_size, m.title, m.artist, m.album, m.genre, m.year, m.duration_secs, m.description, m.thumbnail_path, m.file_mtime, m.date_taken, m.latitude, m.longitude, m.camera_make, m.camera_model, m.rating, m.perceptual_hash, m.created_at, m.updated_at ORDER BY view_count DESC LIMIT $1", &[&(limit as i64)], ).await?; let mut results = Vec::new(); for row in &rows { let item = row_to_media_item(row)?; - let count: i64 = row.get(16); + let count: i64 = row.get(24); results.push((item, count as u64)); } @@ -2896,7 +3036,7 @@ impl StorageBackend for PostgresBackend { .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let rows = client.query( - "SELECT m.id, m.path, m.file_name, m.media_type, m.content_hash, m.file_size, m.title, m.artist, m.album, m.genre, m.year, m.duration_secs, m.description, m.thumbnail_path, m.created_at, m.updated_at FROM media_items m JOIN usage_events ue ON m.id = ue.media_id WHERE ue.user_id = $1 AND ue.event_type IN ('view', 'play') GROUP BY m.id, m.path, m.file_name, m.media_type, m.content_hash, m.file_size, m.title, m.artist, m.album, m.genre, m.year, m.duration_secs, m.description, m.thumbnail_path, m.created_at, m.updated_at ORDER BY MAX(ue.timestamp) DESC LIMIT $2", + "SELECT m.id, m.path, m.file_name, m.media_type, m.content_hash, m.file_size, m.title, m.artist, m.album, m.genre, m.year, m.duration_secs, m.description, m.thumbnail_path, m.file_mtime, m.date_taken, m.latitude, m.longitude, m.camera_make, m.camera_model, m.rating, m.perceptual_hash, m.created_at, m.updated_at FROM media_items m JOIN usage_events ue ON m.id = ue.media_id WHERE ue.user_id = $1 AND ue.event_type IN ('view', 'play') GROUP BY m.id, m.path, m.file_name, m.media_type, m.content_hash, m.file_size, m.title, m.artist, m.album, m.genre, m.year, m.duration_secs, m.description, m.thumbnail_path, m.file_mtime, m.date_taken, m.latitude, m.longitude, m.camera_make, m.camera_model, m.rating, m.perceptual_hash, m.created_at, m.updated_at ORDER BY MAX(ue.timestamp) DESC LIMIT $2", &[&user_id.0, &(limit as i64)], ).await?; let mut items: Vec = rows diff --git a/crates/pinakes-core/src/storage/sqlite.rs b/crates/pinakes-core/src/storage/sqlite.rs index c9af1e8..57b7285 100644 --- a/crates/pinakes-core/src/storage/sqlite.rs +++ b/crates/pinakes-core/src/storage/sqlite.rs @@ -113,6 +113,24 @@ fn row_to_media_item(row: &Row) -> rusqlite::Result { custom_fields: HashMap::new(), // loaded separately // file_mtime may not be present in all queries, so handle gracefully file_mtime: row.get::<_, Option>("file_mtime").unwrap_or(None), + + // Photo-specific fields (may not be present in all queries) + date_taken: row + .get::<_, Option>("date_taken") + .ok() + .flatten() + .and_then(|s| DateTime::parse_from_rfc3339(&s).ok()) + .map(|dt| dt.with_timezone(&Utc)), + latitude: row.get::<_, Option>("latitude").ok().flatten(), + longitude: row.get::<_, Option>("longitude").ok().flatten(), + camera_make: row.get::<_, Option>("camera_make").ok().flatten(), + camera_model: row.get::<_, Option>("camera_model").ok().flatten(), + rating: row.get::<_, Option>("rating").ok().flatten(), + perceptual_hash: row + .get::<_, Option>("perceptual_hash") + .ok() + .flatten(), + created_at: parse_datetime(&created_str), updated_at: parse_datetime(&updated_str), }) @@ -610,8 +628,9 @@ impl StorageBackend for SqliteBackend { db.execute( "INSERT INTO media_items (id, path, file_name, media_type, content_hash, \ file_size, title, artist, album, genre, year, duration_secs, description, \ - thumbnail_path, file_mtime, created_at, updated_at) \ - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)", + thumbnail_path, file_mtime, date_taken, latitude, longitude, camera_make, \ + camera_model, rating, perceptual_hash, created_at, updated_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24)", params![ item.id.0.to_string(), item.path.to_string_lossy().as_ref(), @@ -630,6 +649,13 @@ impl StorageBackend for SqliteBackend { .as_ref() .map(|p| p.to_string_lossy().to_string()), item.file_mtime, + item.date_taken.as_ref().map(|d| d.to_rfc3339()), + item.latitude, + item.longitude, + item.camera_make, + item.camera_model, + item.rating, + item.perceptual_hash, item.created_at.to_rfc3339(), item.updated_at.to_rfc3339(), ], @@ -781,7 +807,9 @@ impl StorageBackend for SqliteBackend { "UPDATE media_items SET path = ?2, file_name = ?3, media_type = ?4, \ content_hash = ?5, file_size = ?6, title = ?7, artist = ?8, album = ?9, \ genre = ?10, year = ?11, duration_secs = ?12, description = ?13, \ - thumbnail_path = ?14, file_mtime = ?15, updated_at = ?16 WHERE id = ?1", + thumbnail_path = ?14, file_mtime = ?15, date_taken = ?16, latitude = ?17, \ + longitude = ?18, camera_make = ?19, camera_model = ?20, rating = ?21, \ + perceptual_hash = ?22, updated_at = ?23 WHERE id = ?1", params![ item.id.0.to_string(), item.path.to_string_lossy().as_ref(), @@ -800,6 +828,13 @@ impl StorageBackend for SqliteBackend { .as_ref() .map(|p| p.to_string_lossy().to_string()), item.file_mtime, + item.date_taken.as_ref().map(|d| d.to_rfc3339()), + item.latitude, + item.longitude, + item.camera_make, + item.camera_model, + item.rating, + item.perceptual_hash, item.updated_at.to_rfc3339(), ], )?; @@ -1534,6 +1569,77 @@ impl StorageBackend for SqliteBackend { .map_err(|e| PinakesError::Database(e.to_string()))? } + async fn find_perceptual_duplicates(&self, threshold: u32) -> Result>> { + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + + // Get all images with perceptual hashes + let mut stmt = db.prepare( + "SELECT * FROM media_items WHERE perceptual_hash IS NOT NULL ORDER BY id", + )?; + let mut items: Vec = stmt + .query_map([], row_to_media_item)? + .collect::>>()?; + + load_custom_fields_batch(&db, &mut items)?; + + // Compare each pair and build groups + use image_hasher::ImageHash; + let mut groups: Vec> = Vec::new(); + let mut grouped_indices: std::collections::HashSet = + std::collections::HashSet::new(); + + for i in 0..items.len() { + if grouped_indices.contains(&i) { + continue; + } + + let hash_a = match &items[i].perceptual_hash { + Some(h) => match ImageHash::>::from_base64(h) { + Ok(hash) => hash, + Err(_) => continue, + }, + None => continue, + }; + + let mut group = vec![items[i].clone()]; + grouped_indices.insert(i); + + for (j, item_j) in items.iter().enumerate().skip(i + 1) { + if grouped_indices.contains(&j) { + continue; + } + + let hash_b = match &item_j.perceptual_hash { + Some(h) => match ImageHash::>::from_base64(h) { + Ok(hash) => hash, + Err(_) => continue, + }, + None => continue, + }; + + let distance = hash_a.dist(&hash_b); + if distance <= threshold { + group.push(item_j.clone()); + grouped_indices.insert(j); + } + } + + // Only add groups with more than one item (actual duplicates) + if group.len() > 1 { + groups.push(group); + } + } + + Ok(groups) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + // -- Database management ----------------------------------------------- async fn database_stats(&self) -> Result { diff --git a/crates/pinakes-core/src/thumbnail.rs b/crates/pinakes-core/src/thumbnail.rs index 2a610a7..9f9ce3a 100644 --- a/crates/pinakes-core/src/thumbnail.rs +++ b/crates/pinakes-core/src/thumbnail.rs @@ -367,7 +367,14 @@ pub fn extract_epub_cover(epub_path: &Path) -> Result>> { } // Fallback: look for common cover image filenames - let cover_names = ["cover.jpg", "cover.jpeg", "cover.png", "Cover.jpg", "Cover.jpeg", "Cover.png"]; + let cover_names = [ + "cover.jpg", + "cover.jpeg", + "cover.png", + "Cover.jpg", + "Cover.jpeg", + "Cover.png", + ]; for name in &cover_names { if let Some(data) = doc.get_resource_by_path(name) { return Ok(Some(data)); @@ -423,3 +430,72 @@ pub fn default_covers_dir() -> PathBuf { pub fn default_thumbnail_dir() -> PathBuf { crate::config::Config::default_data_dir().join("thumbnails") } + +/// Thumbnail size variant for multi-resolution support +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ThumbnailSize { + /// Tiny thumbnail for map markers and icons (64x64) + Tiny, + /// Grid thumbnail for library grid view (320x320) + Grid, + /// Preview thumbnail for quick fullscreen preview (1024x1024) + Preview, +} + +impl ThumbnailSize { + /// Get the pixel size for this thumbnail variant + pub fn pixels(&self) -> u32 { + match self { + ThumbnailSize::Tiny => 64, + ThumbnailSize::Grid => 320, + ThumbnailSize::Preview => 1024, + } + } + + /// Get the subdirectory name for this size + pub fn subdir_name(&self) -> &'static str { + match self { + ThumbnailSize::Tiny => "tiny", + ThumbnailSize::Grid => "grid", + ThumbnailSize::Preview => "preview", + } + } +} + +/// Generate all thumbnail sizes for a media file +/// Returns paths to the generated thumbnails (tiny, grid, preview) +pub fn generate_all_thumbnail_sizes( + media_id: MediaId, + source_path: &Path, + media_type: MediaType, + thumbnail_base_dir: &Path, +) -> Result<(Option, Option, Option)> { + let sizes = [ + ThumbnailSize::Tiny, + ThumbnailSize::Grid, + ThumbnailSize::Preview, + ]; + let mut results = Vec::new(); + + for size in &sizes { + let size_dir = thumbnail_base_dir.join(size.subdir_name()); + std::fs::create_dir_all(&size_dir)?; + + let config = ThumbnailConfig { + size: size.pixels(), + ..ThumbnailConfig::default() + }; + + let result = generate_thumbnail_with_config( + media_id, + source_path, + media_type.clone(), + &size_dir, + &config, + )?; + + results.push(result); + } + + Ok((results[0].clone(), results[1].clone(), results[2].clone())) +} diff --git a/crates/pinakes-core/tests/book_metadata_test.rs b/crates/pinakes-core/tests/book_metadata_test.rs index 63308c8..5cd7199 100644 --- a/crates/pinakes-core/tests/book_metadata_test.rs +++ b/crates/pinakes-core/tests/book_metadata_test.rs @@ -2,7 +2,7 @@ use pinakes_core::books::{extract_isbn_from_text, normalize_isbn, parse_author_f use pinakes_core::enrichment::books::BookEnricher; use pinakes_core::enrichment::googlebooks::GoogleBooksClient; use pinakes_core::enrichment::openlibrary::OpenLibraryClient; -use pinakes_core::thumbnail::{extract_epub_cover, generate_book_covers, CoverSize}; +use pinakes_core::thumbnail::{CoverSize, extract_epub_cover, generate_book_covers}; #[test] fn test_isbn_normalization() { @@ -136,9 +136,13 @@ fn test_book_cover_generation() { let mut img_data = Vec::new(); { use image::{ImageBuffer, Rgb}; - let img: ImageBuffer, Vec> = ImageBuffer::from_fn(100, 100, |_, _| Rgb([255u8, 0u8, 0u8])); - img.write_to(&mut std::io::Cursor::new(&mut img_data), image::ImageFormat::Png) - .unwrap(); + let img: ImageBuffer, Vec> = + ImageBuffer::from_fn(100, 100, |_, _| Rgb([255u8, 0u8, 0u8])); + img.write_to( + &mut std::io::Cursor::new(&mut img_data), + image::ImageFormat::Png, + ) + .unwrap(); } let temp_dir = tempdir().unwrap(); diff --git a/crates/pinakes-core/tests/integration_test.rs b/crates/pinakes-core/tests/integration_test.rs index c0d1506..a3d1ff8 100644 --- a/crates/pinakes-core/tests/integration_test.rs +++ b/crates/pinakes-core/tests/integration_test.rs @@ -36,6 +36,13 @@ async fn test_media_crud() { thumbnail_path: None, custom_fields: HashMap::new(), file_mtime: None, + date_taken: None, + latitude: None, + longitude: None, + camera_make: None, + camera_model: None, + rating: None, + perceptual_hash: None, created_at: now, updated_at: now, }; @@ -115,6 +122,13 @@ async fn test_tags() { thumbnail_path: None, custom_fields: HashMap::new(), file_mtime: None, + date_taken: None, + latitude: None, + longitude: None, + camera_make: None, + camera_model: None, + rating: None, + perceptual_hash: None, created_at: now, updated_at: now, }; @@ -168,6 +182,13 @@ async fn test_collections() { thumbnail_path: None, custom_fields: HashMap::new(), file_mtime: None, + date_taken: None, + latitude: None, + longitude: None, + camera_make: None, + camera_model: None, + rating: None, + perceptual_hash: None, created_at: now, updated_at: now, }; @@ -216,6 +237,13 @@ async fn test_custom_fields() { thumbnail_path: None, custom_fields: HashMap::new(), file_mtime: None, + date_taken: None, + latitude: None, + longitude: None, + camera_make: None, + camera_model: None, + rating: None, + perceptual_hash: None, created_at: now, updated_at: now, }; @@ -283,6 +311,13 @@ async fn test_search() { thumbnail_path: None, custom_fields: HashMap::new(), file_mtime: None, + date_taken: None, + latitude: None, + longitude: None, + camera_make: None, + camera_model: None, + rating: None, + perceptual_hash: None, created_at: now, updated_at: now, }; @@ -415,6 +450,13 @@ async fn test_library_statistics_with_data() { thumbnail_path: None, custom_fields: HashMap::new(), file_mtime: None, + date_taken: None, + latitude: None, + longitude: None, + camera_make: None, + camera_model: None, + rating: None, + perceptual_hash: None, created_at: now, updated_at: now, }; @@ -452,6 +494,13 @@ fn make_test_media(hash: &str) -> MediaItem { thumbnail_path: None, custom_fields: HashMap::new(), file_mtime: None, + date_taken: None, + latitude: None, + longitude: None, + camera_make: None, + camera_model: None, + rating: None, + perceptual_hash: None, created_at: now, updated_at: now, } diff --git a/crates/pinakes-core/tests/integrity_enhanced_test.rs b/crates/pinakes-core/tests/integrity_enhanced_test.rs index 86d44b2..57ba591 100644 --- a/crates/pinakes-core/tests/integrity_enhanced_test.rs +++ b/crates/pinakes-core/tests/integrity_enhanced_test.rs @@ -39,6 +39,13 @@ fn create_test_media_item(path: PathBuf, hash: &str) -> MediaItem { thumbnail_path: None, custom_fields: HashMap::new(), file_mtime: None, + date_taken: None, + latitude: None, + longitude: None, + camera_make: None, + camera_model: None, + rating: None, + perceptual_hash: None, created_at: chrono::Utc::now(), updated_at: chrono::Utc::now(), } diff --git a/crates/pinakes-server/src/app.rs b/crates/pinakes-server/src/app.rs index 8200db9..16de010 100644 --- a/crates/pinakes-server/src/app.rs +++ b/crates/pinakes-server/src/app.rs @@ -102,6 +102,8 @@ pub fn create_router_with_tls( .route("/media/{media_id}/tags", get(routes::tags::get_media_tags)) // Books API .nest("/books", routes::books::routes()) + // Photos API + .nest("/photos", routes::photos::routes()) .route("/tags", get(routes::tags::list_tags)) .route("/tags/{id}", get(routes::tags::get_tag)) .route("/collections", get(routes::collections::list_collections)) diff --git a/crates/pinakes-server/src/dto.rs b/crates/pinakes-server/src/dto.rs index 9c4fc8d..4e777dc 100644 --- a/crates/pinakes-server/src/dto.rs +++ b/crates/pinakes-server/src/dto.rs @@ -23,6 +23,15 @@ pub struct MediaResponse { pub description: Option, pub has_thumbnail: bool, pub custom_fields: HashMap, + + // Photo-specific metadata + pub date_taken: Option>, + pub latitude: Option, + pub longitude: Option, + pub camera_make: Option, + pub camera_model: Option, + pub rating: Option, + pub created_at: DateTime, pub updated_at: DateTime, } @@ -509,6 +518,15 @@ impl From for MediaResponse { ) }) .collect(), + + // Photo-specific metadata + date_taken: item.date_taken, + latitude: item.latitude, + longitude: item.longitude, + camera_make: item.camera_make, + camera_model: item.camera_model, + rating: item.rating, + created_at: item.created_at, updated_at: item.updated_at, } diff --git a/crates/pinakes-server/src/main.rs b/crates/pinakes-server/src/main.rs index 03fcc62..128dfc5 100644 --- a/crates/pinakes-server/src/main.rs +++ b/crates/pinakes-server/src/main.rs @@ -90,9 +90,17 @@ async fn main() -> Result<()> { } let config_path = resolve_config_path(cli.config.as_deref()); - info!(path = %config_path.display(), "loading configuration"); - let mut config = Config::load_or_default(&config_path)?; + let mut config = if config_path.exists() { + info!(path = %config_path.display(), "loading configuration from file"); + Config::from_file(&config_path)? + } else { + info!( + "using default configuration (no config file found at {})", + config_path.display() + ); + Config::default() + }; config.ensure_dirs()?; config .validate() diff --git a/crates/pinakes-server/src/routes/mod.rs b/crates/pinakes-server/src/routes/mod.rs index d0630cd..969c9b4 100644 --- a/crates/pinakes-server/src/routes/mod.rs +++ b/crates/pinakes-server/src/routes/mod.rs @@ -12,6 +12,7 @@ pub mod health; pub mod integrity; pub mod jobs; pub mod media; +pub mod photos; pub mod playlists; pub mod plugins; pub mod saved_searches; diff --git a/crates/pinakes-server/src/routes/photos.rs b/crates/pinakes-server/src/routes/photos.rs new file mode 100644 index 0000000..3b0abd2 --- /dev/null +++ b/crates/pinakes-server/src/routes/photos.rs @@ -0,0 +1,189 @@ +use axum::{ + Json, Router, + extract::{Query, State}, + response::IntoResponse, + routing::get, +}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +use crate::{dto::MediaResponse, error::ApiError, state::AppState}; + +/// Timeline grouping mode +#[derive(Debug, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum GroupBy { + #[default] + Day, + Month, + Year, +} + +/// Timeline query parameters +#[derive(Debug, Deserialize)] +pub struct TimelineQuery { + #[serde(default)] + pub group_by: GroupBy, + pub year: Option, + pub month: Option, +} + +/// Timeline group response +#[derive(Debug, Serialize)] +pub struct TimelineGroup { + pub date: String, + pub count: usize, + pub cover_id: Option, + pub items: Vec, +} + +/// Map query parameters +#[derive(Debug, Deserialize)] +pub struct MapQuery { + pub lat1: f64, + pub lon1: f64, + pub lat2: f64, + pub lon2: f64, +} + +/// Map marker response +#[derive(Debug, Serialize)] +pub struct MapMarker { + pub id: String, + pub latitude: f64, + pub longitude: f64, + pub thumbnail_url: Option, + pub date_taken: Option>, +} + +/// Get timeline of photos grouped by date +pub async fn get_timeline( + State(state): State, + Query(query): Query, +) -> Result { + // Query photos with date_taken + let all_media = state + .storage + .list_media(&pinakes_core::model::Pagination { + offset: 0, + limit: 10000, // TODO: Make this more efficient with streaming + sort: Some("date_taken DESC".to_string()), + }) + .await?; + + // Filter to only photos with date_taken + let photos: Vec<_> = all_media + .into_iter() + .filter(|item| { + item.date_taken.is_some() + && item.media_type.category() == pinakes_core::media_type::MediaCategory::Image + }) + .collect(); + + // Group by the requested period + let mut groups: HashMap> = HashMap::new(); + + for photo in photos { + if let Some(date_taken) = photo.date_taken { + use chrono::Datelike; + + // Filter by year/month if specified + if let Some(y) = query.year + && date_taken.year() != y + { + continue; + } + if let Some(m) = query.month + && date_taken.month() != m + { + continue; + } + + let key = match query.group_by { + GroupBy::Day => date_taken.format("%Y-%m-%d").to_string(), + GroupBy::Month => date_taken.format("%Y-%m").to_string(), + GroupBy::Year => date_taken.format("%Y").to_string(), + }; + + groups.entry(key).or_default().push(photo); + } + } + + // Convert to response format + let mut timeline: Vec = groups + .into_iter() + .map(|(date, items)| { + let cover_id = items.first().map(|i| i.id.0.to_string()); + let count = items.len(); + let items: Vec = items.into_iter().map(MediaResponse::from).collect(); + + TimelineGroup { + date, + count, + cover_id, + items, + } + }) + .collect(); + + // Sort by date descending + timeline.sort_by(|a, b| b.date.cmp(&a.date)); + + Ok(Json(timeline)) +} + +/// Get photos in a bounding box for map view +pub async fn get_map_photos( + State(state): State, + Query(query): Query, +) -> Result { + // Validate bounding box + let min_lat = query.lat1.min(query.lat2); + let max_lat = query.lat1.max(query.lat2); + let min_lon = query.lon1.min(query.lon2); + let max_lon = query.lon1.max(query.lon2); + + // Query all media (we'll filter in-memory for now - could optimize with DB query) + let all_media = state + .storage + .list_media(&pinakes_core::model::Pagination { + offset: 0, + limit: 10000, + sort: None, + }) + .await?; + + // Filter to photos with GPS coordinates in the bounding box + let markers: Vec = all_media + .into_iter() + .filter_map(|item| { + if let (Some(lat), Some(lon)) = (item.latitude, item.longitude) + && lat >= min_lat + && lat <= max_lat + && lon >= min_lon + && lon <= max_lon + { + return Some(MapMarker { + id: item.id.0.to_string(), + latitude: lat, + longitude: lon, + thumbnail_url: item + .thumbnail_path + .map(|_p| format!("/api/v1/media/{}/thumbnail", item.id.0)), + date_taken: item.date_taken, + }); + } + None + }) + .collect(); + + Ok(Json(markers)) +} + +/// Photo routes +pub fn routes() -> Router { + Router::new() + .route("/timeline", get(get_timeline)) + .route("/map", get(get_map_photos)) +} diff --git a/crates/pinakes-server/tests/api_test.rs b/crates/pinakes-server/tests/api_test.rs index 925ea4a..2b66343 100644 --- a/crates/pinakes-server/tests/api_test.rs +++ b/crates/pinakes-server/tests/api_test.rs @@ -11,9 +11,9 @@ use tower::ServiceExt; use pinakes_core::cache::CacheLayer; use pinakes_core::config::{ AccountsConfig, AnalyticsConfig, CloudConfig, Config, DirectoryConfig, EnrichmentConfig, - JobsConfig, PluginsConfig, ScanningConfig, ServerConfig, SqliteConfig, StorageBackendType, - StorageConfig, ThumbnailConfig, TlsConfig, TranscodingConfig, UiConfig, UserAccount, UserRole, - WebhookConfig, + JobsConfig, PhotoConfig, PluginsConfig, ScanningConfig, ServerConfig, SqliteConfig, + StorageBackendType, StorageConfig, ThumbnailConfig, TlsConfig, TranscodingConfig, UiConfig, + UserAccount, UserRole, WebhookConfig, }; use pinakes_core::jobs::JobQueue; use pinakes_core::storage::StorageBackend; @@ -126,6 +126,7 @@ fn default_config() -> Config { enrichment: EnrichmentConfig::default(), cloud: CloudConfig::default(), analytics: AnalyticsConfig::default(), + photos: PhotoConfig::default(), } } diff --git a/crates/pinakes-server/tests/plugin_test.rs b/crates/pinakes-server/tests/plugin_test.rs index 6111271..7970f6d 100644 --- a/crates/pinakes-server/tests/plugin_test.rs +++ b/crates/pinakes-server/tests/plugin_test.rs @@ -11,8 +11,9 @@ use tower::ServiceExt; use pinakes_core::cache::CacheLayer; use pinakes_core::config::{ AccountsConfig, AnalyticsConfig, CloudConfig, Config, DirectoryConfig, EnrichmentConfig, - JobsConfig, PluginsConfig, ScanningConfig, ServerConfig, SqliteConfig, StorageBackendType, - StorageConfig, ThumbnailConfig, TlsConfig, TranscodingConfig, UiConfig, WebhookConfig, + JobsConfig, PhotoConfig, PluginsConfig, ScanningConfig, ServerConfig, SqliteConfig, + StorageBackendType, StorageConfig, ThumbnailConfig, TlsConfig, TranscodingConfig, UiConfig, + WebhookConfig, }; use pinakes_core::jobs::JobQueue; use pinakes_core::plugin::PluginManager; @@ -91,6 +92,7 @@ async fn setup_app_with_plugins() -> (axum::Router, Arc, tempfile enrichment: EnrichmentConfig::default(), cloud: CloudConfig::default(), analytics: AnalyticsConfig::default(), + photos: PhotoConfig::default(), }; let job_queue = JobQueue::new(1, |_id, _kind, _cancel, _jobs| tokio::spawn(async {})); diff --git a/crates/pinakes-ui/src/client.rs b/crates/pinakes-ui/src/client.rs index d3c90fd..55a01ab 100644 --- a/crates/pinakes-ui/src/client.rs +++ b/crates/pinakes-ui/src/client.rs @@ -1145,7 +1145,10 @@ mod tests { fn test_thumbnail_url() { let client = ApiClient::new("http://localhost:3000", None); let url = client.thumbnail_url("test-id-456"); - assert_eq!(url, "http://localhost:3000/api/v1/media/test-id-456/thumbnail"); + assert_eq!( + url, + "http://localhost:3000/api/v1/media/test-id-456/thumbnail" + ); } #[test] diff --git a/migrations/postgres/V13__photo_metadata.sql b/migrations/postgres/V13__photo_metadata.sql new file mode 100644 index 0000000..f1365cd --- /dev/null +++ b/migrations/postgres/V13__photo_metadata.sql @@ -0,0 +1,15 @@ +-- V13: Enhanced photo metadata support +-- Add photo-specific fields to media_items table + +ALTER TABLE media_items ADD COLUMN date_taken TIMESTAMPTZ; +ALTER TABLE media_items ADD COLUMN latitude DOUBLE PRECISION; +ALTER TABLE media_items ADD COLUMN longitude DOUBLE PRECISION; +ALTER TABLE media_items ADD COLUMN camera_make TEXT; +ALTER TABLE media_items ADD COLUMN camera_model TEXT; +ALTER TABLE media_items ADD COLUMN rating INTEGER CHECK (rating >= 0 AND rating <= 5); + +-- Indexes for photo queries +CREATE INDEX idx_media_date_taken ON media_items(date_taken) WHERE date_taken IS NOT NULL; +CREATE INDEX idx_media_location ON media_items(latitude, longitude) WHERE latitude IS NOT NULL AND longitude IS NOT NULL; +CREATE INDEX idx_media_camera ON media_items(camera_make) WHERE camera_make IS NOT NULL; +CREATE INDEX idx_media_rating ON media_items(rating) WHERE rating IS NOT NULL; diff --git a/migrations/postgres/V14__perceptual_hash.sql b/migrations/postgres/V14__perceptual_hash.sql new file mode 100644 index 0000000..4bdc677 --- /dev/null +++ b/migrations/postgres/V14__perceptual_hash.sql @@ -0,0 +1,7 @@ +-- V14: Perceptual hash for duplicate detection +-- Add perceptual hash column for image similarity detection + +ALTER TABLE media_items ADD COLUMN perceptual_hash TEXT; + +-- Index for perceptual hash lookups +CREATE INDEX idx_media_phash ON media_items(perceptual_hash) WHERE perceptual_hash IS NOT NULL; diff --git a/migrations/sqlite/V13__photo_metadata.sql b/migrations/sqlite/V13__photo_metadata.sql new file mode 100644 index 0000000..616b2fa --- /dev/null +++ b/migrations/sqlite/V13__photo_metadata.sql @@ -0,0 +1,15 @@ +-- V13: Enhanced photo metadata support +-- Add photo-specific fields to media_items table + +ALTER TABLE media_items ADD COLUMN date_taken TIMESTAMP; +ALTER TABLE media_items ADD COLUMN latitude REAL; +ALTER TABLE media_items ADD COLUMN longitude REAL; +ALTER TABLE media_items ADD COLUMN camera_make TEXT; +ALTER TABLE media_items ADD COLUMN camera_model TEXT; +ALTER TABLE media_items ADD COLUMN rating INTEGER CHECK (rating >= 0 AND rating <= 5); + +-- Indexes for photo queries +CREATE INDEX idx_media_date_taken ON media_items(date_taken) WHERE date_taken IS NOT NULL; +CREATE INDEX idx_media_location ON media_items(latitude, longitude) WHERE latitude IS NOT NULL AND longitude IS NOT NULL; +CREATE INDEX idx_media_camera ON media_items(camera_make) WHERE camera_make IS NOT NULL; +CREATE INDEX idx_media_rating ON media_items(rating) WHERE rating IS NOT NULL; diff --git a/migrations/sqlite/V14__perceptual_hash.sql b/migrations/sqlite/V14__perceptual_hash.sql new file mode 100644 index 0000000..4bdc677 --- /dev/null +++ b/migrations/sqlite/V14__perceptual_hash.sql @@ -0,0 +1,7 @@ +-- V14: Perceptual hash for duplicate detection +-- Add perceptual hash column for image similarity detection + +ALTER TABLE media_items ADD COLUMN perceptual_hash TEXT; + +-- Index for perceptual hash lookups +CREATE INDEX idx_media_phash ON media_items(perceptual_hash) WHERE perceptual_hash IS NOT NULL;