/** * 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. */ import { getConfig, albumSlug, normalizeSlug, type SubpageConfig } from './config'; import { cache } from './cache'; import { compareByCaptureTime, sortAlbumAssets, DEFAULT_ALBUM_SORT } from './albumSort'; // ── 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'; /** * Thrown when Immich could not answer: transport failure, an error status, or * a non-JSON body where JSON was requested. * * This is deliberately distinct from a `null` return, which means Immich *did* * answer and said the resource does not exist. Conflating the two made every * album URL render a hard 404 while Immich was down — including albums that * exist — because the page calls `notFound()` on a null album. Only 404/410 * are treated as "gone"; everything else is an outage and must surface as one. */ /** * Cache sentinel for "Immich answered, and the resource does not exist". * * The cache cannot tell a miss from a stored null — both read back as null — so * absence is recorded as a distinct object identity instead. * * Only definitive 404/410 answers are stored. An outage throws * `ImmichUnavailableError` and is never cached: pinning one would keep the * gallery broken long after Immich recovered. * * Stored under the normal `cacheTtl` rather than a shorter negative TTL. A 404 * from Immich is as authoritative as a 200, and both invalidation paths * (the Immich webhook and the admin panel's save/reload) clear it immediately — * so a corrected asset ID takes effect at once rather than waiting out a TTL. */ const MISSING = Object.freeze({ __immichMissing: true }); type Missing = typeof MISSING; /** * `AbortSignal.timeout()` rejects with a `TimeoutError`; an explicit * `controller.abort()` rejects with an `AbortError`. Both mean we gave up * waiting, and both arrive as a plain DOMException. */ function isTimeout(error: unknown): boolean { return error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError'); } export class ImmichUnavailableError extends Error { readonly status?: number; constructor(message: string, status?: number) { super(message); this.name = 'ImmichUnavailableError'; this.status = status; } } /** Enriched subpage with album metadata (for rendering cards). */ export interface SubpageSummary { name: string; slug: string; albumCount: number; totalAssetCount: number; coverAssetId: string | null; } // Note: Album summary logging state is kept in the client instance. // ── API Client ───────────────────────────────────────────────── class ImmichClient { private hasLoggedAlbums = false; private hasWarnedNoCredentials = false; private pendingAlbumsPromise: Promise | null = null; private pendingAlbumPromises = new Map>(); private pendingAssetPromises = new Map>(); 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 get hasCredentials(): boolean { const { apiUrl, apiKey } = this.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): void { cache.set(key, data, this.config.cacheTtl, this.config.staleMaxAge); } /** * Last resort when Immich is unavailable: hand back the most recent known * good answer rather than failing the page. Only successes ever reach the * cache, so this cannot resurrect an outage — and past staleMaxAge the entry * is gone and the error propagates as before. */ private staleOrThrow(cacheKey: string, error: unknown, label: string): T { if (error instanceof ImmichUnavailableError) { const stale = cache.getStale(cacheKey); if (stale !== null) { console.warn(`[Immich] ⚠️ Upstream unavailable — serving stale ${label}`); return stale; } } throw error; } private async request(endpoint: string, body?: unknown): Promise { if (!this.hasCredentials) { this.warnNoCredentials(endpoint); return null; } const url = `${this.config.immich.apiUrl}${endpoint}`; let res: Response; try { res = await fetch(url, { method: body === undefined ? 'GET' : 'POST', headers: { 'x-api-key': this.config.immich.apiKey, Accept: 'application/json', ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), }, ...(body === undefined ? {} : { body: JSON.stringify(body) }), // JSON payloads are small, so one budget can cover the whole exchange. // Without it the only ceiling is undici's default, measured in minutes. signal: AbortSignal.timeout(this.config.immichTimeoutMs), }); } catch (error) { console.error(`[Immich] Failed to reach ${url}:`, error); throw new ImmichUnavailableError( isTimeout(error) ? `Immich did not respond within ${this.config.immichTimeoutMs}ms for ${endpoint}` : `Cannot reach Immich for ${endpoint}`, ); } if (!res.ok) { console.error(`[Immich] ${res.status} ${res.statusText} for ${endpoint}`); // Only "gone" means gone. A 5xx, a rate limit, or a rejected API key all // mean Immich cannot serve us right now — rendering those as a missing // album would tell visitors and crawlers the content no longer exists. if (res.status !== 404 && res.status !== 410) { throw new ImmichUnavailableError( `Immich returned ${res.status} ${res.statusText} for ${endpoint}`, res.status, ); } return null; } const contentType = res.headers.get('Content-Type') || ''; if (!contentType.includes('application/json')) { // We always send Accept: application/json. Anything else is a gateway or // proxy error page, not a valid answer about the resource. throw new ImmichUnavailableError( `Immich returned non-JSON (${contentType || 'no Content-Type'}) for ${endpoint}`, ); } try { return (await res.json()) as T; } catch (error) { // The timeout also covers the body read, so an abort can surface here. if (isTimeout(error)) { console.error(`[Immich] Timed out reading ${url}`); throw new ImmichUnavailableError( `Immich did not respond within ${this.config.immichTimeoutMs}ms for ${endpoint}`, ); } console.error(`[Immich] Malformed JSON from ${url}:`, error); throw new ImmichUnavailableError(`Immich returned malformed JSON for ${endpoint}`); } } /** * Stream a video from Immich with optional Range header forwarding. * Range support is required for `