photos, extracted from the platform into its own repository

The fifth plugin out, and the cheapest so far — offscale's shape rather than
music's or vault's. Nothing stayed behind and nothing had to be redesigned,
for three structural reasons recorded in PLUGIN.md:

  • No reverse dependency. Nothing in src/ imported anything of photos'.
  • Configuration was already right. The URL and API key live in photos_config,
    a per-user registry encrypted at rest and driven from the app. Vault had to
    move VAULTWARDEN_URL out of .env and invent a settings panel; here the
    sidecar already read the database, and its own comment already said why
    nothing reads process.env.
  • The frontend was already panels. The screen was a WorkspaceView around
    exactly photos-nav and photos-view, so web/panels.ts and web/layout.ts are
    the screen's contents with the wrapper removed.

The router's prefix is now derived from mountPrefix rather than the literal
'/api/photos', which was correct only by coincidence — a third-party publisher
mounts at /api/p/<publisher>/photos and the literal would forward the wrong
path to the sidecar.

Palette debt paid down as the runbook requires: 6 classes (it says 47; someone
had already done most of it), all of them state colours wearing palette
numbers, now `success` and `warning` from the closed tone set so they follow
the theme.

Immich itself is still provisioned by the app store rather than by this plugin
— the `provisioned` half of its old catalogue entry has no home until Docker
provisioning for plugins exists. Point it at an instance you already run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 19:10:39 +00:00
co-authored by Claude Opus 5
commit 2dad1907f8
39 changed files with 5321 additions and 0 deletions
+400
View File
@@ -0,0 +1,400 @@
// Shared types, constants and pure helpers for the /photos workspace panels.
//
// Unlike a plugin whose sidecar reshapes an upstream API into Officer's own types — offscale does — the
// photos sidecar is a deliberate pass-through, so these types mirror Immich's wire shapes directly. They were
// generated from the LIVE instance's OpenAPI document (`GET /api/spec.json`, Immich 3.0.3), not from memory:
// several of them changed shape in 3.0 (`isArchived` became `visibility`, and the timeline bucket response
// became columnar), so anything written from an older mental model would be subtly wrong.
export const PHOTOS_SECTIONS = [
{ id: 'timeline', label: 'Photos' },
{ id: 'explore', label: 'Explore' },
{ id: 'map', label: 'Map' },
{ id: 'search', label: 'Search' },
{ id: 'albums', label: 'Albums' },
{ id: 'people', label: 'People' },
{ id: 'favorites', label: 'Favorites' },
{ id: 'archive', label: 'Archive' },
{ id: 'sharing', label: 'Sharing' },
{ id: 'trash', label: 'Trash' },
{ id: 'settings', label: 'Connection' },
] as const;
export type PhotosSectionId = (typeof PHOTOS_SECTIONS)[number]['id'];
/** Where /photos lands, and where an unrecognised section redirects to. */
export const DEFAULT_PHOTOS_SECTION: PhotosSectionId = 'timeline';
export const isPhotosSection = (value: string | undefined): value is PhotosSectionId =>
PHOTOS_SECTIONS.some((s) => s.id === value);
/** The one place the section URL is spelled, so nav, guard and deep links cannot drift apart. */
export const photosSectionPath = (id: PhotosSectionId) => `/photos/${id}`;
/** Which asset is open in the lightbox — `?asset=<id>`. */
export const ASSET_PARAM = 'asset';
/** Which album is open — `?album=<id>`. */
export const ALBUM_PARAM = 'album';
/** Which person is open — `?person=<id>`. */
export const PERSON_PARAM = 'person';
/** The search text — `?q=…`. */
export const QUERY_PARAM = 'q';
// ── Wire types ────────────────────────────────────────────────────────────────────────────────────
export type AssetVisibility = 'archive' | 'timeline' | 'hidden' | 'locked';
export type AssetType = 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER';
export type ExifInfo = {
make?: string | null;
model?: string | null;
lensModel?: string | null;
exifImageWidth?: number | null;
exifImageHeight?: number | null;
fileSizeInByte?: number | null;
dateTimeOriginal?: string | null;
fNumber?: number | null;
focalLength?: number | null;
iso?: number | null;
exposureTime?: string | null;
latitude?: number | null;
longitude?: number | null;
city?: string | null;
state?: string | null;
country?: string | null;
description?: string | null;
rating?: number | null;
};
export type Asset = {
id: string;
type: AssetType;
thumbhash: string | null;
localDateTime: string;
fileCreatedAt: string;
/** Milliseconds, null for stills. It was an `HH:MM:SS.mmm` string before Immich 3.0 — it is a number now. */
duration: number | null;
livePhotoVideoId?: string | null;
width: number;
height: number;
ownerId: string;
originalFileName: string;
originalPath: string;
originalMimeType?: string;
isFavorite: boolean;
isArchived: boolean;
isTrashed: boolean;
isOffline: boolean;
visibility: AssetVisibility;
exifInfo?: ExifInfo;
people?: Person[];
stack?: { id: string; primaryAssetId: string; assetCount: number } | null;
};
export type Album = {
id: string;
albumName: string;
description: string;
createdAt: string;
updatedAt: string;
albumThumbnailAssetId: string | null;
shared: boolean;
hasSharedLink: boolean;
assetCount: number;
startDate?: string;
endDate?: string;
albumUsers: { user: { id: string; name: string; email: string } }[];
};
/** `GET /albums/{id}` returns the album with its assets attached. */
export type AlbumDetail = Album & { assets?: Asset[] };
export type Person = {
id: string;
name: string;
birthDate: string | null;
thumbnailPath: string;
isHidden: boolean;
isFavorite?: boolean;
};
export type PeopleResponse = { total: number; hidden: number; people: Person[]; hasNextPage?: boolean };
export type SearchResponse = {
assets: { total: number; count: number; items: Asset[]; nextPage: string | null };
albums: { total: number; count: number; items: Album[] };
};
export type ExploreItem = { value: string; data: Asset };
export type ExploreSection = { fieldName: string; items: ExploreItem[] };
export type SharedLink = {
id: string;
key: string;
type: string;
album?: Album | null;
assets: Asset[];
description?: string | null;
expiresAt: string | null;
createdAt: string;
allowDownload: boolean;
allowUpload: boolean;
};
export type TimeBucket = { timeBucket: string; count: number };
/** `GET /map/markers` — one row per geotagged asset. Deliberately tiny: the whole library fits in one response. */
export type MapMarker = { id: string; lat: number; lon: number; city: string | null; country: string | null };
/**
* The slice of `GET /server/config` this UI needs.
*
* The map style is Immich's own, fetched by the browser straight from `tiles.immich.cloud` — the same place
* Immich's web app gets it, and the only part of /photos that talks to anything other than Officer. It is not
* proxied because a vector basemap is thousands of tile requests that have nothing to do with the library.
*/
export type ServerConfig = { mapDarkStyleUrl: string; mapLightStyleUrl: string; version?: string };
/**
* `GET /timeline/bucket` is COLUMNAR — parallel arrays, not a list of objects. Immich went this way in 1.133
* because a month of assets is tens of thousands of near-identical objects and the array-of-arrays form is a
* fraction of the JSON. Zip it with `bucketAssets()` before touching it.
*/
export type TimeBucketColumns = {
id: string[];
ownerId: string[];
ratio: number[];
isFavorite: boolean[];
visibility: AssetVisibility[];
isTrashed: boolean[];
isImage: boolean[];
thumbhash: (string | null)[];
createdAt: string[];
fileCreatedAt: string[];
localOffsetHours: number[];
/** Milliseconds, null for stills — same units as `Asset.duration`. */
duration: (number | null)[];
stack?: (string[] | null)[];
projectionType: (string | null)[];
livePhotoVideoId: (string | null)[];
city?: (string | null)[];
country?: (string | null)[];
};
/** The minimum a tile needs to draw itself. Both the columnar timeline and a full Asset reduce to this. */
export type GridAsset = {
id: string;
/** width / height. The whole point of the columnar payload: layout without loading a single image. */
ratio: number;
isFavorite: boolean;
isImage: boolean;
isTrashed: boolean;
/** Milliseconds, null for stills. */
duration: number | null;
livePhotoVideoId: string | null;
fileCreatedAt: string;
stackCount: number;
};
export function bucketAssets(cols: TimeBucketColumns): GridAsset[] {
return cols.id.map((id, i) => ({
id,
ratio: cols.ratio[i] || 1,
isFavorite: cols.isFavorite[i] ?? false,
isImage: cols.isImage[i] ?? true,
isTrashed: cols.isTrashed[i] ?? false,
duration: cols.duration?.[i] ?? null,
livePhotoVideoId: cols.livePhotoVideoId?.[i] ?? null,
fileCreatedAt: cols.fileCreatedAt[i] ?? '',
// stack is [stackId, assetCount] when present.
stackCount: Number(cols.stack?.[i]?.[1] ?? 0),
}));
}
export const toGridAsset = (a: Asset): GridAsset => ({
id: a.id,
ratio: a.height ? a.width / a.height : 1,
isFavorite: a.isFavorite,
isImage: a.type === 'IMAGE',
isTrashed: a.isTrashed,
duration: a.duration ?? null,
livePhotoVideoId: a.livePhotoVideoId ?? null,
fileCreatedAt: a.fileCreatedAt,
stackCount: a.stack?.assetCount ?? 0,
});
// ── Media URLs ────────────────────────────────────────────────────────────────────────────────────
//
// An <img> cannot send an Authorization header, so media URLs carry `?token=` instead — the same escape
// hatch userMiddleware already supports for <audio>/<video> and the one the music app uses for cover art.
// The photos sidecar strips the token before forwarding, so it never reaches Immich or its access log.
const withToken = (url: string, token: string | null) =>
token ? `${url}${url.includes('?') ? '&' : '?'}token=${encodeURIComponent(token)}` : url;
export const PHOTOS_API = '/api/photos/_officer';
/** `thumbnail` is the grid tile (~250px); `preview` is the lightbox; `fullsize` is the decoded original. */
export const thumbUrl = (id: string, token: string | null, size: 'thumbnail' | 'preview' | 'fullsize' = 'thumbnail') =>
withToken(`${PHOTOS_API}/assets/${id}/thumbnail?size=${size}`, token);
export const originalUrl = (id: string, token: string | null) =>
withToken(`${PHOTOS_API}/assets/${id}/original`, token);
export const videoUrl = (id: string, token: string | null) =>
withToken(`${PHOTOS_API}/assets/${id}/video/playback`, token);
export const personThumbUrl = (id: string, token: string | null) =>
withToken(`${PHOTOS_API}/people/${id}/thumbnail`, token);
// ── Formatting ────────────────────────────────────────────────────────────────────────────────────
export const formatBytes = (bytes: number | null | undefined): string => {
if (!bytes) return '—';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let value = bytes;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit += 1;
}
return `${value < 10 && unit > 0 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`;
};
/**
* Milliseconds → the grid badge's `M:SS` (or `H:MM:SS` when it's long).
*
* Immich 3.0 sends durations as an integer count of milliseconds. Older versions sent an `HH:MM:SS.mmm`
* string, which is worth knowing because every stale example on the internet still shows that form — and
* `'…'.split(':')` on a number is a render-time TypeError, not a wrong label.
*/
export const formatDuration = (ms: number | null | undefined): string | null => {
if (!ms || !Number.isFinite(ms)) return null;
const total = Math.floor(ms / 1000);
if (total <= 0) return null;
const pad = (n: number) => String(n).padStart(2, '0');
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const seconds = total % 60;
return hours > 0 ? `${hours}:${pad(minutes)}:${pad(seconds)}` : `${minutes}:${pad(seconds)}`;
};
const MONTH_YEAR = new Intl.DateTimeFormat(undefined, { month: 'long', year: 'numeric' });
const LONG_DATE = new Intl.DateTimeFormat(undefined, {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
});
const SHORT_DATE = new Intl.DateTimeFormat(undefined, { day: 'numeric', month: 'short', year: 'numeric' });
const TIME = new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' });
/**
* `2026-08-01` → `August 2026`.
*
* Parsed by hand, not with `new Date(bucket)`: a bare `YYYY-MM-DD` is read as UTC midnight, so formatting it
* in a timezone west of UTC lands on the last day of the PREVIOUS month and the header reads July.
*/
export const formatBucket = (bucket: string): string => {
const [year, month] = bucket.split('-').map(Number);
if (!year || !month) return bucket;
return MONTH_YEAR.format(new Date(year, month - 1, 1));
};
export const formatLongDate = (iso: string | null | undefined): string => {
if (!iso) return '—';
const date = new Date(iso);
return Number.isNaN(date.getTime()) ? '—' : LONG_DATE.format(date);
};
export const formatShortDate = (iso: string | null | undefined): string => {
if (!iso) return '—';
const date = new Date(iso);
return Number.isNaN(date.getTime()) ? '—' : SHORT_DATE.format(date);
};
export const formatTime = (iso: string | null | undefined): string => {
if (!iso) return '';
const date = new Date(iso);
return Number.isNaN(date.getTime()) ? '' : TIME.format(date);
};
/** `2020-01-05` + `2020-03-11` → `5 Jan 2020 – 11 Mar 2020`, collapsing to one when they match. */
export const formatDateRange = (start?: string, end?: string): string | null => {
if (!start && !end) return null;
const a = start ? formatShortDate(start) : null;
const b = end ? formatShortDate(end) : null;
if (a && b && a !== b) return `${a} – ${b}`;
return a ?? b;
};
// ── Justified rows ────────────────────────────────────────────────────────────────────────────────
export type LaidOutTile<T> = { item: T; width: number; height: number };
export type LaidOutRow<T> = { tiles: LaidOutTile<T>[]; height: number };
type JustifyParams<T> = {
items: T[];
ratioOf: (item: T) => number;
containerWidth: number;
targetHeight: number;
gap: number;
};
/**
* Immich's signature layout: variable-width tiles packed into rows of equal height, each row filling the
* container exactly, so nothing is cropped and there are no ragged gaps.
*
* Greedy — take tiles at the target height until the row overflows, then scale the row down to fit. The last
* row is left at the target height rather than stretched, because stretching four holiday photos across a
* 2000px viewport makes them enormous.
*/
export function justifyRows<T>({
items,
ratioOf,
containerWidth,
targetHeight,
gap,
}: JustifyParams<T>): LaidOutRow<T>[] {
if (containerWidth <= 0 || items.length === 0) return [];
// A panorama can be wider than the container on its own; clamp so one asset can't own a 12-wide row. The
// SAME clamped value has to drive both the row's ratio sum and the tile's width, or the row is scaled to fit
// a total it then overflows.
const clampRatio = (item: T) => Math.min(Math.max(ratioOf(item) || 1, 0.2), 5);
const rows: LaidOutRow<T>[] = [];
let current: T[] = [];
let ratioSum = 0;
const flush = (stretch: boolean) => {
if (current.length === 0) return;
const available = containerWidth - gap * (current.length - 1);
const height = stretch ? available / ratioSum : targetHeight;
rows.push({
height,
tiles: current.map((item) => ({ item, width: Math.max(1, clampRatio(item) * height), height })),
});
current = [];
ratioSum = 0;
};
for (const item of items) {
const ratio = clampRatio(item);
current.push(item);
ratioSum += ratio;
const width = ratioSum * targetHeight + gap * (current.length - 1);
if (width >= containerWidth) flush(true);
}
flush(false);
return rows;
}
/** Estimated pixel height of an unloaded bucket, so the scrollbar is roughly honest before it loads. */
type EstimateParams = { count: number; containerWidth: number; rowHeight: number; gap: number };
export const estimateBucketHeight = ({ count, containerWidth, rowHeight, gap }: EstimateParams): number => {
const perRow = Math.max(1, Math.round(containerWidth / (rowHeight * 1.35)));
const rows = Math.ceil(count / perRow);
return rows * (rowHeight + gap);
};