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

+118 -14
View File
@@ -8,6 +8,7 @@ use tracing_opentelemetry::OpenTelemetrySpanExt;
use crate::database::models::{
Favorite, ImageExif, InsertFavorite, InsertImageExif, InsertUser, User,
};
use crate::database::version_dao::{NOT_A_VERSION_CLAUSE, visible_version_clause};
use crate::otel::trace_db_call;
/// Wire shape for a single member of a duplicate group, returned by
@@ -311,6 +312,11 @@ impl FavoriteDao for SqliteFavoriteDao {
use schema::favorites::dsl::*;
let mut connection = self.connection.lock().expect("Unable to get FavoriteDao");
// Favoriting a photo version favorites its original (#191).
let favorite_path = &version_dao::canonical_rel_path(connection.deref_mut(), favorite_path)
.ok()
.flatten()
.unwrap_or_else(|| favorite_path.to_string());
if favorites
.filter(userid.eq(user_id).and(rel_path.eq(&favorite_path)))
@@ -332,9 +338,14 @@ impl FavoriteDao for SqliteFavoriteDao {
fn remove_favorite(&mut self, user_id: i32, favorite_path: String) {
use schema::favorites::dsl::*;
let mut connection = self.connection.lock().unwrap();
let favorite_path = version_dao::canonical_rel_path(connection.deref_mut(), &favorite_path)
.ok()
.flatten()
.unwrap_or(favorite_path);
diesel::delete(favorites)
.filter(userid.eq(user_id).and(rel_path.eq(favorite_path)))
.execute(self.connection.lock().unwrap().deref_mut())
.execute(connection.deref_mut())
.unwrap();
}
@@ -403,6 +414,10 @@ pub trait ExifDao: Sync + Send {
/// or the permanent floor) through `date_view::row_true_instant`'s
/// per-source fallback. Every row returned satisfies
/// [`InstantRange::contains`](crate::date_view::InstantRange::contains).
///
/// `include_versions=false` drops version rows whose original is
/// reachable in the view (`library_id`, or every library when `None`);
/// see [`visible_version_clause`].
fn query_by_exif(
&mut self,
library_id: Option<i32>,
@@ -411,6 +426,7 @@ pub trait ExifDao: Sync + Send {
lens_model: Option<&str>,
gps_bounds: Option<(f64, f64, f64, f64)>, // (min_lat, max_lat, min_lon, max_lon)
date_range: Option<crate::date_view::InstantRange>,
include_versions: bool,
) -> Result<Vec<ImageExif>, DbError>;
/// Get distinct camera makes with counts
@@ -655,6 +671,10 @@ pub trait ExifDao: Sync + Send {
///
/// Returns full rows so the caller can read the date triple
/// (`date_utc` / `camera_offset_minutes` / `date_taken_tz_source`).
///
/// `view_library_ids` is the whole view the caller is assembling (it
/// queries one library at a time): a photo version is left out while its
/// original has a row in any of them.
fn get_memories_in_window(
&mut self,
library_id: i32,
@@ -662,6 +682,7 @@ pub trait ExifDao: Sync + Send {
conv_hi: i64,
unconv_lo: i64,
unconv_hi: i64,
view_library_ids: &[i32],
) -> Result<Vec<ImageExif>, DbError>;
/// Return image rows that have a `content_hash` but no `phash_64`,
@@ -811,11 +832,16 @@ pub trait ExifDao: Sync + Send {
/// `duplicate_of_hash IS NOT NULL` so the default photo listing hides
/// demoted siblings; the Apollo duplicates modal passes `true` to
/// see both survivors and demoted members inside a group.
///
/// `include_versions=false` likewise hides photo versions while their
/// original is reachable in the same view (an unscoped call counts every
/// library; a scoped one only `library_ids`).
fn list_rel_paths_for_libraries(
&mut self,
library_ids: &[i32],
path_prefix: Option<&str>,
include_duplicates: bool,
include_versions: bool,
) -> Result<Vec<(i32, String)>, DbError>;
/// Delete a single image_exif row scoped to `(library_id, rel_path)`.
@@ -899,6 +925,12 @@ fn classify_image_exif_error(e: &anyhow::Error) -> DbErrorKind {
}
}
#[derive(diesel::QueryableByName)]
struct OriginalHashRow {
#[diesel(sql_type = diesel::sql_types::Text)]
original_hash: String,
}
pub struct SqliteExifDao {
connection: Arc<Mutex<SqliteConnection>>,
}
@@ -1103,6 +1135,7 @@ impl ExifDao for SqliteExifDao {
lens_model_filter: Option<&str>,
gps_bounds: Option<(f64, f64, f64, f64)>,
date_range: Option<crate::date_view::InstantRange>,
include_versions: bool,
) -> Result<Vec<ImageExif>, DbError> {
trace_db_call("query", "query_by_exif", |_span| {
use schema::image_exif::dsl::*;
@@ -1110,6 +1143,13 @@ impl ExifDao for SqliteExifDao {
let mut connection = self.connection.lock().expect("Unable to get ExifDao");
let mut query = image_exif.into_boxed();
if !include_versions {
let view: Vec<i32> = library_id_filter.into_iter().collect();
query = query.filter(diesel::dsl::sql::<diesel::sql_types::Bool>(
&visible_version_clause(&view),
));
}
// Library scope (most-selective filter — apply first so the
// `(library_id, ...)` indexes are eligible).
if let Some(lib_id) = library_id_filter {
@@ -1177,6 +1217,9 @@ impl ExifDao for SqliteExifDao {
image_exif
.filter(camera_make.is_not_null())
.filter(diesel::dsl::sql::<diesel::sql_types::Bool>(
&visible_version_clause(&[]),
))
.group_by(camera_make)
.select((camera_make, count(id)))
.order(count(id).desc())
@@ -1202,6 +1245,9 @@ impl ExifDao for SqliteExifDao {
image_exif
.filter(camera_make.is_not_null())
.filter(camera_model.is_not_null())
.filter(diesel::dsl::sql::<diesel::sql_types::Bool>(
&visible_version_clause(&[]),
))
.group_by((camera_make, camera_model))
.select((camera_make, camera_model, count(id)))
.order(count(id).desc())
@@ -1262,6 +1308,9 @@ impl ExifDao for SqliteExifDao {
// Query all photos with non-null GPS coordinates
let mut query = image_exif
.filter(gps_latitude.is_not_null().and(gps_longitude.is_not_null()))
.filter(diesel::dsl::sql::<diesel::sql_types::Bool>(
&visible_version_clause(&[]),
))
.into_boxed();
// Apply path filtering
@@ -1572,6 +1621,10 @@ impl ExifDao for SqliteExifDao {
.filter(library_id.eq(library_id_val))
.filter(clip_embedding.is_null())
.filter(content_hash.is_not_null())
// Versions are never embedded: invented detail would pollute search.
.filter(diesel::dsl::sql::<diesel::sql_types::Bool>(
NOT_A_VERSION_CLAUSE,
))
.filter(diesel::dsl::not(diesel::dsl::exists(failed)))
.select((rel_path, content_hash.assume_not_null()))
.order(id.asc())
@@ -1685,6 +1738,9 @@ impl ExifDao for SqliteExifDao {
let mut query = image_exif
.filter(content_hash.is_not_null())
.filter(clip_embedding.is_not_null())
.filter(diesel::dsl::sql::<diesel::sql_types::Bool>(
NOT_A_VERSION_CLAUSE,
))
.into_boxed();
if !library_ids_val.is_empty() {
query = query.filter(library_id.eq_any(library_ids_val));
@@ -1879,6 +1935,7 @@ impl ExifDao for SqliteExifDao {
conv_hi: i64,
unconv_lo: i64,
unconv_hi: i64,
view_library_ids: &[i32],
) -> Result<Vec<ImageExif>, DbError> {
trace_db_call("query", "get_memories_in_window", |_span| {
use crate::database::schema::image_exif::dsl as d;
@@ -1887,6 +1944,9 @@ impl ExifDao for SqliteExifDao {
d::image_exif
.filter(d::library_id.eq(library_id))
.filter(diesel::dsl::sql::<diesel::sql_types::Bool>(
&visible_version_clause(view_library_ids),
))
.filter(d::date_taken.is_not_null())
.filter(d::date_taken.ge(unconv_lo))
.filter(d::date_taken.le(unconv_hi))
@@ -1938,13 +1998,27 @@ impl ExifDao for SqliteExifDao {
.map_err(|e| anyhow::anyhow!("Query error: {}", e))?
.flatten();
// A version shares its annotations (tags, favorites) with its
// original, so its hash set includes the original's.
let paths = match hash {
Some(h) => image_exif
.filter(content_hash.eq(h))
.select(rel_path)
.distinct()
.load::<String>(connection.deref_mut())
.map_err(|e| anyhow::anyhow!("Query error: {}", e))?,
Some(h) => {
let original: Option<String> = diesel::sql_query(
"SELECT original_hash FROM photo_versions WHERE version_hash = ?",
)
.bind::<diesel::sql_types::Text, _>(&h)
.get_result::<OriginalHashRow>(connection.deref_mut())
.optional()
.map_err(|e| anyhow::anyhow!("Query error: {}", e))?
.map(|r| r.original_hash);
let mut hashes = vec![h];
hashes.extend(original);
image_exif
.filter(content_hash.eq_any(hashes))
.select(rel_path)
.distinct()
.load::<String>(connection.deref_mut())
.map_err(|e| anyhow::anyhow!("Query error: {}", e))?
}
None => vec![rel_path_val.to_string()],
};
@@ -2043,6 +2117,7 @@ impl ExifDao for SqliteExifDao {
library_ids: &[i32],
path_prefix: Option<&str>,
include_duplicates: bool,
include_versions: bool,
) -> Result<Vec<(i32, String)>, DbError> {
trace_db_call("query", "list_rel_paths_for_libraries", |_span| {
use schema::image_exif::dsl::*;
@@ -2098,6 +2173,12 @@ impl ExifDao for SqliteExifDao {
}
}
if !include_versions {
query = query.filter(diesel::dsl::sql::<diesel::sql_types::Bool>(
&visible_version_clause(library_ids),
));
}
query
.load::<(i32, String)>(connection.deref_mut())
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
@@ -2263,6 +2344,9 @@ impl ExifDao for SqliteExifDao {
use schema::image_exif::dsl::*;
let mut q = image_exif
.filter(content_hash.eq_any(&dup_hashes))
.filter(diesel::dsl::sql::<diesel::sql_types::Bool>(
NOT_A_VERSION_CLAUSE,
))
.select((
library_id,
rel_path,
@@ -2343,6 +2427,9 @@ impl ExifDao for SqliteExifDao {
let mut q = image_exif
.filter(content_hash.is_not_null())
.filter(phash_64.is_not_null())
.filter(diesel::dsl::sql::<diesel::sql_types::Bool>(
NOT_A_VERSION_CLAUSE,
))
.select((
library_id,
rel_path,
@@ -2421,6 +2508,9 @@ impl ExifDao for SqliteExifDao {
let mut q = image_exif
.filter(content_hash.is_not_null())
.filter(diesel::dsl::sql::<diesel::sql_types::Bool>(
NOT_A_VERSION_CLAUSE,
))
.select((library_id, rel_path))
.into_boxed();
if let Some(lib) = library_id_filter {
@@ -2749,13 +2839,13 @@ mod exif_dao_tests {
// Union: both rows.
let all = dao
.query_by_exif(None, None, None, None, None, None)
.query_by_exif(None, None, None, None, None, None, false)
.unwrap();
assert_eq!(all.len(), 2);
// Scoped to lib 2: only archive row.
let lib2 = dao
.query_by_exif(Some(2), None, None, None, None, None)
.query_by_exif(Some(2), None, None, None, None, None, false)
.unwrap();
assert_eq!(lib2.len(), 1);
assert_eq!(lib2[0].file_path, "archive/a.jpg");
@@ -2908,6 +2998,7 @@ mod exif_dao_tests {
None,
None,
jul5_ny(Some(JUL5_NY_START), Some(JUL5_NY_END)),
false,
)
.unwrap();
assert_eq!(paths(rows), vec!["redated.jpg"]);
@@ -2949,6 +3040,7 @@ mod exif_dao_tests {
None,
None,
jul5_ny(Some(JUL5_NY_START), Some(JUL5_NY_END)),
false,
)
.unwrap();
assert_eq!(paths(rows), vec!["jul5_early.jpg"]);
@@ -2979,6 +3071,7 @@ mod exif_dao_tests {
None,
None,
jul5_ny(Some(JUL5_NY_START), Some(JUL5_NY_END)),
false,
)
.unwrap();
assert_eq!(paths(in_day), vec!["tokyo.jpg"]);
@@ -2992,6 +3085,7 @@ mod exif_dao_tests {
None,
None,
jul5_ny(Some(1_720_195_200), Some(JUL5_NY_END)),
false,
)
.unwrap();
assert!(afternoon.is_empty());
@@ -3021,6 +3115,7 @@ mod exif_dao_tests {
None,
None,
jul5_ny(Some(JUL5_NY_START), None),
false,
)
.unwrap();
assert_eq!(paths(from_only), vec!["a.jpg"]);
@@ -3032,12 +3127,13 @@ mod exif_dao_tests {
None,
None,
jul5_ny(None, Some(JUL5_NY_START)),
false,
)
.unwrap();
assert!(to_only.is_empty());
assert!(jul5_ny(None, None).is_none());
let all = dao
.query_by_exif(None, None, None, None, None, None)
.query_by_exif(None, None, None, None, None, None, false)
.unwrap();
assert_eq!(paths(all), vec!["a.jpg", "undated.jpg"]);
}
@@ -3221,7 +3317,9 @@ mod exif_dao_tests {
insert_row_with_source(&mut dao, 1, "nodate.jpg", None, None);
let (clo, chi, ulo, uhi) = wide_windows(now_ts, 20);
let rows = dao.get_memories_in_window(1, clo, chi, ulo, uhi).unwrap();
let rows = dao
.get_memories_in_window(1, clo, chi, ulo, uhi, &[])
.unwrap();
let paths: std::collections::HashSet<String> =
rows.into_iter().map(|r| r.file_path).collect();
assert!(
@@ -3254,8 +3352,12 @@ mod exif_dao_tests {
Some("exif"),
);
let (clo, chi, ulo, uhi) = wide_windows(now_ts, 20);
let lib1 = dao.get_memories_in_window(1, clo, chi, ulo, uhi).unwrap();
let lib2 = dao.get_memories_in_window(2, clo, chi, ulo, uhi).unwrap();
let lib1 = dao
.get_memories_in_window(1, clo, chi, ulo, uhi, &[])
.unwrap();
let lib2 = dao
.get_memories_in_window(2, clo, chi, ulo, uhi, &[])
.unwrap();
assert_eq!(lib1.len(), 1);
assert_eq!(lib1[0].file_path, "main/x.jpg");
assert_eq!(lib2.len(), 1);
@@ -3320,7 +3422,9 @@ mod exif_dao_tests {
let chi = now_ts - year;
let ulo = now_ts - year - 86_400;
let uhi = now_ts;
let rows = dao.get_memories_in_window(1, clo, chi, ulo, uhi).unwrap();
let rows = dao
.get_memories_in_window(1, clo, chi, ulo, uhi, &[])
.unwrap();
let paths: std::collections::HashSet<String> =
rows.into_iter().map(|r| r.file_path).collect();
assert!(