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
@@ -380,9 +380,21 @@ land on this date?". Order:
|
|||||||
QuickTime/MP4 (`MediaCreateDate`, `TrackCreateDate`, `CreateDate`),
|
QuickTime/MP4 (`MediaCreateDate`, `TrackCreateDate`, `CreateDate`),
|
||||||
Apple's `ContentCreateDate`, MakerNote sub-IFDs. Required for
|
Apple's `ContentCreateDate`, MakerNote sub-IFDs. Required for
|
||||||
videos to land a real date. Single-file at ingest; the per-tick
|
videos to land a real date. Single-file at ingest; the per-tick
|
||||||
drain feeds the whole batch through one `exiftool -@ -` subprocess.
|
drain feeds the whole batch through one `exiftool -j -q -G1 -@ -`
|
||||||
Degrades silently when `exiftool` isn't on PATH (resolver caches the
|
subprocess (raw strings + group prefixes, parsed in Rust — no
|
||||||
"available" check via `OnceLock`).
|
`-d %s`). Degrades silently when `exiftool` isn't on PATH (resolver
|
||||||
|
caches the "available" check via `OnceLock`).
|
||||||
|
**Group classification (ADR-0001, ticket #115):** 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`, no cascade — running them
|
||||||
|
through the wall-clock cascade was the "videos off by 4–5 hours"
|
||||||
|
bug); naive strings from photo-EXIF groups are camera wall clocks →
|
||||||
|
offset cascade (`OffsetTime*` + GPS evidence pulled in the same
|
||||||
|
pass). The legacy `date_taken` column stays byte-identical to the
|
||||||
|
old server-local `-d %s` interpretation; the new-contract
|
||||||
|
`date_utc` / `camera_offset_minutes` / `date_taken_tz_source`
|
||||||
|
columns carry the true instant.
|
||||||
3. **`filename`** — `extract_date_from_filename` in `memories.rs`
|
3. **`filename`** — `extract_date_from_filename` in `memories.rs`
|
||||||
matches screenshot, chat-export, and timestamp-named patterns.
|
matches screenshot, chat-export, and timestamp-named patterns.
|
||||||
4. **`fs_time`** — `earliest_fs_time(metadata)` (earlier of created /
|
4. **`fs_time`** — `earliest_fs_time(metadata)` (earlier of created /
|
||||||
@@ -401,8 +413,9 @@ watcher tick alongside `backfill_unhashed_backlog` (also `src/backfill.rs`). It
|
|||||||
`date_taken IS NULL` (backed by the `idx_image_exif_date_backfill`
|
`date_taken IS NULL` (backed by the `idx_image_exif_date_backfill`
|
||||||
partial index), runs the waterfall batch via `resolve_dates_batch`,
|
partial index), runs the waterfall batch via `resolve_dates_batch`,
|
||||||
and writes results via the `backfill_date_taken` DAO method (touches
|
and writes results via the `backfill_date_taken` DAO method (touches
|
||||||
only `date_taken` + `date_taken_source` so EXIF / hash / perceptual
|
`date_taken` + `date_taken_source` plus the new-contract
|
||||||
columns are preserved). Resolved rows — including the ones the
|
`date_utc` / `camera_offset_minutes` / `date_taken_tz_source` triple,
|
||||||
|
so EXIF / hash / perceptual columns are preserved). Resolved rows — including the ones the
|
||||||
waterfall could only resolve via `fs_time` — are not re-eligible:
|
waterfall could only resolve via `fs_time` — are not re-eligible:
|
||||||
the resolver is deterministic on file bytes + filename + fs metadata,
|
the resolver is deterministic on file bytes + filename + fs metadata,
|
||||||
so re-running on the same inputs lands on the same source every time.
|
so re-running on the same inputs lands on the same source every time.
|
||||||
|
|||||||
@@ -22,7 +22,11 @@ actual moment). The wall clock the camera showed is a **display value**, recover
|
|||||||
instant plus a per-row **camera offset** resolved at ingest by a cascade (explicit EXIF
|
instant plus a per-row **camera offset** resolved at ingest by a cascade (explicit EXIF
|
||||||
offset tag → GPS position → server zone, the last recorded as an assumption). Day-level
|
offset tag → GPS position → server zone, the last recorded as an assumption). Day-level
|
||||||
grouping uses the owner's **display zone** (an IANA zone name). Manual edits enter as wall
|
grouping uses the owner's **display zone** (an IANA zone name). Manual edits enter as wall
|
||||||
clocks in the display zone and are stored as instants.
|
clocks in the display zone (rendered naive-as-UTC by the client) and are
|
||||||
|
stored as instants; the legacy column keeps the sent value verbatim and
|
||||||
|
revert restores a snapshot of both the legacy pair and the new-contract
|
||||||
|
triple (`original_date_taken*` / `original_date_utc` /
|
||||||
|
`original_camera_offset_minutes` / `original_date_taken_tz_source`).
|
||||||
|
|
||||||
Consequences of the decision:
|
Consequences of the decision:
|
||||||
|
|
||||||
@@ -59,3 +63,20 @@ Consequences of the decision:
|
|||||||
- **Re-dating across a day boundary invalidates derived day data** (daily summaries, reel
|
- **Re-dating across a day boundary invalidates derived day data** (daily summaries, reel
|
||||||
pre-gen), which regenerates on its normal schedule; the backfill drain detects the day
|
pre-gen), which regenerates on its normal schedule; the backfill drain detects the day
|
||||||
change per row.
|
change per row.
|
||||||
|
- **Container dates are true instants, not wall clocks.** QuickTime/MP4/MOV
|
||||||
|
`creation_time` fields are seconds-since-1904 in UTC; exiftool renders them as
|
||||||
|
UTC-naive strings. Running a naive container string through the camera wall-clock
|
||||||
|
cascade reinterprets a UTC instant in the server zone and shifts every video by the
|
||||||
|
server's UTC offset (the "videos off by 4–5 hours" bug, ticket #115). Verified against
|
||||||
|
production data: a mirrorless video lands inside the same-moment photo burst, and the
|
||||||
|
delta across all sampled videos is the uniform server EDT/EST offset. So the exiftool
|
||||||
|
branch classifies the *group* of each tag: zoned strings are instants outright,
|
||||||
|
container-group naives are instants (`utc_direct`), and only photo-EXIF-group naives
|
||||||
|
run the cascade. The legacy column for those rows stays byte-identical to the old
|
||||||
|
`-d %s` server-local interpretation; the new-contract columns carry the true instant.
|
||||||
|
- **The legacy column is reproduced in Rust, not by re-invoking exiftool.** The old
|
||||||
|
`-d %s` path interpreted exiftool's rendered naive string in the *server's* local zone
|
||||||
|
(DST-aware, ignoring the file's own `OffsetTime*` tags — verified). `date_taken`
|
||||||
|
for exiftool rows must keep producing exactly that value, so the resolver parses the
|
||||||
|
raw string and applies the server-zone interpretation itself (`utc_direct` rows skip
|
||||||
|
the cascade but still get the server-local legacy value).
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE image_exif DROP COLUMN original_date_utc;
|
||||||
|
ALTER TABLE image_exif DROP COLUMN original_camera_offset_minutes;
|
||||||
|
ALTER TABLE image_exif DROP COLUMN original_date_taken_tz_source;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- Manual date-edit rework (ticket #115).
|
||||||
|
--
|
||||||
|
-- POST /image/exif/date now writes the new-contract triple
|
||||||
|
-- (date_utc, camera_offset_minutes, date_taken_tz_source = 'manual')
|
||||||
|
-- alongside the legacy (date_taken, date_taken_source) pair. Reverting an
|
||||||
|
-- override must restore the resolver's new-contract triple as well as the
|
||||||
|
-- legacy pair, so image_exif snapshots the pre-override new-contract
|
||||||
|
-- columns next to the existing legacy snapshot pair
|
||||||
|
-- (original_date_taken / original_date_taken_source).
|
||||||
|
|
||||||
|
ALTER TABLE image_exif ADD COLUMN original_date_utc BIGINT;
|
||||||
|
ALTER TABLE image_exif ADD COLUMN original_camera_offset_minutes INTEGER;
|
||||||
|
ALTER TABLE image_exif ADD COLUMN original_date_taken_tz_source TEXT;
|
||||||
@@ -3381,6 +3381,9 @@ mod tests {
|
|||||||
date_utc: Some(1_629_028_800),
|
date_utc: Some(1_629_028_800),
|
||||||
camera_offset_minutes: None,
|
camera_offset_minutes: None,
|
||||||
date_taken_tz_source: Some("utc_direct".to_string()),
|
date_taken_tz_source: Some("utc_direct".to_string()),
|
||||||
|
original_date_utc: None,
|
||||||
|
original_camera_offset_minutes: None,
|
||||||
|
original_date_taken_tz_source: None,
|
||||||
});
|
});
|
||||||
let out = resolve_date_taken_for_context(&exif, "Screenshot_2014-06-01.png");
|
let out = resolve_date_taken_for_context(&exif, "Screenshot_2014-06-01.png");
|
||||||
assert_eq!(out.as_deref(), Some("2021-08-15"));
|
assert_eq!(out.as_deref(), Some("2021-08-15"));
|
||||||
|
|||||||
+71
-10
@@ -40,9 +40,8 @@ pub enum TzSource {
|
|||||||
/// The resolver branch already produced a true UTC instant (exiftool,
|
/// The resolver branch already produced a true UTC instant (exiftool,
|
||||||
/// filesystem time, unix-timestamp filename). No offset is involved.
|
/// filesystem time, unix-timestamp filename). No offset is involved.
|
||||||
UtcDirect,
|
UtcDirect,
|
||||||
/// An operator set the date manually.
|
/// An operator set the date manually (POST /image/exif/date, ticket
|
||||||
// Written by the manual-edit rework (#118); nothing constructs it yet.
|
/// #115): the wall the user picked, interpreted in their display zone.
|
||||||
#[allow(dead_code)]
|
|
||||||
Manual,
|
Manual,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,13 +217,39 @@ pub const DEFAULT_DISPLAY_TZ: &str = "America/New_York";
|
|||||||
/// NULL / empty / unparseable prefs fall back to the app default, so
|
/// NULL / empty / unparseable prefs fall back to the app default, so
|
||||||
/// read paths (tickets #119/#120) never have to special-case the
|
/// read paths (tickets #119/#120) never have to special-case the
|
||||||
/// unset state themselves.
|
/// unset state themselves.
|
||||||
pub fn display_zone_for(stored: Option<&str>) -> &str {
|
pub fn display_zone_for(stored: Option<&str>) -> String {
|
||||||
match stored.map(str::trim).filter(|s| !s.is_empty()) {
|
match stored.map(str::trim).filter(|s| !s.is_empty()) {
|
||||||
Some(name) if Tz::from_str(name).is_ok() => name,
|
Some(name) if Tz::from_str(name).is_ok() => name.to_string(),
|
||||||
_ => DEFAULT_DISPLAY_TZ,
|
_ => 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.
|
/// Display-zone offset, in minutes, for a true UTC instant — DST-aware.
|
||||||
/// `None` when the zone name doesn't parse.
|
/// `None` when the zone name doesn't parse.
|
||||||
#[allow(dead_code)] // consumed by the read-side cutover (#119/#120)
|
#[allow(dead_code)] // consumed by the read-side cutover (#119/#120)
|
||||||
@@ -240,10 +265,13 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn display_zone_for_falls_back_to_default() {
|
fn display_zone_for_falls_back_to_default() {
|
||||||
assert_eq!(display_zone_for(None), DEFAULT_DISPLAY_TZ);
|
assert_eq!(display_zone_for(None).as_str(), DEFAULT_DISPLAY_TZ);
|
||||||
assert_eq!(display_zone_for(Some("")), DEFAULT_DISPLAY_TZ);
|
assert_eq!(display_zone_for(Some("")).as_str(), DEFAULT_DISPLAY_TZ);
|
||||||
assert_eq!(display_zone_for(Some(" ")), DEFAULT_DISPLAY_TZ);
|
assert_eq!(display_zone_for(Some(" ")).as_str(), DEFAULT_DISPLAY_TZ);
|
||||||
assert_eq!(display_zone_for(Some("Not/A_Zone")), DEFAULT_DISPLAY_TZ);
|
assert_eq!(
|
||||||
|
display_zone_for(Some("Not/A_Zone")).as_str(),
|
||||||
|
DEFAULT_DISPLAY_TZ
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -359,6 +387,39 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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]
|
#[test]
|
||||||
fn display_zone_offset_minutes_unknown_zone_is_none() {
|
fn display_zone_offset_minutes_unknown_zone_is_none() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -307,6 +307,14 @@ pub struct ExifMetadata {
|
|||||||
pub original_date_taken: Option<i64>,
|
pub original_date_taken: Option<i64>,
|
||||||
/// When `date_taken_source = "manual"`, the prior source.
|
/// When `date_taken_source = "manual"`, the prior source.
|
||||||
pub original_date_taken_source: Option<String>,
|
pub original_date_taken_source: Option<String>,
|
||||||
|
/// When `date_taken_source = "manual"`, the prior `date_utc` snapshot
|
||||||
|
/// (the new-contract triple the revert restores alongside the legacy
|
||||||
|
/// pair above).
|
||||||
|
pub original_date_utc: Option<i64>,
|
||||||
|
/// When `date_taken_source = "manual"`, the prior camera offset.
|
||||||
|
pub original_camera_offset_minutes: Option<i32>,
|
||||||
|
/// When `date_taken_source = "manual"`, the prior tz source.
|
||||||
|
pub original_date_taken_tz_source: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -397,6 +405,9 @@ impl From<ImageExif> for ExifMetadata {
|
|||||||
date_taken_tz_source: exif.date_taken_tz_source,
|
date_taken_tz_source: exif.date_taken_tz_source,
|
||||||
original_date_taken: exif.original_date_taken,
|
original_date_taken: exif.original_date_taken,
|
||||||
original_date_taken_source: exif.original_date_taken_source,
|
original_date_taken_source: exif.original_date_taken_source,
|
||||||
|
original_date_utc: exif.original_date_utc,
|
||||||
|
original_camera_offset_minutes: exif.original_camera_offset_minutes,
|
||||||
|
original_date_taken_tz_source: exif.original_date_taken_tz_source,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -624,6 +635,9 @@ mod date_contract_tests {
|
|||||||
date_utc: Some(1_720_108_800),
|
date_utc: Some(1_720_108_800),
|
||||||
camera_offset_minutes: Some(-240),
|
camera_offset_minutes: Some(-240),
|
||||||
date_taken_tz_source: Some("offset_tag".to_string()),
|
date_taken_tz_source: Some("offset_tag".to_string()),
|
||||||
|
original_date_utc: None,
|
||||||
|
original_camera_offset_minutes: None,
|
||||||
|
original_date_taken_tz_source: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+73
-20
@@ -568,25 +568,32 @@ pub trait ExifDao: Sync + Send {
|
|||||||
model_version: Option<&str>,
|
model_version: Option<&str>,
|
||||||
) -> Result<Vec<(String, Vec<u8>)>, DbError>;
|
) -> Result<Vec<(String, Vec<u8>)>, DbError>;
|
||||||
|
|
||||||
/// Operator-driven date_taken override (POST /image/exif/date). Snapshots
|
/// Operator-driven date_taken override (POST /image/exif/date).
|
||||||
/// the prior `(date_taken, date_taken_source)` into the `original_*`
|
/// `date_taken` is the naive-as-UTC epoch the UI sent; the legacy
|
||||||
/// pair on first override, then writes the new value with
|
/// column stores it verbatim (byte-identical to the pre-rework
|
||||||
/// `date_taken_source = 'manual'`. Subsequent overrides keep the
|
/// behavior). The new-contract triple (ADR-0001, ticket #115) is
|
||||||
/// original snapshot intact so a single revert restores the resolver
|
/// computed from the same value as a wall clock in `display_tz` —
|
||||||
/// result, not whatever override was set just before. Returns the
|
/// the user's display zone — with `date_taken_source = 'manual'`.
|
||||||
/// post-update row.
|
/// 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(
|
fn set_manual_date_taken(
|
||||||
&mut self,
|
&mut self,
|
||||||
context: &opentelemetry::Context,
|
context: &opentelemetry::Context,
|
||||||
library_id: i32,
|
library_id: i32,
|
||||||
rel_path: &str,
|
rel_path: &str,
|
||||||
date_taken: i64,
|
date_taken: i64,
|
||||||
|
display_tz: &str,
|
||||||
) -> Result<ImageExif, DbError>;
|
) -> Result<ImageExif, DbError>;
|
||||||
|
|
||||||
/// Revert a manual override (POST /image/exif/date/clear): restore
|
/// Revert a manual override (POST /image/exif/date/clear): restore
|
||||||
/// `date_taken` + `date_taken_source` from the `original_*` snapshot,
|
/// `date_taken` + `date_taken_source` AND the new-contract triple
|
||||||
/// then null both originals. No-op (returns current row unchanged) when
|
/// (`date_utc` / `camera_offset_minutes` / `date_taken_tz_source`,
|
||||||
/// no override is active.
|
/// 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(
|
fn clear_manual_date_taken(
|
||||||
&mut self,
|
&mut self,
|
||||||
context: &opentelemetry::Context,
|
context: &opentelemetry::Context,
|
||||||
@@ -1640,6 +1647,7 @@ impl ExifDao for SqliteExifDao {
|
|||||||
library_id_val: i32,
|
library_id_val: i32,
|
||||||
rel_path_val: &str,
|
rel_path_val: &str,
|
||||||
date_taken_val: i64,
|
date_taken_val: i64,
|
||||||
|
display_tz_val: &str,
|
||||||
) -> Result<ImageExif, DbError> {
|
) -> Result<ImageExif, DbError> {
|
||||||
trace_db_call(context, "update", "set_manual_date_taken", |_span| {
|
trace_db_call(context, "update", "set_manual_date_taken", |_span| {
|
||||||
use schema::image_exif::dsl::*;
|
use schema::image_exif::dsl::*;
|
||||||
@@ -1652,16 +1660,49 @@ impl ExifDao for SqliteExifDao {
|
|||||||
let current =
|
let current =
|
||||||
locate_image_exif_row(connection.deref_mut(), library_id_val, rel_path_val)?;
|
locate_image_exif_row(connection.deref_mut(), library_id_val, rel_path_val)?;
|
||||||
|
|
||||||
// Snapshot only on first override. Subsequent overrides keep
|
// Snapshot both pairs only on first override. Subsequent
|
||||||
// the original snapshot intact so a single revert restores
|
// overrides keep the original snapshot intact so a single
|
||||||
// the resolver-derived value, not the prior override.
|
// revert restores the resolver-derived value, not the prior
|
||||||
let (orig_dt, orig_src) = if current.original_date_taken.is_none() {
|
// override.
|
||||||
(current.date_taken, current.date_taken_source.clone())
|
let (orig_dt, orig_src, orig_dt_utc, orig_offset, orig_tz) =
|
||||||
} else {
|
if current.original_date_taken.is_none() {
|
||||||
(
|
(
|
||||||
current.original_date_taken,
|
current.date_taken,
|
||||||
current.original_date_taken_source.clone(),
|
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,
|
// 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())),
|
date_taken_source.eq(Some("manual".to_string())),
|
||||||
original_date_taken.eq(orig_dt),
|
original_date_taken.eq(orig_dt),
|
||||||
original_date_taken_source.eq(orig_src),
|
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())
|
.execute(connection.deref_mut())
|
||||||
.map_err(|e| anyhow::Error::from(e).context("update_failed"))?;
|
.map_err(|e| anyhow::Error::from(e).context("update_failed"))?;
|
||||||
@@ -1718,8 +1765,14 @@ impl ExifDao for SqliteExifDao {
|
|||||||
.set((
|
.set((
|
||||||
date_taken.eq(current.original_date_taken),
|
date_taken.eq(current.original_date_taken),
|
||||||
date_taken_source.eq(current.original_date_taken_source.clone()),
|
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.eq::<Option<i64>>(None),
|
||||||
original_date_taken_source.eq::<Option<String>>(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())
|
.execute(connection.deref_mut())
|
||||||
.map_err(|e| anyhow::Error::from(e).context("update_failed"))?;
|
.map_err(|e| anyhow::Error::from(e).context("update_failed"))?;
|
||||||
|
|||||||
@@ -208,6 +208,14 @@ pub struct ImageExif {
|
|||||||
pub camera_offset_minutes: Option<i32>,
|
pub camera_offset_minutes: Option<i32>,
|
||||||
/// How the offset was determined. See `camera_tz::TzSource`.
|
/// How the offset was determined. See `camera_tz::TzSource`.
|
||||||
pub date_taken_tz_source: Option<String>,
|
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)]
|
#[derive(Insertable, Clone)]
|
||||||
|
|||||||
@@ -143,6 +143,9 @@ diesel::table! {
|
|||||||
date_utc -> Nullable<BigInt>,
|
date_utc -> Nullable<BigInt>,
|
||||||
camera_offset_minutes -> Nullable<Integer>,
|
camera_offset_minutes -> Nullable<Integer>,
|
||||||
date_taken_tz_source -> Nullable<Text>,
|
date_taken_tz_source -> Nullable<Text>,
|
||||||
|
original_date_utc -> Nullable<BigInt>,
|
||||||
|
original_camera_offset_minutes -> Nullable<Integer>,
|
||||||
|
original_date_taken_tz_source -> Nullable<Text>,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+599
-137
@@ -10,7 +10,12 @@
|
|||||||
//! can't: QuickTime/MP4 (`MediaCreateDate`, `TrackCreateDate`,
|
//! can't: QuickTime/MP4 (`MediaCreateDate`, `TrackCreateDate`,
|
||||||
//! `CreateDate`), Apple's `ContentCreateDate`, MakerNote sub-IFDs.
|
//! `CreateDate`), Apple's `ContentCreateDate`, MakerNote sub-IFDs.
|
||||||
//! Required for videos to land a real date; degrades silently when
|
//! Required for videos to land a real date; degrades silently when
|
||||||
//! `exiftool` isn't on PATH.
|
//! `exiftool` isn't on PATH. Unlike the kamadak branch, this branch
|
||||||
|
//! reads raw date *strings* (no `-d %s`) and interprets them itself:
|
||||||
|
//! QuickTime/MP4 container dates are true instants (UTC-rendered
|
||||||
|
//! `creation_time` fields) and skip the cascade; photo-EXIF wall
|
||||||
|
//! clocks run the cascade over the file's own `OffsetTime*` / GPS
|
||||||
|
//! evidence (ADR-0001, ticket #115).
|
||||||
//! 3. **filename regex** — `memories::extract_date_from_filename` covers
|
//! 3. **filename regex** — `memories::extract_date_from_filename` covers
|
||||||
//! common screenshot / chat-export / timestamp-named patterns.
|
//! common screenshot / chat-export / timestamp-named patterns.
|
||||||
//! 4. **earliest filesystem time** — `utils::earliest_fs_time` picks the
|
//! 4. **earliest filesystem time** — `utils::earliest_fs_time` picks the
|
||||||
@@ -32,9 +37,8 @@ use std::path::{Path, PathBuf};
|
|||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
use chrono::{DateTime, NaiveDateTime, Utc};
|
use chrono::{DateTime, Datelike, Local, LocalResult, NaiveDateTime, TimeZone, Utc};
|
||||||
use log::{debug, trace, warn};
|
use log::{debug, trace, warn};
|
||||||
use serde::Deserialize;
|
|
||||||
|
|
||||||
use crate::camera_tz;
|
use crate::camera_tz;
|
||||||
use crate::utils::earliest_fs_time;
|
use crate::utils::earliest_fs_time;
|
||||||
@@ -148,16 +152,8 @@ pub fn resolve_date_taken(path: &Path, prior_exif: Option<&ExifPrior>) -> Option
|
|||||||
if let Some(prior) = prior_exif {
|
if let Some(prior) = prior_exif {
|
||||||
return Some(exif_prior_resolved(prior));
|
return Some(exif_prior_resolved(prior));
|
||||||
}
|
}
|
||||||
if let Some(ts) = exiftool_date_single(path) {
|
if let Some(resolved) = exiftool_date_single(path) {
|
||||||
return Some(ResolvedDate {
|
return Some(resolved);
|
||||||
timestamp: ts,
|
|
||||||
source: DateSource::Exiftool,
|
|
||||||
// exiftool's `-d %s` conversion interprets the container date
|
|
||||||
// in the server's local zone — already a true UTC instant.
|
|
||||||
date_utc: Some(ts),
|
|
||||||
camera_offset_minutes: None,
|
|
||||||
tz_source: Some(camera_tz::TzSource::UtcDirect),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if let Some(dt) = path
|
if let Some(dt) = path
|
||||||
.file_name()
|
.file_name()
|
||||||
@@ -214,20 +210,10 @@ pub fn resolve_dates_batch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !needs_exiftool.is_empty() {
|
if !needs_exiftool.is_empty() {
|
||||||
let exiftool_results = exiftool_dates_batch(&needs_exiftool);
|
// One subprocess for the whole batch; each entry is already fully
|
||||||
for path in &needs_exiftool {
|
// resolved (legacy + new-contract fields).
|
||||||
if let Some(&ts) = exiftool_results.get(*path) {
|
for (path, resolved) in exiftool_dates_batch(&needs_exiftool) {
|
||||||
out.insert(
|
out.insert(path, resolved);
|
||||||
path.to_path_buf(),
|
|
||||||
ResolvedDate {
|
|
||||||
timestamp: ts,
|
|
||||||
source: DateSource::Exiftool,
|
|
||||||
date_utc: Some(ts),
|
|
||||||
camera_offset_minutes: None,
|
|
||||||
tz_source: Some(camera_tz::TzSource::UtcDirect),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,7 +258,7 @@ pub fn resolve_dates_batch(
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tag priority for exiftool extraction. First non-zero value wins.
|
/// Tag priority for exiftool extraction. First parseable value wins.
|
||||||
///
|
///
|
||||||
/// Photos: `DateTimeOriginal` (original capture) and `SubSecDateTimeOriginal`
|
/// Photos: `DateTimeOriginal` (original capture) and `SubSecDateTimeOriginal`
|
||||||
/// are most authoritative. `CreateDate` is a common alias and a sane fallback.
|
/// are most authoritative. `CreateDate` is a common alias and a sane fallback.
|
||||||
@@ -294,6 +280,19 @@ const EXIFTOOL_DATE_TAGS: &[&str] = &[
|
|||||||
"ContentCreateDate",
|
"ContentCreateDate",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// Offset + GPS evidence tags requested in the same exiftool pass. They
|
||||||
|
/// feed the camera-offset cascade for naive wall-clock readings that come
|
||||||
|
/// from photo-EXIF groups (ADR-0001).
|
||||||
|
const EXIFTOOL_EVIDENCE_TAGS: &[&str] = &[
|
||||||
|
"OffsetTime",
|
||||||
|
"OffsetTimeOriginal",
|
||||||
|
"OffsetTimeDigitized",
|
||||||
|
"GPS:GPSLatitude",
|
||||||
|
"GPS:GPSLatitudeRef",
|
||||||
|
"GPS:GPSLongitude",
|
||||||
|
"GPS:GPSLongitudeRef",
|
||||||
|
];
|
||||||
|
|
||||||
/// Cache the "exiftool exists on PATH" check across the process lifetime so
|
/// Cache the "exiftool exists on PATH" check across the process lifetime so
|
||||||
/// the per-tick backfill doesn't fork a doomed subprocess every iteration on
|
/// the per-tick backfill doesn't fork a doomed subprocess every iteration on
|
||||||
/// deploys without exiftool installed.
|
/// deploys without exiftool installed.
|
||||||
@@ -314,6 +313,20 @@ fn exiftool_available() -> bool {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Append the exiftool arguments shared by the single-file and batch
|
||||||
|
/// invocations: raw date strings (no `-d %s` — the resolver interprets
|
||||||
|
/// them itself) with group prefixes for classification, plus the offset
|
||||||
|
/// / GPS evidence tags for the cascade.
|
||||||
|
fn exiftool_common_args(cmd: &mut Command) {
|
||||||
|
cmd.arg("-j").arg("-q").arg("-G1");
|
||||||
|
for tag in EXIFTOOL_DATE_TAGS {
|
||||||
|
cmd.arg(format!("-{}", tag));
|
||||||
|
}
|
||||||
|
for tag in EXIFTOOL_EVIDENCE_TAGS {
|
||||||
|
cmd.arg(format!("-{}", tag));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// One-file exiftool invocation. Used by the upload + GPS-write paths,
|
/// One-file exiftool invocation. Used by the upload + GPS-write paths,
|
||||||
/// which deal with one file at a time. The batch path uses
|
/// which deal with one file at a time. The batch path uses
|
||||||
/// `exiftool_dates_batch` so we don't pay subprocess startup per row.
|
/// `exiftool_dates_batch` so we don't pay subprocess startup per row.
|
||||||
@@ -326,31 +339,30 @@ fn exiftool_available() -> bool {
|
|||||||
/// that actually have a real capture date. We pre-filter to files that
|
/// that actually have a real capture date. We pre-filter to files that
|
||||||
/// kamadak-exif couldn't read, so the JPEG fast-path is already covered
|
/// kamadak-exif couldn't read, so the JPEG fast-path is already covered
|
||||||
/// — paying full-scan cost on the residual is the right trade.
|
/// — paying full-scan cost on the residual is the right trade.
|
||||||
fn exiftool_date_single(path: &Path) -> Option<i64> {
|
fn exiftool_date_single(path: &Path) -> Option<ResolvedDate> {
|
||||||
if !exiftool_available() {
|
if !exiftool_available() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let mut cmd = Command::new("exiftool");
|
let mut cmd = Command::new("exiftool");
|
||||||
cmd.arg("-j").arg("-q").arg("-d").arg("%s");
|
exiftool_common_args(&mut cmd);
|
||||||
for tag in EXIFTOOL_DATE_TAGS {
|
|
||||||
cmd.arg(format!("-{}", tag));
|
|
||||||
}
|
|
||||||
cmd.arg(path);
|
cmd.arg(path);
|
||||||
let output = cmd.output().ok()?;
|
let output = cmd.output().ok()?;
|
||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
trace!("exiftool exited non-zero for {:?}", path);
|
trace!("exiftool exited non-zero for {:?}", path);
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
parse_exiftool_json(&output.stdout)
|
parse_exiftool_payload(&output.stdout)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.next()
|
.next()
|
||||||
.map(|(_, ts)| ts)
|
.and_then(|f| resolve_exiftool_payload(&f.date_tags, &f.evidence))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drain a batch via a single exiftool subprocess. Paths are fed on stdin
|
/// Drain a batch via a single exiftool subprocess. Paths are fed on stdin
|
||||||
/// via `-@ -`, so the argv stays short regardless of batch size — safe for
|
/// via `-@ -`, so the argv stays short regardless of batch size — safe for
|
||||||
/// libraries with very long path components.
|
/// libraries with very long path components. Resolution is the same pure
|
||||||
fn exiftool_dates_batch(paths: &[&Path]) -> HashMap<PathBuf, i64> {
|
/// function as the single-file path, so the drain and ingest produce
|
||||||
|
/// identical results for the same file.
|
||||||
|
fn exiftool_dates_batch(paths: &[&Path]) -> HashMap<PathBuf, ResolvedDate> {
|
||||||
let mut out = HashMap::new();
|
let mut out = HashMap::new();
|
||||||
if paths.is_empty() || !exiftool_available() {
|
if paths.is_empty() || !exiftool_available() {
|
||||||
return out;
|
return out;
|
||||||
@@ -360,10 +372,7 @@ fn exiftool_dates_batch(paths: &[&Path]) -> HashMap<PathBuf, i64> {
|
|||||||
// No `-fast2` — see exiftool_date_single for the rationale (QuickTime
|
// No `-fast2` — see exiftool_date_single for the rationale (QuickTime
|
||||||
// moov-at-end files miss CreateDate / MediaCreateDate when the trailer
|
// moov-at-end files miss CreateDate / MediaCreateDate when the trailer
|
||||||
// is skipped).
|
// is skipped).
|
||||||
cmd.arg("-j").arg("-q").arg("-d").arg("%s");
|
exiftool_common_args(&mut cmd);
|
||||||
for tag in EXIFTOOL_DATE_TAGS {
|
|
||||||
cmd.arg(format!("-{}", tag));
|
|
||||||
}
|
|
||||||
cmd.arg("-@").arg("-");
|
cmd.arg("-@").arg("-");
|
||||||
cmd.stdin(Stdio::piped())
|
cmd.stdin(Stdio::piped())
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
@@ -403,36 +412,328 @@ fn exiftool_dates_batch(paths: &[&Path]) -> HashMap<PathBuf, i64> {
|
|||||||
output.status.code()
|
output.status.code()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
for (source, ts) in parse_exiftool_json(&output.stdout) {
|
for file in parse_exiftool_payload(&output.stdout) {
|
||||||
out.insert(PathBuf::from(source), ts);
|
if let Some(resolved) = resolve_exiftool_payload(&file.date_tags, &file.evidence) {
|
||||||
|
out.insert(PathBuf::from(file.source_file), resolved);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One row per input file. exiftool emits any tag we asked for that was
|
/// One raw date string from the exiftool payload, with the exiftool group
|
||||||
/// present, plus the `SourceFile` it was reading. Tags are JSON values
|
/// it came from (`-G1` prefixes every tag key as `"Group:Tag"`).
|
||||||
/// because `-d %s` returns the timestamp as a *string* of digits, not a
|
#[derive(Debug, Clone)]
|
||||||
/// number, when the date parses; absent tags are simply missing keys.
|
struct RawDateTag {
|
||||||
#[derive(Debug, Deserialize)]
|
group: String,
|
||||||
struct ExiftoolEntry {
|
tag: String,
|
||||||
#[serde(rename = "SourceFile")]
|
value: String,
|
||||||
source_file: String,
|
|
||||||
#[serde(rename = "DateTimeOriginal")]
|
|
||||||
date_time_original: Option<serde_json::Value>,
|
|
||||||
#[serde(rename = "SubSecDateTimeOriginal")]
|
|
||||||
sub_sec_date_time_original: Option<serde_json::Value>,
|
|
||||||
#[serde(rename = "CreateDate")]
|
|
||||||
create_date: Option<serde_json::Value>,
|
|
||||||
#[serde(rename = "MediaCreateDate")]
|
|
||||||
media_create_date: Option<serde_json::Value>,
|
|
||||||
#[serde(rename = "TrackCreateDate")]
|
|
||||||
track_create_date: Option<serde_json::Value>,
|
|
||||||
#[serde(rename = "ContentCreateDate")]
|
|
||||||
content_create_date: Option<serde_json::Value>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_exiftool_json(stdout: &[u8]) -> Vec<(String, i64)> {
|
/// Per-file offset/GPS evidence from the same exiftool payload.
|
||||||
let entries: Vec<ExiftoolEntry> = match serde_json::from_slice(stdout) {
|
#[derive(Copy, Clone, Debug, Default)]
|
||||||
|
struct ExiftoolEvidence {
|
||||||
|
/// `OffsetTime` / `OffsetTimeDigitized`, whichever was present first.
|
||||||
|
offset_time: Option<i32>,
|
||||||
|
/// `OffsetTimeOriginal`.
|
||||||
|
offset_time_original: Option<i32>,
|
||||||
|
/// Unsigned decimal degrees; the Ref fields carry the sign.
|
||||||
|
gps_lat: Option<f64>,
|
||||||
|
gps_lon: Option<f64>,
|
||||||
|
gps_lat_south: bool,
|
||||||
|
gps_lon_west: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ExiftoolEvidence {
|
||||||
|
/// Signed `(latitude, longitude)`, when both coordinates are present.
|
||||||
|
fn gps(self) -> Option<(f64, f64)> {
|
||||||
|
let lat = self.gps_lat?;
|
||||||
|
let lon = self.gps_lon?;
|
||||||
|
Some((
|
||||||
|
if self.gps_lat_south { -lat } else { lat },
|
||||||
|
if self.gps_lon_west { -lon } else { lon },
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How one raw date string should be interpreted.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
enum DateReading {
|
||||||
|
/// The string carried its own UTC offset (zoned) — already a true
|
||||||
|
/// instant.
|
||||||
|
Zoned(i64),
|
||||||
|
/// Naive wall-clock reading; needs group classification and (for
|
||||||
|
/// photo-EXIF groups) the offset cascade.
|
||||||
|
Naive(NaiveDateTime),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse one exiftool date string. `None` for empty values, the
|
||||||
|
/// `"0000:00:00 00:00:00"` zero placeholder exiftool emits for
|
||||||
|
/// missing-but-allocated date slots, and anything before 1900.
|
||||||
|
///
|
||||||
|
/// Zoned strings (a trailing `Z`/`z` or a `±HH:MM[:SS]` offset) are true
|
||||||
|
/// instants — XMP dates, some MakerNotes, and exiftool's own
|
||||||
|
/// `Composite:SubSec*` rendering when an `OffsetTime*` tag is present —
|
||||||
|
/// and are converted to unix seconds directly. Naive strings are returned
|
||||||
|
/// as-is for group classification + the offset cascade.
|
||||||
|
fn parse_exiftool_date_string(value: &str) -> Option<DateReading> {
|
||||||
|
let v = value.trim();
|
||||||
|
if v.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// Zoned form: split off the trailing offset, parse the naive part, and
|
||||||
|
// fold the offset into a true instant.
|
||||||
|
if let Some((naive_part, offset_minutes)) = split_zoned_suffix(v) {
|
||||||
|
let naive = parse_naive_exiftool(naive_part)?;
|
||||||
|
if naive.year() < 1900 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let instant = naive.and_utc().timestamp() - (offset_minutes as i64) * 60;
|
||||||
|
return Some(DateReading::Zoned(instant));
|
||||||
|
}
|
||||||
|
let naive = parse_naive_exiftool(v)?;
|
||||||
|
if naive.year() < 1900 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(DateReading::Naive(naive))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The naive date-time formats exiftool renders, in preference order. EXIF
|
||||||
|
/// uses `:` between date fields; ISO uses `-`. The time separator is ` ` or
|
||||||
|
/// `T`; fractional seconds are optional.
|
||||||
|
const NAIVE_EXIFTOOL_FORMATS: &[&str] = &[
|
||||||
|
"%Y:%m:%d %H:%M:%S%.f",
|
||||||
|
"%Y-%m-%d %H:%M:%S%.f",
|
||||||
|
"%Y-%m-%dT%H:%M:%S%.f",
|
||||||
|
"%Y:%m:%d %H:%M:%S",
|
||||||
|
"%Y-%m-%d %H:%M:%S",
|
||||||
|
"%Y-%m-%dT%H:%M:%S",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Parse a naive (offset-less) exiftool date string.
|
||||||
|
fn parse_naive_exiftool(v: &str) -> Option<NaiveDateTime> {
|
||||||
|
for fmt in NAIVE_EXIFTOOL_FORMATS {
|
||||||
|
if let Ok(naive) = NaiveDateTime::parse_from_str(v, fmt) {
|
||||||
|
return Some(naive);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split a zoned date string into its naive part and its UTC offset in
|
||||||
|
/// signed minutes (local − UTC). Recognises a trailing `Z`/`z` (UTC) or a
|
||||||
|
/// `±HH:MM[:SS]` offset. The date's own `-` separators are skipped because
|
||||||
|
/// only a `±` followed by a well-formed offset (digits and an optional
|
||||||
|
/// `:SS` tail) is accepted.
|
||||||
|
fn split_zoned_suffix(v: &str) -> Option<(&str, i32)> {
|
||||||
|
let b = v.as_bytes();
|
||||||
|
// Trailing Z / z → UTC.
|
||||||
|
if matches!(b.last(), Some(b'Z') | Some(b'z')) {
|
||||||
|
return Some((&v[..v.len() - 1], 0));
|
||||||
|
}
|
||||||
|
for (i, &c) in b.iter().enumerate() {
|
||||||
|
if (c == b'+' || c == b'-')
|
||||||
|
&& i + 3 <= v.len()
|
||||||
|
&& b[i + 1].is_ascii_digit()
|
||||||
|
&& b[i + 2].is_ascii_digit()
|
||||||
|
{
|
||||||
|
let rest = v[i + 3..].strip_prefix(':').unwrap_or(&v[i + 3..]);
|
||||||
|
if rest.is_empty() || (rest.len() <= 2 && rest.bytes().all(|x| x.is_ascii_digit())) {
|
||||||
|
let sign = if c == b'+' { 1i32 } else { -1 };
|
||||||
|
let hh: i32 = v[i + 1..i + 3].parse().ok()?;
|
||||||
|
let mm: i32 = if rest.is_empty() {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
rest.parse().ok()?
|
||||||
|
};
|
||||||
|
return Some((&v[..i], sign * (hh * 60 + mm)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse an exiftool `OffsetTime*` rendering (`"+05:30"`, `"-06:00"`,
|
||||||
|
/// `"Z"`) into signed minutes (local − UTC).
|
||||||
|
fn parse_offset_minutes(value: &str) -> Option<i32> {
|
||||||
|
let v = value.trim();
|
||||||
|
if v == "Z" {
|
||||||
|
return Some(0);
|
||||||
|
}
|
||||||
|
let (sign, rest) = if let Some(r) = v.strip_prefix('-') {
|
||||||
|
(-1i32, r)
|
||||||
|
} else if let Some(r) = v.strip_prefix('+') {
|
||||||
|
(1, r)
|
||||||
|
} else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
let (h, m) = rest.split_once(':')?;
|
||||||
|
let h: i32 = h.trim().parse().ok()?;
|
||||||
|
let m: i32 = m.trim().parse().ok()?;
|
||||||
|
Some(sign * (h * 60 + m))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse exiftool's DMS GPS rendering, e.g. `"40 deg 42' 36.00\""`, into
|
||||||
|
/// unsigned decimal degrees. The Ref field carries the sign.
|
||||||
|
fn parse_gps_dms(value: &str) -> Option<f64> {
|
||||||
|
let v = value.trim();
|
||||||
|
let deg_end = v.find(" deg")?;
|
||||||
|
let deg: f64 = v[..deg_end].trim().parse().ok()?;
|
||||||
|
let rest = &v[deg_end + " deg".len()..];
|
||||||
|
let min_end = rest.find('\'')?;
|
||||||
|
let min: f64 = rest[..min_end].trim().parse().ok()?;
|
||||||
|
let rest = &rest[min_end + 1..];
|
||||||
|
let sec_end = rest.find('"')?;
|
||||||
|
let sec: f64 = rest[..sec_end].trim().parse().ok()?;
|
||||||
|
Some(deg + min / 60.0 + sec / 3600.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// QuickTime/MP4/MOV container groups. The `creation_time` field in those
|
||||||
|
/// containers is seconds-since-1904 in **UTC** — a true instant — and
|
||||||
|
/// exiftool renders it as a UTC-naive string. Classifying these as camera
|
||||||
|
/// wall clocks (running the cascade) reinterprets a UTC instant in the
|
||||||
|
/// server zone and shifts every video by the server's UTC offset — the
|
||||||
|
/// "videos off by 4-5 hours" bug, ticket #115.
|
||||||
|
fn is_container_group(group: &str) -> bool {
|
||||||
|
group == "QuickTime" || group.starts_with("Track") || group == "Apple"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When the same tag name is present in multiple groups (hybrid files),
|
||||||
|
/// prefer the photo-EXIF groups over container / XMP renderings.
|
||||||
|
fn group_rank(group: &str) -> u8 {
|
||||||
|
if group.starts_with("ExifIFD")
|
||||||
|
|| group == "IFD0"
|
||||||
|
|| group == "IFD1"
|
||||||
|
|| group.starts_with("MakerNote")
|
||||||
|
|| group.starts_with("IFD")
|
||||||
|
{
|
||||||
|
0
|
||||||
|
} else if group == "Composite" {
|
||||||
|
1
|
||||||
|
} else if is_container_group(group) {
|
||||||
|
2
|
||||||
|
} else if group.starts_with("XMP") {
|
||||||
|
3
|
||||||
|
} else {
|
||||||
|
4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The legacy column's convention for a naive wall reading: interpret it
|
||||||
|
/// in the *server's* local zone — exactly what exiftool `-d %s` did before
|
||||||
|
/// this rework (verified: it ignores the file's own `OffsetTime*` tags).
|
||||||
|
/// DST-aware: ambiguous fall-back walls take the earlier reading;
|
||||||
|
/// nonexistent spring-forward walls take the earliest local interpretation
|
||||||
|
/// — within an hour of what the old host produced.
|
||||||
|
fn legacy_server_local(wall: NaiveDateTime) -> i64 {
|
||||||
|
match Local.from_local_datetime(&wall) {
|
||||||
|
LocalResult::Single(dt) => dt.timestamp(),
|
||||||
|
LocalResult::Ambiguous(earlier, _) => earlier.timestamp(),
|
||||||
|
// Nonexistent spring-forward wall: interpret the reading as UTC
|
||||||
|
// and take the zone's offset at that instant — within an hour of
|
||||||
|
// what the old exiftool/glibc path produced.
|
||||||
|
LocalResult::None => Local.from_utc_datetime(&wall).timestamp(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Interpret one file's raw exiftool payload into a [`ResolvedDate`]
|
||||||
|
/// under the new contract (ADR-0001):
|
||||||
|
///
|
||||||
|
/// - **zoned** strings carry their own UTC offset → true instant,
|
||||||
|
/// `utc_direct`.
|
||||||
|
/// - **container-group** naive strings are UTC-rendered `creation_time`
|
||||||
|
/// fields → true instant, `utc_direct` (no cascade — see
|
||||||
|
/// [`is_container_group`]).
|
||||||
|
/// - **photo-EXIF / other** naive strings are camera wall clocks → offset
|
||||||
|
/// cascade over the file's own `OffsetTime*` + GPS evidence.
|
||||||
|
///
|
||||||
|
/// Tag priority is [`EXIFTOOL_DATE_TAGS`]; within a tag name the photo
|
||||||
|
/// group wins ([`group_rank`]). Unparseable candidates (zero dates,
|
||||||
|
/// junk) fall through to the next candidate.
|
||||||
|
fn resolve_exiftool_payload(
|
||||||
|
date_tags: &[RawDateTag],
|
||||||
|
evidence: &ExiftoolEvidence,
|
||||||
|
) -> Option<ResolvedDate> {
|
||||||
|
let mut candidates: Vec<&RawDateTag> = date_tags.iter().collect();
|
||||||
|
candidates.sort_by(|a, b| {
|
||||||
|
let pa = EXIFTOOL_DATE_TAGS
|
||||||
|
.iter()
|
||||||
|
.position(|t| *t == a.tag)
|
||||||
|
.unwrap_or(usize::MAX);
|
||||||
|
let pb = EXIFTOOL_DATE_TAGS
|
||||||
|
.iter()
|
||||||
|
.position(|t| *t == b.tag)
|
||||||
|
.unwrap_or(usize::MAX);
|
||||||
|
pa.cmp(&pb)
|
||||||
|
.then_with(|| group_rank(&a.group).cmp(&group_rank(&b.group)))
|
||||||
|
});
|
||||||
|
|
||||||
|
for cand in candidates {
|
||||||
|
let Some(reading) = parse_exiftool_date_string(&cand.value) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
match reading {
|
||||||
|
DateReading::Zoned(ts) => {
|
||||||
|
return Some(ResolvedDate {
|
||||||
|
timestamp: ts,
|
||||||
|
source: DateSource::Exiftool,
|
||||||
|
date_utc: Some(ts),
|
||||||
|
camera_offset_minutes: None,
|
||||||
|
tz_source: Some(camera_tz::TzSource::UtcDirect),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
DateReading::Naive(wall) => {
|
||||||
|
if is_container_group(&cand.group) {
|
||||||
|
return Some(ResolvedDate {
|
||||||
|
timestamp: legacy_server_local(wall),
|
||||||
|
source: DateSource::Exiftool,
|
||||||
|
date_utc: Some(wall.and_utc().timestamp()),
|
||||||
|
camera_offset_minutes: None,
|
||||||
|
tz_source: Some(camera_tz::TzSource::UtcDirect),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Camera wall clock: offset-tag pairing mirrors the
|
||||||
|
// kamadak branch (exif.rs) — DateTimeOriginal / SubSec*
|
||||||
|
// pair with OffsetTimeOriginal first, everything else with
|
||||||
|
// OffsetTime first.
|
||||||
|
let prefer_original =
|
||||||
|
cand.tag == "DateTimeOriginal" || cand.tag == "SubSecDateTimeOriginal";
|
||||||
|
let offset_tag = if prefer_original {
|
||||||
|
evidence.offset_time_original.or(evidence.offset_time)
|
||||||
|
} else {
|
||||||
|
evidence.offset_time.or(evidence.offset_time_original)
|
||||||
|
};
|
||||||
|
let ev = camera_tz::OffsetEvidence {
|
||||||
|
offset_tag_minutes: offset_tag,
|
||||||
|
gps: evidence.gps(),
|
||||||
|
};
|
||||||
|
let resolution =
|
||||||
|
camera_tz::resolve_camera_offset(wall, &ev, server_zone_cached(), gps_lookup());
|
||||||
|
return Some(ResolvedDate {
|
||||||
|
timestamp: legacy_server_local(wall),
|
||||||
|
source: DateSource::Exiftool,
|
||||||
|
date_utc: Some(camera_tz::wall_to_instant(wall, resolution.minutes)),
|
||||||
|
camera_offset_minutes: Some(resolution.minutes),
|
||||||
|
tz_source: Some(resolution.source),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One file's share of the exiftool JSON payload.
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct ExiftoolFileData {
|
||||||
|
source_file: String,
|
||||||
|
date_tags: Vec<RawDateTag>,
|
||||||
|
evidence: ExiftoolEvidence,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split the exiftool `-j -G1` output into per-file date tags + evidence.
|
||||||
|
/// With `-G1` every tag key is `"Group:Tag"` (only `SourceFile` is
|
||||||
|
/// unprefixed); absent tags are simply missing keys.
|
||||||
|
fn parse_exiftool_payload(stdout: &[u8]) -> Vec<ExiftoolFileData> {
|
||||||
|
let entries: Vec<serde_json::Value> = match serde_json::from_slice(stdout) {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// Empty stdout on total failure isn't a parse error worth
|
// Empty stdout on total failure isn't a parse error worth
|
||||||
@@ -447,103 +748,264 @@ fn parse_exiftool_json(stdout: &[u8]) -> Vec<(String, i64)> {
|
|||||||
|
|
||||||
let mut out = Vec::with_capacity(entries.len());
|
let mut out = Vec::with_capacity(entries.len());
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
// Walk the priority list. exiftool sometimes returns the literal
|
let Some(obj) = entry.as_object() else {
|
||||||
// string "0000:00:00 00:00:00" for missing-but-allocated date
|
continue;
|
||||||
// slots; with `-d %s` that becomes the unix epoch (0). Reject
|
};
|
||||||
// anything <= 0 so we fall through to the next tag.
|
let Some(source_file) = obj.get("SourceFile").and_then(|v| v.as_str()) else {
|
||||||
let tags = [
|
continue;
|
||||||
entry.date_time_original.as_ref(),
|
};
|
||||||
entry.sub_sec_date_time_original.as_ref(),
|
let mut data = ExiftoolFileData {
|
||||||
entry.create_date.as_ref(),
|
source_file: source_file.to_string(),
|
||||||
entry.media_create_date.as_ref(),
|
date_tags: Vec::new(),
|
||||||
entry.track_create_date.as_ref(),
|
evidence: ExiftoolEvidence::default(),
|
||||||
entry.content_create_date.as_ref(),
|
};
|
||||||
];
|
for (key, value) in obj {
|
||||||
let mut chosen: Option<i64> = None;
|
let Some((group, tag)) = key.split_once(':') else {
|
||||||
for tag in tags.iter().flatten() {
|
continue;
|
||||||
if let Some(ts) = coerce_to_unix_seconds(tag)
|
};
|
||||||
&& ts > 0
|
let Some(v) = value.as_str() else {
|
||||||
{
|
continue;
|
||||||
chosen = Some(ts);
|
};
|
||||||
break;
|
if EXIFTOOL_DATE_TAGS.contains(&tag) {
|
||||||
|
data.date_tags.push(RawDateTag {
|
||||||
|
group: group.to_string(),
|
||||||
|
tag: tag.to_string(),
|
||||||
|
value: v.to_string(),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
match tag {
|
||||||
|
"OffsetTime" | "OffsetTimeDigitized" => {
|
||||||
|
data.evidence.offset_time = data
|
||||||
|
.evidence
|
||||||
|
.offset_time
|
||||||
|
.or_else(|| parse_offset_minutes(v));
|
||||||
|
}
|
||||||
|
"OffsetTimeOriginal" => {
|
||||||
|
data.evidence.offset_time_original = parse_offset_minutes(v);
|
||||||
|
}
|
||||||
|
"GPSLatitude" => data.evidence.gps_lat = parse_gps_dms(v),
|
||||||
|
"GPSLatitudeRef" => {
|
||||||
|
data.evidence.gps_lat_south = v.trim().eq_ignore_ascii_case("S")
|
||||||
|
}
|
||||||
|
"GPSLongitude" => data.evidence.gps_lon = parse_gps_dms(v),
|
||||||
|
"GPSLongitudeRef" => {
|
||||||
|
data.evidence.gps_lon_west = v.trim().eq_ignore_ascii_case("W")
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(ts) = chosen {
|
out.push(data);
|
||||||
out.push((entry.source_file, ts));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `-d %s` should hand us a numeric string, but exiftool's JSON encoder
|
|
||||||
/// will emit a number when the tag was defined as numeric in its lib —
|
|
||||||
/// accept both shapes.
|
|
||||||
fn coerce_to_unix_seconds(v: &serde_json::Value) -> Option<i64> {
|
|
||||||
match v {
|
|
||||||
serde_json::Value::String(s) => s.trim().parse::<i64>().ok(),
|
|
||||||
serde_json::Value::Number(n) => n.as_i64(),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
fn tags(pairs: &[(&str, &str, &str)]) -> Vec<RawDateTag> {
|
||||||
fn parse_exiftool_json_picks_first_priority_tag() {
|
pairs
|
||||||
let json = br#"[{
|
.iter()
|
||||||
"SourceFile": "/lib/IMG.jpg",
|
.map(|(g, t, v)| RawDateTag {
|
||||||
"DateTimeOriginal": "1500000000",
|
group: g.to_string(),
|
||||||
"CreateDate": "1400000000"
|
tag: t.to_string(),
|
||||||
}]"#;
|
value: v.to_string(),
|
||||||
let parsed = parse_exiftool_json(json);
|
})
|
||||||
assert_eq!(parsed, vec![("/lib/IMG.jpg".to_string(), 1500000000)]);
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_exiftool_json_falls_through_zeros() {
|
fn parse_date_string_zoned_forms_are_true_instants() {
|
||||||
// exiftool emits "0000:00:00 00:00:00" → unix epoch 0 with -d %s.
|
// exiftool's Composite:SubSecDateTimeOriginal rendering when an
|
||||||
// The resolver should skip those and pick the next tag.
|
// OffsetTime tag is present: EXIF date separator, zoned time.
|
||||||
let json = br#"[{
|
assert_eq!(
|
||||||
"SourceFile": "/lib/clip.mov",
|
parse_exiftool_date_string("2024:09:17 07:33:59.414-06:00"),
|
||||||
"DateTimeOriginal": "0",
|
Some(DateReading::Zoned(1_726_580_039)), // 13:33:59Z
|
||||||
"MediaCreateDate": "1500000000"
|
);
|
||||||
}]"#;
|
assert_eq!(
|
||||||
let parsed = parse_exiftool_json(json);
|
parse_exiftool_date_string("2024-09-17T07:33:59Z"),
|
||||||
assert_eq!(parsed, vec![("/lib/clip.mov".to_string(), 1500000000)]);
|
Some(DateReading::Zoned(1_726_558_439)), // 07:33:59Z
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_exiftool_json_accepts_numeric_values() {
|
fn parse_date_string_naive_and_rejects() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_exiftool_date_string("2024:09:17 07:33:59"),
|
||||||
|
Some(DateReading::Naive(
|
||||||
|
NaiveDateTime::parse_from_str("2024-09-17 07:33:59", "%Y-%m-%d %H:%M:%S").unwrap()
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
// Zero placeholder, empty, junk, pre-1900.
|
||||||
|
assert_eq!(parse_exiftool_date_string("0000:00:00 00:00:00"), None);
|
||||||
|
assert_eq!(parse_exiftool_date_string(""), None);
|
||||||
|
assert_eq!(parse_exiftool_date_string("nonsense"), None);
|
||||||
|
assert_eq!(parse_exiftool_date_string("1899:12:31 23:59:59"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_offset_minutes_forms() {
|
||||||
|
assert_eq!(parse_offset_minutes("+05:30"), Some(330));
|
||||||
|
assert_eq!(parse_offset_minutes("-06:00"), Some(-360));
|
||||||
|
assert_eq!(parse_offset_minutes("Z"), Some(0));
|
||||||
|
assert_eq!(parse_offset_minutes("junk"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_gps_dms_decimal() {
|
||||||
|
// 40 deg 42' 45.60" = 40.712666...; the Ref fields carry the sign.
|
||||||
|
let v = parse_gps_dms("40 deg 42' 45.60\"").unwrap();
|
||||||
|
assert!((v - 40.7126666667).abs() < 1e-9);
|
||||||
|
assert_eq!(parse_gps_dms("nope"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_payload_groups_dates_and_evidence() {
|
||||||
let json = br#"[{
|
let json = br#"[{
|
||||||
"SourceFile": "/lib/a.jpg",
|
"SourceFile": "/lib/a.jpg",
|
||||||
"CreateDate": 1234567890
|
"ExifIFD:DateTimeOriginal": "2024:07:04 12:00:00",
|
||||||
|
"ExifIFD:OffsetTimeOriginal": "-04:00",
|
||||||
|
"GPS:GPSLatitude": "40 deg 42' 45.60\"",
|
||||||
|
"GPS:GPSLatitudeRef": "N",
|
||||||
|
"GPS:GPSLongitude": "74 deg 0' 21.60\"",
|
||||||
|
"GPS:GPSLongitudeRef": "W"
|
||||||
}]"#;
|
}]"#;
|
||||||
let parsed = parse_exiftool_json(json);
|
let files = parse_exiftool_payload(json);
|
||||||
assert_eq!(parsed, vec![("/lib/a.jpg".to_string(), 1234567890)]);
|
assert_eq!(files.len(), 1);
|
||||||
|
assert_eq!(files[0].source_file, "/lib/a.jpg");
|
||||||
|
assert_eq!(files[0].date_tags.len(), 1);
|
||||||
|
assert_eq!(files[0].date_tags[0].group, "ExifIFD");
|
||||||
|
assert_eq!(files[0].date_tags[0].tag, "DateTimeOriginal");
|
||||||
|
assert_eq!(files[0].evidence.offset_time_original, Some(-240));
|
||||||
|
let (lat, lon) = files[0].evidence.gps().unwrap();
|
||||||
|
assert!((lat - 40.7126666667).abs() < 1e-9);
|
||||||
|
assert!((lon - (-74.006)).abs() < 1e-9);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_exiftool_json_emits_nothing_when_no_tag_present() {
|
fn container_date_is_true_instant_not_wall_clock() {
|
||||||
let json = br#"[{"SourceFile": "/lib/no_dates.bin"}]"#;
|
// QuickTime creation_time renders as a UTC-naive string: the
|
||||||
let parsed = parse_exiftool_json(json);
|
// 15:58:23 reading IS the instant. The pre-rework `-d %s` path
|
||||||
assert!(parsed.is_empty());
|
// reinterpreted it in the server zone — the "videos off by 4-5
|
||||||
|
// hours" bug — and the legacy column keeps exactly that (frozen
|
||||||
|
// for rollback).
|
||||||
|
let wall =
|
||||||
|
NaiveDateTime::parse_from_str("2017-06-11 15:58:23", "%Y-%m-%d %H:%M:%S").unwrap();
|
||||||
|
let resolved = resolve_exiftool_payload(
|
||||||
|
&tags(&[("QuickTime", "CreateDate", "2017:06:11 15:58:23")]),
|
||||||
|
&ExiftoolEvidence::default(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resolved.date_utc, Some(wall.and_utc().timestamp()));
|
||||||
|
assert_eq!(resolved.tz_source, Some(camera_tz::TzSource::UtcDirect));
|
||||||
|
assert_eq!(resolved.camera_offset_minutes, None);
|
||||||
|
assert_eq!(resolved.timestamp, legacy_server_local(wall));
|
||||||
|
assert_eq!(resolved.source, DateSource::Exiftool);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_exiftool_json_handles_multiple_entries() {
|
fn photo_and_video_at_same_instant_agree() {
|
||||||
let json = br#"[
|
// Photo: EXIF wall 2024-07-04 12:00:00 with an OffsetTime tag of
|
||||||
{"SourceFile": "/lib/a.jpg", "DateTimeOriginal": "100"},
|
// -04:00. Video: QuickTime container date 2024:07:04 16:00:00 —
|
||||||
{"SourceFile": "/lib/b.jpg", "CreateDate": "200"}
|
// the same moment, UTC-rendered. Both must land on 16:00:00Z.
|
||||||
]"#;
|
let photo = resolve_exiftool_payload(
|
||||||
let parsed = parse_exiftool_json(json);
|
&tags(&[("ExifIFD", "DateTimeOriginal", "2024:07:04 12:00:00")]),
|
||||||
assert_eq!(
|
&ExiftoolEvidence {
|
||||||
parsed,
|
offset_time_original: Some(-240),
|
||||||
vec![
|
..Default::default()
|
||||||
("/lib/a.jpg".to_string(), 100),
|
},
|
||||||
("/lib/b.jpg".to_string(), 200)
|
)
|
||||||
]
|
.unwrap();
|
||||||
|
let video = resolve_exiftool_payload(
|
||||||
|
&tags(&[("QuickTime", "CreateDate", "2024:07:04 16:00:00")]),
|
||||||
|
&ExiftoolEvidence::default(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(photo.date_utc, video.date_utc);
|
||||||
|
assert_eq!(photo.date_utc, Some(1_720_108_800));
|
||||||
|
assert_eq!(photo.tz_source, Some(camera_tz::TzSource::OffsetTag));
|
||||||
|
assert_eq!(photo.camera_offset_minutes, Some(-240));
|
||||||
|
assert_eq!(video.tz_source, Some(camera_tz::TzSource::UtcDirect));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zoned_string_short_circuits_to_utc_direct() {
|
||||||
|
let resolved = resolve_exiftool_payload(
|
||||||
|
&tags(&[("XMP-xmp", "CreateDate", "2024-09-17T07:33:59-06:00")]),
|
||||||
|
&ExiftoolEvidence::default(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resolved.date_utc, Some(1_726_580_039));
|
||||||
|
assert_eq!(resolved.tz_source, Some(camera_tz::TzSource::UtcDirect));
|
||||||
|
assert_eq!(resolved.camera_offset_minutes, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exif_naive_with_gps_uses_capture_zone() {
|
||||||
|
// No offset tag; GPS at Manhattan → America/New_York, EDT in July.
|
||||||
|
let resolved = resolve_exiftool_payload(
|
||||||
|
&tags(&[("ExifIFD", "CreateDate", "2024:07:04 12:00:00")]),
|
||||||
|
&ExiftoolEvidence {
|
||||||
|
gps_lat: Some(40.7128),
|
||||||
|
gps_lon: Some(74.006),
|
||||||
|
gps_lon_west: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resolved.tz_source, Some(camera_tz::TzSource::Gps));
|
||||||
|
assert_eq!(resolved.camera_offset_minutes, Some(-240));
|
||||||
|
assert_eq!(resolved.date_utc, Some(1_720_108_800));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zero_date_falls_through_to_next_tag() {
|
||||||
|
// exiftool emits "0000:00:00 00:00:00" for missing-but-allocated
|
||||||
|
// date slots (iPhone QuickTime files); the resolver skips it and
|
||||||
|
// picks the next candidate.
|
||||||
|
let wall =
|
||||||
|
NaiveDateTime::parse_from_str("2020-01-02 03:04:05", "%Y-%m-%d %H:%M:%S").unwrap();
|
||||||
|
let resolved = resolve_exiftool_payload(
|
||||||
|
&tags(&[
|
||||||
|
("QuickTime", "CreateDate", "0000:00:00 00:00:00"),
|
||||||
|
("QuickTime", "MediaCreateDate", "2020:01:02 03:04:05"),
|
||||||
|
]),
|
||||||
|
&ExiftoolEvidence::default(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resolved.date_utc, Some(wall.and_utc().timestamp()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn photo_group_wins_over_container_for_same_tag() {
|
||||||
|
// Hybrid file: ExifIFD and QuickTime both carry CreateDate. The
|
||||||
|
// photo-EXIF reading (a wall clock) is preferred and runs the
|
||||||
|
// cascade over the file's own offset tag.
|
||||||
|
let resolved = resolve_exiftool_payload(
|
||||||
|
&tags(&[
|
||||||
|
("QuickTime", "CreateDate", "2024:07:04 16:00:00"),
|
||||||
|
("ExifIFD", "CreateDate", "2024:07:04 12:00:00"),
|
||||||
|
]),
|
||||||
|
&ExiftoolEvidence {
|
||||||
|
offset_time: Some(-240),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resolved.tz_source, Some(camera_tz::TzSource::OffsetTag));
|
||||||
|
assert_eq!(resolved.camera_offset_minutes, Some(-240));
|
||||||
|
assert_eq!(resolved.date_utc, Some(1_720_108_800));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_usable_dates_returns_none() {
|
||||||
|
assert!(
|
||||||
|
resolve_exiftool_payload(
|
||||||
|
&tags(&[("QuickTime", "CreateDate", "0000:00:00 00:00:00")]),
|
||||||
|
&ExiftoolEvidence::default()
|
||||||
|
)
|
||||||
|
.is_none()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1519,6 +1519,9 @@ mod tests {
|
|||||||
date_utc: None,
|
date_utc: None,
|
||||||
camera_offset_minutes: None,
|
camera_offset_minutes: None,
|
||||||
date_taken_tz_source: None,
|
date_taken_tz_source: None,
|
||||||
|
original_date_utc: None,
|
||||||
|
original_camera_offset_minutes: None,
|
||||||
|
original_date_taken_tz_source: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1563,6 +1566,9 @@ mod tests {
|
|||||||
date_utc: data.date_utc,
|
date_utc: data.date_utc,
|
||||||
camera_offset_minutes: data.camera_offset_minutes,
|
camera_offset_minutes: data.camera_offset_minutes,
|
||||||
date_taken_tz_source: data.date_taken_tz_source.clone(),
|
date_taken_tz_source: data.date_taken_tz_source.clone(),
|
||||||
|
original_date_utc: data.date_utc,
|
||||||
|
original_camera_offset_minutes: data.camera_offset_minutes,
|
||||||
|
original_date_taken_tz_source: data.date_taken_tz_source.clone(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1614,6 +1620,9 @@ mod tests {
|
|||||||
date_utc: data.date_utc,
|
date_utc: data.date_utc,
|
||||||
camera_offset_minutes: data.camera_offset_minutes,
|
camera_offset_minutes: data.camera_offset_minutes,
|
||||||
date_taken_tz_source: data.date_taken_tz_source.clone(),
|
date_taken_tz_source: data.date_taken_tz_source.clone(),
|
||||||
|
original_date_utc: data.date_utc,
|
||||||
|
original_camera_offset_minutes: data.camera_offset_minutes,
|
||||||
|
original_date_taken_tz_source: data.date_taken_tz_source.clone(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1751,6 +1760,7 @@ mod tests {
|
|||||||
library_id: i32,
|
library_id: i32,
|
||||||
rel_path: &str,
|
rel_path: &str,
|
||||||
date_taken: i64,
|
date_taken: i64,
|
||||||
|
_display_tz: &str,
|
||||||
) -> Result<crate::database::models::ImageExif, DbError> {
|
) -> Result<crate::database::models::ImageExif, DbError> {
|
||||||
// Mock — files.rs tests don't exercise the date-override endpoints.
|
// Mock — files.rs tests don't exercise the date-override endpoints.
|
||||||
// Returning a synthetic row keeps the trait satisfied without
|
// Returning a synthetic row keeps the trait satisfied without
|
||||||
|
|||||||
+29
-5
@@ -826,10 +826,14 @@ pub async fn get_full_exif(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Body for `POST /image/exif/date` — operator-driven date_taken override.
|
/// Body for `POST /image/exif/date` — operator-driven date override.
|
||||||
/// `date_taken` is unix seconds (matches `image_exif.date_taken`'s convention
|
/// `date_taken` is unix seconds: the wall the user picked rendered in their
|
||||||
/// — naive local reinterpreted as UTC, not real UTC; the Apollo client passes
|
/// display zone, encoded naive-as-UTC (the Apollo client passes through the
|
||||||
/// through the same value the photo carousel rendered before edit).
|
/// 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)]
|
#[derive(serde::Deserialize)]
|
||||||
struct SetDateRequest {
|
struct SetDateRequest {
|
||||||
path: String,
|
path: String,
|
||||||
@@ -910,9 +914,29 @@ pub async fn set_image_date(
|
|||||||
// doesn't end up with a backslash variant that misses the row.
|
// doesn't end up with a backslash variant that misses the row.
|
||||||
let normalized_path = body.path.replace('\\', "/");
|
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 updated = {
|
||||||
let mut dao = exif_dao.lock().expect("Unable to lock ExifDao");
|
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 {
|
match updated {
|
||||||
|
|||||||
Reference in new issue
Block a user