Refactor to use the immich SDK
Build and Deploy / build-deploy (push) Successful in 1m8s

This commit is contained in:
2026-07-08 21:31:55 +01:00
parent 0dd0ac8c0d
commit 4b48c1eb26
8 changed files with 110 additions and 90 deletions
+21 -1
View File
@@ -22,7 +22,15 @@
},
"recommended": true,
"style": {
"noNonNullAssertion": "off"
"noNonNullAssertion": "off",
"noRestrictedImports": {
"level": "error",
"options": {
"paths": {
"@immich/sdk": "Import Immich through @/lib/immich"
}
}
}
},
"suspicious": {
"noUnknownAtRules": "off"
@@ -33,6 +41,18 @@
"react": "recommended"
}
},
"overrides": [
{
"includes": ["src/lib/immich.ts"],
"linter": {
"rules": {
"style": {
"noRestrictedImports": "off"
}
}
}
}
],
"assist": {
"actions": {
"source": {
+8
View File
@@ -7,6 +7,7 @@
"dependencies": {
"@heroui/react": "^3.0.2",
"@heroui/styles": "^3.0.2",
"@immich/sdk": "^3.0.1",
"better-sqlite3": "^12.8.0",
"drizzle-orm": "^0.45.2",
"javascript-time-ago": "^2.6.4",
@@ -14,6 +15,7 @@
"next": "16.2.2",
"react": "19.2.4",
"react-dom": "19.2.4",
"server-only": "^0.0.1",
"swr": "^2.4.1",
"thumbhash": "^0.1.1",
},
@@ -382,6 +384,8 @@
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
"@immich/sdk": ["@immich/sdk@3.0.1", "", { "dependencies": { "@oazapfts/runtime": "^1.0.2" } }, "sha512-+iY6cd5Fc0cDIQOpXCSJHJwByaNeFj5MXzFT0xqRlXLE1G2fizoRt48BQ5eVoI64swDtwevnFIlCLYgydF/qqw=="],
"@internationalized/date": ["@internationalized/date@3.12.0", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-/PyIMzK29jtXaGU23qTvNZxvBXRtKbNnGDFD+PY6CZw/Y8Ex8pFUzkuCJCG9aOqmShjqhS9mPqP6Dk5onQY8rQ=="],
"@internationalized/message": ["@internationalized/message@3.1.8", "", { "dependencies": { "@swc/helpers": "^0.5.0", "intl-messageformat": "^10.1.0" } }, "sha512-Rwk3j/TlYZhn3HQ6PyXUV0XP9Uv42jqZGNegt0BXlxjE6G3+LwHjbQZAGHhCnCPdaA6Tvd3ma/7QzLlLkJxAWA=="],
@@ -418,6 +422,8 @@
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-aHaKceJgdySReT7qeck5oShucxWRiiEuwCGK8HHALe6yZga8uyFpLkPgaRw3kkF04U7ROogL/suYCNt/+CuXGA=="],
"@oazapfts/runtime": ["@oazapfts/runtime@1.2.0", "", {}, "sha512-fi7dp7dNayyh/vzqhf0ZdoPfC7tJvYfjaE8MBL1yR+iIsH7cFoqHt+DV70VU49OMCqLc7wQa+yVJcSmIRnV4wA=="],
"@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.11", "", { "dependencies": { "@radix-ui/react-context": "1.1.3", "@radix-ui/react-primitive": "2.1.4", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q=="],
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
@@ -992,6 +998,8 @@
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"server-only": ["server-only@0.0.1", "", {}, "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA=="],
"sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
"simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="],
+2
View File
@@ -13,6 +13,7 @@
"dependencies": {
"@heroui/react": "^3.0.2",
"@heroui/styles": "^3.0.2",
"@immich/sdk": "^3.0.1",
"better-sqlite3": "^12.8.0",
"drizzle-orm": "^0.45.2",
"javascript-time-ago": "^2.6.4",
@@ -20,6 +21,7 @@
"next": "16.2.2",
"react": "19.2.4",
"react-dom": "19.2.4",
"server-only": "^0.0.1",
"swr": "^2.4.1",
"thumbhash": "^0.1.1"
},
+6 -13
View File
@@ -2,13 +2,12 @@ import { and, eq } from "drizzle-orm";
import type { NextRequest } from "next/server";
import { db } from "@/db";
import { assets, lensPortraits } from "@/db/schema";
import { IMMICH_API_KEY, IMMICH_URL } from "@/lib/env";
import { getIp } from "@/lib/get-ip";
import { AssetMediaSize, fetchAssetThumbnail } from "@/lib/immich";
import { checkImgRateLimit } from "@/lib/rate-limit";
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const ALLOWED_SIZES = new Set(["preview", "thumbnail", "fullsize"]);
async function isAuthorizedAsset(id: string): Promise<boolean> {
// Only serve thumbnails for active assets; lens portraits live outside the
@@ -47,25 +46,19 @@ export async function GET(
return new Response(null, { status: 400 });
}
const sizeParam = request.nextUrl.searchParams.get("size") ?? "preview";
const size = ALLOWED_SIZES.has(sizeParam) ? sizeParam : "preview";
const size =
(request.nextUrl.searchParams.get("size") as AssetMediaSize | null) ??
AssetMediaSize.Preview;
if (!(await isAuthorizedAsset(id))) {
return new Response(null, { status: 404 });
}
const upstream = `${IMMICH_URL}/api/assets/${id}/thumbnail?size=${size}`;
const res = await fetch(upstream, {
headers: { "x-api-key": IMMICH_API_KEY },
signal: AbortSignal.timeout(30_000),
cache: "no-store",
});
const res = await fetchAssetThumbnail(id, size);
if (!res.ok) {
const body = await res.text().catch(() => "");
console.error(
`Immich thumbnail error: ${res.status} for ${id} (size=${size}): ${body}`,
`Immich thumbnail error: ${res.status} for ${id} (size=${size})`,
);
return new Response(null, { status: res.status });
}
+45 -57
View File
@@ -1,65 +1,53 @@
import "server-only";
import {
type AssetMediaSize,
type AssetResponseDto,
defaults,
getAssetThumbnailPath,
getBaseUrl,
init,
searchAssets,
} from "@immich/sdk";
import { IMMICH_ALBUM_ID, IMMICH_API_KEY, IMMICH_URL } from "./env";
async function immichFetch(path: string, init?: RequestInit) {
const res = await fetch(`${IMMICH_URL}/api${path}`, {
...init,
headers: {
"x-api-key": IMMICH_API_KEY,
...init?.headers,
},
});
if (!res.ok) {
const body = await res.text().catch(() => "");
// Logged server-side; never propagate upstream body to public callers.
console.error(`Immich API error: ${res.status} ${body}`);
throw new Error(`Immich API error: ${res.status}`);
export * from "@immich/sdk";
init({ baseUrl: `${IMMICH_URL}/api`, apiKey: IMMICH_API_KEY });
export async function fetchAlbumAssets(): Promise<AssetResponseDto[]> {
const all: AssetResponseDto[] = [];
let page: number | null = 1;
while (page !== null) {
const { assets } = await searchAssets({
metadataSearchDto: {
albumIds: [IMMICH_ALBUM_ID],
withExif: true,
size: 1000,
page,
},
});
all.push(...assets.items);
page = assets.nextPage ? Number(assets.nextPage) : null;
}
return res.json();
return all;
}
export interface ImmichExifInfo {
make: string | null;
model: string | null;
lensModel: string | null;
fNumber: number | null;
focalLength: number | null;
iso: number | null;
exposureTime: string | null;
rating: number | null;
}
export interface ImmichAsset {
id: string;
thumbhash: string | null;
originalFileName: string;
fileCreatedAt: string;
width: number;
height: number;
exifInfo: ImmichExifInfo;
}
interface ImmichAlbumResponse {
assets: ImmichAsset[];
}
export async function fetchAlbumAssets(): Promise<ImmichAsset[]> {
const album: ImmichAlbumResponse = await immichFetch(
`/albums/${IMMICH_ALBUM_ID}?withExif=true`,
);
return album.assets;
}
export async function fetchAsset(assetId: string): Promise<ImmichAsset> {
return immichFetch(`/assets/${assetId}`);
}
export async function updateAssetRating(
// viewAsset() buffers the full image into a Blob; stream it instead, reusing
// the SDK's configured base URL and auth headers.
export function fetchAssetThumbnail(
assetId: string,
rating: number,
): Promise<void> {
await immichFetch(`/assets/${assetId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rating }),
size: AssetMediaSize,
): Promise<Response> {
const headers = new Headers();
for (const [key, value] of Object.entries(defaults.headers ?? {})) {
if (value != null) headers.set(key, String(value));
}
const url = `${getBaseUrl()}${getAssetThumbnailPath(assetId)}?size=${size}`;
return fetch(url, {
headers,
signal: AbortSignal.timeout(30_000),
cache: "no-store",
});
}
+14 -9
View File
@@ -1,7 +1,7 @@
import { and, asc, desc, eq, inArray, sql } from "drizzle-orm";
import { db } from "@/db";
import { assets, lenses, lensPortraits } from "@/db/schema";
import { fetchAsset, type ImmichAsset } from "./immich";
import { type AssetResponseDto, getAssetInfo } from "./immich";
import {
LENS_ASSETS_PAGE_SIZE,
type LensAsset,
@@ -51,10 +51,13 @@ async function slugForExifLens(exifLens: string): Promise<string | null> {
function buildPortrait(
assetId: string,
asset: ImmichAsset,
asset: AssetResponseDto,
slugByName: Map<string, string>,
): LensPortrait {
const exifLens = asset.exifInfo.lensModel;
const exifLens = asset.exifInfo?.lensModel;
if (asset.width == null || asset.height == null) {
throw new Error(`Immich asset ${assetId} is missing dimensions`);
}
return {
assetId,
aspectRatio: asset.width / asset.height,
@@ -66,8 +69,8 @@ function buildPortrait(
async function hydratePortrait(assetId: string): Promise<LensPortrait | null> {
try {
const asset = await fetchAsset(assetId);
const exifLens = asset.exifInfo.lensModel;
const asset = await getAssetInfo({ id: assetId });
const exifLens = asset.exifInfo?.lensModel;
const slugByName = new Map<string, string>();
if (exifLens) {
const slug = await slugForExifLens(exifLens);
@@ -109,7 +112,7 @@ export async function getLensPortraits(
const fetched = await Promise.all(
rows.map(async (r) => {
try {
return { ...r, asset: await fetchAsset(r.assetId) };
return { ...r, asset: await getAssetInfo({ id: r.assetId }) };
} catch {
return null;
}
@@ -119,7 +122,9 @@ export async function getLensPortraits(
const exifNames = [
...new Set(
ok.map((f) => f.asset.exifInfo.lensModel).filter((n): n is string => !!n),
ok
.map((f) => f.asset.exifInfo?.lensModel)
.filter((n): n is string => !!n),
),
];
const slugByName = new Map<string, string>();
@@ -141,7 +146,7 @@ export async function setLensPortrait(
lensId: number,
assetId: string,
): Promise<LensPortrait> {
const asset = await fetchAsset(assetId);
const asset = await getAssetInfo({ id: assetId });
await db
.insert(lensPortraits)
@@ -151,7 +156,7 @@ export async function setLensPortrait(
set: { assetId },
});
const exifLens = asset.exifInfo.lensModel;
const exifLens = asset.exifInfo?.lensModel;
const slugByName = new Map<string, string>();
if (exifLens) {
const slug = await slugForExifLens(exifLens);
+2 -2
View File
@@ -1,7 +1,7 @@
import { desc, eq, sql } from "drizzle-orm";
import { db } from "@/db";
import { assets, meta } from "@/db/schema";
import { updateAssetRating } from "./immich";
import { updateAsset } from "./immich";
// Percentile thresholds matching a normal distribution.
// Each entry is the upper bound of the percentile (01) for its star rating.
@@ -107,7 +107,7 @@ export async function applyStarRatings(
for (const m of mappings) {
try {
await updateAssetRating(m.assetId, m.stars);
await updateAsset({ id: m.assetId, updateAssetDto: { rating: m.stars } });
succeeded++;
} catch (e) {
errors.push({
+12 -8
View File
@@ -2,7 +2,7 @@ import { desc, eq, inArray } from "drizzle-orm";
import { db } from "@/db";
import { assets, lenses } from "@/db/schema";
import { uniqueSlug } from "@/db/slugify";
import { fetchAlbumAssets, type ImmichAsset } from "./immich";
import { type AssetResponseDto, fetchAlbumAssets } from "./immich";
import { bucketByPercentileRank, getStarShiftAt } from "./star-sync";
const STATIC_STAR_TO_ELO: Record<number, number> = {
@@ -110,12 +110,16 @@ async function buildLensResolver() {
}
function assetToRow(
asset: ImmichAsset,
asset: AssetResponseDto,
starToElo: Record<number, number>,
lensId: number | null,
) {
const exif = asset.exifInfo;
const initialElo = starToElo[exif.rating ?? 3] ?? 1500;
const initialElo = starToElo[exif?.rating ?? 3] ?? 1500;
if (asset.width == null || asset.height == null) {
throw new Error(`Immich asset ${asset.id} is missing dimensions`);
}
return {
id: asset.id,
@@ -123,10 +127,10 @@ function assetToRow(
eloVerified: initialElo,
thumbhash: asset.thumbhash,
lensId,
focalLength: exif.focalLength || null,
fNumber: exif.fNumber || null,
exposureTime: parseExposureTime(exif.exposureTime) || null,
iso: exif.iso || null,
focalLength: exif?.focalLength || null,
fNumber: exif?.fNumber || null,
exposureTime: parseExposureTime(exif?.exposureTime ?? null) || null,
iso: exif?.iso || null,
width: asset.width,
height: asset.height,
aspectRatio: asset.width / asset.height,
@@ -151,7 +155,7 @@ export async function syncAssets() {
let updated = 0;
for (const asset of immichAssets) {
const lensId = await resolveLensId(asset.exifInfo.lensModel);
const lensId = await resolveLensId(asset.exifInfo?.lensModel ?? null);
const row = assetToRow(asset, starToElo, lensId);
if (existingIds.has(asset.id)) {