// 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=`. */ export const ASSET_PARAM = 'asset'; /** Which album is open — `?album=`. */ export const ALBUM_PARAM = 'album'; /** Which person is open — `?person=`. */ 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 cannot send an Authorization header, so media URLs carry `?token=` instead — the same escape // hatch userMiddleware already supports for