From 044240037ac5a6765b353ed981d711bfc8364561 Mon Sep 17 00:00:00 2001 From: Cameron Cordes Date: Fri, 30 Oct 2026 11:16:32 -0400 Subject: [PATCH 1/6] feat(versions): schema and VersionDao for hash-keyed photo versions photo_versions links a version's content_hash to its primary original's; photo_version_references keeps provenance-only reference photos; photo_version_display holds the global display choice. VersionDao covers linking (chains flatten to the root), the display choice, per-original summaries for /photos, the hidden-row set for non-SQL listings, and the copy of the original's date/GPS/camera onto version rows. Also the SQL predicate listings use to hide a version while its original is reachable. Co-Authored-By: Claude Opus 5.5 --- .../down.sql | 3 + .../up.sql | 41 + src/database/mod.rs | 2 + src/database/schema.rs | 30 + src/database/version_dao.rs | 1079 +++++++++++++++++ 5 files changed, 1155 insertions(+) create mode 100644 migrations/2026-10-30-000000_add_photo_versions/down.sql create mode 100644 migrations/2026-10-30-000000_add_photo_versions/up.sql create mode 100644 src/database/version_dao.rs diff --git a/migrations/2026-10-30-000000_add_photo_versions/down.sql b/migrations/2026-10-30-000000_add_photo_versions/down.sql new file mode 100644 index 0000000..b09661a --- /dev/null +++ b/migrations/2026-10-30-000000_add_photo_versions/down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS photo_version_display; +DROP TABLE IF EXISTS photo_version_references; +DROP TABLE IF EXISTS photo_versions; diff --git a/migrations/2026-10-30-000000_add_photo_versions/up.sql b/migrations/2026-10-30-000000_add_photo_versions/up.sql new file mode 100644 index 0000000..9bfcf67 --- /dev/null +++ b/migrations/2026-10-30-000000_add_photo_versions/up.sql @@ -0,0 +1,41 @@ +-- Photo versions (#191): an AI restoration, upscale or edit that sits on +-- disk near its original and is shown as the *same photo*. +-- +-- The link is hash-keyed like every other derived table (CLAUDE.md → +-- "Multi-library data model"): a version records the blake3 +-- `content_hash` of its primary original, so the link survives moves, +-- renames and an archive handoff. File name and location are hints only. +-- The version keeps its own `image_exif` row, so it still downloads and +-- thumbnails on its own; listings hide it while its original is reachable +-- in the current view (same rule as `duplicate_of_hash`). + +-- A file is a version of at most one original. +CREATE TABLE photo_versions ( + version_hash TEXT PRIMARY KEY NOT NULL, + original_hash TEXT NOT NULL, + kind TEXT NOT NULL, -- restored | upscale | edit + source TEXT NOT NULL, -- xmp | name | backfill | api + created_at BIGINT NOT NULL, + -- Denormalized back-ref to a live image_exif row for the version, kept + -- current by the back-ref refresh pass (same pattern as face_detections). + library_id INTEGER NOT NULL, + rel_path TEXT NOT NULL +); +CREATE INDEX idx_photo_versions_original ON photo_versions(original_hash); + +-- Reference photos (extra context fed to the model). Provenance only: a +-- reference is never hidden or grouped because of the version. +CREATE TABLE photo_version_references ( + version_hash TEXT NOT NULL, + reference_hash TEXT NOT NULL, + PRIMARY KEY (version_hash, reference_hash) +); +CREATE INDEX idx_photo_version_references_ref ON photo_version_references(reference_hash); + +-- The global (not per-user) choice of which file represents a photo. +-- No row = show the original. +CREATE TABLE photo_version_display ( + original_hash TEXT PRIMARY KEY NOT NULL, + display_hash TEXT NOT NULL, + decided_at BIGINT NOT NULL +); diff --git a/src/database/mod.rs b/src/database/mod.rs index afa62f0..f08e893 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -54,6 +54,7 @@ pub mod reconcile; pub mod schema; pub mod search_dao; pub mod user_ai_prefs_dao; +pub mod version_dao; pub use calendar_dao::{CalendarEventDao, SqliteCalendarEventDao}; pub use daily_summary_dao::{DailySummaryDao, InsertDailySummary, SqliteDailySummaryDao}; @@ -71,6 +72,7 @@ pub use precomputed_reel_dao::{PrecomputedReelDao, SqlitePrecomputedReelDao}; pub use preview_dao::{PreviewDao, SqlitePreviewDao}; pub use search_dao::{SearchHistoryDao, SqliteSearchHistoryDao}; pub use user_ai_prefs_dao::{SqliteUserAiPrefsDao, UserAiPrefsDao}; +pub use version_dao::{SqliteVersionDao, VersionDao}; pub trait UserDao { fn create_user(&mut self, user: &str, password: &str) -> Option; diff --git a/src/database/schema.rs b/src/database/schema.rs index e2d4f7e..ed2cb02 100644 --- a/src/database/schema.rs +++ b/src/database/schema.rs @@ -345,6 +345,33 @@ diesel::table! { } } +diesel::table! { + photo_version_display (original_hash) { + original_hash -> Text, + display_hash -> Text, + decided_at -> BigInt, + } +} + +diesel::table! { + photo_version_references (version_hash, reference_hash) { + version_hash -> Text, + reference_hash -> Text, + } +} + +diesel::table! { + photo_versions (version_hash) { + version_hash -> Text, + original_hash -> Text, + kind -> Text, + source -> Text, + created_at -> BigInt, + library_id -> Integer, + rel_path -> Text, + } +} + diesel::table! { precomputed_reels (id) { id -> Integer, @@ -392,6 +419,9 @@ diesel::allow_tables_to_appear_in_same_query!( personas, persons, photo_insights, + photo_version_display, + photo_version_references, + photo_versions, precomputed_reels, search_history, tagged_photo, diff --git a/src/database/version_dao.rs b/src/database/version_dao.rs new file mode 100644 index 0000000..56f65c7 --- /dev/null +++ b/src/database/version_dao.rs @@ -0,0 +1,1079 @@ +//! Photo versions (#191): hash-keyed links from an AI restoration / upscale +//! / edit to its primary original, the global display choice, and the SQL +//! the listings use to hide a version while its original is reachable. +//! +//! The link is the blake3 `content_hash` of the original (CLAUDE.md → +//! "Multi-library data model"). A version keeps its own `image_exif` row, +//! so `/image?path=` and thumbnails keep working; only listings +//! hide it. `photo_versions` is tiny next to `image_exif`, so reads that +//! need "which files are versions" load that set instead of joining per row. + +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, Mutex}; + +use diesel::prelude::*; +use diesel::sql_query; +use diesel::sql_types::{BigInt, Integer, Nullable, Text}; +use diesel::sqlite::SqliteConnection; + +use crate::database::{DbError, DbErrorKind, connect}; +use crate::otel::trace_db_call; + +/// The `kind` values a version may carry. +pub const KINDS: &[&str] = &["restored", "upscale", "edit"]; + +/// `date_taken_source` on a version row whose date, GPS and camera fields +/// were copied from its original. +pub const DATE_SOURCE_VERSION_OF: &str = "version_of"; + +/// `photo_versions.source`: how the link was learned. +pub const SOURCE_XMP: &str = "xmp"; +pub const SOURCE_NAME: &str = "name"; +pub const SOURCE_BACKFILL: &str = "backfill"; + +pub fn is_known_kind(kind: &str) -> bool { + KINDS.contains(&kind) +} + +/// SQL predicate over an unaliased `image_exif` row: true unless the row is +/// a version that is hidden in the view (`view_library_ids` empty = every +/// library). +/// +/// Same reachability rule as `duplicate_of_hash` in +/// `list_rel_paths_for_libraries`: a version hides only while a row for its +/// original exists in the view, so a version whose original was deleted, or +/// lives in a library outside the view, stays visible as a normal photo. +/// (Unlike duplicates the unscoped view still needs the existence check: +/// the original may be gone.) Library ids are `i32`, so inlining the list +/// is safe. +pub fn visible_version_clause(view_library_ids: &[i32]) -> String { + let scope = if view_library_ids.is_empty() { + String::new() + } else { + let list = view_library_ids + .iter() + .map(i32::to_string) + .collect::>() + .join(","); + format!(" AND orig.library_id IN ({list})") + }; + format!( + "NOT EXISTS (SELECT 1 FROM photo_versions pv \ + JOIN image_exif orig ON orig.content_hash = pv.original_hash \ + WHERE pv.version_hash = image_exif.content_hash{scope})" + ) +} + +/// SQL predicate over an unaliased `image_exif` row: true unless the row's +/// hash is a version at all. For derived work (faces, CLIP, duplicates) +/// that never runs on a version, whatever the view. +pub const NOT_A_VERSION_CLAUSE: &str = "NOT EXISTS (SELECT 1 FROM photo_versions pv \ + WHERE pv.version_hash = image_exif.content_hash)"; + +/// Same as [`NOT_A_VERSION_CLAUSE`] for a raw-SQL query that aliases +/// `image_exif` as `alias`. +pub fn not_a_version_clause_for(alias: &str) -> String { + format!( + "NOT EXISTS (SELECT 1 FROM photo_versions pv WHERE pv.version_hash = {alias}.content_hash)" + ) +} + +#[derive(Debug, Clone, PartialEq, Eq, QueryableByName, serde::Serialize)] +pub struct PhotoVersionRow { + #[diesel(sql_type = Text)] + pub version_hash: String, + #[diesel(sql_type = Text)] + pub original_hash: String, + #[diesel(sql_type = Text)] + pub kind: String, + #[diesel(sql_type = Text)] + pub source: String, + #[diesel(sql_type = BigInt)] + pub created_at: i64, + #[diesel(sql_type = Integer)] + pub library_id: i32, + #[diesel(sql_type = Text)] + pub rel_path: String, +} + +/// A link to record. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NewVersionLink { + pub version_hash: String, + pub original_hash: String, + pub kind: String, + pub source: String, + /// Back-ref to a live `image_exif` row of the version. + pub library_id: i32, + pub rel_path: String, + /// Hashes of reference photos (provenance only). + pub references: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LinkOutcome { + /// A new link was recorded. + Linked, + /// The version already had a link; it was left alone. + AlreadyLinked, + /// The link makes no sense (self-link, unknown kind, cycle). + Rejected(&'static str), +} + +/// A live version file of an original, for `GET /image/versions`. +#[derive(Debug, Clone, QueryableByName)] +pub struct VersionFile { + #[diesel(sql_type = Text)] + pub version_hash: String, + #[diesel(sql_type = Text)] + pub kind: String, + #[diesel(sql_type = Text)] + pub source: String, + #[diesel(sql_type = BigInt)] + pub created_at: i64, + #[diesel(sql_type = Integer)] + pub library_id: i32, + #[diesel(sql_type = Text)] + pub rel_path: String, + #[diesel(sql_type = Nullable)] + pub width: Option, + #[diesel(sql_type = Nullable)] + pub height: Option, +} + +/// One file (some `image_exif` row) for a hash. +#[derive(Debug, Clone, PartialEq, Eq, QueryableByName)] +pub struct HashedFile { + #[diesel(sql_type = Text)] + pub content_hash: String, + #[diesel(sql_type = Integer)] + pub library_id: i32, + #[diesel(sql_type = Text)] + pub rel_path: String, +} + +/// What `/photos` adds next to an original: how many live versions it has +/// and, if the global choice isn't the original, the file to display. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct PhotoVersionSummary { + pub version_count: u32, + /// `(library_id, rel_path)` of the chosen version file. + pub display: Option<(i32, String)>, +} + +pub trait VersionDao: Sync + Send { + /// Record a link (and its reference photos). Flattens chains: an + /// `original_hash` that is itself a version is replaced by its root, and + /// versions already pointing at the newly linked file move up to the root. + fn link_version(&mut self, link: &NewVersionLink) -> Result; + + /// The link row for a version hash, if any. + fn get_link(&mut self, version_hash: &str) -> Result, DbError>; + + /// Which of `hashes` are versions of something. + fn version_hashes_among(&mut self, hashes: &[String]) -> Result, DbError>; + + /// Live versions of an original (those with an `image_exif` row), + /// oldest first. + fn list_versions(&mut self, original_hash: &str) -> Result, DbError>; + + /// Reference photo hashes recorded for a version. + fn list_references(&mut self, version_hash: &str) -> Result, DbError>; + + /// First live `image_exif` row for each hash, preferring `prefer_library`. + fn files_for_hashes( + &mut self, + hashes: &[String], + prefer_library: Option, + ) -> Result, DbError>; + + /// The stored display choice for an original, if any. + fn get_display(&mut self, original_hash: &str) -> Result, DbError>; + + /// Set (`Some`) or clear (`None`) the global display choice. + fn set_display(&mut self, original_hash: &str, display_hash: Option<&str>) + -> Result<(), DbError>; + + /// Per original `(library_id, rel_path)` that has at least one live + /// version: the version count and display file. Empty when no photo has + /// versions, so `/photos` stays byte-identical. + fn photo_summaries(&mut self) -> Result, DbError>; + + /// `(library_id, rel_path)` of every version row hidden in the view + /// (`view_library_ids` empty = all libraries). For listings that don't + /// go through SQL (filesystem walk, tag search, media gauges). + fn hidden_version_rows( + &mut self, + view_library_ids: &[i32], + ) -> Result, DbError>; + + /// Rows in a library whose file name follows the version convention + /// (`.[.].`), have a hash and aren't linked yet. + /// Returns `(rel_path, content_hash)`, oldest first. + fn unlinked_name_candidates( + &mut self, + library_id: i32, + limit: i64, + ) -> Result, DbError>; + + /// Copy the original's date, GPS and camera fields onto every version + /// row whose copy is stale. Returns the number of rows updated. + /// Idempotent; only converted originals (`date_utc` set) are copied, so + /// the legacy-convention date drains never fight the copy. + fn sync_version_metadata(&mut self) -> Result; +} + +pub struct SqliteVersionDao { + connection: Arc>, +} + +impl Default for SqliteVersionDao { + fn default() -> Self { + Self::new() + } +} + +impl SqliteVersionDao { + pub fn new() -> Self { + SqliteVersionDao { + connection: Arc::new(Mutex::new(connect())), + } + } + + /// Test-only constructor sharing an already-wrapped connection, so + /// other DAOs see the rows this one writes. + #[cfg(test)] + pub fn from_shared(connection: Arc>) -> Self { + SqliteVersionDao { connection } + } +} + +/// Chunk size for `IN (...)` lists, safely under SQLite's variable limit. +const CHUNK: usize = 500; + +fn placeholders(n: usize) -> String { + vec!["?"; n].join(",") +} + +/// If `rel_path` is a linked version whose original has a live row, the +/// original's rel_path (same library when possible). Tag and favorite +/// writes on a version path bind to the original through this. +pub fn canonical_rel_path( + conn: &mut SqliteConnection, + rel_path: &str, +) -> QueryResult> { + #[derive(QueryableByName)] + struct Row { + #[diesel(sql_type = Text)] + rel_path: String, + } + let rows = sql_query( + "SELECT o.rel_path AS rel_path FROM image_exif v \ + JOIN photo_versions pv ON pv.version_hash = v.content_hash \ + JOIN image_exif o ON o.content_hash = pv.original_hash \ + WHERE v.rel_path = ? \ + ORDER BY (o.library_id = v.library_id) DESC, o.id \ + LIMIT 1", + ) + .bind::(rel_path) + .load::(conn)?; + Ok(rows.into_iter().next().map(|r| r.rel_path)) +} + +/// Root original of `hash`: follows `photo_versions` until a hash that is +/// not itself a version. `None` on a cycle. +fn root_original(conn: &mut SqliteConnection, hash: &str) -> QueryResult> { + #[derive(QueryableByName)] + struct Row { + #[diesel(sql_type = Text)] + original_hash: String, + } + let mut current = hash.to_string(); + for _ in 0..8 { + let next = sql_query("SELECT original_hash FROM photo_versions WHERE version_hash = ?") + .bind::(¤t) + .load::(conn)? + .into_iter() + .next(); + match next { + Some(r) => current = r.original_hash, + None => return Ok(Some(current)), + } + } + Ok(None) +} + +/// Columns the version copy takes from its original, in `UPDATE ... SET (..)` +/// order, followed by the source marker. +const COPIED_COLUMNS: &str = "date_taken, date_utc, camera_offset_minutes, date_taken_tz_source, \ + gps_latitude, gps_longitude, gps_altitude, camera_make, camera_model, lens_model, \ + focal_length, aperture, shutter_speed, iso"; + +fn copied_differs() -> String { + COPIED_COLUMNS + .split(',') + .map(|c| c.trim()) + .map(|c| format!("v.{c} IS NOT o.{c}")) + .collect::>() + .join(" OR ") +} + +impl VersionDao for SqliteVersionDao { + fn link_version(&mut self, link: &NewVersionLink) -> Result { + trace_db_call("insert", "link_version", |_span| { + let mut conn = self.connection.lock().expect("Unable to get VersionDao"); + + if !is_known_kind(&link.kind) { + return Ok(LinkOutcome::Rejected("unknown kind")); + } + if link.version_hash == link.original_hash { + return Ok(LinkOutcome::Rejected("a file cannot be a version of itself")); + } + + conn.immediate_transaction::(|conn| { + let existing = + sql_query("SELECT version_hash, original_hash, kind, source, created_at, library_id, rel_path FROM photo_versions WHERE version_hash = ?") + .bind::(&link.version_hash) + .load::(conn)?; + if !existing.is_empty() { + return Ok(LinkOutcome::AlreadyLinked); + } + + // Flatten: link to the root, never to another version. + let Some(root) = root_original(conn, &link.original_hash)? else { + return Ok(LinkOutcome::Rejected("link would form a cycle")); + }; + if root == link.version_hash { + return Ok(LinkOutcome::Rejected("link would form a cycle")); + } + + sql_query( + "INSERT INTO photo_versions \ + (version_hash, original_hash, kind, source, created_at, library_id, rel_path) \ + VALUES (?, ?, ?, ?, ?, ?, ?)", + ) + .bind::(&link.version_hash) + .bind::(&root) + .bind::(&link.kind) + .bind::(&link.source) + .bind::(chrono::Utc::now().timestamp()) + .bind::(link.library_id) + .bind::(&link.rel_path) + .execute(conn)?; + + // Versions that pointed at this file now point at the root. + sql_query("UPDATE photo_versions SET original_hash = ? WHERE original_hash = ?") + .bind::(&root) + .bind::(&link.version_hash) + .execute(conn)?; + // A display choice made for the file that just became a + // version is meaningless. + sql_query("DELETE FROM photo_version_display WHERE original_hash = ?") + .bind::(&link.version_hash) + .execute(conn)?; + + for reference in &link.references { + // The primary original is not a reference to itself. + if *reference == root || *reference == link.version_hash { + continue; + } + sql_query( + "INSERT OR IGNORE INTO photo_version_references \ + (version_hash, reference_hash) VALUES (?, ?)", + ) + .bind::(&link.version_hash) + .bind::(reference) + .execute(conn)?; + } + Ok(LinkOutcome::Linked) + }) + }) + .map_err(|e| DbError::log(DbErrorKind::InsertError, e)) + } + + fn get_link(&mut self, version_hash: &str) -> Result, DbError> { + trace_db_call("query", "get_version_link", |_span| { + let mut conn = self.connection.lock().expect("Unable to get VersionDao"); + sql_query( + "SELECT version_hash, original_hash, kind, source, created_at, library_id, rel_path \ + FROM photo_versions WHERE version_hash = ?", + ) + .bind::(version_hash) + .load::(&mut *conn) + .map(|rows| rows.into_iter().next()) + .map_err(|e| anyhow::anyhow!("Query error: {}", e)) + }) + .map_err(|e| DbError::log(DbErrorKind::QueryError, e)) + } + + fn version_hashes_among(&mut self, hashes: &[String]) -> Result, DbError> { + trace_db_call("query", "version_hashes_among", |_span| { + #[derive(QueryableByName)] + struct Row { + #[diesel(sql_type = Text)] + version_hash: String, + } + let mut conn = self.connection.lock().expect("Unable to get VersionDao"); + let mut out = HashSet::new(); + for chunk in hashes.chunks(CHUNK) { + let sql = format!( + "SELECT version_hash FROM photo_versions WHERE version_hash IN ({})", + placeholders(chunk.len()) + ); + let mut q = sql_query(sql).into_boxed(); + for h in chunk { + q = q.bind::(h); + } + let rows = q + .load::(&mut *conn) + .map_err(|e| anyhow::anyhow!("Query error: {}", e))?; + out.extend(rows.into_iter().map(|r| r.version_hash)); + } + Ok(out) + }) + .map_err(|e| DbError::log(DbErrorKind::QueryError, e)) + } + + fn list_versions(&mut self, original_hash: &str) -> Result, DbError> { + trace_db_call("query", "list_versions", |_span| { + let mut conn = self.connection.lock().expect("Unable to get VersionDao"); + sql_query( + "SELECT pv.version_hash AS version_hash, pv.kind AS kind, pv.source AS source, \ + pv.created_at AS created_at, e.library_id AS library_id, \ + e.rel_path AS rel_path, e.width AS width, e.height AS height \ + FROM photo_versions pv \ + JOIN image_exif e ON e.id = \ + (SELECT MIN(id) FROM image_exif WHERE content_hash = pv.version_hash) \ + WHERE pv.original_hash = ? \ + ORDER BY pv.created_at, e.rel_path", + ) + .bind::(original_hash) + .load::(&mut *conn) + .map_err(|e| anyhow::anyhow!("Query error: {}", e)) + }) + .map_err(|e| DbError::log(DbErrorKind::QueryError, e)) + } + + fn list_references(&mut self, version_hash: &str) -> Result, DbError> { + trace_db_call("query", "list_version_references", |_span| { + #[derive(QueryableByName)] + struct Row { + #[diesel(sql_type = Text)] + reference_hash: String, + } + let mut conn = self.connection.lock().expect("Unable to get VersionDao"); + sql_query( + "SELECT reference_hash FROM photo_version_references \ + WHERE version_hash = ? ORDER BY reference_hash", + ) + .bind::(version_hash) + .load::(&mut *conn) + .map(|rows| rows.into_iter().map(|r| r.reference_hash).collect()) + .map_err(|e| anyhow::anyhow!("Query error: {}", e)) + }) + .map_err(|e| DbError::log(DbErrorKind::QueryError, e)) + } + + fn files_for_hashes( + &mut self, + hashes: &[String], + prefer_library: Option, + ) -> Result, DbError> { + trace_db_call("query", "version_files_for_hashes", |_span| { + let mut conn = self.connection.lock().expect("Unable to get VersionDao"); + let mut out: HashMap = HashMap::new(); + for chunk in hashes.chunks(CHUNK) { + let sql = format!( + "SELECT content_hash, library_id, rel_path FROM image_exif \ + WHERE content_hash IN ({}) ORDER BY id", + placeholders(chunk.len()) + ); + let mut q = sql_query(sql).into_boxed(); + for h in chunk { + q = q.bind::(h); + } + let rows = q + .load::(&mut *conn) + .map_err(|e| anyhow::anyhow!("Query error: {}", e))?; + for r in rows { + let better = match out.get(&r.content_hash) { + None => true, + Some((lib, _)) => { + prefer_library == Some(r.library_id) && prefer_library != Some(*lib) + } + }; + if better { + out.insert(r.content_hash, (r.library_id, r.rel_path)); + } + } + } + Ok(out) + }) + .map_err(|e| DbError::log(DbErrorKind::QueryError, e)) + } + + fn get_display(&mut self, original_hash: &str) -> Result, DbError> { + trace_db_call("query", "get_version_display", |_span| { + #[derive(QueryableByName)] + struct Row { + #[diesel(sql_type = Text)] + display_hash: String, + } + let mut conn = self.connection.lock().expect("Unable to get VersionDao"); + sql_query("SELECT display_hash FROM photo_version_display WHERE original_hash = ?") + .bind::(original_hash) + .load::(&mut *conn) + .map(|rows| rows.into_iter().next().map(|r| r.display_hash)) + .map_err(|e| anyhow::anyhow!("Query error: {}", e)) + }) + .map_err(|e| DbError::log(DbErrorKind::QueryError, e)) + } + + fn set_display( + &mut self, + original_hash: &str, + display_hash: Option<&str>, + ) -> Result<(), DbError> { + trace_db_call("update", "set_version_display", |_span| { + let mut conn = self.connection.lock().expect("Unable to get VersionDao"); + match display_hash { + Some(display) => sql_query( + "INSERT INTO photo_version_display (original_hash, display_hash, decided_at) \ + VALUES (?, ?, ?) \ + ON CONFLICT(original_hash) DO UPDATE SET \ + display_hash = excluded.display_hash, decided_at = excluded.decided_at", + ) + .bind::(original_hash) + .bind::(display) + .bind::(chrono::Utc::now().timestamp()) + .execute(&mut *conn), + None => sql_query("DELETE FROM photo_version_display WHERE original_hash = ?") + .bind::(original_hash) + .execute(&mut *conn), + } + .map(|_| ()) + .map_err(|e| anyhow::anyhow!("Update error: {}", e)) + }) + .map_err(|e| DbError::log(DbErrorKind::UpdateError, e)) + } + + fn photo_summaries(&mut self) -> Result, DbError> { + trace_db_call("query", "photo_version_summaries", |_span| { + #[derive(QueryableByName)] + struct OriginalRow { + #[diesel(sql_type = Text)] + original_hash: String, + #[diesel(sql_type = Integer)] + library_id: i32, + #[diesel(sql_type = Text)] + rel_path: String, + #[diesel(sql_type = BigInt)] + version_count: i64, + } + #[derive(QueryableByName)] + struct DisplayRow { + #[diesel(sql_type = Text)] + original_hash: String, + #[diesel(sql_type = Integer)] + library_id: i32, + #[diesel(sql_type = Text)] + rel_path: String, + } + let mut conn = self.connection.lock().expect("Unable to get VersionDao"); + + let originals = sql_query( + "SELECT o.content_hash AS original_hash, o.library_id AS library_id, \ + o.rel_path AS rel_path, \ + (SELECT COUNT(*) FROM photo_versions pv \ + WHERE pv.original_hash = o.content_hash \ + AND EXISTS (SELECT 1 FROM image_exif v \ + WHERE v.content_hash = pv.version_hash)) AS version_count \ + FROM image_exif o \ + WHERE o.content_hash IN (SELECT original_hash FROM photo_versions)", + ) + .load::(&mut *conn) + .map_err(|e| anyhow::anyhow!("Query error: {}", e))?; + if originals.is_empty() { + return Ok(HashMap::new()); + } + + // Only a display choice that is still a version of that original + // counts; anything else falls back to the original. + let displays = sql_query( + "SELECT d.original_hash AS original_hash, v.library_id AS library_id, \ + v.rel_path AS rel_path \ + FROM photo_version_display d \ + JOIN photo_versions pv ON pv.version_hash = d.display_hash \ + AND pv.original_hash = d.original_hash \ + JOIN image_exif v ON v.content_hash = d.display_hash \ + ORDER BY v.id", + ) + .load::(&mut *conn) + .map_err(|e| anyhow::anyhow!("Query error: {}", e))?; + let mut display_by_hash: HashMap> = HashMap::new(); + for d in displays { + display_by_hash + .entry(d.original_hash) + .or_default() + .push((d.library_id, d.rel_path)); + } + + let mut out = HashMap::new(); + for o in originals { + if o.version_count == 0 { + continue; + } + let display = display_by_hash.get(&o.original_hash).and_then(|files| { + files + .iter() + .find(|(lib, _)| *lib == o.library_id) + .or_else(|| files.first()) + .cloned() + }); + out.insert( + (o.library_id, o.rel_path), + PhotoVersionSummary { + version_count: o.version_count as u32, + display, + }, + ); + } + Ok(out) + }) + .map_err(|e| DbError::log(DbErrorKind::QueryError, e)) + } + + fn hidden_version_rows( + &mut self, + view_library_ids: &[i32], + ) -> Result, DbError> { + trace_db_call("query", "hidden_version_rows", |_span| { + #[derive(QueryableByName)] + struct Row { + #[diesel(sql_type = Integer)] + library_id: i32, + #[diesel(sql_type = Text)] + rel_path: String, + } + let scope = if view_library_ids.is_empty() { + String::new() + } else { + let list = view_library_ids + .iter() + .map(i32::to_string) + .collect::>() + .join(","); + format!(" AND orig.library_id IN ({list})") + }; + let sql = format!( + "SELECT image_exif.library_id AS library_id, image_exif.rel_path AS rel_path \ + FROM image_exif \ + JOIN photo_versions pv ON pv.version_hash = image_exif.content_hash \ + WHERE EXISTS (SELECT 1 FROM image_exif orig \ + WHERE orig.content_hash = pv.original_hash{scope})" + ); + let mut conn = self.connection.lock().expect("Unable to get VersionDao"); + sql_query(sql) + .load::(&mut *conn) + .map(|rows| { + rows.into_iter() + .map(|r| (r.library_id, r.rel_path)) + .collect() + }) + .map_err(|e| anyhow::anyhow!("Query error: {}", e)) + }) + .map_err(|e| DbError::log(DbErrorKind::QueryError, e)) + } + + fn unlinked_name_candidates( + &mut self, + library_id: i32, + limit: i64, + ) -> Result, DbError> { + trace_db_call("query", "unlinked_name_candidates", |_span| { + #[derive(QueryableByName)] + struct Row { + #[diesel(sql_type = Text)] + rel_path: String, + #[diesel(sql_type = Text)] + content_hash: String, + } + let kinds = KINDS + .iter() + .map(|k| format!("rel_path LIKE '%.{k}.%'")) + .collect::>() + .join(" OR "); + let sql = format!( + "SELECT rel_path, content_hash FROM image_exif \ + WHERE library_id = ? AND content_hash IS NOT NULL AND ({kinds}) \ + AND NOT EXISTS (SELECT 1 FROM photo_versions pv \ + WHERE pv.version_hash = image_exif.content_hash) \ + ORDER BY id LIMIT ?" + ); + let mut conn = self.connection.lock().expect("Unable to get VersionDao"); + sql_query(sql) + .bind::(library_id) + .bind::(limit) + .load::(&mut *conn) + .map(|rows| { + rows.into_iter() + .map(|r| (r.rel_path, r.content_hash)) + .collect() + }) + .map_err(|e| anyhow::anyhow!("Query error: {}", e)) + }) + .map_err(|e| DbError::log(DbErrorKind::QueryError, e)) + } + + fn sync_version_metadata(&mut self) -> Result { + trace_db_call("update", "sync_version_metadata", |_span| { + #[derive(QueryableByName)] + struct Pair { + #[diesel(sql_type = Integer)] + vid: i32, + #[diesel(sql_type = Integer)] + oid: i32, + } + let mut conn = self.connection.lock().expect("Unable to get VersionDao"); + + // Version rows whose copy is missing or stale, against the + // earliest row of a converted original. `IS NOT` compares NULLs. + let stale = sql_query(format!( + "SELECT v.id AS vid, o.id AS oid \ + FROM image_exif v \ + JOIN photo_versions pv ON pv.version_hash = v.content_hash \ + JOIN image_exif o ON o.id = \ + (SELECT MIN(id) FROM image_exif WHERE content_hash = pv.original_hash) \ + WHERE o.date_taken IS NOT NULL AND o.date_utc IS NOT NULL \ + AND (v.date_taken_source IS NOT '{DATE_SOURCE_VERSION_OF}' OR {})", + copied_differs() + )) + .load::(&mut *conn) + .map_err(|e| anyhow::anyhow!("Query error: {}", e))?; + + for p in &stale { + sql_query(format!( + "UPDATE image_exif SET ({COPIED_COLUMNS}, date_taken_source) = \ + (SELECT {COPIED_COLUMNS}, '{DATE_SOURCE_VERSION_OF}' \ + FROM image_exif WHERE id = ?) \ + WHERE id = ?" + )) + .bind::(p.oid) + .bind::(p.vid) + .execute(&mut *conn) + .map_err(|e| anyhow::anyhow!("Update error: {}", e))?; + } + Ok(stale.len()) + }) + .map_err(|e| DbError::log(DbErrorKind::UpdateError, e)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::database::test::in_memory_db_connection; + + fn dao() -> (SqliteVersionDao, Arc>) { + let conn = Arc::new(Mutex::new(in_memory_db_connection())); + (SqliteVersionDao::from_shared(conn.clone()), conn) + } + + fn exec(conn: &Arc>, sql: &str) { + sql_query(sql) + .execute(&mut *conn.lock().unwrap()) + .unwrap_or_else(|e| panic!("{sql}: {e}")); + } + + fn insert_exif(conn: &Arc>, lib: i32, rel: &str, hash: &str) { + exec( + conn, + &format!( + "INSERT OR IGNORE INTO libraries (id, name, root_path, created_at) \ + VALUES ({lib}, 'lib{lib}', '/tmp/lib{lib}', 0)" + ), + ); + exec( + conn, + &format!( + "INSERT INTO image_exif (library_id, rel_path, created_time, last_modified, content_hash) \ + VALUES ({lib}, '{rel}', 0, 0, '{hash}')" + ), + ); + } + + fn link(vh: &str, oh: &str, rel: &str) -> NewVersionLink { + NewVersionLink { + version_hash: vh.into(), + original_hash: oh.into(), + kind: "restored".into(), + source: SOURCE_XMP.into(), + library_id: 1, + rel_path: rel.into(), + references: vec![], + } + } + + #[test] + fn link_is_recorded_once_and_rejects_nonsense() { + let (mut dao, _c) = dao(); + assert_eq!( + dao.link_version(&link("v", "o", "a/v.png")).unwrap(), + LinkOutcome::Linked + ); + assert_eq!( + dao.link_version(&link("v", "other", "a/v.png")).unwrap(), + LinkOutcome::AlreadyLinked, + "an existing link is never silently re-pointed" + ); + assert_eq!(dao.get_link("v").unwrap().unwrap().original_hash, "o"); + assert!(matches!( + dao.link_version(&link("x", "x", "a/x.png")).unwrap(), + LinkOutcome::Rejected(_) + )); + let mut bad_kind = link("y", "o", "a/y.png"); + bad_kind.kind = "bogus".into(); + assert!(matches!( + dao.link_version(&bad_kind).unwrap(), + LinkOutcome::Rejected(_) + )); + } + + #[test] + fn chains_are_flattened_to_the_root() { + let (mut dao, _c) = dao(); + dao.link_version(&link("v1", "root", "v1.png")).unwrap(); + // A second-generation edit derived from the first version. + dao.link_version(&link("v2", "v1", "v2.png")).unwrap(); + assert_eq!(dao.get_link("v2").unwrap().unwrap().original_hash, "root"); + + // Linking an existing original underneath a new root moves its versions up. + dao.link_version(&link("o2", "top", "o2.png")).unwrap(); + dao.link_version(&link("child", "o2", "child.png")).unwrap(); + assert_eq!(dao.get_link("child").unwrap().unwrap().original_hash, "top"); + + // A cycle is refused. + assert!(matches!( + dao.link_version(&link("root", "v2", "root.png")).unwrap(), + LinkOutcome::Rejected(_) + )); + } + + #[test] + fn references_are_stored_but_never_the_original() { + let (mut dao, _c) = dao(); + let mut l = link("v", "o", "v.png"); + l.references = vec!["ref1".into(), "o".into(), "ref2".into(), "ref1".into()]; + dao.link_version(&l).unwrap(); + assert_eq!(dao.list_references("v").unwrap(), vec!["ref1", "ref2"]); + } + + #[test] + fn visible_clause_hides_only_while_original_is_reachable() { + let (mut dao, c) = dao(); + insert_exif(&c, 1, "a/orig.jpg", "o"); + insert_exif(&c, 1, "a/orig.jpg.restored.png", "v"); + insert_exif(&c, 2, "b/other.jpg", "other"); + dao.link_version(&link("v", "o", "a/orig.jpg.restored.png")) + .unwrap(); + + let visible = |libs: &[i32]| -> Vec { + #[derive(QueryableByName)] + struct R { + #[diesel(sql_type = Text)] + rel_path: String, + } + let mut rows: Vec = sql_query(format!( + "SELECT rel_path FROM image_exif WHERE {} ORDER BY rel_path", + visible_version_clause(libs) + )) + .load::(&mut *c.lock().unwrap()) + .unwrap() + .into_iter() + .map(|r| r.rel_path) + .collect(); + rows.sort(); + rows + }; + + assert_eq!(visible(&[]), vec!["a/orig.jpg", "b/other.jpg"]); + assert_eq!(visible(&[1, 2]), vec!["a/orig.jpg", "b/other.jpg"]); + // The clause only decides hiding; the caller scopes rows to the view. + // With the view narrowed to lib 2 the pair's original is out of + // reach, so the version is not hidden. + assert!(visible(&[2]).contains(&"a/orig.jpg.restored.png".to_string())); + exec( + &c, + "UPDATE image_exif SET library_id = 2 WHERE rel_path = 'a/orig.jpg'", + ); + assert!( + visible(&[1]).contains(&"a/orig.jpg.restored.png".to_string()), + "original lives outside library 1" + ); + // Original deleted: the version shows up everywhere. + exec(&c, "DELETE FROM image_exif WHERE rel_path = 'a/orig.jpg'"); + assert_eq!( + visible(&[]), + vec!["a/orig.jpg.restored.png", "b/other.jpg"] + ); + } + + #[test] + fn hidden_rows_and_summaries_follow_the_same_rule() { + let (mut dao, c) = dao(); + insert_exif(&c, 1, "orig.jpg", "o"); + insert_exif(&c, 1, "aiedits/orig.jpg.restored.png", "v1"); + insert_exif(&c, 1, "aiedits/orig.jpg.edit.png", "v2"); + dao.link_version(&link("v1", "o", "aiedits/orig.jpg.restored.png")) + .unwrap(); + let mut l2 = link("v2", "o", "aiedits/orig.jpg.edit.png"); + l2.kind = "edit".into(); + dao.link_version(&l2).unwrap(); + + let hidden = dao.hidden_version_rows(&[]).unwrap(); + assert_eq!(hidden.len(), 2); + assert!(hidden.contains(&(1, "aiedits/orig.jpg.edit.png".to_string()))); + assert!(dao.hidden_version_rows(&[3]).unwrap().is_empty()); + + let s = dao.photo_summaries().unwrap(); + let sum = &s[&(1, "orig.jpg".to_string())]; + assert_eq!(sum.version_count, 2); + assert_eq!(sum.display, None); + + dao.set_display("o", Some("v2")).unwrap(); + let s = dao.photo_summaries().unwrap(); + assert_eq!( + s[&(1, "orig.jpg".to_string())].display, + Some((1, "aiedits/orig.jpg.edit.png".to_string())) + ); + + // A display file that vanished falls back to the original. + exec(&c, "DELETE FROM image_exif WHERE content_hash = 'v2'"); + let s = dao.photo_summaries().unwrap(); + let sum = &s[&(1, "orig.jpg".to_string())]; + assert_eq!(sum.version_count, 1); + assert_eq!(sum.display, None); + + dao.set_display("o", None).unwrap(); + assert_eq!(dao.get_display("o").unwrap(), None); + } + + #[test] + fn no_versions_means_no_summaries() { + let (mut dao, c) = dao(); + insert_exif(&c, 1, "a.jpg", "a"); + assert!(dao.photo_summaries().unwrap().is_empty()); + assert!(dao.hidden_version_rows(&[]).unwrap().is_empty()); + } + + #[test] + fn canonical_path_resolves_a_version_to_its_original() { + let (mut dao, c) = dao(); + insert_exif(&c, 1, "orig.jpg", "o"); + insert_exif(&c, 1, "aiedits/orig.jpg.restored.png", "v"); + dao.link_version(&link("v", "o", "aiedits/orig.jpg.restored.png")) + .unwrap(); + let mut conn = c.lock().unwrap(); + assert_eq!( + canonical_rel_path(&mut conn, "aiedits/orig.jpg.restored.png").unwrap(), + Some("orig.jpg".to_string()) + ); + assert_eq!(canonical_rel_path(&mut conn, "orig.jpg").unwrap(), None); + drop(conn); + // Original gone: nothing to bind to, the version is a normal photo. + exec(&c, "DELETE FROM image_exif WHERE content_hash = 'o'"); + let mut conn = c.lock().unwrap(); + assert_eq!( + canonical_rel_path(&mut conn, "aiedits/orig.jpg.restored.png").unwrap(), + None + ); + } + + #[test] + fn sync_copies_date_gps_and_camera_from_a_converted_original() { + let (mut dao, c) = dao(); + insert_exif(&c, 1, "orig.jpg", "o"); + insert_exif(&c, 1, "orig.jpg.restored.png", "v"); + dao.link_version(&link("v", "o", "orig.jpg.restored.png")) + .unwrap(); + + // Unconverted original: nothing is copied (the version keeps its own date). + exec( + &c, + "UPDATE image_exif SET date_taken = 1000 WHERE content_hash = 'o'", + ); + assert_eq!(dao.sync_version_metadata().unwrap(), 0); + + exec( + &c, + "UPDATE image_exif SET date_taken = 1000, date_utc = 1000, camera_offset_minutes = -240, \ + date_taken_tz_source = 'offset_tag', gps_latitude = 12.5, gps_longitude = 7.25, \ + camera_make = 'Canon', camera_model = 'EOS', date_taken_source = 'exif' \ + WHERE content_hash = 'o'", + ); + exec( + &c, + "UPDATE image_exif SET date_taken = 5, date_taken_source = 'fs_time' WHERE content_hash = 'v'", + ); + assert_eq!(dao.sync_version_metadata().unwrap(), 1); + assert_eq!(dao.sync_version_metadata().unwrap(), 0, "idempotent"); + + #[derive(QueryableByName)] + struct R { + #[diesel(sql_type = Nullable)] + date_utc: Option, + #[diesel(sql_type = Nullable)] + src: Option, + #[diesel(sql_type = Nullable)] + lat: Option, + #[diesel(sql_type = Nullable)] + make: Option, + } + let row = sql_query( + "SELECT date_utc, date_taken_source AS src, gps_latitude AS lat, camera_make AS make \ + FROM image_exif WHERE content_hash = 'v'", + ) + .get_result::(&mut *c.lock().unwrap()) + .unwrap(); + assert_eq!(row.date_utc, Some(1000)); + assert_eq!(row.src.as_deref(), Some("version_of")); + assert_eq!(row.lat, Some(12.5)); + assert_eq!(row.make.as_deref(), Some("Canon")); + + // The original's date changes (manual override): the copy follows. + exec( + &c, + "UPDATE image_exif SET date_taken = 2000, date_utc = 2000 WHERE content_hash = 'o'", + ); + assert_eq!(dao.sync_version_metadata().unwrap(), 1); + } + + #[test] + fn name_candidates_skip_linked_rows() { + let (mut dao, c) = dao(); + insert_exif(&c, 1, "a/x.jpg.restored.png", "h1"); + insert_exif(&c, 1, "a/x.jpg.edit.2.png", "h2"); + insert_exif(&c, 1, "a/plain.png", "h3"); + insert_exif(&c, 2, "a/y.jpg.restored.png", "h4"); + dao.link_version(&link("h2", "ox", "a/x.jpg.edit.2.png")) + .unwrap_or(LinkOutcome::Linked); + let got = dao.unlinked_name_candidates(1, 100).unwrap(); + assert_eq!(got, vec![("a/x.jpg.restored.png".to_string(), "h1".into())]); + } + + #[test] + fn version_hashes_among_and_files_for_hashes() { + let (mut dao, c) = dao(); + insert_exif(&c, 1, "o.jpg", "o"); + insert_exif(&c, 2, "o.jpg", "o"); + dao.link_version(&link("v", "o", "v.png")).unwrap(); + let got = dao + .version_hashes_among(&["v".to_string(), "o".to_string()]) + .unwrap(); + assert_eq!(got, HashSet::from(["v".to_string()])); + let files = dao.files_for_hashes(&["o".to_string()], Some(2)).unwrap(); + assert_eq!(files["o"], (2, "o.jpg".to_string())); + let files = dao.files_for_hashes(&["o".to_string()], None).unwrap(); + assert_eq!(files["o"].0, 1); + } +} -- 2.54.0 From 080a75c33622f1bca5e800c3200fd6c236b1d420 Mon Sep 17 00:00:00 2001 From: Cameron Cordes Date: Fri, 30 Oct 2026 11:28:34 -0400 Subject: [PATCH 2/6] 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 --- src/ai/handlers.rs | 20 ++ src/ai/insight_generator.rs | 2 +- src/backfill.rs | 76 ++++++- src/clip_search.rs | 12 +- src/data/mod.rs | 26 +++ src/database/mod.rs | 132 +++++++++++-- src/database/version_dao.rs | 25 ++- src/duplicates.rs | 7 + src/faces.rs | 28 ++- src/files.rs | 104 +++++++++- src/handlers/favorites.rs | 2 + src/lib.rs | 1 + src/library_maintenance.rs | 85 +++++++- src/main.rs | 2 + src/memories.rs | 24 ++- src/reels/selector.rs | 4 + src/state.rs | 13 +- src/tags.rs | 17 ++ src/thumbnails.rs | 45 ++++- src/unified_search.rs | 19 +- src/versions/handlers.rs | 252 +++++++++++++++++++++++ src/versions/mod.rs | 384 ++++++++++++++++++++++++++++++++++++ src/versions/xmp.rs | 312 +++++++++++++++++++++++++++++ src/watcher.rs | 69 ++++++- 24 files changed, 1614 insertions(+), 47 deletions(-) create mode 100644 src/versions/handlers.rs create mode 100644 src/versions/mod.rs create mode 100644 src/versions/xmp.rs diff --git a/src/ai/handlers.rs b/src/ai/handlers.rs index eb3d4aa..b1ec0ea 100644 --- a/src/ai/handlers.rs +++ b/src/ai/handlers.rs @@ -430,7 +430,17 @@ pub async fn generate_insight_handler( &mut request.persona_id, ); + // Insights describe the original: asking for one on a photo version + // (#191) runs against the original it belongs to. let normalized_path = normalize_path(&request.file_path); + let normalized_path = app_state + .version_dao + .lock() + .expect("Unable to lock VersionDao") + .original_path_of(&normalized_path) + .ok() + .flatten() + .unwrap_or(normalized_path); let library = app_state.primary_library(); let gen_type = InsightGenerationType::Standard; @@ -748,7 +758,17 @@ pub async fn generate_agentic_insight_handler( &mut request.persona_id, ); + // Insights describe the original: asking for one on a photo version + // (#191) runs against the original it belongs to. let normalized_path = normalize_path(&request.file_path); + let normalized_path = app_state + .version_dao + .lock() + .expect("Unable to lock VersionDao") + .original_path_of(&normalized_path) + .ok() + .flatten() + .unwrap_or(normalized_path); let library = app_state.primary_library(); let gen_type = InsightGenerationType::Agentic; diff --git a/src/ai/insight_generator.rs b/src/ai/insight_generator.rs index 0643b2c..91e8eeb 100644 --- a/src/ai/insight_generator.rs +++ b/src/ai/insight_generator.rs @@ -2544,7 +2544,7 @@ Return ONLY the summary, nothing else."#, ); let rows = { let mut dao = self.exif_dao.lock().expect("Unable to lock ExifDao"); - match dao.query_by_exif(None, None, None, None, None, range) { + match dao.query_by_exif(None, None, None, None, None, range, false) { Ok(r) => r, Err(e) => return format!("Error querying photos: {:?}", e), } diff --git a/src/backfill.rs b/src/backfill.rs index b743cee..d592eb2 100644 --- a/src/backfill.rs +++ b/src/backfill.rs @@ -18,7 +18,7 @@ use prometheus::IntGauge; use crate::camera_tz; use crate::content_hash; use crate::database::models::PrecomputedReel; -use crate::database::{ExifDao, PrecomputedReelDao, UtcBackfillCandidate}; +use crate::database::{ExifDao, PrecomputedReelDao, UtcBackfillCandidate, VersionDao}; use crate::date_resolver; use crate::face_watch; use crate::faces; @@ -827,12 +827,14 @@ pub fn backfill_missing_content_hashes( /// content_hash recorded in image_exif but no row in face_detections /// yet. Re-querying image_exif here picks up rows the EXIF write loop /// just inserted alongside any pre-existing rows the watcher walked -/// over — covers both new uploads and the initial backlog scan. +/// over — covers both new uploads and the initial backlog scan. Photo +/// versions (#191) are never candidates: faces come from the original only. pub fn build_face_candidates( library: &libraries::Library, files: &[(PathBuf, String)], exif_dao: &Arc>>, face_dao: &Arc>>, + version_dao: &Arc>>, ) -> Vec { // Restrict to image files; videos aren't face-scanned in v1 (kamadak // doesn't even register them in image_exif). @@ -859,12 +861,22 @@ pub fn build_face_candidates( } } + let hashes: Vec = hash_by_path.values().cloned().collect(); + let versions = version_dao + .lock() + .expect("version dao") + .version_hashes_among(&hashes) + .unwrap_or_default(); + let mut candidates = Vec::new(); let mut dao = face_dao.lock().expect("face dao"); for rel_path in image_paths { let Some(hash) = hash_by_path.get(&rel_path) else { continue; }; + if versions.contains(hash) { + continue; + } match dao.already_scanned(hash) { Ok(true) => continue, Ok(false) => candidates.push(face_watch::FaceCandidate { @@ -891,9 +903,11 @@ mod tests { use crate::database::models::{InsertImageExif, InsertLibrary}; use crate::database::test::in_memory_db_connection; - use crate::database::{ExifDao, SqliteExifDao, schema}; + use crate::database::version_dao::NewVersionLink; + use crate::database::{ExifDao, SqliteExifDao, SqliteVersionDao, schema}; use crate::faces::{FaceDao, SqliteFaceDao}; use crate::libraries::Library; + use diesel::sqlite::SqliteConnection; /// Everything `setup` hands back to a test: tempdir, library, shared /// connection, and the two DAOs. Aliased to keep clippy's @@ -1148,7 +1162,9 @@ mod tests { (tmp.path().join("unhashed.jpg"), "unhashed.jpg".to_string()), (tmp.path().join("clip.mp4"), "clip.mp4".to_string()), ]; - let candidates = build_face_candidates(&library, &files, &exif_dao, &face_dao); + let version_dao = version_dao_for(&_conn); + let candidates = + build_face_candidates(&library, &files, &exif_dao, &face_dao, &version_dao); assert_eq!( candidates.len(), @@ -1159,6 +1175,58 @@ mod tests { assert_eq!(candidates[0].content_hash, "hash-fresh"); } + fn version_dao_for(conn: &Arc>) -> Arc>> { + Arc::new(Mutex::new(Box::new(SqliteVersionDao::from_shared( + Arc::clone(conn), + )))) + } + + #[test] + fn build_face_candidates_skips_photo_versions() { + let (tmp, library, conn, exif_dao, face_dao) = setup(); + insert_exif(&exif_dao, 1, "orig.jpg", Some("hash-orig")); + insert_exif( + &exif_dao, + 1, + "aiedits/orig.jpg.restored.png", + Some("hash-ver"), + ); + let version_dao = version_dao_for(&conn); + version_dao + .lock() + .unwrap() + .link_version(&NewVersionLink { + version_hash: "hash-ver".into(), + original_hash: "hash-orig".into(), + kind: "restored".into(), + source: "xmp".into(), + library_id: 1, + rel_path: "aiedits/orig.jpg.restored.png".into(), + references: vec![], + }) + .unwrap(); + let files: Vec<(PathBuf, String)> = vec![ + (tmp.path().join("orig.jpg"), "orig.jpg".to_string()), + ( + tmp.path().join("aiedits/orig.jpg.restored.png"), + "aiedits/orig.jpg.restored.png".to_string(), + ), + ]; + let candidates = + build_face_candidates(&library, &files, &exif_dao, &face_dao, &version_dao); + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].rel_path, "orig.jpg"); + + // The SQL backlog drain agrees. + let unscanned = face_dao + .lock() + .unwrap() + .list_unscanned_candidates(1, 10) + .unwrap(); + assert_eq!(unscanned.len(), 1); + assert_eq!(unscanned[0].0, "orig.jpg"); + } + // --- True-instant backfill drain (ADR-0001, ticket #118) ------------- use crate::database::SqlitePrecomputedReelDao; diff --git a/src/clip_search.rs b/src/clip_search.rs index 40bc75c..5b9413b 100644 --- a/src/clip_search.rs +++ b/src/clip_search.rs @@ -82,6 +82,10 @@ pub struct SearchHit { /// Cosine similarity in [-1, 1]. In practice OpenAI CLIP returns /// 0.10–0.40 for the typical photo library. pub score: f32, + /// `display_path` / `version_count` (#191); absent for a photo without + /// versions. `rel_path` is always the original's. + #[serde(flatten)] + pub versions: crate::versions::VersionFields, } #[derive(Debug, Serialize)] @@ -230,11 +234,13 @@ pub async fn score_photos( /// to a row are skipped. Shared by both endpoints. pub fn resolve_hits( exif_dao: &Mutex>, + version_dao: &Mutex>, scored: &[(f32, String)], ) -> Vec { if scored.is_empty() { return Vec::new(); } + let summaries = crate::versions::load_summaries(version_dao); let hashes: Vec = scored.iter().map(|(_, h)| h.clone()).collect(); let mut dao = exif_dao.lock().expect("exif dao"); let path_map = dao.get_rel_paths_for_hashes(&hashes).unwrap_or_else(|e| { @@ -258,11 +264,15 @@ pub fn resolve_hits( .get(hash) .and_then(|paths| paths.first().cloned()) .unwrap_or(row.file_path); + let versions = crate::versions::VersionFields::from_summary( + summaries.get(&(row.library_id, rel_path.clone())), + ); results.push(SearchHit { library_id: row.library_id, rel_path, content_hash: hash.clone(), score: *score, + versions, }); } results @@ -343,7 +353,7 @@ pub async fn search_photos( let end = (offset + limit).min(total_matching); scored.hits[offset..end].to_vec() }; - let results = resolve_hits(&exif_dao, &page); + let results = resolve_hits(&exif_dao, &state.version_dao, &page); Ok(HttpResponse::Ok().json(SearchResponse { query: q_text, diff --git a/src/data/mod.rs b/src/data/mod.rs index a9cc04d..f872f0f 100644 --- a/src/data/mod.rs +++ b/src/data/mod.rs @@ -108,6 +108,19 @@ pub struct PhotosResponse { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub photo_libraries: Vec, + /// Photo versions (#191), parallel to `photos`. `photos[i]` is always + /// the *original's* path (it anchors tags, sort order and dates). + /// `photo_display_paths[i]` is the file to show instead when the global + /// display choice isn't the original, else `null`; skipped when no photo + /// on the page has one. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub photo_display_paths: Vec>, + + /// How many versions stand behind `photos[i]`; skipped when the page has + /// none, so responses without versions are unchanged. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub photo_version_counts: Vec, + // Pagination metadata (only present when limit is set) #[serde(skip_serializing_if = "Option::is_none")] pub total_count: Option, @@ -180,6 +193,13 @@ pub struct FilesRequest { /// inside a group. #[serde(default)] pub include_duplicates: Option, + + /// When true, list photo versions (#191) as photos of their own instead + /// of hiding them behind their original. Default false. Counterpart of + /// `include_duplicates`; a version whose original is not reachable in + /// the request's view is listed either way. + #[serde(default)] + pub include_versions: Option, } #[derive(Copy, Clone, Deserialize, PartialEq, Debug)] @@ -495,6 +515,11 @@ pub struct ExifSummary { /// Capture wall clock to display (naive ISO-8601; see /// `MetadataResponse::wall_clock`). pub wall_clock: Option, + /// `display_path` / `version_count` (#191); absent for a photo without + /// versions. `file_path` is always the original's (versions are hidden + /// from this listing while their original is reachable). + #[serde(flatten)] + pub versions: crate::versions::VersionFields, } impl ExifSummary { @@ -525,6 +550,7 @@ impl ExifSummary { date_utc: r.date_utc, camera_offset_minutes: r.camera_offset_minutes, date_taken_tz_source: r.date_taken_tz_source, + versions: Default::default(), } } } diff --git a/src/database/mod.rs b/src/database/mod.rs index f08e893..8939efe 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -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, @@ -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, + include_versions: bool, ) -> Result, 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, 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, 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>, } @@ -1103,6 +1135,7 @@ impl ExifDao for SqliteExifDao { lens_model_filter: Option<&str>, gps_bounds: Option<(f64, f64, f64, f64)>, date_range: Option, + include_versions: bool, ) -> Result, 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 = library_id_filter.into_iter().collect(); + query = query.filter(diesel::dsl::sql::( + &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::( + &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::( + &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::( + &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::( + 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::( + 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, 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::( + &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::(connection.deref_mut()) - .map_err(|e| anyhow::anyhow!("Query error: {}", e))?, + Some(h) => { + let original: Option = diesel::sql_query( + "SELECT original_hash FROM photo_versions WHERE version_hash = ?", + ) + .bind::(&h) + .get_result::(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::(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, 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::( + &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::( + 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::( + 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::( + 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 = 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 = rows.into_iter().map(|r| r.file_path).collect(); assert!( diff --git a/src/database/version_dao.rs b/src/database/version_dao.rs index 56f65c7..68bed30 100644 --- a/src/database/version_dao.rs +++ b/src/database/version_dao.rs @@ -170,6 +170,10 @@ pub trait VersionDao: Sync + Send { /// The link row for a version hash, if any. fn get_link(&mut self, version_hash: &str) -> Result, DbError>; + /// If `rel_path` is a linked version whose original is still indexed, + /// the original's rel_path (same library when possible). + fn original_path_of(&mut self, rel_path: &str) -> Result, DbError>; + /// Which of `hashes` are versions of something. fn version_hashes_among(&mut self, hashes: &[String]) -> Result, DbError>; @@ -191,8 +195,11 @@ pub trait VersionDao: Sync + Send { fn get_display(&mut self, original_hash: &str) -> Result, DbError>; /// Set (`Some`) or clear (`None`) the global display choice. - fn set_display(&mut self, original_hash: &str, display_hash: Option<&str>) - -> Result<(), DbError>; + fn set_display( + &mut self, + original_hash: &str, + display_hash: Option<&str>, + ) -> Result<(), DbError>; /// Per original `(library_id, rel_path)` that has at least one live /// version: the version count and display file. Empty when no photo has @@ -406,6 +413,15 @@ impl VersionDao for SqliteVersionDao { .map_err(|e| DbError::log(DbErrorKind::QueryError, e)) } + fn original_path_of(&mut self, rel_path: &str) -> Result, DbError> { + trace_db_call("query", "original_path_of", |_span| { + let mut conn = self.connection.lock().expect("Unable to get VersionDao"); + canonical_rel_path(&mut conn, rel_path) + .map_err(|e| anyhow::anyhow!("Query error: {}", e)) + }) + .map_err(|e| DbError::log(DbErrorKind::QueryError, e)) + } + fn version_hashes_among(&mut self, hashes: &[String]) -> Result, DbError> { trace_db_call("query", "version_hashes_among", |_span| { #[derive(QueryableByName)] @@ -912,10 +928,7 @@ mod tests { ); // Original deleted: the version shows up everywhere. exec(&c, "DELETE FROM image_exif WHERE rel_path = 'a/orig.jpg'"); - assert_eq!( - visible(&[]), - vec!["a/orig.jpg.restored.png", "b/other.jpg"] - ); + assert_eq!(visible(&[]), vec!["a/orig.jpg.restored.png", "b/other.jpg"]); } #[test] diff --git a/src/duplicates.rs b/src/duplicates.rs index 21f687f..69e17ec 100644 --- a/src/duplicates.rs +++ b/src/duplicates.rs @@ -65,6 +65,13 @@ lazy_static! { static ref FOLDER_PAIR_CACHE: Mutex> = Mutex::new(None); } +/// Drop both caches. Called when a version link appears: the version +/// leaves the duplicate groups, so cached clusters that include it are stale. +pub fn invalidate_caches() { + invalidate_perceptual_cache(); + invalidate_folder_pair_cache(); +} + /// Drop the perceptual-cluster cache. Called from `resolve`/`unresolve` /// so the next modal open reflects the soft-mark change immediately. fn invalidate_perceptual_cache() { diff --git a/src/faces.rs b/src/faces.rs index edbb201..0f912f3 100644 --- a/src/faces.rs +++ b/src/faces.rs @@ -569,12 +569,16 @@ impl FaceDao for SqliteFaceDao { // be filtered client-side and re-pulled every tick forever // because no marker row is written for excluded paths. let ext_predicate = image_path_predicate("rel_path"); + // Photo versions (#191) are never scanned: invented detail + // must not seed person clusters. + let not_version = crate::database::version_dao::not_a_version_clause_for("e"); let sql = format!( "SELECT rel_path, content_hash \ FROM image_exif e \ WHERE library_id = ? \ AND content_hash IS NOT NULL \ AND {ext_predicate} \ + AND {not_version} \ AND NOT EXISTS ( \ SELECT 1 FROM face_detections f \ WHERE f.content_hash = e.content_hash \ @@ -928,7 +932,11 @@ impl FaceDao for SqliteFaceDao { // hash can have many rows (multiple detected faces) but we // want it counted once. let scanned: i64 = { - let mut q = face_detections::table.into_boxed(); + let mut q = face_detections::table + .filter(diesel::dsl::sql::( + "content_hash NOT IN (SELECT version_hash FROM photo_versions)", + )) + .into_boxed(); if let Some(lib) = library_id { q = q.filter(face_detections::library_id.eq(lib)); } @@ -944,6 +952,9 @@ impl FaceDao for SqliteFaceDao { let with_faces: i64 = { let mut q = face_detections::table .filter(face_detections::status.eq("detected")) + .filter(diesel::dsl::sql::( + "content_hash NOT IN (SELECT version_hash FROM photo_versions)", + )) .into_boxed(); if let Some(lib) = library_id { q = q.filter(face_detections::library_id.eq(lib)); @@ -960,6 +971,9 @@ impl FaceDao for SqliteFaceDao { let no_faces: i64 = { let mut q = face_detections::table .filter(face_detections::status.eq("no_faces")) + .filter(diesel::dsl::sql::( + "content_hash NOT IN (SELECT version_hash FROM photo_versions)", + )) .into_boxed(); if let Some(lib) = library_id { q = q.filter(face_detections::library_id.eq(lib)); @@ -976,6 +990,9 @@ impl FaceDao for SqliteFaceDao { let failed: i64 = { let mut q = face_detections::table .filter(face_detections::status.eq("failed")) + .filter(diesel::dsl::sql::( + "content_hash NOT IN (SELECT version_hash FROM photo_versions)", + )) .into_boxed(); if let Some(lib) = library_id { q = q.filter(face_detections::library_id.eq(lib)); @@ -1006,10 +1023,14 @@ impl FaceDao for SqliteFaceDao { // below 100% for the duration of that backfill. let total_photos: i64 = { let ext_predicate = image_path_predicate("rel_path"); + // Versions are never face-scanned, so they stay out of the + // denominator (and the numerators above). + let not_version = crate::database::version_dao::NOT_A_VERSION_CLAUSE; let row: CountRow = if let Some(lib) = library_id { let sql = format!( "SELECT COUNT(DISTINCT content_hash) AS count FROM image_exif \ - WHERE library_id = ? AND content_hash IS NOT NULL AND {ext_predicate}" + WHERE library_id = ? AND content_hash IS NOT NULL AND {ext_predicate} \ + AND {not_version}" ); diesel::sql_query(sql) .bind::(lib) @@ -1018,7 +1039,8 @@ impl FaceDao for SqliteFaceDao { } else { let sql = format!( "SELECT COUNT(DISTINCT content_hash) AS count FROM image_exif \ - WHERE content_hash IS NOT NULL AND {ext_predicate}" + WHERE content_hash IS NOT NULL AND {ext_predicate} \ + AND {not_version}" ); diesel::sql_query(sql) .get_result(conn.deref_mut()) diff --git a/src/files.rs b/src/files.rs index 7a01921..fcd398b 100644 --- a/src/files.rs +++ b/src/files.rs @@ -342,6 +342,9 @@ pub async fn list_photos( 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( }; 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( }) .collect::>() }) + .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( 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( 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( } } + // 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, Vec) = 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( 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, +) -> std::collections::HashSet<(i32, String)> { + let view: Vec = 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>, Vec) { + let summaries = crate::versions::load_summaries(&app_state.version_dao); + if summaries.is_empty() { + return (Vec::new(), Vec::new()); + } + let mut display: Vec> = Vec::with_capacity(photos.len()); + let mut counts: Vec = 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, file_libraries: Vec, @@ -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 = 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, + _include_versions: bool, ) -> Result, DbError> { Ok(Vec::new()) } @@ -1748,6 +1846,7 @@ mod tests { _conv_hi: i64, _unconv_lo: i64, _unconv_hi: i64, + _view_library_ids: &[i32], ) -> Result, DbError> { Ok(Vec::new()) } @@ -1794,6 +1893,7 @@ mod tests { _library_ids: &[i32], _path_prefix: Option<&str>, _include_duplicates: bool, + _include_versions: bool, ) -> Result, DbError> { Ok(vec![]) } diff --git a/src/handlers/favorites.rs b/src/handlers/favorites.rs index bee057f..48a73bd 100644 --- a/src/handlers/favorites.rs +++ b/src/handlers/favorites.rs @@ -44,6 +44,8 @@ pub async fn favorites( photos: favorites, dirs: Vec::new(), photo_libraries: Vec::new(), + photo_display_paths: Vec::new(), + photo_version_counts: Vec::new(), total_count: None, has_more: None, next_offset: None, diff --git a/src/lib.rs b/src/lib.rs index d9c5048..e7f5010 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -46,6 +46,7 @@ pub mod testhelpers; pub mod thumbnails; pub mod unified_search; pub mod utils; +pub mod versions; pub mod video; pub mod watcher; diff --git a/src/library_maintenance.rs b/src/library_maintenance.rs index f06af04..bb4a34a 100644 --- a/src/library_maintenance.rs +++ b/src/library_maintenance.rs @@ -282,6 +282,37 @@ pub fn refresh_back_refs(conn: &mut SqliteConnection) -> usize { }); total += updated; + // photo_versions — (library_id, rel_path) of the version file; same + // repoint as photo_insights. + let updated = sql_query( + "UPDATE photo_versions \ + SET library_id = ( \ + SELECT ie.library_id FROM image_exif ie \ + WHERE ie.content_hash = photo_versions.version_hash \ + ORDER BY ie.id LIMIT 1 \ + ), \ + rel_path = ( \ + SELECT ie.rel_path FROM image_exif ie \ + WHERE ie.content_hash = photo_versions.version_hash \ + ORDER BY ie.id LIMIT 1 \ + ) \ + WHERE EXISTS ( \ + SELECT 1 FROM image_exif ie \ + WHERE ie.content_hash = photo_versions.version_hash \ + ) \ + AND NOT EXISTS ( \ + SELECT 1 FROM image_exif ie \ + WHERE ie.library_id = photo_versions.library_id \ + AND ie.rel_path = photo_versions.rel_path \ + )", + ) + .execute(conn) + .unwrap_or_else(|e| { + warn!("back-ref refresh: photo_versions update failed: {:?}", e); + 0 + }); + total += updated; + if total > 0 { info!("back-ref refresh: updated {} hash-keyed row(s)", total); } @@ -299,6 +330,8 @@ pub struct GcStats { pub deleted_face_detections: usize, pub deleted_tagged_photo: usize, pub deleted_photo_insights: usize, + /// Version links (and display choices) whose version file is gone. + pub deleted_photo_versions: usize, /// Hashes dropped from the pending set because they re-appeared /// in image_exif (e.g. user remounted a backup that was briefly /// missing). @@ -311,12 +344,16 @@ impl GcStats { || self.deleted_face_detections > 0 || self.deleted_tagged_photo > 0 || self.deleted_photo_insights > 0 + || self.deleted_photo_versions > 0 || self.revived > 0 } #[allow(dead_code)] pub fn total_deleted(&self) -> usize { - self.deleted_face_detections + self.deleted_tagged_photo + self.deleted_photo_insights + self.deleted_face_detections + + self.deleted_tagged_photo + + self.deleted_photo_insights + + self.deleted_photo_versions } } @@ -433,10 +470,11 @@ pub fn run_orphan_gc( if !to_delete.is_empty() { match delete_hash_keyed_rows(conn, &to_delete) { - Ok((faces, tags, insights)) => { + Ok((faces, tags, insights, versions)) => { stats.deleted_face_detections = faces; stats.deleted_tagged_photo = tags; stats.deleted_photo_insights = insights; + stats.deleted_photo_versions = versions; // Drop deleted hashes from pending so we don't try to // re-delete them next tick (they'll have already been // removed from the orphan set). @@ -452,12 +490,13 @@ pub fn run_orphan_gc( if stats.changed() { info!( - "orphan-gc: {} new orphan hash(es) marked, {} revived; deleted {} face_detections / {} tagged_photo / {} photo_insights row(s) (pending: {})", + "orphan-gc: {} new orphan hash(es) marked, {} revived; deleted {} face_detections / {} tagged_photo / {} photo_insights / {} photo_versions row(s) (pending: {})", stats.newly_marked, stats.revived, stats.deleted_face_detections, stats.deleted_tagged_photo, stats.deleted_photo_insights, + stats.deleted_photo_versions, state.pending.len(), ); } else { @@ -503,6 +542,12 @@ fn collect_orphan_hashes(conn: &mut SqliteConnection) -> QueryResult QueryResult QueryResult<(usize, usize, usize)> { +) -> QueryResult<(usize, usize, usize, usize)> { if hashes.is_empty() { - return Ok((0, 0, 0)); + return Ok((0, 0, 0, 0)); } use crate::database::schema::{face_detections, photo_insights, tagged_photo}; @@ -535,7 +586,27 @@ fn delete_hash_keyed_rows( diesel::delete(photo_insights::table.filter(photo_insights::content_hash.eq_any(hashes))) .execute(conn)?; - Ok((faces, tags, insights)) + use crate::database::schema::{ + photo_version_display, photo_version_references, photo_versions, + }; + let versions = + diesel::delete(photo_versions::table.filter(photo_versions::version_hash.eq_any(hashes))) + .execute(conn)?; + diesel::delete( + photo_version_references::table + .filter(photo_version_references::version_hash.eq_any(hashes)), + ) + .execute(conn)?; + let displays = diesel::delete( + photo_version_display::table.filter( + photo_version_display::display_hash + .eq_any(hashes) + .or(photo_version_display::original_hash.eq_any(hashes)), + ), + ) + .execute(conn)?; + + Ok((faces, tags, insights, versions + displays)) } #[cfg(test)] diff --git a/src/main.rs b/src/main.rs index fae1696..e8f3de4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -326,6 +326,8 @@ fn main() -> std::io::Result<()> { .service(handlers::favorites::favorites) .service(handlers::favorites::put_add_favorite) .service(handlers::favorites::delete_favorite) + .service(versions::get_versions) + .service(versions::put_display) .service(handlers::image::get_file_metadata) .service(handlers::image::set_image_gps) .service(handlers::image::set_image_date) diff --git a/src/memories.rs b/src/memories.rs index e1c4875..496c299 100644 --- a/src/memories.rs +++ b/src/memories.rs @@ -172,6 +172,10 @@ pub struct MemoryItem { /// the camera's clock, which can differ from the owner's day that /// `created` groups under. Render verbatim (ADR-0001). pub wall_clock: Option, + /// `display_path` / `version_count` (#191); absent for a photo without + /// versions. The item's `path` is always the original's. + #[serde(flatten)] + pub versions: crate::versions::VersionFields, } #[derive(Debug, Serialize)] @@ -652,6 +656,11 @@ pub fn gather_memory_items( // memory in the client's tz, used as the primary sort key. let mut memories_with_dates: Vec<(MemoryItem, NaiveDate)> = Vec::new(); + // A photo version is left out of the memories while its original is + // reachable in this view (the selected library, else every library). + let view_library_ids: Vec = library.map(|l| vec![l.id]).unwrap_or_default(); + let version_summaries = crate::versions::load_summaries(&app_state.version_dao); + for lib in &libraries_to_scan { let base = Path::new(&lib.root_path); let effective = lib.effective_excluded_dirs(&app_state.excluded_dirs); @@ -659,7 +668,14 @@ pub fn gather_memory_items( let rows = match exif_dao.lock() { Ok(mut dao) => { - match dao.get_memories_in_window(lib.id, conv_lo, conv_hi, unconv_lo, unconv_hi) { + match dao.get_memories_in_window( + lib.id, + conv_lo, + conv_hi, + unconv_lo, + unconv_hi, + &view_library_ids, + ) { Ok(rows) => rows, Err(e) => { warn!( @@ -697,6 +713,9 @@ pub fn gather_memory_items( let created = crate::date_view::row_true_instant(&row, display_zone, server_zone); let wall_clock = crate::date_view::row_wall_clock(&row, display_zone, server_zone); + let versions = crate::versions::VersionFields::from_summary( + version_summaries.get(&(lib.id, row.file_path.clone())), + ); memories_with_dates.push(( MemoryItem { path: row.file_path, @@ -707,6 +726,7 @@ pub fn gather_memory_items( camera_offset_minutes: row.camera_offset_minutes, date_taken_tz_source: row.date_taken_tz_source, wall_clock, + versions, }, display_day, )); @@ -1228,6 +1248,7 @@ mod tests { camera_offset_minutes: None, date_taken_tz_source: None, wall_clock: None, + versions: Default::default(), } } @@ -1746,6 +1767,7 @@ mod tests { camera_offset_minutes: None, date_taken_tz_source: None, wall_clock: None, + versions: Default::default(), }; let v: serde_json::Value = serde_json::to_value(&MemoriesResponse { items: vec![item] }).unwrap(); diff --git a/src/reels/selector.rs b/src/reels/selector.rs index dbbb7b9..b89d9a4 100644 --- a/src/reels/selector.rs +++ b/src/reels/selector.rs @@ -433,6 +433,7 @@ mod tests { camera_offset_minutes: None, date_taken_tz_source: None, wall_clock: None, + versions: Default::default(), }, memories::MemoryItem { path: "b".into(), @@ -443,6 +444,7 @@ mod tests { camera_offset_minutes: None, date_taken_tz_source: None, wall_clock: None, + versions: Default::default(), }, memories::MemoryItem { path: "c".into(), @@ -453,6 +455,7 @@ mod tests { camera_offset_minutes: None, date_taken_tz_source: None, wall_clock: None, + versions: Default::default(), }, ]; assert_eq!( @@ -472,6 +475,7 @@ mod tests { camera_offset_minutes: None, date_taken_tz_source: None, wall_clock: None, + versions: Default::default(), } } fn item_at(ts: i64, name: &str) -> memories::MemoryItem { diff --git a/src/state.rs b/src/state.rs index 3bb1518..21a8046 100644 --- a/src/state.rs +++ b/src/state.rs @@ -13,7 +13,7 @@ use crate::database::{ LocationHistoryDao, PrecomputedReelDao, SearchHistoryDao, SqliteCalendarEventDao, SqliteDailySummaryDao, SqliteExifDao, SqliteInsightDao, SqliteInsightGenerationJobDao, SqliteKnowledgeDao, SqliteLocationHistoryDao, SqlitePrecomputedReelDao, SqliteSearchHistoryDao, - SqliteUserAiPrefsDao, UserAiPrefsDao, connect, + SqliteUserAiPrefsDao, SqliteVersionDao, UserAiPrefsDao, VersionDao, connect, }; use crate::database::{PreviewDao, SqlitePreviewDao}; use crate::faces; @@ -98,6 +98,10 @@ pub struct AppState { /// client; read by the nightly pre-generation scheduler. #[allow(dead_code)] pub user_ai_prefs_dao: Arc>>, + /// Photo-version links and the global display choice (#191). Read by + /// the listings that annotate a photo with its versions and by the + /// `/image/versions` endpoints. + pub version_dao: Arc>>, } impl AppState { @@ -142,6 +146,7 @@ impl AppState { insight_job_handles: Arc>>, precomputed_reel_dao: Arc>>, user_ai_prefs_dao: Arc>>, + version_dao: Arc>>, ) -> Self { assert!( !libraries_vec.is_empty(), @@ -202,6 +207,7 @@ impl AppState { insight_job_handles, precomputed_reel_dao, user_ai_prefs_dao, + version_dao, } } @@ -290,6 +296,9 @@ impl Default for AppState { let user_ai_prefs_dao: Arc>> = Arc::new(Mutex::new(Box::new(SqliteUserAiPrefsDao::new()))); + let version_dao: Arc>> = + Arc::new(Mutex::new(Box::new(SqliteVersionDao::new()))); + // Load base path and ensure the primary library row reflects it. let base_path = env::var("BASE_PATH").expect("BASE_PATH was not set in the env"); let mut seed_conn = connect(); @@ -374,6 +383,7 @@ impl Default for AppState { insight_job_handles, precomputed_reel_dao, user_ai_prefs_dao, + version_dao, ) } } @@ -552,6 +562,7 @@ impl AppState { Arc::new(Mutex::new(HashMap::new())), // placeholder for test Arc::new(Mutex::new(Box::new(SqlitePrecomputedReelDao::new()))), // placeholder for test Arc::new(Mutex::new(Box::new(SqliteUserAiPrefsDao::new()))), // placeholder for test + Arc::new(Mutex::new(Box::new(SqliteVersionDao::new()))), // placeholder for test ) } } diff --git a/src/tags.rs b/src/tags.rs index b284d8d..a517bcc 100644 --- a/src/tags.rs +++ b/src/tags.rs @@ -604,6 +604,16 @@ pub trait TagDao: Send + Sync { fn delete_tag(&mut self, id: i32) -> anyhow::Result; } +/// The path a tag read/write on `path` applies to: the original's when +/// `path` is a linked photo version whose original is still indexed, else +/// `path` itself. +fn canonical_path(conn: &mut SqliteConnection, path: &str) -> String { + crate::database::version_dao::canonical_rel_path(conn, path) + .ok() + .flatten() + .unwrap_or_else(|| path.to_string()) +} + pub struct SqliteTagDao { connection: Arc>, } @@ -670,6 +680,8 @@ impl TagDao for SqliteTagDao { span.set_attribute("path", path.to_string()); debug!("Getting Tags for path: {:?}", path); + // A photo version carries its original's tags. + let path = &canonical_path(conn.deref_mut(), path); tags::table .left_join(tagged_photo::table) .filter(tagged_photo::rel_path.eq(&path)) @@ -861,6 +873,7 @@ impl TagDao for SqliteTagDao { span.set_attribute("tag_name", tag_name.to_string()); span.set_attribute("path", path.to_string()); + let path = &canonical_path(conn.deref_mut(), path); tags::table .filter(tags::name.eq(tag_name)) .get_result::(conn.deref_mut()) @@ -893,6 +906,10 @@ impl TagDao for SqliteTagDao { span.set_attribute("path", path.to_string()); span.set_attribute("tag_id", tag_id.to_string()); + // Tagging a photo version tags its original (photo versions, + // #191): the tag binds to the original's bytes and path. + let path = &canonical_path(conn.deref_mut(), path); + // Eagerly populate content_hash so this tag follows the bytes, // not the path (see CLAUDE.md "Multi-library data model"). // None is fine — the reconciliation pass will backfill once diff --git a/src/thumbnails.rs b/src/thumbnails.rs index dcec895..b75d6f1 100644 --- a/src/thumbnails.rs +++ b/src/thumbnails.rs @@ -343,9 +343,36 @@ pub fn create_thumbnails(libs: &[libraries::Library], excluded_dirs: &[String]) } pub fn update_media_counts(media_dir: &Path, excluded_dirs: &[String]) { + update_media_counts_excluding(media_dir, excluded_dirs, &std::collections::HashSet::new()); +} + +/// As [`update_media_counts`], leaving out the files in `hidden` (rel paths +/// under `media_dir`): photo versions (#191) are the same photo as their +/// original, so the gauges count it once. +pub fn update_media_counts_excluding( + media_dir: &Path, + excluded_dirs: &[String], + hidden: &std::collections::HashSet, +) { + let (image_count, video_count) = count_media(media_dir, excluded_dirs, hidden); + IMAGE_GAUGE.set(image_count); + VIDEO_GAUGE.set(video_count); +} + +fn count_media( + media_dir: &Path, + excluded_dirs: &[String], + hidden: &std::collections::HashSet, +) -> (i64, i64) { let mut image_count = 0; let mut video_count = 0; for entry in crate::file_scan::walk_library_files(media_dir, excluded_dirs) { + if !hidden.is_empty() + && let Ok(rel) = entry.path().strip_prefix(media_dir) + && hidden.contains(&rel.to_string_lossy().replace('\\', "/")) + { + continue; + } if is_image(&entry) { image_count += 1; } else if is_video(&entry) { @@ -353,8 +380,7 @@ pub fn update_media_counts(media_dir: &Path, excluded_dirs: &[String]) { } } - IMAGE_GAUGE.set(image_count); - VIDEO_GAUGE.set(video_count); + (image_count, video_count) } pub fn is_image(entry: &DirEntry) -> bool { @@ -395,6 +421,21 @@ mod tests { assert_eq!(s, PathBuf::from("/thumbs/notes.unsupported")); } + #[test] + fn update_media_counts_leaves_out_hidden_versions() { + let tmp = TempDir::new().expect("tempdir"); + fs::create_dir(tmp.path().join("aiedits")).unwrap(); + fs::write(tmp.path().join("a.jpg"), b"").unwrap(); + fs::write(tmp.path().join("aiedits/a.jpg.restored.png"), b"").unwrap(); + let hidden = std::collections::HashSet::from(["aiedits/a.jpg.restored.png".to_string()]); + assert_eq!(count_media(tmp.path(), &[], &hidden), (1, 0)); + assert_eq!( + count_media(tmp.path(), &[], &Default::default()), + (2, 0), + "nothing hidden: both files count" + ); + } + /// Smoke-test update_media_counts: build a tempdir with two images /// and one video, run the walker, and assert the gauges line up. /// Exercises the is_image / is_video classifier on real DirEntry diff --git a/src/unified_search.rs b/src/unified_search.rs index a9346d3..042d546 100644 --- a/src/unified_search.rs +++ b/src/unified_search.rs @@ -459,12 +459,21 @@ pub async fn unified_search( 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( 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( ); 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( let results: Vec = 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( 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() }; diff --git a/src/versions/handlers.rs b/src/versions/handlers.rs new file mode 100644 index 0000000..acdb25c --- /dev/null +++ b/src/versions/handlers.rs @@ -0,0 +1,252 @@ +//! `GET /image/versions` and `PUT /image/versions/display`. +//! +//! Both take the path of the original *or* of any version, and answer about +//! the whole set. The display choice is global (not per user). + +use std::sync::Mutex; + +use actix_web::{HttpResponse, Responder, get, put, web}; +use log::warn; +use serde::{Deserialize, Serialize}; + +use crate::data::Claims; +use crate::database::ExifDao; +use crate::database::version_dao::VersionDao; +use crate::libraries; +use crate::state::AppState; +use crate::utils::normalize_path; + +#[derive(Debug, Deserialize)] +pub struct VersionsQuery { + pub path: String, + pub library: Option, +} + +#[derive(Debug, Deserialize)] +pub struct SetDisplayBody { + pub path: String, + /// Content hash of the version to display; `null` / absent = the original. + #[serde(default)] + pub display_hash: Option, + pub library: Option, +} + +#[derive(Debug, Serialize, PartialEq)] +pub struct FileRef { + pub path: String, + pub library_id: i32, + pub hash: String, +} + +#[derive(Debug, Serialize, PartialEq)] +pub struct ReferenceEntry { + pub hash: String, + /// Where the reference photo lives, if it has been ingested. + pub path: Option, + pub library_id: Option, +} + +#[derive(Debug, Serialize, PartialEq)] +pub struct VersionEntry { + pub path: String, + pub library_id: i32, + pub hash: String, + pub kind: String, + pub source: String, + pub created_at: i64, + pub references: Vec, + pub width: Option, + pub height: Option, +} + +#[derive(Debug, Serialize, PartialEq)] +pub struct VersionsResponse { + /// `null` when the original's file is gone (its versions then show up as + /// standalone photos). + pub original: Option, + pub versions: Vec, + /// The globally displayed version; `null` = the original. + pub display_hash: Option, +} + +/// The photo a request path belongs to. +struct Resolved { + original_hash: String, + /// Library of the row the path named; preferred when picking files. + library_id: i32, +} + +fn resolve( + app_state: &AppState, + exif_dao: &Mutex>, + path: &str, + library: Option<&str>, +) -> Result { + let library_id = match libraries::resolve_library_param_state(app_state, library) { + Ok(lib) => lib.map(|l| l.id), + Err(msg) => return Err(HttpResponse::BadRequest().body(msg)), + }; + let path = normalize_path(path); + let mut rows = exif_dao + .lock() + .expect("Unable to lock ExifDao") + .get_exif_batch(library_id, std::slice::from_ref(&path)) + .map_err(|_| HttpResponse::InternalServerError().finish())?; + // Without `library`, a path can name rows in several libraries. Take the + // earliest hashed one; it then resolves to its original below, so a + // version in one library and its original in another agree. + rows.sort_by_key(|r| r.id); + let Some((row, hash)) = rows + .iter() + .find_map(|r| r.content_hash.clone().map(|h| (r, h))) + else { + return Err(HttpResponse::NotFound().body("no indexed file at that path")); + }; + let link = app_state + .version_dao + .lock() + .expect("Unable to lock VersionDao") + .get_link(&hash) + .map_err(|_| HttpResponse::InternalServerError().finish())?; + Ok(Resolved { + original_hash: link.map(|l| l.original_hash).unwrap_or(hash), + library_id: row.library_id, + }) +} + +/// Everything about the set around `original_hash`. Reads fall back to the +/// original when the stored display choice has no live row. +pub fn build_response( + dao: &mut dyn VersionDao, + original_hash: &str, + prefer_library: i32, +) -> Result { + let original = dao + .files_for_hashes(&[original_hash.to_string()], Some(prefer_library))? + .remove(original_hash) + .map(|(library_id, path)| FileRef { + path, + library_id, + hash: original_hash.to_string(), + }); + + let files = dao.list_versions(original_hash)?; + let mut versions = Vec::with_capacity(files.len()); + for f in files { + let reference_hashes = dao.list_references(&f.version_hash)?; + let located = dao.files_for_hashes(&reference_hashes, Some(f.library_id))?; + let references = reference_hashes + .into_iter() + .map(|hash| { + let found = located.get(&hash); + ReferenceEntry { + path: found.map(|(_, p)| p.clone()), + library_id: found.map(|(l, _)| *l), + hash, + } + }) + .collect(); + versions.push(VersionEntry { + path: f.rel_path, + library_id: f.library_id, + hash: f.version_hash, + kind: f.kind, + source: f.source, + created_at: f.created_at, + references, + width: f.width, + height: f.height, + }); + } + + let display_hash = dao + .get_display(original_hash)? + .filter(|d| versions.iter().any(|v| &v.hash == d)); + Ok(VersionsResponse { + original, + versions, + display_hash, + }) +} + +#[get("/image/versions")] +#[tracing::instrument(name = "get_versions", skip_all)] +pub async fn get_versions( + _: Claims, + q: web::Query, + app_state: web::Data, + exif_dao: web::Data>>, +) -> impl Responder { + let resolved = match resolve(&app_state, &exif_dao, &q.path, q.library.as_deref()) { + Ok(r) => r, + Err(resp) => return resp, + }; + let mut dao = app_state + .version_dao + .lock() + .expect("Unable to lock VersionDao"); + match build_response(dao.as_mut(), &resolved.original_hash, resolved.library_id) { + Ok(body) => HttpResponse::Ok().json(body), + Err(e) => { + warn!("get_versions failed: {:?}", e); + HttpResponse::InternalServerError().finish() + } + } +} + +#[put("/image/versions/display")] +#[tracing::instrument(name = "put_version_display", skip_all)] +pub async fn put_display( + _: Claims, + // The client also sends `library` as a query parameter, like its other + // path-keyed calls; the body field wins when both are given. + query: web::Query, + body: web::Json, + app_state: web::Data, + exif_dao: web::Data>>, +) -> impl Responder { + let library = body.library.as_deref().or(query.library.as_deref()); + let resolved = match resolve(&app_state, &exif_dao, &body.path, library) { + Ok(r) => r, + Err(resp) => return resp, + }; + let mut dao = app_state + .version_dao + .lock() + .expect("Unable to lock VersionDao"); + + // Choosing the original is the same as clearing the choice. + let wanted = body + .display_hash + .as_deref() + .filter(|h| *h != resolved.original_hash); + if let Some(hash) = wanted { + let current = + match build_response(dao.as_mut(), &resolved.original_hash, resolved.library_id) { + Ok(r) => r, + Err(e) => { + warn!("put_display lookup failed: {:?}", e); + return HttpResponse::InternalServerError().finish(); + } + }; + if !current.versions.iter().any(|v| v.hash == hash) { + return HttpResponse::BadRequest().body("display_hash is not a version of this photo"); + } + } + if let Err(e) = dao.set_display(&resolved.original_hash, wanted) { + warn!("put_display failed: {:?}", e); + return HttpResponse::InternalServerError().finish(); + } + match build_response(dao.as_mut(), &resolved.original_hash, resolved.library_id) { + Ok(body) => HttpResponse::Ok().json(body), + Err(e) => { + warn!("put_display response failed: {:?}", e); + HttpResponse::InternalServerError().finish() + } + } +} + +#[derive(Debug, Deserialize)] +pub struct LibraryQuery { + pub library: Option, +} diff --git a/src/versions/mod.rs b/src/versions/mod.rs new file mode 100644 index 0000000..efbd9bd --- /dev/null +++ b/src/versions/mod.rs @@ -0,0 +1,384 @@ +//! Photo versions (#191): AI restorations / upscales / edits that sit near +//! their original and are shown as one photo. +//! +//! ImageApi only *detects* versions; an external process (the ComfyUI node +//! in `comfyui-imageapi-versions`) writes them into the library, so the +//! read-only rule for library roots holds. This module owns +//! +//! - the link rules at ingest ([`link_new_files`], [`sweep_name_links`]): +//! the PNG's XMP `DerivedFrom` hash first, then the +//! `.[.].` file-name convention; +//! - the HTTP surface (`GET /image/versions`, `PUT /image/versions/display`). +//! +//! The data model lives in [`crate::database::version_dao`]. The link is the +//! original's `content_hash`, never a path: ComfyUI writes through a UNC +//! share and ImageApi reads `/media/...`, and files get moved. + +pub mod handlers; +pub mod xmp; + +use std::path::{Path, PathBuf}; +use std::sync::{Arc, LazyLock, Mutex}; + +use log::{debug, info, warn}; +use regex::Regex; + +use crate::database::version_dao::{ + KINDS, LinkOutcome, NewVersionLink, SOURCE_NAME, SOURCE_XMP, VersionDao, is_known_kind, +}; +use crate::database::{ExifDao, version_dao}; +use crate::libraries::Library; + +pub use handlers::{get_versions, put_display}; + +/// Directory the producer saves versions into, next to the original. +pub const VERSIONS_SUBFOLDER: &str = "aiedits"; + +/// Cap on name-convention candidates examined per library per tick. +const NAME_SWEEP_LIMIT: i64 = 500; + +static VERSION_NAME: LazyLock = LazyLock::new(|| { + let kinds = KINDS.join("|"); + Regex::new(&format!( + r"(?i)^(?P.+)\.(?P{kinds})(?:\.(?P\d+))?\.(?P[a-z0-9]+)$" + )) + .unwrap() +}); + +/// A file name that follows the producer's `.[.].`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VersionName { + /// The original's full file name, extension included. + pub original_name: String, + pub kind: &'static str, +} + +/// Parse a bare file name (no directory). The original's extension stays in +/// `original_name`, so `IMG_1.NEF` and `IMG_1.JPG` are told apart. +pub fn parse_version_name(file_name: &str) -> Option { + let caps = VERSION_NAME.captures(file_name)?; + let kind_text = caps["kind"].to_ascii_lowercase(); + let kind = KINDS.iter().find(|k| **k == kind_text)?; + Some(VersionName { + original_name: caps["name"].to_string(), + kind, + }) +} + +/// Rel paths where the original of a name-convention file may sit, best +/// first: the same directory, then the parent of an `aiedits/` directory. +pub fn original_candidates(rel_path: &str) -> Vec { + let (dir, file_name) = match rel_path.rsplit_once('/') { + Some((d, f)) => (d, f), + None => ("", rel_path), + }; + let Some(parsed) = parse_version_name(file_name) else { + return Vec::new(); + }; + let join = |d: &str| { + if d.is_empty() { + parsed.original_name.clone() + } else { + format!("{d}/{}", parsed.original_name) + } + }; + let mut out = vec![join(dir)]; + let (parent, leaf) = match dir.rsplit_once('/') { + Some((p, l)) => (p, l), + None => ("", dir), + }; + if leaf.eq_ignore_ascii_case(VERSIONS_SUBFOLDER) { + out.push(join(parent)); + } + out +} + +/// Resolve the name-convention link of `rel_path` (in `library`) against the +/// `image_exif` rows of that library. `None` when the name doesn't follow the +/// convention or no candidate original has a hash yet (the sweep retries). +pub fn name_link( + library: &Library, + rel_path: &str, + version_hash: &str, + exif_dao: &Arc>>, +) -> Option { + let file_name = rel_path.rsplit('/').next().unwrap_or(rel_path); + let kind = parse_version_name(file_name)?.kind; + let candidates = original_candidates(rel_path); + let rows = exif_dao + .lock() + .expect("Unable to lock ExifDao") + .get_exif_batch(Some(library.id), &candidates) + .ok()?; + // First candidate (in preference order) that has a hash of its own. + let original_hash = candidates.iter().find_map(|c| { + rows.iter() + .find(|r| &r.file_path == c) + .and_then(|r| r.content_hash.clone()) + .filter(|h| h != version_hash) + })?; + Some(NewVersionLink { + version_hash: version_hash.to_string(), + original_hash, + kind: kind.to_string(), + source: SOURCE_NAME.to_string(), + library_id: library.id, + rel_path: rel_path.to_string(), + references: Vec::new(), + }) +} + +/// Resolve the XMP link of a PNG on disk. `None` when the file isn't a PNG, +/// can't be read, or carries no `DerivedFrom` blake3 id. +pub fn xmp_link( + library: &Library, + rel_path: &str, + abs_path: &Path, + version_hash: &str, +) -> Option { + let is_png = abs_path + .extension() + .is_some_and(|e| e.eq_ignore_ascii_case("png")); + if !is_png { + return None; + } + let parsed = match xmp::read_version_xmp(abs_path) { + Ok(p) => p?, + Err(e) => { + debug!( + "versions: could not read XMP of {}: {}", + abs_path.display(), + e + ); + return None; + } + }; + let file_name = rel_path.rsplit('/').next().unwrap_or(rel_path); + let kind = parsed + .kind + .filter(|k| is_known_kind(k)) + .or_else(|| parse_version_name(file_name).map(|n| n.kind.to_string())) + .unwrap_or_else(|| "edit".to_string()); + Some(NewVersionLink { + version_hash: version_hash.to_string(), + original_hash: parsed.original_hash, + kind, + source: SOURCE_XMP.to_string(), + library_id: library.id, + rel_path: rel_path.to_string(), + references: parsed.references, + }) +} + +/// Record a link, logging the outcome. Returns true if a new link was made. +pub fn record_link(version_dao: &Arc>>, link: &NewVersionLink) -> bool { + let outcome = version_dao + .lock() + .expect("Unable to lock VersionDao") + .link_version(link); + match outcome { + Ok(LinkOutcome::Linked) => { + info!( + "versions: linked {} -> original {}.. ({}, {})", + link.rel_path, + &link.original_hash[..12.min(link.original_hash.len())], + link.kind, + link.source + ); + true + } + Ok(LinkOutcome::AlreadyLinked) => false, + Ok(LinkOutcome::Rejected(why)) => { + warn!("versions: refused link for {}: {}", link.rel_path, why); + false + } + Err(e) => { + warn!("versions: link failed for {}: {:?}", link.rel_path, e); + false + } + } +} + +/// Link freshly ingested files by their embedded XMP. `files` are the +/// `(absolute, rel_path)` pairs the watcher just registered; rows without a +/// hash yet are skipped (an XMP-less file is the name sweep's job anyway). +/// Returns the number of new links. +pub fn link_new_files( + library: &Library, + files: &[(PathBuf, String)], + exif_dao: &Arc>>, + version_dao: &Arc>>, +) -> usize { + let pngs: Vec<&(PathBuf, String)> = files + .iter() + .filter(|(p, _)| p.extension().is_some_and(|e| e.eq_ignore_ascii_case("png"))) + .collect(); + if pngs.is_empty() { + return 0; + } + let rels: Vec = pngs.iter().map(|(_, r)| r.clone()).collect(); + let rows = exif_dao + .lock() + .expect("Unable to lock ExifDao") + .get_exif_batch(Some(library.id), &rels) + .unwrap_or_default(); + + let mut linked = 0; + for (abs, rel) in pngs { + let Some(hash) = rows + .iter() + .find(|r| &r.file_path == rel) + .and_then(|r| r.content_hash.clone()) + else { + continue; + }; + if let Some(link) = xmp_link(library, rel, abs, &hash) + && record_link(version_dao, &link) + { + linked += 1; + } + } + linked +} + +/// Link name-convention files whose original is (now) known. Idempotent and +/// DB-only, so it also serves as the retry for "original not hashed yet" and +/// "original ingested after the version". Returns the number of new links. +pub fn sweep_name_links( + library: &Library, + exif_dao: &Arc>>, + version_dao: &Arc>>, +) -> usize { + let candidates = match version_dao + .lock() + .expect("Unable to lock VersionDao") + .unlinked_name_candidates(library.id, NAME_SWEEP_LIMIT) + { + Ok(c) => c, + Err(e) => { + warn!( + "versions: name sweep failed for '{}': {:?}", + library.name, e + ); + return 0; + } + }; + let mut linked = 0; + for (rel, hash) in candidates { + if let Some(link) = name_link(library, &rel, &hash, exif_dao) + && record_link(version_dao, &link) + { + linked += 1; + } + } + linked +} + +/// Sweep names and invalidate what a new link makes stale. The watcher calls +/// this once per library per tick, after ingest. +pub fn link_pending( + library: &Library, + exif_dao: &Arc>>, + version_dao: &Arc>>, +) { + if sweep_name_links(library, exif_dao, version_dao) > 0 { + crate::duplicates::invalidate_caches(); + } +} + +/// The version facts a per-photo record (a memory, a search hit) carries. +/// Flattened into the record; absent for a photo without versions, so +/// responses stay byte-identical for libraries that have none. +/// +/// `display_path` is `null` when the original is displayed, a rel_path when a +/// version is. `/photos` carries the same facts as the parallel arrays +/// `photo_display_paths` / `photo_version_counts` instead. +#[derive(Debug, Clone, Default, serde::Serialize)] +pub struct VersionFields { + #[serde(skip_serializing_if = "Option::is_none")] + pub display_path: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub version_count: Option, +} + +impl VersionFields { + pub fn from_summary(summary: Option<&version_dao::PhotoVersionSummary>) -> Self { + match summary { + Some(s) if s.version_count > 0 => VersionFields { + display_path: Some(s.display.as_ref().map(|(_, p)| p.clone())), + version_count: Some(s.version_count), + }, + _ => VersionFields::default(), + } + } +} + +/// Load the per-photo version summaries once for a response. Empty (and +/// cheap) when nothing has versions; a DB failure degrades to "no versions" +/// rather than failing the listing. +pub fn load_summaries( + version_dao: &Mutex>, +) -> std::collections::HashMap<(i32, String), version_dao::PhotoVersionSummary> { + version_dao + .lock() + .expect("Unable to lock VersionDao") + .photo_summaries() + .unwrap_or_else(|e| { + warn!("versions: could not load summaries: {:?}", e); + Default::default() + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_the_producer_naming_convention() { + let n = parse_version_name("scan_0042.jpg.restored.png").unwrap(); + assert_eq!(n.original_name, "scan_0042.jpg"); + assert_eq!(n.kind, "restored"); + let n = parse_version_name("scan_0042.jpg.restored.2.png").unwrap(); + assert_eq!(n.original_name, "scan_0042.jpg"); + let n = parse_version_name("IMG_1.NEF.upscale.png").unwrap(); + assert_eq!(n.original_name, "IMG_1.NEF"); + assert_eq!(n.kind, "upscale"); + assert_eq!( + parse_version_name("a.b.c.EDIT.12.PNG") + .unwrap() + .original_name, + "a.b.c" + ); + } + + #[test] + fn rejects_names_outside_the_convention() { + assert!(parse_version_name("holiday.png").is_none()); + assert!(parse_version_name("restored.png").is_none()); + assert!(parse_version_name("x.jpg.blurred.png").is_none()); + assert!(parse_version_name("x.jpg.restored").is_none()); + assert!(parse_version_name("x.restored.png.bak").is_none()); + assert!(parse_version_name(".restored.png").is_none()); + } + + #[test] + fn candidates_prefer_the_same_dir_then_the_parent_of_aiedits() { + assert_eq!( + original_candidates("2004/aiedits/scan.jpg.restored.png"), + vec!["2004/aiedits/scan.jpg", "2004/scan.jpg"] + ); + assert_eq!( + original_candidates("2004/scan.jpg.restored.png"), + vec!["2004/scan.jpg"] + ); + assert_eq!( + original_candidates("aiedits/scan.jpg.edit.3.png"), + vec!["aiedits/scan.jpg", "scan.jpg"] + ); + assert_eq!( + original_candidates("scan.jpg.upscale.png"), + vec!["scan.jpg"] + ); + assert!(original_candidates("2004/plain.png").is_empty()); + } +} diff --git a/src/versions/xmp.rs b/src/versions/xmp.rs new file mode 100644 index 0000000..ce1d8c2 --- /dev/null +++ b/src/versions/xmp.rs @@ -0,0 +1,312 @@ +//! Reading the version link out of a PNG: the `XML:com.adobe.xmp` iTXt +//! chunk the ComfyUI producer writes (`comfyui-imageapi-versions`), and the +//! ComfyUI `prompt` tEXt chunk the backfill uses for files without XMP. +//! +//! The chunk stream is walked by hand: no pixel decode, and the `png` +//! crate's text API would still inflate every IDAT's neighbours for no gain. +//! IDAT payloads are seeked over, so a multi-megabyte restoration costs a +//! handful of small reads on a network share. + +use std::fs::File; +use std::io::{self, BufReader, Read, Seek, SeekFrom}; +use std::path::Path; +use std::sync::LazyLock; + +use regex::Regex; + +/// iTXt keyword exiftool, Lightroom and the producer put XMP under. +pub const XMP_KEYWORD: &str = "XML:com.adobe.xmp"; + +/// Text chunks bigger than this are skipped rather than read: ComfyUI's +/// `prompt` / `workflow` JSON is tens of KiB, an XMP packet around 1 KiB. +const MAX_TEXT_CHUNK: u32 = 4 * 1024 * 1024; + +const PNG_SIGNATURE: [u8; 8] = [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]; + +/// What a version PNG's XMP says about its provenance. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VersionXmp { + /// `xmpMM:DerivedFrom/stRef:documentID`: the original's `content_hash`. + pub original_hash: String, + /// `xmpMM:Ingredients[]/stRef:documentID`: reference photos. + pub references: Vec, + /// `imageapi:versionKind`, as written (not yet validated). + pub kind: Option, +} + +/// Value of the first text chunk (`tEXt` or uncompressed `iTXt`) whose +/// keyword is in `keywords`, for each keyword found. Compressed chunks are +/// skipped (the producer never compresses). `Ok(vec![])` for a file that +/// isn't a PNG. +pub fn read_png_text(path: &Path, keywords: &[&str]) -> io::Result> { + let mut reader = BufReader::new(File::open(path)?); + let mut sig = [0u8; 8]; + if reader.read_exact(&mut sig).is_err() || sig != PNG_SIGNATURE { + return Ok(Vec::new()); + } + let mut found: Vec<(String, String)> = Vec::new(); + loop { + let mut head = [0u8; 8]; + if reader.read_exact(&mut head).is_err() { + break; // truncated file: use what we have + } + let len = u32::from_be_bytes([head[0], head[1], head[2], head[3]]); + let kind = &head[4..8]; + let is_text = kind == b"tEXt" || kind == b"iTXt"; + if is_text && len <= MAX_TEXT_CHUNK { + let mut data = vec![0u8; len as usize]; + if reader.read_exact(&mut data).is_err() { + break; + } + let parsed = if kind == b"tEXt" { + parse_text(&data) + } else { + parse_itxt(&data) + }; + if let Some((key, value)) = parsed + && keywords.contains(&key.as_str()) + && !found.iter().any(|(k, _)| *k == key) + { + found.push((key, value)); + if found.len() == keywords.len() { + break; + } + } + reader.seek_relative(4)?; // CRC + } else { + reader.seek(SeekFrom::Current(i64::from(len) + 4))?; + } + if kind == b"IEND" { + break; + } + } + Ok(found) +} + +/// `keyword\0text` (Latin-1). +fn parse_text(data: &[u8]) -> Option<(String, String)> { + let nul = data.iter().position(|b| *b == 0)?; + let key = String::from_utf8_lossy(&data[..nul]).into_owned(); + let value: String = data[nul + 1..].iter().map(|b| *b as char).collect(); + Some((key, value)) +} + +/// `keyword\0 compressed(1) method(1) lang\0 translated\0 text` (UTF-8); +/// `None` for compressed chunks. +fn parse_itxt(data: &[u8]) -> Option<(String, String)> { + let nul = data.iter().position(|b| *b == 0)?; + let key = String::from_utf8_lossy(&data[..nul]).into_owned(); + let rest = data.get(nul + 1..)?; + if *rest.first()? != 0 { + return None; + } + let rest = rest.get(2..)?; // flag + method + let lang_end = rest.iter().position(|b| *b == 0)?; + let rest = &rest[lang_end + 1..]; + let translated_end = rest.iter().position(|b| *b == 0)?; + let text = &rest[translated_end + 1..]; + Some((key, String::from_utf8_lossy(text).into_owned())) +} + +/// The XMP packet of a PNG, if it has one. +pub fn read_png_xmp(path: &Path) -> io::Result> { + Ok(read_png_text(path, &[XMP_KEYWORD])? + .into_iter() + .next() + .map(|(_, v)| v)) +} + +static DERIVED_FROM: LazyLock = LazyLock::new(|| { + // Element form (`...`) or the + // attribute form exiftool may write (``). + Regex::new(r"(?s)]*?)(?:/>|>(.*?))").unwrap() +}); +static INGREDIENTS: LazyLock = LazyLock::new(|| { + Regex::new(r"(?s)]*>(.*?)").unwrap() +}); +static DOCUMENT_ID: LazyLock = LazyLock::new(|| { + Regex::new(r#"documentID(?:\s*=\s*["']|\s*>\s*)blake3:([0-9a-fA-F]{64})"#).unwrap() +}); +static VERSION_KIND: LazyLock = LazyLock::new(|| { + Regex::new(r#"imageapi:versionKind(?:\s*=\s*["']|\s*>\s*)([A-Za-z]+)"#).unwrap() +}); + +/// Parse the link fields out of an XMP packet. `None` when it has no +/// `DerivedFrom` blake3 id (an ordinary photo's XMP, or someone else's). +pub fn parse_version_xmp(xmp: &str) -> Option { + let derived = DERIVED_FROM.captures(xmp)?; + let region = format!( + "{} {}", + derived.get(1).map_or("", |m| m.as_str()), + derived.get(2).map_or("", |m| m.as_str()) + ); + let original_hash = DOCUMENT_ID.captures(®ion)?[1].to_ascii_lowercase(); + + let mut references: Vec = Vec::new(); + if let Some(ing) = INGREDIENTS.captures(xmp) { + for cap in DOCUMENT_ID.captures_iter(&ing[1]) { + let h = cap[1].to_ascii_lowercase(); + if !references.contains(&h) { + references.push(h); + } + } + } + let kind = VERSION_KIND + .captures(xmp) + .map(|c| c[1].to_ascii_lowercase()); + Some(VersionXmp { + original_hash, + references, + kind, + }) +} + +/// Parse the version link out of a PNG file; `None` if it has none. +pub fn read_version_xmp(path: &Path) -> io::Result> { + Ok(read_png_xmp(path)?.and_then(|x| parse_version_xmp(&x))) +} + +#[cfg(test)] +pub(crate) mod test_png { + use std::io::Write; + + fn chunk(out: &mut Vec, kind: &[u8; 4], data: &[u8]) { + out.extend_from_slice(&(data.len() as u32).to_be_bytes()); + out.extend_from_slice(kind); + out.extend_from_slice(data); + out.extend_from_slice(&[0, 0, 0, 0]); // CRC: the reader never checks it + } + + /// A PNG-shaped byte stream: signature, IHDR, optional tEXt `prompt`, + /// optional XMP iTXt, an IDAT, IEND. Not decodable; enough for the + /// chunk walker (and unique per `salt`, so each file hashes differently). + pub fn png_bytes(xmp: Option<&str>, prompt: Option<&str>, salt: u8) -> Vec { + let mut out = super::PNG_SIGNATURE.to_vec(); + chunk(&mut out, b"IHDR", &[0, 0, 0, 1, 0, 0, 0, 1, 8, 2, 0, 0, 0]); + if let Some(p) = prompt { + let mut d = b"prompt\0".to_vec(); + d.extend_from_slice(p.as_bytes()); + chunk(&mut out, b"tEXt", &d); + } + if let Some(x) = xmp { + let mut d = b"XML:com.adobe.xmp\0\0\0\0\0".to_vec(); + d.extend_from_slice(x.as_bytes()); + chunk(&mut out, b"iTXt", &d); + } + chunk(&mut out, b"IDAT", &[salt; 32]); + chunk(&mut out, b"IEND", &[]); + out + } + + pub fn write_png(path: &std::path::Path, xmp: Option<&str>, prompt: Option<&str>, salt: u8) { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir).unwrap(); + } + std::fs::File::create(path) + .unwrap() + .write_all(&png_bytes(xmp, prompt, salt)) + .unwrap(); + } + + /// The packet the producer writes, built the way `versioning.build_xmp` does. + pub fn producer_xmp(original: &str, kind: &str, references: &[&str]) -> String { + let ingredients = if references.is_empty() { + String::new() + } else { + let items: String = references + .iter() + .map(|h| { + format!( + "blake3:{h}\ + ref.jpg" + ) + }) + .collect(); + format!("{items}") + }; + format!( + "\ + \ + blake3:{original}\ + orig.jpg{ingredients}\ + {kind}" + ) + } +} + +#[cfg(test)] +mod tests { + use super::test_png::*; + use super::*; + + const ORIG: &str = "d5fa14088629f680f4f8e5815df473cc4684a97fc937da588baf72d2693dea3a"; + const REF: &str = "0000000000000000000000000000000000000000000000000000000000000001"; + + /// The packet embedded in a real archive restoration (copied verbatim). + const REAL_XMP: &str = r#"blake3:d5fa14088629f680f4f8e5815df473cc4684a97fc937da588baf72d2693dea3a019_19.JPGrestored"#; + + #[test] + fn parses_the_real_producer_packet() { + let x = parse_version_xmp(REAL_XMP).unwrap(); + assert_eq!(x.original_hash, ORIG); + assert_eq!(x.kind.as_deref(), Some("restored")); + assert!(x.references.is_empty()); + } + + #[test] + fn parses_references_and_keeps_them_apart_from_the_original() { + let x = parse_version_xmp(&producer_xmp(ORIG, "edit", &[REF])).unwrap(); + assert_eq!(x.original_hash, ORIG); + assert_eq!(x.references, vec![REF]); + assert_eq!(x.kind.as_deref(), Some("edit")); + } + + #[test] + fn accepts_the_attribute_form_other_writers_use() { + let xmp = format!( + r#""# + ); + assert_eq!(parse_version_xmp(&xmp).unwrap().original_hash, ORIG); + } + + #[test] + fn ignores_xmp_without_a_blake3_derived_from() { + assert!(parse_version_xmp("hi").is_none()); + let other = "xmp.did:1234"; + assert!(parse_version_xmp(other).is_none()); + // An ingredient alone is not a link. + let only_ing = format!( + "blake3:{REF}" + ); + assert!(parse_version_xmp(&only_ing).is_none()); + } + + #[test] + fn reads_xmp_and_prompt_chunks_from_a_png_file() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("v.png"); + write_png( + &p, + Some(&producer_xmp(ORIG, "restored", &[])), + Some(r#"{"1": {"class_type": "LoadImage"}}"#), + 1, + ); + let got = read_version_xmp(&p).unwrap().unwrap(); + assert_eq!(got.original_hash, ORIG); + let text = read_png_text(&p, &["prompt"]).unwrap(); + assert_eq!(text[0].1, r#"{"1": {"class_type": "LoadImage"}}"#); + } + + #[test] + fn plain_png_and_non_png_have_no_link() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("plain.png"); + write_png(&p, None, None, 2); + assert!(read_version_xmp(&p).unwrap().is_none()); + let j = dir.path().join("x.jpg"); + std::fs::write(&j, b"\xff\xd8\xff\xe0 not a png").unwrap(); + assert!(read_version_xmp(&j).unwrap().is_none()); + } +} diff --git a/src/watcher.rs b/src/watcher.rs index 593d299..ea25974 100644 --- a/src/watcher.rs +++ b/src/watcher.rs @@ -28,7 +28,7 @@ use crate::content_hash; use crate::database::models::InsertImageExif; use crate::database::{ ExifDao, PrecomputedReelDao, PreviewDao, SqliteExifDao, SqlitePrecomputedReelDao, - SqlitePreviewDao, SqliteUserAiPrefsDao, UserAiPrefsDao, + SqlitePreviewDao, SqliteUserAiPrefsDao, SqliteVersionDao, UserAiPrefsDao, VersionDao, }; use crate::date_resolver; use crate::exif; @@ -42,6 +42,7 @@ use crate::perceptual_hash; use crate::tags; use crate::tags::SqliteTagDao; use crate::thumbnails; +use crate::versions; use crate::video; use crate::video::actors::{ GeneratePreviewClipMessage, QueueVideosMessage, VideoPlaylistManager, VideoToQueue, @@ -329,6 +330,9 @@ pub fn watch_files( let preview_dao = Arc::new(Mutex::new( Box::new(SqlitePreviewDao::new()) as Box )); + let version_dao = Arc::new(Mutex::new( + Box::new(SqliteVersionDao::new()) as Box + )); let face_dao = Arc::new(Mutex::new( Box::new(faces::SqliteFaceDao::new()) as Box )); @@ -561,6 +565,7 @@ pub fn watch_files( Arc::clone(&preview_dao), Arc::clone(&face_dao), Arc::clone(&watcher_tag_dao), + Arc::clone(&version_dao), face_client.clone(), &effective_excludes, None, @@ -582,6 +587,7 @@ pub fn watch_files( Arc::clone(&preview_dao), Arc::clone(&face_dao), Arc::clone(&watcher_tag_dao), + Arc::clone(&version_dao), face_client.clone(), &effective_excludes, Some(check_since), @@ -590,8 +596,26 @@ pub fn watch_files( ); } - // Update media counts per library (metric aggregates across all) - thumbnails::update_media_counts(Path::new(&lib.root_path), &effective_excludes); + // Name-convention versions whose original is (now) known, + // incl. the retry for originals that weren't hashed yet. + versions::link_pending(lib, &exif_dao, &version_dao); + + // Update media counts per library (metric aggregates across all). + // A version is the same photo as its original, so it isn't counted. + let hidden_versions: HashSet = version_dao + .lock() + .expect("version_dao poisoned") + .hidden_version_rows(&[]) + .unwrap_or_default() + .into_iter() + .filter(|(lib_id, _)| *lib_id == lib.id) + .map(|(_, rel)| rel) + .collect(); + thumbnails::update_media_counts_excluding( + Path::new(&lib.root_path), + &effective_excludes, + &hidden_versions, + ); // Missing-file detection: prune image_exif rows whose // source file is no longer on disk. Per-library, so we @@ -634,6 +658,21 @@ pub fn watch_files( } let _ = crate::database::reconcile::run(&mut conn); + // Photo versions: copy the original's date / GPS / camera + // onto version rows whose copy is missing or stale (a + // manual date override, a re-resolve). DB-only, idempotent. + match version_dao + .lock() + .expect("version_dao poisoned") + .sync_version_metadata() + { + Ok(n) if n > 0 => { + info!("versions: refreshed date/GPS on {} version row(s)", n); + } + Ok(_) => {} + Err(e) => warn!("versions: metadata sync failed: {:?}", e), + } + // Back-ref refresh: hash-keyed rows whose // (library_id, rel_path) tuple no longer matches any // image_exif row but whose hash still does. After a @@ -707,6 +746,7 @@ pub fn process_new_files( preview_dao: Arc>>, face_dao: Arc>>, tag_dao: Arc>>, + version_dao: Arc>>, face_client: crate::ai::face_client::FaceClient, excluded_dirs: &[String], modified_since: Option, @@ -755,6 +795,10 @@ pub fn process_new_files( let mut new_files_found = false; let mut files_needing_row = Vec::new(); + // Files whose image_exif row was written this tick (version linking + // looks only at these; re-reading every PNG's XMP on each full scan + // would be wasted I/O on a network share). + let mut newly_registered: Vec<(PathBuf, String)> = Vec::new(); // Register every image/video file in image_exif. Rows without EXIF // still carry library_id, rel_path, content_hash, and size_bytes so @@ -796,6 +840,7 @@ pub fn process_new_files( ); for (file_path, relative_path) in files_needing_row { + let registered_file = (file_path.clone(), relative_path.clone()); let timestamp = Utc::now().timestamp(); // Hash + size from filesystem metadata — always attempted so @@ -899,10 +944,25 @@ pub fn process_new_files( ); } else { debug!("Registered {} in image_exif", relative_path); + newly_registered.push(registered_file); } } } + // ── Photo versions (#191) ────────────────────────────────────────── + // Link before any derived work below, so a version is never face-scanned + // or CLIP-encoded in the tick it appears. XMP first (PNG from the + // ComfyUI node; survives renames and moves), then the file-name + // convention. Both run after every new row exists, so a version walked + // before its original in the same batch still finds it. + if !newly_registered.is_empty() { + let linked = versions::link_new_files(library, &newly_registered, &exif_dao, &version_dao) + + versions::sweep_name_links(library, &exif_dao, &version_dao); + if linked > 0 { + crate::duplicates::invalidate_caches(); + } + } + // ── Face detection pass ──────────────────────────────────────────── // Run after EXIF writes so newly-registered files have their // content_hash populated. Skipped wholesale when face_client is @@ -920,7 +980,8 @@ pub fn process_new_files( // ensures small/medium deploys self-heal without operator // action. backfill::backfill_missing_content_hashes(&files, library, &exif_dao); - let candidates = backfill::build_face_candidates(library, &files, &exif_dao, &face_dao); + let candidates = + backfill::build_face_candidates(library, &files, &exif_dao, &face_dao, &version_dao); debug!( "face_watch: scan tick — {} image file(s) walked, {} candidate(s) (library '{}', modified_since={})", files -- 2.54.0 From 8b7c517a17c498b7d9989a45680132d149ea14f0 Mon Sep 17 00:00:00 2001 From: Cameron Cordes Date: Fri, 30 Oct 2026 11:33:47 -0400 Subject: [PATCH 3/6] test(versions): ingest linking, listings, endpoints, tag binding, lifecycle Co-Authored-By: Claude Opus 5.5 --- src/database/mod.rs | 8 + src/versions/integration_tests.rs | 839 ++++++++++++++++++++++++++++++ src/versions/mod.rs | 41 ++ 3 files changed, 888 insertions(+) create mode 100644 src/versions/integration_tests.rs diff --git a/src/database/mod.rs b/src/database/mod.rs index 8939efe..bdbfc3f 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -307,6 +307,14 @@ impl SqliteFavoriteDao { } } +#[cfg(test)] +impl SqliteFavoriteDao { + /// Test-only constructor sharing an already-wrapped connection. + pub fn from_shared(connection: Arc>) -> Self { + SqliteFavoriteDao { connection } + } +} + impl FavoriteDao for SqliteFavoriteDao { fn add_favorite(&mut self, user_id: i32, favorite_path: &str) -> Result { use schema::favorites::dsl::*; diff --git a/src/versions/integration_tests.rs b/src/versions/integration_tests.rs new file mode 100644 index 0000000..a97296f --- /dev/null +++ b/src/versions/integration_tests.rs @@ -0,0 +1,839 @@ +//! End-to-end checks for photo versions (#191): linking at ingest, hiding in +//! listings, the HTTP surface, tag/favorite binding and the lifecycle rules. +//! Everything runs against one shared in-memory SQLite and a temp library +//! root; nothing reads a real library. + +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +use actix_web::test as awtest; +use actix_web::web::Data; +use actix_web::{App, web}; +use diesel::prelude::*; +use diesel::sql_query; +use diesel::sql_types::{BigInt, Text}; +use diesel::sqlite::SqliteConnection; + +use super::xmp::test_png::{producer_xmp, write_png}; +use super::*; +use crate::data::Claims; +use crate::database::test::in_memory_db_connection; +use crate::database::{ExifDao, FavoriteDao, SqliteExifDao, SqliteFavoriteDao, SqliteVersionDao}; +use crate::files::{RealFileSystem, list_photos}; +use crate::library_maintenance; +use crate::state::AppState; +use crate::tags::{SqliteTagDao, TagDao}; + +const LIB: i32 = 1; + +struct Fx { + state: Data, + conn: Arc>, + exif: Data>>, + library: Library, + root: PathBuf, +} + +fn fx() -> Fx { + let mut state = AppState::test_state(); + let conn = Arc::new(Mutex::new(in_memory_db_connection())); + state.version_dao = Arc::new(Mutex::new(Box::new(SqliteVersionDao::from_shared( + conn.clone(), + )))); + let library = state.primary_library().clone(); + let root = PathBuf::from(&library.root_path); + std::fs::create_dir_all(&root).unwrap(); + // The migration seeds library 1 with a placeholder root. + sql_query("UPDATE libraries SET root_path = ? WHERE id = 1") + .bind::(&library.root_path) + .execute(&mut *conn.lock().unwrap()) + .unwrap(); + Fx { + exif: Data::new(Mutex::new( + Box::new(SqliteExifDao::from_shared(conn.clone())) as Box, + )), + state: Data::new(state), + conn, + library, + root, + } +} + +impl Fx { + fn exec(&self, sql: &str) { + sql_query(sql) + .execute(&mut *self.conn.lock().unwrap()) + .unwrap_or_else(|e| panic!("{sql}: {e}")); + } + + /// Write a file under the library root and register it in `image_exif` + /// with its real content hash. Returns the hash. + fn put(&self, rel: &str, bytes: &[u8]) -> String { + let abs = self.root.join(rel); + std::fs::create_dir_all(abs.parent().unwrap()).unwrap(); + std::fs::write(&abs, bytes).unwrap(); + self.register(rel) + } + + fn put_png(&self, rel: &str, xmp: Option<&str>, salt: u8) -> String { + write_png(&self.root.join(rel), xmp, None, salt); + self.register(rel) + } + + fn register(&self, rel: &str) -> String { + let hash = crate::content_hash::compute(&self.root.join(rel)) + .unwrap() + .content_hash; + sql_query( + "INSERT INTO image_exif (library_id, rel_path, created_time, last_modified, content_hash) \ + VALUES (1, ?, 0, 0, ?)", + ) + .bind::(rel) + .bind::(&hash) + .execute(&mut *self.conn.lock().unwrap()) + .unwrap(); + hash + } + + fn forget(&self, rel: &str) { + sql_query("DELETE FROM image_exif WHERE rel_path = ?") + .bind::(rel) + .execute(&mut *self.conn.lock().unwrap()) + .unwrap(); + } + + fn exif_arc(&self) -> Arc>> { + Arc::new(Mutex::new( + Box::new(SqliteExifDao::from_shared(self.conn.clone())) as Box, + )) + } + + fn link(&self, version_hash: &str, original_hash: &str, rel: &str) { + let outcome = self + .state + .version_dao + .lock() + .unwrap() + .link_version(&NewVersionLink { + version_hash: version_hash.into(), + original_hash: original_hash.into(), + kind: "restored".into(), + source: "xmp".into(), + library_id: LIB, + rel_path: rel.into(), + references: vec![], + }) + .unwrap(); + assert_eq!(outcome, LinkOutcome::Linked); + } + + fn token() -> String { + jsonwebtoken::encode( + &jsonwebtoken::Header::default(), + &Claims::valid_user("1".to_string()), + &jsonwebtoken::EncodingKey::from_secret(b"test_key"), + ) + .unwrap() + } + + fn count(&self, sql: &str) -> i64 { + #[derive(QueryableByName)] + struct N { + #[diesel(sql_type = BigInt)] + n: i64, + } + sql_query(sql) + .get_result::(&mut *self.conn.lock().unwrap()) + .unwrap() + .n + } +} + +fn dao_listing(fx: &Fx, include_versions: bool) -> Vec { + let mut v: Vec = fx + .exif + .lock() + .unwrap() + .list_rel_paths_for_libraries(&[], None, false, include_versions) + .unwrap() + .into_iter() + .map(|(_, p)| p) + .collect(); + v.sort(); + v +} + +// ── Ingest ────────────────────────────────────────────────────────────── + +#[actix_rt::test] +async fn xmp_link_is_made_at_ingest_and_survives_a_rename() { + let fx = fx(); + let original = fx.put("2004/scan_0042.jpg", b"original bytes"); + let xmp = producer_xmp(&original, "restored", &[]); + // The producer saved it as `aiedits/.restored.png`; here it was + // renamed and moved somewhere the name convention can't explain. + fx.put_png("somewhere/else/renamed copy.png", Some(&xmp), 1); + + let files = vec![( + fx.root.join("somewhere/else/renamed copy.png"), + "somewhere/else/renamed copy.png".to_string(), + )]; + let linked = link_new_files(&fx.library, &files, &fx.exif_arc(), &fx.state.version_dao); + assert_eq!(linked, 1); + + let (vh, _) = { + let row = fx + .exif + .lock() + .unwrap() + .get_exif("somewhere/else/renamed copy.png") + .unwrap() + .unwrap(); + (row.content_hash.unwrap(), ()) + }; + let link = fx + .state + .version_dao + .lock() + .unwrap() + .get_link(&vh) + .unwrap() + .unwrap(); + assert_eq!(link.original_hash, original); + assert_eq!(link.kind, "restored"); + assert_eq!(link.source, "xmp"); +} + +#[actix_rt::test] +async fn xmp_references_are_provenance_only() { + let fx = fx(); + let original = fx.put("a/main.jpg", b"main"); + let other = fx.put("a/other-view.jpg", b"another view of the subject"); + let xmp = producer_xmp(&original, "edit", &[&other]); + let vh = fx.put_png("a/aiedits/main.jpg.edit.png", Some(&xmp), 2); + let files = vec![( + fx.root.join("a/aiedits/main.jpg.edit.png"), + "a/aiedits/main.jpg.edit.png".to_string(), + )]; + assert_eq!( + link_new_files(&fx.library, &files, &fx.exif_arc(), &fx.state.version_dao), + 1 + ); + let mut dao = fx.state.version_dao.lock().unwrap(); + assert_eq!(dao.get_link(&vh).unwrap().unwrap().original_hash, original); + assert_eq!(dao.list_references(&vh).unwrap(), vec![other.clone()]); + // The reference photo is not linked to anything and stays listed. + assert!(dao.get_link(&other).unwrap().is_none()); + drop(dao); + assert!(dao_listing(&fx, false).contains(&"a/other-view.jpg".to_string())); +} + +#[actix_rt::test] +async fn name_link_pairs_raw_and_jpeg_with_the_right_original() { + let fx = fx(); + let raw = fx.put("trip/IMG_1.NEF", b"raw bytes"); + let jpg = fx.put("trip/IMG_1.JPG", b"jpeg bytes"); + let v_raw = fx.put_png("trip/aiedits/IMG_1.NEF.restored.png", None, 3); + let v_jpg = fx.put_png("trip/aiedits/IMG_1.JPG.restored.2.png", None, 4); + + let n = sweep_name_links(&fx.library, &fx.exif_arc(), &fx.state.version_dao); + assert_eq!(n, 2); + let mut dao = fx.state.version_dao.lock().unwrap(); + let l_raw = dao.get_link(&v_raw).unwrap().unwrap(); + let l_jpg = dao.get_link(&v_jpg).unwrap().unwrap(); + assert_eq!(l_raw.original_hash, raw); + assert_eq!(l_jpg.original_hash, jpg); + assert_eq!(l_raw.source, "name"); + assert_eq!(l_raw.kind, "restored"); + drop(dao); + // Idempotent. + assert_eq!( + sweep_name_links(&fx.library, &fx.exif_arc(), &fx.state.version_dao), + 0 + ); +} + +#[actix_rt::test] +async fn name_link_waits_for_the_original_to_be_hashed() { + let fx = fx(); + let vh = fx.put_png("a/photo.jpg.upscale.png", None, 5); + // Original row exists but its hash hasn't landed yet. + fx.exec( + "INSERT INTO image_exif (library_id, rel_path, created_time, last_modified) \ + VALUES (1, 'a/photo.jpg', 0, 0)", + ); + assert_eq!( + sweep_name_links(&fx.library, &fx.exif_arc(), &fx.state.version_dao), + 0, + "no link by path alone while the original is hash-less" + ); + let original_hash = "ab".repeat(32); + fx.exec(&format!( + "UPDATE image_exif SET content_hash = '{original_hash}' WHERE rel_path = 'a/photo.jpg'" + )); + assert_eq!( + sweep_name_links(&fx.library, &fx.exif_arc(), &fx.state.version_dao), + 1 + ); + let link = fx + .state + .version_dao + .lock() + .unwrap() + .get_link(&vh) + .unwrap() + .unwrap(); + assert_eq!(link.original_hash, original_hash); + assert_eq!(link.kind, "upscale"); +} + +// ── Listings ──────────────────────────────────────────────────────────── + +/// Build the test service: `/photos` plus the version endpoints, over the +/// fixture's shared connection. +macro_rules! photos_app { + ($fx:expr) => {{ + let fx: &Fx = &$fx; + let tag_dao = Data::new(Mutex::new(SqliteTagDao::new(fx.conn.clone()))); + let fs = Data::new(RealFileSystem::new(fx.library.root_path.clone())); + awtest::init_service( + App::new() + .service( + web::resource("/photos") + .route(web::get().to(list_photos::)), + ) + .service(super::handlers::get_versions) + .service(super::handlers::put_display) + .app_data(fx.state.clone()) + .app_data(fx.exif.clone()) + .app_data(tag_dao) + .app_data(fs), + ) + .await + }}; +} + +/// GET `uri` as an authenticated user: `(status, json body)`. +macro_rules! get_json { + ($app:expr, $uri:expr) => {{ + let req = awtest::TestRequest::get() + .uri($uri) + .insert_header(("Authorization", format!("Bearer {}", Fx::token()))) + .to_request(); + let resp = awtest::call_service(&$app, req).await; + let status = resp.status().as_u16(); + let body = awtest::read_body(resp).await; + ( + status, + serde_json::from_slice::(&body).unwrap_or_default(), + ) + }}; +} + +/// A photo with a restoration, one reference-less edit and an unrelated photo. +fn seeded() -> (Fx, String, String, String) { + let fx = fx(); + let original = fx.put("2004/scan.jpg", b"scan bytes"); + fx.put("2004/unrelated.jpg", b"something else"); + let v1 = fx.put_png("2004/aiedits/scan.jpg.restored.png", None, 7); + let v2 = fx.put_png("2004/aiedits/scan.jpg.edit.png", None, 8); + fx.link(&v1, &original, "2004/aiedits/scan.jpg.restored.png"); + fx.link(&v2, &original, "2004/aiedits/scan.jpg.edit.png"); + (fx, original, v1, v2) +} + +#[actix_rt::test] +async fn photos_hides_versions_and_adds_parallel_arrays() { + let (fx, _o, v1, _v2) = seeded(); + let app = photos_app!(fx); + + // Recursive (DB-backed) listing. + let (status, body) = get_json!(app, "/photos?path=&recursive=true&sort=nameasc"); + assert_eq!(status, 200); + assert_eq!( + body["photos"], + serde_json::json!(["2004/scan.jpg", "2004/unrelated.jpg"]) + ); + // No display choice yet: counts only; display paths are skipped. + assert_eq!(body["photo_version_counts"], serde_json::json!([2, 0])); + assert!(body.get("photo_display_paths").is_none()); + + // Non-recursive (filesystem walk) listing of the folder with the original. + let (_, body) = get_json!(app, "/photos?path=2004&sort=nameasc"); + assert_eq!( + body["photos"], + serde_json::json!(["2004/scan.jpg", "2004/unrelated.jpg"]) + ); + // ...and of the aiedits folder itself: the versions are hidden there too. + let (_, body) = get_json!(app, "/photos?path=2004/aiedits&sort=nameasc"); + assert_eq!(body["photos"], serde_json::json!([])); + + // include_versions lists them as photos of their own. + let (_, body) = get_json!( + app, + "/photos?path=&recursive=true&sort=nameasc&include_versions=true" + ); + assert_eq!(body["photos"].as_array().unwrap().len(), 4); + + // A global display choice shows up as photo_display_paths. + let req = awtest::TestRequest::put() + .uri("/image/versions/display") + .insert_header(("Authorization", format!("Bearer {}", Fx::token()))) + .set_json(serde_json::json!({"path": "2004/scan.jpg", "display_hash": v1})) + .to_request(); + assert_eq!(awtest::call_service(&app, req).await.status(), 200); + let (_, body) = get_json!(app, "/photos?path=&recursive=true&sort=nameasc"); + assert_eq!( + body["photo_display_paths"], + serde_json::json!(["2004/aiedits/scan.jpg.restored.png", null]) + ); + assert_eq!( + body["photos"][0], "2004/scan.jpg", + "photos[i] stays the original" + ); +} + +#[actix_rt::test] +async fn photos_without_versions_is_unchanged() { + let fx = fx(); + fx.put("a.jpg", b"a"); + fx.put("b.jpg", b"b"); + let app = photos_app!(fx); + let (_, body) = get_json!(app, "/photos?path=&recursive=true&sort=nameasc"); + let keys: Vec<&String> = body.as_object().unwrap().keys().collect(); + assert!( + !keys + .iter() + .any(|k| k.starts_with("photo_display") || k.starts_with("photo_version")) + ); + assert_eq!(body["photos"], serde_json::json!(["a.jpg", "b.jpg"])); +} + +#[actix_rt::test] +async fn deleting_the_original_surfaces_the_version() { + let (fx, _o, _v1, _v2) = seeded(); + fx.forget("2004/scan.jpg"); + let app = photos_app!(fx); + let (_, body) = get_json!(app, "/photos?path=&recursive=true&sort=nameasc"); + assert_eq!( + body["photos"], + serde_json::json!([ + "2004/aiedits/scan.jpg.edit.png", + "2004/aiedits/scan.jpg.restored.png", + "2004/unrelated.jpg" + ]) + ); + assert!(body.get("photo_version_counts").is_none()); +} + +#[actix_rt::test] +async fn library_scoped_listing_hides_only_when_the_original_is_in_view() { + let (fx, original, v1, _v2) = seeded(); + fx.exec( + "INSERT INTO libraries (id, name, root_path, created_at) VALUES (2, 'two', '/tmp/two', 0)", + ); + // Move the original into library 2. + fx.exec(&format!( + "UPDATE image_exif SET library_id = 2 WHERE content_hash = '{original}'" + )); + let lib1 = fx + .exif + .lock() + .unwrap() + .list_rel_paths_for_libraries(&[1], None, false, false) + .unwrap(); + assert!( + lib1.iter().any(|(_, p)| p.ends_with("restored.png")), + "original is outside library 1: the version stays visible" + ); + let all = dao_listing(&fx, false); + assert!(!all.iter().any(|p| p.ends_with("restored.png"))); + let _ = v1; +} + +#[actix_rt::test] +async fn exif_gps_and_memories_queries_hide_versions() { + let (fx, original, v1, _v2) = seeded(); + fx.exec(&format!( + "UPDATE image_exif SET date_taken = 1500000000, date_utc = 1500000000, \ + gps_latitude = 10.5, gps_longitude = 20.5, camera_make = 'Canon', camera_model = 'EOS', \ + date_taken_source = 'exif' WHERE content_hash = '{original}'" + )); + // Copy to the versions (what the watcher does each tick). + assert_eq!( + fx.state + .version_dao + .lock() + .unwrap() + .sync_version_metadata() + .unwrap(), + 2 + ); + assert_eq!( + fx.count(&format!( + "SELECT COUNT(*) AS n FROM image_exif WHERE content_hash = '{v1}' \ + AND date_utc = 1500000000 AND gps_latitude = 10.5 AND camera_make = 'Canon' \ + AND date_taken_source = 'version_of'" + )), + 1, + "a version takes the original's date, GPS and camera" + ); + + let mut dao = fx.exif.lock().unwrap(); + let gps = dao.get_all_with_gps("", true).unwrap(); + assert_eq!(gps.len(), 1, "GPS summary shows the photo once"); + assert_eq!(gps[0].file_path, "2004/scan.jpg"); + let hit = dao + .query_by_exif(None, Some("Canon"), None, None, None, None, false) + .unwrap(); + assert_eq!(hit.len(), 1); + let all = dao + .query_by_exif(None, Some("Canon"), None, None, None, None, true) + .unwrap(); + assert_eq!(all.len(), 3); + assert_eq!( + dao.get_camera_makes().unwrap(), + vec![("Canon".to_string(), 1)] + ); + // /memories: the original's day, once. + let window = |dao: &mut Box| { + dao.get_memories_in_window(1, 0, 4_000_000_000, -1, 4_000_000_000, &[]) + .unwrap() + }; + let rows = window(&mut dao); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].file_path, "2004/scan.jpg"); + drop(dao); + + // Original deleted: both versions surface on the original's date. + fx.forget("2004/scan.jpg"); + let mut dao = fx.exif.lock().unwrap(); + let rows = window(&mut dao); + assert_eq!(rows.len(), 2); + assert!(rows.iter().all(|r| r.date_utc == Some(1_500_000_000))); +} + +#[actix_rt::test] +async fn derived_work_never_sees_version_rows() { + let (fx, original, v1, _v2) = seeded(); + // Same phash on original and version, as a restore would have. + fx.exec("UPDATE image_exif SET phash_64 = 42, dhash_64 = 7, clip_embedding = x'00000000', clip_model_version = 'm'"); + let mut dao = fx.exif.lock().unwrap(); + let perceptual = dao.list_perceptual_candidates(None, true).unwrap(); + assert!(perceptual.iter().all(|r| r.content_hash != v1)); + assert!(perceptual.iter().any(|r| r.content_hash == original)); + let idx = dao.list_clip_index(&[], None).unwrap(); + assert!(idx.iter().all(|(h, _)| *h != v1)); + drop(dao); + + fx.exec("UPDATE image_exif SET clip_embedding = NULL"); + let todo = fx + .exif + .lock() + .unwrap() + .list_clip_unencoded_candidates(1, 100) + .unwrap(); + assert!(todo.iter().all(|(_, h)| *h != v1 && !h.is_empty())); + assert_eq!(todo.len(), 2, "original + unrelated; neither version"); + let paths = fx + .exif + .lock() + .unwrap() + .list_image_paths(None, true) + .unwrap(); + assert!(paths.iter().all(|(_, p)| !p.contains("aiedits"))); +} + +// ── Endpoints ─────────────────────────────────────────────────────────── + +#[actix_rt::test] +async fn versions_endpoint_answers_for_original_or_any_version_path() { + let (fx, original, v1, v2) = seeded(); + let app = photos_app!(fx); + + for path in [ + "2004/scan.jpg", + "2004/aiedits/scan.jpg.restored.png", + "2004/aiedits/scan.jpg.edit.png", + ] { + let (status, body) = get_json!(app, &format!("/image/versions?path={path}")); + assert_eq!(status, 200, "{path}"); + assert_eq!(body["original"]["path"], "2004/scan.jpg"); + assert_eq!(body["original"]["hash"], original); + assert_eq!(body["original"]["library_id"], 1); + let versions = body["versions"].as_array().unwrap(); + assert_eq!(versions.len(), 2); + let hashes: Vec<&str> = versions + .iter() + .map(|v| v["hash"].as_str().unwrap()) + .collect(); + assert!(hashes.contains(&v1.as_str()) && hashes.contains(&v2.as_str())); + for key in [ + "path", + "library_id", + "kind", + "source", + "created_at", + "references", + ] { + assert!(versions[0].get(key).is_some(), "missing {key}"); + } + assert!(body["display_hash"].is_null()); + } + + let (status, _) = get_json!(app, "/image/versions?path=nope.jpg"); + assert_eq!(status, 404); +} + +#[actix_rt::test] +async fn display_choice_set_from_a_version_path_cleared_and_validated() { + let (fx, original, v1, _v2) = seeded(); + let app = photos_app!(fx); + let put = |body: serde_json::Value| { + awtest::TestRequest::put() + .uri("/image/versions/display") + .insert_header(("Authorization", format!("Bearer {}", Fx::token()))) + .set_json(body) + .to_request() + }; + + // Set via a *version's* path, no library given. + let resp = awtest::call_service( + &app, + put(serde_json::json!({"path": "2004/aiedits/scan.jpg.edit.png", "display_hash": v1})), + ) + .await; + assert_eq!(resp.status(), 200); + let body: serde_json::Value = awtest::read_body_json(resp).await; + assert_eq!(body["display_hash"], v1); + + // Not a version of this photo. + let resp = awtest::call_service( + &app, + put(serde_json::json!({"path": "2004/scan.jpg", "display_hash": "f".repeat(64)})), + ) + .await; + assert_eq!(resp.status(), 400); + + // Choosing the original clears it, as does null. + let resp = awtest::call_service( + &app, + put(serde_json::json!({"path": "2004/scan.jpg", "display_hash": original})), + ) + .await; + let body: serde_json::Value = awtest::read_body_json(resp).await; + assert!(body["display_hash"].is_null()); + awtest::call_service( + &app, + put(serde_json::json!({"path": "2004/scan.jpg", "display_hash": v1})), + ) + .await; + let resp = awtest::call_service( + &app, + put(serde_json::json!({"path": "2004/scan.jpg", "display_hash": null})), + ) + .await; + let body: serde_json::Value = awtest::read_body_json(resp).await; + assert!(body["display_hash"].is_null()); + + // `library` may also ride in the query string (Apollo/FileViewer differ). + let req = awtest::TestRequest::put() + .uri("/image/versions/display?library=1") + .insert_header(("Authorization", format!("Bearer {}", Fx::token()))) + .set_json(serde_json::json!({"path": "2004/scan.jpg", "display_hash": v1})) + .to_request(); + assert_eq!(awtest::call_service(&app, req).await.status(), 200); +} + +#[actix_rt::test] +async fn deleting_the_displayed_version_falls_back_to_the_original() { + let (fx, _o, v1, _v2) = seeded(); + fx.state + .version_dao + .lock() + .unwrap() + .set_display(&_o, Some(&v1)) + .unwrap(); + fx.forget("2004/aiedits/scan.jpg.restored.png"); + let app = photos_app!(fx); + let (_, body) = get_json!(app, "/photos?path=&recursive=true&sort=nameasc"); + assert_eq!(body["photos"][0], "2004/scan.jpg"); + assert!( + body.get("photo_display_paths").is_none(), + "falls back to the original" + ); + assert_eq!(body["photo_version_counts"][0], 1); + let (_, body) = get_json!(app, "/image/versions?path=2004/scan.jpg"); + assert!(body["display_hash"].is_null()); +} + +#[actix_rt::test] +async fn a_version_serves_its_full_file() { + let (fx, _o, _v1, _v2) = seeded(); + let app = awtest::init_service( + App::new() + .service(crate::handlers::image::get_image) + .app_data(fx.state.clone()) + .app_data(fx.exif.clone()), + ) + .await; + let req = awtest::TestRequest::get() + .uri("/image?path=2004/aiedits/scan.jpg.restored.png") + .insert_header(("Authorization", format!("Bearer {}", Fx::token()))) + .to_request(); + let resp = awtest::call_service(&app, req).await; + assert_eq!(resp.status(), 200); + let on_disk = std::fs::read(fx.root.join("2004/aiedits/scan.jpg.restored.png")).unwrap(); + assert_eq!(awtest::read_body(resp).await.as_ref(), on_disk.as_slice()); +} + +// ── Tags and favorites ────────────────────────────────────────────────── + +#[actix_rt::test] +async fn tags_and_favorites_on_a_version_path_bind_to_the_original() { + let (fx, original, _v1, _v2) = seeded(); + fx.exec("INSERT INTO users (username, password) VALUES ('u', 'p')"); + let mut tags = SqliteTagDao::new(fx.conn.clone()); + let tag = tags.create_tag("holiday").unwrap(); + tags.tag_file("2004/aiedits/scan.jpg.restored.png", tag.id) + .unwrap(); + + assert_eq!( + fx.count("SELECT COUNT(*) AS n FROM tagged_photo WHERE rel_path = '2004/scan.jpg'"), + 1 + ); + assert_eq!( + fx.count(&format!( + "SELECT COUNT(*) AS n FROM tagged_photo WHERE content_hash = '{original}'" + )), + 1, + "bound to the original's hash" + ); + // Read against either path. + for p in ["2004/scan.jpg", "2004/aiedits/scan.jpg.edit.png"] { + assert_eq!(tags.get_tags_for_path(p).unwrap().len(), 1, "{p}"); + } + // The shared-content path set (what GET /image/tags expands) covers both. + let sharing = fx + .exif + .lock() + .unwrap() + .get_rel_paths_sharing_content(1, "2004/aiedits/scan.jpg.edit.png") + .unwrap(); + assert!(sharing.contains(&"2004/scan.jpg".to_string())); + // Removing via a version path removes the original's tag. + tags.remove_tag("holiday", "2004/aiedits/scan.jpg.restored.png") + .unwrap(); + assert!(tags.get_tags_for_path("2004/scan.jpg").unwrap().is_empty()); + + let mut favs = SqliteFavoriteDao::from_shared(fx.conn.clone()); + favs.add_favorite(1, "2004/aiedits/scan.jpg.restored.png") + .unwrap(); + let listed: Vec = favs + .get_favorites(1) + .unwrap() + .into_iter() + .map(|f| f.path) + .collect(); + assert_eq!(listed, vec!["2004/scan.jpg"]); + favs.remove_favorite(1, "2004/aiedits/scan.jpg.edit.png".to_string()); + assert!(favs.get_favorites(1).unwrap().is_empty()); +} + +#[actix_rt::test] +async fn tags_bind_to_the_version_itself_once_the_original_is_gone() { + let (fx, _o, _v1, _v2) = seeded(); + fx.forget("2004/scan.jpg"); + let mut tags = SqliteTagDao::new(fx.conn.clone()); + let tag = tags.create_tag("keeper").unwrap(); + tags.tag_file("2004/aiedits/scan.jpg.edit.png", tag.id) + .unwrap(); + assert_eq!( + tags.get_tags_for_path("2004/aiedits/scan.jpg.edit.png") + .unwrap() + .len(), + 1 + ); +} + +// ── Lifecycle ─────────────────────────────────────────────────────────── + +#[actix_rt::test] +async fn orphan_gc_retires_a_gone_version_but_keeps_links_of_a_gone_original() { + let (fx, original, v1, v2) = seeded(); + fx.state + .version_dao + .lock() + .unwrap() + .set_display(&original, Some(&v1)) + .unwrap(); + + // Version v1's file disappears. + fx.forget("2004/aiedits/scan.jpg.restored.png"); + let mut conn = fx.conn.lock().unwrap(); + let mut state = library_maintenance::OrphanGcState::default(); + library_maintenance::run_orphan_gc(&mut conn, &mut state, true); + let stats = library_maintenance::run_orphan_gc(&mut conn, &mut state, true); + assert_eq!(stats.deleted_photo_versions, 2, "link + display row"); + drop(conn); + assert_eq!(fx.count("SELECT COUNT(*) AS n FROM photo_versions"), 1); + assert_eq!( + fx.count("SELECT COUNT(*) AS n FROM photo_version_display"), + 0 + ); + + // The original goes too: the remaining link is kept (the version is a + // standalone photo, and the link revives if the original returns). + fx.forget("2004/scan.jpg"); + let mut conn = fx.conn.lock().unwrap(); + for _ in 0..3 { + library_maintenance::run_orphan_gc(&mut conn, &mut state, true); + } + drop(conn); + assert_eq!( + fx.count(&format!( + "SELECT COUNT(*) AS n FROM photo_versions WHERE version_hash = '{v2}'" + )), + 1 + ); +} + +#[actix_rt::test] +async fn back_ref_refresh_follows_a_moved_version_file() { + let (fx, _o, v1, _v2) = seeded(); + // The file moved: old row retired, new row appears elsewhere. + fx.forget("2004/aiedits/scan.jpg.restored.png"); + fx.exec(&format!( + "INSERT INTO image_exif (library_id, rel_path, created_time, last_modified, content_hash) \ + VALUES (1, 'archive/scan.jpg.restored.png', 0, 0, '{v1}')" + )); + let mut conn = fx.conn.lock().unwrap(); + assert!(library_maintenance::refresh_back_refs(&mut conn) >= 1); + drop(conn); + assert_eq!( + fx.count(&format!( + "SELECT COUNT(*) AS n FROM photo_versions \ + WHERE version_hash = '{v1}' AND rel_path = 'archive/scan.jpg.restored.png'" + )), + 1 + ); +} + +#[actix_rt::test] +async fn face_stats_denominator_leaves_versions_out() { + let (fx, _o, _v1, _v2) = seeded(); + let hidden = fx + .state + .version_dao + .lock() + .unwrap() + .hidden_version_rows(&[]) + .unwrap(); + assert_eq!(hidden.len(), 2); + let mut faces = crate::faces::SqliteFaceDao::from_connection(fx.conn.clone()); + use crate::faces::FaceDao; + let stats = faces.stats(None).unwrap(); + // scan.jpg + unrelated.jpg; the two .png versions stay out of the + // denominator because they are never scanned. + assert_eq!(stats.total_photos, 2); +} diff --git a/src/versions/mod.rs b/src/versions/mod.rs index efbd9bd..1100890 100644 --- a/src/versions/mod.rs +++ b/src/versions/mod.rs @@ -17,6 +17,9 @@ pub mod handlers; pub mod xmp; +#[cfg(test)] +mod integration_tests; + use std::path::{Path, PathBuf}; use std::sync::{Arc, LazyLock, Mutex}; @@ -361,6 +364,44 @@ mod tests { assert!(parse_version_name(".restored.png").is_none()); } + #[test] + fn record_fields_are_skipped_without_versions_and_null_without_a_display_choice() { + #[derive(serde::Serialize)] + struct Rec { + path: &'static str, + #[serde(flatten)] + versions: VersionFields, + } + let json = |summary: Option<&version_dao::PhotoVersionSummary>| { + serde_json::to_value(Rec { + path: "a.jpg", + versions: VersionFields::from_summary(summary), + }) + .unwrap() + }; + assert_eq!(json(None), serde_json::json!({"path": "a.jpg"})); + let plain = version_dao::PhotoVersionSummary { + version_count: 2, + display: None, + }; + assert_eq!( + json(Some(&plain)), + serde_json::json!({"path": "a.jpg", "display_path": null, "version_count": 2}) + ); + let shown = version_dao::PhotoVersionSummary { + version_count: 1, + display: Some((1, "aiedits/a.jpg.restored.png".into())), + }; + assert_eq!( + json(Some(&shown)), + serde_json::json!({ + "path": "a.jpg", + "display_path": "aiedits/a.jpg.restored.png", + "version_count": 1 + }) + ); + } + #[test] fn candidates_prefer_the_same_dir_then_the_parent_of_aiedits() { assert_eq!( -- 2.54.0 From fa3554c4116f09d7f0a4bcb465850eecd7440f1f Mon Sep 17 00:00:00 2001 From: Cameron Cordes Date: Fri, 30 Oct 2026 11:39:35 -0400 Subject: [PATCH 4/6] feat(versions): link_versions backfill binary Dry run by default: prints a TSV of proposed links. XMP (PNG DerivedFrom, incl. files already ingested) and file-name links are exact and apply without review; ComfyUI prompt LoadImage inputs, Upscayl-style names and nearest pHash are heuristic and apply only from a reviewed TSV, checked against current hashes. Unresolvable cases are listed, not guessed. --apply takes a WAL-safe VACUUM INTO snapshot first and aborts if it fails. Co-Authored-By: Claude Opus 5.5 --- src/bin/link_versions.rs | 225 ++++++++++ src/versions/backfill.rs | 931 +++++++++++++++++++++++++++++++++++++++ src/versions/mod.rs | 1 + 3 files changed, 1157 insertions(+) create mode 100644 src/bin/link_versions.rs create mode 100644 src/versions/backfill.rs diff --git a/src/bin/link_versions.rs b/src/bin/link_versions.rs new file mode 100644 index 0000000..580d424 --- /dev/null +++ b/src/bin/link_versions.rs @@ -0,0 +1,225 @@ +//! Link photo versions that were ingested before linking existed (#191). +//! +//! **Default is a dry run**: nothing is written. It prints a TSV of every +//! proposed link (`status method library_id version_path original_path +//! phash_distance kind ...`) and a summary on stderr. +//! +//! - `exact` rows (the PNG's XMP `DerivedFrom` hash, or the +//! `.[.].` convention next to an indexed original) are +//! the same links the watcher makes at ingest; `--apply` writes them +//! without review. +//! - `review` rows (ComfyUI prompt `LoadImage` input, Upscayl-style names, +//! nearest pHash) are heuristic. Save the dry-run output, delete the rows +//! you don't want, and pass the file as `--reviewed`; only those are +//! written, and each is checked against the database first so a stale file +//! can't link a path that now holds different bytes. +//! - `unresolved` rows are listed, never guessed. +//! +//! `--apply` first takes a WAL-safe snapshot of the database +//! (`VACUUM INTO`) and refuses to write if that fails. Prove it on a copy of +//! the database first. The library roots are only ever read. + +use std::collections::HashSet; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +use clap::Parser; +use diesel::prelude::*; +use diesel::sql_types::{BigInt, Integer, Nullable, Text}; + +use image_api::database::version_dao::VersionDao; +use image_api::database::{SqliteVersionDao, connect}; +use image_api::libraries; +use image_api::versions::backfill::{self, FileRow, Status}; + +#[derive(Parser, Debug)] +#[command(name = "link_versions")] +#[command(about = "Propose (dry run) or write links from photo versions to their originals")] +struct Args { + /// Write the links. Without it nothing is changed. + #[arg(long)] + apply: bool, + + /// A reviewed copy of the dry-run TSV: its `review` rows are linked too. + /// Only meaningful with --apply. + #[arg(long, value_name = "FILE")] + reviewed: Option, + + /// Directory for the pre-apply snapshot (default: next to the database). + #[arg(long, value_name = "DIR")] + backup_dir: Option, + + /// Only consider this library id (repeatable). Default: every library. + #[arg(long = "library", value_name = "ID")] + libraries: Vec, +} + +#[derive(QueryableByName)] +struct DbRow { + #[diesel(sql_type = Integer)] + library_id: i32, + #[diesel(sql_type = Text)] + rel_path: String, + #[diesel(sql_type = Text)] + content_hash: String, + #[diesel(sql_type = Nullable)] + phash_64: Option, +} + +#[derive(QueryableByName)] +struct Count { + #[diesel(sql_type = BigInt)] + n: i64, +} + +#[derive(QueryableByName)] +struct Hash { + #[diesel(sql_type = Text)] + version_hash: String, +} + +fn main() -> anyhow::Result<()> { + env_logger::init(); + dotenv::dotenv().ok(); + let args = Args::parse(); + if args.reviewed.is_some() && !args.apply { + anyhow::bail!("--reviewed only applies together with --apply (the default is a dry run)"); + } + + let mut conn = connect(); + let has_table = diesel::sql_query( + "SELECT COUNT(*) AS n FROM sqlite_master WHERE type = 'table' AND name = 'photo_versions'", + ) + .get_result::(&mut conn)?; + if has_table.n == 0 { + anyhow::bail!( + "this database has no photo_versions table; start the server once (it runs the \ + migrations) or point DATABASE_URL at a migrated copy" + ); + } + + let mut libs = libraries::load_all(&mut conn); + if !args.libraries.is_empty() { + libs.retain(|l| args.libraries.contains(&l.id)); + } + if libs.is_empty() { + anyhow::bail!("no matching libraries"); + } + let lib_ids: HashSet = libs.iter().map(|l| l.id).collect(); + + let rows: Vec = diesel::sql_query( + "SELECT library_id, rel_path, content_hash, phash_64 FROM image_exif \ + WHERE content_hash IS NOT NULL ORDER BY library_id, rel_path", + ) + .load::(&mut conn)? + .into_iter() + .filter(|r| lib_ids.contains(&r.library_id)) + .map(|r| FileRow { + library_id: r.library_id, + rel_path: r.rel_path, + content_hash: r.content_hash, + phash: r.phash_64, + }) + .collect(); + let linked: HashSet = diesel::sql_query("SELECT version_hash FROM photo_versions") + .load::(&mut conn)? + .into_iter() + .map(|h| h.version_hash) + .collect(); + + eprintln!( + "{} hashed image/video rows, {} version links already recorded", + rows.len(), + linked.len() + ); + let proposals = backfill::plan(&libs, &rows, &linked); + + println!("{}", backfill::TSV_HEADER); + for p in &proposals { + println!("{}", p.to_tsv()); + } + let count = |s: Status| proposals.iter().filter(|p| p.status == s).count(); + eprintln!( + "\nproposals: {} exact (apply without review), {} review (need --reviewed), {} unresolved", + count(Status::Exact), + count(Status::Review), + count(Status::Unresolved) + ); + for method in ["xmp", "name", "comfy-loader", "name-prefix", "phash"] { + let n = proposals.iter().filter(|p| p.method == method).count(); + if n > 0 { + eprintln!(" {method}: {n}"); + } + } + + if !args.apply { + eprintln!("dry run: nothing written. Re-run with --apply to write the exact links."); + return Ok(()); + } + + let reviewed = match &args.reviewed { + Some(path) => { + let text = std::fs::read_to_string(path)?; + let (links, skipped) = backfill::parse_reviewed(&text); + eprintln!( + "reviewed file: {} review row(s) to link, {} other line(s) ignored", + links.len(), + skipped + ); + links + } + None => Vec::new(), + }; + + let backup = backup_database(&mut conn, args.backup_dir.as_deref())?; + eprintln!("snapshot written: {}", backup.display()); + drop(conn); + + let dao: Arc>> = + Arc::new(Mutex::new(Box::new(SqliteVersionDao::new()))); + let mut guard = dao.lock().unwrap(); + let report = backfill::apply(guard.as_mut(), &proposals, &reviewed, &rows); + let synced = guard + .sync_version_metadata() + .map_err(|e| anyhow::anyhow!("{e:?}"))?; + eprintln!( + "linked {} ({} already linked, {} refused, {} stale reviewed row(s)); \ + date/GPS/camera copied onto {} version row(s)", + report.linked, report.already_linked, report.refused, report.stale, synced + ); + Ok(()) +} + +/// Snapshot the live database with `VACUUM INTO`, which is consistent with +/// the WAL (a plain file copy is not). +fn backup_database( + conn: &mut diesel::SqliteConnection, + dir: Option<&std::path::Path>, +) -> anyhow::Result { + let url = dotenv::var("DATABASE_URL")?; + let db_path = PathBuf::from(url.trim_start_matches("sqlite://")); + let dir = match dir { + Some(d) => d.to_path_buf(), + None => db_path + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")), + }; + let stem = db_path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| "database".into()); + let target = dir.join(format!( + "{stem}.pre-link_versions-{}.bak", + chrono::Utc::now().format("%Y%m%dT%H%M%SZ") + )); + if target.exists() { + anyhow::bail!("{} already exists", target.display()); + } + let escaped = target.to_string_lossy().replace('\'', "''"); + diesel::sql_query(format!("VACUUM INTO '{escaped}'")) + .execute(conn) + .map_err(|e| anyhow::anyhow!("snapshot failed, nothing was written: {e}"))?; + Ok(target) +} diff --git a/src/versions/backfill.rs b/src/versions/backfill.rs new file mode 100644 index 0000000..0d7af8b --- /dev/null +++ b/src/versions/backfill.rs @@ -0,0 +1,931 @@ +//! Backfill of photo versions that were ingested before linking existed +//! (the `link_versions` binary). +//! +//! Two kinds of proposal: +//! +//! - **Exact** (`xmp`, `name`): the PNG carries the producer's XMP +//! `DerivedFrom` hash, or its name follows `.[.].` next +//! to an indexed original. The ingest path would have made the same link, +//! so these apply without review. +//! - **Review** (`comfy-loader`, `name-prefix`, `phash`): files with no XMP, +//! matched by the ComfyUI prompt's `LoadImage` input, an Upscayl-style +//! name, or the nearest pHash. Heuristic, so they only go through a TSV a +//! human has read (`--reviewed`). Ambiguous or unresolvable cases are +//! listed, never guessed. +//! +//! [`plan`] only reads (database rows handed in, library files for XMP and +//! the prompt chunk); [`apply`] is the only writer and goes through +//! [`VersionDao`]. + +use std::collections::{HashMap, HashSet}; +use std::path::Path; +use std::sync::LazyLock; + +use regex::Regex; + +use super::xmp::{self, XMP_KEYWORD}; +use super::{VERSIONS_SUBFOLDER, original_candidates, parse_version_name}; +use crate::database::version_dao::{ + LinkOutcome, NewVersionLink, SOURCE_BACKFILL, SOURCE_NAME, SOURCE_XMP, VersionDao, + is_known_kind, +}; +use crate::file_types; +use crate::libraries::Library; + +/// A nearest-pHash match farther than this (of 64 bits) is not proposed. +pub const MAX_PHASH_DISTANCE: u32 = 16; + +/// One `image_exif` row, as far as planning needs it. +#[derive(Debug, Clone)] +pub struct FileRow { + pub library_id: i32, + pub rel_path: String, + pub content_hash: String, + pub phash: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Status { + /// Applies without review. + Exact, + /// Heuristic; applies only from a reviewed TSV. + Review, + /// Listed, not guessed. + Unresolved, +} + +impl Status { + fn as_str(self) -> &'static str { + match self { + Status::Exact => "exact", + Status::Review => "review", + Status::Unresolved => "unresolved", + } + } +} + +#[derive(Debug, Clone)] +pub struct Proposal { + pub status: Status, + /// `xmp` | `name` | `comfy-loader` | `name-prefix` | `phash` + pub method: &'static str, + pub library_id: i32, + pub version_path: String, + pub version_hash: String, + /// Empty for an unresolved case, or an XMP link whose original isn't indexed. + pub original_path: String, + pub original_hash: String, + pub kind: String, + pub distance: Option, + /// Hashes of reference photos (the other loader inputs). + pub references: Vec, + pub notes: String, +} + +pub const TSV_HEADER: &str = "status\tmethod\tlibrary_id\tversion_path\toriginal_path\tphash_distance\tkind\tversion_hash\toriginal_hash\treferences\tnotes"; + +impl Proposal { + pub fn to_tsv(&self) -> String { + let clean = |s: &str| s.replace(['\t', '\n', '\r'], " "); + [ + self.status.as_str().to_string(), + self.method.to_string(), + self.library_id.to_string(), + clean(&self.version_path), + clean(&self.original_path), + self.distance.map(|d| d.to_string()).unwrap_or_default(), + self.kind.clone(), + self.version_hash.clone(), + self.original_hash.clone(), + self.references.join(","), + clean(&self.notes), + ] + .join("\t") + } +} + +/// A row of a reviewed TSV: just enough to link. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReviewedLink { + pub method: String, + pub library_id: i32, + pub version_path: String, + pub kind: String, + pub version_hash: String, + pub original_hash: String, + pub references: Vec, +} + +/// Parse a reviewed TSV (the dry-run output with rows deleted or kept). +/// Rows that aren't `review` status with both hashes are skipped; so are the +/// header, blanks and `#` comments. Returns `(links, skipped_lines)`. +pub fn parse_reviewed(tsv: &str) -> (Vec, usize) { + let mut out = Vec::new(); + let mut skipped = 0; + for line in tsv.lines() { + if line.trim().is_empty() || line.starts_with('#') || line.starts_with("status\t") { + continue; + } + let f: Vec<&str> = line.split('\t').collect(); + let usable = f.len() >= 10 + && f[0] == "review" + && f[2].parse::().is_ok() + && !f[7].is_empty() + && !f[8].is_empty(); + if !usable { + skipped += 1; + continue; + } + out.push(ReviewedLink { + method: f[1].to_string(), + library_id: f[2].parse().unwrap(), + version_path: f[3].to_string(), + kind: f[6].to_string(), + version_hash: f[7].to_string(), + original_hash: f[8].to_string(), + references: f[9] + .split(',') + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect(), + }); + } + (out, skipped) +} + +fn dir_of(rel: &str) -> &str { + rel.rsplit_once('/').map_or("", |(d, _)| d) +} + +fn name_of(rel: &str) -> &str { + rel.rsplit('/').next().unwrap_or(rel) +} + +fn parent_of_dir(dir: &str) -> &str { + dir.rsplit_once('/').map_or("", |(p, _)| p) +} + +fn is_aiedits_dir(dir: &str) -> bool { + name_of(dir).eq_ignore_ascii_case(VERSIONS_SUBFOLDER) +} + +/// Names that are themselves versions (convention or Upscayl output) are +/// never proposed as someone's original. +fn looks_like_a_version(file_name: &str) -> bool { + parse_version_name(file_name).is_some() || upscayl_stem(file_name).is_some() +} + +fn hamming(a: i64, b: i64) -> u32 { + ((a ^ b) as u64).count_ones() +} + +static UPSCAYL_SUFFIX: LazyLock = LazyLock::new(|| { + // `-4x.png`, `_upscayl_2x_ultramix_balanced.png` + Regex::new(r"(?i)^(?P.+?)(?:-\d+x|_upscayl_\d+(?:\.\d+)?x_[^.]+)\.png$").unwrap() +}); + +fn upscayl_stem(file_name: &str) -> Option { + UPSCAYL_SUFFIX + .captures(file_name) + .map(|c| c["stem"].to_string()) +} + +/// File names a ComfyUI prompt loads, in node order: the `image` input of +/// every `LoadImage*` node. +pub fn loader_names(prompt_json: &str) -> Vec { + let Ok(value) = serde_json::from_str::(prompt_json) else { + return Vec::new(); + }; + let Some(nodes) = value.as_object() else { + return Vec::new(); + }; + let mut ids: Vec<&String> = nodes.keys().collect(); + ids.sort_by_key(|k| k.parse::().unwrap_or(u64::MAX)); + let mut out = Vec::new(); + for id in ids { + let node = &nodes[id]; + let class = node["class_type"].as_str().unwrap_or(""); + if !class.starts_with("LoadImage") { + continue; + } + if let Some(raw) = node["inputs"]["image"].as_str() { + let name = raw.trim_end_matches(" [input]"); + let name = name.rsplit(['/', '\\']).next().unwrap_or(name); + if !name.is_empty() { + out.push(name.to_string()); + } + } + } + out +} + +struct Index<'a> { + by_path: HashMap<(i32, &'a str), &'a FileRow>, + by_name: HashMap<(i32, &'a str), Vec<&'a FileRow>>, + by_dir: HashMap<(i32, &'a str), Vec<&'a FileRow>>, +} + +impl<'a> Index<'a> { + fn new(rows: &'a [FileRow]) -> Self { + let mut idx = Index { + by_path: HashMap::new(), + by_name: HashMap::new(), + by_dir: HashMap::new(), + }; + for r in rows { + idx.by_path.insert((r.library_id, r.rel_path.as_str()), r); + idx.by_name + .entry((r.library_id, name_of(&r.rel_path))) + .or_default() + .push(r); + idx.by_dir + .entry((r.library_id, dir_of(&r.rel_path))) + .or_default() + .push(r); + } + idx + } +} + +/// Plan every link the backfill can propose. `rows` are all hashed image +/// rows; `linked` the version hashes that already have a link. +pub fn plan(libs: &[Library], rows: &[FileRow], linked: &HashSet) -> Vec { + let idx = Index::new(rows); + let root_of: HashMap = libs.iter().map(|l| (l.id, l.root_path.as_str())).collect(); + let mut out = Vec::new(); + + for row in rows { + if linked.contains(&row.content_hash) { + continue; + } + let name = name_of(&row.rel_path); + let dir = dir_of(&row.rel_path); + let is_png = name.to_ascii_lowercase().ends_with(".png"); + let name_conv = parse_version_name(name); + let upscayl = if is_png { upscayl_stem(name) } else { None }; + let in_aiedits = is_aiedits_dir(dir); + if !(is_png || name_conv.is_some()) { + continue; + } + if !file_types::is_image_file(Path::new(&row.rel_path)) { + continue; + } + + // Text chunks (XMP + ComfyUI prompt) of PNGs, read once. + let mut xmp_text = None; + let mut prompt = None; + if is_png && let Some(root) = root_of.get(&row.library_id) { + let abs = Path::new(root).join(&row.rel_path); + if let Ok(chunks) = xmp::read_png_text(&abs, &[XMP_KEYWORD, "prompt"]) { + for (k, v) in chunks { + if k == XMP_KEYWORD { + xmp_text = Some(v); + } else { + prompt = Some(v); + } + } + } + } + + // 1. XMP: exact, even when the original isn't indexed yet. + if let Some(x) = xmp_text.as_deref().and_then(xmp::parse_version_xmp) + && x.original_hash != row.content_hash + { + let original = rows.iter().find(|r| r.content_hash == x.original_hash); + let kind = x + .kind + .filter(|k| is_known_kind(k)) + .or_else(|| name_conv.as_ref().map(|n| n.kind.to_string())) + .unwrap_or_else(|| "edit".to_string()); + out.push(Proposal { + status: Status::Exact, + method: "xmp", + library_id: row.library_id, + version_path: row.rel_path.clone(), + version_hash: row.content_hash.clone(), + original_path: original.map(|o| o.rel_path.clone()).unwrap_or_default(), + original_hash: x.original_hash, + kind, + distance: distance(row, original), + references: x.references, + notes: if original.is_some() { + String::new() + } else { + "original not indexed (yet)".into() + }, + }); + continue; + } + + // 2. Name convention next to an indexed original: exact. + if let Some(n) = &name_conv { + let found = original_candidates(&row.rel_path) + .into_iter() + .find_map(|c| { + idx.by_path + .get(&(row.library_id, c.as_str())) + .copied() + .filter(|o| o.content_hash != row.content_hash) + }); + if let Some(o) = found { + out.push(Proposal { + status: Status::Exact, + method: "name", + library_id: row.library_id, + version_path: row.rel_path.clone(), + version_hash: row.content_hash.clone(), + original_path: o.rel_path.clone(), + original_hash: o.content_hash.clone(), + kind: n.kind.to_string(), + distance: distance(row, Some(o)), + references: Vec::new(), + notes: String::new(), + }); + continue; + } + // A convention name with no original found falls through to the + // heuristics only if it is also a ComfyUI output in aiedits/. + if !in_aiedits { + out.push(unresolved(row, "name", "no original with that name nearby")); + continue; + } + } + + // 3. Heuristics (PNG only; reviewed before applying). + if !is_png { + continue; + } + let loaders = prompt.as_deref().map(loader_names).unwrap_or_default(); + if in_aiedits && !loaders.is_empty() { + out.push(plan_comfy_loader(row, &loaders, &idx)); + } else if let Some(stem) = upscayl { + out.push(plan_prefix(row, &stem, &idx)); + } else if in_aiedits { + out.push(plan_phash(row, rows)); + } + } + out.sort_by(|a, b| { + (a.status as u8, a.library_id, &a.version_path).cmp(&( + b.status as u8, + b.library_id, + &b.version_path, + )) + }); + out +} + +fn distance(version: &FileRow, original: Option<&FileRow>) -> Option { + Some(hamming(version.phash?, original?.phash?)) +} + +fn unresolved(row: &FileRow, method: &'static str, why: &str) -> Proposal { + Proposal { + status: Status::Unresolved, + method, + library_id: row.library_id, + version_path: row.rel_path.clone(), + version_hash: row.content_hash.clone(), + original_path: String::new(), + original_hash: String::new(), + kind: "edit".into(), + distance: None, + references: Vec::new(), + notes: why.to_string(), + } +} + +/// Nearest row to `row` by pHash among `candidates` (needs both hashes). +fn nearest<'a>(row: &FileRow, candidates: &[&'a FileRow]) -> Option<(&'a FileRow, u32)> { + let p = row.phash?; + candidates + .iter() + .filter_map(|c| Some((*c, hamming(p, c.phash?)))) + .min_by_key(|(c, d)| (*d, c.rel_path.clone())) +} + +fn plan_comfy_loader(row: &FileRow, loaders: &[String], idx: &Index) -> Proposal { + let dir = dir_of(&row.rel_path); + let parent = parent_of_dir(dir); + // Per loader name: the matching rows from the first tier that has any, + // preferring the parent of aiedits/, then the same folder, then anywhere + // in the library. Version-looking files never count as originals. + let resolve = |loader: &str| -> Vec<&FileRow> { + let all: Vec<&FileRow> = idx + .by_name + .get(&(row.library_id, loader)) + .map(|v| { + v.iter() + .copied() + .filter(|r| { + r.content_hash != row.content_hash && !is_aiedits_dir(dir_of(&r.rel_path)) + }) + .collect() + }) + .unwrap_or_default(); + for tier in [parent, dir] { + let hit: Vec<&FileRow> = all + .iter() + .copied() + .filter(|r| dir_of(&r.rel_path) == tier) + .collect(); + if !hit.is_empty() { + return hit; + } + } + all + }; + + let mut resolved: Vec<(&String, Vec<&FileRow>)> = Vec::new(); + let mut missing: Vec<&String> = Vec::new(); + for l in loaders { + let hits = resolve(l); + if hits.is_empty() { + missing.push(l); + } else { + resolved.push((l, hits)); + } + } + if resolved.is_empty() { + let mut p = unresolved( + row, + "comfy-loader", + &format!( + "loader input(s) not found in the library: {}", + loaders.join(", ") + ), + ); + // A hint for the reviewer, not a proposal. + let siblings: Vec<&FileRow> = idx + .by_dir + .get(&(row.library_id, parent)) + .map(|v| { + v.iter() + .copied() + .filter(|r| !is_aiedits_dir(dir_of(&r.rel_path))) + .collect() + }) + .unwrap_or_default(); + if let Some((n, d)) = nearest(row, &siblings) { + p.notes + .push_str(&format!("; hint: nearest pHash {} (d={d})", n.rel_path)); + } + return p; + } + + // Primary: the nearest pHash across everything the loaders resolved to. + let flat: Vec<(&String, &FileRow)> = resolved + .iter() + .flat_map(|(l, hits)| hits.iter().map(move |h| (*l, *h))) + .collect(); + let rows_only: Vec<&FileRow> = flat.iter().map(|(_, r)| *r).collect(); + let (primary, dist) = match nearest(row, &rows_only) { + Some((p, d)) => (p, Some(d)), + None => (rows_only[0], None), + }; + let mut notes = Vec::new(); + if resolved.len() > 1 { + notes.push("two loader inputs: primary chosen by nearest pHash, the other recorded as a reference — check".to_string()); + } + if flat.len() > resolved.len() { + notes.push("a loader name matched several files — check".into()); + } + if !missing.is_empty() { + notes.push(format!( + "other loader input(s) not in the library: {}", + missing + .iter() + .map(|s| s.as_str()) + .collect::>() + .join(", ") + )); + } + if dist.is_none() { + notes.push("no pHash to compare".into()); + } + let mut references: Vec = Vec::new(); + for (_, hits) in &resolved { + for h in hits { + if h.content_hash != primary.content_hash && !references.contains(&h.content_hash) { + references.push(h.content_hash.clone()); + } + } + } + Proposal { + status: Status::Review, + method: "comfy-loader", + library_id: row.library_id, + version_path: row.rel_path.clone(), + version_hash: row.content_hash.clone(), + original_path: primary.rel_path.clone(), + original_hash: primary.content_hash.clone(), + kind: "edit".into(), + distance: dist, + references, + notes: notes.join("; "), + } +} + +fn plan_prefix(row: &FileRow, stem: &str, idx: &Index) -> Proposal { + let dir = dir_of(&row.rel_path); + let matches: Vec<&FileRow> = idx + .by_dir + .get(&(row.library_id, dir)) + .map(|v| { + v.iter() + .copied() + .filter(|r| { + r.content_hash != row.content_hash + && name_of(&r.rel_path) + .rsplit_once('.') + .is_some_and(|(s, _)| s == stem) + }) + .collect() + }) + .unwrap_or_default(); + match matches.as_slice() { + [] => unresolved( + row, + "name-prefix", + &format!("no file named {stem}.* beside it"), + ), + [only] => Proposal { + status: Status::Review, + method: "name-prefix", + library_id: row.library_id, + version_path: row.rel_path.clone(), + version_hash: row.content_hash.clone(), + original_path: only.rel_path.clone(), + original_hash: only.content_hash.clone(), + kind: "upscale".into(), + distance: distance(row, Some(only)), + references: Vec::new(), + notes: String::new(), + }, + several => { + let (best, d) = nearest(row, several).unwrap_or((several[0], u32::MAX)); + Proposal { + status: Status::Review, + method: "name-prefix", + library_id: row.library_id, + version_path: row.rel_path.clone(), + version_hash: row.content_hash.clone(), + original_path: best.rel_path.clone(), + original_hash: best.content_hash.clone(), + kind: "upscale".into(), + distance: (d != u32::MAX).then_some(d), + references: Vec::new(), + notes: format!("{} files share that name — check", several.len()), + } + } + } +} + +fn plan_phash(row: &FileRow, rows: &[FileRow]) -> Proposal { + let dir = dir_of(&row.rel_path); + let parent = parent_of_dir(dir); + let candidates: Vec<&FileRow> = rows + .iter() + .filter(|r| { + r.library_id == row.library_id + && r.content_hash != row.content_hash + && !is_aiedits_dir(dir_of(&r.rel_path)) + && !looks_like_a_version(name_of(&r.rel_path)) + && (dir_of(&r.rel_path) == parent || dir_of(&r.rel_path) == dir) + }) + .collect(); + match nearest(row, &candidates) { + Some((o, d)) if d <= MAX_PHASH_DISTANCE => Proposal { + status: Status::Review, + method: "phash", + library_id: row.library_id, + version_path: row.rel_path.clone(), + version_hash: row.content_hash.clone(), + original_path: o.rel_path.clone(), + original_hash: o.content_hash.clone(), + kind: "edit".into(), + distance: Some(d), + references: Vec::new(), + notes: "matched by nearest pHash only".into(), + }, + Some((o, d)) => unresolved( + row, + "phash", + &format!("nearest pHash is {} at distance {d}, too far", o.rel_path), + ), + None => unresolved(row, "phash", "no pHash candidates nearby"), + } +} + +/// What [`apply`] did. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct ApplyReport { + pub linked: usize, + pub already_linked: usize, + pub refused: usize, + /// Reviewed rows whose version row no longer matches the file in the TSV. + pub stale: usize, +} + +/// Write the links. `exact` proposals apply as they are; `reviewed` come +/// from a TSV a human kept, and are checked against `rows` first so a stale +/// file can't link a path that now holds different bytes. +pub fn apply( + dao: &mut dyn VersionDao, + exact: &[Proposal], + reviewed: &[ReviewedLink], + rows: &[FileRow], +) -> ApplyReport { + let mut report = ApplyReport::default(); + let current: HashMap<(i32, &str), &str> = rows + .iter() + .map(|r| ((r.library_id, r.rel_path.as_str()), r.content_hash.as_str())) + .collect(); + + let mut links: Vec = Vec::new(); + for p in exact.iter().filter(|p| p.status == Status::Exact) { + links.push(NewVersionLink { + version_hash: p.version_hash.clone(), + original_hash: p.original_hash.clone(), + kind: p.kind.clone(), + source: if p.method == "xmp" { + SOURCE_XMP + } else { + SOURCE_NAME + } + .to_string(), + library_id: p.library_id, + rel_path: p.version_path.clone(), + references: p.references.clone(), + }); + } + for r in reviewed { + if current.get(&(r.library_id, r.version_path.as_str())) != Some(&r.version_hash.as_str()) { + report.stale += 1; + continue; + } + links.push(NewVersionLink { + version_hash: r.version_hash.clone(), + original_hash: r.original_hash.clone(), + kind: if is_known_kind(&r.kind) { + r.kind.clone() + } else { + "edit".into() + }, + source: SOURCE_BACKFILL.to_string(), + library_id: r.library_id, + rel_path: r.version_path.clone(), + references: r.references.clone(), + }); + } + for link in links { + match dao.link_version(&link) { + Ok(LinkOutcome::Linked) => report.linked += 1, + Ok(LinkOutcome::AlreadyLinked) => report.already_linked += 1, + Ok(LinkOutcome::Rejected(_)) | Err(_) => report.refused += 1, + } + } + report +} + +#[cfg(test)] +mod tests { + use super::super::xmp::test_png::{producer_xmp, write_png}; + use super::*; + use crate::database::SqliteVersionDao; + use crate::database::test::in_memory_db_connection; + use std::sync::{Arc, Mutex}; + + fn row(rel: &str, hash: &str, phash: Option) -> FileRow { + FileRow { + library_id: 1, + rel_path: rel.into(), + content_hash: hash.into(), + phash, + } + } + + fn lib(root: &Path) -> Library { + Library { + id: 1, + name: "main".into(), + root_path: root.to_string_lossy().into(), + enabled: true, + excluded_dirs: vec![], + } + } + + fn prompt(loaders: &[&str]) -> String { + let mut nodes = serde_json::Map::new(); + nodes.insert( + "1".into(), + serde_json::json!({"class_type": "UNETLoader", "inputs": {"unet_name": "m"}}), + ); + for (i, l) in loaders.iter().enumerate() { + nodes.insert( + (i + 2).to_string(), + serde_json::json!({"class_type": "LoadImage", "inputs": {"image": l}}), + ); + } + serde_json::Value::Object(nodes).to_string() + } + + fn write_comfy_png(root: &Path, rel: &str, loaders: &[&str], salt: u8) { + super::super::xmp::test_png::write_png(&root.join(rel), None, Some(&prompt(loaders)), salt); + } + + #[test] + fn loader_names_reads_only_loadimage_inputs_in_node_order() { + let p = prompt(&["a.jpg", "sub/b [input]"]); + assert_eq!(loader_names(&p), vec!["a.jpg", "b"]); + assert!(loader_names("not json").is_empty()); + } + + #[test] + fn already_ingested_xmp_files_are_proposed_as_exact_without_naming_help() { + let tmp = tempfile::tempdir().unwrap(); + let orig_hash = "d5".repeat(32); + write_png( + &tmp.path().join("2009/aiedits/019_19.JPG.restored.png"), + Some(&producer_xmp(&orig_hash, "restored", &[])), + None, + 1, + ); + // An XMP file whose name gives no hint (a renamed copy). + write_png( + &tmp.path().join("misc/renamed.png"), + Some(&producer_xmp( + &orig_hash, + "upscale", + &["ee".repeat(32).as_str()], + )), + None, + 2, + ); + let rows = vec![ + row("2009/019_19.JPG", &orig_hash, None), + row("2009/aiedits/019_19.JPG.restored.png", "v1", None), + row("misc/renamed.png", "v2", None), + ]; + let plan = plan(&[lib(tmp.path())], &rows, &HashSet::new()); + let by_path: HashMap<&str, &Proposal> = + plan.iter().map(|p| (p.version_path.as_str(), p)).collect(); + let a = by_path["2009/aiedits/019_19.JPG.restored.png"]; + assert_eq!((a.status, a.method), (Status::Exact, "xmp")); + assert_eq!(a.original_path, "2009/019_19.JPG"); + let b = by_path["misc/renamed.png"]; + assert_eq!( + (b.status, b.method, b.kind.as_str()), + (Status::Exact, "xmp", "upscale") + ); + assert_eq!(b.references.len(), 1); + } + + #[test] + fn comfy_outputs_are_resolved_by_loader_and_flagged_when_two_inputs() { + let tmp = tempfile::tempdir().unwrap(); + write_comfy_png(tmp.path(), "Amanda/aiedits/out_00001_.png", &["a.jpg"], 1); + write_comfy_png( + tmp.path(), + "Amanda/aiedits/out_00002_.png", + &["a.jpg", "b.jpg"], + 2, + ); + write_comfy_png( + tmp.path(), + "Amanda/aiedits/out_00003_.png", + &["typo.jpg"], + 3, + ); + let rows = vec![ + row("Amanda/a.jpg", "ha", Some(0b1111)), + row("Amanda/b.jpg", "hb", Some(-1)), + row("Amanda/aiedits/out_00001_.png", "v1", Some(0b1110)), + row("Amanda/aiedits/out_00002_.png", "v2", Some(0b1011)), + row("Amanda/aiedits/out_00003_.png", "v3", Some(0)), + ]; + let plan = plan(&[lib(tmp.path())], &rows, &HashSet::new()); + let get = |p: &str| plan.iter().find(|x| x.version_path == p).unwrap(); + + let one = get("Amanda/aiedits/out_00001_.png"); + assert_eq!((one.status, one.method), (Status::Review, "comfy-loader")); + assert_eq!(one.original_path, "Amanda/a.jpg"); + assert_eq!(one.distance, Some(1)); + assert!(one.references.is_empty()); + + let two = get("Amanda/aiedits/out_00002_.png"); + assert_eq!( + two.original_path, "Amanda/a.jpg", + "primary by nearest pHash" + ); + assert_eq!(two.references, vec!["hb"]); + assert!(two.notes.contains("two loader inputs")); + + let lost = get("Amanda/aiedits/out_00003_.png"); + assert_eq!(lost.status, Status::Unresolved); + assert!(lost.original_path.is_empty(), "listed, not guessed"); + } + + #[test] + fn upscayl_names_and_phash_fallback() { + let tmp = tempfile::tempdir().unwrap(); + write_png(&tmp.path().join("p/att_1-4x.png"), None, None, 1); + write_png( + &tmp.path().join("p/pic_upscayl_3x_ultramix_balanced.png"), + None, + None, + 2, + ); + write_png(&tmp.path().join("p/aiedits/mystery.png"), None, None, 3); + let rows = vec![ + row("p/att_1.jpg", "h1", Some(5)), + row("p/pic.jpeg", "h2", Some(5)), + row("p/other.jpg", "h3", Some(0b0111_1111_1111)), + row("p/att_1-4x.png", "u1", Some(5)), + row("p/pic_upscayl_3x_ultramix_balanced.png", "u2", Some(5)), + row("p/aiedits/mystery.png", "m1", Some(7)), + ]; + let plan = plan(&[lib(tmp.path())], &rows, &HashSet::new()); + let get = |p: &str| plan.iter().find(|x| x.version_path == p).unwrap(); + assert_eq!(get("p/att_1-4x.png").original_path, "p/att_1.jpg"); + assert_eq!(get("p/att_1-4x.png").kind, "upscale"); + assert_eq!( + get("p/pic_upscayl_3x_ultramix_balanced.png").original_path, + "p/pic.jpeg" + ); + let m = get("p/aiedits/mystery.png"); + assert_eq!(m.method, "phash"); + assert_eq!(m.status, Status::Review); + assert_eq!( + m.original_path, "p/att_1.jpg", + "distance 2 beats the others" + ); + } + + #[test] + fn linked_rows_are_not_proposed_again() { + let tmp = tempfile::tempdir().unwrap(); + write_png(&tmp.path().join("a.jpg.restored.png"), None, None, 1); + let rows = vec![ + row("a.jpg", "o", None), + row("a.jpg.restored.png", "v", None), + ]; + let linked = HashSet::from(["v".to_string()]); + assert!(plan(&[lib(tmp.path())], &rows, &linked).is_empty()); + let p = plan(&[lib(tmp.path())], &rows, &HashSet::new()); + assert_eq!((p[0].status, p[0].method), (Status::Exact, "name")); + } + + #[test] + fn tsv_round_trip_and_reviewed_application() { + let tmp = tempfile::tempdir().unwrap(); + write_comfy_png(tmp.path(), "d/aiedits/o_00001_.png", &["a.jpg", "b.jpg"], 1); + let rows = vec![ + row("d/a.jpg", "ha", Some(1)), + row("d/b.jpg", "hb", Some(-1)), + row("d/aiedits/o_00001_.png", "v", Some(1)), + row("d/x.jpg", "hx", None), + row("d/x.jpg.edit.png", "vx", None), + ]; + write_png(&tmp.path().join("d/x.jpg.edit.png"), None, None, 9); + let planned = plan(&[lib(tmp.path())], &rows, &HashSet::new()); + let tsv: String = std::iter::once(TSV_HEADER.to_string()) + .chain(planned.iter().map(Proposal::to_tsv)) + .collect::>() + .join("\n"); + let (reviewed, skipped) = parse_reviewed(&tsv); + assert_eq!( + reviewed.len(), + 1, + "only the review row; the exact one isn't re-applied from the file" + ); + assert_eq!(skipped, 1); + assert_eq!(reviewed[0].references, vec!["hb"]); + + let conn = Arc::new(Mutex::new(in_memory_db_connection())); + let mut dao = SqliteVersionDao::from_shared(conn); + // Without --reviewed: exact only. + let report = apply(&mut dao, &planned, &[], &rows); + assert_eq!(report.linked, 1); + assert!(dao.get_link("vx").unwrap().is_some()); + assert!( + dao.get_link("v").unwrap().is_none(), + "heuristic rows never auto-apply" + ); + // With the reviewed file. + let report = apply(&mut dao, &planned, &reviewed, &rows); + assert_eq!(report.linked, 1); + assert_eq!(report.already_linked, 1); + let l = dao.get_link("v").unwrap().unwrap(); + assert_eq!( + (l.original_hash.as_str(), l.source.as_str()), + ("ha", "backfill") + ); + assert_eq!(dao.list_references("v").unwrap(), vec!["hb"]); + + // A stale TSV (the path now holds other bytes) is refused. + let mut stale_rows = rows.clone(); + stale_rows[2].content_hash = "changed".into(); + let report = apply(&mut dao, &[], &reviewed, &stale_rows); + assert_eq!(report.stale, 1); + } +} diff --git a/src/versions/mod.rs b/src/versions/mod.rs index 1100890..2ef7803 100644 --- a/src/versions/mod.rs +++ b/src/versions/mod.rs @@ -14,6 +14,7 @@ //! original's `content_hash`, never a path: ComfyUI writes through a UNC //! share and ImageApi reads `/media/...`, and files get moved. +pub mod backfill; pub mod handlers; pub mod xmp; -- 2.54.0 From 6135034d092938ac73a6b7545d3b5f3a37211fd8 Mon Sep 17 00:00:00 2001 From: Cameron Cordes Date: Fri, 30 Oct 2026 13:53:21 -0400 Subject: [PATCH 5/6] feat(versions): e2e fixtures, check_versions, docs Fixtures gain an XMP-linked version (renamed file), a name-linked version and an edit with a reference photo; e2e.sh counts with include_versions and runs check_versions.py. CLAUDE.md documents the feature. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 78 +++++++++++++++ dev/e2e/README.md | 3 +- dev/e2e/check_versions.py | 113 +++++++++++++++++++++ dev/e2e/e2e.sh | 5 +- dev/e2e/gen_fixtures.py | 201 +++++++++++++++++++++++++++++++++++++- 5 files changed, 394 insertions(+), 6 deletions(-) create mode 100755 dev/e2e/check_versions.py diff --git a/CLAUDE.md b/CLAUDE.md index bf7d59a..631a702 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,6 +77,11 @@ cargo run --bin cleanup_files -- --base-path /path/to/media --database-url ./dat # Create a login user (no HTTP registration route; idempotent) cargo run --bin create_user -- + +# Link photo versions ingested before linking existed. Dry run by default +# (prints a TSV); --apply snapshots the DB first and writes the exact links, +# --reviewed adds the heuristic rows a human kept. See "Photo versions". +cargo run --bin link_versions ``` ## Architecture Overview @@ -351,6 +356,69 @@ the new hash, and the two histories cleanly split. Apollo's `derived.db` cache will need an invalidation hook for the changed hash — design it alongside Branch D. +### Photo versions + +A **version** is a non-destructive restoration, upscale or edit of a photo that +an external process (the ComfyUI node in `comfyui-imageapi-versions`) writes next +to the original, as `/aiedits/.[.].png`. ImageApi +only detects it (library roots stay read-only) and shows original + versions as +**one photo**; a *global* choice (not per user) says which file is displayed. +Code: `src/database/version_dao.rs` (tables, DAO, SQL predicates), +`src/versions/` (linking, endpoints, `xmp.rs` PNG chunk reader, `backfill.rs` +for the `link_versions` binary). + +- **The link is the original's `content_hash`** (`photo_versions.version_hash -> + original_hash`, plus `kind`, `source` and a `(library_id, rel_path)` back-ref), + so it survives renames, moves and the archive handoff. A file is a version of at + most one original; chains flatten to the root. Reference photos (extra context fed + to the model) go to `photo_version_references` as provenance only and are never + hidden or grouped. `photo_version_display(original_hash -> display_hash)` is the + global choice; no row = the original, and a display file with no live row reads as + the original. +- **Linking at ingest** (`watcher::process_new_files`, before any derived work): + (1) a PNG's XMP `xmpMM:DerivedFrom/stRef:documentID = blake3:` (iTXt chunk + `XML:com.adobe.xmp`, read by hand, no decode); (2) the file-name convention next + to an indexed original in the same library (same dir, or the parent of + `aiedits/`). Name links are retried every tick (`sweep_name_links`), so an + original that wasn't hashed yet, or arrives later, still links. Only *new* rows + are examined, so files ingested before this existed need the `link_versions` + binary once. +- **Date/GPS/camera copy**: a version row takes its original's date triple, GPS and + camera fields with `date_taken_source = 'version_of'`, re-copied each tick + (`sync_version_metadata`) when the original changes (manual override, re-resolve). + Only a *converted* original (`date_utc` set) is copied, so the legacy-convention + date drains never fight the copy. Memories therefore put a standalone version on + the original's day. +- **Hiding** uses the same reachability rule as `duplicate_of_hash`, with one + addition: a version row is hidden only while a row for its original exists *in + the current view* (`visible_version_clause`; unscoped = any library). A version + whose original was deleted, or lives outside the requested library, shows as a + normal photo, so nothing disappears silently. Applied in `/photos` (SQL for the + recursive listing, `hidden_version_rows` for the folder walk and tag branch), + `/memories` and reels, `/photos/exif`, `/photos/gps-summary`, unified search, the + camera counts and the media gauges; `include_versions=true` lists them. +- **Tags and favorites** written or read on a version path bind to the original + (`canonical_rel_path` in the tag/favorite DAOs, `get_rel_paths_sharing_content` + for reads). Insight generation on a version path runs against the original. A + *manual date* set on a version path is overwritten by the copy above: send the + original's path. +- **Excluded from derived work** (`NOT_A_VERSION_CLAUSE`): face backlog and ingest + candidates, face stats (numerator and denominator), CLIP backlog and index, and + exact/perceptual duplicate grouping. Thumbnails and previews *are* generated. + Linking invalidates the duplicate caches. +- **Lifecycle**: version file deleted -> the missing-file scan retires its row and + the link plus any display choice naming it become orphan-GC candidates (two-tick, + all-libraries-online), reads fall back to the original meanwhile. Original + deleted -> versions surface as standalone photos and the links are kept (they + revive if the original returns). The back-ref refresh repoints + `photo_versions.(library_id, rel_path)` after a move. +- **`link_versions`** (`src/bin/link_versions.rs`): dry run by default, prints a + TSV. `exact` rows (XMP, file name) are what ingest would have linked and apply + with `--apply`; `review` rows (ComfyUI prompt `LoadImage` input, Upscayl-style + names, nearest pHash) apply only from a reviewed TSV (`--reviewed`), each checked + against the current hash; `unresolved` rows are listed, never guessed. `--apply` + takes a `VACUUM INTO` snapshot first. + ### File Processing Pipeline **Thumbnail Generation:** @@ -554,6 +622,16 @@ POST /image/tags (add tag to file) DELETE /image/tags (remove tag from file) POST /image/tags/batch (bulk tag updates) +// Photo versions (#191): AI restorations / upscales / edits shown as ONE photo +GET /image/versions?path=...&library=... (path of the original or any version -> + { original:{path,library_id,hash}, versions:[{path, + library_id,hash,kind,source,created_at,references, + width,height}], display_hash }) +PUT /image/versions/display (body { path, display_hash|null, library? }; global choice) +// /photos hides versions behind their original (include_versions=true lists them) and adds +// parallel arrays photo_display_paths / photo_version_counts; memories items, search hits and +// /photos/exif rows carry display_path / version_count. All skipped when there are none. + // Memories (week-based grouping) GET /memories?path=...&recursive=true diff --git a/dev/e2e/README.md b/dev/e2e/README.md index 6544964..63804aa 100644 --- a/dev/e2e/README.md +++ b/dev/e2e/README.md @@ -7,7 +7,7 @@ end-to-end and agent-driven UI testing of the SynologyFileViewer app. dev/e2e/e2e.sh up # build + fixtures + reset + start; blocks until ingest is done dev/e2e/e2e.sh reset # back to the pristine baseline (after tests mutate things) dev/e2e/e2e.sh stop -dev/e2e/e2e.sh check # every fixture's wall_clock + true_instant vs the manifest, and /photos/exif range edges +dev/e2e/e2e.sh check # every fixture's wall_clock + true_instant vs the manifest, /photos/exif range edges, photo versions ``` Log in from the app with **server** `http://10.0.2.2:8090` (Android @@ -56,6 +56,7 @@ way phones write containers (that wall, as a UTC `creation_time`). | `private` | `EXCLUDED_DIRS` — never indexed | | `Duplicates` | byte-identical copy of `2021/Paris Trip/IMG_1000.jpg` | | `Empty Album` | folder with no media | +| `Versions/*` | photo versions (#191): an XMP-linked restoration (renamed file), a name-linked upscale (no XMP) and an edit with a reference photo, each under `aiedits/`; the manifest's `versions` block lists them. `e2e.sh check` verifies hiding, counts, display choice and the endpoints | "On This Day" is relative to the generation date — run `e2e.sh fixtures --force && e2e.sh reset` on a later day to keep memories diff --git a/dev/e2e/check_versions.py b/dev/e2e/check_versions.py new file mode 100755 index 0000000..9f16aee --- /dev/null +++ b/dev/e2e/check_versions.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""Photo-version checks (ImageApi #191) against the e2e server. + +Every `versions` entry in the manifest (an XMP-linked version, a name-linked +one and one with a reference photo) must be: + +- linked to its original with the expected kind, source-agnostic of path, and + the reference photo recorded as provenance only; +- hidden from `/photos` (recursive and the folder walk), `/photos/exif` and + `/photos/gps-summary`, but listed with `include_versions=true`; +- reflected on the original's row by `photo_version_counts`, and by + `photo_display_paths` once a display version is chosen; +- served on its own at `/image?path=`. + +The display choice is put back afterwards. Usage: check_versions.py +(run via `e2e.sh check`). +""" + +import json +import os +import subprocess +import sys +import urllib.parse +from pathlib import Path + +E2E = Path(__file__).with_name("e2e.sh") + + +def api(method: str, path: str, body: dict | None = None): + cmd = [str(E2E), "api", method, path] + ([json.dumps(body)] if body is not None else []) + out = subprocess.check_output(cmd) + return json.loads(out) if out.strip() else None + + +def q(path: str) -> str: + return urllib.parse.quote(path) + + +def main() -> int: + manifest = json.load(open(sys.argv[1])) + versions = manifest.get("versions", []) + if not versions: + print("manifest has no versions; regenerate the fixtures (e2e.sh fixtures --force)") + return 1 + failures: list[str] = [] + + def check(ok: bool, what: str) -> None: + if not ok: + failures.append(what) + + listing = api("GET", "photos?path=&recursive=true&sort=nameasc") + full = api("GET", "photos?path=&recursive=true&sort=nameasc&include_versions=true") + exif = {e["file_path"] for e in api("GET", f"photos/exif?date_from=0&date_to={2**62}")["photos"]} + gps = {g["path"] for g in api("GET", "photos/gps-summary?path=")["photos"]} + + for v in versions: + orig, ver = v["original"], v["version"] + name = f"{ver} ({v['source']})" + check(orig in listing["photos"], f"{orig}: original missing from /photos") + check(ver not in listing["photos"], f"{name}: version leaks into /photos") + check(ver in full["photos"], f"{name}: include_versions=true must list it") + check(ver not in exif, f"{name}: version leaks into /photos/exif") + check(ver not in gps, f"{name}: version leaks into /photos/gps-summary") + folder = ver.rsplit("/", 1)[0] + walk = api("GET", f"photos?path={q(folder)}") + check(ver not in walk["photos"], f"{name}: version leaks into the folder walk") + for ref in v["references"]: + check(ref in listing["photos"], f"{ref}: reference photo must stay visible") + + # Counts ride along the original's row. + idx = listing["photos"].index(orig) if orig in listing["photos"] else -1 + counts = listing.get("photo_version_counts", []) + check(idx >= 0 and idx < len(counts) and counts[idx] >= 1, f"{orig}: photo_version_counts") + + # The set, asked from either side. + for asked in (orig, ver): + info = api("GET", f"image/versions?path={q(asked)}") + check(info["original"]["path"] == orig, f"{asked}: original.path") + mine = [x for x in info["versions"] if x["path"] == ver] + check(len(mine) == 1, f"{asked}: version {ver} not listed") + if mine: + check(mine[0]["kind"] == v["kind"], f"{ver}: kind {mine[0]['kind']} != {v['kind']}") + check(len(mine[0]["references"]) == len(v["references"]), f"{ver}: reference count") + for want, got in zip(v["references"], mine[0]["references"]): + check(got.get("path") == want, f"{ver}: reference path {got.get('path')} != {want}") + check(info["display_hash"] is None, f"{asked}: display_hash should start unset") + + # Display choice: set from the version's own path, seen in /photos, cleared. + info = api("GET", f"image/versions?path={q(orig)}") + h = next(x["hash"] for x in info["versions"] if x["path"] == ver) + api("PUT", "image/versions/display", {"path": ver, "display_hash": h}) + try: + after = api("GET", "photos?path=&recursive=true&sort=nameasc") + i = after["photos"].index(orig) + shown = after.get("photo_display_paths", []) + check(i < len(shown) and shown[i] == ver, f"{orig}: photo_display_paths after choosing {ver}") + finally: + api("PUT", "image/versions/display", {"path": orig, "display_hash": None}) + + # A version still downloads on its own. + data = subprocess.run([str(E2E), "api", "GET", f"image?path={q(ver)}"], + capture_output=True) + check(data.returncode == 0 and len(data.stdout) > 1000, f"{ver}: /image?path= did not serve the file") + + if failures: + print("\n".join(f"FAIL {f}" for f in failures)) + print(f"{len(versions)} version fixtures checked, {len(failures)} failure(s)") + return 1 if failures else 0 + + +if __name__ == "__main__": + os.environ.setdefault("E2E_TOKEN", subprocess.check_output([str(E2E), "token"], text=True).strip()) + sys.exit(main()) diff --git a/dev/e2e/e2e.sh b/dev/e2e/e2e.sh index 285982c..1e85bc0 100755 --- a/dev/e2e/e2e.sh +++ b/dev/e2e/e2e.sh @@ -150,7 +150,7 @@ api() { # api METHOD PATH [JSON]; reuses $E2E_TOKEN when set (login is a bcrypt } indexed_count() { - api GET "photos?path=&recursive=true" | python3 -c \ + api GET "photos?path=&recursive=true&include_versions=true" | python3 -c \ 'import json,sys; print(len(json.load(sys.stdin).get("photos", [])))' 2>/dev/null || echo 0 } @@ -273,7 +273,8 @@ case "${1:-help}" in restart) cmd_stop; cmd_start ;; status) cmd_status ;; token) login ;; - check) E2E_TOKEN="$(login)" python3 "$HERE/check_wall_clock.py" "$STATE/manifest.json" ;; + check) E2E_TOKEN="$(login)" python3 "$HERE/check_wall_clock.py" "$STATE/manifest.json" \ + && E2E_TOKEN="$(login)" python3 "$HERE/check_versions.py" "$STATE/manifest.json" ;; api) shift; api "$@" ;; logs) tail -f "$LOG" ;; info) cmd_info ;; diff --git a/dev/e2e/gen_fixtures.py b/dev/e2e/gen_fixtures.py index 95dd114..45d6056 100755 --- a/dev/e2e/gen_fixtures.py +++ b/dev/e2e/gen_fixtures.py @@ -24,8 +24,10 @@ import datetime as dt import json import os import shutil +import struct import subprocess import sys +import zlib from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass, field from pathlib import Path @@ -115,6 +117,14 @@ class Item: tags: list[str] = field(default_factory=list) scenario: str = "" copy_of: str | None = None # byte-identical duplicate of another item + # Photo versions (ImageApi #191): this PNG is a restoration / upscale / edit + # of another item. `xmp` embeds the producer's XMP link (DerivedFrom = the + # original's blake3, Ingredients = `references`), as the ComfyUI node does; + # without it only the `..png` file-name convention links it. + version_of: str | None = None + version_kind: str = "restored" + xmp: bool = False + references: list[str] = field(default_factory=list) # item paths (provenance only) def label_date(self) -> str: if self.taken: @@ -215,6 +225,45 @@ def build_items(today: dt.date) -> list[Item]: "Duplicates/IMG_1000 copy.jpg", "image", copy_of="2021/Paris Trip/IMG_1000.jpg", scenario="exact duplicate of 2021/Paris Trip/IMG_1000.jpg", )) + + # Photo versions (#191). Originals are dated, GPS-tagged JPEGs so the + # version's copied date / GPS is checkable; every case lives in its own + # folder with the producer's `aiedits/` subfolder. + canon_v = ("Canon", "Canon EOS R5") + for folder, stem, when in ( + ("Xmp Linked", "scan_0042", dt.datetime(2004, 6, 12, 14, 30)), + ("Name Linked", "family_shot", dt.datetime(2008, 12, 24, 9, 15)), + ("Reference Photo", "subject", dt.datetime(2010, 3, 7, 16, 45)), + ): + items.append(Item( + f"Versions/{folder}/{stem}.jpg", "image", taken=when, gps="paris", + camera=canon_v, tags=["versions"], scenario=f"versions: original ({folder})", + )) + # Linked by the XMP chunk alone: the name says nothing about the original. + items.append(Item( + "Versions/Xmp Linked/aiedits/enhanced_final.png", "image", version_of= + "Versions/Xmp Linked/scan_0042.jpg", version_kind="restored", xmp=True, + scenario="version linked by XMP only (renamed file)", + )) + # Linked by the file-name convention alone: no XMP in the file. + items.append(Item( + "Versions/Name Linked/aiedits/family_shot.jpg.upscale.png", "image", version_of= + "Versions/Name Linked/family_shot.jpg", version_kind="upscale", + scenario="version linked by file name only (no XMP)", + )) + # An edit that also fed a second photo to the model: the other photo is a + # reference (provenance), never hidden or grouped. + items.append(Item( + "Versions/Reference Photo/other_view.jpg", "image", + taken=dt.datetime(2010, 3, 7, 16, 50), camera=canon_v, + scenario="reference photo of a version: stays an ordinary photo", + )) + items.append(Item( + "Versions/Reference Photo/aiedits/subject.jpg.edit.png", "image", version_of= + "Versions/Reference Photo/subject.jpg", version_kind="edit", xmp=True, + references=["Versions/Reference Photo/other_view.jpg"], + scenario="version with a reference photo (XMP Ingredients)", + )) return items @@ -250,6 +299,139 @@ def _same_day(today: dt.date, year: int, hour: int) -> dt.datetime: return dt.datetime(year, today.month, 28, hour, 0) +# ── photo versions (#191) ─────────────────────────────────────────────── + +try: + import blake3 as _blake3 + + def blake3_hex(data: bytes) -> str: + return _blake3.blake3(data).hexdigest() +except ImportError: # keep the generator dependency-free: reference BLAKE3 + _IV = [0x6A09E667, 0xBB67AE85, 0x3C6EF372, 0xA54FF53A, + 0x510E527F, 0x9B05688C, 0x1F83D9AB, 0x5BE0CD19] + _PERM = [2, 6, 3, 10, 7, 0, 4, 13, 1, 11, 12, 5, 9, 14, 15, 8] + _CHUNK_START, _CHUNK_END, _PARENT, _ROOT = 1, 2, 4, 8 + _M = 0xFFFFFFFF + + + def _compress(cv, block, counter, block_len, flags): + v = list(cv) + _IV[:4] + [counter & _M, counter >> 32, block_len, flags] + m = list(block) + + def g(a, b, c, d, mx, my): + v[a] = (v[a] + v[b] + mx) & _M + v[d] = ((v[d] ^ v[a]) >> 16 | (v[d] ^ v[a]) << 16) & _M + v[c] = (v[c] + v[d]) & _M + v[b] = ((v[b] ^ v[c]) >> 12 | (v[b] ^ v[c]) << 20) & _M + v[a] = (v[a] + v[b] + my) & _M + v[d] = ((v[d] ^ v[a]) >> 8 | (v[d] ^ v[a]) << 24) & _M + v[c] = (v[c] + v[d]) & _M + v[b] = ((v[b] ^ v[c]) >> 7 | (v[b] ^ v[c]) << 25) & _M + + for r in range(7): + g(0, 4, 8, 12, m[0], m[1]); g(1, 5, 9, 13, m[2], m[3]) + g(2, 6, 10, 14, m[4], m[5]); g(3, 7, 11, 15, m[6], m[7]) + g(0, 5, 10, 15, m[8], m[9]); g(1, 6, 11, 12, m[10], m[11]) + g(2, 7, 8, 13, m[12], m[13]); g(3, 4, 9, 14, m[14], m[15]) + if r < 6: + m = [m[i] for i in _PERM] + return [v[i] ^ v[i + 8] for i in range(8)] + [v[i + 8] ^ cv[i] for i in range(8)] + + + def _words(block): + return struct.unpack("<16I", block.ljust(64, b"\0")) + + + def _chunk_output(chunk, counter): + """(cv_in, block_words, counter, block_len, flags) of the chunk's last block, + plus the chaining value entering it.""" + cv = _IV + blocks = [chunk[i:i + 64] for i in range(0, len(chunk), 64)] or [b""] + for n, blk in enumerate(blocks[:-1]): + flags = _CHUNK_START if n == 0 else 0 + cv = _compress(cv, _words(blk), counter, 64, flags)[:8] + last = blocks[-1] + flags = _CHUNK_END | (_CHUNK_START if len(blocks) == 1 else 0) + return cv, _words(last), counter, len(last), flags + + + def blake3_hex(data: bytes) -> str: + """Plain 32-byte BLAKE3 of `data`, lowercase hex (the reference algorithm).""" + chunks = [data[i:i + 1024] for i in range(0, len(data), 1024)] or [b""] + + def subtree(lo, hi, root): + n = hi - lo + if n == 1: + cv, w, ctr, ln, fl = _chunk_output(chunks[lo], lo) + return _compress(cv, w, ctr, ln, fl | (_ROOT if root else 0))[:8] + split = 1 << ((n - 1).bit_length() - 1) # largest power of two below n + left = subtree(lo, lo + split, False) + right = subtree(lo + split, hi, False) + flags = _PARENT | (_ROOT if root else 0) + return _compress(_IV, left + right, 0, 64, flags)[:8] + + return struct.pack("<8I", *subtree(0, len(chunks), True)).hex() + +XMP_KEY = "XML:com.adobe.xmp" +IMAGEAPI_NS = "https://git.crkc.xyz/Apps/ImageApi/ns/versions/1.0/" + + +def _resource_ref(file_hash: str, name: str) -> str: + return (f"blake3:{file_hash}" + f"{name}") + + +def build_xmp(source_hash: str, source_name: str, kind: str, + references: list[tuple[str, str]]) -> str: + """The packet comfyui-imageapi-versions writes (versioning.build_xmp).""" + ingredients = "" + if references: + lis = "".join( + f'{_resource_ref(h, n)}' + for h, n in references + ) + ingredients = f"{lis}" + return ( + '' + '' + '' + '' + f'{_resource_ref(source_hash, source_name)}' + f"{ingredients}{kind}" + "" + '' + ) + + +def embed_xmp(png: Path, xmp: str) -> None: + """Insert the XMP as an uncompressed iTXt chunk right after IHDR.""" + data = png.read_bytes() + assert data[:8] == b"\x89PNG\r\n\x1a\n", f"{png} is not a PNG" + body = XMP_KEY.encode() + b"\0\0\0\0\0" + xmp.encode("utf-8") + chunk = struct.pack(">I", len(body)) + b"iTXt" + body + chunk += struct.pack(">I", zlib.crc32(b"iTXt" + body) & 0xFFFFFFFF) + ihdr_end = 8 + 4 + 4 + 13 + 4 + png.write_bytes(data[:ihdr_end] + chunk + data[ihdr_end:]) + + +def link_versions(items: list["Item"], media: Path) -> None: + """Write the XMP link into each `xmp` version. Runs after EXIF stamping: + the link is the original's hash over its final bytes.""" + by_path = {it.rel: it for it in items} + for it in items: + if not (it.version_of and it.xmp): + continue + original = media / it.version_of + refs = [(blake3_hex((media / r).read_bytes()), Path(r).name) for r in it.references] + embed_xmp(media / it.rel, + build_xmp(blake3_hex(original.read_bytes()), original.name, + it.version_kind, refs)) + assert by_path[it.version_of].kind == "image" + + def render(item: Item, idx: int, media: Path) -> None: out = media / item.rel out.parent.mkdir(parents=True, exist_ok=True) @@ -355,6 +537,7 @@ def main() -> int: if it.copy_of: (media / it.rel).parent.mkdir(parents=True, exist_ok=True) shutil.copyfile(media / it.copy_of, media / it.rel) + link_versions(items, media) # Pin mtimes last: exiftool rewrites (and so touches) the files. for it in items: when = it.mtime or it.taken or dt.datetime(2024, 1, 1) @@ -387,6 +570,15 @@ def main() -> int: "excluded_dirs": ["/private"], "places": {k: {"lat": v[0], "lon": v[1]} for k, v in PLACES.items()}, "tags": tags, + # Photo versions (#191): hidden behind `original` in listings; the + # reference photos stay visible. `source` is how ImageApi links it. + "versions": [ + { + "original": it.version_of, "version": it.rel, "kind": it.version_kind, + "source": "xmp" if it.xmp else "name", "references": it.references, + } + for it in items if it.version_of + ], "items": [ { "path": it.rel, "kind": it.kind, "scenario": it.scenario, @@ -394,9 +586,12 @@ def main() -> int: "taken": it.taken.isoformat() if it.taken else None, "gps": it.gps, "camera": it.camera[1] if it.camera else None, "tags": it.tags, "duplicate_of": it.copy_of, - # A duplicate carries its source's bytes, EXIF included. - "wall_clock": expected_wall_clock(by_path.get(it.copy_of, it)), - "true_instant": expected_true_instant(by_path.get(it.copy_of, it)), + "version_of": it.version_of, + # A duplicate carries its source's bytes, EXIF included; a + # version takes its original's date and GPS (ImageApi copies + # them onto the version row). + "wall_clock": expected_wall_clock(by_path.get(it.copy_of or it.version_of, it)), + "true_instant": expected_true_instant(by_path.get(it.copy_of or it.version_of, it)), } for it in items ], -- 2.54.0 From 9f50843479e90490f0b3727b8455ad7386843a0e Mon Sep 17 00:00:00 2001 From: Cameron Cordes Date: Fri, 30 Oct 2026 14:27:54 -0400 Subject: [PATCH 6/6] fix(versions): review findings on PR #193 - A version no longer hides behind an original that duplicates resolve demoted (visible_version_clause, hidden_version_rows). - Recording a link drops the version's faces, CLIP data and clip failures (covers link-at-ingest and link_versions --apply). - The display file must live in the original's library; otherwise the original shows, and PUT /image/versions/display answers 400. - link_new_files looks up hashes in chunks of 500 and logs errors. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 11 +- src/database/version_dao.rs | 231 ++++++++++++++++++++++++++---- src/versions/backfill.rs | 48 +++++++ src/versions/handlers.rs | 23 ++- src/versions/integration_tests.rs | 101 +++++++++++++ src/versions/mod.rs | 35 +++-- 6 files changed, 402 insertions(+), 47 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 631a702..7320b69 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -390,13 +390,18 @@ for the `link_versions` binary). date drains never fight the copy. Memories therefore put a standalone version on the original's day. - **Hiding** uses the same reachability rule as `duplicate_of_hash`, with one - addition: a version row is hidden only while a row for its original exists *in - the current view* (`visible_version_clause`; unscoped = any library). A version - whose original was deleted, or lives outside the requested library, shows as a + addition: a version row is hidden only while its original is *shown in the + current view*: a row exists there and was not demoted by duplicates resolve + (`visible_version_clause`; unscoped = any library). A version + whose original was deleted, demoted, or lives outside the requested library, shows as a normal photo, so nothing disappears silently. Applied in `/photos` (SQL for the recursive listing, `hidden_version_rows` for the folder walk and tag branch), `/memories` and reels, `/photos/exif`, `/photos/gps-summary`, unified search, the camera counts and the media gauges; `include_versions=true` lists them. +- **Display file** is fetched through the original's library, so the display + choice only counts when the chosen version has a row there (else the original + shows; `PUT /image/versions/display` answers 400). Recording a link also drops + the version's `face_detections`, CLIP columns and `clip_failures`. - **Tags and favorites** written or read on a version path bind to the original (`canonical_rel_path` in the tag/favorite DAOs, `get_rel_paths_sharing_content` for reads). Insight generation on a version path runs against the original. A diff --git a/src/database/version_dao.rs b/src/database/version_dao.rs index 68bed30..355312a 100644 --- a/src/database/version_dao.rs +++ b/src/database/version_dao.rs @@ -47,20 +47,33 @@ pub fn is_known_kind(kind: &str) -> bool { /// the original may be gone.) Library ids are `i32`, so inlining the list /// is safe. pub fn visible_version_clause(view_library_ids: &[i32]) -> String { - let scope = if view_library_ids.is_empty() { - String::new() - } else { - let list = view_library_ids - .iter() - .map(i32::to_string) - .collect::>() - .join(","); - format!(" AND orig.library_id IN ({list})") - }; format!( "NOT EXISTS (SELECT 1 FROM photo_versions pv \ JOIN image_exif orig ON orig.content_hash = pv.original_hash \ - WHERE pv.version_hash = image_exif.content_hash{scope})" + WHERE pv.version_hash = image_exif.content_hash AND {})", + reachable_original_condition(view_library_ids) + ) +} + +/// Condition over an `image_exif` row aliased `orig`: the row is an original +/// that is itself shown in the view. It must be in the view, and not demoted +/// by duplicates resolve, using the duplicate-listing rule: unscoped, any +/// `duplicate_of_hash` hides it; scoped, only when the survivor is in the +/// view too. Otherwise its versions would vanish along with it. +fn reachable_original_condition(view_library_ids: &[i32]) -> String { + if view_library_ids.is_empty() { + return "orig.duplicate_of_hash IS NULL".to_string(); + } + let list = view_library_ids + .iter() + .map(i32::to_string) + .collect::>() + .join(","); + format!( + "orig.library_id IN ({list}) AND (orig.duplicate_of_hash IS NULL OR NOT EXISTS \ + (SELECT 1 FROM image_exif AS survivor \ + WHERE survivor.content_hash = orig.duplicate_of_hash \ + AND survivor.library_id IN ({list})))" ) } @@ -157,7 +170,8 @@ pub struct HashedFile { #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct PhotoVersionSummary { pub version_count: u32, - /// `(library_id, rel_path)` of the chosen version file. + /// `(library_id, rel_path)` of the chosen version file; the library is + /// always the original's own. pub display: Option<(i32, String)>, } @@ -368,6 +382,21 @@ impl VersionDao for SqliteVersionDao { .bind::(&link.rel_path) .execute(conn)?; + // A version is never a face / CLIP candidate; if it was + // ingested before the link, drop what was already derived. + sql_query("DELETE FROM face_detections WHERE content_hash = ?") + .bind::(&link.version_hash) + .execute(conn)?; + sql_query( + "UPDATE image_exif SET clip_embedding = NULL, clip_model_version = NULL \ + WHERE content_hash = ?", + ) + .bind::(&link.version_hash) + .execute(conn)?; + sql_query("DELETE FROM clip_failures WHERE content_hash = ?") + .bind::(&link.version_hash) + .execute(conn)?; + // Versions that pointed at this file now point at the root. sql_query("UPDATE photo_versions SET original_hash = ? WHERE original_hash = ?") .bind::(&root) @@ -639,13 +668,12 @@ impl VersionDao for SqliteVersionDao { if o.version_count == 0 { continue; } - let display = display_by_hash.get(&o.original_hash).and_then(|files| { - files - .iter() - .find(|(lib, _)| *lib == o.library_id) - .or_else(|| files.first()) - .cloned() - }); + // Clients fetch the display file through the original's + // library, so a chosen version with no row there falls back + // to the original. + let display = display_by_hash + .get(&o.original_hash) + .and_then(|files| files.iter().find(|(lib, _)| *lib == o.library_id).cloned()); out.insert( (o.library_id, o.rel_path), PhotoVersionSummary { @@ -671,22 +699,13 @@ impl VersionDao for SqliteVersionDao { #[diesel(sql_type = Text)] rel_path: String, } - let scope = if view_library_ids.is_empty() { - String::new() - } else { - let list = view_library_ids - .iter() - .map(i32::to_string) - .collect::>() - .join(","); - format!(" AND orig.library_id IN ({list})") - }; let sql = format!( "SELECT image_exif.library_id AS library_id, image_exif.rel_path AS rel_path \ FROM image_exif \ JOIN photo_versions pv ON pv.version_hash = image_exif.content_hash \ WHERE EXISTS (SELECT 1 FROM image_exif orig \ - WHERE orig.content_hash = pv.original_hash{scope})" + WHERE orig.content_hash = pv.original_hash AND {})", + reachable_original_condition(view_library_ids) ); let mut conn = self.connection.lock().expect("Unable to get VersionDao"); sql_query(sql) @@ -1089,4 +1108,156 @@ mod tests { let files = dao.files_for_hashes(&["o".to_string()], None).unwrap(); assert_eq!(files["o"].0, 1); } + fn visible_in(c: &Arc>, libs: &[i32]) -> Vec { + #[derive(QueryableByName)] + struct R { + #[diesel(sql_type = Text)] + rel_path: String, + } + let mut rows: Vec = sql_query(format!( + "SELECT rel_path FROM image_exif WHERE {} ORDER BY rel_path", + visible_version_clause(libs) + )) + .load::(&mut *c.lock().unwrap()) + .unwrap() + .into_iter() + .map(|r| r.rel_path) + .collect(); + rows.sort(); + rows + } + + #[test] + fn a_version_surfaces_when_its_original_is_demoted_as_a_duplicate() { + let (mut dao, c) = dao(); + insert_exif(&c, 1, "orig.jpg", "o"); + insert_exif(&c, 1, "orig.jpg.restored.png", "v"); + insert_exif(&c, 1, "survivor.jpg", "s"); + dao.link_version(&link("v", "o", "orig.jpg.restored.png")) + .unwrap(); + assert_eq!(visible_in(&c, &[]), vec!["orig.jpg", "survivor.jpg"]); + + exec( + &c, + "UPDATE image_exif SET duplicate_of_hash = 's' WHERE content_hash = 'o'", + ); + // Unscoped: O is hidden by the duplicate rule, so V stands alone. + let shown = visible_in(&c, &[]); + assert!( + shown.contains(&"orig.jpg.restored.png".to_string()), + "{shown:?}" + ); + assert!(dao.hidden_version_rows(&[]).unwrap().is_empty()); + // Scoped with the survivor in view: same. + assert!(visible_in(&c, &[1]).contains(&"orig.jpg.restored.png".to_string())); + assert!(dao.hidden_version_rows(&[1]).unwrap().is_empty()); + + // Scoped to a view that lacks the survivor, O stays visible (the + // duplicate rule), so V hides behind it again. + insert_exif(&c, 2, "elsewhere.jpg", "e"); + exec( + &c, + "UPDATE image_exif SET duplicate_of_hash = 'e' WHERE content_hash = 'o'", + ); + assert!(!visible_in(&c, &[1]).contains(&"orig.jpg.restored.png".to_string())); + assert_eq!(dao.hidden_version_rows(&[1]).unwrap().len(), 1); + } + + #[test] + fn display_file_comes_from_the_originals_library_or_not_at_all() { + let (mut dao, c) = dao(); + insert_exif(&c, 1, "orig.jpg", "o"); + // Same bytes in two libraries. + insert_exif(&c, 2, "copy/v.png", "v"); + insert_exif(&c, 1, "aiedits/v.png", "v"); + dao.link_version(&link("v", "o", "aiedits/v.png")).unwrap(); + dao.set_display("o", Some("v")).unwrap(); + let s = dao.photo_summaries().unwrap(); + assert_eq!( + s[&(1, "orig.jpg".to_string())].display, + Some((1, "aiedits/v.png".to_string())) + ); + + // Only the other library holds the chosen bytes: show the original. + exec( + &c, + "DELETE FROM image_exif WHERE library_id = 1 AND content_hash = 'v'", + ); + let s = dao.photo_summaries().unwrap(); + let sum = &s[&(1, "orig.jpg".to_string())]; + assert_eq!(sum.version_count, 1); + assert_eq!(sum.display, None); + } + + #[test] + fn linking_drops_faces_and_clip_data_derived_before_the_link() { + let (mut dao, c) = dao(); + insert_exif(&c, 1, "orig.jpg", "o"); + insert_exif(&c, 1, "v.png", "v"); + exec( + &c, + "UPDATE image_exif SET clip_embedding = x'00', clip_model_version = 'm' \ + WHERE content_hash IN ('o', 'v')", + ); + for h in ["o", "v"] { + exec( + &c, + &format!( + "INSERT INTO face_detections (library_id, content_hash, rel_path, bbox_x, \ + embedding, source, status, model_version, created_at) \ + VALUES (1, '{h}', 'x', 0.1, x'00', 'auto', 'detected', 'm', 0)" + ), + ); + exec( + &c, + &format!( + "INSERT INTO clip_failures (library_id, rel_path, content_hash, error, failed_at) \ + VALUES (1, 'p-{h}', '{h}', 'boom', 0)" + ), + ); + } + dao.link_version(&link("v", "o", "v.png")).unwrap(); + + let n = |sql: &str| -> i64 { + #[derive(QueryableByName)] + struct N { + #[diesel(sql_type = BigInt)] + n: i64, + } + sql_query(sql) + .get_result::(&mut *c.lock().unwrap()) + .unwrap() + .n + }; + assert_eq!( + n("SELECT COUNT(*) AS n FROM face_detections WHERE content_hash='v'"), + 0 + ); + assert_eq!( + n("SELECT COUNT(*) AS n FROM clip_failures WHERE content_hash='v'"), + 0 + ); + assert_eq!( + n( + "SELECT COUNT(*) AS n FROM image_exif WHERE content_hash='v' \ + AND (clip_embedding IS NOT NULL OR clip_model_version IS NOT NULL)" + ), + 0 + ); + // The original keeps its own. + assert_eq!( + n("SELECT COUNT(*) AS n FROM face_detections WHERE content_hash='o'"), + 1 + ); + assert_eq!( + n("SELECT COUNT(*) AS n FROM clip_failures WHERE content_hash='o'"), + 1 + ); + assert_eq!( + n( + "SELECT COUNT(*) AS n FROM image_exif WHERE content_hash='o' AND clip_embedding IS NOT NULL" + ), + 1 + ); + } } diff --git a/src/versions/backfill.rs b/src/versions/backfill.rs index 0d7af8b..32d965b 100644 --- a/src/versions/backfill.rs +++ b/src/versions/backfill.rs @@ -928,4 +928,52 @@ mod tests { let report = apply(&mut dao, &[], &reviewed, &stale_rows); assert_eq!(report.stale, 1); } + #[test] + fn apply_drops_derived_data_of_an_already_ingested_version() { + let conn = Arc::new(Mutex::new(in_memory_db_connection())); + let exec = |sql: &str| { + diesel::RunQueryDsl::execute(diesel::sql_query(sql), &mut *conn.lock().unwrap()) + .unwrap_or_else(|e| panic!("{sql}: {e}")); + }; + exec( + "INSERT INTO image_exif (library_id, rel_path, created_time, last_modified, content_hash, clip_embedding, clip_model_version) VALUES (1, 'v.png', 0, 0, 'v', x'00', 'm')", + ); + exec( + "INSERT INTO face_detections (library_id, content_hash, rel_path, bbox_x, embedding, source, status, model_version, created_at) VALUES (1, 'v', 'v.png', 0.1, x'00', 'auto', 'detected', 'm', 0)", + ); + exec( + "INSERT INTO clip_failures (library_id, rel_path, content_hash, error, failed_at) VALUES (1, 'v.png', 'v', 'boom', 0)", + ); + let mut dao = SqliteVersionDao::from_shared(conn.clone()); + let rows = vec![row("v.png", "v", None)]; + let reviewed = vec![ReviewedLink { + method: "phash".into(), + library_id: 1, + version_path: "v.png".into(), + kind: "edit".into(), + version_hash: "v".into(), + original_hash: "o".into(), + references: vec![], + }]; + assert_eq!(apply(&mut dao, &[], &reviewed, &rows).linked, 1); + + #[derive(diesel::QueryableByName)] + struct N { + #[diesel(sql_type = diesel::sql_types::BigInt)] + n: i64, + } + for sql in [ + "SELECT COUNT(*) AS n FROM face_detections WHERE content_hash = 'v'", + "SELECT COUNT(*) AS n FROM clip_failures WHERE content_hash = 'v'", + "SELECT COUNT(*) AS n FROM image_exif WHERE content_hash = 'v' AND (clip_embedding IS NOT NULL OR clip_model_version IS NOT NULL)", + ] { + let n = diesel::RunQueryDsl::get_result::( + diesel::sql_query(sql), + &mut *conn.lock().unwrap(), + ) + .unwrap() + .n; + assert_eq!(n, 0, "{sql}"); + } + } } diff --git a/src/versions/handlers.rs b/src/versions/handlers.rs index acdb25c..f88481d 100644 --- a/src/versions/handlers.rs +++ b/src/versions/handlers.rs @@ -159,9 +159,16 @@ pub fn build_response( }); } - let display_hash = dao - .get_display(original_hash)? - .filter(|d| versions.iter().any(|v| &v.hash == d)); + // Clients fetch the display file through the original's library, so a + // choice with no row there reads as the original (as `/photos` does). + let display_hash = dao.get_display(original_hash)?.filter(|d| { + versions.iter().any(|v| { + &v.hash == d + && original + .as_ref() + .is_none_or(|o| o.library_id == v.library_id) + }) + }); Ok(VersionsResponse { original, versions, @@ -232,6 +239,16 @@ pub async fn put_display( if !current.versions.iter().any(|v| v.hash == hash) { return HttpResponse::BadRequest().body("display_hash is not a version of this photo"); } + // The display file is fetched through the original's library. + let original_lib = current.original.as_ref().map(|o| o.library_id); + if !current + .versions + .iter() + .any(|v| v.hash == hash && original_lib.is_none_or(|l| l == v.library_id)) + { + return HttpResponse::BadRequest() + .body("display_hash has no file in the original's library"); + } } if let Err(e) = dao.set_display(&resolved.original_hash, wanted) { warn!("put_display failed: {:?}", e); diff --git a/src/versions/integration_tests.rs b/src/versions/integration_tests.rs index a97296f..0ebae04 100644 --- a/src/versions/integration_tests.rs +++ b/src/versions/integration_tests.rs @@ -837,3 +837,104 @@ async fn face_stats_denominator_leaves_versions_out() { // denominator because they are never scanned. assert_eq!(stats.total_photos, 2); } + +#[actix_rt::test] +async fn linking_at_ingest_drops_derived_data_of_an_already_ingested_version() { + let fx = fx(); + let original = fx.put("scan.jpg", b"original bytes"); + let xmp = producer_xmp(&original, "restored", &[]); + let vh = fx.put_png("renamed.png", Some(&xmp), 1); + fx.exec(&format!( + "UPDATE image_exif SET clip_embedding = x'00', clip_model_version = 'm' \ + WHERE content_hash = '{vh}'" + )); + fx.exec(&format!( + "INSERT INTO face_detections (library_id, content_hash, rel_path, bbox_x, embedding, \ + source, status, model_version, created_at) \ + VALUES (1, '{vh}', 'renamed.png', 0.1, x'00', 'auto', 'detected', 'm', 0)" + )); + fx.exec(&format!( + "INSERT INTO clip_failures (library_id, rel_path, content_hash, error, failed_at) \ + VALUES (1, 'renamed.png', '{vh}', 'boom', 0)" + )); + + let files = vec![(fx.root.join("renamed.png"), "renamed.png".to_string())]; + assert_eq!( + link_new_files(&fx.library, &files, &fx.exif_arc(), &fx.state.version_dao), + 1 + ); + assert_eq!( + fx.count(&format!( + "SELECT COUNT(*) AS n FROM face_detections WHERE content_hash = '{vh}'" + )), + 0 + ); + assert_eq!( + fx.count(&format!( + "SELECT COUNT(*) AS n FROM clip_failures WHERE content_hash = '{vh}'" + )), + 0 + ); + assert_eq!( + fx.count(&format!( + "SELECT COUNT(*) AS n FROM image_exif WHERE content_hash = '{vh}' \ + AND (clip_embedding IS NOT NULL OR clip_model_version IS NOT NULL)" + )), + 0 + ); +} + +#[actix_rt::test] +async fn link_new_files_links_across_more_than_one_lookup_chunk() { + let fx = fx(); + let original = fx.put("scan.jpg", b"original bytes"); + let xmp = producer_xmp(&original, "restored", &[]); + let vh = fx.put_png("zz/renamed.png", Some(&xmp), 1); + // Enough unrelated PNG paths to force several lookup chunks; the real + // one sits in the last. + let mut files: Vec<(PathBuf, String)> = (0..1200) + .map(|i| (fx.root.join(format!("n/{i}.png")), format!("n/{i}.png"))) + .collect(); + files.push((fx.root.join("zz/renamed.png"), "zz/renamed.png".to_string())); + assert_eq!( + link_new_files(&fx.library, &files, &fx.exif_arc(), &fx.state.version_dao), + 1 + ); + assert!( + fx.state + .version_dao + .lock() + .unwrap() + .get_link(&vh) + .unwrap() + .is_some() + ); +} + +#[actix_rt::test] +async fn display_choice_needs_a_file_in_the_originals_library() { + let fx = fx(); + let original = fx.put("scan.jpg", b"original bytes"); + let xmp = producer_xmp(&original, "restored", &[]); + let vh = fx.put_png("aiedits/scan.jpg.restored.png", Some(&xmp), 1); + fx.link(&vh, &original, "aiedits/scan.jpg.restored.png"); + // The version's bytes live only in a second library. + fx.exec("INSERT INTO libraries (id, name, root_path, created_at) VALUES (2, 'two', '/tmp/two-nope', 0)"); + fx.exec( + "UPDATE image_exif SET library_id = 2 WHERE rel_path = 'aiedits/scan.jpg.restored.png'", + ); + let app = photos_app!(fx); + let req = awtest::TestRequest::put() + .uri("/image/versions/display") + .insert_header(("Authorization", format!("Bearer {}", Fx::token()))) + .set_json(serde_json::json!({"path": "scan.jpg", "display_hash": vh})) + .to_request(); + assert_eq!(awtest::call_service(&app, req).await.status(), 400); + // Clearing is still fine. + let req = awtest::TestRequest::put() + .uri("/image/versions/display") + .insert_header(("Authorization", format!("Bearer {}", Fx::token()))) + .set_json(serde_json::json!({"path": "scan.jpg", "display_hash": null})) + .to_request(); + assert_eq!(awtest::call_service(&app, req).await.status(), 200); +} diff --git a/src/versions/mod.rs b/src/versions/mod.rs index 2ef7803..c46359a 100644 --- a/src/versions/mod.rs +++ b/src/versions/mod.rs @@ -221,22 +221,35 @@ pub fn link_new_files( return 0; } let rels: Vec = pngs.iter().map(|(_, r)| r.clone()).collect(); - let rows = exif_dao - .lock() - .expect("Unable to lock ExifDao") - .get_exif_batch(Some(library.id), &rels) - .unwrap_or_default(); + // Chunked: a first scan can register far more paths than SQLite's + // bound-variable limit allows in one `IN (...)`. + let mut rows = Vec::with_capacity(rels.len()); + for chunk in rels.chunks(500) { + match exif_dao + .lock() + .expect("Unable to lock ExifDao") + .get_exif_batch(Some(library.id), chunk) + { + Ok(r) => rows.extend(r), + Err(e) => warn!( + "versions: XMP link lookup failed for {} paths in '{}': {:?}", + chunk.len(), + library.name, + e + ), + } + } + let hashes: std::collections::HashMap<&str, &str> = rows + .iter() + .filter_map(|r| Some((r.file_path.as_str(), r.content_hash.as_deref()?))) + .collect(); let mut linked = 0; for (abs, rel) in pngs { - let Some(hash) = rows - .iter() - .find(|r| &r.file_path == rel) - .and_then(|r| r.content_hash.clone()) - else { + let Some(hash) = hashes.get(rel.as_str()) else { continue; }; - if let Some(link) = xmp_link(library, rel, abs, &hash) + if let Some(link) = xmp_link(library, rel, abs, hash) && record_link(version_dao, &link) { linked += 1; -- 2.54.0