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:
@@ -295,17 +295,29 @@ pub trait ExifDao: Sync + Send {
|
||||
library_id: Option<i32>,
|
||||
) -> Result<Vec<(String, i64)>, DbError>;
|
||||
|
||||
/// Batch load EXIF data for multiple file paths (single query)
|
||||
/// Batch load EXIF data for multiple file paths (single query). When
|
||||
/// `library_id = Some(id)` the lookup is keyed on `(library_id,
|
||||
/// rel_path)`; cross-library duplicates with the same rel_path are
|
||||
/// excluded. `None` keeps the legacy rel-path-only behavior — used by
|
||||
/// the union-mode `/photos` listing, which already disambiguates by
|
||||
/// `(file_path, library_id)` in the caller.
|
||||
fn get_exif_batch(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
library_id: Option<i32>,
|
||||
file_paths: &[String],
|
||||
) -> Result<Vec<ImageExif>, DbError>;
|
||||
|
||||
/// Query files by EXIF criteria with optional filters
|
||||
/// Query files by EXIF criteria with optional filters. `library_id =
|
||||
/// Some(id)` restricts to that library; `None` spans every library
|
||||
/// (used by the unscoped `/photos` form). The composite
|
||||
/// `(library_id, date_taken)` index added in the multi_library
|
||||
/// migration depends on `library_id` being part of the WHERE clause —
|
||||
/// callers that have a library context must pass it.
|
||||
fn query_by_exif(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
library_id: Option<i32>,
|
||||
camera_make: Option<&str>,
|
||||
camera_model: Option<&str>,
|
||||
lens_model: Option<&str>,
|
||||
@@ -443,6 +455,16 @@ pub trait ExifDao: Sync + Send {
|
||||
library_id: i32,
|
||||
rel_path: &str,
|
||||
) -> Result<(), DbError>;
|
||||
|
||||
/// Number of image_exif rows for a library. Used by the availability
|
||||
/// probe to decide whether an empty mount is "fresh" (zero rows: fine)
|
||||
/// or "the share went offline" (non-zero rows: stale). Zero on query
|
||||
/// error so a transient DB hiccup doesn't itself cause a Stale flip.
|
||||
fn count_for_library(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
library_id: i32,
|
||||
) -> Result<i64, DbError>;
|
||||
}
|
||||
|
||||
pub struct SqliteExifDao {
|
||||
@@ -622,6 +644,7 @@ impl ExifDao for SqliteExifDao {
|
||||
fn get_exif_batch(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
library_id_filter: Option<i32>,
|
||||
file_paths: &[String],
|
||||
) -> Result<Vec<ImageExif>, DbError> {
|
||||
trace_db_call(context, "query", "get_exif_batch", |_span| {
|
||||
@@ -632,8 +655,11 @@ impl ExifDao for SqliteExifDao {
|
||||
}
|
||||
|
||||
let mut connection = self.connection.lock().expect("Unable to get ExifDao");
|
||||
|
||||
image_exif
|
||||
let mut query = image_exif.into_boxed();
|
||||
if let Some(lib_id) = library_id_filter {
|
||||
query = query.filter(library_id.eq(lib_id));
|
||||
}
|
||||
query
|
||||
.filter(rel_path.eq_any(file_paths))
|
||||
.load::<ImageExif>(connection.deref_mut())
|
||||
.map_err(|_| anyhow::anyhow!("Query error"))
|
||||
@@ -644,6 +670,7 @@ impl ExifDao for SqliteExifDao {
|
||||
fn query_by_exif(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
library_id_filter: Option<i32>,
|
||||
camera_make_filter: Option<&str>,
|
||||
camera_model_filter: Option<&str>,
|
||||
lens_model_filter: Option<&str>,
|
||||
@@ -657,6 +684,12 @@ impl ExifDao for SqliteExifDao {
|
||||
let mut connection = self.connection.lock().expect("Unable to get ExifDao");
|
||||
let mut query = image_exif.into_boxed();
|
||||
|
||||
// Library scope (most-selective filter — apply first so the
|
||||
// `(library_id, ...)` indexes are eligible).
|
||||
if let Some(lib_id) = library_id_filter {
|
||||
query = query.filter(library_id.eq(lib_id));
|
||||
}
|
||||
|
||||
// Camera filters (case-insensitive partial match)
|
||||
if let Some(make) = camera_make_filter {
|
||||
query = query.filter(camera_make.like(format!("%{}%", make)));
|
||||
@@ -1078,6 +1111,23 @@ impl ExifDao for SqliteExifDao {
|
||||
})
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn count_for_library(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
library_id_val: i32,
|
||||
) -> Result<i64, DbError> {
|
||||
trace_db_call(context, "query", "count_for_library", |_span| {
|
||||
use schema::image_exif::dsl::*;
|
||||
|
||||
image_exif
|
||||
.filter(library_id.eq(library_id_val))
|
||||
.count()
|
||||
.get_result::<i64>(self.connection.lock().unwrap().deref_mut())
|
||||
.map_err(|_| anyhow::anyhow!("Count error"))
|
||||
})
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1167,4 +1217,61 @@ mod exif_dao_tests {
|
||||
let lib1 = dao.get_all_with_date_taken(&ctx(), Some(1)).unwrap();
|
||||
assert_eq!(lib1, vec![("main/a.jpg".to_string(), 100)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_by_exif_scopes_by_library_id() {
|
||||
let mut dao = setup_two_libraries();
|
||||
insert_row(&mut dao, 1, "main/a.jpg", Some(100));
|
||||
insert_row(&mut dao, 2, "archive/a.jpg", Some(200));
|
||||
|
||||
// Union: both rows.
|
||||
let all = dao
|
||||
.query_by_exif(&ctx(), None, None, None, None, None, None, None)
|
||||
.unwrap();
|
||||
assert_eq!(all.len(), 2);
|
||||
|
||||
// Scoped to lib 2: only archive row.
|
||||
let lib2 = dao
|
||||
.query_by_exif(&ctx(), Some(2), None, None, None, None, None, None)
|
||||
.unwrap();
|
||||
assert_eq!(lib2.len(), 1);
|
||||
assert_eq!(lib2[0].file_path, "archive/a.jpg");
|
||||
assert_eq!(lib2[0].library_id, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_exif_batch_scopes_by_library_id() {
|
||||
let mut dao = setup_two_libraries();
|
||||
// Same rel_path, different libraries — the cross-library duplicate
|
||||
// case the audit flagged.
|
||||
insert_row(&mut dao, 1, "shared/photo.jpg", Some(100));
|
||||
insert_row(&mut dao, 2, "shared/photo.jpg", Some(200));
|
||||
|
||||
// None spans both libraries (legacy union behavior).
|
||||
let union = dao
|
||||
.get_exif_batch(&ctx(), None, &["shared/photo.jpg".to_string()])
|
||||
.unwrap();
|
||||
assert_eq!(union.len(), 2);
|
||||
|
||||
// Some(2) returns only the archive row.
|
||||
let scoped = dao
|
||||
.get_exif_batch(&ctx(), Some(2), &["shared/photo.jpg".to_string()])
|
||||
.unwrap();
|
||||
assert_eq!(scoped.len(), 1);
|
||||
assert_eq!(scoped[0].library_id, 2);
|
||||
assert_eq!(scoped[0].date_taken, Some(200));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn count_for_library_returns_per_library_count() {
|
||||
let mut dao = setup_two_libraries();
|
||||
insert_row(&mut dao, 1, "main/a.jpg", None);
|
||||
insert_row(&mut dao, 1, "main/b.jpg", None);
|
||||
insert_row(&mut dao, 2, "archive/a.jpg", None);
|
||||
|
||||
assert_eq!(dao.count_for_library(&ctx(), 1).unwrap(), 2);
|
||||
assert_eq!(dao.count_for_library(&ctx(), 2).unwrap(), 1);
|
||||
// Unknown library: zero, no error.
|
||||
assert_eq!(dao.count_for_library(&ctx(), 999).unwrap(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user