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:
1 parent
6f020f4201
commit
d2263be6ac
13 files changed
+866
-178
No files matched your search
+73
-20
@@ -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"))?;
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user