feat: Importing shared assets

- Implemented `upload.ts` for handling the upload of shared assets, including validation and error handling.
- Created `video.ts` for proxying video playback requests from shared assets.
- Developed `import-shared.tsx` page for importing shared albums, including UI for asset selection and album creation options.
- Updated album asset schema to use more descriptive field names (`albumId` and `assetId`).
This commit is contained in:
sai80082 committed 2025-12-29 21:31:41 +05:30
1 parent 5af9e85f16
commit ac8267b431
12 files changed
+1984 -5

No files matched your search

BIN
View File
Binary file not shown.
+1
View File
@@ -52,6 +52,7 @@
"date-fns": "^3.6.0",
"drizzle-orm": "^0.33.0",
"jsonwebtoken": "^9.0.2",
"jszip": "^3.10.1",
"leaflet": "^1.9.4",
"leaflet-defaulticon-compatibility": "0.1.2",
"leaflet.heat": "^0.2.0",
+6 -1
View File
@@ -1,4 +1,4 @@
import { Copy, GalleryHorizontal, GalleryVerticalEnd, Image as ImageIcon, MapPin, MapPinX, Rewind, Search, User, Video } from "lucide-react";
import { Copy, GalleryHorizontal, GalleryVerticalEnd, Image as ImageIcon, MapPin, MapPinX, Rewind, Search, Share2, User, Video } from "lucide-react";
export const sidebarNavs = [
// {
@@ -36,6 +36,11 @@ export const sidebarNavs = [
link: "/albums",
icon: <GalleryHorizontal className="h-4 w-4" />,
},
{
title: "Import shared",
link: "/assets/import-shared",
icon: <Share2 className="h-4 w-4" />,
},
{
title: "Geo Heatmap",
@@ -27,12 +27,12 @@ const SELECT_ORPHAN_PHOTOS = (date: string, ownerId: string) =>
asset a
LEFT JOIN
album_asset aaa
ON a.id = aaa."assetsId"
ON a.id = aaa."assetId"
LEFT JOIN
asset_exif e
ON a.id = e."assetId"
WHERE
aaa."albumsId" IS NULL
aaa."albumId" IS NULL
AND a."ownerId" = '${ownerId}'
AND e."dateTimeOriginal"::date = '${date}'
AND a."visibility" = 'timeline'
+222
View File
@@ -0,0 +1,222 @@
import type { NextApiRequest, NextApiResponse } from "next";
interface IAlbumContributorCount {
userId: string;
assetCount: number;
}
interface ImmichOwner {
name?: string | null;
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;
description?: string | null;
startDate?: string | null;
endDate?: string | null;
shared?: boolean;
hasSharedLink?: boolean;
lastModifiedAssetTimestamp?: string | null;
order?: string | null;
contributorCounts?: IAlbumContributorCount[];
assets: ImmichAsset[];
}
interface ImmichSharedLinkResponse {
id: string;
key: string;
type: string;
createdAt: string;
expiresAt?: string | null;
allowUpload?: boolean;
allowDownload?: boolean;
showMetadata?: boolean;
album?: { id: string } | null;
}
interface IImportSharedAsset {
id: string;
originalFileName: string;
type: string;
fileCreatedAt?: string | null;
localDateTime?: string | null;
description?: string | null;
location?: string | null;
thumbhash?: string | null;
fileSizeInByte?: number | null;
}
interface IImportSharedAlbum {
albumName: string;
assetCount: number;
owner?: ImmichOwner | null;
description?: string | null;
startDate?: string | null;
endDate?: string | null;
shared?: boolean;
hasSharedLink?: boolean;
lastModifiedAssetTimestamp?: string | null;
order?: string | null;
contributorCounts?: IAlbumContributorCount[];
assets: IImportSharedAsset[];
}
interface IImportSharedResponse {
link: string;
origin: string;
key: string;
sharedLink: {
id: string;
type: string;
createdAt: string;
expiresAt?: string | null;
allowUpload?: boolean;
allowDownload?: boolean;
showMetadata?: boolean;
};
album: IImportSharedAlbum | null;
}
const respondWithError = (res: NextApiResponse, status: number, message: string) => {
return res.status(status).json({ error: message });
};
const parseSharedLink = (link: string) => {
const trimmed = link.trim();
if (!trimmed) {
return null;
}
try {
const url = new URL(trimmed);
const segments = url.pathname.split("/").filter(Boolean);
const key = segments.pop();
if (!key) {
return null;
}
return { key, origin: url.origin, original: trimmed };
} catch (_err) {
return null;
}
};
const buildLocationString = (exif?: ImmichExifInfo | null) => {
if (!exif) {
return 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;
};
const fetchJson = async <T>(url: string) => {
const response = await fetch(url, {
headers: { accept: "application/json" },
});
if (!response.ok) {
throw new Error(`Request failed for ${url} (status ${response.status})`);
}
return (await response.json()) as T;
};
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "POST") {
res.setHeader("Allow", "POST");
return respondWithError(res, 405, "Method Not Allowed");
}
const payload = typeof req.body === "string" ? JSON.parse(req.body) : req.body;
const link = payload?.link;
if (!link || typeof link !== "string") {
return respondWithError(res, 400, "A shared album link is required");
}
const parsed = parseSharedLink(link);
if (!parsed) {
return respondWithError(res, 400, "Invalid Immich share link");
}
try {
const sharedLink = await fetchJson<ImmichSharedLinkResponse>(
`${parsed.origin}/api/shared-links/me?key=${parsed.key}`
);
const albumId = sharedLink.album?.id;
let albumResult: IImportSharedAlbum | null = null;
if (albumId) {
const album = await fetchJson<ImmichAlbumResponse>(
`${parsed.origin}/api/albums/${albumId}?key=${parsed.key}&withoutAssets=false`
);
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));
albumResult = {
albumName: album.albumName,
assetCount: album.assetCount ?? assets.length,
owner: album.owner ?? null,
description: album.description ?? null,
startDate: album.startDate ?? null,
endDate: album.endDate ?? null,
shared: album.shared ?? false,
hasSharedLink: album.hasSharedLink ?? false,
lastModifiedAssetTimestamp: album.lastModifiedAssetTimestamp ?? null,
order: album.order ?? null,
contributorCounts: album.contributorCounts ?? [],
assets,
} satisfies IImportSharedAlbum;
}
const responseBody: IImportSharedResponse = {
link: parsed.original,
origin: parsed.origin,
key: parsed.key,
sharedLink: {
id: sharedLink.id,
type: sharedLink.type,
createdAt: sharedLink.createdAt,
expiresAt: sharedLink.expiresAt ?? null,
allowUpload: sharedLink.allowUpload ?? false,
allowDownload: sharedLink.allowDownload ?? false,
showMetadata: sharedLink.showMetadata ?? false,
},
album: albumResult,
};
return res.status(200).json(responseBody);
} catch (error: any) {
console.error("Import shared error", error);
return respondWithError(res, 500, error?.message ?? "Failed to import shared album");
}
}
+63
View File
@@ -0,0 +1,63 @@
import { ENV } from "@/config/environment";
export const POWER_TOOLS_TAG_NAME = "immich-power-tools";
export type HeadersRecord = Record<string, string>;
interface ImmichTagResponse {
id: string;
name: string;
}
export const ensurePowerToolsTag = async (headers: HeadersRecord) => {
const listResponse = await fetch(`${ENV.IMMICH_URL}/api/tags`, {
headers,
});
if (!listResponse.ok) {
throw new Error("Failed to fetch tags from Immich instance");
}
const tags = (await listResponse.json()) as ImmichTagResponse[];
const existing = tags.find((tag) => tag.name === POWER_TOOLS_TAG_NAME);
if (existing) {
return existing;
}
const createResponse = await fetch(`${ENV.IMMICH_URL}/api/tags`, {
method: "POST",
headers,
body: JSON.stringify({ name: POWER_TOOLS_TAG_NAME }),
});
if (!createResponse.ok) {
throw new Error("Failed to create immich-power-tools tag");
}
return (await createResponse.json()) as ImmichTagResponse;
};
export const tagAssetWithPowerTools = async (
tagId: string,
assetId: string,
headers: HeadersRecord
) => {
const taggingHeaders: HeadersRecord = {
...headers,
"Content-Type": "application/json",
Accept: "application/json",
};
const tagResponse = await fetch(`${ENV.IMMICH_URL}/api/tags/assets`, {
method: "PUT",
headers: taggingHeaders,
body: JSON.stringify({ tagIds: [tagId], assetIds: [assetId] }),
});
if (!tagResponse.ok) {
const errorBody = await tagResponse.text().catch(() => "");
throw new Error(
`Failed to tag uploaded asset (status ${tagResponse.status}): ${errorBody || "Unknown error"}`
);
}
};
+87
View File
@@ -0,0 +1,87 @@
import type { NextApiRequest, NextApiResponse } from "next";
const respondWithError = (res: NextApiResponse, status: number, message: string) => {
return res.status(status).json({ error: message });
};
const ALLOWED_SIZES = new Set(["thumbnail", "preview"]);
const validateParams = (req: NextApiRequest) => {
const { origin, assetId, key, thumbhash, size } = req.query;
if (!origin || Array.isArray(origin)) {
return { error: "Query parameter 'origin' is required" };
}
if (!assetId || Array.isArray(assetId)) {
return { error: "Query parameter 'assetId' is required" };
}
if (!key || Array.isArray(key)) {
return { error: "Query parameter 'key' is required" };
}
let resolvedSize = "thumbnail";
if (typeof size === "string") {
resolvedSize = size;
} else if (Array.isArray(size)) {
return { error: "Query parameter 'size' must be a single value" };
}
if (!ALLOWED_SIZES.has(resolvedSize)) {
return { error: "Query parameter 'size' must be either 'thumbnail' or 'preview'" };
}
try {
const parsedOrigin = new URL(origin);
return {
origin: parsedOrigin.origin,
assetId,
key,
size: resolvedSize,
thumbhash: typeof thumbhash === "string" ? thumbhash : undefined,
};
} catch (_err) {
return { error: "Invalid origin provided" };
}
};
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "GET") {
res.setHeader("Allow", "GET");
return respondWithError(res, 405, "Method Not Allowed");
}
const params = validateParams(req);
if ("error" in params) {
return respondWithError(res, 400, params.error);
}
const search = new URLSearchParams({ key: params.key, size: params.size });
if (params.thumbhash) {
search.set("c", params.thumbhash);
}
try {
const response = await fetch(
`${params.origin}/api/assets/${params.assetId}/thumbnail?${search.toString()}`,
{
headers: {
accept: "image/*",
},
}
);
if (!response.ok) {
return respondWithError(res, response.status, "Failed to fetch thumbnail");
}
const arrayBuffer = await response.arrayBuffer();
const contentType = response.headers.get("content-type") ?? "image/jpeg";
res.setHeader("Content-Type", contentType);
res.setHeader("Cache-Control", "public, max-age=60");
return res.status(200).send(Buffer.from(arrayBuffer));
} catch (error: any) {
console.error("Thumbnail proxy error", error);
return respondWithError(res, 500, error?.message ?? "Thumbnail proxy failed");
}
}
+518
View File
@@ -0,0 +1,518 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { ENV } from "@/config/environment";
import { getCurrentUser } from "@/handlers/serverUtils/user.utils";
import { getUserHeaders } from "@/helpers/user.helper";
import {
HeadersRecord,
ensurePowerToolsTag,
tagAssetWithPowerTools,
} from "./helpers";
interface ISharedAssetPayload {
id: string;
originalFileName?: string;
type: string;
fileCreatedAt?: string | null;
localDateTime?: string | null;
duration?: string | null;
isFavorite?: boolean;
isArchived?: boolean;
}
interface IAlbumImportOptions {
createAlbum: boolean;
albumName?: string;
addToAlbumId?: string;
}
interface IImportAllPayload {
origin: string;
key: string;
assets: ISharedAssetPayload[];
albumOptions?: IAlbumImportOptions | null;
}
interface IFailureDetail {
assetId: string;
reason: string;
}
interface DownloadedAssetPayload {
buffer: Buffer;
fileName: string;
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 });
};
const parseBody = (req: NextApiRequest) => {
try {
return typeof req.body === "string" ? JSON.parse(req.body) : req.body;
} catch (_error) {
return null;
}
};
const normalizeAlbumOptions = (value: any): IAlbumImportOptions => {
if (!value || typeof value !== "object") {
return { createAlbum: false };
}
const createAlbum = Boolean(value.createAlbum);
const addToAlbumId = typeof value.addToAlbumId === "string" && value.addToAlbumId.trim().length > 0 ? value.addToAlbumId.trim() : undefined;
if (!createAlbum && !addToAlbumId) {
return { createAlbum: false };
}
const albumName =
typeof value.albumName === "string" && value.albumName.trim().length > 0
? value.albumName.trim()
: undefined;
return {
createAlbum,
albumName,
addToAlbumId,
};
};
const normalizeAssetPayload = (value: any): ISharedAssetPayload | null => {
if (!value || typeof value !== "object") {
return null;
}
if (!value.id || typeof value.id !== "string") {
return null;
}
if (!value.type || typeof value.type !== "string") {
return null;
}
return {
id: value.id,
originalFileName: typeof value.originalFileName === "string" ? value.originalFileName : undefined,
type: value.type,
fileCreatedAt: value.fileCreatedAt ?? null,
localDateTime: value.localDateTime ?? null,
duration: value.duration ?? null,
isFavorite: value.isFavorite ?? false,
isArchived: value.isArchived ?? false,
};
};
const validatePayload = (payload: any) => {
if (!payload || typeof payload !== "object") {
return { error: "Invalid request payload" };
}
const { origin, key, assets } = payload as IImportAllPayload;
const normalizedAlbumOptions = normalizeAlbumOptions((payload as IImportAllPayload).albumOptions);
if (!origin || typeof origin !== "string") {
return { error: "Field 'origin' is required" };
}
if (!key || typeof key !== "string") {
return { error: "Field 'key' is required" };
}
if (!Array.isArray(assets) || assets.length === 0) {
return { error: "Field 'assets' must include at least one entry" };
}
let parsedOrigin: URL;
try {
parsedOrigin = new URL(origin.trim());
} catch (_err) {
return { error: "Invalid origin provided" };
}
const normalizedAssets = assets
.map(normalizeAssetPayload)
.filter((asset): asset is ISharedAssetPayload => !!asset);
if (normalizedAssets.length === 0) {
return { error: "No valid assets provided" };
}
return {
origin: parsedOrigin.origin,
key,
assets: normalizedAssets,
albumOptions: normalizedAlbumOptions,
};
};
const inferAssetTypeFromName = (fileName: string): "IMAGE" | "VIDEO" => {
const extension = fileName.split(".").pop()?.toLowerCase();
if (!extension) {
return "IMAGE";
}
switch (extension) {
case "mp4":
case "mov":
case "m4v":
case "avi":
case "mkv":
case "webm":
case "3gp":
case "3g2":
case "mts":
case "m2ts":
return "VIDEO";
default:
return "IMAGE";
}
};
const parseFileNameFromDisposition = (disposition?: string | null) => {
if (!disposition) {
return null;
}
const match = disposition.match(/filename\*?=([^;]+)/i);
if (!match || !match[1]) {
return null;
}
const value = match[1].trim().replace(/^UTF-8''/i, "").replace(/^"|"$/g, "").replace(/^'|'$/g, "");
try {
return decodeURIComponent(value);
} catch (_error) {
return value;
}
};
const guessContentType = (fileName: string) => {
const lower = fileName.toLowerCase();
const extension = lower.split(".").pop();
switch (extension) {
case "jpg":
case "jpeg":
case "jfif":
return "image/jpeg";
case "png":
return "image/png";
case "heic":
return "image/heic";
case "heif":
return "image/heif";
case "webp":
return "image/webp";
case "gif":
return "image/gif";
case "tif":
case "tiff":
return "image/tiff";
case "bmp":
return "image/bmp";
case "mp4":
case "m4v":
return "video/mp4";
case "mov":
return "video/quicktime";
case "avi":
return "video/x-msvideo";
case "mkv":
return "video/x-matroska";
case "webm":
return "video/webm";
case "3gp":
case "3g2":
return "video/3gpp";
case "mts":
case "m2ts":
return "video/MP2T";
default:
return "application/octet-stream";
}
};
const createImmichAlbum = async (albumName: string, headers: HeadersRecord) => {
const response = await fetch(`${ENV.IMMICH_URL}/api/albums`, {
method: "POST",
headers,
body: JSON.stringify({ albumName }),
});
if (!response.ok) {
const body = await response.text().catch(() => "");
throw new Error(
`Failed to create album "${albumName}" (status ${response.status}): ${body || "Unknown error"}`
);
}
const album = await response.json().catch(() => null);
const albumId = album?.id;
if (!albumId || typeof albumId !== "string") {
throw new Error("Immich album creation response did not include an id");
}
return albumId;
};
const addAssetToAlbum = async (albumId: string, assetId: string, headers: HeadersRecord) => {
const response = await fetch(`${ENV.IMMICH_URL}/api/albums/${albumId}/assets`, {
method: "PUT",
headers,
body: JSON.stringify({ ids: [assetId] }),
});
if (!response.ok) {
const body = await response.text().catch(() => "");
throw new Error(
`Failed to attach asset ${assetId} to album ${albumId} (status ${response.status}): ${
body || "Unknown error"
}`
);
}
};
const downloadSharedAsset = async (
asset: ISharedAssetPayload,
origin: string,
key: string
): Promise<DownloadedAssetPayload> => {
const url = `${origin}/api/assets/${asset.id}/original?key=${key}`;
const response = await fetch(url, {
method: "GET",
headers: {
accept: "*/*",
},
});
if (!response.ok) {
throw new Error(`Failed to download ${asset.originalFileName ?? asset.id} (status ${response.status})`);
}
const buffer = Buffer.from(await response.arrayBuffer());
const disposition = response.headers.get("content-disposition");
const inferredName = parseFileNameFromDisposition(disposition);
const fallbackExtension = asset.type === "VIDEO" ? "mp4" : "jpg";
const fileName = inferredName ?? asset.originalFileName ?? `${asset.id}.${fallbackExtension}`;
return {
buffer,
fileName,
contentType: response.headers.get("content-type"),
};
};
const uploadAssetBuffer = async (
asset: ISharedAssetPayload,
payload: DownloadedAssetPayload,
uploadHeaders: HeadersRecord,
jsonHeaders: HeadersRecord,
tagId: string
) => {
const fileType = asset.type ?? inferAssetTypeFromName(payload.fileName);
const resolvedFileName = payload.fileName || asset.originalFileName || `${asset.id}.bin`;
const contentType = payload.contentType ?? guessContentType(resolvedFileName);
const blob = new Blob([payload.buffer], { type: contentType });
const createdAt = asset.fileCreatedAt ?? asset.localDateTime ?? new Date().toISOString();
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));
}
if (asset.isFavorite) {
formData.set("isFavorite", String(asset.isFavorite));
}
const uploadResponse = await fetch(`${ENV.IMMICH_URL}/api/assets`, {
method: "POST",
headers: uploadHeaders,
body: formData,
});
if (!uploadResponse.ok) {
const body = await uploadResponse.text().catch(() => "");
throw new Error(
`Upload failed for ${asset.originalFileName ?? asset.id} (status ${uploadResponse.status}): ${
body || "Unknown error"
}`
);
}
const uploaded = await uploadResponse.json();
const uploadedId = uploaded?.id;
if (!uploadedId) {
throw new Error("Immich upload response did not include an asset id");
}
await tagAssetWithPowerTools(tagId, uploadedId, jsonHeaders);
return uploadedId;
};
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "POST") {
res.setHeader("Allow", "POST");
return respondWithError(res, 405, "Method Not Allowed");
}
if (!ENV.IMMICH_URL) {
return respondWithError(res, 500, "IMMICH_URL must be configured");
}
const currentUser = await getCurrentUser(req);
if (!currentUser) {
return respondWithError(res, 401, "Unauthorized");
}
const payload = parseBody(req);
const validation = validatePayload(payload);
if ("error" in validation) {
return respondWithError(res, 400, validation.error ?? "Validation failed");
}
const { origin, key, assets, albumOptions } = validation;
const uploadableAssets = assets.filter((asset) => ALLOWED_ASSET_TYPES.has(asset.type));
const normalizedAlbumOptions = albumOptions ?? { createAlbum: false };
if (uploadableAssets.length === 0) {
return respondWithError(res, 400, "No image or video assets available to import");
}
const jsonHeaders = getUserHeaders(currentUser) as HeadersRecord;
const jsonHeadersWithContentType: HeadersRecord = {
...jsonHeaders,
"Content-Type": "application/json",
};
const deviceAssetIds: string[] = [];
const deviceAssetIdLookup = new Map<string, 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 assetsToImport = uploadableAssets.filter((asset) => !existingAssetIds.has(asset.id));
const skippedAssetIds = uploadableAssets
.filter((asset) => existingAssetIds.has(asset.id))
.map((asset) => asset.id);
if (assetsToImport.length === 0) {
return res.status(200).json({
uploadedIds: [],
processedAssetIds: [],
uploadedCount: 0,
failed: [],
requestedCount: 0,
skippedAssetIds,
});
}
if (skippedAssetIds.length) {
console.log(`Skipping ${skippedAssetIds.length} assets already stored in Immich`);
}
let targetAlbumId: string | null = null;
if (normalizedAlbumOptions.createAlbum) {
const desiredAlbumName =
normalizedAlbumOptions.albumName?.trim() || `Shared import ${new Date().toISOString()}`;
try {
targetAlbumId = await createImmichAlbum(desiredAlbumName, jsonHeadersWithContentType);
console.log(`Created Immich album ${targetAlbumId} for shared import (${desiredAlbumName})`);
} catch (error: any) {
console.error("Failed to create Immich album for import", error);
return respondWithError(res, 500, error?.message ?? "Unable to create Immich album");
}
} else if (normalizedAlbumOptions.addToAlbumId) {
targetAlbumId = normalizedAlbumOptions.addToAlbumId;
}
const assetIds = assetsToImport.map((asset) => asset.id);
console.log(
`Starting per-asset import for ${assetIds.length} assets (concurrency ${Math.min(
CONCURRENT_TRANSFERS,
assetsToImport.length
)})`
);
const { ["Content-Type"]: _omitContentType, ...uploadHeaders } = jsonHeaders;
const tag = await ensurePowerToolsTag(jsonHeaders);
const uploadedImmichIds: string[] = [];
const processedAssetIds: string[] = [];
const failed: IFailureDetail[] = [];
let currentIndex = 0;
const concurrency = Math.min(CONCURRENT_TRANSFERS, assetsToImport.length);
const worker = async () => {
while (true) {
const index = currentIndex++;
if (index >= assetsToImport.length) {
break;
}
const asset = assetsToImport[index];
try {
const downloaded = await downloadSharedAsset(asset, origin, key);
const uploadedId = await uploadAssetBuffer(asset, downloaded, uploadHeaders, jsonHeaders, tag.id);
if (targetAlbumId) {
await addAssetToAlbum(targetAlbumId, uploadedId, jsonHeadersWithContentType);
}
uploadedImmichIds.push(uploadedId);
processedAssetIds.push(asset.id);
} catch (error: any) {
failed.push({ assetId: asset.id, reason: error?.message ?? "Upload failed" });
}
}
};
await Promise.all(Array.from({ length: concurrency }, worker));
return res.status(200).json({
uploadedIds: uploadedImmichIds,
processedAssetIds,
uploadedCount: uploadedImmichIds.length,
failed,
requestedCount: assetsToImport.length,
skippedAssetIds,
albumId: targetAlbumId,
});
}
+174
View File
@@ -0,0 +1,174 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { ENV } from "@/config/environment";
import { getCurrentUser } from "@/handlers/serverUtils/user.utils";
import { getUserHeaders } from "@/helpers/user.helper";
import {
HeadersRecord,
ensurePowerToolsTag,
tagAssetWithPowerTools,
} from "./helpers";
const respondWithError = (res: NextApiResponse, status: number, message: string) => {
return res.status(status).json({ error: message });
};
const parseBody = (req: NextApiRequest) => {
try {
return typeof req.body === "string" ? JSON.parse(req.body) : req.body;
} catch (error) {
return null;
}
};
const validatePayload = (payload: any) => {
const { assetId, origin, key } = payload || {};
if (!assetId || typeof assetId !== "string") {
return { error: "Field 'assetId' is required" };
}
if (!origin || typeof origin !== "string") {
return { error: "Field 'origin' is required" };
}
if (!key || typeof key !== "string") {
return { error: "Field 'key' is required" };
}
let parsedOrigin: URL | null = null;
try {
parsedOrigin = new URL(origin);
} catch (_err) {
return { error: "Invalid origin provided" };
}
return {
assetId,
key,
origin: parsedOrigin.origin,
};
};
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "POST") {
res.setHeader("Allow", "POST");
return respondWithError(res, 405, "Method Not Allowed");
}
if (!ENV.IMMICH_URL) {
return respondWithError(res, 500, "IMMICH_URL must be configured");
}
const currentUser = await getCurrentUser(req);
if (!currentUser) {
return respondWithError(res, 401, "Unauthorized");
}
const payload = parseBody(req);
if (!payload) {
return respondWithError(res, 400, "Invalid request payload");
}
const jsonHeaders = getUserHeaders(currentUser) as HeadersRecord;
const validation = validatePayload(payload);
if ("error" in validation) {
return respondWithError(res, 400, validation.error);
}
const params = validation;
try {
const assetResponse = await fetch(
`${params.origin}/api/assets/${params.assetId}?key=${params.key}`,
{
headers: {
accept: "application/json",
},
}
);
if (!assetResponse.ok) {
return respondWithError(res, assetResponse.status, "Failed to fetch remote asset metadata");
}
const asset = await assetResponse.json();
if (!asset || asset.type !== "IMAGE") {
return respondWithError(res, 400, "Only photo assets can be uploaded from this view");
}
const originalResponse = await fetch(
`${params.origin}/api/assets/${params.assetId}/original?key=${params.key}`,
{
headers: {
accept: "application/octet-stream",
},
}
);
if (!originalResponse.ok) {
return respondWithError(res, originalResponse.status, "Failed to download original asset data");
}
const contentType = originalResponse.headers.get("content-type") ?? "application/octet-stream";
const originalBuffer = await originalResponse.arrayBuffer();
const assetBlob = new Blob([originalBuffer], { type: contentType });
const fileName = asset.originalFileName ?? `${params.assetId}.bin`;
const createdAt = asset.fileCreatedAt ?? asset.localDateTime ?? new Date().toISOString();
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());
}
if (asset.isFavorite) {
formData.set("isFavorite", asset.isFavorite.toString());
}
const { ["Content-Type"]: _omitContentType, ...uploadHeaders } = jsonHeaders;
const uploadResponse = await fetch(`${ENV.IMMICH_URL}/api/assets`, {
method: "POST",
headers: uploadHeaders,
body: formData,
});
if (!uploadResponse.ok) {
const errorBody = await uploadResponse.text();
console.error("Immich upload error", uploadResponse.status, errorBody);
return respondWithError(res, uploadResponse.status, "Failed to upload asset to Immich instance");
}
const uploaded = await uploadResponse.json();
const uploadedAssetId = uploaded?.id;
if (!uploadedAssetId) {
throw new Error("Immich upload response did not include an asset id");
}
const tagHeaders: HeadersRecord = {
...jsonHeaders,
Accept: "application/json",
};
const tag = await ensurePowerToolsTag(tagHeaders);
await tagAssetWithPowerTools(tag.id, uploadedAssetId, tagHeaders);
return res.status(200).json({
status: "uploaded",
immichId: uploadedAssetId,
deviceAssetId: `shared-${params.assetId}`,
tagId: tag.id,
});
} catch (error: any) {
console.error("Upload shared asset error", error);
return respondWithError(res, 500, error?.message ?? "Unexpected error while uploading asset");
}
}
+95
View File
@@ -0,0 +1,95 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { Readable } from "stream";
const respondWithError = (res: NextApiResponse, status: number, message: string) => {
return res.status(status).json({ error: message });
};
const validateParams = (req: NextApiRequest) => {
const { origin, assetId, key, thumbhash } = req.query;
if (!origin || Array.isArray(origin)) {
return { error: "Query parameter 'origin' is required" };
}
if (!assetId || Array.isArray(assetId)) {
return { error: "Query parameter 'assetId' is required" };
}
if (!key || Array.isArray(key)) {
return { error: "Query parameter 'key' is required" };
}
try {
const parsedOrigin = new URL(origin);
return {
origin: parsedOrigin.origin,
assetId,
key,
thumbhash: typeof thumbhash === "string" ? thumbhash : undefined,
};
} catch (_err) {
return { error: "Invalid origin provided" };
}
};
const passthroughHeaders = [
"content-type",
"content-length",
"content-range",
"accept-ranges",
"cache-control",
];
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "GET") {
res.setHeader("Allow", "GET");
return respondWithError(res, 405, "Method Not Allowed");
}
const params = validateParams(req);
if ("error" in params) {
return respondWithError(res, 400, params.error);
}
const search = new URLSearchParams({ key: params.key });
if (params.thumbhash) {
search.set("c", params.thumbhash);
}
const targetUrl = `${params.origin}/api/assets/${params.assetId}/video/playback?${search.toString()}`;
try {
const upstream = await fetch(targetUrl, {
headers: {
accept: "video/*",
...(req.headers.range ? { range: req.headers.range } : {}),
referer: req.headers.referer?.toString() ?? params.origin,
"sec-fetch-dest": "video",
},
});
if (!upstream.ok && upstream.status !== 206) {
return respondWithError(res, upstream.status, "Failed to fetch video playback");
}
passthroughHeaders.forEach((header) => {
const value = upstream.headers.get(header);
if (value) {
res.setHeader(header, value);
}
});
res.status(upstream.status);
const body = upstream.body;
if (body) {
const nodeStream = Readable.fromWeb(body as any);
nodeStream.pipe(res);
} else {
const buffer = Buffer.from(await upstream.arrayBuffer());
res.send(buffer);
}
} catch (error: any) {
console.error("Video playback proxy error", error);
return respondWithError(res, 500, error?.message ?? "Video playback proxy failed");
}
}
+814
View File
@@ -0,0 +1,814 @@
import React, { FormEvent, useEffect, useState } from "react";
import { Loader2 } from "lucide-react";
import PageLayout from "@/components/layouts/PageLayout";
import Header from "@/components/shared/Header";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { listAlbums } from "@/handlers/api/album.handler";
import { IAlbum } from "@/types/album";
interface IAlbumContributorCount {
userId: string;
assetCount: number;
}
interface IImportSharedAsset {
id: string;
originalFileName: string;
type: string;
fileCreatedAt?: string | null;
localDateTime?: string | null;
description?: string | null;
location?: string | null;
thumbhash?: string | null;
fileSizeInByte?: number | null;
duration?: string | null;
isFavorite?: boolean;
isArchived?: boolean;
}
interface IImportSharedAlbum {
albumName: string;
assetCount: number;
owner?: {
name?: string | null;
email?: string | null;
} | null;
description?: string | null;
startDate?: string | null;
endDate?: string | null;
shared?: boolean;
hasSharedLink?: boolean;
lastModifiedAssetTimestamp?: string | null;
order?: string | null;
contributorCounts?: IAlbumContributorCount[];
assets: IImportSharedAsset[];
}
interface IImportSharedResponse {
link: string;
origin: string;
key: string;
sharedLink: {
id: string;
type: string;
createdAt: string;
expiresAt?: string | null;
allowUpload?: boolean;
allowDownload?: boolean;
showMetadata?: boolean;
};
album: IImportSharedAlbum | null;
}
const formatDate = (value?: string | null) => {
if (!value) {
return "—";
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleString();
};
const formatDateOnly = (value?: string | null) => {
if (!value) {
return "—";
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
};
const InfoRow = ({ label, value }: { label: string; value: React.ReactNode }) => (
<div className="flex flex-col gap-1 rounded-md border border-border bg-muted/30 p-3">
<p className="text-xs uppercase tracking-wide text-muted-foreground">{label}</p>
<p className="text-sm font-medium text-foreground break-words">{value ?? "—"}</p>
</div>
);
const formatContributors = (counts?: IAlbumContributorCount[]) => {
if (!counts || counts.length === 0) {
return "—";
}
return counts.map((entry) => `${entry.assetCount} assets`).join(", ");
};
const formatPermissions = (link?: IImportSharedResponse["sharedLink"]) => {
if (!link) {
return "View only";
}
const labels: string[] = [];
if (link.allowDownload) {
labels.push("Download");
}
if (link.allowUpload) {
labels.push("Upload");
}
return labels.length ? labels.join(" / ") : "View only";
};
const formatFileSize = (bytes?: number | null) => {
if (!bytes || bytes <= 0) {
return null;
}
const units = ["B", "KB", "MB", "GB", "TB"];
let size = bytes;
let unit = 0;
while (size >= 1024 && unit < units.length - 1) {
size /= 1024;
unit += 1;
}
return `${size % 1 === 0 ? size : size.toFixed(1)} ${units[unit]}`;
};
export default function ImportSharedPage() {
const [shareLink, setShareLink] = useState("");
const [submittedLink, setSubmittedLink] = useState<string | null>(null);
const [sharedData, setSharedData] = useState<IImportSharedResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [activeVideoAsset, setActiveVideoAsset] = useState<IImportSharedAsset | null>(null);
const [activeImageAsset, setActiveImageAsset] = useState<IImportSharedAsset | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [uploadBanner, setUploadBanner] = useState<{ type: "success" | "error"; message: string } | null>(null);
const [importAllLoading, setImportAllLoading] = useState(false);
const [importAllDialogOpen, setImportAllDialogOpen] = useState(false);
const [albumImportMode, setAlbumImportMode] = useState<"album" | "no-album" | "existing-album">("album");
const [albumNameInput, setAlbumNameInput] = useState("");
const [selectedAssetIds, setSelectedAssetIds] = useState<Set<string>>(new Set());
const [existingAlbums, setExistingAlbums] = useState<IAlbum[]>([]);
const [selectedAlbumId, setSelectedAlbumId] = useState<string | null>(null);
useEffect(() => {
if (importAllDialogOpen) {
listAlbums().then(setExistingAlbums).catch(console.error);
}
}, [importAllDialogOpen]);
useEffect(() => {
if (sharedData?.album?.albumName) {
setAlbumNameInput(sharedData.album.albumName);
} else if (sharedData) {
setAlbumNameInput("Imported shared album");
} else {
setAlbumNameInput("");
}
}, [sharedData]);
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError(null);
setSharedData(null);
setSubmittedLink(null);
setUploadBanner(null);
setImportAllLoading(false);
setImportAllDialogOpen(false);
setAlbumImportMode("album");
setAlbumNameInput("");
setSelectedAssetIds(new Set());
setSelectedAlbumId(null);
if (!shareLink.trim()) {
setError("Please paste a shared album link.");
return;
}
setLoading(true);
try {
const response = await fetch("/api/import-shared", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ link: shareLink }),
});
if (!response.ok) {
const errorPayload = await response.json().catch(() => ({}));
throw new Error(errorPayload.error || "Failed to import shared album");
}
const payload: IImportSharedResponse = await response.json();
setSharedData(payload);
setSubmittedLink(payload.link);
setUploadBanner(null);
setImportAllLoading(false);
setAlbumImportMode("album");
} catch (err) {
const message = err instanceof Error ? err.message : "Unexpected error";
setError(message);
} finally {
setLoading(false);
}
};
const sharedLinkDetails = sharedData?.sharedLink;
const albumDetails = sharedData?.album;
const importableAssetCount =
albumDetails?.assets.filter((asset) => asset.type === "IMAGE" || asset.type === "VIDEO").length ?? 0;
const buildThumbnailUrl = (asset: IImportSharedAsset) => {
if (!sharedData) {
return null;
}
const params = new URLSearchParams({
assetId: asset.id,
origin: sharedData.origin,
key: sharedData.key,
size: "thumbnail",
});
if (asset.thumbhash) {
params.set("thumbhash", asset.thumbhash);
}
return `/api/import-shared/thumbnail?${params.toString()}`;
};
const buildVideoPlaybackUrl = (asset: IImportSharedAsset) => {
if (!sharedData) {
return null;
}
const params = new URLSearchParams({
assetId: asset.id,
origin: sharedData.origin,
key: sharedData.key,
});
if (asset.thumbhash) {
params.set("thumbhash", asset.thumbhash);
}
return `/api/import-shared/video?${params.toString()}`;
};
const buildPreviewUrl = (asset: IImportSharedAsset) => {
if (!sharedData) {
return null;
}
const params = new URLSearchParams({
assetId: asset.id,
origin: sharedData.origin,
key: sharedData.key,
size: "preview",
});
if (asset.thumbhash) {
params.set("thumbhash", asset.thumbhash);
}
return `/api/import-shared/thumbnail?${params.toString()}`;
};
const handleImportAll = async (options: { createAlbum: boolean; albumName?: string; addToAlbumId?: string }) => {
if (!sharedData || !albumDetails) {
return;
}
let uploadableAssets = albumDetails.assets.filter(
(asset) => asset.type === "IMAGE" || asset.type === "VIDEO"
);
if (selectedAssetIds.size > 0) {
uploadableAssets = uploadableAssets.filter((asset) => selectedAssetIds.has(asset.id));
}
if (uploadableAssets.length === 0) {
setUploadBanner({ type: "error", message: "No assets selected to import." });
return;
}
const normalizedAlbumName = options.createAlbum
? options.albumName?.trim() || albumDetails.albumName || "Imported shared album"
: undefined;
const albumOptions = options.createAlbum
? { createAlbum: true, albumName: normalizedAlbumName }
: options.addToAlbumId
? { createAlbum: false, addToAlbumId: options.addToAlbumId }
: { createAlbum: false };
setUploadBanner(null);
setImportAllLoading(true);
try {
const response = await fetch("/api/import-shared/upload-all", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
origin: sharedData.origin,
key: sharedData.key,
assets: uploadableAssets.map((asset) => ({
id: asset.id,
originalFileName: asset.originalFileName,
type: asset.type,
fileCreatedAt: asset.fileCreatedAt ?? null,
localDateTime: asset.localDateTime ?? null,
duration: asset.duration ?? null,
isFavorite: asset.isFavorite ?? false,
isArchived: asset.isArchived ?? false,
})),
albumOptions,
}),
});
const result = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(result.error || "Failed to import shared archive");
}
const processedAssetIds: string[] = result.processedAssetIds ?? [];
const skippedAssetIds: string[] = result.skippedAssetIds ?? [];
const failed: { assetId: string; reason?: string }[] = result.failed ?? [];
const failedCount = failed.length;
const skippedCount = skippedAssetIds.length;
const uploadedCount = result.uploadedCount ?? processedAssetIds.length;
let bannerType: "success" | "error" = failedCount ? "error" : "success";
let message: string;
if (!uploadedCount && skippedCount && !failedCount) {
message = `All ${skippedCount} assets already exist on Immich.`;
} else if (failedCount) {
const skippedSuffix = skippedCount ? `, ${skippedCount} skipped` : "";
message = `Imported ${uploadedCount} assets, ${failedCount} failed${skippedSuffix}.`;
} else if (skippedCount) {
message = `Imported ${uploadedCount} assets, skipped ${skippedCount} already on Immich.`;
} else {
message = `Imported ${uploadedCount} assets successfully.`;
}
if (options.createAlbum && uploadedCount > 0 && normalizedAlbumName) {
message = `${message} Added to album "${normalizedAlbumName}".`;
} else if (options.addToAlbumId && uploadedCount > 0) {
const album = existingAlbums.find((a) => a.id === options.addToAlbumId);
message = `${message} Added to album "${album?.albumName || "existing album"}".`;
}
setUploadBanner({ type: bannerType, message });
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to import shared archive";
setUploadBanner({ type: "error", message });
} finally {
setImportAllLoading(false);
}
};
return (
<PageLayout className="!p-0 !mb-0 relative pb-20">
<Header
leftComponent={
sharedData ? (
<Button variant="ghost" size="sm" onClick={() => {
setSharedData(null);
setShareLink("");
setSubmittedLink(null);
setError(null);
setActiveVideoAsset(null);
setActiveImageAsset(null);
setPreviewLoading(false);
setUploadBanner(null);
setImportAllLoading(false);
setImportAllDialogOpen(false);
setAlbumImportMode("album");
setAlbumNameInput("");
setSelectedAssetIds(new Set());
setSelectedAlbumId(null);
}}>
← Back
</Button>
) : (
"Import shared"
)
}
rightComponent={
sharedData && albumDetails ? (
<div className="flex gap-2">
{selectedAssetIds.size > 0 && (
<Button
variant="outline"
size="sm"
onClick={() => setSelectedAssetIds(new Set())}
disabled={importAllLoading}
>
Clear selection
</Button>
)}
<Button
variant="default"
size="sm"
disabled={importAllLoading || importableAssetCount === 0}
onClick={() => {
if (importAllLoading) {
return;
}
if (!albumNameInput.trim()) {
setAlbumNameInput(albumDetails?.albumName || "Imported shared album");
}
setAlbumImportMode("album");
setImportAllDialogOpen(true);
}}
>
{importAllLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{selectedAssetIds.size > 0 ? `Import selected (${selectedAssetIds.size})` : "Import all"}
</Button>
</div>
) : null
}
/>
<div className={!sharedData ? "flex flex-1 justify-center px-4 py-6" : "flex flex-col gap-6 p-4"}>
<section className={!sharedData ? "w-full max-w-6xl flex flex-col gap-6" : "w-full flex flex-col gap-6"}>
{!sharedData && (
<Card>
<CardHeader className="space-y-2">
<CardTitle className="text-2xl">Import a shared album</CardTitle>
<CardDescription>
Paste the shared public link for the album you want to import.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="share-link">Shared album link</Label>
<Input
id="share-link"
value={shareLink}
onChange={(event) => setShareLink(event.target.value)}
placeholder="https://demo.immich.app/share/<key>"
type="url"
required
/>
</div>
<div className="flex items-center justify-end gap-2">
<Button type="submit" disabled={!shareLink.trim() || loading}>
{loading ? "Processing..." : "Submit"}
</Button>
</div>
</form>
{error && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error}
</div>
)}
</CardContent>
</Card>
)}
{sharedLinkDetails && sharedData && albumDetails && (
<Card>
<CardHeader className="space-y-1">
<CardTitle className="text-xl">{albumDetails.albumName}</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-3">
<InfoRow label="Expires" value={sharedLinkDetails.expiresAt ? formatDate(sharedLinkDetails.expiresAt) : "No expiry"} />
{albumDetails && (
<>
<InfoRow label="Owner" value={albumDetails.owner?.name ?? "Unknown"} />
<InfoRow label="Assets" value={albumDetails.assetCount} />
<InfoRow label="Date range" value={`${formatDateOnly(albumDetails.startDate)} → ${formatDateOnly(albumDetails.endDate)}`} />
</>
)}
</CardContent>
</Card>
)}
{albumDetails && sharedData && (
<Card>
<CardHeader className="space-y-1">
<CardTitle className="text-xl">Gallery</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{uploadBanner && (
<div
className={`rounded-md border px-4 py-2 text-sm ${
uploadBanner.type === "success"
? "border-emerald-500/40 bg-emerald-500/10 text-emerald-600"
: "border-destructive/40 bg-destructive/10 text-destructive"
}`}
>
{uploadBanner.message}
</div>
)}
{albumDetails.description && (
<p className="text-sm text-muted-foreground">{albumDetails.description}</p>
)}
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-8">
{albumDetails.assets.length === 0 && (
<div className="col-span-full rounded-lg border border-border p-6 text-center text-sm text-muted-foreground">
This album has no assets yet.
</div>
)}
{albumDetails.assets.map((asset) => {
const formattedSize = formatFileSize(asset.fileSizeInByte);
const thumbnailUrl = buildThumbnailUrl(asset);
const isVideoAsset = asset.type === "VIDEO";
const isInteractive = true;
const ariaLabel = isVideoAsset
? `Play video ${asset.originalFileName}`
: `View photo ${asset.originalFileName}`;
const isSelected = selectedAssetIds.has(asset.id);
return (
<div
key={asset.id}
className={`flex flex-col overflow-hidden rounded-xl border border-border bg-card shadow-sm ${
isInteractive ?
"cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" :
""
} ${isSelected ? "ring-2 ring-primary" : ""}`}
onClick={() => {
if (isVideoAsset) {
setActiveImageAsset(null);
setActiveVideoAsset(asset);
setPreviewLoading(false);
} else {
setActiveVideoAsset(null);
setPreviewLoading(true);
setActiveImageAsset(asset);
}
}}
role={isInteractive ? "button" : undefined}
tabIndex={isInteractive ? 0 : undefined}
aria-label={ariaLabel}
onKeyDown={(event) => {
if (!isInteractive) {
return;
}
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
if (isVideoAsset) {
setActiveImageAsset(null);
setActiveVideoAsset(asset);
setPreviewLoading(false);
} else {
setActiveVideoAsset(null);
setPreviewLoading(true);
setActiveImageAsset(asset);
}
}
}}
>
<div className="relative aspect-square w-full bg-muted">
<div className="absolute top-2 left-2 z-10" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isSelected}
onCheckedChange={() => {
const newSelection = new Set(selectedAssetIds);
if (newSelection.has(asset.id)) {
newSelection.delete(asset.id);
} else {
newSelection.add(asset.id);
}
setSelectedAssetIds(newSelection);
}}
className="bg-white/80 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground"
/>
</div>
{thumbnailUrl ? (
<img
src={thumbnailUrl}
alt={`Thumbnail for ${asset.originalFileName}`}
loading="lazy"
className="absolute inset-0 h-full w-full object-cover"
/>
) : (
<div className="absolute inset-0 flex h-full w-full items-center justify-center text-xs text-muted-foreground">
No preview
</div>
)}
{isVideoAsset && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="rounded-full bg-black/60 px-4 py-2 text-sm font-semibold uppercase tracking-wide text-white">
▶ Play
</div>
</div>
)}
<div className="absolute bottom-2 right-2 flex gap-2">
<span className="rounded-full bg-black/60 px-2 py-0.5 text-xs text-white">
{asset.type}
</span>
{formattedSize && (
<span className="rounded-full bg-black/60 px-2 py-0.5 text-xs text-white">
{formattedSize}
</span>
)}
</div>
</div>
<div className="flex flex-col gap-1 p-3">
<p className="text-sm font-semibold text-foreground" title={asset.originalFileName}>
{asset.originalFileName}
</p>
<p className="text-xs text-muted-foreground">Captured {formatDate(asset.fileCreatedAt || asset.localDateTime)}</p>
{asset.location && (
<p className="text-xs text-muted-foreground">{asset.location}</p>
)}
{asset.description && (
<p className="text-xs text-muted-foreground line-clamp-2">{asset.description}</p>
)}
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
)}
</section>
</div>
<Dialog
open={importAllDialogOpen}
onOpenChange={(open) => {
if (importAllLoading) {
return;
}
setImportAllDialogOpen(open);
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{selectedAssetIds.size > 0 ? "Import selected assets" : "Import all assets"}</DialogTitle>
<DialogDescription>
Decide whether to create a new Immich album for these imported assets.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="flex flex-col gap-2">
<Label className="text-sm font-medium text-foreground">Destination</Label>
<div className="flex flex-col gap-2 sm:flex-row">
<Button
type="button"
variant={albumImportMode === "album" ? "default" : "outline"}
className="flex-1"
onClick={() => setAlbumImportMode("album")}
disabled={importAllLoading}
>
Create a new album
</Button>
<Button
type="button"
variant={albumImportMode === "existing-album" ? "default" : "outline"}
className="flex-1"
onClick={() => setAlbumImportMode("existing-album")}
disabled={importAllLoading}
>
Existing Album
</Button>
<Button
type="button"
variant={albumImportMode === "no-album" ? "default" : "outline"}
className="flex-1"
onClick={() => setAlbumImportMode("no-album")}
disabled={importAllLoading}
>
No Album
</Button>
</div>
</div>
{albumImportMode === "album" && (
<div className="flex flex-col gap-2">
<Label htmlFor="import-album-name">Album name</Label>
<Input
id="import-album-name"
value={albumNameInput}
onChange={(event) => setAlbumNameInput(event.target.value)}
placeholder="Imported shared album"
disabled={importAllLoading}
/>
</div>
)}
{albumImportMode === "existing-album" && (
<div className="flex flex-col gap-2">
<Label htmlFor="existing-album">Select Album</Label>
<Select onValueChange={setSelectedAlbumId} value={selectedAlbumId || undefined}>
<SelectTrigger>
<SelectValue placeholder="Select an album" />
</SelectTrigger>
<SelectContent>
{existingAlbums.map((album) => (
<SelectItem key={album.id} value={album.id}>
{album.albumName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
<div className="flex justify-end gap-2 pt-4">
<Button
type="button"
variant="ghost"
onClick={() => setImportAllDialogOpen(false)}
disabled={importAllLoading}
>
Cancel
</Button>
<Button
type="button"
onClick={() => {
if (importAllLoading) {
return;
}
const shouldCreateAlbum = albumImportMode === "album";
const trimmedAlbumName = albumNameInput.trim();
setImportAllDialogOpen(false);
handleImportAll({
createAlbum: shouldCreateAlbum,
albumName: shouldCreateAlbum ? trimmedAlbumName : undefined,
addToAlbumId: albumImportMode === "existing-album" && selectedAlbumId ? selectedAlbumId : undefined,
});
}}
disabled={
importAllLoading ||
(albumImportMode === "album" && albumNameInput.trim().length === 0) ||
(albumImportMode === "existing-album" && !selectedAlbumId)
}
>
{importAllLoading ? "Importing..." : "Start import"}
</Button>
</div>
</DialogContent>
</Dialog>
<Dialog
open={!!activeVideoAsset}
onOpenChange={(open) => {
if (!open) {
setActiveVideoAsset(null);
}
}}
>
<DialogContent className="max-w-3xl">
{activeVideoAsset && (
<>
<DialogHeader>
<DialogTitle>{activeVideoAsset.originalFileName}</DialogTitle>
</DialogHeader>
<div className="flex w-full justify-center">
<video
controls
autoPlay
playsInline
className="max-h-[70vh] w-auto max-w-full rounded-lg bg-black object-contain"
src={buildVideoPlaybackUrl(activeVideoAsset) ?? undefined}
poster={buildThumbnailUrl(activeVideoAsset) ?? undefined}
/>
</div>
</>
)}
</DialogContent>
</Dialog>
<Dialog
open={!!activeImageAsset}
onOpenChange={(open) => {
if (!open) {
setActiveImageAsset(null);
setPreviewLoading(false);
}
}}
>
<DialogContent className="max-w-3xl">
{activeImageAsset && (
<>
<DialogHeader>
<DialogTitle>{activeImageAsset.originalFileName}</DialogTitle>
</DialogHeader>
<div className="relative flex w-full justify-center min-h-[200px]">
<img
src={buildPreviewUrl(activeImageAsset) ?? undefined}
alt={activeImageAsset.originalFileName}
className={`max-h-[80vh] w-auto max-w-full rounded-lg object-contain transition-opacity ${
previewLoading ? "opacity-0" : "opacity-100"
}`}
onLoad={() => setPreviewLoading(false)}
onError={() => setPreviewLoading(false)}
/>
{previewLoading && (
<div className="absolute inset-0 flex items-center justify-center">
<Loader2 className="h-10 w-10 animate-spin text-muted-foreground" />
</div>
)}
</div>
</>
)}
</DialogContent>
</Dialog>
</PageLayout>
);
}
+2 -2
View File
@@ -1,7 +1,7 @@
import { pgTable, uuid, timestamp } from 'drizzle-orm/pg-core';
export const albumsAssetsAssets = pgTable('album_asset', {
albumsId: uuid('albumsId').notNull(),
assetsId: uuid('assetsId').notNull(),
albumsId: uuid('albumId').notNull(),
assetsId: uuid('assetId').notNull(),
createdAt: timestamp('createdAt', { withTimezone: true }).defaultNow().notNull(),
});