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
+118
-14
@@ -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!(
|
||||
|
||||
Reference in new issue
Block a user