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:
@@ -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()))
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
52
src/main.rs
52
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(|(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();
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user