From 0015a6d88d0d8f7c8a1c4e0ac4d33a8b3e5a9c10 Mon Sep 17 00:00:00 2001 From: Thomas Date: Mon, 20 Jul 2026 14:53:27 +0200 Subject: [PATCH] fix(immich): use thumbnail endpoint instead of original for album carousel (#6396) * fix(immich): use thumbnail endpoint instead of original for album carousel Switches from /api/assets/:id/original to /api/assets/:id/thumbnail to fix HEIC image incompatibility (thumbnails are always JPEG), reduce bandwidth, and improve loading speed. Closes #6388 * working on immich * Improve image proxy error handling * fix(image-proxy): use trusted fetch response type * docs(immich): require Asset View permission --- apps/docs/docs/integrations/immich/index.mdx | 2 +- .../src/app/api/image-proxy/[id]/route.ts | 18 ++++++--- packages/image-proxy/src/index.ts | 39 +++++++++++++++---- .../src/immich/immich-integration.ts | 5 ++- 4 files changed, 48 insertions(+), 16 deletions(-) diff --git a/apps/docs/docs/integrations/immich/index.mdx b/apps/docs/docs/integrations/immich/index.mdx index ed88466ce..f3bcded63 100644 --- a/apps/docs/docs/integrations/immich/index.mdx +++ b/apps/docs/docs/integrations/immich/index.mdx @@ -55,7 +55,7 @@ import { immichServerStatsWidget } from '../../widgets/immich-server-stats'; server.statistics,{" "} server.about.
- Additional permissions may be required depending on your Immich version. If you encounter authorization errors, grant any missing permissions requested by the server. + The Asset View permission is required for the Immich Album Carousel to load image thumbnails. Select it when creating the API key, or use the All permissions preset. Without it, Immich returns a 403 Forbidden response for thumbnail requests. ), diff --git a/apps/nextjs/src/app/api/image-proxy/[id]/route.ts b/apps/nextjs/src/app/api/image-proxy/[id]/route.ts index 26eb70dd8..1235d54a4 100644 --- a/apps/nextjs/src/app/api/image-proxy/[id]/route.ts +++ b/apps/nextjs/src/app/api/image-proxy/[id]/route.ts @@ -1,19 +1,25 @@ -import { notFound } from "next/navigation"; - import { ImageProxy } from "@homarr/image-proxy"; export const GET = async (_request: Request, props: { params: Promise<{ id: string }> }) => { const { id } = await props.params; const imageProxy = new ImageProxy(); - const image = await imageProxy.forwardImageAsync(id); - if (!image) { - notFound(); + const result = await imageProxy.forwardImageAsync(id); + + if ("error" in result) { + const status = result.error === "upstream-error" ? result.statusCode : result.error === "fetch-error" ? 502 : 404; + return new Response(null, { + status, + headers: { + "X-Homarr-Image-Proxy-Error": result.error, + }, + }); } - return new Response(image, { + return new Response(result.image, { headers: { "Cache-Control": "public, max-age=3600, immutable", // Cache for 1 hour + ...(result.contentType ? { "Content-Type": result.contentType } : {}), }, }); }; diff --git a/packages/image-proxy/src/index.ts b/packages/image-proxy/src/index.ts index 900509d38..445547992 100644 --- a/packages/image-proxy/src/index.ts +++ b/packages/image-proxy/src/index.ts @@ -19,6 +19,12 @@ const createUrlByIdChannel = (id: string) => headers: `${string}.${string}`; }>(`image-proxy:url:${id}`); +export type ForwardImageResult = + | { image: ArrayBuffer; contentType: string | null } + | { error: "not-found" } + | { error: "upstream-error"; statusCode: number } + | { error: "fetch-error" }; + export class ImageProxy { private static hmacKey: Buffer | null = null; @@ -47,17 +53,34 @@ export class ImageProxy { return this.createImageUrl(id); } - public async forwardImageAsync(id: string): Promise { + public async forwardImageAsync(id: string): Promise { const urlAndHeaders = await this.getImageUrlAndHeadersAsync(id); if (!urlAndHeaders) { - return null; + return { error: "not-found" }; } - const response = await fetchWithTrustedCertificatesAsync(urlAndHeaders.url, { - headers: urlAndHeaders.headers ?? {}, - }); - const proxyUrl = this.createImageUrl(id); + let response: Awaited>; + try { + response = await fetchWithTrustedCertificatesAsync(urlAndHeaders.url, { + headers: urlAndHeaders.headers ?? {}, + }); + } catch (error) { + logger.error( + new ErrorWithMetadata( + "Failed to connect to image source", + { + id, + url: this.redactUrl(urlAndHeaders.url), + headers: this.redactHeaders(urlAndHeaders.headers), + proxyUrl, + }, + { cause: error }, + ), + ); + return { error: "fetch-error" }; + } + if (!response.ok) { logger.error( new ErrorWithMetadata("Failed to fetch image", { @@ -68,7 +91,7 @@ export class ImageProxy { statusCode: response.status, }), ); - return null; + return { error: "upstream-error", statusCode: response.status }; } const arrayBuffer = await response.arrayBuffer(); @@ -80,7 +103,7 @@ export class ImageProxy { size: `${(arrayBuffer.byteLength / 1024).toFixed(1)}KB`, }); - return arrayBuffer; + return { image: arrayBuffer, contentType: response.headers.get("content-type") }; } private createImageUrl(id: string): string { diff --git a/packages/integrations/src/immich/immich-integration.ts b/packages/integrations/src/immich/immich-integration.ts index c01386256..6274802a5 100644 --- a/packages/integrations/src/immich/immich-integration.ts +++ b/packages/integrations/src/immich/immich-integration.ts @@ -1,4 +1,5 @@ import { + AssetMediaSize, getAlbumInfo, getAllAlbums, getMyUser, @@ -75,7 +76,9 @@ export class ImmichIntegration extends Integration { assets: await Promise.all( albumAssets.map(async (asset) => { const publicLink = await imageProxy.createImageAsync( - this.url(`/api/assets/${asset.id}/original`).toString(), + // This is the URL generated by the SDK's viewAsset call. Preview images are + // browser-compatible while avoiding the large original asset download. + this.url(`/api/assets/${asset.id}/thumbnail`, { size: AssetMediaSize.Preview }).toString(), { "x-api-key": this.getSecretValue("apiKey"), },