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
+599
-137
@@ -10,7 +10,12 @@
|
||||
//! can't: QuickTime/MP4 (`MediaCreateDate`, `TrackCreateDate`,
|
||||
//! `CreateDate`), Apple's `ContentCreateDate`, MakerNote sub-IFDs.
|
||||
//! 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
|
||||
//! common screenshot / chat-export / timestamp-named patterns.
|
||||
//! 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::sync::OnceLock;
|
||||
|
||||
use chrono::{DateTime, NaiveDateTime, Utc};
|
||||
use chrono::{DateTime, Datelike, Local, LocalResult, NaiveDateTime, TimeZone, Utc};
|
||||
use log::{debug, trace, warn};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::camera_tz;
|
||||
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 {
|
||||
return Some(exif_prior_resolved(prior));
|
||||
}
|
||||
if let Some(ts) = exiftool_date_single(path) {
|
||||
return Some(ResolvedDate {
|
||||
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(resolved) = exiftool_date_single(path) {
|
||||
return Some(resolved);
|
||||
}
|
||||
if let Some(dt) = path
|
||||
.file_name()
|
||||
@@ -214,20 +210,10 @@ pub fn resolve_dates_batch(
|
||||
}
|
||||
|
||||
if !needs_exiftool.is_empty() {
|
||||
let exiftool_results = exiftool_dates_batch(&needs_exiftool);
|
||||
for path in &needs_exiftool {
|
||||
if let Some(&ts) = exiftool_results.get(*path) {
|
||||
out.insert(
|
||||
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),
|
||||
},
|
||||
);
|
||||
}
|
||||
// One subprocess for the whole batch; each entry is already fully
|
||||
// resolved (legacy + new-contract fields).
|
||||
for (path, resolved) in exiftool_dates_batch(&needs_exiftool) {
|
||||
out.insert(path, resolved);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,7 +258,7 @@ pub fn resolve_dates_batch(
|
||||
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`
|
||||
/// are most authoritative. `CreateDate` is a common alias and a sane fallback.
|
||||
@@ -294,6 +280,19 @@ const EXIFTOOL_DATE_TAGS: &[&str] = &[
|
||||
"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
|
||||
/// the per-tick backfill doesn't fork a doomed subprocess every iteration on
|
||||
/// 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,
|
||||
/// which deal with one file at a time. The batch path uses
|
||||
/// `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
|
||||
/// kamadak-exif couldn't read, so the JPEG fast-path is already covered
|
||||
/// — 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() {
|
||||
return None;
|
||||
}
|
||||
let mut cmd = Command::new("exiftool");
|
||||
cmd.arg("-j").arg("-q").arg("-d").arg("%s");
|
||||
for tag in EXIFTOOL_DATE_TAGS {
|
||||
cmd.arg(format!("-{}", tag));
|
||||
}
|
||||
exiftool_common_args(&mut cmd);
|
||||
cmd.arg(path);
|
||||
let output = cmd.output().ok()?;
|
||||
if !output.status.success() {
|
||||
trace!("exiftool exited non-zero for {:?}", path);
|
||||
return None;
|
||||
}
|
||||
parse_exiftool_json(&output.stdout)
|
||||
parse_exiftool_payload(&output.stdout)
|
||||
.into_iter()
|
||||
.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
|
||||
/// via `-@ -`, so the argv stays short regardless of batch size — safe for
|
||||
/// libraries with very long path components.
|
||||
fn exiftool_dates_batch(paths: &[&Path]) -> HashMap<PathBuf, i64> {
|
||||
/// libraries with very long path components. Resolution is the same pure
|
||||
/// 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();
|
||||
if paths.is_empty() || !exiftool_available() {
|
||||
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
|
||||
// moov-at-end files miss CreateDate / MediaCreateDate when the trailer
|
||||
// is skipped).
|
||||
cmd.arg("-j").arg("-q").arg("-d").arg("%s");
|
||||
for tag in EXIFTOOL_DATE_TAGS {
|
||||
cmd.arg(format!("-{}", tag));
|
||||
}
|
||||
exiftool_common_args(&mut cmd);
|
||||
cmd.arg("-@").arg("-");
|
||||
cmd.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
@@ -403,36 +412,328 @@ fn exiftool_dates_batch(paths: &[&Path]) -> HashMap<PathBuf, i64> {
|
||||
output.status.code()
|
||||
);
|
||||
}
|
||||
for (source, ts) in parse_exiftool_json(&output.stdout) {
|
||||
out.insert(PathBuf::from(source), ts);
|
||||
for file in parse_exiftool_payload(&output.stdout) {
|
||||
if let Some(resolved) = resolve_exiftool_payload(&file.date_tags, &file.evidence) {
|
||||
out.insert(PathBuf::from(file.source_file), resolved);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// One row per input file. exiftool emits any tag we asked for that was
|
||||
/// present, plus the `SourceFile` it was reading. Tags are JSON values
|
||||
/// because `-d %s` returns the timestamp as a *string* of digits, not a
|
||||
/// number, when the date parses; absent tags are simply missing keys.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ExiftoolEntry {
|
||||
#[serde(rename = "SourceFile")]
|
||||
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>,
|
||||
/// One raw date string from the exiftool payload, with the exiftool group
|
||||
/// it came from (`-G1` prefixes every tag key as `"Group:Tag"`).
|
||||
#[derive(Debug, Clone)]
|
||||
struct RawDateTag {
|
||||
group: String,
|
||||
tag: String,
|
||||
value: String,
|
||||
}
|
||||
|
||||
fn parse_exiftool_json(stdout: &[u8]) -> Vec<(String, i64)> {
|
||||
let entries: Vec<ExiftoolEntry> = match serde_json::from_slice(stdout) {
|
||||
/// Per-file offset/GPS evidence from the same exiftool payload.
|
||||
#[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,
|
||||
Err(e) => {
|
||||
// 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());
|
||||
for entry in entries {
|
||||
// Walk the priority list. exiftool sometimes returns the literal
|
||||
// string "0000:00:00 00:00:00" for missing-but-allocated date
|
||||
// slots; with `-d %s` that becomes the unix epoch (0). Reject
|
||||
// anything <= 0 so we fall through to the next tag.
|
||||
let tags = [
|
||||
entry.date_time_original.as_ref(),
|
||||
entry.sub_sec_date_time_original.as_ref(),
|
||||
entry.create_date.as_ref(),
|
||||
entry.media_create_date.as_ref(),
|
||||
entry.track_create_date.as_ref(),
|
||||
entry.content_create_date.as_ref(),
|
||||
];
|
||||
let mut chosen: Option<i64> = None;
|
||||
for tag in tags.iter().flatten() {
|
||||
if let Some(ts) = coerce_to_unix_seconds(tag)
|
||||
&& ts > 0
|
||||
{
|
||||
chosen = Some(ts);
|
||||
break;
|
||||
let Some(obj) = entry.as_object() else {
|
||||
continue;
|
||||
};
|
||||
let Some(source_file) = obj.get("SourceFile").and_then(|v| v.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
let mut data = ExiftoolFileData {
|
||||
source_file: source_file.to_string(),
|
||||
date_tags: Vec::new(),
|
||||
evidence: ExiftoolEvidence::default(),
|
||||
};
|
||||
for (key, value) in obj {
|
||||
let Some((group, tag)) = key.split_once(':') else {
|
||||
continue;
|
||||
};
|
||||
let Some(v) = value.as_str() else {
|
||||
continue;
|
||||
};
|
||||
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((entry.source_file, ts));
|
||||
}
|
||||
out.push(data);
|
||||
}
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_exiftool_json_picks_first_priority_tag() {
|
||||
let json = br#"[{
|
||||
"SourceFile": "/lib/IMG.jpg",
|
||||
"DateTimeOriginal": "1500000000",
|
||||
"CreateDate": "1400000000"
|
||||
}]"#;
|
||||
let parsed = parse_exiftool_json(json);
|
||||
assert_eq!(parsed, vec![("/lib/IMG.jpg".to_string(), 1500000000)]);
|
||||
fn tags(pairs: &[(&str, &str, &str)]) -> Vec<RawDateTag> {
|
||||
pairs
|
||||
.iter()
|
||||
.map(|(g, t, v)| RawDateTag {
|
||||
group: g.to_string(),
|
||||
tag: t.to_string(),
|
||||
value: v.to_string(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_exiftool_json_falls_through_zeros() {
|
||||
// exiftool emits "0000:00:00 00:00:00" → unix epoch 0 with -d %s.
|
||||
// The resolver should skip those and pick the next tag.
|
||||
let json = br#"[{
|
||||
"SourceFile": "/lib/clip.mov",
|
||||
"DateTimeOriginal": "0",
|
||||
"MediaCreateDate": "1500000000"
|
||||
}]"#;
|
||||
let parsed = parse_exiftool_json(json);
|
||||
assert_eq!(parsed, vec![("/lib/clip.mov".to_string(), 1500000000)]);
|
||||
fn parse_date_string_zoned_forms_are_true_instants() {
|
||||
// exiftool's Composite:SubSecDateTimeOriginal rendering when an
|
||||
// OffsetTime tag is present: EXIF date separator, zoned time.
|
||||
assert_eq!(
|
||||
parse_exiftool_date_string("2024:09:17 07:33:59.414-06:00"),
|
||||
Some(DateReading::Zoned(1_726_580_039)), // 13:33:59Z
|
||||
);
|
||||
assert_eq!(
|
||||
parse_exiftool_date_string("2024-09-17T07:33:59Z"),
|
||||
Some(DateReading::Zoned(1_726_558_439)), // 07:33:59Z
|
||||
);
|
||||
}
|
||||
|
||||
#[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#"[{
|
||||
"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);
|
||||
assert_eq!(parsed, vec![("/lib/a.jpg".to_string(), 1234567890)]);
|
||||
let files = parse_exiftool_payload(json);
|
||||
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]
|
||||
fn parse_exiftool_json_emits_nothing_when_no_tag_present() {
|
||||
let json = br#"[{"SourceFile": "/lib/no_dates.bin"}]"#;
|
||||
let parsed = parse_exiftool_json(json);
|
||||
assert!(parsed.is_empty());
|
||||
fn container_date_is_true_instant_not_wall_clock() {
|
||||
// QuickTime creation_time renders as a UTC-naive string: the
|
||||
// 15:58:23 reading IS the instant. The pre-rework `-d %s` path
|
||||
// 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]
|
||||
fn parse_exiftool_json_handles_multiple_entries() {
|
||||
let json = br#"[
|
||||
{"SourceFile": "/lib/a.jpg", "DateTimeOriginal": "100"},
|
||||
{"SourceFile": "/lib/b.jpg", "CreateDate": "200"}
|
||||
]"#;
|
||||
let parsed = parse_exiftool_json(json);
|
||||
assert_eq!(
|
||||
parsed,
|
||||
vec![
|
||||
("/lib/a.jpg".to_string(), 100),
|
||||
("/lib/b.jpg".to_string(), 200)
|
||||
]
|
||||
fn photo_and_video_at_same_instant_agree() {
|
||||
// Photo: EXIF wall 2024-07-04 12:00:00 with an OffsetTime tag of
|
||||
// -04:00. Video: QuickTime container date 2024:07:04 16:00:00 —
|
||||
// the same moment, UTC-rendered. Both must land on 16:00:00Z.
|
||||
let photo = resolve_exiftool_payload(
|
||||
&tags(&[("ExifIFD", "DateTimeOriginal", "2024:07:04 12:00:00")]),
|
||||
&ExiftoolEvidence {
|
||||
offset_time_original: Some(-240),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.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()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user