Initial open-source self-hosted Songs2YT edition
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Plan } from "@prisma/client";
|
||||
import { hasProFeatures } from "../edition";
|
||||
import type { CreateJobPayload, CreatePlaylistRequest, ItemMetadata } from "../types";
|
||||
import { createYouTubePlaylist } from "../youtube/upload";
|
||||
|
||||
export function parseCreatePlaylistInput(raw: unknown): CreatePlaylistRequest | null {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
const value = raw as Record<string, unknown>;
|
||||
const title = typeof value.title === "string" ? value.title.trim() : "";
|
||||
if (!title) return null;
|
||||
|
||||
const privacy =
|
||||
value.privacy === "public" || value.privacy === "unlisted" || value.privacy === "private"
|
||||
? value.privacy
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
title,
|
||||
description: typeof value.description === "string" ? value.description : undefined,
|
||||
privacy,
|
||||
};
|
||||
}
|
||||
|
||||
export async function applyCreatePlaylistToItems(
|
||||
user: { id: string; plan: Plan },
|
||||
items: CreateJobPayload["items"],
|
||||
createPlaylist: CreatePlaylistRequest | null | undefined,
|
||||
) {
|
||||
if (!createPlaylist) {
|
||||
return { items, playlist: null as Awaited<ReturnType<typeof createYouTubePlaylist>> | null };
|
||||
}
|
||||
|
||||
if (!hasProFeatures(user.plan)) {
|
||||
throw new Error("Creating a YouTube playlist requires the Pro plan");
|
||||
}
|
||||
|
||||
const playlist = await createYouTubePlaylist(user.id, createPlaylist);
|
||||
const nextItems = items.map((item) => ({
|
||||
...item,
|
||||
metadata: {
|
||||
...item.metadata,
|
||||
playlistId: item.metadata.playlistId || playlist.id,
|
||||
} satisfies ItemMetadata,
|
||||
}));
|
||||
|
||||
return { items: nextItems, playlist };
|
||||
}
|
||||
Reference in New Issue
Block a user