PhoenixKitPhotos.Timeline (PhoenixKit Photos v0.3.0)

Copy Markdown View Source

The server side of the timeline: a monthly index and per-day windows over one storage library.

Scope

v1 is {:library, uuid}. A library is the partition PhoenixKit stores files in (PhoenixKit.Modules.Storage.Libraries). The caller also passes the current PhoenixKit.Users.Auth.Scope, and every file in a window is checked with Libraries.can?/3 before a URL is minted.

The fast index is phoenix_kit_files_library_capture_date_index on (library_uuid, taken_on, taken_at). Months follow taken_on, the local calendar date, not the UTC instant in taken_at.

The two shapes

The index is a cheap aggregation — a few hundred month buckets for a library of any size:

%{
  total: 184_203,
  state: :ready,
  sections: [
    %{
      id: "2018-07",
      label: "July 2018",
      count: 412,
      sum_aspect: 548.2,
      days: 19,
      days_list: [%{id: "2018-07-14", count: 20}]
    }
  ]
}

A window is a day or a 500-item slice of a larger day, ordered by taken_at. label is already translated.

state is :ready when the index has photos. An empty library is :empty, :processing (an upload is not active yet), or :backfill (capture dates are still being filled in).

Summary

Types

A month bucket: "2018-07".

What the swipe viewer needs for one file: a picture (src), or a video (poster and sources). w and h are the size of the picture the viewer shows, never the stored size of a video (a phone clip is stored as landscape and played as portrait).

A day window: "2018-07-14".

Functions

The condition of the files after a cursor/1, or :error when it is not one.

The aspect ratio of every file of a library, for the styles that show whole photos: a map of section id to one byte per file, in the order the timeline shows them.

The predicates every timeline query must carry, on phoenix_kit_files.

A place in the timeline's order, for paging. It holds the place itself rather than naming a file, so a page still follows on when the file it ended on has since been trashed or moved.

The library the timeline opens when the caller does not name one.

The query of what a timeline shows, for code that narrows it further (PhoenixKitPhotos.API). The library is checked for auth; each file of the answer still has to pass Libraries.can?/3.

Folds per-day counts into month sections.

The ids of positions from to to of the timeline, both included, at most limit. With :kind ("image" or "video") the positions are those of that kind only, and without from and to that is the whole library.

Monthly index for one library.

Libraries the scope may open in the timeline: the system library Media for someone who can manage it (media_visible?/1), then the user libraries they own or belong to.

Whether the site's Media library is one of this scope's libraries.

Already-translated month heading for a "2018-07" section id.

Where a photo is in the timeline: how many photos of the library come before it, in the order the timeline shows them (by day, then taken_at, then uuid). The browser opens the swipe viewer at that position, so an address that names a photo can open on it.

Most files slides/6 returns at once.

A slice of a window for the swipe viewer: count files (at most slide_limit/0) from position from of the day or day-slice day, in the order window/3 gives them, each with full-size URLs for a screen want pixels wide.

Whether a storage event about file_uuids can change what library scope shows.

Read-authorized data for PhoenixKit's existing canvas viewer.

One day of items for a library, at most window_limit/0, ordered by taken_at.

Maximum items returned in a single window; larger days are subdivided.

Types

day_count()

@type day_count() :: %{id: window_id(), count: non_neg_integer()}

error()

@type error() :: :capture_date_unavailable | :not_found | :unauthorized

index()

@type index() :: %{total: non_neg_integer(), state: state(), sections: [section()]}

item()

@type item() :: %{
  id: String.t(),
  taken_at: DateTime.t() | nil,
  w: pos_integer(),
  h: pos_integer(),
  ar: float(),
  thumb: String.t() | nil,
  fit: String.t() | nil,
  preview: String.t() | nil,
  large: String.t() | nil,
  kind: :image | :video,
  code: String.t() | nil,
  name: String.t() | nil,
  size: non_neg_integer() | nil,
  duration: non_neg_integer() | nil
}

scope()

@type scope() :: {:library, String.t()}

section()

@type section() :: %{
  id: section_id(),
  label: String.t(),
  count: non_neg_integer(),
  sum_aspect: float(),
  days: non_neg_integer(),
  days_list: [day_count()]
}

section_id()

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

A month bucket: "2018-07".

slide()

@type slide() :: %{
  :id => String.t(),
  :kind => :image | :video,
  :w => pos_integer(),
  :h => pos_integer(),
  optional(:code) => String.t() | nil,
  optional(:src) => String.t() | nil,
  optional(:poster) => String.t() | nil,
  optional(:sources) => [
    %{url: String.t(), type: String.t(), role: :rendition | :original}
  ]
}

What the swipe viewer needs for one file: a picture (src), or a video (poster and sources). w and h are the size of the picture the viewer shows, never the stored size of a video (a phone clip is stored as landscape and played as portrait).

state()

@type state() :: :ready | :empty | :processing | :backfill

window()

@type window() :: %{section: window_id(), items: [item()]}

window_id()

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

A day window: "2018-07-14".

Functions

after_cursor(cursor)

@spec after_cursor(term()) :: {:ok, Ecto.Query.dynamic_expr()} | :error

The condition of the files after a cursor/1, or :error when it is not one.

aspects(arg1, auth)

@spec aspects(scope(), PhoenixKit.Users.Auth.Scope.t() | nil) ::
  {:ok, %{required(section_id()) => binary()}} | {:error, error()}

The aspect ratio of every file of a library, for the styles that show whole photos: a map of section id to one byte per file, in the order the timeline shows them.

With these the browser knows the exact height of every month without fetching a single window, so the scrollbar and jump to month stay honest in those styles too. A byte is the logarithm of the ratio from 1:8 to 8:1 in 256 steps of 1.6%, which is finer than anyone can see in a crop and costs 260 KB for 200,000 files, once. assets/js/layouts.js reads it.

base_filters()

@spec base_filters() :: keyword()

The predicates every timeline query must carry, on phoenix_kit_files.

status == "active" keeps processing and failed rows out — they have no variants and would render as permanently grey tiles. The dimension predicate keeps ar from blowing up the layout.

edit_state is absent on purpose. A pending or failed edit occupies the same uuid and belongs in the grid.

cursor(file)

A place in the timeline's order, for paging. It holds the place itself rather than naming a file, so a page still follows on when the file it ended on has since been trashed or moved.

default_library_uuid(scope)

@spec default_library_uuid(PhoenixKit.Users.Auth.Scope.t() | nil) :: String.t() | nil

The library the timeline opens when the caller does not name one.

A user's default personal library when they have one, otherwise Media when they may see it (media_visible?/1), otherwise none.

files_query(arg1, auth)

@spec files_query(scope(), PhoenixKit.Users.Auth.Scope.t() | nil) ::
  {:ok, Ecto.Query.t()} | {:error, error()}

The query of what a timeline shows, for code that narrows it further (PhoenixKitPhotos.API). The library is checked for auth; each file of the answer still has to pass Libraries.can?/3.

group_days(day_rows)

@spec group_days([
  %{taken_on: Date.t(), count: non_neg_integer(), sum_aspect: number()}
]) :: [section()]

Folds per-day counts into month sections.

taken_on decides the month. The label is translated before it reaches the hook.

ids_between(scope, auth, opts)

@spec ids_between(scope(), PhoenixKit.Users.Auth.Scope.t() | nil, keyword()) ::
  {:ok, [String.t()], boolean()} | {:error, error()}

The ids of positions from to to of the timeline, both included, at most limit. With :kind ("image" or "video") the positions are those of that kind only, and without from and to that is the whole library.

This is what a selection asks when a range runs through months the browser has not fetched.

index(arg1, auth)

@spec index(scope(), PhoenixKit.Users.Auth.Scope.t() | nil) ::
  {:ok, index()} | {:error, error()}

Monthly index for one library.

Live aggregation, grouped by taken_on. A photo taken late in the evening whose UTC instant falls on the next day stays in the month of taken_on.

libraries(scope)

Libraries the scope may open in the timeline: the system library Media for someone who can manage it (media_visible?/1), then the user libraries they own or belong to.

Media does not require user libraries to be enabled. A personal library does.

media_visible?(scope)

@spec media_visible?(PhoenixKit.Users.Auth.Scope.t() | nil) :: boolean()

Whether the site's Media library is one of this scope's libraries.

It belongs to the site, not to a person: a system role or access to the media module (the rule for adding to it, Uploads.can_upload?/2). To anyone else it would be an empty library they cannot add to, since a user reads only their own uploads there.

month_label(other)

@spec month_label(section_id()) :: String.t()

Already-translated month heading for a "2018-07" section id.

position(arg1, uuid, auth)

@spec position(scope(), String.t(), PhoenixKit.Users.Auth.Scope.t() | nil) ::
  {:ok, non_neg_integer()} | {:error, error()}

Where a photo is in the timeline: how many photos of the library come before it, in the order the timeline shows them (by day, then taken_at, then uuid). The browser opens the swipe viewer at that position, so an address that names a photo can open on it.

{:error, :not_found} for a file that is not in the library or that auth may not read.

slide_limit()

@spec slide_limit() :: pos_integer()

Most files slides/6 returns at once.

slides(arg1, day, from, count, want, auth)

@spec slides(
  scope(),
  window_id(),
  integer(),
  integer(),
  integer(),
  PhoenixKit.Users.Auth.Scope.t() | nil
) :: {:ok, [slide()]} | {:error, error()}

A slice of a window for the swipe viewer: count files (at most slide_limit/0) from position from of the day or day-slice day, in the order window/3 gives them, each with full-size URLs for a screen want pixels wide.

from + count may run past the end of a 500-item slice into the next one: the position is the same in the day, so the caller's index stays right.

touches?(arg1, file_uuids)

@spec touches?(scope(), [String.t()]) :: boolean()

Whether a storage event about file_uuids can change what library scope shows.

Storage announces every file event to everyone, whichever library the file is in. This answers from where the files are kept, trashed ones included (a trashed file leaves the timeline but is still a row of its library). Files that no longer exist cannot be placed, so they count: someone's gone for good, and the timeline asks.

viewer_file(arg1, uuid, auth)

Read-authorized data for PhoenixKit's existing canvas viewer.

window(arg1, day, auth)

@spec window(scope(), window_id(), PhoenixKit.Users.Auth.Scope.t() | nil) ::
  {:ok, window()} | {:error, error()}

One day of items for a library, at most window_limit/0, ordered by taken_at.

Thumbnail and preview URLs come from Storage.variant_for/2 (a crop and an aspect-preserving size). The original is never used as a tile. Each URL is minted with Storage.authorized_url/4, which refuses a file the scope may not read.

window_limit()

@spec window_limit() :: pos_integer()

Maximum items returned in a single window; larger days are subdivided.