PhoenixKit.Modules.Storage.CaptureDate (phoenix_kit v2.37.1)

Copy Markdown View Source

When a photo or video was taken, as opposed to when it was uploaded.

Four columns on phoenix_kit_files (V200) hold it:

  • taken_at — the moment, in UTC. Exact when the offset is known. When it is not — most EXIF carries only a local wall-clock time — it is that local time stored as if it were UTC: it still orders a library correctly, but it is not a true instant.
  • taken_on — the local calendar date, which is what a library groups by. A photo taken at 23:00 on 31 July in California is a July photo, although in UTC it is already 1 August; grouping on taken_at would file it under the wrong month.
  • taken_at_offset — seconds east of UTC, when known.
  • taken_at_source — where the date came from (see below).

Where a date comes from, strongest first

  1. "manual" — set by a person. Never replaced automatically.
  2. "exif" — an image's DateTimeOriginal, else DateTimeDigitized, with OffsetTimeOriginal / OffsetTimeDigitized when present. "container" — a video's QuickTime creation date (a local time with its offset), else the container's creation_time (UTC). Same strength.
  3. "filename" — a date in the uploaded file's name, the way phones and cameras name files (IMG_20180701_120000.jpg, PXL_20240315_081100123.jpg, 2018-07-01 12.34.56.jpg).
  4. "inserted_at" — the upload time. Always available, so every file resolves to some date.

A file's modification time is deliberately not a source: originals live on local disk or in object storage, where it is the upload time — the same information inserted_at already carries.

Never downgraded

An image edit re-encodes the file keeping only its ICC profile (see PhoenixKit.Modules.Storage.ImageEdit), so an edited original carries no EXIF, and anything that reads a date from those bytes finds the file name or the upload time at best. replace?/2 is the guard every automatic writer goes through: a date is replaced only by one from an equally strong or stronger source, and a manual date never.

Summary

Types

Where a capture date came from. See the moduledoc.

t()

Functions

attrs without its capture-date keys when writing them over current would be a downgrade (replace?/2), unchanged otherwise.

The four columns a capture date occupies on phoenix_kit_files.

A capture date from EXIF tags: DateTimeOriginal, else DateTimeDigitized, each with its own offset tag when present. nil when neither is a plausible date.

A capture date from the date in a file name, or nil. Only the base name is read, and only dates in the shapes phones and cameras write.

The upload time as a capture date: the fallback every file has.

A capture date from container tags: QuickTime's creation date, which keeps the local time and its offset (what an iPhone writes), else the container's creation_time, which is UTC with no offset — the instant is exact, but the local date can only be taken from UTC.

The EXIF tags ImageMagick reads from the first frame at path, as %{"DateTimeOriginal" => "2018:07:31 23:04:05", …}.

The creation tags ffprobe reads from the container at path, as %{"creation_time" => …, "com.apple.quicktime.creationdate" => …}. An empty map when there are none or ffprobe is missing.

Whether an automatic writer may replace a date from current with one from new: only by an equally strong or stronger source, and a manual date never. nil (no date yet) is always replaceable.

The capture date of file, reading its bytes at path when given.

Every valid taken_at_source, strongest first.

Types

source()

@type source() :: String.t()

Where a capture date came from. See the moduledoc.

t()

@type t() :: %{
  taken_at: DateTime.t(),
  taken_on: Date.t(),
  taken_at_offset: integer() | nil,
  taken_at_source: source()
}

Functions

admit(attrs, current)

@spec admit(map(), map()) :: map()

attrs without its capture-date keys when writing them over current would be a downgrade (replace?/2), unchanged otherwise.

fields()

@spec fields() :: [atom()]

The four columns a capture date occupies on phoenix_kit_files.

from_exif(tags)

@spec from_exif(%{required(String.t()) => String.t()}) :: t() | nil

A capture date from EXIF tags: DateTimeOriginal, else DateTimeDigitized, each with its own offset tag when present. nil when neither is a plausible date.

from_filename(name)

@spec from_filename(String.t() | nil) :: t() | nil

A capture date from the date in a file name, or nil. Only the base name is read, and only dates in the shapes phones and cameras write.

from_inserted_at(inserted_at)

@spec from_inserted_at(DateTime.t() | NaiveDateTime.t()) :: t()

The upload time as a capture date: the fallback every file has.

from_video_tags(tags)

@spec from_video_tags(%{required(String.t()) => String.t()}) :: t() | nil

A capture date from container tags: QuickTime's creation date, which keeps the local time and its offset (what an iPhone writes), else the container's creation_time, which is UTC with no offset — the instant is exact, but the local date can only be taken from UTC.

read_exif(path)

@spec read_exif(Path.t()) :: %{required(String.t()) => String.t()}

The EXIF tags ImageMagick reads from the first frame at path, as %{"DateTimeOriginal" => "2018:07:31 23:04:05", …}.

%[EXIF:*] rather than one %[EXIF:Tag] per tag: ImageMagick 7 warns on stderr for every named tag a file lacks, and most files lack some. An empty map when the file has no EXIF, is not an image, or identify is missing.

read_video_tags(path)

@spec read_video_tags(Path.t()) :: %{required(String.t()) => String.t()}

The creation tags ffprobe reads from the container at path, as %{"creation_time" => …, "com.apple.quicktime.creationdate" => …}. An empty map when there are none or ffprobe is missing.

replace?(current, new)

@spec replace?(source() | nil, source()) :: boolean()

Whether an automatic writer may replace a date from current with one from new: only by an equally strong or stronger source, and a manual date never. nil (no date yet) is always replaceable.

resolve(path, file)

@spec resolve(Path.t() | nil, map()) :: t()

The capture date of file, reading its bytes at path when given.

Always returns a date: embedded metadata, else the original file name, else the upload time. path is nil when the bytes could not be read, which skips straight to the file name.

sources()

@spec sources() :: [source()]

Every valid taken_at_source, strongest first.