feat: compression 11

This commit is contained in:
2026-09-24 21:05:37 +01:00
parent 01cdb3a0de
commit cc237d092c
+58 -10
View File
@@ -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}`, {