Initial commit from Create Next App
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
DATABASE_URL="postgresql://s2yt:s2yt@localhost:5432/s2yt"
|
||||
REDIS_URL="redis://localhost:6379"
|
||||
NEXTAUTH_URL="http://localhost:3000"
|
||||
NEXTAUTH_SECRET="generate-a-random-secret-here"
|
||||
GOOGLE_CLIENT_ID="your-google-client-id"
|
||||
GOOGLE_CLIENT_SECRET="your-google-client-secret"
|
||||
UPLOAD_DIR="./uploads"
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
uploads/
|
||||
node_modules/
|
||||
.next/
|
||||
.env
|
||||
.env.local
|
||||
@@ -0,0 +1,5 @@
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
@@ -0,0 +1,63 @@
|
||||
# s2yt
|
||||
|
||||
Create YouTube videos from an image and audio files.
|
||||
|
||||
## Stack
|
||||
|
||||
- Next.js 15 (App Router, TypeScript, Tailwind)
|
||||
- PostgreSQL + Prisma
|
||||
- Redis + BullMQ
|
||||
- NextAuth (Google OAuth with YouTube scopes)
|
||||
- FFmpeg for video encoding
|
||||
- YouTube Data API v3
|
||||
|
||||
## Setup
|
||||
|
||||
1. Copy environment variables:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
2. Start PostgreSQL and Redis:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
3. Install dependencies and run migrations:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run db:push
|
||||
```
|
||||
|
||||
4. Configure Google OAuth in [Google Cloud Console](https://console.cloud.google.com/):
|
||||
- Enable YouTube Data API v3
|
||||
- Create OAuth 2.0 credentials
|
||||
- Add redirect URI: `http://localhost:3000/api/auth/callback/google`
|
||||
- Set `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` in `.env`
|
||||
|
||||
5. Install FFmpeg on your system (required for the worker).
|
||||
|
||||
6. Start the web app and worker in separate terminals:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
npm run worker
|
||||
```
|
||||
|
||||
## Free Plan
|
||||
|
||||
- 14 videos/month (each audio = 1 video)
|
||||
- Max 720p resolution
|
||||
- 30 MB max per file
|
||||
- Watermark required
|
||||
- Per-video metadata (title auto-filled from audio filename)
|
||||
|
||||
## Scripts
|
||||
|
||||
- `npm run dev` — Next.js dev server
|
||||
- `npm run worker` — Background job processor
|
||||
- `npm run db:push` — Push Prisma schema to database
|
||||
- `npm run build` — Production build
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { UploadForm } from "@/components/UploadForm";
|
||||
import { SignOutButton } from "@/components/SignOutButton";
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) redirect("/");
|
||||
|
||||
return (
|
||||
<main className="min-h-screen">
|
||||
<header className="border-b border-gray-800 bg-surface">
|
||||
<div className="mx-auto flex max-w-4xl items-center justify-between px-6 py-4">
|
||||
<div>
|
||||
<a href="/" className="text-xl font-bold text-white">
|
||||
s2yt
|
||||
</a>
|
||||
{session.user.channelTitle && (
|
||||
<p className="text-xs text-gray-500">Channel: {session.user.channelTitle}</p>
|
||||
)}
|
||||
</div>
|
||||
<SignOutButton />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="mx-auto max-w-4xl px-6 py-8">
|
||||
<h1 className="mb-6 text-2xl font-bold text-white">Create Videos</h1>
|
||||
<UploadForm />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,15 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-surface-dark text-gray-100 antialiased;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.input-field {
|
||||
@apply w-full rounded border border-gray-600 bg-surface-light px-3 py-2 text-sm text-white placeholder-gray-500 focus:border-accent focus:outline-none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { JobProgress } from "@/components/JobProgress";
|
||||
|
||||
type Props = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
export default async function JobPage({ params }: Props) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) redirect("/");
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen">
|
||||
<header className="border-b border-gray-800 bg-surface">
|
||||
<div className="mx-auto flex max-w-4xl items-center justify-between px-6 py-4">
|
||||
<a href="/dashboard" className="text-xl font-bold text-white">
|
||||
s2yt
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="mx-auto max-w-4xl px-6 py-8">
|
||||
<JobProgress jobId={id} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { Providers } from "./providers";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "s2yt — Image + Audio to YouTube",
|
||||
description: "Create and upload YouTube videos from an image and audio files",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={inter.className}>
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import Link from "next/link";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { SignInButton } from "@/components/SignInButton";
|
||||
|
||||
export default async function HomePage() {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen">
|
||||
<header className="border-b border-gray-800 bg-surface">
|
||||
<div className="mx-auto flex max-w-5xl items-center justify-between px-6 py-4">
|
||||
<span className="text-xl font-bold text-white">s2yt</span>
|
||||
{session ? (
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="rounded bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent-hover"
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
) : (
|
||||
<SignInButton />
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="mx-auto max-w-3xl px-6 py-20 text-center">
|
||||
<h1 className="mb-4 text-4xl font-bold text-white">
|
||||
Turn images and audio into YouTube videos
|
||||
</h1>
|
||||
<p className="mb-8 text-lg text-gray-400">
|
||||
Upload one image and one or more audio files. Each audio becomes its own video
|
||||
with individual metadata, then uploads directly to your YouTube channel.
|
||||
</p>
|
||||
|
||||
<div className="mb-12 grid gap-4 text-left sm:grid-cols-3">
|
||||
<Feature title="Batch creation" desc="One image, many audios — each becomes a separate video." />
|
||||
<Feature title="Per-video settings" desc="Title, tags, privacy, and category for every upload." />
|
||||
<Feature title="Direct upload" desc="Connect YouTube once and publish automatically." />
|
||||
</div>
|
||||
|
||||
{session ? (
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="inline-block rounded bg-accent px-8 py-3 font-medium text-white hover:bg-accent-hover"
|
||||
>
|
||||
Go to Dashboard
|
||||
</Link>
|
||||
) : (
|
||||
<SignInButton large />
|
||||
)}
|
||||
|
||||
<p className="mt-8 text-sm text-gray-500">
|
||||
Free plan: up to 14 videos/month, 720p max, 30 MB per file
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function Feature({ title, desc }: { title: string; desc: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-700 bg-surface p-4">
|
||||
<h3 className="mb-1 font-medium text-white">{title}</h3>
|
||||
<p className="text-sm text-gray-400">{desc}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return <SessionProvider>{children}</SessionProvider>;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { YOUTUBE_CATEGORIES } from "@/lib/constants";
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
export function CategorySelect({ value, onChange }: Props) {
|
||||
return (
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-full rounded border border-gray-600 bg-surface-light px-3 py-2 text-sm text-white focus:border-accent focus:outline-none"
|
||||
>
|
||||
{YOUTUBE_CATEGORIES.map((cat) => (
|
||||
<option key={cat.id} value={cat.id}>
|
||||
{cat.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import type { JobResponse } from "@/lib/types";
|
||||
|
||||
type Props = {
|
||||
jobId: string;
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
PENDING: "Queued",
|
||||
ENCODING: "Encoding video…",
|
||||
UPLOADING: "Uploading to YouTube…",
|
||||
COMPLETED: "Completed",
|
||||
FAILED: "Failed",
|
||||
};
|
||||
|
||||
export function JobProgress({ jobId }: Props) {
|
||||
const [job, setJob] = useState<JobResponse | null>(null);
|
||||
const [quota, setQuota] = useState<{ remaining: number; limit: number } | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
const [jobRes, quotaRes] = await Promise.all([
|
||||
fetch(`/api/jobs/${jobId}`),
|
||||
fetch("/api/quota"),
|
||||
]);
|
||||
|
||||
if (!jobRes.ok) throw new Error("Failed to load job");
|
||||
const jobData = await jobRes.json();
|
||||
if (active) setJob(jobData);
|
||||
|
||||
if (quotaRes.ok) {
|
||||
const quotaData = await quotaRes.json();
|
||||
if (active) setQuota({ remaining: quotaData.remaining, limit: quotaData.limit });
|
||||
}
|
||||
} catch (err) {
|
||||
if (active) setError(err instanceof Error ? err.message : "Error loading job");
|
||||
}
|
||||
}
|
||||
|
||||
poll();
|
||||
const interval = setInterval(poll, 3000);
|
||||
return () => {
|
||||
active = false;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [jobId]);
|
||||
|
||||
if (error) {
|
||||
return <div className="rounded border border-red-500/50 bg-red-500/10 p-4 text-red-300">{error}</div>;
|
||||
}
|
||||
|
||||
if (!job) {
|
||||
return <div className="text-gray-400">Loading job status…</div>;
|
||||
}
|
||||
|
||||
const allDone = job.items.every((i) => i.status === "COMPLETED" || i.status === "FAILED");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white">Job Status</h2>
|
||||
<p className="text-sm text-gray-400">
|
||||
Overall: <span className="text-white">{job.status}</span>
|
||||
</p>
|
||||
</div>
|
||||
{quota && (
|
||||
<div className="rounded bg-surface-light px-4 py-2 text-sm text-gray-300">
|
||||
{quota.remaining} / {quota.limit} videos remaining this month
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{job.items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="rounded-lg border border-gray-700 bg-surface-light p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="font-medium text-white">{item.title}</p>
|
||||
<p className="text-xs text-gray-500">{item.audioFilename}</p>
|
||||
</div>
|
||||
<span
|
||||
className={`rounded px-2 py-1 text-xs font-medium ${
|
||||
item.status === "COMPLETED"
|
||||
? "bg-green-500/20 text-green-400"
|
||||
: item.status === "FAILED"
|
||||
? "bg-red-500/20 text-red-400"
|
||||
: "bg-yellow-500/20 text-yellow-400"
|
||||
}`}
|
||||
>
|
||||
{STATUS_LABELS[item.status] || item.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{item.youtubeVideoId && (
|
||||
<a
|
||||
href={`https://youtube.com/watch?v=${item.youtubeVideoId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-2 inline-block text-sm text-accent hover:underline"
|
||||
>
|
||||
View on YouTube →
|
||||
</a>
|
||||
)}
|
||||
|
||||
{item.error && (
|
||||
<p className="mt-2 text-sm text-red-400">{item.error}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{allDone && (
|
||||
<a
|
||||
href="/dashboard"
|
||||
className="inline-block rounded bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent-hover"
|
||||
>
|
||||
Create another video
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { Privacy } from "@prisma/client";
|
||||
|
||||
type Props = {
|
||||
value: Privacy;
|
||||
onChange: (value: Privacy) => void;
|
||||
};
|
||||
|
||||
const OPTIONS: { value: Privacy; label: string }[] = [
|
||||
{ value: "PUBLIC", label: "Public" },
|
||||
{ value: "PRIVATE", label: "Private" },
|
||||
{ value: "UNLISTED", label: "Unlisted" },
|
||||
];
|
||||
|
||||
export function PrivacyToggle({ value, onChange }: Props) {
|
||||
return (
|
||||
<div className="flex rounded border border-gray-600 overflow-hidden">
|
||||
{OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => onChange(opt.value)}
|
||||
className={`flex-1 px-3 py-2 text-sm transition-colors ${
|
||||
value === opt.value
|
||||
? "bg-accent text-white"
|
||||
: "bg-surface-light text-gray-300 hover:bg-gray-700"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { RESOLUTIONS } from "@/lib/constants";
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function ResolutionSelect({ value, onChange, disabled }: Props) {
|
||||
return (
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
className="w-full rounded border border-gray-600 bg-surface-light px-3 py-2 text-sm text-white focus:border-accent focus:outline-none disabled:opacity-50"
|
||||
>
|
||||
{RESOLUTIONS.map((r) => (
|
||||
<option key={r.value} value={r.value}>
|
||||
{r.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { signIn } from "next-auth/react";
|
||||
|
||||
type Props = {
|
||||
large?: boolean;
|
||||
};
|
||||
|
||||
export function SignInButton({ large }: Props) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => signIn("google", { callbackUrl: "/dashboard" })}
|
||||
className={`rounded bg-white font-medium text-gray-900 transition-colors hover:bg-gray-100 ${
|
||||
large ? "px-8 py-3 text-base" : "px-4 py-2 text-sm"
|
||||
}`}
|
||||
>
|
||||
Sign in with Google
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { signOut } from "next-auth/react";
|
||||
|
||||
export function SignOutButton() {
|
||||
return (
|
||||
<button
|
||||
onClick={() => signOut({ callbackUrl: "/" })}
|
||||
className="rounded border border-gray-600 px-4 py-2 text-sm text-gray-300 hover:bg-surface-light"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Privacy } from "@prisma/client";
|
||||
import { filenameWithoutExtension } from "@/lib/constants";
|
||||
import type { ItemMetadata } from "@/lib/types";
|
||||
import { CategorySelect } from "./CategorySelect";
|
||||
import { PrivacyToggle } from "./PrivacyToggle";
|
||||
import { ResolutionSelect } from "./ResolutionSelect";
|
||||
|
||||
type AudioItem = {
|
||||
id: string;
|
||||
file: File;
|
||||
path: string | null;
|
||||
uploading: boolean;
|
||||
metadata: ItemMetadata;
|
||||
};
|
||||
|
||||
function defaultMetadata(title = ""): ItemMetadata {
|
||||
return {
|
||||
title,
|
||||
description: "",
|
||||
tags: "",
|
||||
privacy: "PUBLIC" as Privacy,
|
||||
categoryId: "10",
|
||||
resolution: "1280x720",
|
||||
notifySubscribers: true,
|
||||
madeForKids: false,
|
||||
embeddable: true,
|
||||
creativeCommons: false,
|
||||
includeWatermark: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function UploadForm() {
|
||||
const router = useRouter();
|
||||
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||
const [imagePath, setImagePath] = useState<string | null>(null);
|
||||
const [imageUploading, setImageUploading] = useState(false);
|
||||
const [audioItems, setAudioItems] = useState<AudioItem[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [quota, setQuota] = useState<{ remaining: number; limit: number } | null>(null);
|
||||
|
||||
const loadQuota = useCallback(async () => {
|
||||
const res = await fetch("/api/quota");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setQuota({ remaining: data.remaining, limit: data.limit });
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadQuota();
|
||||
}, [loadQuota]);
|
||||
|
||||
async function uploadFile(file: File, type: "image" | "audio"): Promise<string> {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("type", type);
|
||||
const res = await fetch("/api/upload", { method: "POST", body: formData });
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || "Upload failed");
|
||||
}
|
||||
const data = await res.json();
|
||||
return data.path;
|
||||
}
|
||||
|
||||
async function handleImageChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setError(null);
|
||||
setImageFile(file);
|
||||
setImageUploading(true);
|
||||
try {
|
||||
const path = await uploadFile(file, "image");
|
||||
setImagePath(path);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Image upload failed");
|
||||
setImageFile(null);
|
||||
setImagePath(null);
|
||||
} finally {
|
||||
setImageUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAudioChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const files = Array.from(e.target.files || []);
|
||||
if (!files.length) return;
|
||||
setError(null);
|
||||
|
||||
for (const file of files) {
|
||||
const id = crypto.randomUUID();
|
||||
const autoTitle = filenameWithoutExtension(file.name);
|
||||
|
||||
setAudioItems((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id,
|
||||
file,
|
||||
path: null,
|
||||
uploading: true,
|
||||
metadata: defaultMetadata(autoTitle),
|
||||
},
|
||||
]);
|
||||
|
||||
try {
|
||||
const path = await uploadFile(file, "audio");
|
||||
setAudioItems((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === id ? { ...item, path, uploading: false } : item,
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Audio upload failed");
|
||||
setAudioItems((prev) => prev.filter((item) => item.id !== id));
|
||||
}
|
||||
}
|
||||
|
||||
e.target.value = "";
|
||||
}
|
||||
|
||||
function updateItemMetadata(id: string, updates: Partial<ItemMetadata>) {
|
||||
setAudioItems((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === id
|
||||
? { ...item, metadata: { ...item.metadata, ...updates } }
|
||||
: item,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function removeAudioItem(id: string) {
|
||||
setAudioItems((prev) => prev.filter((item) => item.id !== id));
|
||||
}
|
||||
|
||||
const readyAudios = audioItems.filter((a) => a.path && !a.uploading);
|
||||
const canSubmit =
|
||||
imagePath &&
|
||||
!imageUploading &&
|
||||
readyAudios.length > 0 &&
|
||||
readyAudios.every((a) => a.metadata.title.trim()) &&
|
||||
!submitting;
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!canSubmit || !imagePath) return;
|
||||
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/jobs", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
imagePath,
|
||||
items: readyAudios.map((item) => ({
|
||||
audioPath: item.path!,
|
||||
audioFilename: item.file.name,
|
||||
metadata: item.metadata,
|
||||
})),
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "Failed to create job");
|
||||
|
||||
router.push(`/jobs/${data.jobId}`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Submission failed");
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{quota && (
|
||||
<div className="rounded border border-gray-700 bg-surface-light px-4 py-3 text-sm text-gray-300">
|
||||
{quota.remaining} of {quota.limit} videos remaining this month
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded border border-red-500/50 bg-red-500/10 px-4 py-3 text-sm text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="rounded-lg border border-gray-700 bg-surface p-6 space-y-4">
|
||||
<h2 className="text-lg font-medium text-white">Files</h2>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm text-gray-400">Image</label>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleImageChange}
|
||||
className="block w-full text-sm text-gray-300 file:mr-4 file:rounded file:border-0 file:bg-accent file:px-4 file:py-2 file:text-sm file:text-white"
|
||||
/>
|
||||
<div className="mt-2 flex items-center gap-2 text-sm">
|
||||
<StatusDot ok={!!imagePath && !imageUploading} />
|
||||
<span className="text-gray-400">
|
||||
{imageUploading ? "Uploading…" : imageFile?.name || "No image selected"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm text-gray-400">Audio files</label>
|
||||
<input
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
multiple
|
||||
onChange={handleAudioChange}
|
||||
className="block w-full text-sm text-gray-300 file:mr-4 file:rounded file:border-0 file:bg-accent file:px-4 file:py-2 file:text-sm file:text-white"
|
||||
/>
|
||||
<div className="mt-2 flex items-center gap-2 text-sm">
|
||||
<StatusDot ok={readyAudios.length > 0} />
|
||||
<span className="text-gray-400">
|
||||
{audioItems.length === 0
|
||||
? "No audio files selected"
|
||||
: `${readyAudios.length} of ${audioItems.length} ready`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{audioItems.length > 0 && (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-medium text-white">Video details (per audio)</h2>
|
||||
<p className="text-sm text-gray-400">
|
||||
Each audio file gets its own metadata. Title is auto-filled from the filename.
|
||||
</p>
|
||||
|
||||
{audioItems.map((item, index) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="rounded-lg border border-gray-700 bg-surface p-6 space-y-4"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium text-white">
|
||||
Video {index + 1}: {item.file.name}
|
||||
</h3>
|
||||
{item.uploading && (
|
||||
<p className="text-xs text-yellow-400">Uploading…</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeAudioItem(item.id)}
|
||||
className="text-sm text-red-400 hover:text-red-300"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field label="Title">
|
||||
<input
|
||||
type="text"
|
||||
value={item.metadata.title}
|
||||
onChange={(e) => updateItemMetadata(item.id, { title: e.target.value })}
|
||||
className="input-field"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Category">
|
||||
<CategorySelect
|
||||
value={item.metadata.categoryId}
|
||||
onChange={(v) => updateItemMetadata(item.id, { categoryId: v })}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field label="Description">
|
||||
<textarea
|
||||
value={item.metadata.description}
|
||||
onChange={(e) => updateItemMetadata(item.id, { description: e.target.value })}
|
||||
rows={3}
|
||||
className="input-field resize-y"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Tags">
|
||||
<input
|
||||
type="text"
|
||||
value={item.metadata.tags}
|
||||
onChange={(e) => updateItemMetadata(item.id, { tags: e.target.value })}
|
||||
placeholder='Separate with spaces or commas. Use "quoted phrases" for multi-word tags.'
|
||||
className="input-field"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Privacy">
|
||||
<PrivacyToggle
|
||||
value={item.metadata.privacy}
|
||||
onChange={(v) => updateItemMetadata(item.id, { privacy: v })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Video size">
|
||||
<ResolutionSelect
|
||||
value={item.metadata.resolution}
|
||||
onChange={(v) => updateItemMetadata(item.id, { resolution: v })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Checkbox
|
||||
label="Notify subscribers about this upload"
|
||||
checked={item.metadata.notifySubscribers}
|
||||
onChange={(v) => updateItemMetadata(item.id, { notifySubscribers: v })}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Made For Kids?"
|
||||
checked={item.metadata.madeForKids}
|
||||
onChange={(v) => updateItemMetadata(item.id, { madeForKids: v })}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Embeddable?"
|
||||
checked={item.metadata.embeddable}
|
||||
onChange={(v) => updateItemMetadata(item.id, { embeddable: v })}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Creative Commons?"
|
||||
checked={item.metadata.creativeCommons}
|
||||
onChange={(v) => updateItemMetadata(item.id, { creativeCommons: v })}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Include s2yt watermark?"
|
||||
checked={item.metadata.includeWatermark}
|
||||
onChange={() => {}}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-accent">
|
||||
Upgrade your account to remove watermarks, go ad-free, and unlock advanced settings.
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
className="w-full rounded bg-accent px-6 py-3 font-medium text-white transition-colors hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{submitting
|
||||
? "Creating videos…"
|
||||
: `Create ${readyAudios.length || ""} Video${readyAudios.length !== 1 ? "s" : ""}`}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusDot({ ok }: { ok: boolean }) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-block h-3 w-3 rounded-full ${ok ? "bg-green-500" : "bg-red-500"}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-gray-400">{label}</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Checkbox({
|
||||
label,
|
||||
checked,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<label className={`flex items-center gap-2 text-sm text-gray-300 ${disabled ? "opacity-60" : ""}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
disabled={disabled}
|
||||
className="rounded border-gray-600 bg-surface-light text-accent focus:ring-accent"
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: s2yt
|
||||
POSTGRES_PASSWORD: s2yt
|
||||
POSTGRES_DB: s2yt
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
+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",
|
||||
};
|
||||
}
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
experimental: {
|
||||
serverActions: {
|
||||
bodySizeLimit: "32mb",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
+8033
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "s2yt",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"worker": "tsx worker/index.ts",
|
||||
"db:generate": "prisma generate",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:push": "prisma db push"
|
||||
},
|
||||
"dependencies": {
|
||||
"@auth/prisma-adapter": "^2.7.4",
|
||||
"@prisma/client": "^6.1.0",
|
||||
"bullmq": "^5.34.5",
|
||||
"googleapis": "^144.0.0",
|
||||
"ioredis": "^5.4.2",
|
||||
"next": "^15.1.3",
|
||||
"next-auth": "^4.24.11",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/react": "^19.0.2",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-config-next": "^15.1.3",
|
||||
"postcss": "^8.4.49",
|
||||
"prisma": "^6.1.0",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,93 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
enum Plan {
|
||||
FREE
|
||||
PREMIUM
|
||||
}
|
||||
|
||||
enum Privacy {
|
||||
PUBLIC
|
||||
PRIVATE
|
||||
UNLISTED
|
||||
}
|
||||
|
||||
enum JobStatus {
|
||||
PENDING
|
||||
PROCESSING
|
||||
COMPLETED
|
||||
FAILED
|
||||
PARTIAL
|
||||
}
|
||||
|
||||
enum JobItemStatus {
|
||||
PENDING
|
||||
ENCODING
|
||||
UPLOADING
|
||||
COMPLETED
|
||||
FAILED
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
name String?
|
||||
image String?
|
||||
plan Plan @default(FREE)
|
||||
videosUsed Int @default(0)
|
||||
quotaResetAt DateTime
|
||||
youtubeConnection YouTubeConnection?
|
||||
jobs Job[]
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model YouTubeConnection {
|
||||
id String @id @default(cuid())
|
||||
userId String @unique
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
accessToken String
|
||||
refreshToken String
|
||||
expiresAt DateTime
|
||||
channelId String
|
||||
channelTitle String
|
||||
}
|
||||
|
||||
model Job {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
status JobStatus @default(PENDING)
|
||||
imagePath String
|
||||
items JobItem[]
|
||||
createdAt DateTime @default(now())
|
||||
completedAt DateTime?
|
||||
}
|
||||
|
||||
model JobItem {
|
||||
id String @id @default(cuid())
|
||||
jobId String
|
||||
job Job @relation(fields: [jobId], references: [id], onDelete: Cascade)
|
||||
audioPath String
|
||||
audioFilename String
|
||||
title String
|
||||
description String @default("")
|
||||
tags String @default("")
|
||||
privacy Privacy @default(PUBLIC)
|
||||
categoryId String @default("10")
|
||||
resolution String @default("1280x720")
|
||||
notifySubscribers Boolean @default(true)
|
||||
madeForKids Boolean @default(false)
|
||||
embeddable Boolean @default(true)
|
||||
creativeCommons Boolean @default(false)
|
||||
includeWatermark Boolean @default(true)
|
||||
status JobItemStatus @default(PENDING)
|
||||
outputPath String?
|
||||
youtubeVideoId String?
|
||||
error String?
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
@@ -0,0 +1,27 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
const config: Config = {
|
||||
content: [
|
||||
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
surface: {
|
||||
DEFAULT: "#1a1a2e",
|
||||
light: "#252540",
|
||||
dark: "#12121f",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "#4a9eff",
|
||||
hover: "#3a8eef",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Vendored
+21
@@ -0,0 +1,21 @@
|
||||
import "next-auth";
|
||||
|
||||
declare module "next-auth" {
|
||||
interface Session {
|
||||
user: {
|
||||
id?: string;
|
||||
name?: string | null;
|
||||
email?: string | null;
|
||||
image?: string | null;
|
||||
plan?: string;
|
||||
youtubeConnected?: boolean;
|
||||
channelTitle?: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
declare module "next-auth/jwt" {
|
||||
interface JWT {
|
||||
id?: string;
|
||||
}
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
import { Worker } from "bullmq";
|
||||
import path from "path";
|
||||
import { JobItemStatus, JobStatus } from "@prisma/client";
|
||||
import { QUEUE_NAME } from "./lib/constants";
|
||||
import { prisma } from "./lib/db";
|
||||
import { cleanupFiles, encodeVideo } from "./lib/ffmpeg/encode";
|
||||
import { getRedisConnection } from "./lib/queue/client";
|
||||
import { incrementQuota } from "./lib/quota";
|
||||
import { getJobDir } from "./lib/storage";
|
||||
import type { VideoJobData } from "./lib/types";
|
||||
import { uploadToYouTube } from "./lib/youtube/upload";
|
||||
|
||||
async function updateJobStatus(jobId: string) {
|
||||
const items = await prisma.jobItem.findMany({ where: { jobId } });
|
||||
const completed = items.filter((i) => i.status === JobItemStatus.COMPLETED).length;
|
||||
const failed = items.filter((i) => i.status === JobItemStatus.FAILED).length;
|
||||
const total = items.length;
|
||||
|
||||
let status: JobStatus = JobStatus.PROCESSING;
|
||||
if (completed + failed === total) {
|
||||
if (completed === total) status = JobStatus.COMPLETED;
|
||||
else if (failed === total) status = JobStatus.FAILED;
|
||||
else status = JobStatus.PARTIAL;
|
||||
}
|
||||
|
||||
await prisma.job.update({
|
||||
where: { id: jobId },
|
||||
data: {
|
||||
status,
|
||||
completedAt: completed + failed === total ? new Date() : null,
|
||||
},
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
async function processJobItem(data: VideoJobData) {
|
||||
const item = await prisma.jobItem.findUniqueOrThrow({
|
||||
where: { id: data.jobItemId },
|
||||
include: { job: true },
|
||||
});
|
||||
|
||||
const jobDir = getJobDir(data.userId, data.jobId);
|
||||
const outputPath = path.join(jobDir, `${item.id}.mp4`);
|
||||
|
||||
try {
|
||||
await prisma.jobItem.update({
|
||||
where: { id: item.id },
|
||||
data: { status: JobItemStatus.ENCODING },
|
||||
});
|
||||
await prisma.job.update({
|
||||
where: { id: data.jobId },
|
||||
data: { status: JobStatus.PROCESSING },
|
||||
});
|
||||
|
||||
await encodeVideo({
|
||||
imagePath: item.job.imagePath,
|
||||
audioPath: item.audioPath,
|
||||
outputPath,
|
||||
resolution: item.resolution,
|
||||
includeWatermark: item.includeWatermark,
|
||||
});
|
||||
|
||||
await prisma.jobItem.update({
|
||||
where: { id: item.id },
|
||||
data: { status: JobItemStatus.UPLOADING, outputPath },
|
||||
});
|
||||
|
||||
const youtubeVideoId = await uploadToYouTube(data.userId, outputPath, item);
|
||||
|
||||
await prisma.jobItem.update({
|
||||
where: { id: item.id },
|
||||
data: {
|
||||
status: JobItemStatus.COMPLETED,
|
||||
youtubeVideoId,
|
||||
},
|
||||
});
|
||||
|
||||
await incrementQuota(data.userId, 1);
|
||||
await cleanupFiles([outputPath]);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
await prisma.jobItem.update({
|
||||
where: { id: item.id },
|
||||
data: { status: JobItemStatus.FAILED, error: message },
|
||||
});
|
||||
throw err;
|
||||
} finally {
|
||||
await updateJobStatus(data.jobId);
|
||||
}
|
||||
}
|
||||
|
||||
const worker = new Worker<VideoJobData>(
|
||||
QUEUE_NAME,
|
||||
async (job) => {
|
||||
await processJobItem(job.data);
|
||||
},
|
||||
{
|
||||
connection: getRedisConnection(),
|
||||
concurrency: 2,
|
||||
},
|
||||
);
|
||||
|
||||
worker.on("completed", (job) => {
|
||||
console.log(`Job item ${job.data.jobItemId} completed`);
|
||||
});
|
||||
|
||||
worker.on("failed", (job, err) => {
|
||||
console.error(`Job item ${job?.data.jobItemId} failed:`, err.message);
|
||||
});
|
||||
|
||||
console.log("s2yt worker started, waiting for jobs...");
|
||||
Reference in New Issue
Block a user