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, }; }