Twenty-nine commits since v0.12.0. See CHANGELOG.md for the full list and the upgrade notes. Ten additions — the SEO package (site URL, robots.txt, sitemap.xml, JSON-LD), slideshow mode, opt-in original downloads, copy-link, previous/next album navigation, alt text from the Immich description, per-album location precision, the update notice, a Help tab, and a subpage cover grid separate from its photo grid. Two fixes: CJK album slugs, and the lightbox control bar. Photo of the Day (#476) was built on `dev` and reverted as not planned; it never appears in a release. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01DmsHVRL9yQvckUB1ZpVWre --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: ralksta <ralksta@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
829 lines
30 KiB
TypeScript
829 lines
30 KiB
TypeScript
/**
|
|
* 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;
|
|
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<ImmichAlbum[]> | null = null;
|
|
private pendingAlbumPromises = new Map<string, Promise<ImmichAlbum | null>>();
|
|
private pendingAssetPromises = new Map<string, Promise<ImmichAsset | null>>();
|
|
|
|
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<T>(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<T>(cacheKey: string, error: unknown, label: string): T {
|
|
if (error instanceof ImmichUnavailableError) {
|
|
const stale = cache.getStale<T>(cacheKey);
|
|
if (stale !== null) {
|
|
console.warn(`[Immich] ⚠️ Upstream unavailable — serving stale ${label}`);
|
|
return stale;
|
|
}
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
private async request<T>(endpoint: string, body?: unknown): Promise<T | null> {
|
|
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 `<video>` seeking in browsers.
|
|
*/
|
|
async streamVideo(
|
|
assetId: string,
|
|
rangeHeader?: string | null,
|
|
): Promise<{
|
|
stream: ReadableStream;
|
|
contentType: string;
|
|
contentLength: string | null;
|
|
contentRange: string | null;
|
|
status: 200 | 206;
|
|
} | null> {
|
|
if (!this.hasCredentials) return null;
|
|
|
|
const endpoint = `/assets/${encodeURIComponent(assetId)}/video/playback`;
|
|
const url = `${this.config.immich.apiUrl}${endpoint}`;
|
|
|
|
// Bound the wait for response *headers* only. The `finally` clears the timer
|
|
// the moment they arrive, so the body may then stream for as long as it
|
|
// needs — a whole-request timeout would truncate playback mid-video and
|
|
// break seeking, since every range request would restart the clock.
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), this.config.immichTimeoutMs);
|
|
|
|
try {
|
|
const headers: Record<string, string> = {
|
|
'x-api-key': this.config.immich.apiKey,
|
|
};
|
|
if (rangeHeader) {
|
|
headers['Range'] = rangeHeader;
|
|
}
|
|
|
|
const res = await fetch(url, { headers, signal: controller.signal });
|
|
|
|
if (!res.ok && res.status !== 206) {
|
|
console.error(`[Immich] Failed to stream video ${assetId}: ${res.status}`);
|
|
if (res.status !== 404 && res.status !== 410) {
|
|
throw new ImmichUnavailableError(
|
|
`Immich returned ${res.status} streaming video ${assetId}`,
|
|
res.status,
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
if (!res.body) {
|
|
throw new ImmichUnavailableError(`Immich returned an empty body for video ${assetId}`);
|
|
}
|
|
|
|
return {
|
|
stream: res.body,
|
|
contentType: res.headers.get('Content-Type') || 'video/mp4',
|
|
contentLength: res.headers.get('Content-Length'),
|
|
contentRange: res.headers.get('Content-Range'),
|
|
status: res.status === 206 ? 206 : 200,
|
|
};
|
|
} catch (error) {
|
|
if (error instanceof ImmichUnavailableError) throw error;
|
|
console.error(
|
|
isTimeout(error)
|
|
? `[Immich] Video ${assetId} did not respond within ${this.config.immichTimeoutMs}ms`
|
|
: `[Immich] Video stream error for ${assetId}:`,
|
|
error,
|
|
);
|
|
throw new ImmichUnavailableError(
|
|
isTimeout(error)
|
|
? `Immich did not respond within ${this.config.immichTimeoutMs}ms for video ${assetId}`
|
|
: `Cannot reach Immich to stream video ${assetId}`,
|
|
);
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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> {
|
|
if (!this.hasCredentials) return null;
|
|
|
|
const endpoint =
|
|
size === 'original'
|
|
? `/assets/${encodeURIComponent(assetId)}/original`
|
|
: `/assets/${encodeURIComponent(assetId)}/thumbnail?size=${size}`;
|
|
|
|
const url = `${this.config.immich.apiUrl}${endpoint}`;
|
|
|
|
// Headers-only timeout, same reasoning as streamVideo: an original-size
|
|
// photo is legitimately slow to transfer and must not be capped.
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), this.config.immichTimeoutMs);
|
|
|
|
try {
|
|
const res = await fetch(url, {
|
|
headers: {
|
|
'x-api-key': this.config.immich.apiKey,
|
|
},
|
|
signal: controller.signal,
|
|
});
|
|
|
|
if (!res.ok) {
|
|
console.error(`[Immich] Failed to stream ${assetId}: ${res.status}`);
|
|
if (res.status !== 404 && res.status !== 410) {
|
|
throw new ImmichUnavailableError(
|
|
`Immich returned ${res.status} streaming asset ${assetId}`,
|
|
res.status,
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
if (!res.body) {
|
|
throw new ImmichUnavailableError(`Immich returned an empty body for asset ${assetId}`);
|
|
}
|
|
|
|
return {
|
|
stream: res.body,
|
|
contentType: res.headers.get('Content-Type') || 'application/octet-stream',
|
|
contentLength: res.headers.get('Content-Length'),
|
|
};
|
|
} catch (error) {
|
|
if (error instanceof ImmichUnavailableError) throw error;
|
|
console.error(
|
|
isTimeout(error)
|
|
? `[Immich] Asset ${assetId} did not respond within ${this.config.immichTimeoutMs}ms`
|
|
: `[Immich] Stream error for ${assetId}:`,
|
|
error,
|
|
);
|
|
throw new ImmichUnavailableError(
|
|
isTimeout(error)
|
|
? `Immich did not respond within ${this.config.immichTimeoutMs}ms for asset ${assetId}`
|
|
: `Cannot reach Immich to stream asset ${assetId}`,
|
|
);
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get ALL configured albums (filtered by the full allowlist).
|
|
* Uses ?shared=true to only fetch albums that have been shared in Immich.
|
|
*/
|
|
async getAlbums(forceFresh = false): Promise<ImmichAlbum[]> {
|
|
const cacheKey = 'albums-list';
|
|
if (forceFresh) {
|
|
cache.delete(cacheKey);
|
|
} else {
|
|
const cached = cache.get<ImmichAlbum[]>(cacheKey);
|
|
if (cached) return cached;
|
|
}
|
|
|
|
if (this.pendingAlbumsPromise) {
|
|
return this.pendingAlbumsPromise;
|
|
}
|
|
|
|
this.pendingAlbumsPromise = (async () => {
|
|
try {
|
|
const all = await this.request<ImmichAlbum[]>('/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;
|
|
const description = this.config.albumDescriptions[album.id] ?? album.description ?? '';
|
|
return {
|
|
...album,
|
|
albumName: name,
|
|
description,
|
|
slug: albumSlug(name, album.id),
|
|
};
|
|
});
|
|
|
|
// Log album summary on first load so admins can see what's published
|
|
if (!this.hasLoggedAlbums) {
|
|
this.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');
|
|
}
|
|
|
|
// Not while the install is unfinished. The dummy setup config carries an
|
|
// empty allowlist, so `filtered` is [] no matter what Immich returned —
|
|
// and caching that outlives the wizard that fixes it, because
|
|
// invalidateAll() runs in the install route's own module instance
|
|
// (Next bundles each route separately; see lib/install.ts). The gallery
|
|
// then looks empty until the server restarts.
|
|
if (!this.config.needsSetup) {
|
|
this.cacheSet(cacheKey, filtered);
|
|
}
|
|
return filtered;
|
|
} catch (error) {
|
|
return this.staleOrThrow<ImmichAlbum[]>(cacheKey, error, 'album list');
|
|
} finally {
|
|
this.pendingAlbumsPromise = null;
|
|
}
|
|
})();
|
|
|
|
return this.pendingAlbumsPromise;
|
|
}
|
|
|
|
/**
|
|
* Get only standalone albums (not in any subpage) — for the homepage.
|
|
*/
|
|
async getStandaloneAlbums(forceFresh = false): Promise<ImmichAlbum[]> {
|
|
const albums = await this.getAlbums(forceFresh);
|
|
// The API returns albums in its own order; gallery.yaml order wins.
|
|
const orderIdx = new Map(this.config.standaloneAlbums.map((id, i) => [id, i]));
|
|
return albums
|
|
.filter((a) => orderIdx.has(a.id))
|
|
.sort((a, b) => orderIdx.get(a.id)! - orderIdx.get(b.id)!);
|
|
}
|
|
|
|
/**
|
|
* Get enriched subpage summaries for the homepage.
|
|
*/
|
|
async getSubpages(forceFresh = false): Promise<SubpageSummary[]> {
|
|
// hidden (EXPERIMENTAL) drops a subpage from every list this feeds (nav,
|
|
// homepage, hero) while getSubpageAlbums()/isValidSubpage() still resolve
|
|
// it — unlike enabled:false, which 404s the page entirely.
|
|
const activeSubpages = this.config.subpages.filter(
|
|
(sp) => sp.enabled !== false && sp.hidden !== true,
|
|
);
|
|
if (activeSubpages.length === 0) return [];
|
|
|
|
const albums = await this.getAlbums(forceFresh);
|
|
const albumMap = new Map(albums.map((a) => [a.id, a]));
|
|
|
|
return activeSubpages.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,
|
|
forceFresh = false,
|
|
): Promise<{ subpage: SubpageConfig; albums: ImmichAlbum[] } | null> {
|
|
const wanted = normalizeSlug(subpageSlug);
|
|
const subpage = this.config.subpages.find((sp) => sp.slug === wanted && sp.enabled !== false);
|
|
if (!subpage) return null;
|
|
|
|
const allAlbums = await this.getAlbums(forceFresh);
|
|
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.
|
|
*/
|
|
/**
|
|
* Fetch every asset belonging to an album.
|
|
*
|
|
* Immich 3.x no longer embeds assets in `GET /albums/:id` — the response
|
|
* still carries `assetCount` but `assets` comes back empty, and
|
|
* `?withoutAssets=false` does not change that. Metadata search is the
|
|
* supported replacement.
|
|
*
|
|
* `withExif` is required: without it the response omits `exifInfo`, and the
|
|
* grid (aspect ratios), the lightbox EXIF panel and the map (GPS) all go
|
|
* blank even though the images themselves load.
|
|
*/
|
|
private async fetchAlbumAssets(albumId: string): Promise<ImmichAsset[]> {
|
|
const PAGE_SIZE = 1000; // Immich rejects size > 1000 with a validation error
|
|
const MAX_PAGES = 100; // Backstop against a malformed nextPage looping forever
|
|
const assets: ImmichAsset[] = [];
|
|
let page = 1;
|
|
|
|
for (let i = 0; i < MAX_PAGES; i++) {
|
|
const res = await this.request<{
|
|
assets?: { items?: ImmichAsset[]; nextPage?: string | null };
|
|
}>('/search/metadata', {
|
|
albumIds: [albumId],
|
|
withExif: true,
|
|
size: PAGE_SIZE,
|
|
page,
|
|
});
|
|
|
|
const items = res?.assets?.items;
|
|
if (!items?.length) break;
|
|
assets.push(...items);
|
|
|
|
// nextPage is a string page number, or null on the last page. The
|
|
// response's `total` only counts the current page, so it cannot bound
|
|
// this loop.
|
|
const next = Number(res?.assets?.nextPage);
|
|
if (!Number.isFinite(next) || next <= page) break;
|
|
page = next;
|
|
}
|
|
|
|
return assets;
|
|
}
|
|
|
|
/**
|
|
* Every asset of an album, in the album's canonical Immich order, bypassing
|
|
* the allowlist.
|
|
*
|
|
* For the admin panel only. `getAlbum()` refuses albums that are not in the
|
|
* saved config, which is right for the public site but wrong for the page
|
|
* builder: an album just dragged in has not been saved yet, so the picker
|
|
* and the reorder editor would come up empty. Admin auth is the gate here.
|
|
*/
|
|
async getAlbumAssetsRaw(albumId: string): Promise<ImmichAsset[]> {
|
|
const [album, assets] = await Promise.all([
|
|
this.request<ImmichAlbum>(`/albums/${encodeURIComponent(albumId)}`),
|
|
this.fetchAlbumAssets(albumId),
|
|
]);
|
|
if (!album) return [];
|
|
|
|
// Same order the site would render under `sort: immich`. The reorder
|
|
// editor's baseline has to match it exactly, or the assets it shows as
|
|
// "follows automatically" would not be the ones that actually follow.
|
|
return assets
|
|
.filter((a) => !a.isTrashed)
|
|
.sort(compareByCaptureTime(album.order === 'asc' ? 1 : -1));
|
|
}
|
|
|
|
/**
|
|
* Apply the album's configured sort on the way out.
|
|
*
|
|
* Deliberately not folded into the cached load: the LRU holds one canonical
|
|
* Immich-ordered copy per album, so the mode never has to enter the cache key
|
|
* and a config change takes effect without depending on invalidation.
|
|
*
|
|
* The clone is load-bearing. `cache.get()` hands back the stored object by
|
|
* reference, so sorting `album.assets` here would permanently reorder the
|
|
* cached entry — and under request coalescing every concurrent caller would
|
|
* be sorting the same array.
|
|
*/
|
|
private withSort(album: ImmichAlbum): ImmichAlbum {
|
|
const mode = this.config.albumSortModes[album.id] ?? DEFAULT_ALBUM_SORT;
|
|
return {
|
|
...album,
|
|
assets: sortAlbumAssets(album.assets, {
|
|
mode,
|
|
immichOrder: album.order,
|
|
manualOrder: this.config.albumManualOrders[album.id],
|
|
}),
|
|
};
|
|
}
|
|
|
|
async getAlbum(albumId: string, forceFresh = false): Promise<ImmichAlbum | null> {
|
|
const album = await this.loadAlbum(albumId, forceFresh);
|
|
return album ? this.withSort(album) : null;
|
|
}
|
|
|
|
/** The cached load. Always yields the canonical Immich order; see withSort(). */
|
|
private async loadAlbum(albumId: string, forceFresh: boolean): Promise<ImmichAlbum | null> {
|
|
// 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}`;
|
|
if (forceFresh) {
|
|
cache.delete(cacheKey);
|
|
} else {
|
|
const cached = cache.get<ImmichAlbum | Missing>(cacheKey);
|
|
if (cached) return cached === MISSING ? null : (cached as ImmichAlbum);
|
|
}
|
|
|
|
if (this.pendingAlbumPromises.has(albumId)) {
|
|
return this.pendingAlbumPromises.get(albumId)!;
|
|
}
|
|
|
|
const promise = (async () => {
|
|
try {
|
|
// Metadata and assets come from two different endpoints (see
|
|
// fetchAlbumAssets); they are independent, so fetch them together.
|
|
const [album, assets] = await Promise.all([
|
|
this.request<ImmichAlbum>(`/albums/${encodeURIComponent(albumId)}`),
|
|
this.fetchAlbumAssets(albumId),
|
|
]);
|
|
if (!album) {
|
|
cache.set(cacheKey, MISSING, this.config.cacheTtl);
|
|
return null;
|
|
}
|
|
|
|
// An album that Immich says has photos but returns none is almost
|
|
// always a server older than 3.0, where metadata search behaves
|
|
// differently. Say so instead of rendering a silently empty gallery.
|
|
if (album.assetCount > 0 && assets.length === 0) {
|
|
console.warn(
|
|
`[Immich] Album "${album.albumName}" reports ${album.assetCount} assets but the metadata search returned none. ` +
|
|
`Immich Folio requires Immich 3.0 or newer.`,
|
|
);
|
|
}
|
|
|
|
// Filter out trashed assets
|
|
album.assets = assets.filter((a) => !a.isTrashed);
|
|
|
|
// The album endpoint used to return assets in the album's configured
|
|
// order. Metadata search happens to default to fileCreatedAt desc, but
|
|
// that is not contractual — sort explicitly so 'asc' albums are right.
|
|
//
|
|
// This is the *canonical* order, and it is what goes into the cache.
|
|
// A configured per-album sort is applied later, on the way out of
|
|
// getAlbum(); see withSort().
|
|
album.assets.sort(compareByCaptureTime(album.order === 'asc' ? 1 : -1));
|
|
|
|
const name = this.config.albumOverrides[album.id] ?? album.albumName;
|
|
const description = this.config.albumDescriptions[album.id] ?? album.description ?? '';
|
|
album.albumName = name;
|
|
album.description = description;
|
|
album.slug = albumSlug(name, album.id);
|
|
|
|
this.cacheSet(cacheKey, album);
|
|
return album;
|
|
} catch (error) {
|
|
return this.staleOrThrow<ImmichAlbum>(cacheKey, error, `album ${albumId}`);
|
|
} finally {
|
|
this.pendingAlbumPromises.delete(albumId);
|
|
}
|
|
})();
|
|
|
|
this.pendingAlbumPromises.set(albumId, promise);
|
|
return promise;
|
|
}
|
|
|
|
/**
|
|
* Find an album by its URL slug.
|
|
* If subpageSlug is provided, only search within that subpage's albums.
|
|
*/
|
|
async getAlbumBySlug(
|
|
slug: string,
|
|
subpageSlug?: string,
|
|
forceFresh = false,
|
|
): Promise<ImmichAlbum | null> {
|
|
const albums = await this.getAlbums(forceFresh);
|
|
|
|
let searchSet = albums;
|
|
if (subpageSlug) {
|
|
const wantedSubpage = normalizeSlug(subpageSlug);
|
|
const subpage = this.config.subpages.find((sp) => sp.slug === wantedSubpage);
|
|
if (!subpage) return null;
|
|
const subpageIds = new Set(subpage.albumIds);
|
|
searchSet = albums.filter((a) => subpageIds.has(a.id));
|
|
}
|
|
|
|
const wanted = normalizeSlug(slug);
|
|
const match = searchSet.find((a) => a.slug === wanted);
|
|
if (!match) return null;
|
|
return this.getAlbum(match.id, forceFresh);
|
|
}
|
|
|
|
/**
|
|
* Check if a slug corresponds to a subpage.
|
|
*/
|
|
isSubpageSlug(slug: string): boolean {
|
|
const wanted = normalizeSlug(slug);
|
|
return this.config.subpages.some((sp) => sp.slug === wanted && sp.enabled !== false);
|
|
}
|
|
|
|
/**
|
|
* Get asset info including EXIF data.
|
|
*/
|
|
async getAssetInfo(assetId: string): Promise<ImmichAsset | null> {
|
|
const cacheKey = `asset-${assetId}`;
|
|
const cached = cache.get<ImmichAsset | Missing>(cacheKey);
|
|
if (cached) return cached === MISSING ? null : (cached as ImmichAsset);
|
|
|
|
// ⚡ Bolt: Deduplicate concurrent requests for the same asset ID.
|
|
// If a request for this asset is already in flight (e.g. from Promise.all in a grid),
|
|
// return the pending promise instead of triggering a redundant API call.
|
|
if (this.pendingAssetPromises.has(assetId)) {
|
|
return this.pendingAssetPromises.get(assetId)!;
|
|
}
|
|
|
|
const promise = (async () => {
|
|
try {
|
|
const asset = await this.request<ImmichAsset>(`/assets/${encodeURIComponent(assetId)}`);
|
|
if (!asset) {
|
|
// The homepage looks up every gallery.yaml hero ID on each render
|
|
// (app/page.tsx), and those pages are force-dynamic — so a hero photo
|
|
// deleted from Immich otherwise costs an upstream 404 every time.
|
|
cache.set(cacheKey, MISSING, this.config.cacheTtl);
|
|
return null;
|
|
}
|
|
|
|
this.cacheSet(cacheKey, asset);
|
|
return asset;
|
|
} catch (error) {
|
|
return this.staleOrThrow<ImmichAsset>(cacheKey, error, `asset ${assetId}`);
|
|
} finally {
|
|
this.pendingAssetPromises.delete(assetId);
|
|
}
|
|
})();
|
|
|
|
this.pendingAssetPromises.set(assetId, promise);
|
|
return promise;
|
|
}
|
|
|
|
/**
|
|
* Invalidate the cache for a specific album (by Immich UUID) and the
|
|
* shared albums-list entry. Called by the webhook handler after an
|
|
* album.updated / album.assetAdded event from Immich.
|
|
*/
|
|
invalidateAlbum(albumId: string): void {
|
|
cache.delete(`album-${albumId}`);
|
|
cache.delete('albums-list');
|
|
console.log(`[Immich] 🔄 Cache invalidated for album ${albumId}`);
|
|
}
|
|
|
|
/**
|
|
* Invalidate all album caches at once (e.g. on a full sync event).
|
|
*/
|
|
invalidateAll(): void {
|
|
cache.clear();
|
|
this.hasLoggedAlbums = false;
|
|
console.log('[Immich] 🔄 Full cache cleared');
|
|
}
|
|
|
|
/**
|
|
* Check if the Immich server is reachable.
|
|
*/
|
|
async ping(): Promise<boolean> {
|
|
try {
|
|
const res = await this.request<{ res: string }>('/server/ping');
|
|
return !!res;
|
|
} catch (error) {
|
|
// "Is Immich reachable?" — unreachable is the answer, not an exception.
|
|
// Both callers (/api/health, /api/admin/status) render this as a status.
|
|
// It is still logged: a red badge in the admin panel with nothing in the
|
|
// log leaves the operator guessing (#507).
|
|
console.warn('[Immich] Ping failed:', error);
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
export const immich = new ImmichClient();
|