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
This commit is contained in:
Thomas authored and GitHub committed 2026-07-20 14:53:27 +02:00
1 parent 77dc317da2
commit 0015a6d88d
4 files changed
+48 -16

No files matched your search

+1 -1
View File
@@ -55,7 +55,7 @@ import { immichServerStatsWidget } from '../../widgets/immich-server-stats';
<code>server.statistics</code>,{" "}
<code>server.about</code>.
<br />
Additional permissions may be required depending on your Immich version. If you encounter authorization errors, grant any missing permissions requested by the server.
The <strong>Asset View</strong> permission is required for the Immich Album Carousel to load image thumbnails. Select it when creating the API key, or use the <strong>All permissions</strong> preset. Without it, Immich returns a <code>403 Forbidden</code> response for thumbnail requests.
</span>
),
@@ -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 } : {}),
},
});
};
+31 -8
View File
@@ -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<ArrayBuffer> | null = null;
@@ -47,17 +53,34 @@ export class ImageProxy {
return this.createImageUrl(id);
}
public async forwardImageAsync(id: string): Promise<ArrayBuffer | null> {
public async forwardImageAsync(id: string): Promise<ForwardImageResult> {
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<ReturnType<typeof fetchWithTrustedCertificatesAsync>>;
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 {
@@ -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"),
},