219 lines
7.0 KiB
TypeScript
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,
|
|
};
|
|
}
|