feat: union /photos and /memories across libraries

When `library` is omitted, both endpoints now walk every configured
library root, interleave the results, and tag each row with its source
library via the parallel `photo_libraries` / per-row `library_id`
arrays. Previously the handlers fell back to the primary library,
silently hiding the rest.

Threads a parallel `file_libraries: Vec<i32>` through the sort/paginate
helpers so library attribution survives sorting and pagination.
Directory names are de-duplicated across libraries.

`get_all_with_date_taken` grows an optional library filter so memories
can scope its EXIF query per-library during the union walk.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cameron
2026-04-18 17:27:41 -04:00
committed by cameron
parent 586b735af5
commit 2c8de8dcc6
3 changed files with 490 additions and 421 deletions

View File

@@ -16,6 +16,7 @@ use walkdir::WalkDir;
use crate::data::Claims;
use crate::database::ExifDao;
use crate::files::is_image_or_video;
use crate::libraries::Library;
use crate::otel::{extract_context_from_request, global_tracer};
use crate::state::AppState;
@@ -378,7 +379,7 @@ fn collect_exif_memories(
) -> Vec<(MemoryItem, NaiveDate)> {
// Query database for all files with date_taken
let exif_records = match exif_dao.lock() {
Ok(mut dao) => match dao.get_all_with_date_taken(context) {
Ok(mut dao) => match dao.get_all_with_date_taken(context, Some(library_id)) {
Ok(records) => records,
Err(e) => {
warn!("Failed to query EXIF database: {:?}", e);
@@ -546,48 +547,50 @@ pub async fn list_memories(
return HttpResponse::BadRequest().body(msg);
}
};
// For Phase 3 the walker still operates against a single library's root.
// Multi-library union support for the filesystem walk comes in Phase 4.
let scoped_library = library.unwrap_or_else(|| app_state.primary_library());
let base = Path::new(&scoped_library.root_path);
// When `library` is `Some`, scope to that one library; otherwise union
// across every configured library and let the results interleave.
let libraries_to_scan: Vec<&Library> = match library {
Some(lib) => vec![lib],
None => app_state.libraries.iter().collect(),
};
// Build the path excluder from base and env-configured exclusions
let path_excluder = PathExcluder::new(base, &app_state.excluded_dirs);
let mut memories_with_dates: Vec<(MemoryItem, NaiveDate)> = Vec::new();
// Phase 1: Query EXIF database
let exif_memories = collect_exif_memories(
&exif_dao,
&span_context,
&scoped_library.root_path,
scoped_library.id,
now,
span_mode,
years_back,
&client_timezone,
&path_excluder,
);
for lib in &libraries_to_scan {
let base = Path::new(&lib.root_path);
let path_excluder = PathExcluder::new(base, &app_state.excluded_dirs);
// Build HashSet for deduplication
let exif_paths: HashSet<PathBuf> = exif_memories
.iter()
.map(|(item, _)| PathBuf::from(&scoped_library.root_path).join(&item.path))
.collect();
let exif_memories = collect_exif_memories(
&exif_dao,
&span_context,
&lib.root_path,
lib.id,
now,
span_mode,
years_back,
&client_timezone,
&path_excluder,
);
// Phase 2: File system scan (skip EXIF files)
let fs_memories = collect_filesystem_memories(
&scoped_library.root_path,
scoped_library.id,
&path_excluder,
&exif_paths,
now,
span_mode,
years_back,
&client_timezone,
);
let exif_paths: HashSet<PathBuf> = exif_memories
.iter()
.map(|(item, _)| PathBuf::from(&lib.root_path).join(&item.path))
.collect();
// Phase 3: Merge and sort
let mut memories_with_dates = exif_memories;
memories_with_dates.extend(fs_memories);
let fs_memories = collect_filesystem_memories(
&lib.root_path,
lib.id,
&path_excluder,
&exif_paths,
now,
span_mode,
years_back,
&client_timezone,
);
memories_with_dates.extend(exif_memories);
memories_with_dates.extend(fs_memories);
}
match span_mode {
// Sort by absolute time for a more 'overview'