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"),
},