From 46768eea7a9b672ae6236c5e8f8ec9e43833d46f Mon Sep 17 00:00:00 2001 From: sai80082 Date: Thu, 2 Jul 2026 20:26:53 +0530 Subject: [PATCH] fix: update README and asset upload for Immich v3.0 compatibility --- README.md | 3 + src/pages/api/import-shared.ts | 86 ++++++++++--------- src/pages/api/import-shared/helpers.ts | 52 +++++++++-- src/pages/api/import-shared/upload-all.ts | 51 ++--------- src/pages/api/import-shared/upload.ts | 5 +- .../import/processors/immich-shared-link.ts | 60 ++++--------- .../processors/nextcloud-shared-link.ts | 41 --------- 7 files changed, 119 insertions(+), 179 deletions(-) diff --git a/README.md b/README.md index 52d4cc5..33e7196 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/src/pages/api/import-shared.ts b/src/pages/api/import-shared.ts index c2238a3..1a4d983 100644 --- a/src/pages/api/import-shared.ts +++ b/src/pages/api/import-shared.ts @@ -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 => { + 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( + `${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 (url: string) => { @@ -266,25 +282,15 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) if (albumId) { const album = await fetchJson( - `${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, diff --git a/src/pages/api/import-shared/helpers.ts b/src/pages/api/import-shared/helpers.ts index 8f4968f..36f6919 100644 --- a/src/pages/api/import-shared/helpers.ts +++ b/src/pages/api/import-shared/helpers.ts @@ -174,7 +174,6 @@ export const uploadAssetBuffer = async ( payload: DownloadedAssetPayload, uploadHeaders: HeadersRecord, jsonHeaders: HeadersRecord, - deviceAssetId: string, tagId?: string ): Promise => { 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 => { + 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> => { + const existing = new Set(); + 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, diff --git a/src/pages/api/import-shared/upload-all.ts b/src/pages/api/import-shared/upload-all.ts index 0790d71..4b3f293 100644 --- a/src/pages/api/import-shared/upload-all.ts +++ b/src/pages/api/import-shared/upload-all.ts @@ -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(); + 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(); - 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 diff --git a/src/pages/api/import-shared/upload.ts b/src/pages/api/import-shared/upload.ts index 0732731..7fb36f5 100644 --- a/src/pages/api/import-shared/upload.ts +++ b/src/pages/api/import-shared/upload.ts @@ -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()); diff --git a/src/workers/import/processors/immich-shared-link.ts b/src/workers/import/processors/immich-shared-link.ts index ed6b1aa..4a11a9d 100644 --- a/src/workers/import/processors/immich-shared-link.ts +++ b/src/workers/import/processors/immich-shared-link.ts @@ -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(); + 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 = {}; @@ -183,7 +154,6 @@ export class ImmichSharedLinkProcessor implements ImportProcessor { downloaded, uploadHeaders as HeadersRecord, headers as HeadersRecord, - makeDeviceAssetId(asset.id), tagId ); diff --git a/src/workers/import/processors/nextcloud-shared-link.ts b/src/workers/import/processors/nextcloud-shared-link.ts index 6cdd62f..9bc589b 100644 --- a/src/workers/import/processors/nextcloud-shared-link.ts +++ b/src/workers/import/processors/nextcloud-shared-link.ts @@ -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(); - 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 = {}; @@ -199,7 +159,6 @@ export class NextcloudSharedLinkProcessor implements ImportProcessor { downloaded, uploadHeaders as HeadersRecord, headers as HeadersRecord, - makeDeviceAssetId(relativePath), tagId );