fix: update README and asset upload for Immich v3.0 compatibility

This commit is contained in:
sai80082
2026-07-02 20:26:53 +05:30
parent af4cfceb47
commit 46768eea7a
7 changed files with 119 additions and 179 deletions
+3
View File
@@ -2,6 +2,9 @@
A unofficial immich client to provide better tools to organize and manage your immich account. Building it to speed up your workflows in Immich to organize your people and albums.
> [!IMPORTANT]
> **Immich v3.0 compatibility:** Power Tools **v0.22.0 and above require Immich 3.0 or newer**.
[Watch Demo Video here](https://www.loom.com/embed/13aa90d8ab2e4acab0993bdc8703a750?sid=71498690-b745-473f-b239-a7bdbe6efc21)
[![Immich Power Tools](./screenshots/screenshot-1.png)](https://www.loom.com/embed/13aa90d8ab2e4acab0993bdc8703a750?sid=71498690-b745-473f-b239-a7bdbe6efc21)
+46 -40
View File
@@ -11,28 +11,10 @@ interface ImmichOwner {
email?: string | null;
}
interface ImmichExifInfo {
city?: string | null;
state?: string | null;
country?: string | null;
description?: string | null;
fileSizeInByte?: number | null;
}
interface ImmichAsset {
id: string;
originalFileName: string;
type: string;
fileCreatedAt?: string | null;
localDateTime?: string | null;
thumbhash?: string | null;
exifInfo?: ImmichExifInfo | null;
}
interface ImmichAlbumResponse {
albumName: string;
assetCount?: number;
owner?: ImmichOwner | null;
albumUsers?: { user: ImmichOwner; role: string }[];
description?: string | null;
startDate?: string | null;
endDate?: string | null;
@@ -41,7 +23,6 @@ interface ImmichAlbumResponse {
lastModifiedAssetTimestamp?: string | null;
order?: string | null;
contributorCounts?: IAlbumContributorCount[];
assets: ImmichAsset[];
}
interface ImmichSharedLinkResponse {
@@ -58,7 +39,7 @@ interface ImmichSharedLinkResponse {
interface IImportSharedAsset {
id: string;
originalFileName: string;
originalFileName?: string;
type: string;
fileCreatedAt?: string | null;
localDateTime?: string | null;
@@ -68,6 +49,15 @@ interface IImportSharedAsset {
fileSizeInByte?: number | null;
}
interface ITimeBucketAssets {
id: string[];
fileCreatedAt: string[];
isImage: boolean[];
thumbhash: (string | null)[];
city?: (string | null)[];
country?: (string | null)[];
}
interface IImportSharedAlbum {
albumName: string;
assetCount: number;
@@ -122,13 +112,39 @@ const parseSharedLink = (link: string) => {
}
};
const buildLocationString = (exif?: ImmichExifInfo | null) => {
if (!exif) {
return null;
const enumerateAlbumAssets = async (
origin: string,
albumId: string,
authQuery: string
): Promise<IImportSharedAsset[]> => {
const buckets = await fetchJson<{ timeBucket: string }[]>(
`${origin}/api/timeline/buckets?albumId=${albumId}&${authQuery}`
);
const assets: IImportSharedAsset[] = [];
for (const bucket of buckets) {
const columns = await fetchJson<ITimeBucketAssets>(
`${origin}/api/timeline/bucket?albumId=${albumId}&timeBucket=${encodeURIComponent(bucket.timeBucket)}&${authQuery}`
);
const count = columns.id?.length ?? 0;
for (let i = 0; i < count; i++) {
const city = columns.city?.[i] ?? null;
const country = columns.country?.[i] ?? null;
const location = [city, country].filter(Boolean).join(", ") || null;
const fileCreatedAt = columns.fileCreatedAt?.[i] ?? null;
assets.push({
id: columns.id[i],
type: columns.isImage?.[i] === false ? "VIDEO" : "IMAGE",
fileCreatedAt,
localDateTime: fileCreatedAt,
description: null,
location,
thumbhash: columns.thumbhash?.[i] ?? null,
fileSizeInByte: null,
});
}
}
const parts = [exif.city, exif.state, exif.country]
.filter((value): value is string => !!value && value.trim().length > 0);
return parts.length > 0 ? parts.join(", ") : null;
return assets;
};
const fetchJson = async <T>(url: string) => {
@@ -266,25 +282,15 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
if (albumId) {
const album = await fetchJson<ImmichAlbumResponse>(
`${parsed.origin}/api/albums/${albumId}?${authQuery}&withoutAssets=false`
`${parsed.origin}/api/albums/${albumId}?${authQuery}`
);
const assets = (album.assets || []).map((asset) => ({
id: asset.id,
originalFileName: asset.originalFileName,
type: asset.type,
fileCreatedAt: asset.fileCreatedAt ?? null,
localDateTime: asset.localDateTime ?? null,
description: asset.exifInfo?.description ?? null,
location: buildLocationString(asset.exifInfo),
thumbhash: asset.thumbhash ?? null,
fileSizeInByte: asset.exifInfo?.fileSizeInByte ?? null,
} satisfies IImportSharedAsset));
const assets = await enumerateAlbumAssets(parsed.origin, albumId, authQuery);
albumResult = {
albumName: album.albumName,
assetCount: album.assetCount ?? assets.length,
owner: album.owner ?? null,
owner: album.albumUsers?.[0]?.user ?? null,
description: album.description ?? null,
startDate: album.startDate ?? null,
endDate: album.endDate ?? null,
+47 -5
View File
@@ -174,7 +174,6 @@ export const uploadAssetBuffer = async (
payload: DownloadedAssetPayload,
uploadHeaders: HeadersRecord,
jsonHeaders: HeadersRecord,
deviceAssetId: string,
tagId?: string
): Promise<string> => {
const fileType = asset.type ?? inferAssetTypeFromName(payload.fileName);
@@ -187,14 +186,11 @@ export const uploadAssetBuffer = async (
const modifiedAt = asset.localDateTime ?? asset.fileCreatedAt ?? createdAt;
const formData = new FormData();
formData.set("deviceAssetId", deviceAssetId);
formData.set("deviceId", DEVICE_ID);
formData.set("fileCreatedAt", createdAt);
formData.set("fileModifiedAt", modifiedAt);
formData.set("fileType", fileType);
formData.set("duration", asset.duration ?? "0:00:00.000000");
formData.set("assetData", blob, resolvedFileName);
if (asset.isArchived) formData.set("isArchived", String(asset.isArchived));
if (asset.isArchived) formData.set("visibility", "archive");
if (asset.isFavorite) formData.set("isFavorite", String(asset.isFavorite));
const uploadResponse = await fetch(`${ENV.IMMICH_URL}/api/assets`, {
@@ -222,6 +218,52 @@ export const uploadAssetBuffer = async (
return uploadedId;
};
export const fetchSourceAssetChecksum = async (
origin: string,
assetId: string,
key: string
): Promise<string | null> => {
try {
const res = await fetch(
`${origin}/api/assets/${assetId}?key=${encodeURIComponent(key)}`,
{ headers: { accept: "application/json" } }
);
if (!res.ok) return null;
const data = await res.json().catch(() => null);
return typeof data?.checksum === "string" ? data.checksum : null;
} catch {
return null;
}
};
export const findExistingAssetIds = async (
targetJsonHeaders: HeadersRecord,
items: { id: string; checksum: string }[]
): Promise<Set<string>> => {
const existing = new Set<string>();
if (items.length === 0) return existing;
try {
const res = await fetch(`${ENV.IMMICH_URL}/api/assets/bulk-upload-check`, {
method: "POST",
headers: targetJsonHeaders,
body: JSON.stringify({ assets: items }),
});
if (!res.ok) {
console.warn(`[import] bulk-upload-check failed (status ${res.status})`);
return existing;
}
const data = await res.json().catch(() => ({}));
for (const result of data?.results ?? []) {
if (result?.action === "reject" && result?.reason === "duplicate" && typeof result?.id === "string") {
existing.add(result.id);
}
}
} catch (error) {
console.warn("[import] bulk-upload-check error", error);
}
return existing;
};
export const tagAssetWithPowerTools = async (
tagId: string,
assetId: string,
+7 -44
View File
@@ -6,6 +6,8 @@ import {
HeadersRecord,
ensurePowerToolsTag,
tagAssetWithPowerTools,
fetchSourceAssetChecksum,
findExistingAssetIds,
} from "./helpers";
interface ISharedAssetPayload {
@@ -43,10 +45,8 @@ interface DownloadedAssetPayload {
contentType: string | null;
}
const DEVICE_ID = "immich-power-tools";
const ALLOWED_ASSET_TYPES = new Set(["IMAGE", "VIDEO"]);
const CONCURRENT_TRANSFERS = 4;
const makeDeviceAssetId = (assetId: string) => `shared-${assetId}`;
const respondWithError = (res: NextApiResponse, status: number, message: string) => {
return res.status(status).json({ error: message });
@@ -316,16 +316,13 @@ const uploadAssetBuffer = async (
const modifiedAt = asset.localDateTime ?? asset.fileCreatedAt ?? createdAt;
const formData = new FormData();
formData.set("deviceAssetId", makeDeviceAssetId(asset.id));
formData.set("deviceId", DEVICE_ID);
formData.set("fileCreatedAt", createdAt);
formData.set("fileModifiedAt", modifiedAt);
formData.set("fileType", fileType);
formData.set("duration", asset.duration ?? "0:00:00.000000");
formData.set("assetData", blob, resolvedFileName);
if (asset.isArchived) {
formData.set("isArchived", String(asset.isArchived));
formData.set("visibility", "archive");
}
if (asset.isFavorite) {
formData.set("isFavorite", String(asset.isFavorite));
@@ -390,46 +387,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
"Content-Type": "application/json",
};
const deviceAssetIds: string[] = [];
const deviceAssetIdLookup = new Map<string, string>();
const checkItems: { id: string; checksum: string }[] = [];
for (const asset of uploadableAssets) {
const deviceAssetId = makeDeviceAssetId(asset.id);
deviceAssetIds.push(deviceAssetId);
deviceAssetIdLookup.set(deviceAssetId.toLowerCase(), asset.id);
}
const existingAssetIds = new Set<string>();
if (deviceAssetIds.length > 0) {
try {
const checkResponse = await fetch(`${ENV.IMMICH_URL}/api/assets/exist`, {
method: "POST",
headers: jsonHeadersWithContentType,
body: JSON.stringify({
deviceAssetIds,
deviceId: DEVICE_ID,
}),
});
if (checkResponse.ok) {
const existPayload = await checkResponse.json().catch(() => ({}));
const existingIds: string[] = existPayload?.existingIds ?? [];
for (const deviceAssetId of existingIds) {
if (typeof deviceAssetId !== "string") {
continue;
}
const normalizedId = deviceAssetId.toLowerCase();
const matchedAssetId = deviceAssetIdLookup.get(normalizedId);
if (matchedAssetId) {
existingAssetIds.add(matchedAssetId);
}
}
} else {
console.warn(`Failed to check existing assets before import (status ${checkResponse.status})`);
}
} catch (error) {
console.warn("Unable to check existing assets before import", error);
}
const checksum = await fetchSourceAssetChecksum(origin, asset.id, key);
if (checksum) checkItems.push({ id: asset.id, checksum });
}
const existingAssetIds = await findExistingAssetIds(jsonHeadersWithContentType, checkItems);
const assetsToImport = uploadableAssets.filter((asset) => !existingAssetIds.has(asset.id));
const skippedAssetIds = uploadableAssets
+1 -4
View File
@@ -117,16 +117,13 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const modifiedAt = asset.localDateTime ?? asset.fileCreatedAt ?? createdAt;
const formData = new FormData();
formData.set("deviceAssetId", `shared-${params.assetId}`);
formData.set("deviceId", "immich-power-tools");
formData.set("fileCreatedAt", createdAt);
formData.set("fileModifiedAt", modifiedAt);
formData.set("fileType", asset.type ?? "IMAGE");
formData.set("duration", asset.duration ?? "0:00:00.000000");
formData.set("assetData", assetBlob, fileName);
if (asset.isArchived) {
formData.set("isArchived", asset.isArchived.toString());
formData.set("visibility", "archive");
}
if (asset.isFavorite) {
formData.set("isFavorite", asset.isFavorite.toString());
@@ -1,24 +1,20 @@
import { eq } from "drizzle-orm";
import { appDb } from "@/db";
import { importJobItems } from "@/db/schema";
import { ENV } from "@/config/environment";
import {
ensurePowerToolsTag,
HeadersRecord,
inferAssetTypeFromName,
parseFileNameFromDisposition,
guessContentType,
createImmichAlbum,
addAssetToAlbum,
uploadAssetBuffer,
SharedAssetPayload,
DownloadedAssetPayload,
DEVICE_ID,
fetchSourceAssetChecksum,
findExistingAssetIds,
} from "@/pages/api/import-shared/helpers";
import type { ImportJob, ImportJobItem, ImportProcessor, ProcessorContext, SetupResult } from "../types";
const makeDeviceAssetId = (assetId: string) => `shared-${assetId}`;
const downloadSharedAsset = async (
asset: SharedAssetPayload,
origin: string,
@@ -47,46 +43,21 @@ export class ImmichSharedLinkProcessor implements ImportProcessor {
.from(importJobItems)
.where(eq(importJobItems.jobId, job.id));
// Build deviceAssetIds for dedup check
const deviceAssetIds: string[] = [];
const deviceAssetIdLookup = new Map<string, string>();
let shareKey = "";
try {
const urlConfig = JSON.parse(job.urlConfig) as { key?: string };
if (typeof urlConfig.key === "string") shareKey = urlConfig.key;
} catch {
}
const dedupHeaders: HeadersRecord = { ...headers, "Content-Type": "application/json" };
const checkItems: { id: string; checksum: string }[] = [];
for (const item of items) {
const deviceAssetId = makeDeviceAssetId(item.assetId);
deviceAssetIds.push(deviceAssetId);
deviceAssetIdLookup.set(deviceAssetId.toLowerCase(), item.assetId);
}
// Check which assets already exist in Immich
const skipAssetIds: string[] = [];
if (deviceAssetIds.length > 0) {
try {
const jsonHeadersWithContentType: HeadersRecord = {
...headers,
"Content-Type": "application/json",
};
const checkResponse = await fetch(`${ENV.IMMICH_URL}/api/assets/exist`, {
method: "POST",
headers: jsonHeadersWithContentType,
body: JSON.stringify({ deviceAssetIds, deviceId: DEVICE_ID }),
});
if (checkResponse.ok) {
const existPayload = await checkResponse.json().catch(() => ({}));
const existingIds: string[] = existPayload?.existingIds ?? [];
for (const deviceAssetId of existingIds) {
if (typeof deviceAssetId !== "string") continue;
const matchedAssetId = deviceAssetIdLookup.get(deviceAssetId.toLowerCase());
if (matchedAssetId) {
skipAssetIds.push(matchedAssetId);
}
}
} else {
console.warn(`[ImmichSharedLinkProcessor] Failed to check existing assets (status ${checkResponse.status})`);
}
} catch (error) {
console.warn("[ImmichSharedLinkProcessor] Unable to check existing assets", error);
}
const checksum = await fetchSourceAssetChecksum(job.url, item.assetId, shareKey);
if (checksum) checkItems.push({ id: item.assetId, checksum });
}
const existingAssetIds = await findExistingAssetIds(dedupHeaders, checkItems);
const skipAssetIds: string[] = Array.from(existingAssetIds);
// Parse importData for album options
let importData: Record<string, unknown> = {};
@@ -183,7 +154,6 @@ export class ImmichSharedLinkProcessor implements ImportProcessor {
downloaded,
uploadHeaders as HeadersRecord,
headers as HeadersRecord,
makeDeviceAssetId(asset.id),
tagId
);
@@ -1,7 +1,6 @@
import { eq } from "drizzle-orm";
import { appDb } from "@/db";
import { importJobItems } from "@/db/schema";
import { ENV } from "@/config/environment";
import {
ensurePowerToolsTag,
HeadersRecord,
@@ -10,13 +9,10 @@ import {
uploadAssetBuffer,
SharedAssetPayload,
DownloadedAssetPayload,
DEVICE_ID,
parseFileNameFromDisposition,
} from "@/pages/api/import-shared/helpers";
import type { ImportJob, ImportJobItem, ImportProcessor, ProcessorContext, SetupResult } from "../types";
const makeDeviceAssetId = (relativePath: string) => `shared-nc-${relativePath}`;
const buildBasicAuth = (username: string, password: string): string => {
const encoded = Buffer.from(`${username}:${password}`).toString("base64");
return `Basic ${encoded}`;
@@ -66,43 +62,7 @@ export class NextcloudSharedLinkProcessor implements ImportProcessor {
.from(importJobItems)
.where(eq(importJobItems.jobId, job.id));
// Build deviceAssetIds for dedup check
const deviceAssetIds: string[] = [];
const deviceAssetIdLookup = new Map<string, string>();
for (const item of items) {
const deviceAssetId = makeDeviceAssetId(item.assetId);
deviceAssetIds.push(deviceAssetId);
deviceAssetIdLookup.set(deviceAssetId.toLowerCase(), item.assetId);
}
// Check which assets already exist in Immich
const skipAssetIds: string[] = [];
if (deviceAssetIds.length > 0) {
try {
const jsonHeaders: HeadersRecord = { ...headers, "Content-Type": "application/json" };
const checkResponse = await fetch(`${ENV.IMMICH_URL}/api/assets/exist`, {
method: "POST",
headers: jsonHeaders,
body: JSON.stringify({ deviceAssetIds, deviceId: DEVICE_ID }),
});
if (checkResponse.ok) {
const existPayload = await checkResponse.json().catch(() => ({}));
const existingIds: string[] = existPayload?.existingIds ?? [];
for (const deviceAssetId of existingIds) {
if (typeof deviceAssetId !== "string") continue;
const matchedAssetId = deviceAssetIdLookup.get(deviceAssetId.toLowerCase());
if (matchedAssetId) {
skipAssetIds.push(matchedAssetId);
}
}
} else {
console.warn(`[NextcloudProcessor] Failed to check existing assets (status ${checkResponse.status})`);
}
} catch (error) {
console.warn("[NextcloudProcessor] Unable to check existing assets", error);
}
}
// Parse importData for album options
let importData: Record<string, unknown> = {};
@@ -199,7 +159,6 @@ export class NextcloudSharedLinkProcessor implements ImportProcessor {
downloaded,
uploadHeaders as HeadersRecord,
headers as HeadersRecord,
makeDeviceAssetId(relativePath),
tagId
);