This commit is contained in:
+21
-1
@@ -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": {
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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 (0–1) 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
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user