Initial commit from Create Next App

This commit is contained in:
Atakan Doğan Özban
2026-07-12 15:40:35 +02:00
commit 4f29120768
52 changed files with 10164 additions and 0 deletions
+101
View File
@@ -0,0 +1,101 @@
import { NextAuthOptions } from "next-auth";
import GoogleProvider from "next-auth/providers/google";
import { prisma } from "./db";
import { getInitialQuotaResetAt } from "./quota";
import { fetchYouTubeChannel } from "./youtube/upload";
export const authOptions: NextAuthOptions = {
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
authorization: {
params: {
prompt: "consent",
access_type: "offline",
response_type: "code",
scope: [
"openid",
"email",
"profile",
"https://www.googleapis.com/auth/youtube.upload",
"https://www.googleapis.com/auth/youtube.readonly",
].join(" "),
},
},
}),
],
callbacks: {
async signIn({ user, account }) {
if (!account?.access_token || !user.email) return false;
const dbUser = await prisma.user.upsert({
where: { email: user.email },
create: {
email: user.email,
name: user.name,
image: user.image,
quotaResetAt: getInitialQuotaResetAt(),
},
update: {
name: user.name,
image: user.image,
},
});
if (account.refresh_token) {
const channel = await fetchYouTubeChannel(
account.access_token,
account.refresh_token,
);
await prisma.youTubeConnection.upsert({
where: { userId: dbUser.id },
create: {
userId: dbUser.id,
accessToken: account.access_token,
refreshToken: account.refresh_token,
expiresAt: account.expires_at
? new Date(account.expires_at * 1000)
: new Date(Date.now() + 3600 * 1000),
channelId: channel.channelId,
channelTitle: channel.channelTitle,
},
update: {
accessToken: account.access_token,
refreshToken: account.refresh_token,
expiresAt: account.expires_at
? new Date(account.expires_at * 1000)
: new Date(Date.now() + 3600 * 1000),
channelId: channel.channelId,
channelTitle: channel.channelTitle,
},
});
}
return true;
},
async session({ session }) {
if (session.user?.email) {
const dbUser = await prisma.user.findUnique({
where: { email: session.user.email },
include: { youtubeConnection: true },
});
if (dbUser) {
session.user.id = dbUser.id;
session.user.plan = dbUser.plan;
session.user.youtubeConnected = !!dbUser.youtubeConnection;
session.user.channelTitle = dbUser.youtubeConnection?.channelTitle;
}
}
return session;
},
},
pages: {
signIn: "/",
},
session: {
strategy: "jwt",
},
secret: process.env.NEXTAUTH_SECRET,
};
+57
View File
@@ -0,0 +1,57 @@
export const FREE_PLAN = {
monthlyQuota: 14,
maxFileSizeBytes: 30 * 1024 * 1024,
watermarkRequired: true,
} as const;
export const RESOLUTIONS = [
{ value: "1280x720", label: "1280x720 (16:9)", width: 1280, height: 720 },
{ value: "854x480", label: "854x480 (16:9)", width: 854, height: 480 },
{ value: "720x720", label: "720x720 (1:1)", width: 720, height: 720 },
{ value: "640x360", label: "640x360 (16:9)", width: 640, height: 360 },
{ value: "426x240", label: "426x240 (16:9)", width: 426, height: 240 },
] as const;
export const YOUTUBE_CATEGORIES = [
{ id: "10", name: "Music" },
{ id: "1", name: "Film & Animation" },
{ id: "2", name: "Autos & Vehicles" },
{ id: "15", name: "Pets & Animals" },
{ id: "17", name: "Sports" },
{ id: "19", name: "Travel & Events" },
{ id: "20", name: "Gaming" },
{ id: "22", name: "People & Blogs" },
{ id: "23", name: "Comedy" },
{ id: "24", name: "Entertainment" },
{ id: "25", name: "News & Politics" },
{ id: "26", name: "Howto & Style" },
{ id: "27", name: "Education" },
{ id: "28", name: "Science & Technology" },
{ id: "29", name: "Nonprofits & Activism" },
] as const;
export const QUEUE_NAME = "video-jobs";
export function getResolution(value: string) {
return RESOLUTIONS.find((r) => r.value === value);
}
export function isAllowedResolution(value: string): boolean {
return RESOLUTIONS.some((r) => r.value === value);
}
export function filenameWithoutExtension(filename: string): string {
const lastDot = filename.lastIndexOf(".");
return lastDot > 0 ? filename.slice(0, lastDot) : filename;
}
export function parseTags(input: string): string[] {
const tags: string[] = [];
const regex = /"([^"]+)"|([^,\s]+)/g;
let match: RegExpExecArray | null;
while ((match = regex.exec(input)) !== null) {
const tag = (match[1] || match[2]).trim();
if (tag) tags.push(tag);
}
return tags.slice(0, 500);
}
+11
View File
@@ -0,0 +1,11 @@
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"],
});
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
+100
View File
@@ -0,0 +1,100 @@
import { spawn } from "child_process";
import fs from "fs/promises";
import path from "path";
import { getResolution } from "../constants";
import { getWatermarkPath } from "../storage";
export async function encodeVideo(options: {
imagePath: string;
audioPath: string;
outputPath: string;
resolution: string;
includeWatermark: boolean;
}): Promise<void> {
const res = getResolution(options.resolution);
if (!res) throw new Error(`Invalid resolution: ${options.resolution}`);
await fs.mkdir(path.dirname(options.outputPath), { recursive: true });
const scaleFilter = `scale=${res.width}:${res.height}:force_original_aspect_ratio=decrease,pad=${res.width}:${res.height}:(ow-iw)/2:(oh-ih)/2:black`;
const watermarkPath = getWatermarkPath();
let watermarkExists = false;
try {
await fs.access(watermarkPath);
watermarkExists = true;
} catch {
watermarkExists = false;
}
const useWatermark = options.includeWatermark && watermarkExists;
const args = ["-y", "-loop", "1", "-i", options.imagePath, "-i", options.audioPath];
if (useWatermark) {
args.push("-i", watermarkPath);
args.push(
"-filter_complex",
`[0:v]${scaleFilter}[scaled];[2:v]scale=iw*0.15:-1[wm];[scaled][wm]overlay=W-w-20:H-h-20[vout]`,
"-map",
"[vout]",
"-map",
"1:a",
);
} else if (options.includeWatermark && !watermarkExists) {
args.push(
"-filter_complex",
`[0:v]${scaleFilter},drawtext=text='s2yt':fontsize=24:fontcolor=white@0.7:x=w-tw-20:y=h-th-20[vout]`,
"-map",
"[vout]",
"-map",
"1:a",
);
} else {
args.push("-vf", scaleFilter);
}
args.push(
"-c:v",
"libx264",
"-tune",
"stillimage",
"-c:a",
"aac",
"-b:a",
"192k",
"-pix_fmt",
"yuv420p",
"-shortest",
options.outputPath,
);
await runFfmpeg(args);
}
function runFfmpeg(args: string[]): Promise<void> {
return new Promise((resolve, reject) => {
const proc = spawn("ffmpeg", args, { stdio: ["ignore", "pipe", "pipe"] });
let stderr = "";
proc.stderr.on("data", (chunk) => {
stderr += chunk.toString();
});
proc.on("close", (code) => {
if (code === 0) resolve();
else reject(new Error(`FFmpeg failed (code ${code}): ${stderr.slice(-500)}`));
});
proc.on("error", (err) => {
reject(new Error(`FFmpeg not found or failed to start: ${err.message}`));
});
});
}
export async function cleanupFiles(paths: string[]) {
for (const p of paths) {
try {
await fs.unlink(p);
} catch {
// ignore missing files
}
}
}
+35
View File
@@ -0,0 +1,35 @@
import { Redis } from "ioredis";
import { Queue } from "bullmq";
import { QUEUE_NAME } from "./constants";
import type { VideoJobData } from "./types";
let connection: Redis | null = null;
let queue: Queue<VideoJobData> | null = null;
export function getRedisConnection(): Redis {
if (!connection) {
connection = new Redis(process.env.REDIS_URL || "redis://localhost:6379", {
maxRetriesPerRequest: null,
});
}
return connection;
}
export function getVideoQueue(): Queue<VideoJobData> {
if (!queue) {
queue = new Queue<VideoJobData>(QUEUE_NAME, {
connection: getRedisConnection(),
});
}
return queue;
}
export async function enqueueVideoJob(data: VideoJobData) {
const q = getVideoQueue();
await q.add("process-video", data, {
attempts: 2,
backoff: { type: "exponential", delay: 5000 },
removeOnComplete: 100,
removeOnFail: 200,
});
}
+55
View File
@@ -0,0 +1,55 @@
import { FREE_PLAN } from "./constants";
import { prisma } from "./db";
function getNextQuotaReset(from: Date = new Date()): Date {
return new Date(from.getFullYear(), from.getMonth() + 1, 1);
}
export async function ensureQuotaReset(userId: string) {
const user = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
if (new Date() >= user.quotaResetAt) {
return prisma.user.update({
where: { id: userId },
data: {
videosUsed: 0,
quotaResetAt: getNextQuotaReset(),
},
});
}
return user;
}
export async function getQuotaInfo(userId: string) {
const user = await ensureQuotaReset(userId);
const remaining = Math.max(0, FREE_PLAN.monthlyQuota - user.videosUsed);
return {
used: user.videosUsed,
limit: FREE_PLAN.monthlyQuota,
remaining,
resetsAt: user.quotaResetAt.toISOString(),
};
}
export async function checkQuota(userId: string, requestedCount: number) {
const info = await getQuotaInfo(userId);
if (requestedCount > info.remaining) {
return {
ok: false as const,
error: `Quota exceeded. You have ${info.remaining} videos remaining this month.`,
...info,
};
}
return { ok: true as const, ...info };
}
export async function incrementQuota(userId: string, count: number) {
await ensureQuotaReset(userId);
await prisma.user.update({
where: { id: userId },
data: { videosUsed: { increment: count } },
});
}
export function getInitialQuotaResetAt(): Date {
return getNextQuotaReset();
}
+30
View File
@@ -0,0 +1,30 @@
import { getServerSession } from "next-auth";
import { NextResponse } from "next/server";
import { authOptions } from "./auth";
import { prisma } from "./db";
export async function getSessionUser() {
const session = await getServerSession(authOptions);
if (!session?.user?.email) return null;
const user = await prisma.user.findUnique({
where: { email: session.user.email },
include: { youtubeConnection: true },
});
return user;
}
export async function requireAuth() {
const user = await getSessionUser();
if (!user) {
return { error: NextResponse.json({ error: "Unauthorized" }, { status: 401 }), user: null };
}
if (!user.youtubeConnection) {
return {
error: NextResponse.json({ error: "YouTube account not connected" }, { status: 403 }),
user: null,
};
}
return { error: null, user };
}
+13
View File
@@ -0,0 +1,13 @@
import path from "path";
export function getUploadDir(): string {
return process.env.UPLOAD_DIR || path.join(process.cwd(), "uploads");
}
export function getJobDir(userId: string, jobId: string): string {
return path.join(getUploadDir(), userId, jobId);
}
export function getWatermarkPath(): string {
return path.join(process.cwd(), "assets", "watermark.png");
}
+53
View File
@@ -0,0 +1,53 @@
import { Privacy } from "@prisma/client";
export type ItemMetadata = {
title: string;
description: string;
tags: string;
privacy: Privacy;
categoryId: string;
resolution: string;
notifySubscribers: boolean;
madeForKids: boolean;
embeddable: boolean;
creativeCommons: boolean;
includeWatermark: boolean;
};
export type UploadedAudio = {
path: string;
filename: string;
metadata: ItemMetadata;
};
export type CreateJobPayload = {
imagePath: string;
items: Array<{
audioPath: string;
audioFilename: string;
metadata: ItemMetadata;
}>;
};
export type JobItemResponse = {
id: string;
audioFilename: string;
title: string;
status: string;
youtubeVideoId: string | null;
error: string | null;
};
export type JobResponse = {
id: string;
status: string;
createdAt: string;
completedAt: string | null;
items: JobItemResponse[];
};
export type VideoJobData = {
jobItemId: string;
userId: string;
jobId: string;
};
+111
View File
@@ -0,0 +1,111 @@
import { google } from "googleapis";
import fs from "fs";
import { prisma } from "../db";
import { parseTags } from "../constants";
import type { JobItem } from "@prisma/client";
async function refreshAccessToken(refreshToken: string) {
const oauth2Client = new google.auth.OAuth2(
process.env.GOOGLE_CLIENT_ID,
process.env.GOOGLE_CLIENT_SECRET,
);
oauth2Client.setCredentials({ refresh_token: refreshToken });
const { credentials } = await oauth2Client.refreshAccessToken();
return credentials;
}
export async function getYouTubeClient(userId: string) {
const connection = await prisma.youTubeConnection.findUnique({
where: { userId },
});
if (!connection) throw new Error("YouTube account not connected");
const oauth2Client = new google.auth.OAuth2(
process.env.GOOGLE_CLIENT_ID,
process.env.GOOGLE_CLIENT_SECRET,
);
let accessToken = connection.accessToken;
let expiresAt = connection.expiresAt;
if (new Date() >= expiresAt) {
const credentials = await refreshAccessToken(connection.refreshToken);
if (!credentials.access_token) {
throw new Error("Failed to refresh YouTube access token");
}
accessToken = credentials.access_token;
expiresAt = credentials.expiry_date
? new Date(credentials.expiry_date)
: new Date(Date.now() + 3600 * 1000);
await prisma.youTubeConnection.update({
where: { userId },
data: { accessToken, expiresAt },
});
}
oauth2Client.setCredentials({
access_token: accessToken,
refresh_token: connection.refreshToken,
});
return google.youtube({ version: "v3", auth: oauth2Client });
}
export async function uploadToYouTube(
userId: string,
videoPath: string,
item: JobItem,
): Promise<string> {
const youtube = await getYouTubeClient(userId);
const tags = parseTags(item.tags);
const privacyStatus =
item.privacy === "PUBLIC"
? "public"
: item.privacy === "PRIVATE"
? "private"
: "unlisted";
const response = await youtube.videos.insert({
part: ["snippet", "status"],
notifySubscribers: item.notifySubscribers,
requestBody: {
snippet: {
title: item.title,
description: item.description,
tags: tags.length > 0 ? tags : undefined,
categoryId: item.categoryId,
},
status: {
privacyStatus,
embeddable: item.embeddable,
selfDeclaredMadeForKids: item.madeForKids,
licenseId: item.creativeCommons ? "creativeCommon" : "youtube",
},
},
media: {
body: fs.createReadStream(videoPath),
},
});
const videoId = response.data.id;
if (!videoId) throw new Error("YouTube upload succeeded but no video ID returned");
return videoId;
}
export async function fetchYouTubeChannel(accessToken: string, refreshToken: string) {
const oauth2Client = new google.auth.OAuth2(
process.env.GOOGLE_CLIENT_ID,
process.env.GOOGLE_CLIENT_SECRET,
);
oauth2Client.setCredentials({ access_token: accessToken, refresh_token: refreshToken });
const youtube = google.youtube({ version: "v3", auth: oauth2Client });
const response = await youtube.channels.list({ part: ["snippet"], mine: true });
const channel = response.data.items?.[0];
if (!channel?.id) throw new Error("No YouTube channel found for this account");
return {
channelId: channel.id,
channelTitle: channel.snippet?.title || "Unknown Channel",
};
}