From b5b3ba3a9d88cc58f6e8679660efe16c16b82929 Mon Sep 17 00:00:00 2001 From: Cameron Date: Sat, 18 Apr 2026 09:45:43 -0400 Subject: [PATCH] fix: resolve media across libraries for video, metadata, and insights The /video/generate and /image/metadata handlers assumed files live under the resolved library only, which broke when a mobile client passed no library (union mode) but the file lived in a non-primary library. Both now fall back to scanning every configured library for an existing file. InsightGenerator held a single base_path, so vision-model loads and filename-date fallbacks failed for non-primary libraries. It now takes Vec and probes each root in resolve_full_path. /image/metadata responses now carry library_id/library_name so the mobile viewer can surface which library a file belongs to. Thumbnail generation at startup is now spawned on a background thread so the HTTP server can accept traffic while large libraries backfill. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/ai/insight_generator.rs | 35 +++++++++++++++++------ src/bin/populate_knowledge.rs | 9 +++++- src/data/mod.rs | 4 +++ src/main.rs | 54 +++++++++++++++++++++++++++++------ src/state.rs | 9 ++++-- 5 files changed, 92 insertions(+), 19 deletions(-) diff --git a/src/ai/insight_generator.rs b/src/ai/insight_generator.rs index 6f0b319..07881c6 100644 --- a/src/ai/insight_generator.rs +++ b/src/ai/insight_generator.rs @@ -16,6 +16,7 @@ use crate::database::{ CalendarEventDao, DailySummaryDao, ExifDao, InsightDao, KnowledgeDao, LocationHistoryDao, SearchHistoryDao, }; +use crate::libraries::Library; use crate::memories::extract_date_from_filename; use crate::otel::global_tracer; use crate::tags::TagDao; @@ -52,7 +53,7 @@ pub struct InsightGenerator { // Knowledge memory knowledge_dao: Arc>>, - base_path: String, + libraries: Vec, } impl InsightGenerator { @@ -67,7 +68,7 @@ impl InsightGenerator { search_dao: Arc>>, tag_dao: Arc>>, knowledge_dao: Arc>>, - base_path: String, + libraries: Vec, ) -> Self { Self { ollama, @@ -80,10 +81,25 @@ impl InsightGenerator { search_dao, tag_dao, knowledge_dao, - base_path, + libraries, } } + /// Resolve `rel_path` against the configured libraries, returning the + /// first root under which the file exists. Insights may be generated + /// for any library — the generator itself doesn't know which — so we + /// probe each root rather than trust a single `base_path`. + fn resolve_full_path(&self, rel_path: &str) -> Option { + use std::path::Path; + for lib in &self.libraries { + let candidate = Path::new(&lib.root_path).join(rel_path); + if candidate.exists() { + return Some(candidate); + } + } + None + } + /// Extract contact name from file path /// e.g., "Sarah/img.jpeg" -> Some("Sarah") /// e.g., "img.jpeg" -> None @@ -108,9 +124,13 @@ impl InsightGenerator { /// Resizes to max 1024px on longest edge to reduce context usage fn load_image_as_base64(&self, file_path: &str) -> Result { use image::imageops::FilterType; - use std::path::Path; - let full_path = Path::new(&self.base_path).join(file_path); + let full_path = self.resolve_full_path(file_path).ok_or_else(|| { + anyhow::anyhow!( + "File '{}' not found under any configured library", + file_path + ) + })?; log::debug!("Loading image for vision model: {:?}", full_path); @@ -725,8 +745,7 @@ impl InsightGenerator { extract_date_from_filename(&file_path) .map(|dt| dt.timestamp()) .or_else(|| { - // Combine base_path with file_path to get full path - let full_path = std::path::Path::new(&self.base_path).join(&file_path); + let full_path = self.resolve_full_path(&file_path)?; File::open(&full_path) .and_then(|f| f.metadata()) .and_then(|m| m.created().or(m.modified())) @@ -2455,7 +2474,7 @@ Return ONLY the summary, nothing else."#, extract_date_from_filename(&file_path) .map(|dt| dt.timestamp()) .or_else(|| { - let full_path = std::path::Path::new(&self.base_path).join(&file_path); + let full_path = self.resolve_full_path(&file_path)?; File::open(&full_path) .and_then(|f| f.metadata()) .and_then(|m| m.created().or(m.modified())) diff --git a/src/bin/populate_knowledge.rs b/src/bin/populate_knowledge.rs index f9373ad..bc37960 100644 --- a/src/bin/populate_knowledge.rs +++ b/src/bin/populate_knowledge.rs @@ -11,6 +11,7 @@ use image_api::database::{ SqliteInsightDao, SqliteKnowledgeDao, SqliteLocationHistoryDao, SqliteSearchHistoryDao, }; use image_api::file_types::{IMAGE_EXTENSIONS, VIDEO_EXTENSIONS}; +use image_api::libraries::{self, Library}; use image_api::tags::{SqliteTagDao, TagDao}; #[derive(Parser, Debug)] @@ -125,6 +126,12 @@ async fn main() -> anyhow::Result<()> { let knowledge_dao: Arc>> = Arc::new(Mutex::new(Box::new(SqliteKnowledgeDao::new()))); + let populate_lib = Library { + id: libraries::PRIMARY_LIBRARY_ID, + name: "main".to_string(), + root_path: base_path.clone(), + }; + let generator = InsightGenerator::new( ollama, sms_client, @@ -136,7 +143,7 @@ async fn main() -> anyhow::Result<()> { search_dao, tag_dao, knowledge_dao, - base_path.clone(), + vec![populate_lib], ); println!("Knowledge Base Population"); diff --git a/src/data/mod.rs b/src/data/mod.rs index ff9ac25..b6ba795 100644 --- a/src/data/mod.rs +++ b/src/data/mod.rs @@ -239,6 +239,8 @@ pub struct MetadataResponse { pub size: u64, pub exif: Option, pub filename_date: Option, // Date extracted from filename + pub library_id: Option, + pub library_name: Option, } impl From for MetadataResponse { @@ -255,6 +257,8 @@ impl From for MetadataResponse { size: metadata.len(), exif: None, filename_date: None, // Will be set in endpoint handler + library_id: None, + library_name: None, } } } diff --git a/src/main.rs b/src/main.rs index e28a43a..af20c4e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -319,24 +319,32 @@ async fn get_file_metadata( // Fall back to other libraries if the file isn't under the resolved one, // matching the `/image` handler so union-mode search results resolve. - let full_path = is_valid_full_path(&library.root_path, &path.path, false) + let resolved = is_valid_full_path(&library.root_path, &path.path, false) .filter(|p| p.exists()) + .map(|p| (library, p)) .or_else(|| { app_state.libraries.iter().find_map(|lib| { if lib.id == library.id { return None; } - is_valid_full_path(&lib.root_path, &path.path, false).filter(|p| p.exists()) + is_valid_full_path(&lib.root_path, &path.path, false) + .filter(|p| p.exists()) + .map(|p| (lib, p)) }) }); - match full_path + match resolved .ok_or_else(|| ErrorKind::InvalidData.into()) - .and_then(File::open) - .and_then(|file| file.metadata()) + .and_then(|(lib, full_path)| { + File::open(&full_path) + .and_then(|file| file.metadata()) + .map(|metadata| (lib, metadata)) + }) { - Ok(metadata) => { + Ok((resolved_library, metadata)) => { let mut response: MetadataResponse = metadata.into(); + response.library_id = Some(resolved_library.id); + response.library_name = Some(resolved_library.name.clone()); // Extract date from filename if possible response.filename_date = @@ -573,7 +581,28 @@ async fn generate_video( if let Some(name) = filename.file_name() { let filename = name.to_str().expect("Filename should convert to string"); let playlist = format!("{}/{}.m3u8", app_state.video_path, filename); - if let Some(path) = is_valid_full_path(&app_state.base_path, &body.path, false) { + + let library = libraries::resolve_library_param(&app_state, body.library.as_deref()) + .ok() + .flatten() + .unwrap_or_else(|| app_state.primary_library()); + + // Try the resolved library first, then fall back to any other library + // that actually contains the file — handles union-mode requests where + // the mobile client passes no library but the file lives in a + // non-primary library. + let resolved = is_valid_full_path(&library.root_path, &body.path, false) + .filter(|p| p.exists()) + .or_else(|| { + app_state.libraries.iter().find_map(|lib| { + if lib.id == library.id { + return None; + } + is_valid_full_path(&lib.root_path, &body.path, false).filter(|p| p.exists()) + }) + }); + + if let Some(path) = resolved { if let Ok(child) = create_playlist(path.to_str().unwrap(), &playlist).await { span.add_event( "playlist_created".to_string(), @@ -1161,7 +1190,16 @@ fn main() -> std::io::Result<()> { // table; we use that list to drive the initial thumbnail sweep. let app_data = Data::new(AppState::default()); - create_thumbnails(&app_data.libraries); + // Kick thumbnail generation onto a background thread so the HTTP + // server can accept traffic while large libraries are backfilling. + // Existing thumbs are re-used (exists() check inside the walk), + // so missed files are filled in over successive scans. + { + let libs = app_data.libraries.clone(); + std::thread::spawn(move || { + create_thumbnails(&libs); + }); + } // generate_video_gifs().await; let labels = HashMap::new(); diff --git a/src/state.rs b/src/state.rs index 8f0dc69..d901a66 100644 --- a/src/state.rs +++ b/src/state.rs @@ -174,7 +174,7 @@ impl Default for AppState { search_dao.clone(), tag_dao.clone(), knowledge_dao, - base_path.clone(), + libraries_vec.clone(), ); // Ensure preview clips directory exists @@ -245,6 +245,11 @@ impl AppState { // Initialize test InsightGenerator with all data sources let base_path_str = base_path.to_string_lossy().to_string(); + let test_lib = Library { + id: crate::libraries::PRIMARY_LIBRARY_ID, + name: "main".to_string(), + root_path: base_path_str.clone(), + }; let insight_generator = InsightGenerator::new( ollama.clone(), sms_client.clone(), @@ -256,7 +261,7 @@ impl AppState { search_dao.clone(), tag_dao.clone(), knowledge_dao, - base_path_str.clone(), + vec![test_lib], ); // Initialize test preview DAO