Skip to content

Apple's per-asset capture offset is stored but not used when rendering timestamps #703

Description

@teh-hippo

Summary

Apple sends each photo as an instant plus the timezone offset where it was taken. Kei
stores both but renders every timestamp using the backup machine's timezone instead, so
the same library produces different results depending on which machine runs the backup.

The problem

Apple supplies two separate values per asset:

  • assetDate, an absolute instant in UTC
  • timeZoneOffset, the UTC offset in effect at the capture location

Neither value alone reconstructs the local time of capture. This is useful context for
when a photograph was taken (evening, morning etc.), and also speaks somewhat to the
location.

The offset is not a mandatory field. It is present on effectively everything current, and
aligns with EXIF 2.x, but there are cases in my own library from ~2015 where it was not
provided.

Kei parses both, but only processes the assetDate.

Setting the host timezone is not a solution to this. The offset belongs to the place the
photograph was taken, so a host setting only ever matches for photographs taken at home.
Holidays and trips away are exactly the cases it gets wrong, and they are also the cases
where the capture time matters most. It is a workaround for a value that Apple already
provides per asset.

Kei needs to use this field so that capture times resolve as intended, are applied
consistently when files are placed on disk, and are written correctly into metadata and
sidecars.

Observed on a container running TZ=UTC, backing up a library captured at +11:00. An
asset captured 1 February at 09:31:59, where the device recorded its own capture time and
offset when the photograph was taken:

DateTimeOriginal                : 2026:02:01 09:31:59
OffsetTimeOriginal              : +11:00

Kei retrieved and stored Apple's pair for the same asset:

created_at       1769898719      # the instant, 2026-01-31T22:31:59Z
timezone_offset  39600           # the offset, +11:00

Applying the offset to the instant gives 2026-02-01 09:31:59, matching the device. Kei
instead rendered the instant in the host's timezone, so the asset was filed under January,
and the timestamps it wrote read 2026-01-31T22:31:59, with no offset recorded alongside
them.

The same asset renders differently again on a different host, which can be shown with
chrono alone:

use chrono::{FixedOffset, Local, TimeZone, Utc};

fn main() {
    // An asset captured at 09:31:59 on 1 February, at UTC+11:00.
    let instant = Utc.timestamp_opt(1_769_898_719, 0).unwrap();
    let capture = FixedOffset::east_opt(39_600).unwrap();

    // What kei derives (src/download/filter.rs:928).
    println!("kei           {}", instant.with_timezone(&Local).format("%Y/%m/%d %H:%M:%S"));
    // What Apple described.
    println!("capture-local {}", instant.with_timezone(&capture).format("%Y/%m/%d %H:%M:%S%:z"));
}
Host TZ Kei renders Capture-local
UTC 2026/01/31 22:31:59 2026/02/01 09:31:59+11:00
Australia/Sydney 2026/02/01 09:31:59 2026/02/01 09:31:59+11:00
America/Los_Angeles 2026/01/31 14:31:59 2026/02/01 09:31:59+11:00

Implications

The role of TZ in the project. Host timezone is currently the source for every
rendered date. In most of these places the per-asset offset is the value that was actually
wanted, so this is less a single defect than a question of which timezone the project
should be reading from by default. Anywhere a date is derived for storage or output looks
like a candidate to switch. Host timezone remains reasonable for things a person reads on
their own screen, such as progress output and summaries.

Anything downstream of a rendered date. File placement is the visible one: date
directives in folder templates come from the same host-local value, and the shipped
default for the unfiled pass is %Y/%m/%d. Correcting the source of the date therefore
moves files for existing libraries, which is correct but not a silent change. It also
interacts with skip and adoption logic, because a stored path whose parent no longer
matches the derived parent is not recognised, and with daylight saving, since a host that
observes it shifts twice a year on its own.

Written metadata. This is the larger piece of work. The writer's view of an asset
carries no timezone field at all, so the offset would need to be threaded through before
anything could be written, and the serialisation itself would need to emit an offset where
it currently emits none. Existing files are a separate problem again: nothing in the
current change-detection notices that a previously written timestamp was rendered under a
different host timezone, so a fix would apply to new writes only unless a repair path is
also defined.

Assets with no offset. These need a defined behaviour. One option worth exploring is
deriving the offset from the file itself. DateTimeOriginal does not carry a zone, but it
is a local wall clock, so differencing it against the UTC instant Apple provides yields the
offset that was in effect. Testing this across the assets in my library where Apple
supplied no offset, 14 of 16 produced clean whole-hour values that match the expected zone,
including correctly distinguishing daylight saving between a winter and a summer month. The
remaining two were images with no meaningful capture zone. It is not universal, and it
would need guards against a wrong camera clock, dates edited after capture, and offsets
that are not whole hours, but the information is largely present.

Possible overlap

This may overlap work already open, and I have not assumed otherwise:

Happy to fold this into one of the above if you see it differently.

No account identifiers, asset identifiers, file paths or personal metadata are included
here. Timestamps appear only where they are needed to demonstrate the boundary.

Metadata

Metadata

Assignees

Labels

P1 stability-reliabilityNear-term stability, reliability, recovery, and safe sync behaviorbugSomething isn't workingmetadataEXIF, XMP, provider metadata, and metadata-write behavioruser-submittedSubmitted by a user

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions