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:
47
src/files.rs
47
src/files.rs
@@ -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 2–3 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 {
|
||||
|
||||
Reference in New Issue
Block a user