fix: update README and asset upload for Immich v3.0 compatibility
This commit is contained in:
@@ -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)
|
||||
|
||||
[](https://www.loom.com/embed/13aa90d8ab2e4acab0993bdc8703a750?sid=71498690-b745-473f-b239-a7bdbe6efc21)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>();
|
||||
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) {
|
||||
let shareKey = "";
|
||||
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 }),
|
||||
});
|
||||
const urlConfig = JSON.parse(job.urlConfig) as { key?: string };
|
||||
if (typeof urlConfig.key === "string") shareKey = urlConfig.key;
|
||||
} catch {
|
||||
}
|
||||
|
||||
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 dedupHeaders: HeadersRecord = { ...headers, "Content-Type": "application/json" };
|
||||
const checkItems: { id: string; checksum: string }[] = [];
|
||||
for (const item of items) {
|
||||
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
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user