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

+29 -5
View File
@@ -826,10 +826,14 @@ pub async fn get_full_exif(
}
}
/// Body for `POST /image/exif/date` — operator-driven date_taken override.
/// `date_taken` is unix seconds (matches `image_exif.date_taken`'s convention
/// — naive local reinterpreted as UTC, not real UTC; the Apollo client passes
/// through the same value the photo carousel rendered before edit).
/// Body for `POST /image/exif/date` — operator-driven date override.
/// `date_taken` is unix seconds: the wall the user picked rendered in their
/// display zone, encoded naive-as-UTC (the Apollo client passes through the
/// same value the photo carousel rendered before edit). The legacy
/// `date_taken` column stores it verbatim; the new-contract triple
/// (`date_utc` / `camera_offset_minutes` / `date_taken_tz_source =
/// 'manual'`, ADR-0001 ticket #115) is derived from it as a wall clock in
/// the user's display zone.
#[derive(serde::Deserialize)]
struct SetDateRequest {
path: String,
@@ -910,9 +914,29 @@ pub async fn set_image_date(
// doesn't end up with a backslash variant that misses the row.
let normalized_path = body.path.replace('\\', "/");
// The user's display zone — the wall the UI sent is rendered in this
// zone (ADR-0001, ticket #115). NULL / unparseable prefs fall back to
// the app default inside display_zone_for.
let display_tz = {
let mut prefs_dao = app_state
.user_ai_prefs_dao
.lock()
.expect("Unable to lock UserAiPrefsDao");
match prefs_dao.get_prefs(&span_context) {
Ok(Some(prefs)) => crate::camera_tz::display_zone_for(prefs.display_tz.as_deref()),
_ => crate::camera_tz::display_zone_for(None),
}
};
let updated = {
let mut dao = exif_dao.lock().expect("Unable to lock ExifDao");
dao.set_manual_date_taken(&span_context, library.id, &normalized_path, body.date_taken)
dao.set_manual_date_taken(
&span_context,
library.id,
&normalized_path,
body.date_taken,
&display_tz,
)
};
match updated {