release: v0.16.0

See CHANGELOG.md for the full v0.16.0 entry.

---------

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>
Co-authored-by: Ralf <rohlenbo79@gmail.com>
Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com>
Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com>
This commit is contained in:
Ralksta
2026-09-22 14:12:13 +02:00
committed by GitHub
parent c3f92ca4ec
commit a3a6319c29
120 changed files with 6818 additions and 975 deletions

View File

@@ -5,11 +5,27 @@
*
* 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 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 ──────────────────────────────────────────────────────
@@ -69,53 +85,6 @@ export interface ImmichExifInfo {
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;
@@ -166,93 +135,39 @@ class ImmichClient {
/** 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);
cacheSetWithStale(key, data, this.config.cacheTtl, this.config.staleMaxAge);
}
/** See ./immichCache — the policy lives there, testable on its own. */
private staleOrThrow<T>(cacheKey: string, error: unknown, label: string): T {
return serveStaleOrThrow<T>(cacheKey, error, label);
}
/** For keys that may hold MISSING — a single album or asset. See ./immichCache. */
private staleOrMissing<T>(cacheKey: string, error: unknown, label: string): T | null {
return serveStaleOrMissing<T>(cacheKey, error, label);
}
/**
* 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.
* 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 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}`);
}
return requestJson<T>({
apiUrl: this.config.immich.apiUrl,
apiKey: this.config.immich.apiKey,
timeoutMs: this.config.immichTimeoutMs,
endpoint,
body,
});
}
/**
@@ -293,6 +208,9 @@ class ImmichClient {
if (!res.ok && res.status !== 206) {
console.error(`[Immich] Failed to stream video ${assetId}: ${res.status}`);
// Neither branch below reads the body — an unconsumed one keeps its
// socket out of undici's pool until GC finalises it (#635).
await res.body?.cancel();
if (res.status !== 404 && res.status !== 410) {
throw new ImmichUnavailableError(
`Immich returned ${res.status} streaming video ${assetId}`,
@@ -362,6 +280,9 @@ class ImmichClient {
if (!res.ok) {
console.error(`[Immich] Failed to stream ${assetId}: ${res.status}`);
// Neither branch below reads the body — an unconsumed one keeps its
// socket out of undici's pool until GC finalises it (#635).
await res.body?.cancel();
if (res.status !== 404 && res.status !== 410) {
throw new ImmichUnavailableError(
`Immich returned ${res.status} streaming asset ${assetId}`,
@@ -404,9 +325,12 @@ class ImmichClient {
*/
async getAlbums(forceFresh = false): Promise<ImmichAlbum[]> {
const cacheKey = 'albums-list';
if (forceFresh) {
cache.delete(cacheKey);
} else {
// forceFresh only skips the read below; it must not delete the entry
// outright. Deleting first meant a request that then failed left every
// later visitor with nothing to fall back to — staleOrThrow() below reads
// from the same key, so the old entry has to survive until cacheSet()
// overwrites it on success.
if (!forceFresh) {
const cached = cache.get<ImmichAlbum[]>(cacheKey);
if (cached) return cached;
}
@@ -656,9 +580,10 @@ class ImmichClient {
}
const cacheKey = `album-${albumId}`;
if (forceFresh) {
cache.delete(cacheKey);
} else {
// See getAlbums(): forceFresh skips the cached read, it does not delete
// the entry, so a fetch that then fails still has something to fall back
// to via staleOrThrow() below.
if (!forceFresh) {
const cached = cache.get<ImmichAlbum | Missing>(cacheKey);
if (cached) return cached === MISSING ? null : (cached as ImmichAlbum);
}
@@ -676,7 +601,7 @@ class ImmichClient {
this.fetchAlbumAssets(albumId),
]);
if (!album) {
cache.set(cacheKey, MISSING, this.config.cacheTtl);
this.cacheSet(cacheKey, MISSING);
return null;
}
@@ -711,7 +636,7 @@ class ImmichClient {
this.cacheSet(cacheKey, album);
return album;
} catch (error) {
return this.staleOrThrow<ImmichAlbum>(cacheKey, error, `album ${albumId}`);
return this.staleOrMissing<ImmichAlbum>(cacheKey, error, `album ${albumId}`);
} finally {
this.pendingAlbumPromises.delete(albumId);
}
@@ -723,7 +648,12 @@ class ImmichClient {
/**
* Find an album by its URL slug.
* If subpageSlug is provided, only search within that subpage's albums.
*
* The search set follows the route, never the whole allowlist: with a
* subpageSlug it is that subpage's albums, without one it is the standalone
* albums. `config.albums` is the union of both, so searching it for a
* top-level slug would answer for an album whose only route is a subpage —
* past that subpage's password, and past `enabled: false`.
*/
async getAlbumBySlug(
slug: string,
@@ -732,14 +662,18 @@ class ImmichClient {
): Promise<ImmichAlbum | null> {
const albums = await this.getAlbums(forceFresh);
let searchSet = albums;
let routeIds: Set<string>;
if (subpageSlug) {
const wantedSubpage = normalizeSlug(subpageSlug);
const subpage = this.config.subpages.find((sp) => sp.slug === wantedSubpage);
const subpage = this.config.subpages.find(
(sp) => sp.slug === wantedSubpage && sp.enabled !== false,
);
if (!subpage) return null;
const subpageIds = new Set(subpage.albumIds);
searchSet = albums.filter((a) => subpageIds.has(a.id));
routeIds = new Set(subpage.albumIds);
} else {
routeIds = new Set(this.config.standaloneAlbums);
}
const searchSet = albums.filter((a) => routeIds.has(a.id));
const wanted = normalizeSlug(slug);
const match = searchSet.find((a) => a.slug === wanted);
@@ -777,14 +711,14 @@ class ImmichClient {
// 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);
this.cacheSet(cacheKey, MISSING);
return null;
}
this.cacheSet(cacheKey, asset);
return asset;
} catch (error) {
return this.staleOrThrow<ImmichAsset>(cacheKey, error, `asset ${assetId}`);
return this.staleOrMissing<ImmichAsset>(cacheKey, error, `asset ${assetId}`);
} finally {
this.pendingAssetPromises.delete(assetId);
}