Figuring Out When a Photo Was Actually Taken

Nextcloud stores no first-class 'date taken' field. Photo Sweep's month-by-month review depends on one, so it walks a four-link chain — EXIF, filename, mtime, upload time — and records which link answered.

Figuring Out When a Photo Was Actually Taken

The whole premise of Photo Sweep is a month-by-month review: pick July 2024, see what's in it, judge each photo. That premise depends on knowing when a photo was taken. Nextcloud does not store that anywhere as a first-class field, and a library built from years of phone backups, camera imports, and forwarded screenshots gives you almost every way that fact can go missing at once.

Four links, best evidence first — EXIF, filename, mtime, upload time
Four links, best evidence first — EXIF, filename, mtime, upload time

Whichever link answers is recorded, so an odd-looking month is explainable rather than mysterious.

The chain

DateResolver::resolve() tries four sources in order, returning the first one that produces a plausible timestamp:

  1. EXIF DateTimeOriginal, extracted into file metadata by the Photos app. This is the good case, and it's read in the user's timezone, not the server's — a photo taken at 11pm belongs to that day where the person taking it was standing.
  2. The filename. IMG_20240712_140325.jpg, PXL_20240712_140325123.jpg, Screenshot_20240712-140325.png, 2024-07-12 14.03.25.jpg. Five regex patterns cover the exporters Photos itself parses plus the common ones it skips.
  3. Modification time. Files copied over a network share, most screenshots that lost their EXIF along the way.
  4. Upload time, for storages that report no usable mtime at all.
public function resolve(File $file, \DateTimeZone $timezone, array $preloaded = []): ResolvedDate {
    $exifTaken = $this->fromExif($metadata, $timezone);
    if ($exifTaken !== null) {
        return new ResolvedDate($exifTaken, DateSource::EXIF);
    }

    $nameTaken = $this->fromFilename($file->getName(), $timezone);
    if ($nameTaken !== null) {
        return new ResolvedDate($nameTaken, DateSource::FILENAME);
    }
    // … mtime, then upload time
}

Why the source is recorded, not just the timestamp

Every ResolvedDate carries a source alongside the timestamp. That field does no work in the month bucketing — it exists purely so that when a user finds a screenshot sitting in March 2019 for no obvious reason, the answer is one field away instead of a mystery. "This came from mtime" tells you the file was probably copied in, not taken then, and that's often all the context needed to trust the grid again.

It also caught a real bug during development: a batch of files whose fromMetadataTimestamp() fallback was itself just Photos' own mtime guess, tagged as if it were a real capture date. Once the chain distinguished "Photos told us a date" from "Photos told us a date it computed from mtime," the distinction mattered enough to keep as its own case.

Timezone reading is deliberate, not incidental

EXIF's DateTimeOriginal has no timezone attached — it's written as 2024:07:12 14:03:25, a naive local time. The obvious implementation reads it in whatever timezone the PHP process runs in, which for a self-hosted Nextcloud is the server's zone, not the uploader's. For anyone whose server sits in a different timezone from where they take photos, that quietly shifts every photo taken late at night into the wrong month.

DateResolver takes the user's configured timezone as a parameter and reads every naive timestamp — EXIF and filename alike — against it. It's a single parameter threaded through the whole chain, and it's the kind of correctness that never shows up in a demo and only shows up as one indignant bug report from the one user in a different timezone from their server.

What happens when nothing matches

Not every step tries to be clever. isPlausible() gates every candidate timestamp against a fixed window — no earlier than 1986, no more than two days in the future — before it's accepted, and if EXIF and filename both come back empty or implausible, mtime is used only if it also passes that check. The fallback to upload time exists for exactly the storages where none of that holds: getUploadTime() first, then max($file->getMTime(), 0) as the last resort that cannot itself fail.

Four sources, tried in order, each rejected the moment it looks wrong rather than trusted the moment it parses. The parsing-versus-trusting distinction is its own story — it's the subject of the next post.

Series: Photo Sweep.