diff --git a/src/pages/api/assets/compression/replace.ts b/src/pages/api/assets/compression/replace.ts index a52f18a..c2b8382 100644 --- a/src/pages/api/assets/compression/replace.ts +++ b/src/pages/api/assets/compression/replace.ts @@ -2,10 +2,11 @@ import { ENV } from "@/config/environment"; import { db } from "@/config/db"; import { getCurrentUser } from "@/handlers/serverUtils/user.utils"; import { getUserHeaders } from "@/helpers/user.helper"; -import { clearCompressionDirectory, getCompressionOutputPath, getVideoContentType } from "@/helpers/compression.server"; +import { clearCompressionDirectory, getCompressionOutputPath } from "@/helpers/compression.server"; import { assets } from "@/schema"; import { and, eq } from "drizzle-orm"; -import { openAsBlob } from "node:fs"; +import { createReadStream } from "node:fs"; +import { stat } from "node:fs/promises"; import path from "node:path"; import type { NextApiRequest, NextApiResponse } from "next"; @@ -14,6 +15,24 @@ const readError = async (response: Response) => { return body || response.statusText || `HTTP ${response.status}`; }; +class UploadFile extends File { + constructor( + private readonly filePath: string, + private readonly fileSize: number, + fileName: string, + ) { + super([], fileName); + } + + override get size() { + return this.fileSize; + } + + override stream() { + return createReadStream(this.filePath) as any; + } +} + export default async function handler(req: NextApiRequest, res: NextApiResponse) { if (req.method !== "POST") return res.status(405).json({ message: "Method not allowed" }); @@ -65,18 +84,19 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const replacementFileName = path.basename(source.originalFileName || dbAsset.originalFileName || `${dbAsset.id}.mp4`); const outputPath = getCompressionOutputPath(dbAsset.id, dbAsset.originalFileName); - const fileBlob = await openAsBlob(outputPath, { type: getVideoContentType(outputPath) }); + const outputStats = await stat(outputPath); + const uploadFile = new UploadFile(outputPath, outputStats.size, replacementFileName); + + // Keep the upload payload aligned with Immich's own CLI uploader. + // Restore Power Tools/custom metadata only after Immich has created the asset. const formData = new FormData(); formData.set("fileCreatedAt", source.fileCreatedAt); formData.set("fileModifiedAt", source.fileModifiedAt); + formData.set("fileSize", String(outputStats.size)); formData.set("filename", replacementFileName); - formData.set("assetData", fileBlob, replacementFileName); - if (typeof source.duration === "number") formData.set("duration", String(source.duration)); + formData.set("isFavorite", source.isFavorite ? "true" : "false"); + formData.set("assetData", uploadFile, replacementFileName); if (source.visibility) formData.set("visibility", source.visibility); - if (source.isFavorite) formData.set("isFavorite", "true"); - if (Array.isArray(customMetadata) && customMetadata.length > 0) { - formData.set("metadata", JSON.stringify(customMetadata.map(({ key, value }: any) => ({ key, value })))); - } const { ["Content-Type"]: _contentType, ...uploadHeaders } = jsonHeaders; const uploadResponse = await fetch(`${ENV.IMMICH_URL}/api/assets`, { @@ -84,7 +104,13 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) headers: uploadHeaders, body: formData, }); - if (!uploadResponse.ok) throw new Error(`Failed to upload compressed asset: ${await readError(uploadResponse)}`); + if (!uploadResponse.ok) { + const details = await readError(uploadResponse); + if (uploadResponse.status === 403) { + throw new Error(`Immich rejected the upload (403). The configured API key/session needs asset.upload permission. ${details}`); + } + throw new Error(`Failed to upload compressed asset (${uploadResponse.status}): ${details}`); + } const uploaded = await uploadResponse.json(); if (!uploaded?.id) throw new Error("Immich upload response did not include an asset id"); @@ -95,6 +121,23 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) newAssetId = uploaded.id; createdNewAsset = true; + if (Array.isArray(customMetadata) && customMetadata.length > 0) { + const metadataItems = customMetadata + .filter((item: any) => item?.key && item?.value && typeof item.value === "object") + .map(({ key, value }: any) => ({ key, value })); + + if (metadataItems.length > 0) { + const customMetadataResponse = await fetch(`${ENV.IMMICH_URL}/api/assets/${newAssetId}/metadata`, { + method: "PUT", + headers: jsonHeaders, + body: JSON.stringify({ items: metadataItems }), + }); + if (!customMetadataResponse.ok) { + throw new Error(`Failed to restore custom metadata: ${await readError(customMetadataResponse)}`); + } + } + } + const copyResponse = await fetch(`${ENV.IMMICH_URL}/api/assets/copy`, { method: "PUT", headers: jsonHeaders, @@ -186,6 +229,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) fileName: replacementFileName, }); } catch (error: any) { + console.error("[compression:replace]", { + assetId: dbAsset.id, + message: error?.message, + stack: error?.stack, + }); if (rollbackSafe && changedLivePhotoParentIds.length > 0) { await Promise.all(changedLivePhotoParentIds.map((parentId) => fetch(`${ENV.IMMICH_URL}/api/assets/${parentId}`, {