feat(dates): container video dates, filesystem branch, manual edit (#115)

The exiftool branch of the date waterfall now pulls raw strings with
group prefixes (one `exiftool -j -q -G1` subprocess per batch, no
`-d %s`) and classifies each candidate:

- zoned strings (own offset / Z) are true instants;
- naive strings from container groups (QuickTime/Track*/Apple) are
  UTC-rendered creation_time fields -> true instants (utc_direct).
  Running them through the camera wall-clock cascade was the
  "videos off by 4-5 hours" bug: it reinterpreted a UTC instant in
  the server zone;
- naive strings from photo-EXIF groups are camera wall clocks and run
  the offset cascade (OffsetTime* pairing + GPS evidence pulled in
  the same exiftool pass).

The legacy date_taken column stays byte-identical to the old
server-local interpretation (reproduced in Rust, DST-aware); the
new-contract date_utc / camera_offset_minutes / date_taken_tz_source
columns carry the true instant.

Manual date edit (POST /image/exif/date) reinterprets the
naive-as-UTC epoch the client sent as a wall clock in the user's
display zone (camera_tz::manual_date_resolution, tz_source =
'manual'), snapshots the pre-override new-contract triple next to the
existing legacy snapshot pair (3 new columns, migration
2026-09-25-000000), and revert restores both pairs.

ADR-0001 records the container-date finding; CLAUDE.md date-pipeline
section updated.
This commit is contained in:
Cameron Cordes committed 2026-09-25 15:26:26 -04:00
1 parent 6f020f4201
commit d2263be6ac
13 files changed
+866 -178

No files matched your search

+73 -20
View File
@@ -568,25 +568,32 @@ pub trait ExifDao: Sync + Send {
model_version: Option<&str>,
) -> Result<Vec<(String, Vec<u8>)>, DbError>;
/// Operator-driven date_taken override (POST /image/exif/date). Snapshots
/// the prior `(date_taken, date_taken_source)` into the `original_*`
/// pair on first override, then writes the new value with
/// `date_taken_source = 'manual'`. Subsequent overrides keep the
/// original snapshot intact so a single revert restores the resolver
/// result, not whatever override was set just before. Returns the
/// post-update row.
/// Operator-driven date_taken override (POST /image/exif/date).
/// `date_taken` is the naive-as-UTC epoch the UI sent; the legacy
/// column stores it verbatim (byte-identical to the pre-rework
/// behavior). The new-contract triple (ADR-0001, ticket #115) is
/// computed from the same value as a wall clock in `display_tz` —
/// the user's display zone — with `date_taken_source = 'manual'`.
/// Snapshots the prior legacy pair AND prior new-contract triple into
/// the `original_*` columns on first override; subsequent overrides
/// keep the original snapshot intact so a single revert restores the
/// resolver result, not whatever override was set just before.
/// Returns the post-update row.
fn set_manual_date_taken(
&mut self,
context: &opentelemetry::Context,
library_id: i32,
rel_path: &str,
date_taken: i64,
display_tz: &str,
) -> Result<ImageExif, DbError>;
/// Revert a manual override (POST /image/exif/date/clear): restore
/// `date_taken` + `date_taken_source` from the `original_*` snapshot,
/// then null both originals. No-op (returns current row unchanged) when
/// no override is active.
/// `date_taken` + `date_taken_source` AND the new-contract triple
/// (`date_utc` / `camera_offset_minutes` / `date_taken_tz_source`,
/// ADR-0001 ticket #115) from the `original_*` snapshots, then null
/// all five. No-op (returns current row unchanged) when no override is
/// active.
fn clear_manual_date_taken(
&mut self,
context: &opentelemetry::Context,
@@ -1640,6 +1647,7 @@ impl ExifDao for SqliteExifDao {
library_id_val: i32,
rel_path_val: &str,
date_taken_val: i64,
display_tz_val: &str,
) -> Result<ImageExif, DbError> {
trace_db_call(context, "update", "set_manual_date_taken", |_span| {
use schema::image_exif::dsl::*;
@@ -1652,16 +1660,49 @@ impl ExifDao for SqliteExifDao {
let current =
locate_image_exif_row(connection.deref_mut(), library_id_val, rel_path_val)?;
// Snapshot only on first override. Subsequent overrides keep
// the original snapshot intact so a single revert restores
// the resolver-derived value, not the prior override.
let (orig_dt, orig_src) = if current.original_date_taken.is_none() {
(current.date_taken, current.date_taken_source.clone())
} else {
(
current.original_date_taken,
current.original_date_taken_source.clone(),
)
// Snapshot both pairs only on first override. Subsequent
// overrides keep the original snapshot intact so a single
// revert restores the resolver-derived value, not the prior
// override.
let (orig_dt, orig_src, orig_dt_utc, orig_offset, orig_tz) =
if current.original_date_taken.is_none() {
(
current.date_taken,
current.date_taken_source.clone(),
current.date_utc,
current.camera_offset_minutes,
current.date_taken_tz_source.clone(),
)
} else {
(
current.original_date_taken,
current.original_date_taken_source.clone(),
current.original_date_utc,
current.original_camera_offset_minutes,
current.original_date_taken_tz_source.clone(),
)
};
// The user's wall clock reinterpreted in their display zone
// (ADR-0001, ticket #115). `manual_date_resolution` is the
// pure seam; `None` (unreachable in practice —
// `display_zone_for` always yields a parseable zone) keeps
// the resolver's existing triple while the legacy columns
// still update.
let (new_utc, new_offset, new_tz) = match crate::camera_tz::manual_date_resolution(
Some(display_tz_val),
date_taken_val,
) {
Some((utc, offset)) => (
Some(utc),
Some(offset),
Some(crate::camera_tz::TzSource::Manual.as_str().to_string()),
),
None => (
current.date_utc,
current.camera_offset_minutes,
current.date_taken_tz_source.clone(),
),
};
// Update by primary key so we hit the exact row we read,
@@ -1673,6 +1714,12 @@ impl ExifDao for SqliteExifDao {
date_taken_source.eq(Some("manual".to_string())),
original_date_taken.eq(orig_dt),
original_date_taken_source.eq(orig_src),
original_date_utc.eq(orig_dt_utc),
original_camera_offset_minutes.eq(orig_offset),
original_date_taken_tz_source.eq(orig_tz),
date_utc.eq(new_utc),
camera_offset_minutes.eq(new_offset),
date_taken_tz_source.eq(new_tz),
))
.execute(connection.deref_mut())
.map_err(|e| anyhow::Error::from(e).context("update_failed"))?;
@@ -1718,8 +1765,14 @@ impl ExifDao for SqliteExifDao {
.set((
date_taken.eq(current.original_date_taken),
date_taken_source.eq(current.original_date_taken_source.clone()),
date_utc.eq(current.original_date_utc),
camera_offset_minutes.eq(current.original_camera_offset_minutes),
date_taken_tz_source.eq(current.original_date_taken_tz_source.clone()),
original_date_taken.eq::<Option<i64>>(None),
original_date_taken_source.eq::<Option<String>>(None),
original_date_utc.eq::<Option<i64>>(None),
original_camera_offset_minutes.eq::<Option<i32>>(None),
original_date_taken_tz_source.eq::<Option<String>>(None),
))
.execute(connection.deref_mut())
.map_err(|e| anyhow::Error::from(e).context("update_failed"))?;
+8
View File
@@ -208,6 +208,14 @@ pub struct ImageExif {
pub camera_offset_minutes: Option<i32>,
/// How the offset was determined. See `camera_tz::TzSource`.
pub date_taken_tz_source: Option<String>,
/// Snapshot of the prior `date_utc` taken on first manual override.
/// NULL when no override is active. POST /image/exif/date/clear
/// restores the new-contract triple from these columns.
pub original_date_utc: Option<i64>,
/// Snapshot of the prior `camera_offset_minutes` (first override).
pub original_camera_offset_minutes: Option<i32>,
/// Snapshot of the prior `date_taken_tz_source` (first override).
pub original_date_taken_tz_source: Option<String>,
}
#[derive(Insertable, Clone)]
+3
View File
@@ -143,6 +143,9 @@ diesel::table! {
date_utc -> Nullable<BigInt>,
camera_offset_minutes -> Nullable<Integer>,
date_taken_tz_source -> Nullable<Text>,
original_date_utc -> Nullable<BigInt>,
original_camera_offset_minutes -> Nullable<Integer>,
original_date_taken_tz_source -> Nullable<Text>,
}
}