feat(versions): link at ingest, hide in listings, version endpoints

XMP (PNG iTXt DerivedFrom, native chunk reader) then file-name convention
links at ingest; unlinked name candidates are retried each tick. Versions
are hidden from /photos (SQL for the recursive listing, hidden-row set for
the walk and tag branches), /memories, reels, /photos/exif, the GPS summary,
unified search and the media gauges while their original is reachable in the
view; include_versions=true lists them. /photos gains the parallel arrays
photo_display_paths / photo_version_counts, memories / search hits /
/photos/exif rows gain display_path / version_count, all skipped when empty.
GET /image/versions and PUT /image/versions/display (library optional).
Tags and favorites on a version path bind to the original; faces, CLIP and
duplicate grouping skip version hashes; insight generation redirects to the
original. Orphan GC and the back-ref refresh cover the new tables.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Cameron CordesandClaude Opus 5.5 committed 2026-10-30 11:28:34 -04:00
1 parent 044240037a
commit 080a75c336
24 files changed
+1614 -47

No files matched your search

+65 -4
View File
@@ -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<dyn PreviewDao>
));
let version_dao = Arc::new(Mutex::new(
Box::new(SqliteVersionDao::new()) as Box<dyn VersionDao>
));
let face_dao = Arc::new(Mutex::new(
Box::new(faces::SqliteFaceDao::new()) as Box<dyn faces::FaceDao>
));
@@ -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<String> = 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<Mutex<Box<dyn PreviewDao>>>,
face_dao: Arc<Mutex<Box<dyn faces::FaceDao>>>,
tag_dao: Arc<Mutex<Box<dyn tags::TagDao>>>,
version_dao: Arc<Mutex<Box<dyn VersionDao>>>,
face_client: crate::ai::face_client::FaceClient,
excluded_dirs: &[String],
modified_since: Option<SystemTime>,
@@ -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