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,
|
CalendarEventDao, DailySummaryDao, ExifDao, InsightDao, KnowledgeDao, LocationHistoryDao,
|
||||||
SearchHistoryDao,
|
SearchHistoryDao,
|
||||||
};
|
};
|
||||||
|
use crate::libraries::Library;
|
||||||
use crate::memories::extract_date_from_filename;
|
use crate::memories::extract_date_from_filename;
|
||||||
use crate::otel::global_tracer;
|
use crate::otel::global_tracer;
|
||||||
use crate::tags::TagDao;
|
use crate::tags::TagDao;
|
||||||
@@ -52,7 +53,7 @@ pub struct InsightGenerator {
|
|||||||
// Knowledge memory
|
// Knowledge memory
|
||||||
knowledge_dao: Arc<Mutex<Box<dyn KnowledgeDao>>>,
|
knowledge_dao: Arc<Mutex<Box<dyn KnowledgeDao>>>,
|
||||||
|
|
||||||
base_path: String,
|
libraries: Vec<Library>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl InsightGenerator {
|
impl InsightGenerator {
|
||||||
@@ -67,7 +68,7 @@ impl InsightGenerator {
|
|||||||
search_dao: Arc<Mutex<Box<dyn SearchHistoryDao>>>,
|
search_dao: Arc<Mutex<Box<dyn SearchHistoryDao>>>,
|
||||||
tag_dao: Arc<Mutex<Box<dyn TagDao>>>,
|
tag_dao: Arc<Mutex<Box<dyn TagDao>>>,
|
||||||
knowledge_dao: Arc<Mutex<Box<dyn KnowledgeDao>>>,
|
knowledge_dao: Arc<Mutex<Box<dyn KnowledgeDao>>>,
|
||||||
base_path: String,
|
libraries: Vec<Library>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
ollama,
|
ollama,
|
||||||
@@ -80,10 +81,25 @@ impl InsightGenerator {
|
|||||||
search_dao,
|
search_dao,
|
||||||
tag_dao,
|
tag_dao,
|
||||||
knowledge_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
|
/// Extract contact name from file path
|
||||||
/// e.g., "Sarah/img.jpeg" -> Some("Sarah")
|
/// e.g., "Sarah/img.jpeg" -> Some("Sarah")
|
||||||
/// e.g., "img.jpeg" -> None
|
/// e.g., "img.jpeg" -> None
|
||||||
@@ -108,9 +124,13 @@ impl InsightGenerator {
|
|||||||
/// Resizes to max 1024px on longest edge to reduce context usage
|
/// Resizes to max 1024px on longest edge to reduce context usage
|
||||||
fn load_image_as_base64(&self, file_path: &str) -> Result<String> {
|
fn load_image_as_base64(&self, file_path: &str) -> Result<String> {
|
||||||
use image::imageops::FilterType;
|
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);
|
log::debug!("Loading image for vision model: {:?}", full_path);
|
||||||
|
|
||||||
@@ -725,8 +745,7 @@ impl InsightGenerator {
|
|||||||
extract_date_from_filename(&file_path)
|
extract_date_from_filename(&file_path)
|
||||||
.map(|dt| dt.timestamp())
|
.map(|dt| dt.timestamp())
|
||||||
.or_else(|| {
|
.or_else(|| {
|
||||||
// Combine base_path with file_path to get full path
|
let full_path = self.resolve_full_path(&file_path)?;
|
||||||
let full_path = std::path::Path::new(&self.base_path).join(&file_path);
|
|
||||||
File::open(&full_path)
|
File::open(&full_path)
|
||||||
.and_then(|f| f.metadata())
|
.and_then(|f| f.metadata())
|
||||||
.and_then(|m| m.created().or(m.modified()))
|
.and_then(|m| m.created().or(m.modified()))
|
||||||
@@ -2455,7 +2474,7 @@ Return ONLY the summary, nothing else."#,
|
|||||||
extract_date_from_filename(&file_path)
|
extract_date_from_filename(&file_path)
|
||||||
.map(|dt| dt.timestamp())
|
.map(|dt| dt.timestamp())
|
||||||
.or_else(|| {
|
.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)
|
File::open(&full_path)
|
||||||
.and_then(|f| f.metadata())
|
.and_then(|f| f.metadata())
|
||||||
.and_then(|m| m.created().or(m.modified()))
|
.and_then(|m| m.created().or(m.modified()))
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use image_api::database::{
|
|||||||
SqliteInsightDao, SqliteKnowledgeDao, SqliteLocationHistoryDao, SqliteSearchHistoryDao,
|
SqliteInsightDao, SqliteKnowledgeDao, SqliteLocationHistoryDao, SqliteSearchHistoryDao,
|
||||||
};
|
};
|
||||||
use image_api::file_types::{IMAGE_EXTENSIONS, VIDEO_EXTENSIONS};
|
use image_api::file_types::{IMAGE_EXTENSIONS, VIDEO_EXTENSIONS};
|
||||||
|
use image_api::libraries::{self, Library};
|
||||||
use image_api::tags::{SqliteTagDao, TagDao};
|
use image_api::tags::{SqliteTagDao, TagDao};
|
||||||
|
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
@@ -125,6 +126,12 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let knowledge_dao: Arc<Mutex<Box<dyn KnowledgeDao>>> =
|
let knowledge_dao: Arc<Mutex<Box<dyn KnowledgeDao>>> =
|
||||||
Arc::new(Mutex::new(Box::new(SqliteKnowledgeDao::new())));
|
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(
|
let generator = InsightGenerator::new(
|
||||||
ollama,
|
ollama,
|
||||||
sms_client,
|
sms_client,
|
||||||
@@ -136,7 +143,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
search_dao,
|
search_dao,
|
||||||
tag_dao,
|
tag_dao,
|
||||||
knowledge_dao,
|
knowledge_dao,
|
||||||
base_path.clone(),
|
vec![populate_lib],
|
||||||
);
|
);
|
||||||
|
|
||||||
println!("Knowledge Base Population");
|
println!("Knowledge Base Population");
|
||||||
|
|||||||
@@ -239,6 +239,8 @@ pub struct MetadataResponse {
|
|||||||
pub size: u64,
|
pub size: u64,
|
||||||
pub exif: Option<ExifMetadata>,
|
pub exif: Option<ExifMetadata>,
|
||||||
pub filename_date: Option<i64>, // Date extracted from filename
|
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 {
|
impl From<fs::Metadata> for MetadataResponse {
|
||||||
@@ -255,6 +257,8 @@ impl From<fs::Metadata> for MetadataResponse {
|
|||||||
size: metadata.len(),
|
size: metadata.len(),
|
||||||
exif: None,
|
exif: None,
|
||||||
filename_date: None, // Will be set in endpoint handler
|
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,
|
// Fall back to other libraries if the file isn't under the resolved one,
|
||||||
// matching the `/image` handler so union-mode search results resolve.
|
// 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())
|
.filter(|p| p.exists())
|
||||||
|
.map(|p| (library, p))
|
||||||
.or_else(|| {
|
.or_else(|| {
|
||||||
app_state.libraries.iter().find_map(|lib| {
|
app_state.libraries.iter().find_map(|lib| {
|
||||||
if lib.id == library.id {
|
if lib.id == library.id {
|
||||||
return None;
|
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())
|
.ok_or_else(|| ErrorKind::InvalidData.into())
|
||||||
.and_then(File::open)
|
.and_then(|(lib, full_path)| {
|
||||||
|
File::open(&full_path)
|
||||||
.and_then(|file| file.metadata())
|
.and_then(|file| file.metadata())
|
||||||
|
.map(|metadata| (lib, metadata))
|
||||||
|
})
|
||||||
{
|
{
|
||||||
Ok(metadata) => {
|
Ok((resolved_library, metadata)) => {
|
||||||
let mut response: MetadataResponse = metadata.into();
|
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
|
// Extract date from filename if possible
|
||||||
response.filename_date =
|
response.filename_date =
|
||||||
@@ -573,7 +581,28 @@ async fn generate_video(
|
|||||||
if let Some(name) = filename.file_name() {
|
if let Some(name) = filename.file_name() {
|
||||||
let filename = name.to_str().expect("Filename should convert to string");
|
let filename = name.to_str().expect("Filename should convert to string");
|
||||||
let playlist = format!("{}/{}.m3u8", app_state.video_path, filename);
|
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 {
|
if let Ok(child) = create_playlist(path.to_str().unwrap(), &playlist).await {
|
||||||
span.add_event(
|
span.add_event(
|
||||||
"playlist_created".to_string(),
|
"playlist_created".to_string(),
|
||||||
@@ -1161,7 +1190,16 @@ fn main() -> std::io::Result<()> {
|
|||||||
// table; we use that list to drive the initial thumbnail sweep.
|
// table; we use that list to drive the initial thumbnail sweep.
|
||||||
let app_data = Data::new(AppState::default());
|
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;
|
// generate_video_gifs().await;
|
||||||
|
|
||||||
let labels = HashMap::new();
|
let labels = HashMap::new();
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ impl Default for AppState {
|
|||||||
search_dao.clone(),
|
search_dao.clone(),
|
||||||
tag_dao.clone(),
|
tag_dao.clone(),
|
||||||
knowledge_dao,
|
knowledge_dao,
|
||||||
base_path.clone(),
|
libraries_vec.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Ensure preview clips directory exists
|
// Ensure preview clips directory exists
|
||||||
@@ -245,6 +245,11 @@ impl AppState {
|
|||||||
|
|
||||||
// Initialize test InsightGenerator with all data sources
|
// Initialize test InsightGenerator with all data sources
|
||||||
let base_path_str = base_path.to_string_lossy().to_string();
|
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(
|
let insight_generator = InsightGenerator::new(
|
||||||
ollama.clone(),
|
ollama.clone(),
|
||||||
sms_client.clone(),
|
sms_client.clone(),
|
||||||
@@ -256,7 +261,7 @@ impl AppState {
|
|||||||
search_dao.clone(),
|
search_dao.clone(),
|
||||||
tag_dao.clone(),
|
tag_dao.clone(),
|
||||||
knowledge_dao,
|
knowledge_dao,
|
||||||
base_path_str.clone(),
|
vec![test_lib],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Initialize test preview DAO
|
// Initialize test preview DAO
|
||||||
|
|||||||
Reference in New Issue
Block a user