fix(backfill): convert exiftool-legacy rows whose only dates are zero

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>
This commit is contained in:
Cameron CordesandClaude Opus 5.5 committed 2026-10-17 12:17:17 -04:00
1 parent 783fcb8dbe
commit 55d2e888d6
3 files changed
+222 -18

No files matched your search

+4 -1
View File
@@ -458,7 +458,10 @@ the file. Missing-file rows are paged past without spending the cap, and
a per-library resume cursor (carried across ticks by the watcher, wraps a per-library resume cursor (carried across ticks by the watcher, wraps
at end of table) keeps unconvertible rows from stalling the rows behind at end of table) keeps unconvertible rows from stalling the rows behind
them; EXIF/exiftool-legacy rows wait when exiftool is absent (they'd them; EXIF/exiftool-legacy rows wait when exiftool is absent (they'd
otherwise fall to fs_time). When a converted row's display-zone day differs from otherwise fall to fs_time), and a re-resolution to fs_time never replaces
a tag date — except an `exiftool`-legacy row that exiftool read cleanly
and found no usable date in (all-zero tags), where fs_time is the honest
answer (`resolve_dates_batch_report`'s `exiftool_read`). When a converted row's display-zone day differs from
the day its legacy value rendered on, the `day`-span `precomputed_reels` the day its legacy value rendered on, the `day`-span `precomputed_reels`
rows covering either month-day (this library + `all`) are deleted and rows covering either month-day (this library + `all`) are deleted and
unlinked; the nightly pre-gen regenerates. Daily conversation summaries unlinked; the nightly pre-gen regenerates. Daily conversation summaries
+143 -10
View File
@@ -302,6 +302,32 @@ pub fn backfill_utc_dates(
backfill_utc_dates_capped(library, exif_dao, reel_dao, display_tz, cursor, cap) backfill_utc_dates_capped(library, exif_dao, reel_dao, display_tz, cursor, cap)
} }
/// Whether a re-resolution can stand in for a row's legacy date. Only an
/// `fs_time` landing on a tag-dated row is suspect:
///
/// - legacy `exif` (kamadak-exif) — a different reader found a date there,
/// so exiftool coming up empty says nothing about it: refuse.
/// - legacy `exiftool` — refuse unless exiftool read the file this pass.
/// If it did and found no usable date (e.g. a muxer's all-zero
/// `CreateDate`s), the legacy value was never a real capture date and
/// fs_time is the honest answer; without this the row re-runs through
/// exiftool every tick and never converts. If it didn't (crash, read
/// error), the legacy date may be real.
fn utc_resolution_is_faithful(
legacy_source: Option<&str>,
resolved_source: date_resolver::DateSource,
exiftool_read: bool,
) -> bool {
if resolved_source != date_resolver::DateSource::FsTime {
return true;
}
match legacy_source {
Some("exif") => false,
Some("exiftool") => exiftool_read,
_ => true,
}
}
fn backfill_utc_dates_capped( fn backfill_utc_dates_capped(
library: &libraries::Library, library: &libraries::Library,
exif_dao: &Arc<Mutex<Box<dyn ExifDao>>>, exif_dao: &Arc<Mutex<Box<dyn ExifDao>>>,
@@ -390,14 +416,16 @@ fn backfill_utc_dates_capped(
// One exiftool subprocess for the whole batch — byte-identical // One exiftool subprocess for the whole batch — byte-identical
// call shape to the #117 audit preview. // call shape to the #117 audit preview.
let paths: Vec<PathBuf> = filed.iter().map(|(_, p)| p.clone()).collect(); let paths: Vec<PathBuf> = filed.iter().map(|(_, p)| p.clone()).collect();
let resolved = date_resolver::resolve_dates_batch(&paths, &HashMap::new()); let batch = date_resolver::resolve_dates_batch_report(&paths, &HashMap::new());
for (row, abs) in filed { for (row, abs) in filed {
let legacy_had_tag = matches!( match batch.resolved.get(&abs) {
row.date_taken_source.as_deref(), Some(rd)
Some("exif") | Some("exiftool") if utc_resolution_is_faithful(
); row.date_taken_source.as_deref(),
match resolved.get(&abs) { rd.source,
Some(rd) if !(legacy_had_tag && rd.source == date_resolver::DateSource::FsTime) => { batch.exiftool_read.contains(&abs),
) =>
{
match (rd.date_utc, rd.tz_source) { match (rd.date_utc, rd.tz_source) {
(Some(utc), Some(tz)) => conversions.push(Conversion { (Some(utc), Some(tz)) => conversions.push(Conversion {
row, row,
@@ -408,9 +436,9 @@ fn backfill_utc_dates_capped(
_ => outcome.unresolved += 1, _ => outcome.unresolved += 1,
} }
} }
// No result, or a row whose legacy date came from a tag // No result, or a tag-dated row now resolving to fs_time
// now resolving to fs_time (exiftool failed on it): don't // without exiftool having read the file: don't bake a
// bake a filesystem time in over a real capture date. // filesystem time in over a real capture date.
_ => { _ => {
debug!( debug!(
"utc_backfill: no faithful resolution for lib {} {}", "utc_backfill: no faithful resolution for lib {} {}",
@@ -1494,6 +1522,111 @@ mod tests {
assert_eq!(progress[&1], 0); assert_eq!(progress[&1], 0);
} }
#[test]
fn utc_resolution_fs_time_over_tag_date_needs_an_exiftool_read() {
use date_resolver::DateSource::{Exiftool, FsTime};
// Anything but fs_time stands, whatever the legacy source.
assert!(utc_resolution_is_faithful(
Some("exiftool"),
Exiftool,
false
));
assert!(utc_resolution_is_faithful(Some("exif"), Exiftool, false));
// fs_time over a non-tag legacy source stands.
assert!(utc_resolution_is_faithful(Some("fs_time"), FsTime, false));
assert!(utc_resolution_is_faithful(None, FsTime, false));
// Legacy exiftool: only once exiftool read the file and found no
// usable date.
assert!(utc_resolution_is_faithful(Some("exiftool"), FsTime, true));
assert!(!utc_resolution_is_faithful(Some("exiftool"), FsTime, false));
// Legacy kamadak exif: never — exiftool's read doesn't vouch for it.
assert!(!utc_resolution_is_faithful(Some("exif"), FsTime, true));
assert!(!utc_resolution_is_faithful(Some("exif"), FsTime, false));
}
/// Tiny H.264 MP4 with no metadata: exiftool reads it cleanly but every
/// QuickTime / Track date is the `0000:00:00 00:00:00` placeholder.
fn write_zero_date_video(root: &std::path::Path, rel: &str) {
run_ok(
std::process::Command::new("ffmpeg")
.args([
"-y",
"-loglevel",
"error",
"-f",
"lavfi",
"-i",
"color=c=black:s=32x32:d=0.4",
"-c:v",
"libx264",
"-pix_fmt",
"yuv420p",
"-fflags",
"+bitexact",
"-map_metadata",
"-1",
])
.arg(root.join(rel)),
);
}
#[test]
fn utc_backfill_legacy_exiftool_row_with_only_zero_dates_converts_to_fs_time() {
if !tools_available() {
eprintln!("skipping: ffmpeg/exiftool not on PATH");
return;
}
let (tmp, library, conn, exif_dao, _face_dao) = setup();
let reel_dao = reel_dao_for(&conn);
// Both legacy-exiftool rows land on fs_time. exiftool reads the
// video cleanly and finds only zero dates (the prod
// `uv-prod-long.mp4` case); the "jpg" is binary junk exiftool
// reports a File format error for, so it never got a look inside.
// (Printable junk wouldn't do: exiftool reads it as a text file.)
write_zero_date_video(tmp.path(), "uv-long.mp4");
write_image(
tmp.path(),
"unreadable.jpg",
&[0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
);
let video = insert_legacy(
&exif_dao,
1,
"uv-long.mp4",
Some(1_000_000_000),
Some("exiftool"),
);
let junk = insert_legacy(
&exif_dao,
1,
"unreadable.jpg",
Some(1_000_000_000),
Some("exiftool"),
);
let mut cursor = 0;
let out = backfill_utc_dates_capped(&library, &exif_dao, &reel_dao, NY, &mut cursor, 10);
assert_eq!(out.converted, 1);
assert_eq!(out.unresolved, 1);
let fs_secs = std::fs::metadata(tmp.path().join("uv-long.mp4"))
.ok()
.and_then(|m| crate::utils::earliest_fs_time(&m))
.map(|t| chrono::DateTime::<chrono::Utc>::from(t).timestamp())
.expect("fs time");
let r = row(&conn, video);
assert_eq!(r.date_utc, Some(fs_secs));
assert_eq!(r.camera_offset_minutes, None);
assert_eq!(r.date_taken_tz_source.as_deref(), Some("utc_direct"));
// Legacy pair stays frozen.
assert_eq!(r.date_taken, Some(1_000_000_000));
assert_eq!(r.date_taken_source.as_deref(), Some("exiftool"));
// The read-error row keeps its legacy date and stays pending.
assert_eq!(row(&conn, junk).date_utc, None);
assert_eq!(remaining(&exif_dao, 1), 1);
}
#[test] #[test]
fn utc_backfill_unresolvable_rows_cannot_stall_the_rows_behind_them() { fn utc_backfill_unresolvable_rows_cannot_stall_the_rows_behind_them() {
let (tmp, library, conn, exif_dao, _face_dao) = setup(); let (tmp, library, conn, exif_dao, _face_dao) = setup();
+75 -7
View File
@@ -48,7 +48,7 @@
//! In-class files invert that rule deliberately (ADR-0001): a renamed //! In-class files invert that rule deliberately (ADR-0001): a renamed
//! screenshot or a chat-export timestamp is the only honest date left. //! screenshot or a chat-export timestamp is the only honest date left.
use std::collections::HashMap; use std::collections::{HashMap, HashSet};
use std::io::Write; use std::io::Write;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
@@ -259,7 +259,30 @@ pub fn resolve_dates_batch(
paths: &[PathBuf], paths: &[PathBuf],
prior_exif_priors: &HashMap<PathBuf, ExifPrior>, prior_exif_priors: &HashMap<PathBuf, ExifPrior>,
) -> HashMap<PathBuf, ResolvedDate> { ) -> 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 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()); let mut needs_exiftool: Vec<&Path> = Vec::with_capacity(paths.len());
for path in paths { for path in paths {
@@ -282,7 +305,10 @@ pub fn resolve_dates_batch(
// One subprocess for the whole batch; each entry is already fully // One subprocess for the whole batch; each entry is already fully
// resolved (legacy + new-contract fields). // resolved (legacy + new-contract fields).
for (path, resolved) in exiftool_dates_batch(&needs_exiftool) { for (path, resolved) in exiftool_dates_batch(&needs_exiftool) {
out.insert(path, resolved); if let Some(resolved) = resolved {
out.insert(path.clone(), resolved);
}
exiftool_read.insert(path);
} }
} }
@@ -310,7 +336,10 @@ pub fn resolve_dates_batch(
} }
} }
out BatchResolution {
resolved: out,
exiftool_read,
}
} }
/// Tag priority for exiftool extraction. First parseable value wins. /// Tag priority for exiftool extraction. First parseable value wins.
@@ -384,9 +413,12 @@ pub fn exiftool_available() -> bool {
/// Append the exiftool arguments shared by the single-file and batch /// Append the exiftool arguments shared by the single-file and batch
/// invocations: raw date strings (no `-d %s` — the resolver interprets /// invocations: raw date strings (no `-d %s` — the resolver interprets
/// them itself) with group prefixes for classification, plus the offset /// them itself) with group prefixes for classification, plus the offset
/// / GPS evidence tags for the cascade. /// / 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) { fn exiftool_common_args(cmd: &mut Command) {
cmd.arg("-j").arg("-q").arg("-G1"); cmd.arg("-j").arg("-q").arg("-G1").arg("-Error");
for tag in EXIFTOOL_DATE_TAGS { for tag in EXIFTOOL_DATE_TAGS {
cmd.arg(format!("-{}", tag)); cmd.arg(format!("-{}", tag));
} }
@@ -430,7 +462,12 @@ fn exiftool_date_single(path: &Path) -> Option<ResolvedDate> {
/// libraries with very long path components. Resolution is the same pure /// libraries with very long path components. Resolution is the same pure
/// function as the single-file path, so the drain and ingest produce /// function as the single-file path, so the drain and ingest produce
/// identical results for the same file. /// identical results for the same file.
fn exiftool_dates_batch(paths: &[&Path]) -> HashMap<PathBuf, ResolvedDate> { ///
/// 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(); let mut out = HashMap::new();
if paths.is_empty() || !exiftool_available() { if paths.is_empty() || !exiftool_available() {
return out; return out;
@@ -481,7 +518,8 @@ fn exiftool_dates_batch(paths: &[&Path]) -> HashMap<PathBuf, ResolvedDate> {
); );
} }
for file in parse_exiftool_payload(&output.stdout) { for file in parse_exiftool_payload(&output.stdout) {
if let Some(resolved) = resolve_exiftool_payload(&file.date_tags, &file.evidence) { 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.insert(PathBuf::from(file.source_file), resolved);
} }
} }
@@ -810,6 +848,8 @@ struct ExiftoolFileData {
source_file: String, source_file: String,
date_tags: Vec<RawDateTag>, date_tags: Vec<RawDateTag>,
evidence: ExiftoolEvidence, 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. /// Split the exiftool `-j -G1` output into per-file date tags + evidence.
@@ -841,11 +881,16 @@ fn parse_exiftool_payload(stdout: &[u8]) -> Vec<ExiftoolFileData> {
source_file: source_file.to_string(), source_file: source_file.to_string(),
date_tags: Vec::new(), date_tags: Vec::new(),
evidence: ExiftoolEvidence::default(), evidence: ExiftoolEvidence::default(),
read_error: false,
}; };
for (key, value) in obj { for (key, value) in obj {
let Some((group, tag)) = key.split_once(':') else { let Some((group, tag)) = key.split_once(':') else {
continue; continue;
}; };
if group == "ExifTool" && tag == "Error" {
data.read_error = true;
continue;
}
let Some(v) = value.as_str() else { let Some(v) = value.as_str() else {
continue; continue;
}; };
@@ -962,6 +1007,29 @@ mod tests {
assert!((lon - (-74.006)).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 /// #163: exiftool's default print conversion renders hemisphere refs as
/// words. Before the fix "West" / "South" never matched, so western / /// words. Before the fix "West" / "South" never matched, so western /
/// southern coordinates came out positive and the GPS offset lookup hit /// southern coordinates came out positive and the GPS offset lookup hit