Files
immich-power-tools/diff.patch
T
2026-09-24 11:37:28 +01:00

973 lines
37 KiB
Diff

diff --git a/Dockerfile b/Dockerfile
index 6e7b68e..da5b04c 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -25,7 +25,7 @@ ENV VERSION=$VERSION
ENV NODE_ENV=production
-RUN apk add --no-cache curl
+RUN apk add --no-cache curl ffmpeg
RUN addgroup -g 1001 -S nodejs
RUN adduser -S nextjs -u 1001
diff --git a/src/config/compression.ts b/src/config/compression.ts
new file mode 100644
index 0000000..9356163
--- /dev/null
+++ b/src/config/compression.ts
@@ -0,0 +1,4 @@
+export const FFMPEG_COMMAND_SETTING = "video_compression_ffmpeg_command";
+
+export const DEFAULT_FFMPEG_COMMAND =
+ 'ffmpeg -i "{input}" -c:v libx265 -crf 28 -c:a aac "{output}"';
diff --git a/src/config/constants/sidebarNavs.tsx b/src/config/constants/sidebarNavs.tsx
index b511a7e..985cff1 100644
--- a/src/config/constants/sidebarNavs.tsx
+++ b/src/config/constants/sidebarNavs.tsx
@@ -28,6 +28,7 @@ export const sidebarGroups: SidebarGroup[] = [
{ title: "Potential Albums", link: "/albums/potential-albums", icon: <GalleryVerticalEnd className="h-4 w-4" /> },
{ title: "Missing Locations", link: "/assets/missing-locations", icon: <MapPinX className="h-4 w-4" /> },
{ title: "Empty Videos", link: "/assets/empty-videos", icon: <Video className="h-4 w-4" /> },
+ { title: "Compress Videos", link: "/assets/compress-videos", icon: <Video className="h-4 w-4" /> },
{ title: "Bulk Duplicate Finder", link: "/assets/bulk-duplicate-finder", icon: <Copy className="h-4 w-4" /> },
{ title: "Orphan Finder", link: "/assets/orphan-finder", icon: <PackageSearch className="h-4 w-4" /> },
],
diff --git a/src/config/routes.ts b/src/config/routes.ts
index 5111d13..cce8dd1 100644
--- a/src/config/routes.ts
+++ b/src/config/routes.ts
@@ -48,6 +48,10 @@ export const ASSET_VIDEO_PATH = (id: string) => BASE_PROXY_ENDPOINT + "/asset/vi
export const ASSET_GEO_HEATMAP_PATH = BASE_API_ENDPOINT + "/assets/geo-heatmap";
export const LIST_EMPTY_VIDEOS_PATH = BASE_API_ENDPOINT + "/assets/empty-videos";
export const LIST_ORPHAN_ASSETS_PATH = BASE_API_ENDPOINT + "/assets/orphan-finder";
+export const LIST_COMPRESSION_VIDEOS_PATH = BASE_API_ENDPOINT + "/assets/compression/videos";
+export const TRANSCODE_VIDEO_PATH = BASE_API_ENDPOINT + "/assets/compression/transcode";
+export const REPLACE_COMPRESSED_VIDEO_PATH = BASE_API_ENDPOINT + "/assets/compression/replace";
+export const COMPRESSION_RESULT_PATH = (id: string) => BASE_API_ENDPOINT + "/assets/compression/result/" + id;
diff --git a/src/handlers/api/asset.handler.ts b/src/handlers/api/asset.handler.ts
index 9cf09c7..a8f47e7 100644
--- a/src/handlers/api/asset.handler.ts
+++ b/src/handlers/api/asset.handler.ts
@@ -9,6 +9,10 @@ import {
UPDATE_ASSETS_PATH,
LIST_DUPLICATES_PATH,
LIST_ORPHAN_ASSETS_PATH,
+ LIST_COMPRESSION_VIDEOS_PATH,
+ TRANSCODE_VIDEO_PATH,
+ REPLACE_COMPRESSED_VIDEO_PATH,
+ COMPRESSION_RESULT_PATH,
} from "@/config/routes";
import { cleanUpAsset } from "@/helpers/asset.helper";
import API from "@/lib/api";
@@ -125,3 +129,26 @@ export const listOrphanAssets = async (filters: IOrphanFilters): Promise<IAsset[
if (filters.page) params.page = String(filters.page);
return API.get(LIST_ORPHAN_ASSETS_PATH, params).then((assets) => assets.map(cleanUpAsset));
}
+
+export const listCompressionVideos = async (page = 1, limit = 200): Promise<IAsset[]> => {
+ return API.get(LIST_COMPRESSION_VIDEOS_PATH, { page, limit }).then((assets) => assets.map(cleanUpAsset));
+};
+
+export interface ICompressionResult {
+ assetId: string;
+ originalSize: number;
+ compressedSize: number;
+ resultUrl: string;
+}
+
+export const transcodeVideo = async (assetId: string): Promise<ICompressionResult> => {
+ return API.post(TRANSCODE_VIDEO_PATH, { assetId });
+};
+
+export const replaceCompressedVideo = async (assetId: string) => {
+ return API.post(REPLACE_COMPRESSED_VIDEO_PATH, { assetId });
+};
+
+export const discardCompressedVideo = async (assetId: string) => {
+ return API.delete(COMPRESSION_RESULT_PATH(assetId));
+};
diff --git a/src/helpers/compression.server.ts b/src/helpers/compression.server.ts
new file mode 100644
index 0000000..ddfb70e
--- /dev/null
+++ b/src/helpers/compression.server.ts
@@ -0,0 +1,69 @@
+import { appDb } from "@/db";
+import { settings } from "@/db/schema/settings.schema";
+import { DEFAULT_FFMPEG_COMMAND, FFMPEG_COMMAND_SETTING } from "@/config/compression";
+import { and, eq } from "drizzle-orm";
+import { mkdir, rm } from "node:fs/promises";
+import path from "node:path";
+import { Readable } from "node:stream";
+import { pipeline } from "node:stream/promises";
+import { createWriteStream } from "node:fs";
+import { spawn } from "node:child_process";
+
+const COMPRESSION_ROOT = "/app/data/compression";
+
+export const getCompressionDirectory = (assetId: string) =>
+ path.join(COMPRESSION_ROOT, assetId);
+
+export const getCompressionInputPath = (assetId: string) =>
+ path.join(getCompressionDirectory(assetId), "input");
+
+export const getCompressionOutputPath = (assetId: string) =>
+ path.join(getCompressionDirectory(assetId), "output.mp4");
+
+export const ensureCompressionDirectory = async (assetId: string) => {
+ await mkdir(getCompressionDirectory(assetId), { recursive: true });
+};
+
+export const clearCompressionDirectory = async (assetId: string) => {
+ await rm(getCompressionDirectory(assetId), { recursive: true, force: true });
+};
+
+export const getFfmpegCommand = async (ownerId: string) => {
+ const [row] = await appDb
+ .select({ value: settings.value })
+ .from(settings)
+ .where(and(eq(settings.key, FFMPEG_COMMAND_SETTING), eq(settings.ownerId, ownerId)));
+
+ return row?.value || DEFAULT_FFMPEG_COMMAND;
+};
+
+export const saveResponseBody = async (response: Response, filePath: string) => {
+ if (!response.body) throw new Error("Immich returned an empty asset body");
+ await pipeline(
+ Readable.fromWeb(response.body as any),
+ createWriteStream(filePath)
+ );
+};
+
+export const runFfmpegCommand = async (
+ command: string,
+ inputPath: string,
+ outputPath: string
+) => {
+ const resolvedCommand = command
+ .split("{input}").join(inputPath)
+ .split("{output}").join(outputPath);
+
+ await new Promise<void>((resolve, reject) => {
+ const child = spawn(resolvedCommand, {
+ shell: true,
+ stdio: "ignore",
+ });
+
+ child.once("error", reject);
+ child.once("close", (code) => {
+ if (code === 0) resolve();
+ else reject(new Error(`FFmpeg exited with code ${code ?? "unknown"}`));
+ });
+ });
+};
diff --git a/src/pages/api/assets/compression/replace.ts b/src/pages/api/assets/compression/replace.ts
new file mode 100644
index 0000000..80984ff
--- /dev/null
+++ b/src/pages/api/assets/compression/replace.ts
@@ -0,0 +1,226 @@
+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 } from "@/helpers/compression.server";
+import { assets } from "@/schema";
+import { and, eq } from "drizzle-orm";
+import { openAsBlob } from "node:fs";
+import path from "node:path";
+import type { NextApiRequest, NextApiResponse } from "next";
+
+const readError = async (response: Response) => {
+ const body = await response.text().catch(() => "");
+ return body || response.statusText || `HTTP ${response.status}`;
+};
+
+export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+ if (req.method !== "POST") return res.status(405).json({ message: "Method not allowed" });
+
+ const currentUser = await getCurrentUser(req);
+ if (!currentUser) return res.status(401).json({ message: "Unauthorized" });
+
+ const rawAssetId = req.body?.assetId as string;
+ const [dbAsset] = await db
+ .select({ id: assets.id, type: assets.type })
+ .from(assets)
+ .where(and(eq(assets.id, rawAssetId), eq(assets.ownerId, currentUser.id)));
+
+ if (!dbAsset || dbAsset.type !== "VIDEO") {
+ return res.status(404).json({ message: "Video not found" });
+ }
+
+ const livePhotoParents = await db
+ .select({ id: assets.id })
+ .from(assets)
+ .where(and(eq(assets.livePhotoVideoId, dbAsset.id), eq(assets.ownerId, currentUser.id)));
+
+ const jsonHeaders = {
+ ...getUserHeaders(currentUser),
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ };
+
+ let newAssetId: string | null = null;
+ let createdNewAsset = false;
+ let sourcePrimaryStackId: string | null = null;
+ let stackPrimaryChanged = false;
+ const changedLivePhotoParentIds: string[] = [];
+ let rollbackSafe = true;
+
+ try {
+ const sourceResponse = await fetch(`${ENV.IMMICH_URL}/api/assets/${dbAsset.id}`, {
+ headers: jsonHeaders,
+ });
+ if (!sourceResponse.ok) throw new Error(`Failed to read source asset: ${await readError(sourceResponse)}`);
+ const source = await sourceResponse.json();
+ if (source.stack?.id && source.stack.primaryAssetId === dbAsset.id) {
+ sourcePrimaryStackId = source.stack.id;
+ }
+
+ const metadataResponse = await fetch(`${ENV.IMMICH_URL}/api/assets/${dbAsset.id}/metadata`, {
+ headers: jsonHeaders,
+ });
+ const customMetadata = metadataResponse.ok ? await metadataResponse.json() : [];
+
+ const originalExtension = path.extname(source.originalFileName || "");
+ const originalBase = originalExtension
+ ? path.basename(source.originalFileName, originalExtension)
+ : (source.originalFileName || dbAsset.id);
+ const replacementFileName = originalExtension.toLowerCase() === ".mp4"
+ ? source.originalFileName
+ : `${originalBase}.mp4`;
+
+ const fileBlob = await openAsBlob(getCompressionOutputPath(dbAsset.id), { type: "video/mp4" });
+ const formData = new FormData();
+ formData.set("fileCreatedAt", source.fileCreatedAt);
+ formData.set("fileModifiedAt", source.fileModifiedAt);
+ formData.set("filename", replacementFileName);
+ formData.set("assetData", fileBlob, replacementFileName);
+ if (typeof source.duration === "number") formData.set("duration", String(source.duration));
+ 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`, {
+ method: "POST",
+ headers: uploadHeaders,
+ body: formData,
+ });
+ if (!uploadResponse.ok) throw new Error(`Failed to upload compressed asset: ${await readError(uploadResponse)}`);
+
+ const uploaded = await uploadResponse.json();
+ if (!uploaded?.id) throw new Error("Immich upload response did not include an asset id");
+ if (uploaded.status === "duplicate") {
+ throw new Error("The compressed file already exists in Immich");
+ }
+
+ newAssetId = uploaded.id;
+ createdNewAsset = true;
+
+ const copyResponse = await fetch(`${ENV.IMMICH_URL}/api/assets/copy`, {
+ method: "PUT",
+ headers: jsonHeaders,
+ body: JSON.stringify({ sourceId: dbAsset.id, targetId: newAssetId }),
+ });
+ if (!copyResponse.ok) throw new Error(`Failed to copy asset associations: ${await readError(copyResponse)}`);
+
+ const exifInfo = source.exifInfo || {};
+ const update: Record<string, unknown> = {
+ isFavorite: Boolean(source.isFavorite),
+ visibility: source.visibility,
+ };
+ if (exifInfo.dateTimeOriginal) update.dateTimeOriginal = exifInfo.dateTimeOriginal;
+ if (typeof exifInfo.description === "string") update.description = exifInfo.description;
+ if (exifInfo.rating !== undefined) update.rating = exifInfo.rating;
+ if (exifInfo.latitude != null && exifInfo.longitude != null) {
+ update.latitude = exifInfo.latitude;
+ update.longitude = exifInfo.longitude;
+ }
+
+ const updateResponse = await fetch(`${ENV.IMMICH_URL}/api/assets/${newAssetId}`, {
+ method: "PATCH",
+ headers: jsonHeaders,
+ body: JSON.stringify(update),
+ });
+ if (!updateResponse.ok) throw new Error(`Failed to restore asset metadata: ${await readError(updateResponse)}`);
+
+ if (source.duplicateId) {
+ const duplicateResponse = await fetch(`${ENV.IMMICH_URL}/api/assets`, {
+ method: "PATCH",
+ headers: jsonHeaders,
+ body: JSON.stringify({ ids: [newAssetId], duplicateId: source.duplicateId }),
+ });
+ if (!duplicateResponse.ok) throw new Error(`Failed to restore duplicate group: ${await readError(duplicateResponse)}`);
+ }
+
+ const tagIds = Array.isArray(source.tags) ? source.tags.map((tag: any) => tag.id).filter(Boolean) : [];
+ if (tagIds.length > 0) {
+ const tagsResponse = await fetch(`${ENV.IMMICH_URL}/api/tags/assets`, {
+ method: "PUT",
+ headers: jsonHeaders,
+ body: JSON.stringify({ tagIds, assetIds: [newAssetId] }),
+ });
+ if (!tagsResponse.ok) throw new Error(`Failed to restore tags: ${await readError(tagsResponse)}`);
+ }
+
+ for (const parent of livePhotoParents) {
+ const parentResponse = await fetch(`${ENV.IMMICH_URL}/api/assets/${parent.id}`, {
+ method: "PATCH",
+ headers: jsonHeaders,
+ body: JSON.stringify({ livePhotoVideoId: newAssetId }),
+ });
+ if (!parentResponse.ok) {
+ throw new Error(`Failed to restore live photo association: ${await readError(parentResponse)}`);
+ }
+ changedLivePhotoParentIds.push(parent.id);
+ }
+
+ if (sourcePrimaryStackId) {
+ const stackResponse = await fetch(`${ENV.IMMICH_URL}/api/stacks/${sourcePrimaryStackId}`, {
+ method: "PATCH",
+ headers: jsonHeaders,
+ body: JSON.stringify({ primaryAssetId: newAssetId }),
+ });
+ if (!stackResponse.ok) throw new Error(`Failed to restore primary stack asset: ${await readError(stackResponse)}`);
+ stackPrimaryChanged = true;
+ }
+
+ let deleteResponse: Response;
+ try {
+ deleteResponse = await fetch(`${ENV.IMMICH_URL}/api/assets`, {
+ method: "DELETE",
+ headers: jsonHeaders,
+ body: JSON.stringify({ ids: [dbAsset.id], force: true }),
+ });
+ } catch {
+ // The server may have processed the delete even if the response was lost.
+ // Keep the new asset rather than risk deleting both copies.
+ rollbackSafe = false;
+ throw new Error("Connection lost while deleting the original. The new compressed asset was kept to avoid data loss.");
+ }
+ if (!deleteResponse.ok) throw new Error(`Failed to delete original asset: ${await readError(deleteResponse)}`);
+
+ await clearCompressionDirectory(dbAsset.id).catch(() => undefined);
+
+ return res.status(200).json({
+ oldAssetId: dbAsset.id,
+ newAssetId,
+ fileName: replacementFileName,
+ });
+ } catch (error: any) {
+ if (rollbackSafe && changedLivePhotoParentIds.length > 0) {
+ await Promise.all(changedLivePhotoParentIds.map((parentId) =>
+ fetch(`${ENV.IMMICH_URL}/api/assets/${parentId}`, {
+ method: "PATCH",
+ headers: jsonHeaders,
+ body: JSON.stringify({ livePhotoVideoId: dbAsset.id }),
+ }).catch(() => undefined)
+ ));
+ }
+
+ if (rollbackSafe && stackPrimaryChanged && sourcePrimaryStackId) {
+ await fetch(`${ENV.IMMICH_URL}/api/stacks/${sourcePrimaryStackId}`, {
+ method: "PATCH",
+ headers: jsonHeaders,
+ body: JSON.stringify({ primaryAssetId: dbAsset.id }),
+ }).catch(() => undefined);
+ }
+
+ if (rollbackSafe && createdNewAsset && newAssetId) {
+ await fetch(`${ENV.IMMICH_URL}/api/assets`, {
+ method: "DELETE",
+ headers: jsonHeaders,
+ body: JSON.stringify({ ids: [newAssetId], force: true }),
+ }).catch(() => undefined);
+ }
+ return res.status(500).json({ message: error?.message ?? "Failed to replace asset" });
+ }
+}
+
+export const config = {
+ api: { responseLimit: false },
+};
diff --git a/src/pages/api/assets/compression/result/[assetId].ts b/src/pages/api/assets/compression/result/[assetId].ts
new file mode 100644
index 0000000..d789270
--- /dev/null
+++ b/src/pages/api/assets/compression/result/[assetId].ts
@@ -0,0 +1,70 @@
+import { db } from "@/config/db";
+import { getCurrentUser } from "@/handlers/serverUtils/user.utils";
+import { clearCompressionDirectory, getCompressionOutputPath } from "@/helpers/compression.server";
+import { assets } from "@/schema";
+import { and, eq } from "drizzle-orm";
+import { createReadStream } from "node:fs";
+import { stat } from "node:fs/promises";
+import type { NextApiRequest, NextApiResponse } from "next";
+
+export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+ const currentUser = await getCurrentUser(req);
+ if (!currentUser) return res.status(401).json({ message: "Unauthorized" });
+
+ const rawAssetId = req.query.assetId as string;
+ const [asset] = await db
+ .select({ id: assets.id })
+ .from(assets)
+ .where(and(eq(assets.id, rawAssetId), eq(assets.ownerId, currentUser.id)));
+
+ if (!asset) return res.status(404).json({ message: "Asset not found" });
+
+ if (req.method === "DELETE") {
+ await clearCompressionDirectory(asset.id);
+ return res.status(204).end();
+ }
+
+ if (req.method !== "GET") return res.status(405).json({ message: "Method not allowed" });
+
+ const filePath = getCompressionOutputPath(asset.id);
+
+ try {
+ const fileStat = await stat(filePath);
+ const range = req.headers.range;
+
+ res.setHeader("Content-Type", "video/mp4");
+ res.setHeader("Accept-Ranges", "bytes");
+ res.setHeader("Cache-Control", "no-store");
+
+ if (!range) {
+ res.setHeader("Content-Length", fileStat.size);
+ createReadStream(filePath).pipe(res);
+ return;
+ }
+
+ const match = /^bytes=(\d+)-(\d*)$/.exec(range);
+ if (!match) {
+ res.setHeader("Content-Range", `bytes */${fileStat.size}`);
+ return res.status(416).end();
+ }
+
+ const start = Number(match[1]);
+ const requestedEnd = match[2] ? Number(match[2]) : fileStat.size - 1;
+ const end = Math.min(requestedEnd, fileStat.size - 1);
+ if (start >= fileStat.size || start > end) {
+ res.setHeader("Content-Range", `bytes */${fileStat.size}`);
+ return res.status(416).end();
+ }
+
+ res.status(206);
+ res.setHeader("Content-Range", `bytes ${start}-${end}/${fileStat.size}`);
+ res.setHeader("Content-Length", end - start + 1);
+ createReadStream(filePath, { start, end }).pipe(res);
+ } catch {
+ return res.status(404).json({ message: "Compressed file not found" });
+ }
+}
+
+export const config = {
+ api: { responseLimit: false },
+};
diff --git a/src/pages/api/assets/compression/transcode.ts b/src/pages/api/assets/compression/transcode.ts
new file mode 100644
index 0000000..c868b17
--- /dev/null
+++ b/src/pages/api/assets/compression/transcode.ts
@@ -0,0 +1,79 @@
+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,
+ ensureCompressionDirectory,
+ getCompressionInputPath,
+ getCompressionOutputPath,
+ getFfmpegCommand,
+ runFfmpegCommand,
+ saveResponseBody,
+} from "@/helpers/compression.server";
+import { assets } from "@/schema";
+import { and, eq } from "drizzle-orm";
+import { rm, stat } from "node:fs/promises";
+import type { NextApiRequest, NextApiResponse } from "next";
+
+export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+ if (req.method !== "POST") return res.status(405).json({ message: "Method not allowed" });
+
+ const currentUser = await getCurrentUser(req);
+ if (!currentUser) return res.status(401).json({ message: "Unauthorized" });
+
+ const assetId = req.body?.assetId as string;
+ const [asset] = await db
+ .select({
+ id: assets.id,
+ type: assets.type,
+ })
+ .from(assets)
+ .where(and(eq(assets.id, assetId), eq(assets.ownerId, currentUser.id)));
+
+ if (!asset || asset.type !== "VIDEO") {
+ return res.status(404).json({ message: "Video not found" });
+ }
+
+ const inputPath = getCompressionInputPath(asset.id);
+ const outputPath = getCompressionOutputPath(asset.id);
+
+ try {
+ await clearCompressionDirectory(asset.id);
+ await ensureCompressionDirectory(asset.id);
+
+ const downloadResponse = await fetch(`${ENV.IMMICH_URL}/api/assets/${asset.id}/original`, {
+ headers: {
+ ...getUserHeaders(currentUser),
+ Accept: "application/octet-stream",
+ },
+ });
+
+ if (!downloadResponse.ok) {
+ throw new Error(`Failed to download original asset (${downloadResponse.status})`);
+ }
+
+ await saveResponseBody(downloadResponse, inputPath);
+ const originalStat = await stat(inputPath);
+ const command = await getFfmpegCommand(currentUser.id);
+ await runFfmpegCommand(command, inputPath, outputPath);
+ const outputStat = await stat(outputPath);
+
+ await rm(inputPath, { force: true });
+
+ return res.status(200).json({
+ assetId: asset.id,
+ originalSize: originalStat.size,
+ compressedSize: outputStat.size,
+ resultUrl: `/api/assets/compression/result/${asset.id}`,
+ });
+ } catch (error: any) {
+ await rm(inputPath, { force: true }).catch(() => undefined);
+ await rm(outputPath, { force: true }).catch(() => undefined);
+ return res.status(500).json({ message: error?.message ?? "Compression failed" });
+ }
+}
+
+export const config = {
+ api: { responseLimit: false },
+};
diff --git a/src/pages/api/assets/compression/videos.ts b/src/pages/api/assets/compression/videos.ts
new file mode 100644
index 0000000..881db0b
--- /dev/null
+++ b/src/pages/api/assets/compression/videos.ts
@@ -0,0 +1,51 @@
+import { db } from "@/config/db";
+import { ASSET_VIDEO_PATH } from "@/config/routes";
+import { getCurrentUser } from "@/handlers/serverUtils/user.utils";
+import { isFlipped } from "@/helpers/asset.helper";
+import { assets, exif } from "@/schema";
+import { and, desc, eq, isNull } from "drizzle-orm";
+import type { NextApiRequest, NextApiResponse } from "next";
+
+export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+ const currentUser = await getCurrentUser(req);
+ if (!currentUser) return res.status(401).json({ message: "Unauthorized" });
+ if (req.method !== "GET") return res.status(405).json({ message: "Method not allowed" });
+
+ const page = Math.max(1, Number(req.query.page) || 1);
+ const limit = Math.max(1, Number(req.query.limit) || 200);
+
+ const rows = await db
+ .select({
+ id: assets.id,
+ type: assets.type,
+ originalPath: assets.originalPath,
+ isFavorite: assets.isFavorite,
+ duration: assets.duration,
+ originalFileName: assets.originalFileName,
+ localDateTime: assets.localDateTime,
+ exifImageWidth: exif.exifImageWidth,
+ exifImageHeight: exif.exifImageHeight,
+ ownerId: assets.ownerId,
+ dateTimeOriginal: exif.dateTimeOriginal,
+ orientation: exif.orientation,
+ fileSizeInByte: exif.fileSizeInByte,
+ })
+ .from(assets)
+ .leftJoin(exif, eq(assets.id, exif.assetId))
+ .where(and(
+ eq(assets.ownerId, currentUser.id),
+ eq(assets.type, "VIDEO"),
+ eq(assets.status, "active"),
+ isNull(assets.deletedAt)
+ ))
+ .orderBy(desc(assets.localDateTime))
+ .limit(limit)
+ .offset((page - 1) * limit);
+
+ return res.status(200).json(rows.map((asset) => ({
+ ...asset,
+ exifImageHeight: isFlipped(asset.orientation) ? asset.exifImageWidth : asset.exifImageHeight,
+ exifImageWidth: isFlipped(asset.orientation) ? asset.exifImageHeight : asset.exifImageWidth,
+ downloadUrl: ASSET_VIDEO_PATH(asset.id),
+ })));
+}
diff --git a/src/pages/assets/compress-videos.tsx b/src/pages/assets/compress-videos.tsx
new file mode 100644
index 0000000..fd4de1b
--- /dev/null
+++ b/src/pages/assets/compress-videos.tsx
@@ -0,0 +1,245 @@
+import PageLayout from '@/components/layouts/PageLayout'
+import Header from '@/components/shared/Header'
+import AssetGrid from '@/components/shared/AssetGrid'
+import FloatingBar from '@/components/shared/FloatingBar'
+import Loader from '@/components/ui/loader'
+import { Button } from '@/components/ui/button'
+import { AlertDialog } from '@/components/ui/alert-dialog'
+import PhotoSelectionContext, { IPhotoSelectionContext } from '@/contexts/PhotoSelectionContext'
+import {
+ discardCompressedVideo,
+ ICompressionResult,
+ listCompressionVideos,
+ replaceCompressedVideo,
+ transcodeVideo,
+} from '@/handlers/api/asset.handler'
+import { IAsset } from '@/types/asset'
+import { ChevronLeft, ChevronRight, ExternalLink, Trash2, Video } from 'lucide-react'
+import { useEffect, useMemo, useState } from 'react'
+import toast from 'react-hot-toast'
+
+const PAGE_SIZE = 200
+
+const formatMB = (bytes?: number | null) => {
+ if (bytes == null) return 'Unknown'
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`
+}
+
+export default function CompressVideosPage() {
+ const [assets, setAssets] = useState<IAsset[]>([])
+ const [loading, setLoading] = useState(false)
+ const [page, setPage] = useState(1)
+ const [errorMessage, setErrorMessage] = useState('')
+ const [compressing, setCompressing] = useState(false)
+ const [replacing, setReplacing] = useState(false)
+ const [discarding, setDiscarding] = useState(false)
+ const [result, setResult] = useState<ICompressionResult | null>(null)
+
+ const [contextState, setContextState] = useState<IPhotoSelectionContext>({
+ selectedIds: [],
+ assets: [],
+ config: {
+ albumId: '',
+ sort: 'fileOriginalDate',
+ sortOrder: 'asc',
+ },
+ updateContext: (newConfig: Partial<IPhotoSelectionContext>) => {
+ setContextState((previous) => ({
+ ...previous,
+ ...newConfig,
+ config: newConfig.config
+ ? { ...previous.config, ...newConfig.config }
+ : previous.config,
+ }))
+ },
+ })
+
+ const selectedAsset = useMemo(() => {
+ const selectedId = contextState.selectedIds[0]
+ return contextState.assets.find((asset) => asset.id === selectedId) ?? null
+ }, [contextState.assets, contextState.selectedIds])
+
+ const loadVideos = async () => {
+ setLoading(true)
+ setErrorMessage('')
+ try {
+ const fetchedAssets = await listCompressionVideos(page, PAGE_SIZE)
+ setAssets(fetchedAssets)
+ contextState.updateContext({ assets: fetchedAssets, selectedIds: [] })
+ setResult(null)
+ } catch (error: any) {
+ setErrorMessage(error?.message ?? 'Failed to load videos')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ useEffect(() => {
+ loadVideos()
+ }, [page])
+
+ const handleSelectionChange = (ids: string[]) => {
+ if (result) {
+ contextState.updateContext({ selectedIds: [result.assetId] })
+ return
+ }
+ const selectedIds = ids.length > 0 ? [ids[ids.length - 1]] : []
+ contextState.updateContext({ selectedIds })
+ }
+
+ const handleCompress = async () => {
+ if (!selectedAsset) return
+ setCompressing(true)
+ setResult(null)
+ try {
+ const compressionResult = await transcodeVideo(selectedAsset.id)
+ setResult(compressionResult)
+ toast.success('Compression finished')
+ } catch (error: any) {
+ toast.error(error?.message ?? 'Compression failed')
+ } finally {
+ setCompressing(false)
+ }
+ }
+
+ const handleDiscard = async () => {
+ if (!result) return
+ setDiscarding(true)
+ try {
+ await discardCompressedVideo(result.assetId)
+ setResult(null)
+ toast.success('Compressed file discarded')
+ } catch (error: any) {
+ toast.error(error?.message ?? 'Failed to discard compressed file')
+ } finally {
+ setDiscarding(false)
+ }
+ }
+
+ const handleReplace = async () => {
+ if (!result) return
+ setReplacing(true)
+ try {
+ await replaceCompressedVideo(result.assetId)
+ const remainingAssets = assets.filter((asset) => asset.id !== result.assetId)
+ setAssets(remainingAssets)
+ contextState.updateContext({ assets: remainingAssets, selectedIds: [] })
+ setResult(null)
+ toast.success('Asset replaced in Immich')
+ } catch (error: any) {
+ toast.error(error?.message ?? 'Failed to replace asset')
+ } finally {
+ setReplacing(false)
+ }
+ }
+
+ const savedBytes = result ? result.originalSize - result.compressedSize : 0
+ const savedPercent = result && result.originalSize > 0
+ ? Math.round((savedBytes / result.originalSize) * 100)
+ : 0
+
+ return (
+ <PageLayout className="!p-0 !mb-0 relative pb-24">
+ <Header
+ leftComponent="Compress Videos"
+ rightComponent={
+ <div className="flex items-center gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ disabled={page === 1 || loading || !!result}
+ onClick={() => setPage((value) => Math.max(1, value - 1))}
+ >
+ <ChevronLeft className="w-4 h-4" />
+ </Button>
+ <span className="text-sm text-muted-foreground">Page {page}</span>
+ <Button
+ variant="outline"
+ size="sm"
+ disabled={assets.length < PAGE_SIZE || loading || !!result}
+ onClick={() => setPage((value) => value + 1)}
+ >
+ <ChevronRight className="w-4 h-4" />
+ </Button>
+ </div>
+ }
+ />
+
+ <PhotoSelectionContext.Provider value={{ ...contextState, updateContext: contextState.updateContext }}>
+ {loading ? (
+ <div className="flex justify-center py-16"><Loader /></div>
+ ) : errorMessage ? (
+ <div className="p-6 text-destructive">{errorMessage}</div>
+ ) : assets.length === 0 ? (
+ <div className="text-center py-16 text-muted-foreground">
+ <Video className="w-10 h-10 mx-auto mb-3" />
+ No videos found.
+ </div>
+ ) : (
+ <div className="p-4">
+ <AssetGrid
+ assets={assets}
+ selectable
+ onSelectionChange={handleSelectionChange}
+ />
+ </div>
+ )}
+
+ {selectedAsset && (
+ <FloatingBar className="max-w-3xl">
+ <div className="w-full flex items-center gap-4 justify-between">
+ <div className="min-w-0">
+ <div className="text-sm font-medium truncate">{selectedAsset.originalFileName}</div>
+ {!result ? (
+ <div className="text-xs text-muted-foreground">{formatMB(selectedAsset.fileSizeInByte)}</div>
+ ) : (
+ <div className="text-xs text-muted-foreground flex flex-wrap gap-x-3">
+ <span>Original: {formatMB(result.originalSize)}</span>
+ <span>Compressed: {formatMB(result.compressedSize)}</span>
+ <span>Saved: {formatMB(savedBytes)} ({savedPercent}%)</span>
+ </div>
+ )}
+ </div>
+
+ {!result ? (
+ <Button onClick={handleCompress} disabled={compressing}>
+ {compressing ? 'Compressing...' : 'Compress'}
+ </Button>
+ ) : (
+ <div className="flex items-center gap-2 shrink-0">
+ <Button
+ variant="outline"
+ onClick={() => window.open(result.resultUrl, '_blank')}
+ >
+ <ExternalLink className="w-4 h-4 mr-2" />
+ Preview
+ </Button>
+ <Button
+ variant="outline"
+ onClick={handleDiscard}
+ disabled={discarding || replacing}
+ >
+ <Trash2 className="w-4 h-4 mr-2" />
+ {discarding ? 'Discarding...' : 'Discard'}
+ </Button>
+ <AlertDialog
+ title="Replace this asset in Immich?"
+ description="The compressed video will be uploaded as a new Immich asset. Its associations and metadata will be copied, then the original asset will be permanently deleted."
+ onConfirm={handleReplace}
+ variant="destructive"
+ disabled={replacing || discarding}
+ asChild
+ >
+ <Button variant="destructive" disabled={replacing || discarding}>
+ {replacing ? 'Replacing...' : 'Replace in Immich'}
+ </Button>
+ </AlertDialog>
+ </div>
+ )}
+ </div>
+ </FloatingBar>
+ )}
+ </PhotoSelectionContext.Provider>
+ </PageLayout>
+ )
+}
diff --git a/src/pages/settings/index.tsx b/src/pages/settings/index.tsx
index a19eccf..ff59d15 100644
--- a/src/pages/settings/index.tsx
+++ b/src/pages/settings/index.tsx
@@ -5,7 +5,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Save, Eye, EyeOff, ExternalLink,
- Workflow, Shield, CheckCircle, XCircle, Server, Globe, Download, Wand2,
+ Workflow, Shield, CheckCircle, XCircle, Server, Globe, Download, Wand2, Video,
} from "lucide-react";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
@@ -13,6 +13,7 @@ import API from "@/lib/api";
import { useConfig } from "@/contexts/ConfigContext";
import Link from "next/link";
import { WORKFLOW_PERMISSIONS } from "@/config/permissions";
+import { DEFAULT_FFMPEG_COMMAND, FFMPEG_COMMAND_SETTING } from "@/config/compression";
function ConnectionStatus({ label, url, icon: Icon }: { label: string; url: string; icon: any }) {
return (
@@ -55,6 +56,30 @@ export default function SettingsPage() {
valid: boolean; error: string | null; permissions: string[]; missing: string[];
} | null>(null);
+ const [ffmpegCommand, setFfmpegCommand] = useState(DEFAULT_FFMPEG_COMMAND);
+ const [ffmpegCommandLoading, setFfmpegCommandLoading] = useState(false);
+
+ const fetchFfmpegCommand = async () => {
+ try {
+ const data = await API.get(`/api/settings/kv/${FFMPEG_COMMAND_SETTING}`);
+ setFfmpegCommand(data.value || DEFAULT_FFMPEG_COMMAND);
+ } catch {
+ setFfmpegCommand(DEFAULT_FFMPEG_COMMAND);
+ }
+ };
+
+ const saveFfmpegCommand = async () => {
+ setFfmpegCommandLoading(true);
+ try {
+ await API.put(`/api/settings/kv/${FFMPEG_COMMAND_SETTING}`, { value: ffmpegCommand });
+ toast.success("FFmpeg command saved");
+ } catch {
+ toast.error("Failed to save FFmpeg command");
+ } finally {
+ setFfmpegCommandLoading(false);
+ }
+ };
+
const fetchWorkflowApiKey = async () => {
try {
const data = await API.get("/api/settings/kv/workflow_api_key");
@@ -174,6 +199,7 @@ export default function SettingsPage() {
useEffect(() => {
fetchWorkflowApiKey();
fetchImportApiKey();
+ fetchFfmpegCommand();
}, []);
return (
@@ -203,6 +229,31 @@ export default function SettingsPage() {
</div>
</section>
+ {/* Video Compression */}
+ <section>
+ <div className="flex items-center gap-2 mb-3">
+ <Video className="h-4 w-4 text-muted-foreground" />
+ <h2 className="text-sm font-semibold tracking-wide uppercase text-muted-foreground">Video Compression</h2>
+ </div>
+ <div className="border rounded-lg p-4 space-y-3">
+ <p className="text-xs text-muted-foreground">
+ FFmpeg command used by the video compression tool. Available placeholders: <code>{"{input}"}</code> and <code>{"{output}"}</code>.
+ </p>
+ <textarea
+ className="w-full min-h-24 rounded-md border border-input bg-background px-3 py-2 text-sm font-mono ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
+ value={ffmpegCommand}
+ onChange={(event) => setFfmpegCommand(event.target.value)}
+ spellCheck={false}
+ />
+ <div className="flex justify-end">
+ <Button size="sm" onClick={saveFfmpegCommand} disabled={ffmpegCommandLoading}>
+ <Save className="h-3.5 w-3.5 mr-1" />
+ {ffmpegCommandLoading ? "Saving..." : "Save"}
+ </Button>
+ </div>
+ </div>
+ </section>
+
{/* Workflow API Key */}
<section>
<div className="flex items-center gap-2 mb-3">
diff --git a/src/types/asset.d.ts b/src/types/asset.d.ts
index 38f3f67..41899d8 100644
--- a/src/types/asset.d.ts
+++ b/src/types/asset.d.ts
@@ -16,6 +16,7 @@ export interface IAsset {
dateTimeOriginal: string;
orientation?: number | null | string;
downloadUrl?: string;
+ fileSizeInByte?: number | null;
}
export interface IAssetThumbhash {