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

After

Width:  |  Height:  |  Size: 25 KiB

+15
View File
@@ -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;
}
}
+31
View File
@@ -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>
);
}
+25
View File
@@ -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>
);
}
+68
View File
@@ -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>
);
}
+7
View File
@@ -0,0 +1,7 @@
"use client";
import { SessionProvider } from "next-auth/react";
export function Providers({ children }: { children: React.ReactNode }) {
return <SessionProvider>{children}</SessionProvider>;
}
+24
View File
@@ -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>
);
}
+132
View File
@@ -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>
);
}
+35
View File
@@ -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>
);
}
+26
View File
@@ -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>
);
}
+20
View File
@@ -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>
);
}
+14
View File
@@ -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>
);
}
+405
View File
@@ -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>
);
}
+24
View File
@@ -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:
+18
View File
@@ -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
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",
};
}
+2
View File
@@ -0,0 +1,2 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
+11
View File
@@ -0,0 +1,11 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
experimental: {
serverActions: {
bodySizeLimit: "32mb",
},
},
};
export default nextConfig;
+8033
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -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"
}
}
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;
+93
View File
@@ -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?
}
+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+27
View File
@@ -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;
+23
View File
@@ -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"]
}
+21
View File
@@ -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
View File
@@ -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...");