multi-library: availability probe + scoped EXIF queries + collision fixes

Branch A of the multi-library data-model rollout. Three threads of
correctness/safety work that ship together because the new mount
needs all three before it can land:

1. Library availability probe (libraries.rs, state.rs, main.rs)

   New LibraryHealth (Online | Stale { reason, since }) and a shared
   LibraryHealthMap on AppState. Probe checks root_path exists +
   is_dir + readable + non-empty (relative to a "had_data" signal so
   fresh mounts aren't downgraded). The watcher tick begins with a
   refresh_health() per library; stale libraries skip ingest, the
   hash backfill, and face-detection backlog drains for that tick.
   The orphaned-playlist cleanup also gates on every library being
   online — a missing source on a stale library is indistinguishable
   from a transient unmount, and the cleanup is destructive.

   /libraries now returns each library with its current health
   state. Logs only on Online↔Stale transitions so a long outage
   doesn't spam.

   New ExifDao::count_for_library is the "had_data" signal.

2. EXIF queries scoped by library_id (database/mod.rs, files.rs,
   main.rs, tags.rs)

   query_by_exif gains an Option<i32> library filter; /photos and
   /photos/exif now pass it. Without this, an EXIF-filtered request
   scoped to ?library=N returned cross-library results because the
   handler resolved the library but didn't push it through to SQL.

   get_exif_batch gains the same option. The watcher's per-library
   ingest, face-candidate build, and content-hash backfill all
   scope to their library; the union-mode /photos date-sort path
   and the library-agnostic tag fan-out (lookup_tags_batch, by
   design) keep using None.

3. Derivative-path collision fixes (content_hash.rs, main.rs)

   New content_hash::library_scoped_legacy_path helper:
   <derivative_dir>/<library_id>/<rel_path>. Thumbnail generation
   (startup walk + watcher needs-thumb check) and serving now use
   it; serving falls back to the bare-legacy mirrored path so
   pre-multi-library deployments keep working without
   regeneration. Without this, lib2 with the same rel_path as lib1
   would have its thumbnail request short-circuit to lib1's image.

   Orphaned-playlist cleanup walks every library when checking for
   the source video (was: BASE_PATH only). Without this, mounting
   a 2nd library and waiting 24h would delete every playlist whose
   source lived only in the 2nd library.

   The HLS playlist write path collision (filename-only basename,
   not rel_path) is left as a known issue with a TODO at the call
   site — the actor-pipeline rewrite belongs in Branch B/C.

Tests: 212 pass (cargo test --lib). New tests cover the probe
states (online / missing root / non-dir / empty-with-prior-data),
refresh_health transitions, query_by_exif scoping, get_exif_batch
keying on (library_id, rel_path), library_scoped_legacy_path, and
count_for_library.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cameron Cordes
2026-05-01 14:12:49 +00:00
parent 2f91891459
commit eea1bf3181
7 changed files with 622 additions and 51 deletions

View File

@@ -110,11 +110,18 @@ fn in_memory_date_sort(
let total_count = files.len() as i64;
let file_paths: Vec<String> = files.iter().map(|f| f.file_name.clone()).collect();
// Batch fetch EXIF data (keyed by rel_path; in union mode a rel_path may
// correspond to rows in multiple libraries — pick the date from the one
// matching the requesting row's library_id when possible).
// Batch fetch EXIF data. When every file in this batch belongs to the
// same library, scope the SQL filter to that library so cross-library
// duplicates with the same rel_path don't get fetched and discarded.
// In genuine union mode (mixed libraries) keep the rel-path-only
// lookup; the caller's `(file_path, library_id)` map below picks the
// right row.
let scope_library = match file_libraries.first() {
Some(&first) if file_libraries.iter().all(|&id| id == first) => Some(first),
_ => None,
};
let exif_rows = exif_dao
.get_exif_batch(span_context, &file_paths)
.get_exif_batch(span_context, scope_library, &file_paths)
.unwrap_or_default();
let exif_map: std::collections::HashMap<(String, i32), i64> = exif_rows
.into_iter()
@@ -309,11 +316,15 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
None
};
// Query EXIF database
// Query EXIF database. When the request named a library, the EXIF
// filter must be scoped to it — otherwise camera/date/GPS hits
// from other libraries would pollute the result set even though
// downstream filesystem walks would never visit those files.
let mut exif_dao_guard = exif_dao.lock().expect("Unable to get ExifDao");
let exif_results = exif_dao_guard
.query_by_exif(
&span_context,
library.map(|l| l.id),
req.camera_make.as_deref(),
req.camera_model.as_deref(),
req.lens_model.as_deref(),
@@ -1242,15 +1253,19 @@ pub async fn list_exif_summary(
.collect();
let mut exif_dao_guard = exif_dao.lock().expect("Unable to get ExifDao");
match exif_dao_guard.query_by_exif(&cx, None, None, None, None, req.date_from, req.date_to) {
match exif_dao_guard.query_by_exif(
&cx,
library_filter,
None,
None,
None,
None,
req.date_from,
req.date_to,
) {
Ok(rows) => {
let photos: Vec<ExifSummary> = rows
.into_iter()
// Library filter post-query: keeps the DAO trait (and its
// mocks) unchanged. For typical 23 library setups the in-
// memory pass over a date-bounded result set is negligible;
// can be pushed into SQL later if it ever isn't.
.filter(|r| library_filter.is_none_or(|id| r.library_id == id))
.map(|r| ExifSummary {
library_name: library_names.get(&r.library_id).cloned(),
file_path: r.file_path,
@@ -1549,6 +1564,7 @@ mod tests {
fn get_exif_batch(
&mut self,
_context: &opentelemetry::Context,
_library_id: Option<i32>,
_: &[String],
) -> Result<Vec<crate::database::models::ImageExif>, DbError> {
Ok(Vec::new())
@@ -1557,6 +1573,7 @@ mod tests {
fn query_by_exif(
&mut self,
_context: &opentelemetry::Context,
_library_id: Option<i32>,
_: Option<&str>,
_: Option<&str>,
_: Option<&str>,
@@ -1684,6 +1701,14 @@ mod tests {
) -> Result<(), DbError> {
Ok(())
}
fn count_for_library(
&mut self,
_context: &opentelemetry::Context,
_library_id: i32,
) -> Result<i64, DbError> {
Ok(0)
}
}
mod api {