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<Library> 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) <noreply@anthropic.com>
This commit is contained in:
Cameron
2026-04-18 09:45:43 -04:00
parent 61f98066f6
commit b5b3ba3a9d
5 changed files with 92 additions and 19 deletions

View File

@@ -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<Mutex<Box<dyn KnowledgeDao>>>,
base_path: String,
libraries: Vec<Library>,
}
impl InsightGenerator {
@@ -67,7 +68,7 @@ impl InsightGenerator {
search_dao: Arc<Mutex<Box<dyn SearchHistoryDao>>>,
tag_dao: Arc<Mutex<Box<dyn TagDao>>>,
knowledge_dao: Arc<Mutex<Box<dyn KnowledgeDao>>>,
base_path: String,
libraries: Vec<Library>,
) -> 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<std::path::PathBuf> {
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<String> {
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()))

View File

@@ -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<Mutex<Box<dyn KnowledgeDao>>> =
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");

View File

@@ -239,6 +239,8 @@ pub struct MetadataResponse {
pub size: u64,
pub exif: Option<ExifMetadata>,
pub filename_date: Option<i64>, // Date extracted from filename
pub library_id: Option<i32>,
pub library_name: Option<String>,
}
impl From<fs::Metadata> for MetadataResponse {
@@ -255,6 +257,8 @@ impl From<fs::Metadata> for MetadataResponse {
size: metadata.len(),
exif: None,
filename_date: None, // Will be set in endpoint handler
library_id: None,
library_name: None,
}
}
}

View File

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

View File

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