Initial commit from Create Next App
This commit is contained in:
+101
@@ -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,
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user