diff --git a/src/watcher.rs b/src/watcher.rs index ea25974..1b2aae8 100644 --- a/src/watcher.rs +++ b/src/watcher.rs @@ -740,6 +740,135 @@ pub fn playlist_needs_generation(video_path: &Path, playlist_path: &Path) -> boo 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>>, +) -> 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( library: &libraries::Library, exif_dao: Arc>>, @@ -834,119 +963,7 @@ pub fn process_new_files( } if !files_needing_row.is_empty() { - 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 = register_new_files(library, files_needing_row, &exif_dao); } // ── Photo versions (#191) ──────────────────────────────────────────