image_exif: manual date_taken override (set/clear endpoints)

Add `POST /image/exif/date` and `POST /image/exif/date/clear` so an
operator can correct a row whose canonical-date waterfall landed on the
wrong value (camera clock reset, fs_time fallback for a copied-from-
backup file, etc). New `original_date_taken` / `original_date_taken_source`
columns snapshot the prior value on first override so revert is lossless.

The waterfall source set is now `'exif' | 'exiftool' | 'filename' | 'fs_time' | 'manual'`.
The existing `idx_image_exif_date_backfill` partial index already filters
to `date_taken IS NULL OR date_taken_source = 'fs_time'`, so manual rows
are naturally excluded from the per-tick drain — no index change needed.

`ExifMetadata` now exposes `date_taken_source` + originals so a UI can
render "manually set; was X via filename".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cameron Cordes
2026-05-06 19:24:27 -04:00
parent 2acc525e73
commit 832b50d587
8 changed files with 383 additions and 1 deletions

View File

@@ -1475,6 +1475,44 @@ mod tests {
struct MockExifDao;
fn mock_exif_row(
library_id: i32,
rel_path: &str,
date_taken: Option<i64>,
date_taken_source: Option<String>,
) -> crate::database::models::ImageExif {
crate::database::models::ImageExif {
id: 1,
library_id,
file_path: rel_path.to_string(),
camera_make: None,
camera_model: None,
lens_model: None,
width: None,
height: None,
orientation: None,
gps_latitude: None,
gps_longitude: None,
gps_altitude: None,
focal_length: None,
aperture: None,
shutter_speed: None,
iso: None,
date_taken,
created_time: 0,
last_modified: 0,
content_hash: None,
size_bytes: None,
phash_64: None,
dhash_64: None,
duplicate_of_hash: None,
duplicate_decided_at: None,
date_taken_source,
original_date_taken: None,
original_date_taken_source: None,
}
}
impl ExifDao for MockExifDao {
fn store_exif(
&mut self,
@@ -1509,6 +1547,8 @@ mod tests {
duplicate_of_hash: None,
duplicate_decided_at: None,
date_taken_source: data.date_taken_source.clone(),
original_date_taken: None,
original_date_taken_source: None,
})
}
@@ -1553,6 +1593,8 @@ mod tests {
duplicate_of_hash: None,
duplicate_decided_at: None,
date_taken_source: data.date_taken_source.clone(),
original_date_taken: None,
original_date_taken_source: None,
})
}
@@ -1666,6 +1708,28 @@ mod tests {
Ok(())
}
fn set_manual_date_taken(
&mut self,
_context: &opentelemetry::Context,
library_id: i32,
rel_path: &str,
date_taken: i64,
) -> Result<crate::database::models::ImageExif, DbError> {
// Mock — files.rs tests don't exercise the date-override endpoints.
// Returning a synthetic row keeps the trait satisfied without
// depending on private DbError constructors.
Ok(mock_exif_row(library_id, rel_path, Some(date_taken), Some("manual".to_string())))
}
fn clear_manual_date_taken(
&mut self,
_context: &opentelemetry::Context,
library_id: i32,
rel_path: &str,
) -> Result<crate::database::models::ImageExif, DbError> {
Ok(mock_exif_row(library_id, rel_path, None, None))
}
fn get_memories_in_window(
&mut self,
_context: &opentelemetry::Context,