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:
1 parent
77dc317da2
commit
0015a6d88d
4 files changed
+48
-16
No files matched your search
@@ -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 } : {}),
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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"),
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user