memories: parse filename dates as UTC, not server local

`extract_date_from_filename` was calling `Local::from_local_datetime`
on the parsed YYYY-MM-DD-HH-MM-SS components, then `.timestamp()` was
shifting the result by the SERVER's TZ offset to produce real UTC
seconds. That made filename-sourced timestamps disagree with EXIF-
sourced timestamps by hours: kamadak-exif's `DateTimeOriginal` is a
naive string parsed AS-IF-UTC (the project's load-bearing
"naive local reinterpreted as UTC" convention), and Apollo's photo
matcher re-anchors that naive value through the BROWSER's TZ when
matching to the track. Anything stamped in server-local instead got
double-shifted on its way through the matcher and through any
`formatNaive*` display path on the client.

Visible symptom in the Apollo DETAILS modal: a photo's CURRENT date
read correctly (1:25 AM via exif) while FROM FILENAME read 4 hours
ahead (5:25 AM in EDT) for the same `IMG_20160710_012515.jpg`.

Switch to `Utc::from_utc_datetime` so `.timestamp()` returns the
wall-clock-as-UTC unix seconds — same convention as the EXIF path.
The /memories endpoint, the canonical-date waterfall (which feeds
`image_exif.date_taken` for filename-only files), and Apollo's
DETAILS modal `filename_date` field all now line up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cameron Cordes
2026-05-06 20:43:18 -04:00
parent 16d6586b7d
commit 65af7d999e

View File

@@ -1,7 +1,6 @@
use actix_web::web::Data;
use actix_web::{HttpRequest, HttpResponse, Responder, get, web};
use chrono::LocalResult::{Ambiguous, Single};
use chrono::{DateTime, FixedOffset, Local, LocalResult, NaiveDate, TimeZone};
use chrono::{DateTime, FixedOffset, Local, NaiveDate, TimeZone, Utc};
use log::{debug, trace, warn};
use opentelemetry::KeyValue;
use opentelemetry::trace::{Span, Status, TraceContextExt, Tracer};
@@ -134,6 +133,15 @@ pub struct MemoriesResponse {
}
pub fn extract_date_from_filename(filename: &str) -> Option<DateTime<FixedOffset>> {
// Filenames carry only digits — no timezone. We deliberately interpret
// them as UTC so `.timestamp()` returns the wall-clock-as-UTC unix
// seconds, matching the "naive local reinterpreted as UTC" convention
// image_exif.date_taken uses for kamadak-exif DateTimeOriginal (which
// is also naive). Anything else (Local::from_local_datetime, the
// previous behavior) shifted filename-sourced dates by the SERVER's
// TZ offset relative to UTC, making them disagree with EXIF-sourced
// dates by hours and double-shifting through Apollo's photo matcher
// (which re-anchors naive-as-UTC via the browser TZ).
let build_date_from_ymd_capture =
|captures: &regex::Captures| -> Option<DateTime<FixedOffset>> {
let year = captures.get(1)?.as_str().parse::<i32>().ok()?;
@@ -143,16 +151,8 @@ pub fn extract_date_from_filename(filename: &str) -> Option<DateTime<FixedOffset
let min = captures.get(5)?.as_str().parse::<u32>().ok()?;
let sec = captures.get(6)?.as_str().parse::<u32>().ok()?;
match Local.from_local_datetime(
&NaiveDate::from_ymd_opt(year, month, day)?.and_hms_opt(hour, min, sec)?,
) {
Single(dt) => Some(dt.fixed_offset()),
Ambiguous(early_dt, _) => Some(early_dt.fixed_offset()),
LocalResult::None => {
warn!("Weird local date: {:?}", filename);
None
}
}
let naive = NaiveDate::from_ymd_opt(year, month, day)?.and_hms_opt(hour, min, sec)?;
Some(Utc.from_utc_datetime(&naive).fixed_offset())
};
// 1. Screenshot format: Screenshot_2014-06-01-20-44-50.png