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