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:
Cameron CordesandClaude Opus 5.5 committed 2026-10-30 11:28:34 -04:00
1 parent 044240037a
commit 080a75c336
24 files changed
+1614 -47

No files matched your search

+102 -2
View File
@@ -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![])
}