feat(versions): link at ingest, hide in listings, version endpoints
XMP (PNG iTXt DerivedFrom, native chunk reader) then file-name convention links at ingest; unlinked name candidates are retried each tick. Versions are hidden from /photos (SQL for the recursive listing, hidden-row set for the walk and tag branches), /memories, reels, /photos/exif, the GPS summary, unified search and the media gauges while their original is reachable in the view; include_versions=true lists them. /photos gains the parallel arrays photo_display_paths / photo_version_counts, memories / search hits / /photos/exif rows gain display_path / version_count, all skipped when empty. GET /image/versions and PUT /image/versions/display (library optional). Tags and favorites on a version path bind to the original; faces, CLIP and duplicate grouping skip version hashes; insight generation redirects to the original. Orphan GC and the back-ref refresh cover the new tables. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
044240037a
commit
080a75c336
24 files changed
+1614
-47
No files matched your search
+102
-2
@@ -342,6 +342,9 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
|
||||
req.lens_model.as_deref(),
|
||||
gps_bounds,
|
||||
date_range,
|
||||
// Hiding happens in the listing below; this is only the
|
||||
// set the listing is intersected with.
|
||||
true,
|
||||
)
|
||||
.unwrap_or_else(|e| {
|
||||
warn!("EXIF query failed: {:?}", e);
|
||||
@@ -391,6 +394,7 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
|
||||
};
|
||||
|
||||
let search_recursively = req.recursive.unwrap_or(false);
|
||||
let include_versions = req.include_versions.unwrap_or(false);
|
||||
if let Some(tag_ids) = &req.tag_ids
|
||||
&& search_recursively
|
||||
{
|
||||
@@ -498,6 +502,21 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
|
||||
})
|
||||
.collect::<Vec<(FileWithTagCount, i32)>>()
|
||||
})
|
||||
.map(|paired| {
|
||||
// Versions stay hidden behind their original (tag hits can be
|
||||
// either file, since tags bind to the original's path).
|
||||
if include_versions {
|
||||
return paired;
|
||||
}
|
||||
let hidden = hidden_version_rows(&app_state, library.map(|l| l.id));
|
||||
if hidden.is_empty() {
|
||||
return paired;
|
||||
}
|
||||
paired
|
||||
.into_iter()
|
||||
.filter(|(f, lib_id)| !hidden.contains(&(*lib_id, f.file_name.clone())))
|
||||
.collect()
|
||||
})
|
||||
.map(|paired| {
|
||||
// Handle sorting - use helper function that supports EXIF date sorting and pagination
|
||||
let sort_type = req.sort.unwrap_or(NameAsc);
|
||||
@@ -545,10 +564,14 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
|
||||
span.set_attribute("total_count", total_count.to_string());
|
||||
span.set_status(Status::Ok);
|
||||
|
||||
let (photo_display_paths, photo_version_counts) =
|
||||
version_arrays(&app_state, &tagged_files, &photo_libraries);
|
||||
HttpResponse::Ok().json(PhotosResponse {
|
||||
photos: tagged_files,
|
||||
dirs: vec![],
|
||||
photo_libraries,
|
||||
photo_display_paths,
|
||||
photo_version_counts,
|
||||
total_count: pagination_metadata.0,
|
||||
has_more: pagination_metadata.1,
|
||||
next_offset: pagination_metadata.2,
|
||||
@@ -583,7 +606,7 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
|
||||
let include_duplicates = req.include_duplicates.unwrap_or(false);
|
||||
let rows = {
|
||||
let mut dao = exif_dao.lock().expect("Unable to get ExifDao");
|
||||
dao.list_rel_paths_for_libraries(&lib_ids, prefix, include_duplicates)
|
||||
dao.list_rel_paths_for_libraries(&lib_ids, prefix, include_duplicates, include_versions)
|
||||
.unwrap_or_else(|e| {
|
||||
warn!("list_rel_paths_for_libraries failed: {:?}", e);
|
||||
Vec::new()
|
||||
@@ -681,6 +704,21 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
|
||||
}
|
||||
}
|
||||
|
||||
// The filesystem walk knows nothing about versions: drop the ones hidden
|
||||
// behind their original in this view (the recursive branch did it in SQL).
|
||||
if !search_recursively && !include_versions {
|
||||
let hidden = hidden_version_rows(&app_state, library.map(|l| l.id));
|
||||
if !hidden.is_empty() {
|
||||
let (names, libs): (Vec<String>, Vec<i32>) = file_names
|
||||
.into_iter()
|
||||
.zip(file_libraries)
|
||||
.filter(|(name, lib_id)| !hidden.contains(&(*lib_id, name.clone())))
|
||||
.unzip();
|
||||
file_names = names;
|
||||
file_libraries = libs;
|
||||
}
|
||||
}
|
||||
|
||||
if !any_library_resolved {
|
||||
error!("Bad photos request: {}", req.path);
|
||||
span.set_status(Status::error("Invalid path"));
|
||||
@@ -871,16 +909,68 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
|
||||
span.set_attribute("total_count", total_count.to_string());
|
||||
span.set_status(Status::Ok);
|
||||
|
||||
let (photo_display_paths, photo_version_counts) =
|
||||
version_arrays(&app_state, &response_files, &response_libraries);
|
||||
HttpResponse::Ok().json(PhotosResponse {
|
||||
photos: response_files,
|
||||
dirs,
|
||||
photo_libraries: response_libraries,
|
||||
photo_display_paths,
|
||||
photo_version_counts,
|
||||
total_count: pagination_metadata.0,
|
||||
has_more: pagination_metadata.1,
|
||||
next_offset: pagination_metadata.2,
|
||||
})
|
||||
}
|
||||
|
||||
/// `(library_id, rel_path)` of the version rows hidden in the request's view
|
||||
/// (`library` = the resolved `?library=`; `None` = every library). A lookup
|
||||
/// failure hides nothing: better a duplicate tile than a missing photo.
|
||||
fn hidden_version_rows(
|
||||
app_state: &AppState,
|
||||
library: Option<i32>,
|
||||
) -> std::collections::HashSet<(i32, String)> {
|
||||
let view: Vec<i32> = library.into_iter().collect();
|
||||
app_state
|
||||
.version_dao
|
||||
.lock()
|
||||
.expect("Unable to lock VersionDao")
|
||||
.hidden_version_rows(&view)
|
||||
.unwrap_or_else(|e| {
|
||||
warn!("hidden_version_rows failed: {:?}", e);
|
||||
Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// The `/photos` parallel arrays for a page: `photo_display_paths` (the file
|
||||
/// to show when the global choice isn't the original) and
|
||||
/// `photo_version_counts`. Each is empty, so skipped on the wire, when no
|
||||
/// photo on the page has anything to report.
|
||||
fn version_arrays(
|
||||
app_state: &AppState,
|
||||
photos: &[String],
|
||||
libraries: &[i32],
|
||||
) -> (Vec<Option<String>>, Vec<u32>) {
|
||||
let summaries = crate::versions::load_summaries(&app_state.version_dao);
|
||||
if summaries.is_empty() {
|
||||
return (Vec::new(), Vec::new());
|
||||
}
|
||||
let mut display: Vec<Option<String>> = Vec::with_capacity(photos.len());
|
||||
let mut counts: Vec<u32> = Vec::with_capacity(photos.len());
|
||||
for (path, lib) in photos.iter().zip(libraries) {
|
||||
let s = summaries.get(&(*lib, path.clone()));
|
||||
display.push(s.and_then(|s| s.display.as_ref().map(|(_, p)| p.clone())));
|
||||
counts.push(s.map_or(0, |s| s.version_count));
|
||||
}
|
||||
if display.iter().all(Option::is_none) {
|
||||
display.clear();
|
||||
}
|
||||
if counts.iter().all(|c| *c == 0) {
|
||||
counts.clear();
|
||||
}
|
||||
(display, counts)
|
||||
}
|
||||
|
||||
fn sort(
|
||||
files: Vec<FileWithTagCount>,
|
||||
file_libraries: Vec<i32>,
|
||||
@@ -1253,13 +1343,20 @@ pub async fn list_exif_summary(
|
||||
None,
|
||||
None,
|
||||
crate::date_view::InstantRange::new(req.date_from, req.date_to, display_zone, server_zone),
|
||||
false,
|
||||
) {
|
||||
Ok(rows) => {
|
||||
let summaries = crate::versions::load_summaries(&app_state.version_dao);
|
||||
let photos: Vec<ExifSummary> = rows
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
let name = library_names.get(&r.library_id).cloned();
|
||||
ExifSummary::from_row(r, name, display_zone, server_zone)
|
||||
let versions = crate::versions::VersionFields::from_summary(
|
||||
summaries.get(&(r.library_id, r.file_path.clone())),
|
||||
);
|
||||
let mut summary = ExifSummary::from_row(r, name, display_zone, server_zone);
|
||||
summary.versions = versions;
|
||||
summary
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -1622,6 +1719,7 @@ mod tests {
|
||||
_: Option<&str>,
|
||||
_: Option<(f64, f64, f64, f64)>,
|
||||
_: Option<crate::date_view::InstantRange>,
|
||||
_include_versions: bool,
|
||||
) -> Result<Vec<crate::database::models::ImageExif>, DbError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
@@ -1748,6 +1846,7 @@ mod tests {
|
||||
_conv_hi: i64,
|
||||
_unconv_lo: i64,
|
||||
_unconv_hi: i64,
|
||||
_view_library_ids: &[i32],
|
||||
) -> Result<Vec<crate::database::models::ImageExif>, DbError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
@@ -1794,6 +1893,7 @@ mod tests {
|
||||
_library_ids: &[i32],
|
||||
_path_prefix: Option<&str>,
|
||||
_include_duplicates: bool,
|
||||
_include_versions: bool,
|
||||
) -> Result<Vec<(i32, String)>, DbError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user