/** * 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, slugify, type SubpageConfig } from './config'; import { cache } from './cache'; // ── 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; } 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; 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 pendingAlbumsPromise: Promise | null = null; private pendingAlbumPromises = new Map>(); private pendingAssetPromises = new Map>(); private get config() { return getConfig(); } /** 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.config.needsSetup) 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 `