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
parent d79fe1ae35
commit 61f98066f6
6 changed files with 376 additions and 8 deletions

View File

@@ -335,6 +335,13 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
None
};
// When a specific library is selected, we'll gate tag-based results
// (which key on rel_path only, library-agnostic) by "does this
// rel_path actually exist on disk in the selected library's root".
// We check per-file below rather than pre-enumerating image_exif,
// since image_exif may lag a just-added library.
let library_for_scope: Option<&crate::libraries::Library> = library;
let search_recursively = req.recursive.unwrap_or(false);
if let Some(tag_ids) = &req.tag_ids
&& search_recursively
@@ -400,6 +407,34 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
true
}
})
.filter(|f| {
// Scope to the selected library by checking the file
// actually exists under its root. Falls back to the
// content-hash sibling set (looked up globally, since
// the tagged rel_path may have been registered under
// a different library than the one selected).
let Some(lib) = library_for_scope else {
return true;
};
if PathBuf::from(&lib.root_path).join(&f.file_name).exists() {
return true;
}
let siblings = {
let mut dao = exif_dao.lock().expect("Unable to get ExifDao");
match dao
.find_content_hash_anywhere(&span_context, &f.file_name)
.unwrap_or(None)
{
Some(hash) => dao
.get_rel_paths_by_hash(&span_context, &hash)
.unwrap_or_default(),
None => Vec::new(),
}
};
siblings
.iter()
.any(|p| PathBuf::from(&lib.root_path).join(p).exists())
})
.filter(|f| {
// Apply media type filtering if specified
if let Some(ref media_type) = req.media_type {
@@ -1403,6 +1438,39 @@ mod tests {
) -> Result<Option<crate::database::models::ImageExif>, DbError> {
Ok(None)
}
fn get_rel_paths_sharing_content(
&mut self,
_context: &opentelemetry::Context,
_library_id: i32,
rel_path: &str,
) -> Result<Vec<String>, DbError> {
Ok(vec![rel_path.to_string()])
}
fn get_rel_paths_for_library(
&mut self,
_context: &opentelemetry::Context,
_library_id: i32,
) -> Result<Vec<String>, DbError> {
Ok(vec![])
}
fn find_content_hash_anywhere(
&mut self,
_context: &opentelemetry::Context,
_rel_path: &str,
) -> Result<Option<String>, DbError> {
Ok(None)
}
fn get_rel_paths_by_hash(
&mut self,
_context: &opentelemetry::Context,
_hash: &str,
) -> Result<Vec<String>, DbError> {
Ok(vec![])
}
}
mod api {