/** * 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; 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'; /** Enriched subpage with album metadata (for rendering cards). */ export interface SubpageSummary { name: string; slug: string; albumCount: number; totalAssetCount: number; coverAssetId: string | null; } // Module-level flag so the album summary is only logged once even if the // ImmichClient singleton is replaced in hot-reload scenarios. let _hasLoggedAlbums = false; // ── API Client ───────────────────────────────────────────────── class ImmichClient { private get config() { return getConfig(); } /** * Make an authenticated request to the Immich API. */ private async request(endpoint: string): Promise { const url = `${this.config.immich.apiUrl}${endpoint}`; try { const res = await fetch(url, { headers: { 'x-api-key': this.config.immich.apiKey, Accept: 'application/json', }, }); if (!res.ok) { console.error(`[Immich] ${res.status} ${res.statusText} for ${endpoint}`); return null; } const contentType = res.headers.get('Content-Type') || ''; if (contentType.includes('application/json')) { return (await res.json()) as T; } return null; } catch (error) { console.error(`[Immich] Failed to reach ${url}:`, error); return null; } } /** * Stream a binary response from Immich (for image proxying). */ async streamAsset( assetId: string, size: ImageSize = 'preview', ): Promise<{ stream: ReadableStream; contentType: string; contentLength: string | null } | null> { const endpoint = size === 'original' ? `/assets/${encodeURIComponent(assetId)}/original` : `/assets/${encodeURIComponent(assetId)}/thumbnail?size=${size}`; const url = `${this.config.immich.apiUrl}${endpoint}`; try { const res = await fetch(url, { headers: { 'x-api-key': this.config.immich.apiKey, }, }); if (!res.ok || !res.body) { console.error(`[Immich] Failed to stream ${assetId}: ${res.status}`); return null; } return { stream: res.body, contentType: res.headers.get('Content-Type') || 'application/octet-stream', contentLength: res.headers.get('Content-Length'), }; } catch (error) { console.error(`[Immich] Stream error for ${assetId}:`, error); return null; } } /** * Get ALL configured albums (filtered by the full allowlist). * Uses ?shared=true to only fetch albums that have been shared in Immich. */ async getAlbums(): Promise { const cacheKey = 'albums-list'; const cached = cache.get(cacheKey); if (cached) return cached; const all = await this.request('/albums?shared=true'); if (!all) return []; const allowedIds = new Set(this.config.albums); const filtered = all .filter((album) => allowedIds.has(album.id)) .map((album) => { const name = this.config.albumOverrides[album.id] ?? album.albumName; return { ...album, albumName: name, slug: slugify(name), }; }); // Log album summary on first load so admins can see what's published if (!_hasLoggedAlbums) { _hasLoggedAlbums = true; console.log('\n[Lightbox] Published albums:'); console.log('─'.repeat(80)); // Log standalone albums const standaloneIds = new Set(this.config.standaloneAlbums); const standalone = filtered.filter((a) => standaloneIds.has(a.id)); if (standalone.length > 0) { console.log(' Standalone:'); for (const a of standalone) { console.log(` 📷 ${a.albumName}`); console.log(` URL: /${a.slug} • ${a.assetCount} photos • ID: ${a.id}`); } } // Log subpage groupings for (const sp of this.config.subpages) { const spAlbums = filtered.filter((a) => sp.albumIds.includes(a.id)); console.log(` 📁 ${sp.name} (/${sp.slug}):`); for (const a of spAlbums) { console.log(` 📷 ${a.albumName}`); console.log(` URL: /${sp.slug}/${a.slug} • ${a.assetCount} photos`); } } const missing = this.config.albums.filter((id) => !all.some((a) => a.id === id)); if (missing.length > 0) { console.warn(` ⚠️ Unknown album IDs: ${missing.join(', ')}`); } console.log('─'.repeat(80) + '\n'); } cache.set(cacheKey, filtered, this.config.cacheTtl); return filtered; } /** * Get only standalone albums (not in any subpage) — for the homepage. */ async getStandaloneAlbums(): Promise { const albums = await this.getAlbums(); const standaloneIds = new Set(this.config.standaloneAlbums); return albums.filter((a) => standaloneIds.has(a.id)); } /** * Get enriched subpage summaries for the homepage. */ async getSubpages(): Promise { if (this.config.subpages.length === 0) return []; const albums = await this.getAlbums(); const albumMap = new Map(albums.map((a) => [a.id, a])); return this.config.subpages.map((sp) => { const spAlbums = sp.albumIds .map((id) => albumMap.get(id)) .filter((a): a is ImmichAlbum => a !== undefined); return { name: sp.name, slug: sp.slug, albumCount: spAlbums.length, totalAssetCount: spAlbums.reduce((sum, a) => sum + a.assetCount, 0), coverAssetId: spAlbums[0]?.albumThumbnailAssetId ?? null, }; }); } /** * Get albums belonging to a specific subpage. */ async getSubpageAlbums( subpageSlug: string, ): Promise<{ subpage: SubpageConfig; albums: ImmichAlbum[] } | null> { const subpage = this.config.subpages.find((sp) => sp.slug === subpageSlug); if (!subpage) return null; const allAlbums = await this.getAlbums(); const subpageAlbumIds = new Set(subpage.albumIds); const albums = allAlbums.filter((a) => subpageAlbumIds.has(a.id)); return { subpage, albums }; } /** * Get a single album with its assets. */ async getAlbum(albumId: string): Promise { // Security: only serve configured albums if (!this.config.albums.includes(albumId)) { console.warn(`[Immich] Album ${albumId} is not in LIGHTBOX_ALBUMS`); return null; } const cacheKey = `album-${albumId}`; const cached = cache.get(cacheKey); if (cached) return cached; const album = await this.request(`/albums/${encodeURIComponent(albumId)}`); if (!album) return null; // Filter out trashed assets album.assets = (album.assets || []).filter((a) => !a.isTrashed); const name = this.config.albumOverrides[album.id] ?? album.albumName; album.albumName = name; album.slug = slugify(name); cache.set(cacheKey, album, this.config.cacheTtl); return album; } /** * Find an album by its URL slug. * If subpageSlug is provided, only search within that subpage's albums. */ async getAlbumBySlug(slug: string, subpageSlug?: string): Promise { const albums = await this.getAlbums(); let searchSet = albums; if (subpageSlug) { const subpage = this.config.subpages.find((sp) => sp.slug === subpageSlug); if (!subpage) return null; const subpageIds = new Set(subpage.albumIds); searchSet = albums.filter((a) => subpageIds.has(a.id)); } const match = searchSet.find((a) => a.slug === slug); if (!match) return null; return this.getAlbum(match.id); } /** * Check if a slug corresponds to a subpage. */ isSubpageSlug(slug: string): boolean { return this.config.subpages.some((sp) => sp.slug === slug); } /** * Get asset info including EXIF data. */ async getAssetInfo(assetId: string): Promise { const cacheKey = `asset-${assetId}`; const cached = cache.get(cacheKey); if (cached) return cached; const asset = await this.request(`/assets/${encodeURIComponent(assetId)}`); if (!asset) return null; cache.set(cacheKey, asset, this.config.cacheTtl); return asset; } /** * Check if the Immich server is reachable. */ async ping(): Promise { const res = await this.request<{ res: string }>('/server/ping'); return !!res; } } export const immich = new ImmichClient();