/** * Server-only Immich API client. * Inspired by immich-public-proxy's approach but uses API key auth * to access specific albums rather than shared link keys. * * API key never leaves the server — all client-facing image * requests go through our proxy route. * * What this file is about is *what* Folio asks Immich for: albums, subpages, * assets, EXIF. How the asking happens lives in ./immichTransport, and when a * cached answer may still be served in ./immichCache (#610). */ import { getConfig, albumSlug, normalizeSlug, type AppConfig, type SubpageConfig } from './config'; import { cache } from './cache'; import { compareByCaptureTime, sortAlbumAssets, DEFAULT_ALBUM_SORT } from './albumSort'; import { ImmichUnavailableError, isTimeout, requestJson } from './immichTransport'; import { MISSING, type Missing, cacheSet as cacheSetWithStale, staleOrThrow as serveStaleOrThrow, staleOrMissing as serveStaleOrMissing, } from './immichCache'; // Part of this module's public surface since before the split; six call sites // import it from here. export { ImmichUnavailableError }; // ── Types ────────────────────────────────────────────────────── export interface ImmichAlbum { id: string; slug: string; albumName: string; description: string; albumThumbnailAssetId: string | null; assetCount: number; assets: ImmichAsset[]; createdAt: string; updatedAt: string; order: 'asc' | 'desc'; } export interface ImmichAsset { id: string; type: 'IMAGE' | 'VIDEO'; originalFileName: string; originalMimeType: string; thumbhash: string | null; fileCreatedAt: string; /** Capture time in the photographer's local zone — Immich's timeline sort key. */ localDateTime?: string; exifInfo?: ImmichExifInfo; isTrashed: boolean; /** Only surfaced in the admin pickers. Optional: older Immich responses omit it. */ isFavorite?: boolean; } export interface ImmichExifInfo { make: string | null; model: string | null; lensModel: string | null; focalLength: number | null; fNumber: number | null; exposureTime: string | null; iso: number | null; exifImageWidth: number | null; exifImageHeight: number | null; /** * EXIF orientation flag, as a numeric string ('1'..'8'). * * Optional: Immich only fills it when the file carries the tag, and older * responses omit the field entirely. */ orientation?: string | null; latitude: number | null; longitude: number | null; city: string | null; state: string | null; country: string | null; dateTimeOriginal: string | null; description: string | null; } export type ImageSize = 'thumbnail' | 'preview' | 'original'; /** Enriched subpage with album metadata (for rendering cards). */ export interface SubpageSummary { name: string; /** The display title from gallery.yaml; visitors see this, `name` only as fallback. */ title?: string; slug: string; albumCount: number; totalAssetCount: number; coverAssetId: string | null; } // Note: Album summary logging state is kept in the client instance. /** Immich's album list as cached, with the allowlist it was fetched for. */ interface CachedAlbumList { albums: ImmichAlbum[]; fetchedFor: string; } function allowlistKey(config: AppConfig): string { return [...config.albums].sort().join(','); } // ── API Client ───────────────────────────────────────────────── class ImmichClient { private hasLoggedAlbums = false; private hasWarnedNoCredentials = false; private pendingAlbumsPromise: Promise | null = null; private pendingAlbumPromises = new Map>(); private pendingAssetPromises = new Map>(); /** * getConfig() re-checks the content files and hands back a fresh copy on * every call, so a method reads it once into a local and uses that, rather * than going through this getter for each property. */ private get config() { return getConfig(); } /** * Whether a request to Immich is possible at all. * * Deliberately *not* `needsSetup`: a deployment with credentials but no * gallery.yaml can talk to Immich perfectly well, and the admin panel depends * on that to browse albums before the file exists. Gating on `needsSetup` * made every call return null before the fetch, so `ping()` reported the * server as disconnected without a single line in the log (#507). */ private hasCredentials(config: AppConfig): boolean { const { apiUrl, apiKey } = config.immich; return !!apiUrl && !!apiKey; } /** Says once why nothing is being requested, rather than failing in silence. */ private warnNoCredentials(what: string): void { if (this.hasWarnedNoCredentials) return; this.hasWarnedNoCredentials = true; console.warn( `[Immich] No API URL or API key configured — skipping ${what} and every ` + 'later request. Set IMMICH_API_URL and IMMICH_API_KEY, or run the setup wizard at /install.', ); } /** Cache write that carries the configured stale window. */ private cacheSet(key: string, data: T, config: AppConfig = this.config): void { cacheSetWithStale(key, data, config.cacheTtl, config.staleMaxAge); } /** See ./immichCache — the policy lives there, testable on its own. */ private staleOrThrow(cacheKey: string, error: unknown, label: string): T { return serveStaleOrThrow(cacheKey, error, label); } /** For keys that may hold MISSING — a single album or asset. See ./immichCache. */ private staleOrMissing(cacheKey: string, error: unknown, label: string): T | null { return serveStaleOrMissing(cacheKey, error, label); } /** * One JSON request to Immich, with the credentials check in front of it. * * The request itself lives in ./immichTransport; what belongs here is the * decision not to make one at all, which is a question about this * deployment rather than about HTTP. */ private async request(endpoint: string, body?: unknown): Promise { const config = this.config; if (!this.hasCredentials(config)) { this.warnNoCredentials(endpoint); return null; } return requestJson({ apiUrl: config.immich.apiUrl, apiKey: config.immich.apiKey, timeoutMs: config.immichTimeoutMs, endpoint, body, }); } /** * Stream a video from Immich with optional Range header forwarding. * Range support is required for `