Files
ImageApi/src/camera_tz.rs
T
Cameron Cordes d2263be6ac 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.
2026-09-25 15:26:26 -04:00

483 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Camera-offset cascade and display-zone helpers for the true-instant date
//! contract (see `docs/adr/0001-true-utc-dates.md`).
//!
//! The legacy `image_exif.date_taken` column mixes conventions: EXIF-sourced
//! rows hold camera wall-clock *as UTC*, exiftool / filesystem rows hold true
//! UTC instants. The new columns — `date_utc`, `camera_offset_minutes`,
//! `date_taken_tz_source` — are populated at ingest by the offset cascade:
//!
//! 1. **offset tag** — the EXIF `OffsetTime*` tag, when the camera stamped
//! it (explicit, most accurate).
//! 2. **gps** — the capture location resolved to an IANA zone (bundled
//! offline TZF geometry) and that zone's offset for the capture *date*
//! (DST-correct via chrono-tz).
//! 3. **assumed_server** — the server's own IANA zone, offset at the
//! capture date. A camera without offset or GPS was probably carried
//! around by its owner, i.e. in the operator's home zone.
//!
//! The cascade always returns `(offset_minutes, source)` so the caller can
//! compute `date_utc = wall − offset` and audit why the row has the offset
//! it does. Branches that already produce true UTC instants (exiftool,
//! filesystem time) skip the cascade entirely and are recorded as
//! [`TzSource::UtcDirect`].
use std::str::FromStr;
use std::sync::OnceLock;
use chrono::{Local, LocalResult, NaiveDateTime, Offset, TimeZone};
use chrono_tz::Tz;
/// Why a `date_utc` row has the offset it does — or that it is a direct
/// true instant. Written verbatim to `image_exif.date_taken_tz_source`.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum TzSource {
/// An EXIF `OffsetTime*` tag supplied the camera offset directly.
OffsetTag,
/// GPS → IANA-zone lookup resolved the offset for the capture date.
Gps,
/// No offset tag and no usable GPS: assumed the server's zone.
AssumedServer,
/// The resolver branch already produced a true UTC instant (exiftool,
/// filesystem time, unix-timestamp filename). No offset is involved.
UtcDirect,
/// An operator set the date manually (POST /image/exif/date, ticket
/// #115): the wall the user picked, interpreted in their display zone.
Manual,
}
impl TzSource {
pub fn as_str(self) -> &'static str {
match self {
TzSource::OffsetTag => "offset_tag",
TzSource::Gps => "gps",
TzSource::AssumedServer => "assumed_server",
TzSource::UtcDirect => "utc_direct",
TzSource::Manual => "manual",
}
}
}
/// Evidence available to the offset cascade for one file.
#[derive(Copy, Clone, Debug, Default)]
pub struct OffsetEvidence {
/// Signed minutes from the EXIF `OffsetTime*` tag (e.g. EDT = `-240`),
/// when the camera stamped one.
pub offset_tag_minutes: Option<i32>,
/// `(latitude, longitude)` from the EXIF GPS tags, when present.
pub gps: Option<(f64, f64)>,
}
/// A resolved camera offset for one capture.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct OffsetResolution {
/// Camera local time minus UTC, in minutes (negative = west of UTC).
pub minutes: i32,
/// Which rung of the cascade produced it.
pub source: TzSource,
}
/// GPS → IANA-zone seam. The production adapter wraps the bundled offline
/// TZF geometry; tests inject a stub.
pub trait GpsZoneLookup {
fn zone_at(&self, lat: f64, lon: f64) -> Option<Tz>;
}
/// Production [`GpsZoneLookup`]: bundled lite TZF geometry. No network,
/// ~4 MB, loaded once per process on first use.
pub struct TzfGpsZoneLookup {
finder: OnceLock<tzf_rs::DefaultFinder>,
}
impl TzfGpsZoneLookup {
pub fn new() -> Self {
Self {
finder: OnceLock::new(),
}
}
fn finder(&self) -> &tzf_rs::DefaultFinder {
self.finder.get_or_init(tzf_rs::DefaultFinder::new)
}
}
impl Default for TzfGpsZoneLookup {
fn default() -> Self {
Self::new()
}
}
impl GpsZoneLookup for TzfGpsZoneLookup {
fn zone_at(&self, lat: f64, lon: f64) -> Option<Tz> {
// tzf-rs takes (lng, lat) — the reverse of our (lat, lon) convention.
let name = self.finder().get_tz_name(lon, lat);
if name.is_empty() {
return None;
}
Tz::from_str(name).ok()
}
}
/// The zone offset, in minutes, for a naive wall-clock reading.
///
/// DST-correct: resolves the zone's offset for the wall-clock *date itself*
/// (the same zone rule the camera's own clock followed). Ambiguous
/// fall-back wall times resolve to the earlier (pre-transition) offset;
/// nonexistent spring-forward wall times to the later one — each within one
/// hour of the true capture instant, never off by a full day.
pub fn offset_minutes_for_zone(zone: Tz, wall: NaiveDateTime) -> i32 {
let dt = match zone.from_local_datetime(&wall) {
LocalResult::Single(dt) => dt,
LocalResult::Ambiguous(early, _) => early,
// Nonexistent spring-forward wall time: use the zone's offset at
// that wall reading interpreted as UTC — within an hour of truth.
LocalResult::None => zone.from_utc_datetime(&wall),
};
dt.offset().fix().local_minus_utc() / 60
}
/// True UTC instant (unix seconds) for a camera wall-clock reading and its
/// resolved offset: `instant = wall − offset`.
///
/// e.g. wall `2024-07-04 12:00:00` at EDT (`-240`) → `16:00:00Z`.
pub fn wall_to_instant(wall: NaiveDateTime, offset_minutes: i32) -> i64 {
wall.and_utc().timestamp() - (offset_minutes as i64) * 60
}
/// The offset cascade: offset tag → GPS zone → server zone.
///
/// `server_zone` is the operator's home zone (see [`server_zone`]); when it
/// is `None` (unresolvable on this host) the cascade falls back to the
/// process-local offset, which is only exact for fixed-offset zones.
pub fn resolve_camera_offset(
wall: NaiveDateTime,
evidence: &OffsetEvidence,
server_zone: Option<Tz>,
zone_lookup: &dyn GpsZoneLookup,
) -> OffsetResolution {
if let Some(minutes) = evidence.offset_tag_minutes {
return OffsetResolution {
minutes,
source: TzSource::OffsetTag,
};
}
if let Some((lat, lon)) = evidence.gps
&& let Some(zone) = zone_lookup.zone_at(lat, lon)
{
return OffsetResolution {
minutes: offset_minutes_for_zone(zone, wall),
source: TzSource::Gps,
};
}
let minutes = match server_zone {
Some(zone) => offset_minutes_for_zone(zone, wall),
None => Local::now().offset().fix().local_minus_utc() / 60,
};
OffsetResolution {
minutes,
source: TzSource::AssumedServer,
}
}
/// Resolve the server's IANA zone from the host environment, in order:
/// `TZ` env var (zone-name form), `/etc/timezone`, then the
/// `/etc/localtime` symlink target. Returns `None` when nothing parses —
/// callers then fall back to the process-local offset.
pub fn server_zone() -> Option<Tz> {
if let Ok(tz) = std::env::var("TZ")
&& let Some(zone) = Tz::from_str(tz.trim()).ok()
{
return Some(zone);
}
if let Ok(contents) = std::fs::read_to_string("/etc/timezone")
&& let Some(zone) = Tz::from_str(contents.trim()).ok()
{
return Some(zone);
}
if let Ok(target) = std::fs::read_link("/etc/localtime") {
let target = target.to_string_lossy().into_owned();
let zone_name = target
.trim_start_matches("/usr/share/zoneinfo/")
.trim_start_matches("/etc/zoneinfo/")
.trim_start_matches("/var/db/timezone/zoneinfo/");
if !zone_name.is_empty()
&& zone_name != "localtime"
&& let Ok(zone) = Tz::from_str(zone_name)
{
return Some(zone);
}
}
None
}
/// App default display zone when the user hasn't stored one
/// (`user_ai_prefs.display_tz` NULL).
pub const DEFAULT_DISPLAY_TZ: &str = "America/New_York";
/// Resolve the user's stored display zone to a usable IANA name.
/// NULL / empty / unparseable prefs fall back to the app default, so
/// read paths (tickets #119/#120) never have to special-case the
/// unset state themselves.
pub fn display_zone_for(stored: Option<&str>) -> String {
match stored.map(str::trim).filter(|s| !s.is_empty()) {
Some(name) if Tz::from_str(name).is_ok() => name.to_string(),
_ => DEFAULT_DISPLAY_TZ.to_string(),
}
}
/// Pure seam for the manual date-edit endpoints (ADR-0001, ticket #115):
/// reinterpret the naive-as-UTC epoch the UI sent as a wall clock in the
/// user's display zone, and produce the new-contract
/// `(date_utc, camera_offset_minutes)` pair. The caller writes
/// `date_taken_tz_source = 'manual'` itself and stores the epoch verbatim
/// in the legacy `date_taken` column (byte-identical to the pre-rework
/// behavior).
///
/// `stored_zone` is `user_ai_prefs.display_tz` (NULL / unparseable → app
/// default). The offset follows the wall's own date through the zone's DST
/// rules — ambiguous fall-back walls take the earlier (pre-transition)
/// offset; nonexistent spring-forward walls take the zone's offset at the
/// wall-as-UTC reading, within an hour of truth (see
/// [`offset_minutes_for_zone`]). `None` only when the zone name is
/// unparseable, which [`display_zone_for`] already rules out — the caller
/// then leaves the new-contract triple untouched.
pub fn manual_date_resolution(
stored_zone: Option<&str>,
wall_naive_epoch: i64,
) -> Option<(i64, i32)> {
let zone = Tz::from_str(&display_zone_for(stored_zone)).ok()?;
let wall = chrono::DateTime::from_timestamp(wall_naive_epoch, 0).map(|dt| dt.naive_utc())?;
let minutes = offset_minutes_for_zone(zone, wall);
Some((wall_to_instant(wall, minutes), minutes))
}
/// Display-zone offset, in minutes, for a true UTC instant — DST-aware.
/// `None` when the zone name doesn't parse.
#[allow(dead_code)] // consumed by the read-side cutover (#119/#120)
pub fn display_zone_offset_minutes(zone_name: &str, instant: i64) -> Option<i32> {
let zone = Tz::from_str(zone_name).ok()?;
let dt = zone.timestamp_opt(instant, 0).single()?;
Some(dt.offset().fix().local_minus_utc() / 60)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn display_zone_for_falls_back_to_default() {
assert_eq!(display_zone_for(None).as_str(), DEFAULT_DISPLAY_TZ);
assert_eq!(display_zone_for(Some("")).as_str(), DEFAULT_DISPLAY_TZ);
assert_eq!(display_zone_for(Some(" ")).as_str(), DEFAULT_DISPLAY_TZ);
assert_eq!(
display_zone_for(Some("Not/A_Zone")).as_str(),
DEFAULT_DISPLAY_TZ
);
}
#[test]
fn display_zone_for_passes_through_valid_names() {
assert_eq!(display_zone_for(Some("Europe/Berlin")), "Europe/Berlin");
assert_eq!(display_zone_for(Some(" Asia/Tokyo ")), "Asia/Tokyo");
}
/// Stub lookup for cascade tests: one fixed zone or none.
struct StubLookup(Option<Tz>);
impl GpsZoneLookup for StubLookup {
fn zone_at(&self, _lat: f64, _lon: f64) -> Option<Tz> {
self.0
}
}
fn ny() -> Tz {
chrono_tz::America::New_York
}
fn wall_july() -> NaiveDateTime {
NaiveDateTime::parse_from_str("2024-07-04 12:00:00", "%Y-%m-%d %H:%M:%S").unwrap()
}
fn wall_january() -> NaiveDateTime {
NaiveDateTime::parse_from_str("2024-01-15 12:00:00", "%Y-%m-%d %H:%M:%S").unwrap()
}
#[test]
fn offset_tag_wins_over_everything() {
let evidence = OffsetEvidence {
offset_tag_minutes: Some(330), // +05:30
gps: Some((40.71, -74.00)),
};
let res =
resolve_camera_offset(wall_july(), &evidence, Some(ny()), &StubLookup(Some(ny())));
assert_eq!(res.minutes, 330);
assert_eq!(res.source, TzSource::OffsetTag);
}
#[test]
fn gps_zone_wins_when_no_tag() {
let evidence = OffsetEvidence {
offset_tag_minutes: None,
gps: Some((40.71, -74.00)),
};
let res =
resolve_camera_offset(wall_july(), &evidence, Some(ny()), &StubLookup(Some(ny())));
assert_eq!(res.minutes, -240, "July wall time in NY is EDT (UTC-4)");
assert_eq!(res.source, TzSource::Gps);
}
#[test]
fn server_zone_is_last_resort() {
let evidence = OffsetEvidence::default();
let res = resolve_camera_offset(
wall_january(),
&evidence,
Some(ny()),
&StubLookup(Some(ny())), // stub is ignored: no GPS evidence
);
assert_eq!(res.minutes, -300, "January wall time in NY is EST (UTC-5)");
assert_eq!(res.source, TzSource::AssumedServer);
}
#[test]
fn gps_miss_falls_through_to_server_zone() {
let evidence = OffsetEvidence {
offset_tag_minutes: None,
gps: Some((30.0, -40.0)), // open ocean: lookup returns None
};
let res = resolve_camera_offset(wall_january(), &evidence, Some(ny()), &StubLookup(None));
assert_eq!(res.source, TzSource::AssumedServer);
}
#[test]
fn zone_offset_is_dst_correct_for_capture_date() {
assert_eq!(offset_minutes_for_zone(ny(), wall_july()), -240);
assert_eq!(offset_minutes_for_zone(ny(), wall_january()), -300);
// Berlin: CEST +120 in summer, CET +60 in winter.
let berlin = chrono_tz::Europe::Berlin;
assert_eq!(offset_minutes_for_zone(berlin, wall_july()), 120);
assert_eq!(offset_minutes_for_zone(berlin, wall_january()), 60);
}
#[test]
fn wall_to_instant_edt_example() {
// Wall 2024-07-04 12:00:00 at EDT (-240) is 16:00:00 UTC
// = 1720108800. This is the exact pair the mixed-convention audit
// used to prove the legacy EXIF branch was off by 4 hours.
assert_eq!(wall_to_instant(wall_july(), -240), 1_720_108_800);
}
#[test]
fn wall_to_instant_positive_offset() {
// Wall 2024-07-04 12:00:00 at +05:30 (330) is 06:30:00 UTC.
let expected = wall_july().and_utc().timestamp() - 330 * 60;
assert_eq!(wall_to_instant(wall_july(), 330), expected);
}
#[test]
fn display_zone_offset_minutes_is_dst_aware() {
// 2024-07-04 16:00:00Z — New York is on EDT.
assert_eq!(
display_zone_offset_minutes("America/New_York", 1_720_108_800),
Some(-240)
);
// 2024-01-15 17:00:00Z — New York is on EST.
assert_eq!(
display_zone_offset_minutes("America/New_York", 1_705_335_600),
Some(-300)
);
}
#[test]
fn manual_date_resolution_display_zone_edt() {
// The UI sends the wall rendered in the display zone, as a
// naive-as-UTC epoch: 1720080000 = "2024-07-04 04:00:00". July in
// New York is EDT (-240): the instant is 08:00:00Z.
let (utc, off) = manual_date_resolution(Some("America/New_York"), 1_720_080_000).unwrap();
assert_eq!(off, -240);
assert_eq!(utc, 1_720_080_000 + 4 * 3600);
}
#[test]
fn manual_date_resolution_display_zone_est() {
// Same function in January: EST (-300). Wall 2024-01-15 04:00:00
// (naive epoch 1705291200) → 09:00:00Z.
let (utc, off) = manual_date_resolution(Some("America/New_York"), 1_705_291_200).unwrap();
assert_eq!(off, -300);
assert_eq!(utc, 1_705_291_200 + 5 * 3600);
}
#[test]
fn manual_date_resolution_positive_zone_and_default() {
// Tokyo is +09:00 year-round: wall 04:00 → previous day 19:00Z.
let (utc, off) = manual_date_resolution(Some("Asia/Tokyo"), 1_720_080_000).unwrap();
assert_eq!(off, 540);
assert_eq!(utc, 1_720_080_000 - 540 * 60);
// NULL stored zone → app default (America/New_York), July → EDT.
let (_, off) = manual_date_resolution(None, 1_720_080_000).unwrap();
assert_eq!(off, -240);
// Unparseable stored zone also falls back to the default.
let (_, off) = manual_date_resolution(Some("Not/A_Zone"), 1_720_080_000).unwrap();
assert_eq!(off, -240);
}
#[test]
fn display_zone_offset_minutes_unknown_zone_is_none() {
assert_eq!(
display_zone_offset_minutes("Not/A_Zone", 1_700_000_000),
None
);
}
#[test]
fn tz_source_as_str_round_trip() {
let pairs = [
(TzSource::OffsetTag, "offset_tag"),
(TzSource::Gps, "gps"),
(TzSource::AssumedServer, "assumed_server"),
(TzSource::UtcDirect, "utc_direct"),
(TzSource::Manual, "manual"),
];
for (src, expected) in pairs {
assert_eq!(src.as_str(), expected);
}
}
#[test]
fn tzf_lookup_resolves_new_york() {
let lookup = TzfGpsZoneLookup::new();
assert_eq!(
lookup.zone_at(40.7128, -74.0060),
Some(chrono_tz::America::New_York)
);
}
#[test]
fn tzf_lookup_resolves_berlin() {
let lookup = TzfGpsZoneLookup::new();
assert_eq!(
lookup.zone_at(52.5200, 13.4050),
Some(chrono_tz::Europe::Berlin)
);
}
#[test]
fn tzf_lookup_open_ocean_resolves_etc_zone() {
// The bundled lite data covers open ocean with fixed-offset
// Etc/GMT* zones — a mid-Atlantic photo gets a sane local offset
// rather than falling through to the server zone.
let lookup = TzfGpsZoneLookup::new();
let zone = lookup
.zone_at(30.0, -40.0)
.expect("ocean resolves to Etc/GMT*");
assert!(zone.name().starts_with("Etc/GMT"), "got {}", zone.name());
}
#[test]
fn server_zone_resolves_on_this_host_or_none() {
// The host's zone is whatever it is — the contract is that a
// parseable result is a real Tz and a missing config is None, not a
// panic. Both outcomes are valid; assert only the no-panic shape.
let _ = server_zone();
}
}