feat(versions): link at ingest, hide in listings, version endpoints
XMP (PNG iTXt DerivedFrom, native chunk reader) then file-name convention links at ingest; unlinked name candidates are retried each tick. Versions are hidden from /photos (SQL for the recursive listing, hidden-row set for the walk and tag branches), /memories, reels, /photos/exif, the GPS summary, unified search and the media gauges while their original is reachable in the view; include_versions=true lists them. /photos gains the parallel arrays photo_display_paths / photo_version_counts, memories / search hits / /photos/exif rows gain display_path / version_count, all skipped when empty. GET /image/versions and PUT /image/versions/display (library optional). Tags and favorites on a version path bind to the original; faces, CLIP and duplicate grouping skip version hashes; insight generation redirects to the original. Orphan GC and the back-ref refresh cover the new tables. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
044240037a
commit
080a75c336
24 files changed
+1614
-47
No files matched your search
+65
-4
@@ -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
|
||||
|
||||
Reference in new issue
Block a user