Initial commit from Create Next App

This commit is contained in:
Atakan Doğan Özban
2026-07-12 15:40:35 +02:00
commit a0ca85a63d
52 changed files with 10164 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
import NextAuth from "next-auth";
import { authOptions } from "@/lib/auth";
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };
+47
View File
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { requireAuth } from "@/lib/session";
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { error, user } = await requireAuth();
if (error || !user) return error!;
const { id } = await params;
const job = await prisma.job.findFirst({
where: { id, userId: user.id },
include: {
items: {
orderBy: { audioFilename: "asc" },
select: {
id: true,
audioFilename: true,
title: true,
description: true,
tags: true,
privacy: true,
categoryId: true,
resolution: true,
status: true,
youtubeVideoId: true,
error: true,
},
},
},
});
if (!job) {
return NextResponse.json({ error: "Job not found" }, { status: 404 });
}
return NextResponse.json({
id: job.id,
status: job.status,
createdAt: job.createdAt.toISOString(),
completedAt: job.completedAt?.toISOString() ?? null,
items: job.items,
});
}
+139
View File
@@ -0,0 +1,139 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "fs/promises";
import path from "path";
import { Privacy } from "@prisma/client";
import { FREE_PLAN, isAllowedResolution } from "@/lib/constants";
import { prisma } from "@/lib/db";
import { enqueueVideoJob } from "@/lib/queue/client";
import { checkQuota } from "@/lib/quota";
import { requireAuth } from "@/lib/session";
import { getJobDir } from "@/lib/storage";
import type { CreateJobPayload } from "@/lib/types";
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 POST(req: NextRequest) {
const { error, user } = await requireAuth();
if (error || !user) return error!;
const body = (await req.json()) as CreateJobPayload;
if (!body.imagePath || !body.items?.length) {
return NextResponse.json({ error: "Image and at least one audio file required" }, { status: 400 });
}
for (const item of body.items) {
const metaError = validateItemMetadata(item.metadata);
if (metaError) {
return NextResponse.json({ error: metaError }, { status: 400 });
}
if (user.plan === "FREE" && !item.metadata.includeWatermark) {
return NextResponse.json({ error: "Watermark is required on the free plan" }, { status: 400 });
}
}
const quotaCheck = await checkQuota(user.id, body.items.length);
if (!quotaCheck.ok) {
return NextResponse.json({ error: quotaCheck.error }, { status: 403 });
}
try {
await fs.access(body.imagePath);
for (const item of body.items) {
await fs.access(item.audioPath);
const stat = await fs.stat(item.audioPath);
if (stat.size > FREE_PLAN.maxFileSizeBytes) {
return NextResponse.json({ error: `Audio file ${item.audioFilename} exceeds size limit` }, { status: 400 });
}
}
const imageStat = await fs.stat(body.imagePath);
if (imageStat.size > FREE_PLAN.maxFileSizeBytes) {
return NextResponse.json({ error: "Image exceeds size limit" }, { status: 400 });
}
} catch {
return NextResponse.json({ error: "One or more uploaded files not found" }, { status: 400 });
}
const job = await prisma.job.create({
data: {
userId: user.id,
imagePath: body.imagePath,
items: {
create: body.items.map((item) => ({
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: user.plan === "FREE" ? true : item.metadata.includeWatermark,
})),
},
},
include: { items: true },
});
const jobDir = getJobDir(user.id, job.id);
await fs.mkdir(jobDir, { recursive: true });
const imageExt = path.extname(body.imagePath);
const newImagePath = path.join(jobDir, `image${imageExt}`);
await fs.copyFile(body.imagePath, newImagePath);
await prisma.job.update({ where: { id: job.id }, data: { imagePath: newImagePath } });
for (const item of job.items) {
const audioExt = path.extname(item.audioPath);
const newAudioPath = path.join(jobDir, `${item.id}${audioExt}`);
await fs.copyFile(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 NextResponse.json({ jobId: job.id });
}
export async function GET() {
const { error, user } = await requireAuth();
if (error || !user) return error!;
const jobs = await prisma.job.findMany({
where: { userId: user.id },
orderBy: { createdAt: "desc" },
take: 20,
include: {
items: {
select: {
id: true,
audioFilename: true,
title: true,
status: true,
youtubeVideoId: true,
error: true,
},
},
},
});
return NextResponse.json({ jobs });
}
+13
View File
@@ -0,0 +1,13 @@
import { NextResponse } from "next/server";
import { getQuotaInfo } from "@/lib/quota";
import { getSessionUser } from "@/lib/session";
export async function GET() {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const quota = await getQuotaInfo(user.id);
return NextResponse.json(quota);
}
+60
View File
@@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "fs/promises";
import path from "path";
import { FREE_PLAN } from "@/lib/constants";
import { getSessionUser } from "@/lib/session";
import { getUploadDir } from "@/lib/storage";
const ALLOWED_IMAGE_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"];
const ALLOWED_AUDIO_TYPES = [
"audio/mpeg",
"audio/mp3",
"audio/wav",
"audio/x-wav",
"audio/ogg",
"audio/flac",
"audio/aac",
"audio/mp4",
"audio/x-m4a",
];
export async function POST(req: NextRequest) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const formData = await req.formData();
const file = formData.get("file") as File | null;
const type = formData.get("type") as string | null;
if (!file || !type) {
return NextResponse.json({ error: "Missing file or type" }, { status: 400 });
}
if (file.size > FREE_PLAN.maxFileSizeBytes) {
return NextResponse.json(
{ error: `File exceeds ${FREE_PLAN.maxFileSizeBytes / (1024 * 1024)} MB limit` },
{ status: 400 },
);
}
const allowedTypes = type === "image" ? ALLOWED_IMAGE_TYPES : ALLOWED_AUDIO_TYPES;
if (!allowedTypes.includes(file.type) && !file.name.match(/\.(mp3|wav|ogg|flac|aac|m4a|jpg|jpeg|png|webp|gif)$/i)) {
return NextResponse.json({ error: "Invalid file type" }, { status: 400 });
}
const sessionDir = path.join(getUploadDir(), user.id, "sessions", Date.now().toString());
await fs.mkdir(sessionDir, { recursive: true });
const safeName = file.name.replace(/[^a-zA-Z0-9._-]/g, "_");
const filePath = path.join(sessionDir, safeName);
const buffer = Buffer.from(await file.arrayBuffer());
await fs.writeFile(filePath, buffer);
return NextResponse.json({
path: filePath,
filename: file.name,
size: file.size,
});
}