backfill_utc_dates refused any fs_time re-resolution of a tag-dated row, assuming exiftool had failed on the file. Files whose date tags are all 0000:00:00 (e.g. uv-prod-long.mp4) were read fine and simply have no date, so they sat unconverted and re-ran through exiftool every tick. resolve_dates_batch_report now also returns the paths exiftool read without error (-Error surfaces ExifTool:Error in the JSON). The drain lets fs_time stand for an exiftool-legacy row only when exiftool read the file; read errors / missing entries and kamadak exif-legacy rows keep refusing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1444 lines
57 KiB
Rust
1444 lines
57 KiB
Rust
//! Canonical `date_taken` resolution for ingest and the per-tick backfill
|
||
//! drain.
|
||
//!
|
||
//! The waterfall (in order; first hit wins):
|
||
//!
|
||
//! 1. **kamadak-exif** — fast in-process EXIF read. Already done by
|
||
//! `exif::extract_exif_from_path` for image-bearing formats; callers
|
||
//! pass that result in via `prior_exif_date` so we don't re-parse.
|
||
//! 2. **exiftool** — shell-out fallback that reaches places kamadak-exif
|
||
//! 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. 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).
|
||
//! 0. **filename date class** (ticket #116) — `memories::extract_date_from_filename`
|
||
//! covers common screenshot / dash / compact / unix-timestamp patterns.
|
||
//! A file whose name matches any pattern is **in the class**: the
|
||
//! filename is the original date of record and wins *before* EXIF / container
|
||
//! dates are even consulted (wall patterns run the offset cascade over the
|
||
//! file's own evidence; timestamp patterns are true instants).
|
||
//! 1. **kamadak-exif** — fast in-process EXIF read. Already done by
|
||
//! `exif::extract_exif_from_path` for image-bearing formats; callers
|
||
//! pass that result in via `prior_exif_date` so we don't re-parse.
|
||
//! 2. **exiftool** — shell-out fallback that reaches places kamadak-exif
|
||
//! 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. 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. **earliest filesystem time** — `utils::earliest_fs_time` picks the
|
||
//! earlier of created / modified, which on copied-from-backup files is
|
||
//! a better proxy for content age than either alone.
|
||
//!
|
||
//! `DateSource` records which step won so the per-tick drain can re-resolve
|
||
//! weak sources (`fs_time`) once exiftool becomes available, and so the
|
||
//! UI/debug surface can answer "why does this photo show up under this
|
||
//! date." For out-of-class files EXIF is authoritative when it exists,
|
||
//! on the theory that an EXIF `DateTimeOriginal` is more reliable than a
|
||
//! filename pattern that may reflect import time rather than capture time.
|
||
//! In-class files invert that rule deliberately (ADR-0001): a renamed
|
||
//! screenshot or a chat-export timestamp is the only honest date left.
|
||
|
||
use std::collections::{HashMap, HashSet};
|
||
use std::io::Write;
|
||
use std::path::{Path, PathBuf};
|
||
use std::process::{Command, Stdio};
|
||
use std::sync::OnceLock;
|
||
|
||
use chrono::{DateTime, Datelike, Local, LocalResult, NaiveDateTime, TimeZone, Utc};
|
||
use log::{debug, trace, warn};
|
||
|
||
use crate::camera_tz;
|
||
use crate::utils::earliest_fs_time;
|
||
|
||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||
pub enum DateSource {
|
||
/// kamadak-exif read DateTime/DateTimeOriginal directly.
|
||
Exif,
|
||
/// exiftool fallback caught a video / MakerNote / QuickTime tag.
|
||
Exiftool,
|
||
/// `extract_date_from_filename` matched a known pattern — the file is
|
||
/// in the filename date class and the name outranked EXIF (#116).
|
||
Filename,
|
||
/// Fell through to `earliest_fs_time(metadata)`.
|
||
FsTime,
|
||
}
|
||
|
||
impl DateSource {
|
||
pub fn as_str(self) -> &'static str {
|
||
match self {
|
||
DateSource::Exif => "exif",
|
||
DateSource::Exiftool => "exiftool",
|
||
DateSource::Filename => "filename",
|
||
DateSource::FsTime => "fs_time",
|
||
}
|
||
}
|
||
}
|
||
|
||
/// What the ingest path already knows from kamadak-exif for one file: the
|
||
/// camera wall-clock reading plus the offset evidence for the cascade.
|
||
#[derive(Copy, Clone, Debug)]
|
||
pub struct ExifPrior {
|
||
/// Camera wall clock, decoded the same way the legacy column stores it
|
||
/// (naive-as-UTC from `parse_exif_datetime`). The legacy `date_taken`
|
||
/// value stays byte-identical.
|
||
pub wall: NaiveDateTime,
|
||
/// Offset evidence (EXIF `OffsetTime*` tag, GPS coordinates).
|
||
pub evidence: camera_tz::OffsetEvidence,
|
||
}
|
||
|
||
#[derive(Copy, Clone, Debug)]
|
||
pub struct ResolvedDate {
|
||
/// Legacy `date_taken` value. Kept byte-identical to the pre-migration
|
||
/// behavior of each branch so rollback stays trivial (ADR-0001).
|
||
pub timestamp: i64,
|
||
pub source: DateSource,
|
||
/// True UTC instant under the new contract (ADR-0001). Every branch
|
||
/// classifies (wall clocks via the cascade, instants directly);
|
||
/// `Option` is kept for schema compatibility with rows written before
|
||
/// the date-class work landed (#116).
|
||
pub date_utc: Option<i64>,
|
||
/// Camera offset (minutes) that produced `date_utc`; `None` for direct
|
||
/// true instants (no offset is involved).
|
||
pub camera_offset_minutes: Option<i32>,
|
||
/// Why `date_utc` has the offset it does; `None` on rows written
|
||
/// before every branch classified (#116).
|
||
pub tz_source: Option<camera_tz::TzSource>,
|
||
}
|
||
|
||
/// Build an [`ExifPrior`] from a kamadak-exif [`crate::exif::ExifData`]:
|
||
/// the wall clock (naive-as-UTC, matching the legacy column) plus the
|
||
/// offset evidence (stamped offset tag + GPS). `None` when the file had no
|
||
/// EXIF date.
|
||
pub fn exif_prior_from(data: &crate::exif::ExifData) -> Option<ExifPrior> {
|
||
let wall = DateTime::from_timestamp(data.date_taken?, 0)?.naive_utc();
|
||
Some(ExifPrior {
|
||
wall,
|
||
evidence: camera_tz::OffsetEvidence {
|
||
offset_tag_minutes: data.offset_time_minutes,
|
||
gps: match (data.gps_latitude, data.gps_longitude) {
|
||
(Some(lat), Some(lon)) => Some((lat, lon)),
|
||
_ => None,
|
||
},
|
||
},
|
||
})
|
||
}
|
||
|
||
/// EXIF-branch resolution: run the offset cascade over the wall clock and
|
||
/// offset evidence kamadak-exif already extracted. Legacy timestamp is the
|
||
/// wall clock as-UTC — unchanged from the pre-migration behavior.
|
||
fn exif_prior_resolved(prior: &ExifPrior) -> ResolvedDate {
|
||
let resolution = camera_tz::resolve_camera_offset(
|
||
prior.wall,
|
||
&prior.evidence,
|
||
server_zone_cached(),
|
||
gps_lookup(),
|
||
);
|
||
ResolvedDate {
|
||
timestamp: prior.wall.and_utc().timestamp(),
|
||
source: DateSource::Exif,
|
||
date_utc: Some(camera_tz::wall_to_instant(prior.wall, resolution.minutes)),
|
||
camera_offset_minutes: Some(resolution.minutes),
|
||
tz_source: Some(resolution.source),
|
||
}
|
||
}
|
||
|
||
/// The filename-class step (ticket #116), shared by both waterfalls: the
|
||
/// in-class name is the original date of record and outranks EXIF /
|
||
/// container dates. `evidence` is the kamadak prior's offset evidence when
|
||
/// the file has one; `None` means empty evidence — the cascade then assumes
|
||
/// the server zone, so a class file always resolves.
|
||
fn filename_class_resolved(
|
||
path: &Path,
|
||
evidence: Option<&camera_tz::OffsetEvidence>,
|
||
) -> Option<ResolvedDate> {
|
||
let filename_date = path
|
||
.file_name()
|
||
.and_then(|f| f.to_str())
|
||
.and_then(crate::memories::extract_date_from_filename)?;
|
||
let ev = evidence.copied().unwrap_or_default();
|
||
Some(filename_resolved(&filename_date, &ev))
|
||
}
|
||
|
||
/// Classify one filename date (ticket #116). Wall patterns run the offset
|
||
/// cascade over whatever evidence the file carries; timestamp patterns are
|
||
/// true instants outright. The legacy value is the pattern's pre-migration
|
||
/// interpretation: wall-as-UTC / epoch.
|
||
fn filename_resolved(
|
||
filename_date: &crate::memories::FilenameDate,
|
||
evidence: &camera_tz::OffsetEvidence,
|
||
) -> ResolvedDate {
|
||
match filename_date {
|
||
crate::memories::FilenameDate::Utc(instant) => ResolvedDate {
|
||
timestamp: instant.timestamp(),
|
||
source: DateSource::Filename,
|
||
date_utc: Some(instant.timestamp()),
|
||
camera_offset_minutes: None,
|
||
tz_source: Some(camera_tz::TzSource::UtcDirect),
|
||
},
|
||
crate::memories::FilenameDate::Wall(wall) => {
|
||
let resolution = camera_tz::resolve_camera_offset(
|
||
*wall,
|
||
evidence,
|
||
server_zone_cached(),
|
||
gps_lookup(),
|
||
);
|
||
ResolvedDate {
|
||
timestamp: wall.and_utc().timestamp(),
|
||
source: DateSource::Filename,
|
||
date_utc: Some(camera_tz::wall_to_instant(*wall, resolution.minutes)),
|
||
camera_offset_minutes: Some(resolution.minutes),
|
||
tz_source: Some(resolution.source),
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Host zone, resolved once per process.
|
||
fn server_zone_cached() -> Option<chrono_tz::Tz> {
|
||
static ZONE: OnceLock<Option<chrono_tz::Tz>> = OnceLock::new();
|
||
*ZONE.get_or_init(camera_tz::server_zone)
|
||
}
|
||
|
||
/// Shared GPS → zone adapter (bundled TZF geometry, loaded on first use).
|
||
fn gps_lookup() -> &'static camera_tz::TzfGpsZoneLookup {
|
||
static LOOKUP: OnceLock<camera_tz::TzfGpsZoneLookup> = OnceLock::new();
|
||
LOOKUP.get_or_init(camera_tz::TzfGpsZoneLookup::new)
|
||
}
|
||
|
||
/// Resolve the canonical date for a single file, given an already-extracted
|
||
/// kamadak-exif reading if available. Returns `None` only if every step in
|
||
/// the waterfall fails — for files that exist on disk this should be
|
||
/// vanishingly rare (the fs-time fallback alone almost always succeeds).
|
||
pub fn resolve_date_taken(path: &Path, prior_exif: Option<&ExifPrior>) -> Option<ResolvedDate> {
|
||
// Filename date class first (ticket #116): an in-class name is the
|
||
// original date of record and outranks EXIF / container dates.
|
||
if let Some(resolved) = filename_class_resolved(path, prior_exif.map(|p| &p.evidence)) {
|
||
return Some(resolved);
|
||
}
|
||
if let Some(prior) = prior_exif {
|
||
return Some(exif_prior_resolved(prior));
|
||
}
|
||
if let Some(resolved) = exiftool_date_single(path) {
|
||
return Some(resolved);
|
||
}
|
||
if let Ok(meta) = std::fs::metadata(path)
|
||
&& let Some(t) = earliest_fs_time(&meta)
|
||
{
|
||
let dt: DateTime<Utc> = t.into();
|
||
return Some(ResolvedDate {
|
||
timestamp: dt.timestamp(),
|
||
source: DateSource::FsTime,
|
||
date_utc: Some(dt.timestamp()),
|
||
camera_offset_minutes: None,
|
||
tz_source: Some(camera_tz::TzSource::UtcDirect),
|
||
});
|
||
}
|
||
None
|
||
}
|
||
|
||
/// Batch waterfall. In-class filenames resolve first (ticket #116) and
|
||
/// never hit exiftool; exiftool then runs once over the remaining
|
||
/// no-prior files (single subprocess); everything else is per-file and
|
||
/// runs only on misses. `prior_exif_priors` lets the caller pass in
|
||
/// already-known kamadak readings keyed by path; entries without a prior
|
||
/// date fall through to exiftool and the rest of the waterfall.
|
||
///
|
||
/// The per-tick backfill drain is the primary caller — it loads ~500 rows
|
||
/// at a time and uses one exiftool subprocess to drain the lot.
|
||
pub fn resolve_dates_batch(
|
||
paths: &[PathBuf],
|
||
prior_exif_priors: &HashMap<PathBuf, ExifPrior>,
|
||
) -> HashMap<PathBuf, ResolvedDate> {
|
||
resolve_dates_batch_report(paths, prior_exif_priors).resolved
|
||
}
|
||
|
||
/// [`resolve_dates_batch`]'s result plus which files exiftool actually read.
|
||
#[derive(Debug, Default)]
|
||
pub struct BatchResolution {
|
||
pub resolved: HashMap<PathBuf, ResolvedDate>,
|
||
/// Paths exiftool returned an error-free entry for, whether or not it
|
||
/// held a usable date. A path in here that still resolved to `fs_time`
|
||
/// genuinely has no date tag (e.g. a muxer that wrote all-zero
|
||
/// `CreateDate`s); a path missing from it was never read — exiftool
|
||
/// absent, crashed, or reported an error for that file.
|
||
pub exiftool_read: HashSet<PathBuf>,
|
||
}
|
||
|
||
/// [`resolve_dates_batch`], also reporting which files exiftool read, so
|
||
/// a caller can tell "no date tag in the file" from "exiftool didn't get
|
||
/// a look at it" when the waterfall lands on `fs_time`.
|
||
pub fn resolve_dates_batch_report(
|
||
paths: &[PathBuf],
|
||
prior_exif_priors: &HashMap<PathBuf, ExifPrior>,
|
||
) -> BatchResolution {
|
||
let mut out: HashMap<PathBuf, ResolvedDate> = HashMap::new();
|
||
let mut exiftool_read: HashSet<PathBuf> = HashSet::new();
|
||
let mut needs_exiftool: Vec<&Path> = Vec::with_capacity(paths.len());
|
||
|
||
for path in paths {
|
||
// Filename date class first (ticket #116) — same rule as the
|
||
// single-file waterfall; in-class files skip exiftool entirely.
|
||
if let Some(resolved) =
|
||
filename_class_resolved(path, prior_exif_priors.get(path).map(|p| &p.evidence))
|
||
{
|
||
out.insert(path.clone(), resolved);
|
||
continue;
|
||
}
|
||
if let Some(prior) = prior_exif_priors.get(path) {
|
||
out.insert(path.clone(), exif_prior_resolved(prior));
|
||
} else {
|
||
needs_exiftool.push(path.as_path());
|
||
}
|
||
}
|
||
|
||
if !needs_exiftool.is_empty() {
|
||
// 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) {
|
||
if let Some(resolved) = resolved {
|
||
out.insert(path.clone(), resolved);
|
||
}
|
||
exiftool_read.insert(path);
|
||
}
|
||
}
|
||
|
||
// Remaining misses are out-of-class files with no EXIF / container
|
||
// date — the filename step can't match them (any match would have
|
||
// resolved in the first loop), so they fall straight to fs-time.
|
||
for path in paths {
|
||
if out.contains_key(path) {
|
||
continue;
|
||
}
|
||
if let Ok(meta) = std::fs::metadata(path)
|
||
&& let Some(t) = earliest_fs_time(&meta)
|
||
{
|
||
let dt: DateTime<Utc> = t.into();
|
||
out.insert(
|
||
path.clone(),
|
||
ResolvedDate {
|
||
timestamp: dt.timestamp(),
|
||
source: DateSource::FsTime,
|
||
date_utc: Some(dt.timestamp()),
|
||
camera_offset_minutes: None,
|
||
tz_source: Some(camera_tz::TzSource::UtcDirect),
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
BatchResolution {
|
||
resolved: out,
|
||
exiftool_read,
|
||
}
|
||
}
|
||
|
||
/// 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.
|
||
///
|
||
/// Videos: `MediaCreateDate` / `TrackCreateDate` are the QuickTime/MP4
|
||
/// timestamps. `ContentCreateDate` is Apple's iOS-set tag; it often
|
||
/// reflects local capture time on iPhone exports better than the others.
|
||
///
|
||
/// `ModifyDate` comes last and only from `IFD0` (see
|
||
/// [`MODIFY_DATE_GROUP`]): that's EXIF `DateTime`, which the kamadak
|
||
/// ingest path already takes as the capture date when nothing better
|
||
/// exists. Scans, edits and some downloads carry only that tag, and
|
||
/// without it here the drain resolved them to fs_time and then refused
|
||
/// to overwrite their tag-sourced legacy date — stuck forever.
|
||
///
|
||
/// Notably absent: `FileModifyDate` / `FileAccessDate` — those are
|
||
/// filesystem-derived and the resolver covers them via the `fs_time`
|
||
/// fallback. Letting exiftool pull them here would mask "no real EXIF
|
||
/// date" with a `source = exiftool` row that's no better than fs_time.
|
||
const EXIFTOOL_DATE_TAGS: &[&str] = &[
|
||
"DateTimeOriginal",
|
||
"SubSecDateTimeOriginal",
|
||
"CreateDate",
|
||
"MediaCreateDate",
|
||
"TrackCreateDate",
|
||
"ContentCreateDate",
|
||
"ModifyDate",
|
||
];
|
||
|
||
/// The only group whose `ModifyDate` counts as a date: `IFD0`, i.e. EXIF
|
||
/// `DateTime`. XMP / QuickTime `ModifyDate` record when an editor or
|
||
/// muxer last touched the file, which isn't a capture time.
|
||
const MODIFY_DATE_GROUP: &str = "IFD0";
|
||
|
||
/// 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.
|
||
pub fn exiftool_available() -> bool {
|
||
static AVAIL: OnceLock<bool> = OnceLock::new();
|
||
*AVAIL.get_or_init(|| {
|
||
let ok = Command::new("exiftool")
|
||
.arg("-ver")
|
||
.stdout(Stdio::null())
|
||
.stderr(Stdio::null())
|
||
.status()
|
||
.map(|s| s.success())
|
||
.unwrap_or(false);
|
||
if !ok {
|
||
warn!("exiftool not on PATH; date_taken waterfall skips that step");
|
||
}
|
||
ok
|
||
})
|
||
}
|
||
|
||
/// 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. `-Error` puts exiftool's per-file
|
||
/// read error (`ExifTool:Error`, e.g. "File format error") into the JSON
|
||
/// — under `-q` it otherwise only reaches stderr, and the file's entry
|
||
/// looks like a clean read that found no tags.
|
||
fn exiftool_common_args(cmd: &mut Command) {
|
||
cmd.arg("-j").arg("-q").arg("-G1").arg("-Error");
|
||
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.
|
||
///
|
||
/// Notably absent: `-fast` / `-fast2`. For QuickTime/MP4 files whose
|
||
/// `moov` atom sits at the end (non-faststart, common for Snapchat
|
||
/// exports and any MP4 muxed without `-movflags +faststart`), `-fast2`
|
||
/// causes exiftool to skip the trailer and return no `CreateDate` /
|
||
/// `MediaCreateDate`, dropping us to the `fs_time` fallback for files
|
||
/// 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<ResolvedDate> {
|
||
if !exiftool_available() {
|
||
return None;
|
||
}
|
||
let mut cmd = Command::new("exiftool");
|
||
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_payload(&output.stdout)
|
||
.into_iter()
|
||
.next()
|
||
.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. Resolution is the same pure
|
||
/// function as the single-file path, so the drain and ingest produce
|
||
/// identical results for the same file.
|
||
///
|
||
/// Keyed by every file exiftool read without error, `None` when the read
|
||
/// found no usable date. Files it never reported (crash, partial output)
|
||
/// or reported an error for are absent, unless the entry still carried a
|
||
/// usable date.
|
||
fn exiftool_dates_batch(paths: &[&Path]) -> HashMap<PathBuf, Option<ResolvedDate>> {
|
||
let mut out = HashMap::new();
|
||
if paths.is_empty() || !exiftool_available() {
|
||
return out;
|
||
}
|
||
|
||
let mut cmd = Command::new("exiftool");
|
||
// No `-fast2` — see exiftool_date_single for the rationale (QuickTime
|
||
// moov-at-end files miss CreateDate / MediaCreateDate when the trailer
|
||
// is skipped).
|
||
exiftool_common_args(&mut cmd);
|
||
cmd.arg("-@").arg("-");
|
||
cmd.stdin(Stdio::piped())
|
||
.stdout(Stdio::piped())
|
||
.stderr(Stdio::null());
|
||
|
||
let mut child = match cmd.spawn() {
|
||
Ok(c) => c,
|
||
Err(e) => {
|
||
warn!("exiftool batch spawn failed: {}", e);
|
||
return out;
|
||
}
|
||
};
|
||
|
||
if let Some(mut stdin) = child.stdin.take() {
|
||
for p in paths {
|
||
// exiftool's argfile reader treats each line as one path; OS
|
||
// path bytes don't always survive a String round-trip, but
|
||
// every path we get here originated from rel_path / root_path
|
||
// strings already, so to-string-lossy is a non-event.
|
||
if let Err(e) = writeln!(stdin, "{}", p.display()) {
|
||
warn!("exiftool batch stdin write failed: {}", e);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
let output = match child.wait_with_output() {
|
||
Ok(o) => o,
|
||
Err(e) => {
|
||
warn!("exiftool batch wait failed: {}", e);
|
||
return out;
|
||
}
|
||
};
|
||
if !output.status.success() {
|
||
debug!(
|
||
"exiftool batch exit status {:?}; partial output may still parse",
|
||
output.status.code()
|
||
);
|
||
}
|
||
for file in parse_exiftool_payload(&output.stdout) {
|
||
let resolved = resolve_exiftool_payload(&file.date_tags, &file.evidence);
|
||
if resolved.is_some() || !file.read_error {
|
||
out.insert(PathBuf::from(file.source_file), resolved);
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// 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,
|
||
}
|
||
|
||
/// 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.
|
||
/// GPS hemisphere ref test. exiftool's print conversion renders the refs
|
||
/// as words ("West" / "South", ticket #163), `-n` as letters ("W" / "S"):
|
||
/// match on the first letter, case-insensitively, the same rule as
|
||
/// `exif::extract_gps_coordinate`.
|
||
fn gps_ref_is(value: &str, letter: char) -> bool {
|
||
value
|
||
.trim()
|
||
.chars()
|
||
.next()
|
||
.is_some_and(|c| c.eq_ignore_ascii_case(&letter))
|
||
}
|
||
|
||
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 {
|
||
if cand.tag == "ModifyDate" && cand.group != MODIFY_DATE_GROUP {
|
||
continue;
|
||
}
|
||
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,
|
||
/// exiftool reported `ExifTool:Error` for this file (couldn't read it).
|
||
read_error: bool,
|
||
}
|
||
|
||
/// 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
|
||
// logging at warn — the caller already noted the non-zero
|
||
// exit status.
|
||
if !stdout.is_empty() {
|
||
warn!("exiftool JSON parse failed: {}", e);
|
||
}
|
||
return Vec::new();
|
||
}
|
||
};
|
||
|
||
let mut out = Vec::with_capacity(entries.len());
|
||
for entry in entries {
|
||
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(),
|
||
read_error: false,
|
||
};
|
||
for (key, value) in obj {
|
||
let Some((group, tag)) = key.split_once(':') else {
|
||
continue;
|
||
};
|
||
if group == "ExifTool" && tag == "Error" {
|
||
data.read_error = true;
|
||
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 = gps_ref_is(v, 'S'),
|
||
"GPSLongitude" => data.evidence.gps_lon = parse_gps_dms(v),
|
||
"GPSLongitudeRef" => data.evidence.gps_lon_west = gps_ref_is(v, 'W'),
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
out.push(data);
|
||
}
|
||
out
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
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_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_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",
|
||
"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 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);
|
||
}
|
||
|
||
/// A clean read whose date tags are all exiftool's zero placeholder is
|
||
/// "no usable date", not a read error; `ExifTool:Error` is the error
|
||
/// flag and isn't mistaken for a date tag.
|
||
#[test]
|
||
fn parse_payload_flags_read_errors_not_zero_dates() {
|
||
let json = br#"[{
|
||
"SourceFile": "/lib/zero.mp4",
|
||
"QuickTime:CreateDate": "0000:00:00 00:00:00",
|
||
"Track1:TrackCreateDate": "0000:00:00 00:00:00"
|
||
},
|
||
{
|
||
"SourceFile": "/lib/bad.jpg",
|
||
"ExifTool:Error": "File format error"
|
||
}]"#;
|
||
let files = parse_exiftool_payload(json);
|
||
assert_eq!(files.len(), 2);
|
||
assert!(!files[0].read_error);
|
||
assert_eq!(files[0].date_tags.len(), 2);
|
||
assert!(resolve_exiftool_payload(&files[0].date_tags, &files[0].evidence).is_none());
|
||
assert!(files[1].read_error);
|
||
assert!(files[1].date_tags.is_empty());
|
||
}
|
||
|
||
/// #163: exiftool's default print conversion renders hemisphere refs as
|
||
/// words. Before the fix "West" / "South" never matched, so western /
|
||
/// southern coordinates came out positive and the GPS offset lookup hit
|
||
/// the mirrored point (Ohio → China, UTC+8).
|
||
#[test]
|
||
fn parse_payload_reads_exiftool_word_hemisphere_refs() {
|
||
let json = br#"[{
|
||
"SourceFile": "/lib/ohio.jpg",
|
||
"ExifIFD:DateTimeOriginal": "2014:01:12 20:55:48",
|
||
"GPS:GPSLatitude": "41 deg 22' 19.20\"",
|
||
"GPS:GPSLatitudeRef": "North",
|
||
"GPS:GPSLongitude": "81 deg 50' 34.80\"",
|
||
"GPS:GPSLongitudeRef": "West"
|
||
}, {
|
||
"SourceFile": "/lib/sydney.jpg",
|
||
"ExifIFD:DateTimeOriginal": "2019:12:31 23:30:00",
|
||
"GPS:GPSLatitude": "33 deg 52' 4.00\"",
|
||
"GPS:GPSLatitudeRef": "South",
|
||
"GPS:GPSLongitude": "151 deg 12' 26.00\"",
|
||
"GPS:GPSLongitudeRef": "East"
|
||
}]"#;
|
||
let files = parse_exiftool_payload(json);
|
||
let (lat, lon) = files[0].evidence.gps().unwrap();
|
||
assert!((lat - 41.372).abs() < 1e-9, "lat {lat}");
|
||
assert!((lon - (-81.843)).abs() < 1e-9, "lon {lon}");
|
||
let (lat, lon) = files[1].evidence.gps().unwrap();
|
||
assert!(lat < 0.0 && lon > 0.0, "sydney ({lat}, {lon})");
|
||
}
|
||
|
||
#[test]
|
||
fn gps_ref_is_matches_letter_and_word_forms() {
|
||
for v in ["W", "w", "West", " west "] {
|
||
assert!(gps_ref_is(v, 'W'), "{v:?}");
|
||
}
|
||
for v in ["E", "East", "", "N"] {
|
||
assert!(!gps_ref_is(v, 'W'), "{v:?}");
|
||
}
|
||
assert!(gps_ref_is("South", 'S'));
|
||
assert!(!gps_ref_is("North", 'S'));
|
||
}
|
||
|
||
#[test]
|
||
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 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 ifd0_modify_date_is_a_last_resort() {
|
||
// Scans / edits with only EXIF DateTime (exiftool: IFD0:ModifyDate)
|
||
// resolve from it as a camera wall clock, like the kamadak path.
|
||
let resolved = resolve_exiftool_payload(
|
||
&tags(&[
|
||
("IFD0", "ModifyDate", "2009:03:04 14:55:48"),
|
||
("ExifIFD", "DateTimeOriginal", ""),
|
||
]),
|
||
&ExiftoolEvidence {
|
||
offset_time: Some(-300),
|
||
..Default::default()
|
||
},
|
||
)
|
||
.unwrap();
|
||
assert_eq!(resolved.source, DateSource::Exiftool);
|
||
assert_eq!(resolved.date_utc, Some(1_236_196_548));
|
||
assert_eq!(resolved.camera_offset_minutes, Some(-300));
|
||
|
||
// Any capture tag outranks it.
|
||
let resolved = resolve_exiftool_payload(
|
||
&tags(&[
|
||
("IFD0", "ModifyDate", "2022:11:08 15:18:20"),
|
||
("ExifIFD", "CreateDate", "2022:11:01 09:00:00"),
|
||
]),
|
||
&ExiftoolEvidence {
|
||
offset_time: Some(-240),
|
||
..Default::default()
|
||
},
|
||
)
|
||
.unwrap();
|
||
assert_eq!(resolved.date_utc, Some(1_667_307_600));
|
||
}
|
||
|
||
#[test]
|
||
fn non_exif_modify_date_is_ignored() {
|
||
// XMP / QuickTime ModifyDate is an edit or mux time, not capture.
|
||
for group in ["XMP-xmp", "QuickTime"] {
|
||
assert!(
|
||
resolve_exiftool_payload(
|
||
&tags(&[(group, "ModifyDate", "2024:01:01 00:00:00")]),
|
||
&ExiftoolEvidence::default(),
|
||
)
|
||
.is_none(),
|
||
"{group}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[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()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn date_source_as_str_round_trip() {
|
||
for src in [
|
||
DateSource::Exif,
|
||
DateSource::Exiftool,
|
||
DateSource::Filename,
|
||
DateSource::FsTime,
|
||
] {
|
||
assert!(!src.as_str().is_empty());
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn resolve_uses_prior_exif_when_present() {
|
||
// Path doesn't need to exist when a prior EXIF reading
|
||
// short-circuits. Legacy timestamp stays the wall-clock-as-UTC
|
||
// value; the new-contract fields come from the cascade.
|
||
let prior = ExifPrior {
|
||
wall: chrono::DateTime::from_timestamp(1_700_000_000, 0)
|
||
.unwrap()
|
||
.naive_utc(),
|
||
evidence: camera_tz::OffsetEvidence::default(),
|
||
};
|
||
let resolved =
|
||
resolve_date_taken(Path::new("/nonexistent/file.jpg"), Some(&prior)).unwrap();
|
||
assert_eq!(resolved.timestamp, 1_700_000_000);
|
||
assert_eq!(resolved.source, DateSource::Exif);
|
||
// No offset evidence in the stub-less test env: cascade lands on
|
||
// assumed_server (or the local-offset fallback when the host zone
|
||
// doesn't parse), and date_utc is always offset-correct.
|
||
assert!(resolved.camera_offset_minutes.is_some());
|
||
assert!(resolved.date_utc.is_some());
|
||
assert_eq!(
|
||
resolved.date_utc.unwrap(),
|
||
resolved.timestamp - (resolved.camera_offset_minutes.unwrap() as i64) * 60
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn resolve_exif_offset_tag_gives_true_instant_and_source() {
|
||
// Wall 2024-07-04 12:00:00 with a camera-stamped EDT offset.
|
||
// Deterministic: the offset tag short-circuits the cascade before
|
||
// GPS / server-zone lookup.
|
||
let prior = ExifPrior {
|
||
wall: NaiveDateTime::parse_from_str("2024-07-04 12:00:00", "%Y-%m-%d %H:%M:%S")
|
||
.unwrap(),
|
||
evidence: camera_tz::OffsetEvidence {
|
||
offset_tag_minutes: Some(-240),
|
||
gps: None,
|
||
},
|
||
};
|
||
let resolved =
|
||
resolve_date_taken(Path::new("/nonexistent/IMG_0001.jpg"), Some(&prior)).unwrap();
|
||
assert_eq!(resolved.timestamp, 1_720_094_400); // legacy wall-as-UTC
|
||
assert_eq!(resolved.date_utc, Some(1_720_108_800)); // 16:00:00Z
|
||
assert_eq!(resolved.camera_offset_minutes, Some(-240));
|
||
assert_eq!(resolved.tz_source, Some(camera_tz::TzSource::OffsetTag));
|
||
}
|
||
|
||
#[test]
|
||
fn resolve_exif_gps_evidence_uses_capture_zone() {
|
||
// No offset tag; GPS points at Manhattan. The bundled TZF geometry
|
||
// resolves America/New_York, whose offset for the July wall time
|
||
// is EDT (-240) — DST-correct for the capture date, not "now".
|
||
let prior = ExifPrior {
|
||
wall: NaiveDateTime::parse_from_str("2024-07-04 12:00:00", "%Y-%m-%d %H:%M:%S")
|
||
.unwrap(),
|
||
evidence: camera_tz::OffsetEvidence {
|
||
offset_tag_minutes: None,
|
||
gps: Some((40.7128, -74.0060)),
|
||
},
|
||
};
|
||
let resolved =
|
||
resolve_date_taken(Path::new("/nonexistent/IMG_0001.jpg"), Some(&prior)).unwrap();
|
||
assert_eq!(resolved.camera_offset_minutes, Some(-240));
|
||
assert_eq!(resolved.tz_source, Some(camera_tz::TzSource::Gps));
|
||
assert_eq!(resolved.date_utc, Some(1_720_108_800));
|
||
}
|
||
|
||
#[test]
|
||
fn filename_class_wall_without_evidence_assumes_server_zone() {
|
||
// In-class screenshot name, no prior EXIF, file missing. The wall
|
||
// clock runs the cascade with empty evidence and lands on
|
||
// assumed_server — a class file always resolves (#116). Legacy
|
||
// stays the pre-migration wall-as-UTC interpretation.
|
||
let resolved = resolve_date_taken(
|
||
Path::new("/nonexistent/Screenshot_2014-06-01-20-44-50.png"),
|
||
None,
|
||
)
|
||
.unwrap();
|
||
let wall =
|
||
NaiveDateTime::parse_from_str("2014-06-01 20:44:50", "%Y-%m-%d %H:%M:%S").unwrap();
|
||
assert_eq!(resolved.source, DateSource::Filename);
|
||
assert_eq!(resolved.timestamp, wall.and_utc().timestamp());
|
||
assert!(resolved.date_utc.is_some());
|
||
assert!(resolved.camera_offset_minutes.is_some());
|
||
assert_eq!(resolved.tz_source, Some(camera_tz::TzSource::AssumedServer));
|
||
assert_eq!(
|
||
resolved.date_utc.unwrap(),
|
||
resolved.timestamp - (resolved.camera_offset_minutes.unwrap() as i64) * 60
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn filename_class_wall_outranks_exif_and_uses_its_evidence() {
|
||
// In-class filename beats the EXIF date entirely (the priority
|
||
// inversion), and the wall cascade uses the prior's offset-tag
|
||
// evidence. The EXIF wall (2020) never reaches the result.
|
||
let prior = ExifPrior {
|
||
wall: NaiveDateTime::parse_from_str("2020-05-05 05:05:05", "%Y-%m-%d %H:%M:%S")
|
||
.unwrap(),
|
||
evidence: camera_tz::OffsetEvidence {
|
||
offset_tag_minutes: Some(-240),
|
||
gps: None,
|
||
},
|
||
};
|
||
let resolved = resolve_date_taken(
|
||
Path::new("/nonexistent/2015-01-09_02-15-15.jpg"),
|
||
Some(&prior),
|
||
)
|
||
.unwrap();
|
||
assert_eq!(resolved.source, DateSource::Filename);
|
||
assert_eq!(resolved.timestamp, 1_420_769_715); // wall-as-UTC legacy
|
||
assert_eq!(resolved.camera_offset_minutes, Some(-240));
|
||
assert_eq!(resolved.tz_source, Some(camera_tz::TzSource::OffsetTag));
|
||
// The offset tag short-circuits the cascade and is used as-stamped:
|
||
// wall − (−240) → 2015-01-09 06:15:15Z.
|
||
assert_eq!(resolved.date_utc, Some(1_420_784_115));
|
||
}
|
||
|
||
#[test]
|
||
fn filename_class_wall_gps_evidence_uses_capture_zone() {
|
||
// No offset tag; the prior carries Manhattan GPS. The bundled TZF
|
||
// geometry resolves America/New_York, whose offset for the
|
||
// July wall is EDT (-240).
|
||
let prior = ExifPrior {
|
||
wall: NaiveDateTime::parse_from_str("2024-07-04 12:00:00", "%Y-%m-%d %H:%M:%S")
|
||
.unwrap(),
|
||
evidence: camera_tz::OffsetEvidence {
|
||
offset_tag_minutes: None,
|
||
gps: Some((40.7128, -74.0060)),
|
||
},
|
||
};
|
||
let resolved = resolve_date_taken(
|
||
Path::new("/nonexistent/Screenshot_2024-07-04-12-00-00.png"),
|
||
Some(&prior),
|
||
)
|
||
.unwrap();
|
||
assert_eq!(resolved.source, DateSource::Filename);
|
||
assert_eq!(resolved.camera_offset_minutes, Some(-240));
|
||
assert_eq!(resolved.tz_source, Some(camera_tz::TzSource::Gps));
|
||
assert_eq!(resolved.date_utc, Some(1_720_108_800)); // 16:00:00Z
|
||
}
|
||
|
||
#[test]
|
||
fn filename_class_timestamp_is_utc_direct() {
|
||
// A unix-timestamp name is a true instant: no cascade, no offset,
|
||
// and legacy equals the epoch (the pre-migration interpretation).
|
||
let resolved =
|
||
resolve_date_taken(Path::new("/nonexistent/xyz_1401638400.jpeg"), None).unwrap();
|
||
assert_eq!(resolved.source, DateSource::Filename);
|
||
assert_eq!(resolved.timestamp, 1_401_638_400);
|
||
assert_eq!(resolved.date_utc, Some(1_401_638_400));
|
||
assert_eq!(resolved.camera_offset_minutes, None);
|
||
assert_eq!(resolved.tz_source, Some(camera_tz::TzSource::UtcDirect));
|
||
}
|
||
|
||
#[test]
|
||
fn in_class_video_name_resolves_without_exiftool() {
|
||
// A chat-export-style name on a video path: the class check runs
|
||
// before exiftool is consulted — the file need not even exist for
|
||
// the result to be class-resolved.
|
||
let resolved =
|
||
resolve_date_taken(Path::new("/nonexistent/att_1422489664680106.mp4"), None).unwrap();
|
||
assert_eq!(resolved.source, DateSource::Filename);
|
||
assert_eq!(resolved.date_utc, Some(1_422_489_664));
|
||
assert_eq!(resolved.tz_source, Some(camera_tz::TzSource::UtcDirect));
|
||
}
|
||
|
||
#[test]
|
||
fn resolve_fs_time_when_only_metadata_available() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let path = dir.path().join("plain.jpg");
|
||
std::fs::File::create(&path).unwrap();
|
||
let resolved = resolve_date_taken(&path, None).unwrap();
|
||
// exiftool may or may not be installed in the test env; either
|
||
// way the file has no EXIF and no filename date, so we should
|
||
// fall to fs_time.
|
||
assert_eq!(resolved.source, DateSource::FsTime);
|
||
// fs_time is already a true UTC instant — recorded as utc_direct.
|
||
assert_eq!(resolved.date_utc, Some(resolved.timestamp));
|
||
assert_eq!(resolved.camera_offset_minutes, None);
|
||
assert_eq!(resolved.tz_source, Some(camera_tz::TzSource::UtcDirect));
|
||
}
|
||
}
|