refactor(watcher): extract register_new_files for targeted ingest
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4105430681
commit
ebed15bd59
1 file changed
+130
-113
+130
-113
@@ -740,6 +740,135 @@ pub fn playlist_needs_generation(video_path: &Path, playlist_path: &Path) -> boo
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Register files in `image_exif` (hash, perceptual hashes, EXIF, resolved
|
||||||
|
/// date). Returns the `(absolute, rel_path)` pairs whose row was written.
|
||||||
|
/// Shared by the watcher tick and the targeted ingest after an Enhance
|
||||||
|
/// approval (#192).
|
||||||
|
pub fn register_new_files(
|
||||||
|
library: &libraries::Library,
|
||||||
|
files_needing_row: Vec<(PathBuf, String)>,
|
||||||
|
exif_dao: &Arc<Mutex<Box<dyn ExifDao>>>,
|
||||||
|
) -> Vec<(PathBuf, String)> {
|
||||||
|
let mut newly_registered: Vec<(PathBuf, String)> = Vec::new();
|
||||||
|
if files_needing_row.is_empty() {
|
||||||
|
return newly_registered;
|
||||||
|
}
|
||||||
|
info!(
|
||||||
|
"Registering {} new files in image_exif",
|
||||||
|
files_needing_row.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
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
|
||||||
|
// every file gets a content_hash, even when EXIF is absent.
|
||||||
|
let (content_hash, size_bytes) = match content_hash::compute(&file_path) {
|
||||||
|
Ok(id) => (Some(id.content_hash), Some(id.size_bytes)),
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Failed to hash {}: {:?}", file_path.display(), e);
|
||||||
|
(None, None)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Perceptual hashes (pHash + dHash). Best-effort — None for
|
||||||
|
// videos and decode failures. Drives near-duplicate detection
|
||||||
|
// in the Apollo duplicates surface; failure here is non-fatal
|
||||||
|
// and never blocks indexing.
|
||||||
|
let perceptual = perceptual_hash::compute(&file_path);
|
||||||
|
|
||||||
|
// EXIF is best-effort enrichment. When extraction fails (or the
|
||||||
|
// file type doesn't support EXIF) we still store a row with all
|
||||||
|
// EXIF fields NULL; the file remains visible to sort-by-date
|
||||||
|
// and tag queries via its rel_path and filesystem timestamps.
|
||||||
|
let exif_fields = if exif::supports_exif(&file_path) {
|
||||||
|
match exif::extract_exif_from_path(&file_path) {
|
||||||
|
Ok(data) => Some(data),
|
||||||
|
Err(e) => {
|
||||||
|
debug!(
|
||||||
|
"No EXIF or parse error for {}: {:?}",
|
||||||
|
file_path.display(),
|
||||||
|
e
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// Canonical date_taken via the waterfall — kamadak-exif (already
|
||||||
|
// computed above) → exiftool fallback for videos / MakerNote /
|
||||||
|
// QuickTime → filename regex → earliest_fs_time. Source is
|
||||||
|
// recorded so the per-tick backfill drain can re-run weak
|
||||||
|
// resolutions later. The EXIF branch additionally runs the
|
||||||
|
// camera-offset cascade to produce the new-contract columns
|
||||||
|
// (date_utc / camera_offset_minutes / date_taken_tz_source,
|
||||||
|
// ADR-0001) while the legacy date_taken stays byte-identical.
|
||||||
|
let resolved_date = date_resolver::resolve_date_taken(
|
||||||
|
&file_path,
|
||||||
|
exif_fields
|
||||||
|
.as_ref()
|
||||||
|
.and_then(date_resolver::exif_prior_from)
|
||||||
|
.as_ref(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let insert_exif = InsertImageExif {
|
||||||
|
library_id: library.id,
|
||||||
|
file_path: relative_path.clone(),
|
||||||
|
camera_make: exif_fields.as_ref().and_then(|e| e.camera_make.clone()),
|
||||||
|
camera_model: exif_fields.as_ref().and_then(|e| e.camera_model.clone()),
|
||||||
|
lens_model: exif_fields.as_ref().and_then(|e| e.lens_model.clone()),
|
||||||
|
width: exif_fields.as_ref().and_then(|e| e.width),
|
||||||
|
height: exif_fields.as_ref().and_then(|e| e.height),
|
||||||
|
orientation: exif_fields.as_ref().and_then(|e| e.orientation),
|
||||||
|
gps_latitude: exif_fields
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|e| e.gps_latitude.map(|v| v as f32)),
|
||||||
|
gps_longitude: exif_fields
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|e| e.gps_longitude.map(|v| v as f32)),
|
||||||
|
gps_altitude: exif_fields
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|e| e.gps_altitude.map(|v| v as f32)),
|
||||||
|
focal_length: exif_fields
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|e| e.focal_length.map(|v| v as f32)),
|
||||||
|
aperture: exif_fields
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|e| e.aperture.map(|v| v as f32)),
|
||||||
|
shutter_speed: exif_fields.as_ref().and_then(|e| e.shutter_speed.clone()),
|
||||||
|
iso: exif_fields.as_ref().and_then(|e| e.iso),
|
||||||
|
date_taken: resolved_date.map(|r| r.timestamp),
|
||||||
|
created_time: timestamp,
|
||||||
|
last_modified: timestamp,
|
||||||
|
content_hash,
|
||||||
|
size_bytes,
|
||||||
|
phash_64: perceptual.map(|h| h.phash_64),
|
||||||
|
dhash_64: perceptual.map(|h| h.dhash_64),
|
||||||
|
date_taken_source: resolved_date.map(|r| r.source.as_str().to_string()),
|
||||||
|
date_utc: resolved_date.and_then(|r| r.date_utc),
|
||||||
|
camera_offset_minutes: resolved_date.and_then(|r| r.camera_offset_minutes),
|
||||||
|
date_taken_tz_source: resolved_date
|
||||||
|
.and_then(|r| r.tz_source)
|
||||||
|
.map(|t| t.as_str().to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut dao = exif_dao.lock().expect("Unable to lock ExifDao");
|
||||||
|
if let Err(e) = dao.store_exif(insert_exif) {
|
||||||
|
error!(
|
||||||
|
"Failed to register {} in image_exif: {:?}",
|
||||||
|
relative_path, e
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
debug!("Registered {} in image_exif", relative_path);
|
||||||
|
newly_registered.push(registered_file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
newly_registered
|
||||||
|
}
|
||||||
|
|
||||||
pub fn process_new_files(
|
pub fn process_new_files(
|
||||||
library: &libraries::Library,
|
library: &libraries::Library,
|
||||||
exif_dao: Arc<Mutex<Box<dyn ExifDao>>>,
|
exif_dao: Arc<Mutex<Box<dyn ExifDao>>>,
|
||||||
@@ -834,119 +963,7 @@ pub fn process_new_files(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !files_needing_row.is_empty() {
|
if !files_needing_row.is_empty() {
|
||||||
info!(
|
newly_registered = register_new_files(library, files_needing_row, &exif_dao);
|
||||||
"Registering {} new files in image_exif",
|
|
||||||
files_needing_row.len()
|
|
||||||
);
|
|
||||||
|
|
||||||
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
|
|
||||||
// every file gets a content_hash, even when EXIF is absent.
|
|
||||||
let (content_hash, size_bytes) = match content_hash::compute(&file_path) {
|
|
||||||
Ok(id) => (Some(id.content_hash), Some(id.size_bytes)),
|
|
||||||
Err(e) => {
|
|
||||||
warn!("Failed to hash {}: {:?}", file_path.display(), e);
|
|
||||||
(None, None)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Perceptual hashes (pHash + dHash). Best-effort — None for
|
|
||||||
// videos and decode failures. Drives near-duplicate detection
|
|
||||||
// in the Apollo duplicates surface; failure here is non-fatal
|
|
||||||
// and never blocks indexing.
|
|
||||||
let perceptual = perceptual_hash::compute(&file_path);
|
|
||||||
|
|
||||||
// EXIF is best-effort enrichment. When extraction fails (or the
|
|
||||||
// file type doesn't support EXIF) we still store a row with all
|
|
||||||
// EXIF fields NULL; the file remains visible to sort-by-date
|
|
||||||
// and tag queries via its rel_path and filesystem timestamps.
|
|
||||||
let exif_fields = if exif::supports_exif(&file_path) {
|
|
||||||
match exif::extract_exif_from_path(&file_path) {
|
|
||||||
Ok(data) => Some(data),
|
|
||||||
Err(e) => {
|
|
||||||
debug!(
|
|
||||||
"No EXIF or parse error for {}: {:?}",
|
|
||||||
file_path.display(),
|
|
||||||
e
|
|
||||||
);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
// Canonical date_taken via the waterfall — kamadak-exif (already
|
|
||||||
// computed above) → exiftool fallback for videos / MakerNote /
|
|
||||||
// QuickTime → filename regex → earliest_fs_time. Source is
|
|
||||||
// recorded so the per-tick backfill drain can re-run weak
|
|
||||||
// resolutions later. The EXIF branch additionally runs the
|
|
||||||
// camera-offset cascade to produce the new-contract columns
|
|
||||||
// (date_utc / camera_offset_minutes / date_taken_tz_source,
|
|
||||||
// ADR-0001) while the legacy date_taken stays byte-identical.
|
|
||||||
let resolved_date = date_resolver::resolve_date_taken(
|
|
||||||
&file_path,
|
|
||||||
exif_fields
|
|
||||||
.as_ref()
|
|
||||||
.and_then(date_resolver::exif_prior_from)
|
|
||||||
.as_ref(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let insert_exif = InsertImageExif {
|
|
||||||
library_id: library.id,
|
|
||||||
file_path: relative_path.clone(),
|
|
||||||
camera_make: exif_fields.as_ref().and_then(|e| e.camera_make.clone()),
|
|
||||||
camera_model: exif_fields.as_ref().and_then(|e| e.camera_model.clone()),
|
|
||||||
lens_model: exif_fields.as_ref().and_then(|e| e.lens_model.clone()),
|
|
||||||
width: exif_fields.as_ref().and_then(|e| e.width),
|
|
||||||
height: exif_fields.as_ref().and_then(|e| e.height),
|
|
||||||
orientation: exif_fields.as_ref().and_then(|e| e.orientation),
|
|
||||||
gps_latitude: exif_fields
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|e| e.gps_latitude.map(|v| v as f32)),
|
|
||||||
gps_longitude: exif_fields
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|e| e.gps_longitude.map(|v| v as f32)),
|
|
||||||
gps_altitude: exif_fields
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|e| e.gps_altitude.map(|v| v as f32)),
|
|
||||||
focal_length: exif_fields
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|e| e.focal_length.map(|v| v as f32)),
|
|
||||||
aperture: exif_fields
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|e| e.aperture.map(|v| v as f32)),
|
|
||||||
shutter_speed: exif_fields.as_ref().and_then(|e| e.shutter_speed.clone()),
|
|
||||||
iso: exif_fields.as_ref().and_then(|e| e.iso),
|
|
||||||
date_taken: resolved_date.map(|r| r.timestamp),
|
|
||||||
created_time: timestamp,
|
|
||||||
last_modified: timestamp,
|
|
||||||
content_hash,
|
|
||||||
size_bytes,
|
|
||||||
phash_64: perceptual.map(|h| h.phash_64),
|
|
||||||
dhash_64: perceptual.map(|h| h.dhash_64),
|
|
||||||
date_taken_source: resolved_date.map(|r| r.source.as_str().to_string()),
|
|
||||||
date_utc: resolved_date.and_then(|r| r.date_utc),
|
|
||||||
camera_offset_minutes: resolved_date.and_then(|r| r.camera_offset_minutes),
|
|
||||||
date_taken_tz_source: resolved_date
|
|
||||||
.and_then(|r| r.tz_source)
|
|
||||||
.map(|t| t.as_str().to_string()),
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut dao = exif_dao.lock().expect("Unable to lock ExifDao");
|
|
||||||
if let Err(e) = dao.store_exif(insert_exif) {
|
|
||||||
error!(
|
|
||||||
"Failed to register {} in image_exif: {:?}",
|
|
||||||
relative_path, e
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
debug!("Registered {} in image_exif", relative_path);
|
|
||||||
newly_registered.push(registered_file);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Photo versions (#191) ──────────────────────────────────────────
|
// ── Photo versions (#191) ──────────────────────────────────────────
|
||||||
|
|||||||
Reference in new issue
Block a user