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

+18 -1
View File
@@ -459,12 +459,21 @@ pub async fn unified_search<TagD: TagDao>(
display_zone,
server_zone,
),
// Versions are dropped below, against the request's own
// library scope (this query is unscoped).
true,
)
.unwrap_or_else(|e| {
log::warn!("unified_search: query_by_exif failed: {e:?}");
Vec::new()
})
};
let hidden_versions = state
.version_dao
.lock()
.expect("version dao")
.hidden_version_rows(&library_ids)
.unwrap_or_default();
candidate = rows
.into_iter()
@@ -473,6 +482,10 @@ pub async fn unified_search<TagD: TagDao>(
if !library_ids.is_empty() && !library_ids.contains(&row.library_id) {
return false;
}
// A version stays behind its original.
if hidden_versions.contains(&(row.library_id, row.file_path.clone())) {
return false;
}
// Precise GPS distance (the EXIF query only did a coarse box).
if let Some((lat, lon, radius_km)) = gps {
match (row.gps_latitude, row.gps_longitude) {
@@ -569,7 +582,7 @@ pub async fn unified_search<TagD: TagDao>(
);
let total_matching = hits.len();
let page = paginate(&hits, offset, limit);
let results = resolve_hits(&exif_dao, &page);
let results = resolve_hits(&exif_dao, &state.version_dao, &page);
HttpResponse::Ok().json(UnifiedResponse {
query: nl,
interpreted: interpreted(&echo, resolved_place),
@@ -593,6 +606,7 @@ pub async fn unified_search<TagD: TagDao>(
let results: Vec<SearchHit> = if offset >= total_matching {
Vec::new()
} else {
let summaries = crate::versions::load_summaries(&state.version_dao);
candidate[offset..end]
.iter()
.map(|r| SearchHit {
@@ -600,6 +614,9 @@ pub async fn unified_search<TagD: TagDao>(
rel_path: r.file_path.clone(),
content_hash: r.content_hash.clone().unwrap_or_default(),
score: 0.0,
versions: crate::versions::VersionFields::from_summary(
summaries.get(&(r.library_id, r.file_path.clone())),
),
})
.collect()
};