feat: content-hash-aware tag/insight sharing + library scoping

Tags and insights now follow content across libraries via content_hash
lookups on the read path, so the same file indexed at different rel_paths
in multiple libraries shares its annotations. Recursive tag search scopes
hits to the selected library by checking each tagged rel_path against
the library's disk (with a content-hash sibling fallback so tags attached
under one library's rel_path still match a content-equivalent file in
another). The /image and /image/metadata handlers fall back across
libraries when the file isn't under the resolved one, so union-mode
search results (which carry no library attribution in the response)
still serve correctly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Cameron
2026-04-17 18:06:02 -04:00
committed by cameron
parent c01a0479b7
commit 2d942a9926
6 changed files with 376 additions and 8 deletions

View File

@@ -118,7 +118,25 @@ async fn get_image(
}
};
if let Some(path) = is_valid_full_path(&library.root_path, &req.path, false) {
// Union-mode search returns flat rel_paths with no library attribution,
// so clients may request a file under the wrong library. Try the
// resolved library first; if the file isn't there, fall back to any
// other library holding that rel_path on disk.
let resolved = is_valid_full_path(&library.root_path, &req.path, false)
.filter(|p| p.exists())
.map(|p| (library, p))
.or_else(|| {
app_state.libraries.iter().find_map(|lib| {
if lib.id == library.id {
return None;
}
is_valid_full_path(&lib.root_path, &req.path, false)
.filter(|p| p.exists())
.map(|p| (lib, p))
})
});
if let Some((library, path)) = resolved {
let image_size = req.size.unwrap_or(PhotoSize::Full);
if image_size == PhotoSize::Thumb {
let relative_path = path
@@ -207,9 +225,9 @@ async fn get_image(
span.set_status(Status::error("Not found"));
HttpResponse::NotFound().finish()
} else {
span.set_status(Status::error("Bad photos request"));
error!("Bad photos request: {}", req.path);
HttpResponse::BadRequest().finish()
span.set_status(Status::error("Not found"));
error!("Path does not exist in any library: {}", req.path);
HttpResponse::NotFound().finish()
}
}
@@ -294,7 +312,23 @@ async fn get_file_metadata(
let span_context =
opentelemetry::Context::new().with_remote_span_context(span.span_context().clone());
let full_path = is_valid_full_path(&app_state.base_path, &path.path, false);
let library = libraries::resolve_library_param(&app_state, path.library.as_deref())
.ok()
.flatten()
.unwrap_or_else(|| app_state.primary_library());
// Fall back to other libraries if the file isn't under the resolved one,
// matching the `/image` handler so union-mode search results resolve.
let full_path = is_valid_full_path(&library.root_path, &path.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, &path.path, false).filter(|p| p.exists())
})
});
match full_path
.ok_or_else(|| ErrorKind::InvalidData.into())