Files
2026-07-17 02:23:59 +02:00

219 lines
7.0 KiB
TypeScript

import fs from "fs/promises";
import path from "path";
import { Plan, Privacy } from "@prisma/client";
import { readAudioTags } from "../audio-tags";
import { isAllowedResolution } from "../constants";
import { prisma } from "../db";
import { hasProFeatures } from "../edition";
import { moveFile, writeUploadedFile } from "../fs-utils";
import { enqueueVideoJob } from "../queue/client";
import {
getPlanLimits,
isAudioExtensionAllowed,
isResolutionAllowedForPlan,
} from "../plans";
import { releaseReservationSplit, reserveQuota } from "../quota";
import { getJobDir } from "../storage";
import {
assertPathInUserUploads,
getUserStagingDir,
sanitizeUploadSessionKey,
} from "../upload-paths";
import type { CreateJobPayload } from "../types";
export function validateItemMetadata(metadata: CreateJobPayload["items"][0]["metadata"]) {
if (!metadata.title?.trim()) return "Each video must have a title";
if (!isAllowedResolution(metadata.resolution)) return "Invalid resolution";
if (!["PUBLIC", "PRIVATE", "UNLISTED"].includes(metadata.privacy)) {
return "Invalid privacy setting";
}
return null;
}
export async function validateJobPayload(user: { id: string; plan: Plan }, body: CreateJobPayload) {
if (!body.imagePath || !body.items?.length) {
return "Image and at least one audio file required";
}
for (const item of body.items) {
const metaError = validateItemMetadata(item.metadata);
if (metaError) return metaError;
if (!isResolutionAllowedForPlan(item.metadata.resolution, user.plan)) {
return `Resolution ${item.metadata.resolution} is not available on your plan`;
}
if (item.metadata.playlistId && !hasProFeatures(user.plan)) {
return "Adding videos to a YouTube playlist requires the Pro plan";
}
}
const limits = getPlanLimits(user.plan);
try {
const imagePath = assertPathInUserUploads(user.id, body.imagePath);
await fs.access(imagePath);
const imageStat = await fs.stat(imagePath);
if (imageStat.size > limits.maxFileSizeBytes) {
return "Image exceeds size limit";
}
for (const item of body.items) {
if (!isAudioExtensionAllowed(item.audioFilename, user.plan)) {
return `Audio file ${item.audioFilename} is not supported on your plan`;
}
const audioPath = assertPathInUserUploads(user.id, item.audioPath);
await fs.access(audioPath);
const stat = await fs.stat(audioPath);
if (stat.size > limits.maxFileSizeBytes) {
return `Audio file ${item.audioFilename} exceeds size limit`;
}
}
} catch (err) {
if (err instanceof Error && err.message === "Invalid upload path") {
return "Invalid upload path";
}
return "One or more uploaded files not found";
}
return null;
}
export async function createVideoJob(
user: { id: string; plan: Plan },
body: CreateJobPayload,
) {
const validationError = await validateJobPayload(user, body);
if (validationError) {
throw new Error(validationError);
}
const imagePath = assertPathInUserUploads(user.id, body.imagePath);
const items = body.items.map((item) => ({
...item,
audioPath: assertPathInUserUploads(user.id, item.audioPath),
}));
const reservation = await reserveQuota(user.id, items.length);
try {
const job = await prisma.job.create({
data: {
userId: user.id,
imagePath,
items: {
create: items.map((item, index) => ({
audioPath: item.audioPath,
audioFilename: item.audioFilename,
title: item.metadata.title.trim(),
description: item.metadata.description || "",
tags: item.metadata.tags || "",
privacy: item.metadata.privacy as Privacy,
categoryId: item.metadata.categoryId || "10",
resolution: item.metadata.resolution,
notifySubscribers: item.metadata.notifySubscribers,
madeForKids: item.metadata.madeForKids,
embeddable: item.metadata.embeddable,
creativeCommons: item.metadata.creativeCommons,
includeWatermark: item.metadata.includeWatermark,
playlistId: item.metadata.playlistId?.trim() || null,
billingSource: index < reservation.fromQuota ? "QUOTA" : "CREDIT",
})),
},
},
include: { items: true },
});
const jobDir = getJobDir(user.id, job.id);
await fs.mkdir(jobDir, { recursive: true });
const imageExt = path.extname(imagePath);
const newImagePath = path.join(jobDir, `image${imageExt}`);
await moveFile(imagePath, newImagePath);
await prisma.job.update({ where: { id: job.id }, data: { imagePath: newImagePath } });
await Promise.all(
job.items.map(async (item) => {
const audioExt = path.extname(item.audioPath);
const newAudioPath = path.join(jobDir, `${item.id}${audioExt}`);
await moveFile(item.audioPath, newAudioPath);
await prisma.jobItem.update({
where: { id: item.id },
data: { audioPath: newAudioPath },
});
await enqueueVideoJob({
jobItemId: item.id,
userId: user.id,
jobId: job.id,
});
}),
);
return job;
} catch (err) {
await releaseReservationSplit(user.id, reservation).catch(() => {});
throw err;
}
}
export async function saveUploadedFile(
userId: string,
file: File,
type: "image" | "audio",
plan: Plan,
options?: { sessionKey?: string },
) {
const limits = getPlanLimits(plan);
if (file.size > limits.maxFileSizeBytes) {
throw new Error(`File exceeds ${limits.maxFileSizeBytes / (1024 * 1024)} MB limit`);
}
const allowedImageTypes = ["image/jpeg", "image/png", "image/webp", "image/gif"];
const allowedAudioTypes = [
"audio/mpeg",
"audio/mp3",
"audio/wav",
"audio/x-wav",
"audio/ogg",
"audio/flac",
"audio/aac",
"audio/mp4",
"audio/x-m4a",
];
const allowedTypes = type === "image" ? allowedImageTypes : allowedAudioTypes;
if (
!allowedTypes.includes(file.type) &&
!file.name.match(/\.(mp3|wav|ogg|flac|aac|m4a|jpg|jpeg|png|webp|gif)$/i)
) {
throw new Error("Invalid file type");
}
if (type === "audio" && !isAudioExtensionAllowed(file.name, plan)) {
const allowed = limits.allowedAudioExtensions.join(", ");
throw new Error(`Your plan supports ${allowed} audio files only`);
}
const sessionKey = sanitizeUploadSessionKey(options?.sessionKey);
const sessionDir = getUserStagingDir(userId, sessionKey);
await fs.mkdir(sessionDir, { recursive: true });
const safeName = file.name.replace(/[^a-zA-Z0-9._-]/g, "_");
const uniqueName = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}-${safeName}`;
const filePath = path.join(sessionDir, uniqueName);
assertPathInUserUploads(userId, filePath);
await writeUploadedFile(file, filePath);
let audioTags = null;
if (type === "audio" && limits.id3TagSupport && file.name.toLowerCase().endsWith(".mp3")) {
audioTags = await readAudioTags(filePath);
}
return {
path: filePath,
filename: file.name,
size: file.size,
audioTags,
};
}