Initial open-source self-hosted Songs2YT edition

This commit is contained in:
Songs2YT
2026-07-17 02:23:59 +02:00
commit 2aa8a84781
134 changed files with 17758 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
node_modules
.next
.git
website
uploads
*.md
.env
.env.*
!.env.example
deploy-*.tgz
scripts
bg-video
+11
View File
@@ -0,0 +1,11 @@
DATABASE_URL="postgresql://s2yt:s2yt@localhost:5432/s2yt"
REDIS_URL="redis://localhost:6379"
NEXTAUTH_URL="http://localhost:3000"
NEXTAUTH_SECRET="replace-with-a-long-random-secret"
GOOGLE_CLIENT_ID="your-google-client-id.apps.googleusercontent.com"
GOOGLE_CLIENT_SECRET="your-google-client-secret"
UPLOAD_DIR="./uploads"
S2YT_EDITION="selfhosted"
NEXT_PUBLIC_GITEA_ISSUES_URL="https://git.atakanozban.com/Songs2YT/songs2yt/issues"
NEXT_PUBLIC_GITEA_URL="https://git.atakanozban.com/Songs2YT/songs2yt"
NEXT_PUBLIC_DOCKER_HUB_URL="https://hub.docker.com/r/atakanozban/songs2yt"
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}
+14
View File
@@ -0,0 +1,14 @@
uploads/
node_modules/
.next/
.env
.env.local
website/node_modules/
website/build/
website/.docusaurus/
*.tgz
.DS_Store
basibozuk_cover.jpg
bg-video/
scripts/
_restore/
+50
View File
@@ -0,0 +1,50 @@
# syntax=docker/dockerfile:1
FROM node:22-bookworm-slim AS deps
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends openssl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json ./
COPY prisma ./prisma
RUN npm ci
FROM node:22-bookworm-slim AS builder
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends openssl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npx prisma generate && npm run build
FROM node:22-bookworm-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV S2YT_EDITION=selfhosted
ENV UPLOAD_DIR=/app/uploads
RUN apt-get update && apt-get install -y --no-install-recommends \
openssl ca-certificates ffmpeg \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd --system --gid 1001 nodejs \
&& useradd --system --uid 1001 --gid nodejs nextjs
COPY --from=builder /app/public ./public
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/package-lock.json ./package-lock.json
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/worker ./worker
COPY --from=builder /app/lib ./lib
COPY --from=builder /app/tsconfig.json ./tsconfig.json
RUN mkdir -p /app/uploads \
&& chown -R nextjs:nodejs /app
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
CMD ["node", "server.js"]
+23
View File
@@ -0,0 +1,23 @@
# Songs2YT
Open-source, self-hosted Songs2YT. Set `S2YT_EDITION=selfhosted` for unlimited quotas (default).
## Docker
```bash
cp .env.example .env
docker compose up -d --build
```
## Development
```bash
cp .env.example .env
docker compose -f docker-compose.dev.yml up -d
npm install
npm run db:push
npm run dev
npm run worker
```
https://git.atakanozban.com/Songs2YT/songs2yt
+49
View File
@@ -0,0 +1,49 @@
import { NextResponse } from "next/server";
import { createUserApiKey, getUserApiKeyStatus, revokeUserApiKey } from "@/lib/api-keys";
import { hasProFeatures } from "@/lib/edition";
import { getSessionUser } from "@/lib/session";
async function requireApiKeyAccess() {
const user = await getSessionUser();
if (!user) {
return { error: NextResponse.json({ error: "Unauthorized" }, { status: 401 }), user: null };
}
if (!hasProFeatures(user.plan)) {
return {
error: NextResponse.json(
{ error: "API keys are available on the Pro plan only" },
{ status: 403 },
),
user: null,
};
}
return { error: null, user };
}
export async function GET() {
const { error, user } = await requireApiKeyAccess();
if (error || !user) return error!;
const status = await getUserApiKeyStatus(user.id);
return NextResponse.json(status);
}
export async function POST() {
const { error, user } = await requireApiKeyAccess();
if (error || !user) return error!;
const { token, prefix } = await createUserApiKey(user.id);
return NextResponse.json({
apiKey: token,
prefix,
message: "Copy this key now. It will not be shown again.",
});
}
export async function DELETE() {
const { error, user } = await requireApiKeyAccess();
if (error || !user) return error!;
await revokeUserApiKey(user.id);
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from "next/server";
import {
createQuotaExtensionRequest,
EXTENSION_KIND,
getQuotaExtensionUsage,
} from "@/lib/quota-extensions";
import { hasProFeatures } from "@/lib/edition";
import { getSessionUser } from "@/lib/session";
export async function GET() {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!hasProFeatures(user.plan)) {
return NextResponse.json({ error: "Pro plan required" }, { status: 403 });
}
const usage = await getQuotaExtensionUsage(user.id, EXTENSION_KIND.API_RATE_LIMIT);
return NextResponse.json(usage);
}
export async function POST(req: NextRequest) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!hasProFeatures(user.plan)) {
return NextResponse.json({ error: "Pro plan required" }, { status: 403 });
}
let message = "";
try {
const body = await req.json();
if (typeof body.message === "string") message = body.message;
} catch {
// optional body
}
try {
const result = await createQuotaExtensionRequest(
user.id,
message,
EXTENSION_KIND.API_RATE_LIMIT,
);
return NextResponse.json(result);
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to submit request";
return NextResponse.json({ error: msg }, { status: 400 });
}
}
+20
View File
@@ -0,0 +1,20 @@
import { NextResponse } from "next/server";
import { getApiRateLimitStatus } from "@/lib/api-rate-limit";
import { hasProFeatures } from "@/lib/edition";
import { getSessionUser } from "@/lib/session";
export async function GET() {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!hasProFeatures(user.plan)) {
return NextResponse.json(
{ error: "API rate limits are available on the Pro plan only" },
{ status: 403 },
);
}
const status = await getApiRateLimitStatus(user.id);
return NextResponse.json(status);
}
@@ -0,0 +1,30 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getInitialQuotaResetAt } from "@/lib/quota";
import { getSessionUser } from "@/lib/session";
export async function POST() {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (user.plan !== "PREMIUM") {
return NextResponse.json({ error: "No active subscription to cancel" }, { status: 400 });
}
await prisma.user.update({
where: { id: user.id },
data: {
plan: "FREE",
cardLast4: null,
subscribedAt: null,
apiKeyHash: null,
apiKeyPrefix: null,
apiRateLimitBonus: 0,
quotaResetAt: getInitialQuotaResetAt(),
},
});
return NextResponse.json({ ok: true });
}
+14
View File
@@ -0,0 +1,14 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getSessionUser } from "@/lib/session";
export async function POST() {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
await prisma.user.delete({ where: { id: user.id } });
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from "next/server";
import { createQuotaExtensionRequest, getQuotaExtensionUsage } from "@/lib/quota-extensions";
import { getSessionUser } from "@/lib/session";
export async function GET() {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const usage = await getQuotaExtensionUsage(user.id);
return NextResponse.json(usage);
}
export async function POST(req: NextRequest) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let message = "";
try {
const body = await req.json();
if (typeof body.message === "string") message = body.message;
} catch {
// optional body
}
try {
const result = await createQuotaExtensionRequest(user.id, message);
return NextResponse.json(result);
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to submit request";
return NextResponse.json({ error: msg }, { status: 400 });
}
}
@@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from "next/server";
import {
approveQuotaExtensionRequest,
rejectQuotaExtensionRequest,
} from "@/lib/quota-extensions";
function isAuthorized(req: NextRequest) {
const key = process.env.ADMIN_API_KEY;
if (!key) return false;
const auth = req.headers.get("authorization");
return auth === `Bearer ${key}`;
}
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
if (!isAuthorized(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
let action: "approve" | "reject" = "approve";
let bonusQuota: number | undefined;
let bonusRateLimit: number | undefined;
let adminNote: string | undefined;
try {
const body = await req.json();
if (body.action === "reject") action = "reject";
if (typeof body.bonusQuota === "number" && Number.isFinite(body.bonusQuota)) {
bonusQuota = body.bonusQuota;
}
if (typeof body.bonusRateLimit === "number" && Number.isFinite(body.bonusRateLimit)) {
bonusRateLimit = body.bonusRateLimit;
}
if (typeof body.adminNote === "string") adminNote = body.adminNote;
} catch {
// defaults
}
try {
if (action === "reject") {
await rejectQuotaExtensionRequest(id, adminNote);
} else {
await approveQuotaExtensionRequest(id, { bonusQuota, bonusRateLimit, adminNote });
}
return NextResponse.json({ ok: true });
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to process request";
return NextResponse.json({ error: msg }, { status: 400 });
}
}
+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,
});
}
+58
View File
@@ -0,0 +1,58 @@
import { NextRequest, NextResponse } from "next/server";
import { createVideoJob } from "@/lib/jobs/create-job";
import { prisma } from "@/lib/db";
import { requireAuth } from "@/lib/session";
import type { CreateJobPayload } from "@/lib/types";
export const maxDuration = 120;
export async function POST(req: NextRequest) {
const { error, user } = await requireAuth();
if (error || !user) return error!;
const body = (await req.json()) as CreateJobPayload;
try {
const job = await createVideoJob(user, body);
return NextResponse.json({ jobId: job.id });
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to create job";
const status = message.includes("Quota exceeded") ? 403 : 400;
return NextResponse.json({ error: message }, { status });
}
}
export async function GET(req: NextRequest) {
const { error, user } = await requireAuth();
if (error || !user) return error!;
const limit = Math.min(Number(req.nextUrl.searchParams.get("limit")) || 20, 100);
const jobs = await prisma.job.findMany({
where: { userId: user.id },
orderBy: { createdAt: "desc" },
take: limit,
include: {
items: {
select: {
id: true,
audioFilename: true,
title: true,
status: true,
youtubeVideoId: true,
error: true,
},
},
},
});
return NextResponse.json({
jobs: jobs.map((job) => ({
id: job.id,
status: job.status,
createdAt: job.createdAt.toISOString(),
completedAt: job.completedAt?.toISOString() ?? null,
items: job.items,
})),
});
}
+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);
}
+29
View File
@@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from "next/server";
import { saveUploadedFile } from "@/lib/jobs/create-job";
import { getSessionUser } from "@/lib/session";
export const maxDuration = 120;
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;
const sessionKey = (formData.get("session") as string | null)?.trim() || undefined;
if (!file || !type || (type !== "image" && type !== "audio")) {
return NextResponse.json({ error: "Missing file or type" }, { status: 400 });
}
try {
const result = await saveUploadedFile(user.id, file, type, user.plan, { sessionKey });
return NextResponse.json(result);
} catch (err) {
const message = err instanceof Error ? err.message : "Upload failed";
return NextResponse.json({ error: message }, { status: 400 });
}
}
+47
View File
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import { requirePaidApiUser } from "@/lib/api-auth";
import { prisma } from "@/lib/db";
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { error, user } = await requirePaidApiUser(_req);
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,
});
}
+148
View File
@@ -0,0 +1,148 @@
import { NextRequest, NextResponse } from "next/server";
import { Privacy } from "@prisma/client";
import { requirePaidApiUser } from "@/lib/api-auth";
import { createVideoJob, saveUploadedFile } from "@/lib/jobs/create-job";
import {
applyCreatePlaylistToItems,
parseCreatePlaylistInput,
} from "@/lib/jobs/resolve-playlist";
import { filenameWithoutExtension } from "@/lib/constants";
import { mapWithConcurrency } from "@/lib/fs-utils";
import { getPlanLimits } from "@/lib/plans";
import { checkQuota } from "@/lib/quota";
import type { CreateJobPayload, CreatePlaylistRequest, ItemMetadata } from "@/lib/types";
export const maxDuration = 300;
type BatchItemInput = Partial<ItemMetadata> & {
filename?: string;
title?: string;
};
function defaultMetadata(title: string): ItemMetadata {
return {
title,
description: "",
tags: "",
privacy: "PUBLIC",
categoryId: "10",
resolution: "1920x1080",
notifySubscribers: true,
madeForKids: false,
embeddable: true,
creativeCommons: false,
includeWatermark: false,
playlistId: null,
};
}
export async function POST(req: NextRequest) {
try {
const { error, user } = await requirePaidApiUser(req);
if (error || !user) return error!;
const formData = await req.formData();
const image = formData.get("image") as File | null;
const audioFiles = formData.getAll("audio").filter((f): f is File => f instanceof File);
const metadataRaw = formData.get("metadata");
if (!image || audioFiles.length === 0) {
return NextResponse.json(
{ error: "Provide multipart fields: image (file), audio (one or more files)" },
{ status: 400 },
);
}
const limits = getPlanLimits(user.plan);
if (audioFiles.length > limits.maxBatchSize) {
return NextResponse.json(
{ error: `Batch limit exceeded. Your plan allows up to ${limits.maxBatchSize} files per batch.` },
{ status: 400 },
);
}
const quotaCheck = await checkQuota(user.id, audioFiles.length);
if (!quotaCheck.ok) {
return NextResponse.json({ error: quotaCheck.error }, { status: 403 });
}
let itemMeta: BatchItemInput[] = [];
let defaults: Partial<ItemMetadata> = {};
let createPlaylist: CreatePlaylistRequest | null = null;
if (metadataRaw) {
try {
const parsed = JSON.parse(String(metadataRaw)) as {
items?: BatchItemInput[];
defaults?: Partial<ItemMetadata>;
createPlaylist?: unknown;
};
itemMeta = parsed.items ?? [];
defaults = parsed.defaults ?? {};
createPlaylist = parseCreatePlaylistInput(parsed.createPlaylist);
if (parsed.createPlaylist && !createPlaylist) {
return NextResponse.json(
{
error:
"createPlaylist requires a title. Optional: description, privacy (public|unlisted|private)",
},
{ status: 400 },
);
}
} catch {
return NextResponse.json({ error: "metadata must be valid JSON" }, { status: 400 });
}
}
const sessionKey = Date.now().toString();
const imageUpload = await saveUploadedFile(user.id, image, "image", user.plan, { sessionKey });
const uploads = await mapWithConcurrency(audioFiles, 4, (audio) =>
saveUploadedFile(user.id, audio, "audio", user.plan, { sessionKey }),
);
const builtItems: CreateJobPayload["items"] = uploads.map((upload, i) => {
const audio = audioFiles[i];
const metaInput = itemMeta[i] ?? itemMeta.find((m) => m.filename === audio.name) ?? {};
const base = defaultMetadata(
metaInput.title?.trim() || filenameWithoutExtension(audio.name),
);
const metadata: ItemMetadata = {
...base,
...defaults,
...metaInput,
title: (metaInput.title ?? defaults.title ?? base.title).trim(),
privacy: (metaInput.privacy ?? defaults.privacy ?? base.privacy) as Privacy,
};
return {
audioPath: upload.path,
audioFilename: upload.filename,
metadata,
};
});
const { items, playlist } = await applyCreatePlaylistToItems(user, builtItems, createPlaylist);
const job = await createVideoJob(user, {
imagePath: imageUpload.path,
items,
});
return NextResponse.json({
jobId: job.id,
itemCount: job.items.length,
status: job.status,
playlist: playlist
? { id: playlist.id, title: playlist.title, privacy: playlist.privacy }
: null,
});
} catch (err) {
console.error("Batch upload failed:", err);
const message = err instanceof Error ? err.message : "Batch upload failed";
const status = message.includes("Quota exceeded") || message.includes("Batch limit exceeded")
? 403
: 500;
return NextResponse.json({ error: message }, { status });
}
}
+88
View File
@@ -0,0 +1,88 @@
import { NextRequest, NextResponse } from "next/server";
import { requirePaidApiUser } from "@/lib/api-auth";
import { createVideoJob } from "@/lib/jobs/create-job";
import {
applyCreatePlaylistToItems,
parseCreatePlaylistInput,
} from "@/lib/jobs/resolve-playlist";
import { prisma } from "@/lib/db";
import type { CreateJobPayload } from "@/lib/types";
export async function POST(req: NextRequest) {
const { error, user } = await requirePaidApiUser(req);
if (error || !user) return error!;
const body = (await req.json()) as CreateJobPayload & { createPlaylist?: unknown };
try {
const createPlaylist = parseCreatePlaylistInput(body.createPlaylist);
if (body.createPlaylist && !createPlaylist) {
return NextResponse.json(
{
error:
"createPlaylist requires a title. Optional: description, privacy (public|unlisted|private)",
},
{ status: 400 },
);
}
const { items, playlist } = await applyCreatePlaylistToItems(
user,
body.items,
createPlaylist,
);
const job = await createVideoJob(user, {
imagePath: body.imagePath,
items,
});
return NextResponse.json({
jobId: job.id,
itemCount: job.items.length,
status: job.status,
playlist: playlist
? { id: playlist.id, title: playlist.title, privacy: playlist.privacy }
: null,
});
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to create job";
const status = message.includes("Quota exceeded") ? 403 : 400;
return NextResponse.json({ error: message }, { status });
}
}
export async function GET(req: NextRequest) {
const { error, user } = await requirePaidApiUser(req);
if (error || !user) return error!;
const limit = Math.min(Number(req.nextUrl.searchParams.get("limit")) || 20, 100);
const jobs = await prisma.job.findMany({
where: { userId: user.id },
orderBy: { createdAt: "desc" },
take: limit,
include: {
items: {
select: {
id: true,
audioFilename: true,
title: true,
status: true,
youtubeVideoId: true,
error: true,
},
},
},
});
return NextResponse.json({
jobs: jobs.map((job) => ({
id: job.id,
status: job.status,
createdAt: job.createdAt.toISOString(),
completedAt: job.completedAt?.toISOString() ?? null,
items: job.items,
})),
});
}
+44
View File
@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from "next/server";
import { requirePaidApiUser } from "@/lib/api-auth";
import { parseCreatePlaylistInput } from "@/lib/jobs/resolve-playlist";
import { createYouTubePlaylist, listYouTubePlaylists } from "@/lib/youtube/upload";
export async function GET(req: NextRequest) {
const { error, user } = await requirePaidApiUser(req);
if (error || !user) return error!;
try {
const playlists = await listYouTubePlaylists(user.id);
return NextResponse.json({ playlists });
} catch (err) {
console.error("List playlists failed:", err);
const message = err instanceof Error ? err.message : "Failed to list playlists";
return NextResponse.json({ error: message }, { status: 500 });
}
}
export async function POST(req: NextRequest) {
const { error, user } = await requirePaidApiUser(req);
if (error || !user) return error!;
try {
const body = await req.json();
const input = parseCreatePlaylistInput(body);
if (!input) {
return NextResponse.json(
{
error:
"Provide JSON body: { title, description?, privacy? } where privacy is public|unlisted|private",
},
{ status: 400 },
);
}
const playlist = await createYouTubePlaylist(user.id, input);
return NextResponse.json({ playlist });
} catch (err) {
console.error("Create playlist failed:", err);
const message = err instanceof Error ? err.message : "Failed to create playlist";
return NextResponse.json({ error: message }, { status: 500 });
}
}
+61
View File
@@ -0,0 +1,61 @@
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json({
name: "Songs2YT API",
version: "1.0",
authentication: "Authorization: Bearer <api_key>",
requirements: ["Pro subscription", "YouTube account connected"],
rateLimit: "60 requests per minute per account (plus any approved bonus)",
guidance: {
recommended:
"For most jobs (especially 5+ audio files): POST /api/v1/upload per file, then POST /api/v1/jobs with the returned paths",
batch:
"POST /api/v1/jobs/batch is for small packs only. Large multipart bodies may fail with 'failed to parse body as FormData'",
},
endpoints: [
{
method: "POST",
path: "/api/v1/upload",
description: "Upload a single image or audio file (use for two-step flow)",
body: "multipart/form-data: file, type (image|audio)",
},
{
method: "POST",
path: "/api/v1/jobs",
description: "Create a video job from uploaded file paths (recommended for large batches)",
body: "application/json: { imagePath, items[] }",
},
{
method: "POST",
path: "/api/v1/jobs/batch",
description:
"One-shot batch for small packs only; large uploads may fail FormData parsing; prefer upload + jobs",
body: "multipart/form-data: image, audio[] (repeatable), metadata? (JSON string)",
},
{
method: "GET",
path: "/api/v1/playlists",
description: "List YouTube playlists for the authenticated Pro account",
},
{
method: "POST",
path: "/api/v1/playlists",
description: "Create a YouTube playlist",
body: "application/json: { title, description?, privacy? (public|unlisted|private) }",
},
{
method: "GET",
path: "/api/v1/jobs",
description: "List recent jobs",
query: "limit (default 20, max 100)",
},
{
method: "GET",
path: "/api/v1/jobs/:id",
description: "Get job status and item details",
},
],
docs: "/dashboard/api-docs",
});
}
+29
View File
@@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from "next/server";
import { requirePaidApiUser } from "@/lib/api-auth";
import { saveUploadedFile } from "@/lib/jobs/create-job";
export const maxDuration = 120;
export async function POST(req: NextRequest) {
const { error, user } = await requirePaidApiUser(req);
if (error || !user) return error!;
const formData = await req.formData();
const file = formData.get("file") as File | null;
const type = formData.get("type") as string | null;
if (!file || !type || (type !== "image" && type !== "audio")) {
return NextResponse.json(
{ error: "Provide multipart fields: file, type (image|audio)" },
{ status: 400 },
);
}
try {
const result = await saveUploadedFile(user.id, file, type, user.plan);
return NextResponse.json(result);
} catch (err) {
const message = err instanceof Error ? err.message : "Upload failed";
return NextResponse.json({ error: message }, { status: 400 });
}
}
+63
View File
@@ -0,0 +1,63 @@
import { NextRequest, NextResponse } from "next/server";
import { hasProFeatures } from "@/lib/edition";
import { getSessionUser } from "@/lib/session";
import { parseCreatePlaylistInput } from "@/lib/jobs/resolve-playlist";
import { createYouTubePlaylist, listYouTubePlaylists } from "@/lib/youtube/upload";
async function requirePremiumYouTubeUser() {
const user = await getSessionUser();
if (!user) {
return { error: NextResponse.json({ error: "Unauthorized" }, { status: 401 }), user: null };
}
if (!hasProFeatures(user.plan)) {
return {
error: NextResponse.json(
{ error: "Playlist features are available on the Pro plan only" },
{ status: 403 },
),
user: null,
};
}
if (!user.youtubeConnection) {
return {
error: NextResponse.json({ error: "YouTube account not connected" }, { status: 403 }),
user: null,
};
}
return { error: null, user };
}
export async function GET() {
const { error, user } = await requirePremiumYouTubeUser();
if (error || !user) return error!;
try {
const playlists = await listYouTubePlaylists(user.id);
return NextResponse.json({ playlists });
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to list playlists";
return NextResponse.json({ error: message }, { status: 500 });
}
}
export async function POST(req: NextRequest) {
const { error, user } = await requirePremiumYouTubeUser();
if (error || !user) return error!;
try {
const body = await req.json();
const input = parseCreatePlaylistInput(body);
if (!input) {
return NextResponse.json(
{ error: "Provide title, and optionally description and privacy (public|unlisted|private)" },
{ status: 400 },
);
}
const playlist = await createYouTubePlaylist(user.id, input);
return NextResponse.json({ playlist });
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to create playlist";
return NextResponse.json({ error: message }, { status: 500 });
}
}
+219
View File
@@ -0,0 +1,219 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { hasProFeatures } from "@/lib/edition";
import { prisma } from "@/lib/db";
import { DashboardShell } from "@/components/DashboardShell";
import { UpgradeProLink } from "@/components/UpgradeProLink";
function CodeBlock({ children }: { children: string }) {
return (
<pre className="overflow-x-auto rounded-lg border border-gray-800 bg-black/40 p-4 text-xs leading-relaxed text-gray-300">
<code>{children}</code>
</pre>
);
}
export default async function ApiDocsPage() {
const session = await getServerSession(authOptions);
if (!session?.user?.email) redirect("/");
const user = await prisma.user.findUnique({
where: { email: session.user.email },
include: { youtubeConnection: true },
});
if (!user) redirect("/");
const isPro = hasProFeatures(user.plan);
return (
<DashboardShell channelTitle={user.youtubeConnection?.channelTitle}>
<div className="mx-auto max-w-3xl space-y-8">
<div>
<Link
href="/dashboard/settings"
className="text-sm text-gray-400 transition-colors hover:text-white"
>
Back to settings
</Link>
<h1 className="mt-4 text-2xl font-bold text-white">REST API</h1>
<p className="mt-2 text-sm text-gray-400">
Programmatic uploads and batch jobs for Pro subscribers. Generate your API key in{" "}
<Link href="/dashboard/settings" className="text-accent hover:underline">
Settings
</Link>
.
</p>
{!isPro && (
<p className="mt-3 rounded border border-accent/30 bg-accent/10 px-4 py-3 text-sm text-accent">
API access requires Pro. <UpgradeProLink />
</p>
)}
</div>
<section className="space-y-3">
<h2 className="text-lg font-semibold text-white">Authentication</h2>
<p className="text-sm text-gray-400">
Send your API key on every request. Keys start with <code className="text-gray-300">s2yt_live_</code>.
</p>
<CodeBlock>{`Authorization: Bearer s2yt_live_your_key_here`}</CodeBlock>
</section>
<section className="space-y-3">
<h2 className="text-lg font-semibold text-white">Rate limits</h2>
<p className="text-sm text-gray-400">
Default is 60 requests per minute per account. Approved rate-limit extensions increase
that ceiling. Check live usage and request an extension in{" "}
<Link href="/dashboard/settings" className="text-accent hover:underline">
Settings API access
</Link>
. Video quota limits from your Pro plan still apply to job creation.
</p>
</section>
<section className="space-y-3">
<h2 className="text-lg font-semibold text-white">Choosing a flow</h2>
<div className="rounded border border-gray-800 bg-surface p-4 text-sm text-gray-400 space-y-3">
<p>
<span className="font-medium text-white">Recommended for most jobs (especially 5+ audio files):</span>{" "}
two-step: upload each file with <code className="text-gray-300">POST /api/v1/upload</code>, then
create the job with <code className="text-gray-300">POST /api/v1/jobs</code> (JSON).
</p>
<p>
<span className="font-medium text-white">One-shot batch</span>{" "}
(<code className="text-gray-300">POST /api/v1/jobs/batch</code>) is for small packs only, typically
1 cover + up to a few audio files. Large multipart bodies often fail with{" "}
<code className="text-gray-300">failed to parse body as FormData</code>. Prefer two-step for albums
or long tracklists.
</p>
<ul className="list-disc space-y-1 pl-5">
<li>Do not set <code className="text-gray-300">Content-Type</code> manually for multipart; the client must include the boundary.</li>
<li>For Postman: Body form-data, each audio row key must be exactly <code className="text-gray-300">audio</code> (type File).</li>
<li>If a file field shows a warning triangle, re-select the file from disk.</li>
</ul>
</div>
</section>
<section className="space-y-3">
<h2 className="text-lg font-semibold text-white">Discovery</h2>
<CodeBlock>{`GET /api/v1`}</CodeBlock>
<p className="text-sm text-gray-400">Returns endpoint list and requirements (no auth).</p>
</section>
<section className="space-y-3">
<h2 className="text-lg font-semibold text-white">1. Upload a file (two-step)</h2>
<CodeBlock>{`curl -X POST "$BASE_URL/api/v1/upload" \\
-H "Authorization: Bearer $API_KEY" \\
-F "file=@cover.jpg" \\
-F "type=image"`}</CodeBlock>
<CodeBlock>{`curl -X POST "$BASE_URL/api/v1/upload" \\
-H "Authorization: Bearer $API_KEY" \\
-F "file=@track1.mp3" \\
-F "type=audio"`}</CodeBlock>
<p className="text-sm text-gray-400">
Response includes <code className="text-gray-300">path</code>,{" "}
<code className="text-gray-300">filename</code>, and{" "}
<code className="text-gray-300">size</code>. Upload the image once, then each audio file. Keep the{" "}
<code className="text-gray-300">path</code> values for the next step.
</p>
</section>
<section className="space-y-3">
<h2 className="text-lg font-semibold text-white">2. Create job from paths (recommended)</h2>
<p className="text-sm text-gray-400">
Use the exact <code className="text-gray-300">path</code> strings returned by upload. Add one{" "}
<code className="text-gray-300">items[]</code> entry per track.
</p>
<CodeBlock>{`curl -X POST "$BASE_URL/api/v1/jobs" \\
-H "Authorization: Bearer $API_KEY" \\
-H "Content-Type: application/json" \\
-d '{
"imagePath": "/uploads/.../cover.jpg",
"items": [{
"audioPath": "/uploads/.../track.mp3",
"audioFilename": "track.mp3",
"metadata": {
"title": "My Track",
"description": "",
"tags": "electronic",
"privacy": "PUBLIC",
"categoryId": "10",
"resolution": "1920x1080",
"notifySubscribers": true,
"madeForKids": false,
"embeddable": true,
"creativeCommons": false,
"includeWatermark": false,
"playlistId": null
}
}]
}'`}</CodeBlock>
</section>
<section className="space-y-3">
<h2 className="text-lg font-semibold text-white">YouTube playlists (Pro)</h2>
<p className="text-sm text-gray-400">
List existing playlists, create a new one, or create one inline when starting a job.
Pass <code className="text-gray-300">playlistId</code> in item metadata / batch{" "}
<code className="text-gray-300">defaults</code>, or use{" "}
<code className="text-gray-300">createPlaylist</code> to make a playlist and attach all
videos to it. Privacy may be <code className="text-gray-300">public</code>,{" "}
<code className="text-gray-300">unlisted</code>, or{" "}
<code className="text-gray-300">private</code>. If playlist permission was just added,
sign out and sign in again for <code className="text-gray-300">youtube.force-ssl</code>.
</p>
<CodeBlock>{`# List playlists
curl "$BASE_URL/api/v1/playlists" \\
-H "Authorization: Bearer $API_KEY"`}</CodeBlock>
<CodeBlock>{`# Create a playlist
curl -X POST "$BASE_URL/api/v1/playlists" \\
-H "Authorization: Bearer $API_KEY" \\
-H "Content-Type: application/json" \\
-d '{"title":"My Album","description":"From Songs2YT","privacy":"unlisted"}'`}</CodeBlock>
<CodeBlock>{`# Use an existing playlist ID in metadata / defaults:
{ "playlistId": "PLxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" }
# Or create one inline with a job / batch request:
{
"createPlaylist": {
"title": "My Album",
"description": "Uploaded via Songs2YT",
"privacy": "private"
},
"defaults": { "privacy": "PUBLIC" },
"items": [{ "title": "Track One" }]
}`}</CodeBlock>
</section>
<section className="space-y-3">
<h2 className="text-lg font-semibold text-white">One-shot batch (small packs only)</h2>
<p className="text-sm text-gray-400">
Upload one cover image and a few audio files in a single multipart request. Not recommended for
large batches: use two-step instead if you see FormData parse errors.
</p>
<CodeBlock>{`curl -X POST "$BASE_URL/api/v1/jobs/batch" \\
-H "Authorization: Bearer $API_KEY" \\
-F "image=@cover.jpg" \\
-F "audio=@track1.mp3" \\
-F "audio=@track2.mp3" \\
-F 'metadata={"createPlaylist":{"title":"My Album","privacy":"unlisted"},"defaults":{"privacy":"PUBLIC"},"items":[{"title":"Track One"},{"title":"Track Two"}]}'`}</CodeBlock>
<p className="text-sm text-gray-400">
Optional <code className="text-gray-300">metadata</code> JSON supports{" "}
<code className="text-gray-300">defaults</code> applied to every item and per-item overrides in{" "}
<code className="text-gray-300">items</code>. Item order should match the order of{" "}
<code className="text-gray-300">audio</code> files.
</p>
</section>
<section className="space-y-3">
<h2 className="text-lg font-semibold text-white">Poll job status</h2>
<CodeBlock>{`curl "$BASE_URL/api/v1/jobs/JOB_ID" \\
-H "Authorization: Bearer $API_KEY"`}</CodeBlock>
<CodeBlock>{`curl "$BASE_URL/api/v1/jobs?limit=10" \\
-H "Authorization: Bearer $API_KEY"`}</CodeBlock>
</section>
</div>
</DashboardShell>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { DashboardShell } from "@/components/DashboardShell";
import { JobHistory } from "@/components/JobHistory";
export default async function HistoryPage() {
const session = await getServerSession(authOptions);
if (!session) redirect("/");
return (
<DashboardShell channelTitle={session.user.channelTitle}>
<div className="mx-auto max-w-4xl px-6 py-8">
<h1 className="mb-6 text-2xl font-bold text-white">History</h1>
<JobHistory />
</div>
</DashboardShell>
);
}
+21
View File
@@ -0,0 +1,21 @@
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { DashboardShell } from "@/components/DashboardShell";
import { RecentYouTubeLimitAlert } from "@/components/RecentYouTubeLimitAlert";
import { UploadForm } from "@/components/UploadForm";
export default async function DashboardPage() {
const session = await getServerSession(authOptions);
if (!session) redirect("/");
return (
<DashboardShell channelTitle={session.user.channelTitle}>
<div className="mx-auto max-w-4xl px-6 py-8">
<h1 className="mb-6 text-2xl font-bold text-white">Create Videos</h1>
<RecentYouTubeLimitAlert />
<UploadForm />
</div>
</DashboardShell>
);
}
+224
View File
@@ -0,0 +1,224 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { AccountPrivacyActions } from "@/components/AccountPrivacyActions";
import { PlanBillingActions } from "@/components/PlanBillingActions";
import { DashboardShell } from "@/components/DashboardShell";
import { UpgradeProLink } from "@/components/UpgradeProLink";
import { getUserApiKeyStatus } from "@/lib/api-keys";
import { getApiRateLimitStatus } from "@/lib/api-rate-limit";
import { ApiKeySettings } from "@/components/ApiKeySettings";
import { hasProFeatures, isSelfHostedEdition } from "@/lib/edition";
import { EXTENSION_KIND, getQuotaExtensionUsage } from "@/lib/quota-extensions";
import { getQuotaInfo } from "@/lib/quota";
const PLAN_LABELS = {
FREE: "Bedroom Producer",
PREMIUM: "Pro",
} as const;
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex justify-between gap-4">
<dt className="text-gray-400">{label}</dt>
<dd className="text-right text-white">{value}</dd>
</div>
);
}
export default async function SettingsPage() {
const session = await getServerSession(authOptions);
if (!session?.user?.email) redirect("/");
const user = await prisma.user.findUnique({
where: { email: session.user.email },
include: { youtubeConnection: true },
});
if (!user) redirect("/");
const quota = await getQuotaInfo(user.id);
const selfHosted = isSelfHostedEdition();
const proFeatures = hasProFeatures(user.plan);
const extensionUsage =
!selfHosted && user.plan === "PREMIUM" ? await getQuotaExtensionUsage(user.id) : null;
const apiRateExtensionUsage = proFeatures
? await getQuotaExtensionUsage(user.id, EXTENSION_KIND.API_RATE_LIMIT)
: null;
const apiKeyStatus = proFeatures ? await getUserApiKeyStatus(user.id) : null;
const apiRateLimit = proFeatures ? await getApiRateLimitStatus(user.id) : null;
const planLabel = selfHosted ? "Self-hosted" : PLAN_LABELS[user.plan];
const youtube = user.youtubeConnection;
const channelUrl = youtube ? `https://www.youtube.com/channel/${youtube.channelId}` : null;
return (
<DashboardShell channelTitle={youtube?.channelTitle}>
<div className="mx-auto max-w-4xl px-6 py-8">
<h1 className="mb-6 text-2xl font-bold text-white">Settings</h1>
<div className="space-y-6">
<section className="rounded-lg border border-gray-700 bg-surface-light p-6">
<h2 className="mb-4 text-lg font-semibold text-white">Account information</h2>
<dl className="space-y-3 text-sm">
<InfoRow label="Name" value={user.name || "Not set"} />
<InfoRow label="Email" value={user.email} />
</dl>
<div className="mt-6 border-t border-gray-800 pt-6">
<h3 className="mb-4 text-sm font-semibold uppercase tracking-wider text-gray-500">
YouTube
</h3>
{youtube ? (
<dl className="space-y-3 text-sm">
<InfoRow label="Channel name" value={youtube.channelTitle} />
<InfoRow label="Channel ID" value={youtube.channelId} />
</dl>
) : (
<p className="text-sm text-red-400">YouTube account not connected.</p>
)}
{channelUrl && (
<a
href={channelUrl}
target="_blank"
rel="noopener noreferrer"
className="mt-4 inline-flex rounded bg-accent px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent-hover"
>
Go to your channel
</a>
)}
<p className="mt-4 text-sm text-gray-400">
To refresh your YouTube permissions,{" "}
<Link href="/" className="text-accent hover:underline">
sign out and sign in again
</Link>
.
</p>
</div>
</section>
<section className="rounded-lg border border-gray-700 bg-surface-light p-6">
<h2 className="mb-4 text-lg font-semibold text-white">
{selfHosted ? "Usage" : "Plan & billing"}
</h2>
<div className="mb-6 rounded border border-gray-800 bg-surface p-4">
<div className="flex flex-wrap items-end justify-between gap-2">
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-gray-500">
Videos processed
</p>
<p className="mt-1 text-2xl font-semibold text-white">
{selfHosted ? (
quota.used
) : (
<>
{quota.remaining}
<span className="text-base font-normal text-gray-400">
{" "}
of {quota.limit} videos
</span>
</>
)}
</p>
</div>
{!selfHosted && (
<p className="text-sm text-gray-400">
{quota.used} used
{quota.bonusQuota > 0 ? ` · +${quota.bonusQuota} bonus` : ""}
</p>
)}
</div>
{!selfHosted && (
<>
<div className="mt-3 h-2 overflow-hidden rounded bg-gray-800">
<div
className={`h-full rounded ${
quota.remaining <= 0
? "bg-red-500"
: quota.remaining / Math.max(1, quota.limit) <= 0.2
? "bg-amber-500"
: "bg-accent"
}`}
style={{
width: `${Math.min(100, Math.round((quota.used / Math.max(1, quota.limit)) * 100))}%`,
}}
/>
</div>
<p className="mt-2 text-xs text-gray-500">
{user.plan === "PREMIUM"
? `Monthly quota resets on ${quota.resetsIn}`
: `Quota resets in ${quota.resetsIn}`}
</p>
</>
)}
{selfHosted && (
<p className="mt-2 text-xs text-gray-500">
Self-hosted edition: no video or API quotas.
</p>
)}
</div>
<dl className="space-y-3 text-sm">
<InfoRow label="Current plan" value={planLabel} />
{!selfHosted && user.plan === "PREMIUM" && user.subscribedAt && (
<InfoRow
label="Subscribed since"
value={user.subscribedAt.toLocaleDateString(undefined, {
year: "numeric",
month: "long",
day: "numeric",
})}
/>
)}
{!selfHosted && user.plan === "PREMIUM" && (
<InfoRow
label="Payment method"
value={user.cardLast4 ? `•••• ${user.cardLast4}` : "No card on file"}
/>
)}
{extensionUsage && (
<InfoRow
label="Extension requests (this year)"
value={`${extensionUsage.used} / ${extensionUsage.limit}`}
/>
)}
<InfoRow label="Max batch size" value={`${quota.maxBatchSize} videos`} />
<InfoRow label="Max resolution" value={`${quota.maxResolutionHeight}p`} />
</dl>
{!selfHosted && user.plan === "FREE" && (
<p className="mt-4 text-sm text-gray-400">
<UpgradeProLink /> for more videos, 1080p, and lossless audio.
</p>
)}
{!selfHosted && user.plan === "PREMIUM" && extensionUsage && (
<PlanBillingActions initialUsage={extensionUsage} />
)}
</section>
{proFeatures && apiKeyStatus && apiRateLimit && apiRateExtensionUsage && (
<section className="rounded-lg border border-gray-700 bg-surface-light p-6">
<h2 className="mb-4 text-lg font-semibold text-white">API access</h2>
<ApiKeySettings
initialStatus={apiKeyStatus}
initialRateLimit={apiRateLimit}
initialExtensionUsage={apiRateExtensionUsage}
/>
</section>
)}
<section className="rounded-lg border border-gray-700 bg-surface-light p-6">
<h2 className="mb-2 text-lg font-semibold text-white">Account deletion &amp; data</h2>
<p className="mb-4 text-sm text-gray-400">
Request a copy of your data or permanently delete your account and associated uploads.
</p>
<AccountPrivacyActions email={user.email} />
</section>
</div>
</div>
</DashboardShell>
);
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+248
View File
@@ -0,0 +1,248 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html {
scroll-behavior: smooth;
}
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;
}
.mockup-upload-box {
animation: mockup-border-pulse 3s ease-in-out infinite;
}
.mockup-upload-box-image {
animation: mockup-border-pulse 3s ease-in-out infinite,
mockup-float 4s ease-in-out infinite;
}
.mockup-upload-box-audio {
animation: mockup-border-pulse 3s ease-in-out infinite 0.5s,
mockup-float 4s ease-in-out infinite 0.5s;
}
.mockup-icon-image {
animation: mockup-icon-glow 3s ease-in-out infinite;
}
.mockup-icon-audio {
animation: mockup-icon-glow 3s ease-in-out infinite 0.5s;
}
.mockup-progress-shimmer {
animation: mockup-shimmer 2s ease-in-out infinite;
}
.mockup-dot-1 {
animation: processing-dot 1.4s ease-in-out infinite;
}
.mockup-dot-2 {
animation: processing-dot 1.4s ease-in-out infinite 0.2s;
}
.mockup-dot-3 {
animation: processing-dot 1.4s ease-in-out infinite 0.4s;
}
.benefits-flow-line {
animation: benefits-flow-pulse 2s ease-in-out infinite;
}
.benefits-process-icon {
animation: benefits-process-pulse 2.5s ease-in-out infinite;
}
.benefits-flow-path {
stroke-dasharray: 6 8;
animation: benefits-flow-dash 2s linear infinite;
}
.mobile-sidebar-panel-open {
animation: mobile-sidebar-open 0.42s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
.mobile-sidebar-panel-close {
animation: mobile-sidebar-close 0.3s cubic-bezier(0.4, 0, 1, 1) forwards;
}
.mobile-sidebar-close-btn-open {
animation: mobile-sidebar-close-btn-in 0.35s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
.mobile-sidebar-close-btn-close {
animation: mobile-sidebar-close-btn-out 0.2s ease-in forwards;
}
.mobile-sidebar-link {
@apply relative block overflow-hidden rounded-lg px-4 py-3 text-base font-medium text-gray-300 transition-all duration-300 ease-out;
}
.mobile-sidebar-link::before {
content: "";
@apply absolute bottom-2 left-0 top-2 w-1 origin-left scale-y-0 rounded-r bg-accent transition-transform duration-300 ease-out;
}
.mobile-sidebar-link:hover {
@apply translate-x-1 bg-surface-light text-white shadow-[inset_0_0_0_1px_rgba(74,158,255,0.12)];
}
.mobile-sidebar-link:hover::before {
@apply scale-y-100;
}
.mobile-sidebar-link-active {
@apply bg-surface-light text-white shadow-[inset_0_0_0_1px_rgba(74,158,255,0.2)];
}
.mobile-sidebar-link-active::before {
@apply scale-y-100;
}
.mobile-sidebar-link-danger:hover {
@apply bg-red-500/10 text-red-300 shadow-[inset_0_0_0_1px_rgba(239,68,68,0.2)];
}
.mobile-sidebar-link-danger:hover::before {
@apply bg-red-400;
}
}
@keyframes mockup-float {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-3px);
}
}
@keyframes mockup-border-pulse {
0%,
100% {
border-color: rgb(75 85 99 / 0.5);
box-shadow: 0 0 0 0 rgb(255 255 255 / 0);
}
50% {
border-color: rgb(156 163 175 / 0.7);
box-shadow: 0 0 12px 0 rgb(255 255 255 / 0.04);
}
}
@keyframes mockup-icon-glow {
0%,
100% {
opacity: 0.5;
transform: scale(1);
}
50% {
opacity: 0.85;
transform: scale(1.05);
}
}
@keyframes mockup-shimmer {
0% {
left: -30%;
opacity: 0;
}
30% {
opacity: 0.6;
}
100% {
left: 100%;
opacity: 0;
}
}
@keyframes processing-dot {
0%,
20% {
opacity: 0;
}
40%,
100% {
opacity: 1;
}
}
@keyframes benefits-flow-pulse {
0%,
100% {
opacity: 0.4;
}
50% {
opacity: 1;
}
}
@keyframes benefits-process-pulse {
0%,
100% {
box-shadow: 0 0 20px rgba(239, 68, 68, 0.2);
}
50% {
box-shadow: 0 0 28px rgba(239, 68, 68, 0.4);
}
}
@keyframes benefits-flow-dash {
to {
stroke-dashoffset: -28;
}
}
@keyframes mobile-sidebar-open {
from {
opacity: 0.85;
transform: translateX(100%) scale(0.96);
}
to {
opacity: 1;
transform: translateX(0) scale(1);
}
}
@keyframes mobile-sidebar-close {
from {
opacity: 1;
transform: translateX(0) scale(1);
}
to {
opacity: 0.85;
transform: translateX(100%) scale(0.96);
}
}
@keyframes mobile-sidebar-close-btn-in {
from {
opacity: 0;
transform: scale(0.6) rotate(-90deg);
}
to {
opacity: 1;
transform: scale(1) rotate(0deg);
}
}
@keyframes mobile-sidebar-close-btn-out {
from {
opacity: 1;
transform: scale(1) rotate(0deg);
}
to {
opacity: 0;
transform: scale(0.6) rotate(90deg);
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 374 KiB

+24
View File
@@ -0,0 +1,24 @@
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { DashboardShell } from "@/components/DashboardShell";
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 (
<DashboardShell channelTitle={session.user.channelTitle}>
<div className="mx-auto max-w-4xl px-6 py-8">
<JobProgress jobId={id} />
</div>
</DashboardShell>
);
}
+26
View File
@@ -0,0 +1,26 @@
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: "Songs2YT",
description:
"Songs2YT is an automation tool that converts your audio and image files into high-quality videos and uploads them directly to YouTube.",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={inter.className}>
<Providers>{children}</Providers>
</body>
</html>
);
}
+92
View File
@@ -0,0 +1,92 @@
import Link from "next/link";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { BenefitsSection } from "@/components/BenefitsSection";
import { DownloadSection } from "@/components/DownloadSection";
import { LandingNavbar } from "@/components/LandingNavbar";
import { SignInButton } from "@/components/SignInButton";
import { PricingSection } from "@/components/PricingSection";
import { StepsSection } from "@/components/StepsSection";
import { Footer } from "@/components/Footer";
import { SupportSection } from "@/components/SupportSection";
export default async function HomePage() {
const session = await getServerSession(authOptions);
return (
<main className="min-h-screen">
<section className="relative min-h-dvh overflow-hidden">
<video
autoPlay
muted
loop
playsInline
className="absolute inset-0 h-full w-full object-cover"
aria-hidden="true"
>
<source src="/bg-video.mp4" type="video/mp4" />
</video>
<div className="absolute inset-0 bg-black/55" aria-hidden="true" />
<div
className="absolute inset-0 bg-gradient-to-b from-black/70 via-black/40 to-black/80"
aria-hidden="true"
/>
<LandingNavbar />
{/* Purpose + brand above the fold for Google OAuth verification */}
<div className="relative z-10 mx-auto flex min-h-dvh max-w-3xl flex-col justify-center px-6 pb-16 pt-28 text-center">
<h1 className="text-5xl font-bold tracking-tight text-white sm:text-6xl lg:text-7xl">
Songs2YT
</h1>
<p className="mx-auto mt-6 max-w-2xl text-base leading-relaxed text-gray-200 sm:text-lg md:text-xl">
Songs2YT is an automation tool that converts your audio and image files into
high-quality videos and uploads them directly to YouTube.
</p>
<div className="mt-10 flex flex-col items-center gap-3">
{session ? (
<Link
href="/dashboard"
className="inline-flex items-center gap-2 rounded bg-accent px-8 py-3.5 font-medium text-white transition-all duration-300 hover:scale-[1.02] hover:bg-accent-hover hover:shadow-lg hover:shadow-accent/20"
>
Go to Dashboard
</Link>
) : (
<>
<SignInButton large />
<p className="max-w-md text-xs leading-relaxed text-gray-400">
By clicking Continue with Google, you accept our{" "}
<Link
href="/terms"
className="text-gray-300 underline-offset-2 transition-colors hover:text-white hover:underline"
>
Terms of Service
</Link>{" "}
and{" "}
<Link
href="/privacy"
className="text-gray-300 underline-offset-2 transition-colors hover:text-white hover:underline"
>
Privacy Policy
</Link>
.
</p>
</>
)}
</div>
</div>
</section>
<StepsSection />
<BenefitsSection />
<DownloadSection />
<PricingSection />
<SupportSection />
<Footer />
</main>
);
}
+180
View File
@@ -0,0 +1,180 @@
import type { Metadata } from "next";
import { LegalPageLayout } from "@/components/LegalPageLayout";
import { LEGAL_OPERATOR } from "@/lib/legal/constants";
export const metadata: Metadata = {
title: "Privacy Policy | Songs2YT",
description: "How Songs2YT collects, uses, and protects your personal data.",
};
export default function PrivacyPage() {
return (
<LegalPageLayout
title="Privacy Policy"
description="How we handle your personal data when you use Songs2YT."
>
<h2>1. Overview</h2>
<p>
This Privacy Policy explains how {LEGAL_OPERATOR.name} (&quot;we&quot;, &quot;us&quot;)
processes personal data when you use our website, hosted cloud service, and related features
that convert audio and images into videos for upload to YouTube.
</p>
<p>
We process personal data in accordance with applicable data protection laws, including the
General Data Protection Regulation (GDPR) where it applies.
</p>
<h2>2. Data controller</h2>
<p>
{LEGAL_OPERATOR.legalName}
<br />
{LEGAL_OPERATOR.address}
<br />
{LEGAL_OPERATOR.city}
<br />
Email: <a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a>
</p>
<h2>3. What data we collect</h2>
<h3>3.1 Account and authentication data</h3>
<p>When you sign in with Google, we receive and store:</p>
<ul>
<li>Your name, email address, and profile image (from Google)</li>
<li>OAuth tokens required to authenticate your session</li>
<li>YouTube connection data, including channel ID and title</li>
<li>Encrypted YouTube API access and refresh tokens needed to upload videos on your behalf</li>
</ul>
<h3>3.2 Uploaded content</h3>
<p>When you use the service, we temporarily process:</p>
<ul>
<li>Image and audio files you upload</li>
<li>Generated video files</li>
<li>Per-video metadata you provide (title, description, tags, privacy settings, etc.)</li>
</ul>
<h3>3.3 Usage and technical data</h3>
<ul>
<li>Plan type, quota usage, and job processing status</li>
<li>IP address, browser type, device information, and request logs</li>
<li>Error reports and operational diagnostics</li>
</ul>
<h3>3.4 Payment data</h3>
<p>
If you purchase a paid plan, payment processing is handled by our payment provider. We do
not store full payment card details on our servers. We may receive billing status,
subscription identifiers, and transaction references.
</p>
<h2>4. Why we process your data</h2>
<ul>
<li>
<strong>Contract performance:</strong> to provide video encoding, metadata handling, and
YouTube upload features you request
</li>
<li>
<strong>Legitimate interests:</strong> to secure our service, prevent abuse, improve
reliability, and enforce our terms
</li>
<li>
<strong>Legal obligations:</strong> where required by tax, accounting, or regulatory law
</li>
<li>
<strong>Consent:</strong> where you have given explicit consent, such as optional
marketing communications if offered
</li>
</ul>
<h2>5. Third-party services</h2>
<p>We use trusted third parties to operate Songs2YT, including:</p>
<ul>
<li>
<strong>Google / YouTube:</strong> authentication and video uploads via Google OAuth and
the YouTube Data API
</li>
<li>
<strong>Hosting and infrastructure providers:</strong> servers, databases, queues, and
storage
</li>
<li>
<strong>Payment processors:</strong> for Pro and Enterprise billing when available
</li>
</ul>
<p>
These providers process data only as necessary to deliver their services and under
appropriate contractual safeguards where required.
</p>
<h2>6. Data retention</h2>
<ul>
<li>Uploaded source files and generated outputs are retained only as long as needed to complete your jobs</li>
<li>Account data is kept while your account remains active</li>
<li>Billing records may be retained as required by law</li>
<li>Logs are retained for a limited period for security and troubleshooting</li>
</ul>
<p>You may request deletion of your account data subject to legal retention obligations.</p>
<h2>7. Self-hosted deployments</h2>
<p>
If you deploy Songs2YT on your own infrastructure, you are the data controller for data
processed on your instance. This Privacy Policy applies to the hosted cloud service
operated by us, not to independent self-hosted installations unless we provide managed
hosting for you under contract.
</p>
<h2>8. Your rights</h2>
<p>Depending on your location, you may have the right to:</p>
<ul>
<li>Access the personal data we hold about you</li>
<li>Request correction or deletion</li>
<li>Restrict or object to certain processing</li>
<li>Data portability</li>
<li>Withdraw consent where processing is consent-based</li>
<li>Lodge a complaint with a supervisory authority</li>
</ul>
<p>
To exercise these rights, contact{" "}
<a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a>.
</p>
<h2>9. Cookies and local storage</h2>
<p>
We use essential cookies and similar technologies for authentication, session management,
and security. We do not use non-essential tracking cookies unless disclosed separately and
enabled with your consent where required.
</p>
<h2>10. Security</h2>
<p>
We implement appropriate technical and organizational measures to protect your data,
including encryption in transit, access controls, and isolated processing environments.
No method of transmission or storage is 100% secure.
</p>
<h2>11. International transfers</h2>
<p>
If data is transferred outside your country, we ensure appropriate safeguards such as
standard contractual clauses or equivalent mechanisms where required by law.
</p>
<h2>12. Children</h2>
<p>
Songs2YT is not directed at children under 16. We do not knowingly collect personal data
from children. If you believe a child has provided us data, please contact us.
</p>
<h2>13. Changes to this policy</h2>
<p>
We may update this Privacy Policy from time to time. Material changes will be posted on
this page with an updated effective date.
</p>
<h2>14. Contact</h2>
<p>
Questions about this Privacy Policy:{" "}
<a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a>
</p>
</LegalPageLayout>
);
}
+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>;
}
+106
View File
@@ -0,0 +1,106 @@
import type { Metadata } from "next";
import Link from "next/link";
import { LegalPageLayout } from "@/components/LegalPageLayout";
import { LEGAL_OPERATOR } from "@/lib/legal/constants";
export const metadata: Metadata = {
title: "Refund Policy | Songs2YT",
description: "Refund and cancellation policy for Songs2YT paid plans.",
};
export default function RefundPage() {
return (
<LegalPageLayout
title="Refund Policy"
description="Our policy on refunds, cancellations, and billing for paid plans."
>
<h2>1. Overview</h2>
<p>
This Refund Policy explains how refunds and cancellations work for paid Songs2YT plans. The
free Bedroom Producer plan does not involve payments and is not subject to refunds.
</p>
<h2>2. Free plan</h2>
<p>
The free plan is provided at no charge. No payment information is required and no refunds
apply.
</p>
<h2>3. Pro subscriptions</h2>
<h3>3.1 Billing cycle</h3>
<p>
Pro plans are billed on a recurring monthly basis unless otherwise stated at checkout.
Your subscription renews automatically until cancelled.
</p>
<h3>3.2 14-day refund window</h3>
<p>
If you are a new Pro subscriber, you may request a full refund within <strong>14 days</strong> of
your initial purchase, provided you have not substantially consumed paid entitlements
(for example, a large portion of your monthly video quota or premium-only features).
</p>
<h3>3.3 After the refund window</h3>
<p>
After 14 days, subscription fees are generally non-refundable for the current billing
period. You may cancel at any time to prevent future renewals. Access typically continues
until the end of the paid period.
</p>
<h3>3.4 Cancellation</h3>
<p>
You can cancel your subscription through your account billing settings or by contacting{" "}
<a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a>. Cancellation stops
future charges; it does not automatically delete your account or uploaded content history
unless you request account deletion separately.
</p>
<h2>4. Enterprise and custom agreements</h2>
<p>
Enterprise, managed cloud, and paid self-hosted setup fees are governed by the individual
quote or contract signed with us. Refund terms for those services are specified in your
agreement. Contact{" "}
<a href={`mailto:${LEGAL_OPERATOR.salesEmail}`}>{LEGAL_OPERATOR.salesEmail}</a> for
contract-related billing questions.
</p>
<h2>5. Non-refundable situations</h2>
<p>Refunds are generally not provided when:</p>
<ul>
<li>The refund request is made outside the applicable refund window</li>
<li>The account was terminated for violation of our <Link href="/terms">Terms of Service</Link></li>
<li>The issue is caused by third-party services outside our control (e.g. YouTube API outages, Google account restrictions)</li>
<li>You simply changed your mind after substantial use of paid quota or features</li>
<li>One-time setup or license fees after delivery of agreed setup work, unless required by law or contract</li>
</ul>
<h2>6. Chargebacks</h2>
<p>
If you believe a charge is incorrect, please contact us before initiating a chargeback so
we can resolve the issue promptly. Unjustified chargebacks may result in account suspension.
</p>
<h2>7. How to request a refund</h2>
<p>Email us at <a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a> with:</p>
<ul>
<li>Your account email address</li>
<li>Date of purchase and invoice or transaction reference if available</li>
<li>Reason for the refund request</li>
</ul>
<p>We aim to respond within 5 business days. Approved refunds are issued to the original payment method where possible.</p>
<h2>8. Consumer rights</h2>
<p>
Nothing in this policy limits mandatory statutory rights you may have as a consumer under
applicable law, including withdrawal rights where required by EU or local consumer
protection regulations.
</p>
<h2>9. Changes</h2>
<p>
We may update this Refund Policy from time to time. The version published on this page
applies to purchases made after the effective date shown at the top.
</p>
</LegalPageLayout>
);
}
+198
View File
@@ -0,0 +1,198 @@
import type { Metadata } from "next";
import Link from "next/link";
import { LegalPageLayout } from "@/components/LegalPageLayout";
import { LEGAL_OPERATOR } from "@/lib/legal/constants";
export const metadata: Metadata = {
title: "Terms of Service | Songs2YT",
description: "Terms and conditions for using the Songs2YT hosted service.",
};
export default function TermsPage() {
return (
<LegalPageLayout
title="Terms of Service"
description="Please read these terms carefully before using Songs2YT."
>
<h2>1. Agreement</h2>
<p>
These Terms of Service (&quot;Terms&quot;) govern your access to and use of the Songs2YT
website and hosted cloud service (the &quot;Service&quot;) operated by{" "}
{LEGAL_OPERATOR.legalName} (&quot;we&quot;, &quot;us&quot;). By creating an account or using
the Service, you agree to these Terms.
</p>
<p>
If you do not agree, do not use the Service. If you self-host the open-source software on
your own infrastructure without using our hosted Service, these Terms apply only to the
extent you use our website, support channels, or paid services we provide.
</p>
<h2>2. The Service</h2>
<p>
Songs2YT converts user-provided images and audio files into videos and can upload them to
YouTube using your connected Google/YouTube account. Features, limits, and availability
depend on your plan.
</p>
<ul>
<li>
<strong>Bedroom Producer (Free):</strong> limited quota, 720p, MP3, optional watermark
</li>
<li>
<strong>Independent Artist (Pro):</strong> expanded limits and features as described on
our pricing page
</li>
<li>
<strong>Enterprise:</strong> custom terms as agreed in writing
</li>
</ul>
<p>
We may modify features, limits, or pricing with reasonable notice where required. The
open-source software is provided separately under its applicable open-source license.
</p>
<h2>3. Eligibility and accounts</h2>
<ul>
<li>You must be at least 16 years old or the age required in your jurisdiction</li>
<li>You must have a valid Google account and authorized access to the YouTube channel you connect</li>
<li>You are responsible for maintaining the security of your account and OAuth connection</li>
<li>You must provide accurate information and promptly update it if it changes</li>
</ul>
<h2>4. Your content and responsibilities</h2>
<p>You retain ownership of content you upload. You grant us a limited license to host, process, encode, transmit, and upload your content solely to provide the Service.</p>
<p>You represent and warrant that:</p>
<ul>
<li>You own or have all necessary rights to the content you upload</li>
<li>Your content and use of the Service comply with applicable law and YouTube policies</li>
<li>Your content does not infringe third-party rights or contain unlawful material</li>
<li>You have configured metadata (including &quot;Made for Kids&quot; and privacy settings) accurately</li>
</ul>
<p>
You are solely responsible for content published to your YouTube channel through the
Service.
</p>
<h2>5. Acceptable use</h2>
<p>You agree not to:</p>
<ul>
<li>Use the Service for unlawful, harmful, or abusive purposes</li>
<li>Upload malware, attempt unauthorized access, or interfere with the Service</li>
<li>Circumvent quotas, plan limits, or technical restrictions</li>
<li>Resell or commercially exploit the hosted Service without authorization</li>
<li>Use the Service in a way that violates Google, YouTube, or third-party terms</li>
</ul>
<p>We may suspend or terminate access for violations or risks to the Service or other users.</p>
<h2>6. YouTube and third-party services</h2>
<p>
The Service integrates with Google OAuth and the YouTube API. Your use of those services is
subject to Google&apos;s and YouTube&apos;s terms and policies. We are not responsible for
changes, outages, quota limits, or enforcement actions taken by YouTube.
</p>
<h2>7. Open-source software</h2>
<p>
Portions of Songs2YT are available as open-source software. Self-hosting is permitted under
the applicable open-source license. The hosted Service, enterprise features, managed
infrastructure, and certain premium capabilities may require a separate commercial license
or subscription.
</p>
<h2>8. Fees and billing</h2>
<p>
Paid plans are billed according to the pricing displayed at the time of purchase. Taxes may
apply. Subscriptions renew automatically unless cancelled in accordance with our{" "}
<Link href="/refund">Refund Policy</Link>. Failure to pay may result in downgrade or
suspension.
</p>
<h3>8.1 Pro plan quota</h3>
<p>
Independent Artist (Pro) subscribers receive a monthly video quota that resets on the{" "}
<strong>1st day of each calendar month</strong> (UTC). Unused quota does not roll over to
the next month unless we expressly grant an extension in writing.
</p>
<p>
Pro subscribers may contact{" "}
<a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a> to request a manual
quota reset or temporary extension. Each Pro account is entitled to up to{" "}
<strong>five (5) manual monthly quota resets per calendar year</strong>. We review requests
in good faith and may decline requests that are abusive, repetitive without cause, or
inconsistent with fair use. Approved resets do not increase the annual limit of five
requests.
</p>
<h3>8.2 Pro API access</h3>
<p>
Independent Artist (Pro) subscribers may generate an API key in account settings to upload
files and create batch video jobs programmatically. API access requires an active Pro
subscription, a connected YouTube account, and compliance with the same quota, file-type, and
resolution limits as the web interface. API keys are personal, must be kept confidential, and
may be revoked by you or by us if misused. We may apply rate limits and suspend API access
for abuse, security incidents, or plan downgrades.
</p>
<h2>9. Availability and support</h2>
<p>
We strive for high availability but do not guarantee uninterrupted access. Maintenance,
updates, and outages may occur. Support levels depend on your plan. Self-hosted DIY
deployments without a paid setup are community-supported unless otherwise agreed in
writing.
</p>
<h2>10. Disclaimer of warranties</h2>
<p>
THE SERVICE IS PROVIDED &quot;AS IS&quot; AND &quot;AS AVAILABLE&quot; TO THE MAXIMUM EXTENT
PERMITTED BY LAW. WE DISCLAIM ALL WARRANTIES, EXPRESS OR IMPLIED, INCLUDING MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. WE DO NOT WARRANT THAT ENCODING,
UPLOADS, OR METADATA TRANSFER WILL BE ERROR-FREE OR UNINTERRUPTED.
</p>
<h2>11. Limitation of liability</h2>
<p>
TO THE MAXIMUM EXTENT PERMITTED BY LAW, WE SHALL NOT BE LIABLE FOR INDIRECT, INCIDENTAL,
SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES, OR ANY LOSS OF PROFITS, REVENUE, DATA, OR
GOODWILL. OUR TOTAL LIABILITY FOR ANY CLAIM ARISING OUT OF THESE TERMS OR THE SERVICE IS
LIMITED TO THE AMOUNT YOU PAID US IN THE TWELVE (12) MONTHS BEFORE THE EVENT GIVING RISE TO
THE CLAIM, OR EUR 100 IF YOU USE THE FREE PLAN ONLY.
</p>
<p>
Some jurisdictions do not allow certain limitations, so some of the above may not apply to
you.
</p>
<h2>12. Indemnification</h2>
<p>
You agree to indemnify and hold us harmless from claims arising out of your content, your
use of the Service, or your violation of these Terms or applicable law.
</p>
<h2>13. Termination</h2>
<p>
You may stop using the Service at any time. We may suspend or terminate your access if you
breach these Terms, create risk or legal exposure, or where required by law. Upon
termination, your right to use the hosted Service ends. Provisions that by nature should
survive will survive.
</p>
<h2>14. Governing law</h2>
<p>
These Terms are governed by the laws of [Your jurisdiction / country], excluding conflict
of law rules. Courts in [Your jurisdiction] shall have exclusive jurisdiction unless
mandatory consumer protection laws in your country provide otherwise.
</p>
<h2>15. Changes</h2>
<p>
We may update these Terms from time to time. Continued use after changes become effective
constitutes acceptance of the revised Terms, where permitted by law.
</p>
<h2>16. Contact</h2>
<p>
Questions about these Terms:{" "}
<a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a>
</p>
</LegalPageLayout>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+5
View File
@@ -0,0 +1,5 @@
# Watermark image
Place `watermark.png` here for the bottom-right video overlay.
The image should include the full attribution: "Uploaded through Songs2YT.com".
If missing, FFmpeg falls back to bottom-right drawtext with the same message.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 374 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

+78
View File
@@ -0,0 +1,78 @@
"use client";
import { signOut } from "next-auth/react";
import { useState } from "react";
import { SUPPORT_EMAIL } from "@/lib/plans";
type Props = {
email: string;
};
export function AccountPrivacyActions({ email }: Props) {
const [deleting, setDeleting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleDeleteAccount() {
if (
!confirm(
"Delete your account permanently? This removes your jobs, uploads, and YouTube connection. This cannot be undone.",
)
) {
return;
}
setDeleting(true);
setError(null);
try {
const res = await fetch("/api/account/delete", { method: "POST" });
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Failed to delete account");
await signOut({ callbackUrl: "/" });
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to delete account");
setDeleting(false);
}
}
const dataRequestSubject = encodeURIComponent("Data export request");
const dataRequestBody = encodeURIComponent(
`Hello,\n\nI would like to request a copy of my personal data associated with my Songs2YT account (${email}).\n\nThank you.`,
);
return (
<div className="space-y-4">
{error && (
<div className="rounded border border-red-500/50 bg-red-500/10 px-4 py-3 text-sm text-red-300">
{error}
</div>
)}
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap">
<a
href={`mailto:${SUPPORT_EMAIL}?subject=${dataRequestSubject}&body=${dataRequestBody}`}
className="inline-flex items-center justify-center rounded border border-gray-600 px-4 py-2 text-sm font-medium text-gray-200 transition-colors hover:bg-surface"
>
Request my data
</a>
<button
type="button"
onClick={handleDeleteAccount}
disabled={deleting}
className="inline-flex items-center justify-center rounded border border-red-600/50 px-4 py-2 text-sm font-medium text-red-300 transition-colors hover:bg-red-500/10 disabled:opacity-50"
>
{deleting ? "Deleting…" : "Delete account"}
</button>
</div>
<p className="text-xs text-gray-500">
Data requests are handled within the timelines described in our{" "}
<a href="/privacy" className="text-gray-400 underline hover:text-white">
Privacy Policy
</a>
.
</p>
</div>
);
}
+296
View File
@@ -0,0 +1,296 @@
"use client";
import { useEffect, useState } from "react";
type ApiKeyStatus = {
configured: boolean;
prefix: string | null;
};
type RateLimitStatus = {
limit: number;
used: number;
remaining: number;
windowSeconds: number;
resetsInSeconds: number;
bonus?: number;
};
type ExtensionRequest = {
id: string;
status: string;
message: string;
requestedAt: string;
processedAt: string | null;
adminNote: string | null;
};
type ExtensionUsage = {
used: number;
limit: number;
remaining: number;
requests: ExtensionRequest[];
};
type Props = {
initialStatus: ApiKeyStatus;
initialRateLimit: RateLimitStatus;
initialExtensionUsage: ExtensionUsage;
};
export function ApiKeySettings({
initialStatus,
initialRateLimit,
initialExtensionUsage,
}: Props) {
const [status, setStatus] = useState(initialStatus);
const [rateLimit, setRateLimit] = useState(initialRateLimit);
const [extensionUsage, setExtensionUsage] = useState(initialExtensionUsage);
const [newKey, setNewKey] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [requesting, setRequesting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
useEffect(() => {
let active = true;
const refresh = () => {
fetch("/api/account/api-rate-limit")
.then(async (res) => {
if (!res.ok) return;
const data = await res.json();
if (active) setRateLimit(data);
})
.catch(() => {});
};
refresh();
const id = setInterval(refresh, 5000);
return () => {
active = false;
clearInterval(id);
};
}, []);
async function generateKey() {
if (
status.configured &&
!confirm("This will replace your existing API key. Continue?")
) {
return;
}
setLoading(true);
setError(null);
setNewKey(null);
setSuccess(null);
try {
const res = await fetch("/api/account/api-key", { method: "POST" });
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Failed to generate API key");
setNewKey(data.apiKey);
setStatus({ configured: true, prefix: data.prefix });
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to generate API key");
} finally {
setLoading(false);
}
}
async function revokeKey() {
if (!confirm("Revoke your API key? External integrations will stop working.")) return;
setLoading(true);
setError(null);
setSuccess(null);
try {
const res = await fetch("/api/account/api-key", { method: "DELETE" });
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Failed to revoke API key");
setStatus({ configured: false, prefix: null });
setNewKey(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to revoke API key");
} finally {
setLoading(false);
}
}
async function requestRateLimitExtension() {
if (extensionUsage.remaining <= 0) return;
const reason = prompt(
"Optional: tell us why you need a higher API rate limit (leave blank to skip).",
);
if (reason === null) return;
setRequesting(true);
setError(null);
setSuccess(null);
try {
const res = await fetch("/api/account/api-rate-limit-extension-request", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: reason }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Failed to submit request");
setExtensionUsage({
used: data.used,
limit: data.limit,
remaining: data.remaining,
requests: data.requests ?? extensionUsage.requests,
});
setSuccess(
`Rate limit extension request submitted. ${data.used} of ${data.limit} used this year.`,
);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to submit request");
} finally {
setRequesting(false);
}
}
const hasPending = extensionUsage.requests.some((r) => r.status === "PENDING");
const usedPct = Math.min(100, Math.round((rateLimit.used / Math.max(1, rateLimit.limit)) * 100));
return (
<div className="space-y-4">
<p className="text-sm text-gray-400">
Use the REST API to upload files and create batch video jobs programmatically. Pro plan
only.
</p>
<div className="rounded border border-gray-800 bg-surface p-4 space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-sm font-medium text-white">API rate limit</p>
<p className="text-xs text-gray-400">
Resets in {rateLimit.resetsInSeconds}s
</p>
</div>
<p className="text-sm text-gray-300">
{rateLimit.used} / {rateLimit.limit} requests used this minute
{rateLimit.bonus ? (
<span className="text-gray-500"> (includes +{rateLimit.bonus} bonus)</span>
) : null}
</p>
<div className="h-2 overflow-hidden rounded bg-gray-800">
<div
className={`h-full rounded transition-all ${
usedPct >= 90 ? "bg-red-500" : usedPct >= 70 ? "bg-amber-500" : "bg-accent"
}`}
style={{ width: `${usedPct}%` }}
/>
</div>
<p className="text-xs text-gray-500">
{rateLimit.remaining} requests remaining · window {rateLimit.windowSeconds}s
</p>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs text-gray-400">
Extension requests this year: {extensionUsage.used} / {extensionUsage.limit}
</p>
<button
type="button"
onClick={requestRateLimitExtension}
disabled={requesting || extensionUsage.remaining <= 0 || hasPending}
className="inline-flex items-center justify-center rounded border border-accent/40 px-3 py-1.5 text-xs font-medium text-accent transition-colors hover:bg-accent/10 disabled:cursor-not-allowed disabled:opacity-50"
>
{requesting
? "Submitting…"
: hasPending
? "Pending request"
: extensionUsage.remaining <= 0
? "No extensions left"
: "Request rate limit extension"}
</button>
</div>
{extensionUsage.requests.length > 0 && (
<ul className="space-y-1 border-t border-gray-800 pt-3 text-xs text-gray-500">
{extensionUsage.requests.slice(0, 5).map((req) => (
<li key={req.id}>
{new Date(req.requestedAt).toLocaleDateString()} · {req.status}
{req.adminNote ? ` · ${req.adminNote}` : ""}
</li>
))}
</ul>
)}
</div>
{status.configured && status.prefix && (
<p className="text-sm text-gray-300">
Active key: <span className="font-mono text-white">{status.prefix}</span>
</p>
)}
{newKey && (
<div className="rounded border border-green-500/40 bg-green-500/10 p-4">
<p className="mb-2 text-sm font-medium text-green-300">Your new API key (copy now)</p>
<code className="block break-all rounded bg-black/40 px-3 py-2 text-xs text-green-200">
{newKey}
</code>
</div>
)}
{success && (
<div className="rounded border border-green-500/40 bg-green-500/10 px-4 py-3 text-sm text-green-300">
{success}
</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>
)}
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap">
<button
type="button"
onClick={generateKey}
disabled={loading}
className="inline-flex items-center justify-center rounded bg-accent px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent-hover disabled:opacity-50"
>
{loading ? "Working…" : status.configured ? "Regenerate API key" : "Generate API key"}
</button>
{status.configured && (
<button
type="button"
onClick={revokeKey}
disabled={loading}
className="inline-flex items-center justify-center rounded border border-red-600/50 px-4 py-2 text-sm font-medium text-red-300 transition-colors hover:bg-red-500/10 disabled:opacity-50"
>
Revoke API key
</button>
)}
</div>
<div className="rounded border border-gray-800 bg-surface p-4 text-xs text-gray-400">
<p className="mb-2 font-semibold uppercase tracking-wider text-gray-500">Endpoints</p>
<ul className="space-y-2 font-mono">
<li>POST /api/v1/upload: upload image or audio file</li>
<li>POST /api/v1/jobs: create job from paths (recommended for large batches)</li>
<li>POST /api/v1/jobs/batch: small packs only (one-shot multipart)</li>
<li>GET /api/v1/playlists: list YouTube playlists</li>
<li>POST /api/v1/playlists: create a YouTube playlist</li>
<li>GET /api/v1/jobs: list jobs</li>
<li>GET /api/v1/jobs/:id: job status</li>
</ul>
<p className="mt-3">
Send <span className="text-gray-300">Authorization: Bearer YOUR_API_KEY</span> on every
request. For many audio files, upload each file then call{" "}
<span className="text-gray-300">/api/v1/jobs</span> (avoid large one-shot batches).{" "}
<a href="/dashboard/api-docs" className="text-accent hover:underline">
Full API docs
</a>
</p>
</div>
</div>
);
}
+436
View File
@@ -0,0 +1,436 @@
"use client";
import Image from "next/image";
import { useEffect, useRef, useState } from "react";
import { ScrollReveal } from "@/components/ScrollReveal";
import { useInView } from "@/hooks/useInView";
import { useMockupProgress } from "@/hooks/useMockupProgress";
import { SectionScrollTitle } from "@/components/SectionScrollTitle";
function FilmStripIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="2" y="4" width="20" height="16" rx="2" />
<path d="M2 8h20M2 16h20M6 4v4M6 16v4M10 4v4M10 16v4M14 4v4M14 16v4M18 4v4M18 16v4" />
<path d="m15 9 3 3-3 3" />
</svg>
);
}
function YouTubeIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M23.5 6.2a3 3 0 0 0-2.1-2.1C19.5 3.5 12 3.5 12 3.5s-7.5 0-9.4.6A3 3 0 0 0 .5 6.2 31 31 0 0 0 0 12a31 31 0 0 0 .5 5.8 3 3 0 0 0 2.1 2.1c1.9.6 9.4.6 9.4.6s7.5 0 9.4-.6a3 3 0 0 0 2.1-2.1A31 31 0 0 0 24 12a31 31 0 0 0-.5-5.8z" />
<path fill="#12121f" d="M9.75 15.02l6.35-3.02-6.35-3.02v6.04z" />
</svg>
);
}
function CoinsIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<ellipse cx="8" cy="6" rx="5" ry="2" />
<path d="M3 6v4c0 1.1 2.24 2 5 2s5-.9 5-2V6" />
<path d="M3 10v4c0 1.1 2.24 2 5 2s5-.9 5-2v-4" />
<ellipse cx="16" cy="14" rx="5" ry="2" />
<path d="M11 14v4c0 1.1 2.24 2 5 2s5-.9 5-2v-4" />
</svg>
);
}
function PlaylistIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M4 6h12M4 12h12M4 18h8" strokeLinecap="round" />
<path d="M17 14.5v5l4-2.5-4-2.5z" fill="currentColor" stroke="none" />
</svg>
);
}
function NoteIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M9 18V5l12-2v13" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" />
</svg>
);
}
function WaveIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M4 12h1M7 12h1M10 8v8M13 10v4M16 7v10M19 11v2" strokeLinecap="round" />
</svg>
);
}
function GearsIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="12" cy="12" r="3" />
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
</svg>
);
}
const BENEFITS = [
{
title: "No editing required",
desc: "Skip complex video editors. Just upload an image and audio, and Songs2YT handles the rest.",
Icon: FilmStripIcon,
},
{
title: "YouTube-ready output",
desc: "Videos are encoded and uploaded with the metadata you set, ready for your channel.",
Icon: YouTubeIcon,
},
{
title: "Free tier included",
desc: "Start creating with 14 videos every 12 hours at up to 720p. No credit card needed.",
Icon: CoinsIcon,
},
{
title: "YouTube playlists",
desc: "Independent Artist (Pro) can create YouTube playlists and add every upload from the dashboard or API.",
Icon: PlaylistIcon,
},
] as const;
const STOCK_THUMBS = [
"https://images.unsplash.com/photo-1511379938547-c1f69419868d?w=120&h=120&fit=crop&auto=format",
"https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=120&h=120&fit=crop&auto=format",
"https://images.unsplash.com/photo-1514320291840-2e0a9bf2a9ae?w=120&h=120&fit=crop&auto=format",
"https://images.unsplash.com/photo-1487180144351-b8472da7d491?w=120&h=120&fit=crop&auto=format",
"https://images.unsplash.com/photo-1598488035139-bdbb2231ce04?w=120&h=120&fit=crop&auto=format",
"https://images.unsplash.com/photo-1571330735066-03aaa9429d89?w=120&h=120&fit=crop&auto=format",
"https://images.unsplash.com/photo-1619983081563-430f63602796?w=120&h=120&fit=crop&auto=format",
"https://images.unsplash.com/photo-1483412033650-1015ddeb83d1?w=120&h=120&fit=crop&auto=format",
] as const;
function TypewriterText({ text }: { text: string }) {
const [displayed, setDisplayed] = useState("");
const [done, setDone] = useState(false);
useEffect(() => {
let index = 0;
const interval = setInterval(() => {
index += 1;
setDisplayed(text.slice(0, index));
if (index >= text.length) {
clearInterval(interval);
setDone(true);
}
}, 45);
return () => clearInterval(interval);
}, [text]);
return (
<span className="text-xs font-medium text-gray-300">
{displayed}
{!done && <span className="ml-0.5 inline-block w-[2px] animate-pulse bg-red-400">|</span>}
</span>
);
}
function BenefitCard({
title,
desc,
Icon,
}: {
title: string;
desc: string;
Icon: typeof FilmStripIcon;
}) {
return (
<div className="group relative overflow-hidden rounded-xl border border-red-500/20 bg-surface p-6 shadow-[0_0_24px_rgba(239,68,68,0.08)] transition-all duration-300 hover:border-red-500/40 hover:shadow-[0_0_32px_rgba(239,68,68,0.18)]">
<Icon className="absolute right-5 top-5 h-8 w-8 text-gray-600 transition-colors duration-300 group-hover:text-red-500/50" />
<h3 className="pr-12 text-lg font-semibold text-white">{title}</h3>
<p className="mt-3 text-sm leading-relaxed text-gray-400">{desc}</p>
</div>
);
}
function CheckIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
aria-hidden="true"
>
<path d="M5 13l4 4L19 7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function MockupProgressRow({
initialWidth,
midWidth,
active,
phaseOneMs = 3000,
phaseTwoMs = 2000,
}: {
initialWidth: string;
midWidth: string;
active: boolean;
phaseOneMs?: number;
phaseTwoMs?: number;
}) {
const { phase, processed, transitionMs } = useMockupProgress(active, phaseOneMs, phaseTwoMs);
const width = phase === 0 ? initialWidth : phase === 1 ? midWidth : "100%";
return (
<div>
<p className="mb-1 text-[10px] text-gray-500">{processed ? "Processed" : "Processing..."}</p>
<div className="flex items-center gap-2">
<div className="h-1 min-w-0 flex-1 rounded-full bg-gray-700">
<div
className="h-full rounded-full bg-white/80 ease-out"
style={{
width,
transition: phase >= 1 ? `width ${transitionMs}ms ease-out` : "none",
}}
/>
</div>
<CheckIcon
className={`h-3.5 w-3.5 shrink-0 text-accent transition-all duration-300 ${
processed ? "scale-100 opacity-100" : "scale-75 opacity-0"
}`}
/>
</div>
</div>
);
}
function DashboardMockup() {
const { ref, inView } = useInView();
const sidebarIcons = [NoteIcon, WaveIcon, GearsIcon] as const;
return (
<div ref={ref} className="rounded-xl border border-gray-700/50 bg-surface-dark p-4">
<div className="flex gap-4">
<div className="flex w-11 shrink-0 flex-col items-center gap-3 rounded-lg bg-surface py-3">
<div className="h-2 w-2 rounded-full bg-gray-500" />
{sidebarIcons.map((Icon, i) => (
<div
key={i}
className="flex h-7 w-7 items-center justify-center rounded-md border border-gray-700/50 bg-surface-dark text-gray-500"
>
<Icon className="h-4 w-4" />
</div>
))}
</div>
<div className="min-w-0 flex-1 space-y-3">
<div className="flex items-center justify-between">
<TypewriterText text="Multi-step configuration" />
<div className="h-4 w-8 rounded-full bg-red-500/80" />
</div>
<div className="h-7 rounded border border-gray-700 bg-surface px-2 text-xs leading-7 text-gray-500">
Title
</div>
<div className="flex flex-wrap gap-1.5">
<span className="rounded bg-gray-700 px-2 py-0.5 text-[10px] text-gray-400">genre</span>
<span className="rounded bg-gray-700 px-2 py-0.5 text-[10px] text-gray-400">audio</span>
</div>
<div className="flex h-7 items-center justify-between rounded border border-gray-700 bg-surface px-2 text-xs text-gray-500">
Category <span className="text-gray-400">Music </span>
</div>
<div className="space-y-2 pt-1">
<MockupProgressRow active={inView} initialWidth="25%" midWidth="45%" phaseOneMs={3000} phaseTwoMs={2000} />
<MockupProgressRow active={inView} initialWidth="10%" midWidth="55%" phaseOneMs={3000} phaseTwoMs={2000} />
</div>
</div>
</div>
</div>
);
}
function ProcessFlow() {
return (
<div className="relative mt-4 overflow-hidden rounded-xl border border-gray-700/50 bg-surface-dark p-5">
<svg
className="pointer-events-none absolute inset-0 h-full w-full"
viewBox="0 0 420 240"
preserveAspectRatio="none"
aria-hidden="true"
>
<defs>
<linearGradient id="flowGradH" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="rgb(239 68 68 / 0.15)" />
<stop offset="50%" stopColor="rgb(239 68 68 / 0.55)" />
<stop offset="100%" stopColor="rgb(239 68 68 / 0.15)" />
</linearGradient>
<linearGradient id="flowGradV" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor="rgb(239 68 68 / 0.5)" />
<stop offset="100%" stopColor="rgb(239 68 68 / 0.15)" />
</linearGradient>
</defs>
<path
d="M 58 52 C 120 52, 140 58, 168 72"
fill="none"
stroke="url(#flowGradH)"
strokeWidth="1.5"
className="benefits-flow-path"
/>
<path
d="M 58 118 C 120 108, 140 88, 168 78"
fill="none"
stroke="url(#flowGradH)"
strokeWidth="1.5"
className="benefits-flow-path"
style={{ animationDelay: "0.3s" }}
/>
<path
d="M 198 75 C 250 75, 285 68, 318 68"
fill="none"
stroke="url(#flowGradH)"
strokeWidth="1.5"
className="benefits-flow-path"
style={{ animationDelay: "0.6s" }}
/>
</svg>
<div className="relative z-10">
<div className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-4">
<FlowInput label="Batch Audio Files" icon="audio" />
<FlowInput label="Single Cover Image" icon="image" />
</div>
<div className="flex flex-1 flex-col items-center px-2 pt-6">
<div className="benefits-process-icon flex h-16 w-16 items-center justify-center rounded-xl border border-red-500/40 bg-surface shadow-[0_0_20px_rgba(239,68,68,0.25)]">
<Image
src="/database.png"
alt="Process"
width={40}
height={40}
className="h-10 w-10 object-contain brightness-0 invert opacity-90"
/>
</div>
<span className="mt-2 text-[10px] font-medium text-gray-400">Process</span>
</div>
<div className="flex flex-col items-center">
<div className="grid grid-cols-4 gap-1.5">
{STOCK_THUMBS.map((src) => (
<div
key={src}
className="relative h-11 w-11 overflow-hidden rounded border border-gray-700"
>
<Image
src={src}
alt=""
fill
sizes="44px"
className="scale-110 object-cover blur-[2px]"
/>
<div className="absolute inset-0 bg-black/25" />
<span className="absolute inset-0 flex items-center justify-center text-[9px] text-white/80">
</span>
</div>
))}
</div>
<svg
className="my-2 h-10 w-6 overflow-visible"
viewBox="0 0 6 40"
aria-hidden="true"
>
<line
x1="3"
y1="2"
x2="3"
y2="38"
stroke="rgb(239 68 68 / 0.55)"
strokeWidth="1.5"
className="benefits-flow-path"
style={{ animationDelay: "0.9s" }}
/>
</svg>
<div className="flex items-center gap-3">
<YouTubeIcon className="h-9 w-9 text-red-500" />
<div>
<p className="text-xs font-bold tracking-wider text-white">YouTube</p>
<p className="text-[10px] font-semibold tracking-[0.15em] text-red-400">DIRECT UPLOAD</p>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
function FlowInput({ label, icon }: { label: string; icon: "audio" | "image" }) {
return (
<div className="flex flex-col items-center gap-1.5">
<div className="flex h-10 w-10 items-center justify-center rounded-lg border border-gray-700 bg-surface">
{icon === "audio" ? (
<NoteIcon className="h-5 w-5 text-gray-500" />
) : (
<svg className="h-5 w-5 text-gray-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<path d="M21 15l-5-5L5 21" />
</svg>
)}
</div>
<span className="max-w-[72px] text-center text-[9px] leading-tight text-gray-500">{label}</span>
</div>
);
}
function BenefitsVisual() {
return (
<div className="rounded-2xl border border-gray-700/50 bg-surface p-5 shadow-2xl shadow-black/30">
<DashboardMockup />
<ProcessFlow />
</div>
);
}
function BenefitsScrollTitle({ sectionRef }: { sectionRef: React.RefObject<HTMLElement | null> }) {
return <SectionScrollTitle sectionRef={sectionRef} title="Benefits" />;
}
export function BenefitsSection() {
const sectionRef = useRef<HTMLElement>(null);
return (
<section
ref={sectionRef}
id="benefits"
className="relative scroll-mt-24 overflow-x-visible overflow-y-hidden px-6 py-24"
>
<BenefitsScrollTitle sectionRef={sectionRef} />
<div className="relative z-10 mx-auto max-w-7xl">
<ScrollReveal>
<h2 className="mb-12 text-center text-3xl font-bold text-white">Benefits</h2>
</ScrollReveal>
<div className="grid items-start gap-10 lg:grid-cols-[1fr_1.15fr] lg:gap-12">
<div className="flex flex-col gap-5">
{BENEFITS.map((benefit, index) => (
<ScrollReveal key={benefit.title} delay={index * 100} direction="left">
<BenefitCard title={benefit.title} desc={benefit.desc} Icon={benefit.Icon} />
</ScrollReveal>
))}
</div>
<ScrollReveal delay={200} direction="right">
<BenefitsVisual />
</ScrollReveal>
</div>
</div>
</section>
);
}
+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>
);
}
+95
View File
@@ -0,0 +1,95 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useState } from "react";
import { signOut } from "next-auth/react";
import { Logo } from "@/components/Logo";
import { SignOutButton } from "@/components/SignOutButton";
import { MobileMenuButton, MobileSidebar, dashboardSidebarLinkClass } from "@/components/MobileSidebar";
const NAV_LINKS = [
{ href: "/dashboard", label: "Create", match: (path: string) => path === "/dashboard" },
{
href: "/dashboard/history",
label: "History",
match: (path: string) => path.startsWith("/dashboard/history"),
},
{
href: "/dashboard/settings",
label: "Settings",
match: (path: string) => path.startsWith("/dashboard/settings"),
},
] as const;
type Props = {
channelTitle?: string | null;
};
export function DashboardNav({ channelTitle }: Props) {
const pathname = usePathname();
const [menuOpen, setMenuOpen] = useState(false);
return (
<>
<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 className="min-w-0">
<Logo href="/dashboard" size="sm" />
{channelTitle && (
<p className="truncate text-xs text-gray-500">Channel: {channelTitle}</p>
)}
</div>
<div className="hidden items-center gap-4 sm:flex">
<nav className="flex items-center gap-4">
{NAV_LINKS.map(({ href, label, match }) => {
const active = match(pathname);
return (
<Link
key={href}
href={href}
className={`text-sm font-medium transition-colors duration-200 ${
active ? "text-white" : "text-gray-400 hover:text-white"
}`}
>
{label}
</Link>
);
})}
</nav>
<SignOutButton />
</div>
<MobileMenuButton
open={menuOpen}
onClick={() => setMenuOpen((prev) => !prev)}
/>
</div>
</header>
<MobileSidebar open={menuOpen} onClose={() => setMenuOpen(false)} title="Dashboard">
{NAV_LINKS.map(({ href, label, match }) => (
<Link
key={href}
href={href}
onClick={() => setMenuOpen(false)}
className={dashboardSidebarLinkClass(match(pathname))}
>
{label}
</Link>
))}
<button
type="button"
onClick={() => {
setMenuOpen(false);
signOut({ callbackUrl: "/" });
}}
className={`${dashboardSidebarLinkClass(false, true)} w-full text-left`}
>
Sign out
</button>
</MobileSidebar>
</>
);
}
+18
View File
@@ -0,0 +1,18 @@
import type { ReactNode } from "react";
import { DashboardNav } from "@/components/DashboardNav";
import { LegalFooter } from "@/components/LegalFooter";
type Props = {
channelTitle?: string | null;
children: ReactNode;
};
export function DashboardShell({ channelTitle, children }: Props) {
return (
<main className="flex min-h-screen flex-col">
<DashboardNav channelTitle={channelTitle} />
<div className="flex-1">{children}</div>
<LegalFooter />
</main>
);
}
+160
View File
@@ -0,0 +1,160 @@
"use client";
import { useRef } from "react";
import { ScrollReveal } from "@/components/ScrollReveal";
import { SectionScrollTitle } from "@/components/SectionScrollTitle";
import { DOCKER_HUB_URL, GITEA_URL } from "@/lib/plans";
function DockerIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" role="img" aria-label="Docker">
<path
fill="#2496ED"
d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.184-.186h-2.12a.186.186 0 00-.186.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z"
/>
</svg>
);
}
function GiteaIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" role="img" aria-label="Gitea">
<path
fill="#609926"
d="M4.209 4.603c-.247 0-.525.02-.84.088-.333.07-1.28.283-2.054 1.027C-.403 7.25.035 9.685.089 10.052c.065.446.263 1.687 1.21 2.768 1.749 2.141 5.513 2.092 5.513 2.092s.462 1.103 1.168 2.119c.955 1.263 1.936 2.248 2.89 2.367 2.406 0 7.212-.004 7.212-.004s.458.004 1.08-.394c.535-.324 1.013-.893 1.013-.893s.492-.527 1.18-1.73c.21-.37.385-.729.538-1.068 0 0 2.107-4.471 2.107-8.823-.042-1.318-.367-1.55-.443-1.627-.156-.156-.366-.153-.366-.153s-4.475.252-6.792.306c-.508.011-1.012.023-1.512.027v4.474l-.634-.301c0-1.39-.004-4.17-.004-4.17-1.107.016-3.405-.084-3.405-.084s-5.399-.27-5.987-.324c-.187-.011-.401-.032-.648-.032zm.354 1.832h.111s.271 2.269.6 3.597C5.549 11.147 6.22 13 6.22 13s-.996-.119-1.641-.348c-.99-.324-1.409-.714-1.409-.714s-.73-.511-1.096-1.52C1.444 8.73 2.021 7.7 2.021 7.7s.32-.859 1.47-1.145c.395-.106.863-.12 1.072-.12zm8.33 2.554c.26.003.509.127.509.127l.868.422-.529 1.075a.686.686 0 0 0-.614.359.685.685 0 0 0 .072.756l-.939 1.924a.69.69 0 0 0-.66.527.687.687 0 0 0 .347.763.686.686 0 0 0 .867-.206.688.688 0 0 0-.069-.882l.916-1.874a.667.667 0 0 0 .237-.02.657.657 0 0 0 .271-.137 8.826 8.826 0 0 1 1.016.512.761.761 0 0 1 .286.282c.073.21-.073.569-.073.569-.087.29-.702 1.55-.702 1.55a.692.692 0 0 0-.676.477.681.681 0 1 0 1.157-.252c.073-.141.141-.282.214-.431.19-.397.515-1.16.515-1.16.035-.066.218-.394.103-.814-.095-.435-.48-.638-.48-.638-.467-.301-1.116-.58-1.116-.58s0-.156-.042-.27a.688.688 0 0 0-.148-.241l.516-1.062 2.89 1.401s.48.218.583.619c.073.282-.019.534-.069.657-.24.587-2.1 4.317-2.1 4.317s-.232.554-.748.588a1.065 1.065 0 0 1-.393-.045l-.202-.08-4.31-2.1s-.417-.218-.49-.596c-.083-.31.104-.691.104-.691l2.073-4.272s.183-.37.466-.497a.855.855 0 0 1 .35-.077z"
/>
</svg>
);
}
const DEPLOY_CARDS = [
{
name: "Docker Hub",
desc: "Pull the image and run Songs2YT on your own server with Docker Compose.",
Icon: DockerIcon,
cta: "View on Docker Hub",
href: DOCKER_HUB_URL,
accent: "text-[#2496ED]",
hover:
"hover:border-[#2496ED]/45 hover:bg-[#2496ED]/[0.06] hover:shadow-lg hover:shadow-[#2496ED]/20",
titleHover: "group-hover:text-[#2496ED]",
},
{
name: "Gitea",
desc: "Clone the open-source repository and deploy from source on your infrastructure.",
Icon: GiteaIcon,
cta: "View on Gitea",
href: GITEA_URL,
accent: "text-[#609926]",
hover:
"hover:border-[#609926]/45 hover:bg-[#609926]/[0.06] hover:shadow-lg hover:shadow-[#609926]/20",
titleHover: "group-hover:text-[#609926]",
},
] as const;
function DeployCard({
name,
desc,
Icon,
cta,
href,
accent,
hover,
titleHover,
}: (typeof DEPLOY_CARDS)[number]) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className={`group block rounded-xl border border-gray-700/50 bg-surface p-6 transition-all duration-300 ${hover}`}
>
<div className="mb-4 flex items-center justify-between">
<Icon className="h-8 w-8 transition-transform duration-300 group-hover:scale-110" />
<span className="rounded bg-gray-800 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider text-gray-500 transition-colors duration-300 group-hover:bg-gray-700/80">
Open Source
</span>
</div>
<h3 className={`text-lg font-semibold text-white transition-colors duration-300 ${titleHover}`}>
{name}
</h3>
<p className="mt-2 text-sm leading-relaxed text-gray-400 transition-colors duration-300 group-hover:text-gray-300">
{desc}
</p>
<p className={`mt-4 text-sm font-medium ${accent} transition-all duration-300 group-hover:underline`}>
{cta}
</p>
</a>
);
}
export function DownloadSection() {
const sectionRef = useRef<HTMLElement>(null);
return (
<section
ref={sectionRef}
id="download"
className="relative scroll-mt-24 overflow-x-visible overflow-y-hidden px-6 py-24"
>
<SectionScrollTitle sectionRef={sectionRef} title="Download" />
<div className="relative z-10 mx-auto max-w-7xl">
<ScrollReveal>
<h2 className="mb-4 text-center text-3xl font-bold text-white">Download</h2>
<p className="mx-auto mb-12 max-w-2xl text-center text-gray-400">
Songs2YT is open source. Self-host on your own server with Docker or deploy from source.
</p>
</ScrollReveal>
<div className="grid items-start gap-10 lg:grid-cols-[1fr_1.1fr] lg:gap-12">
<ScrollReveal direction="left">
<div className="space-y-6">
<div>
<h3 className="text-xl font-semibold text-white">Run it yourself</h3>
<p className="mt-3 leading-relaxed text-gray-400">
Host Songs2YT on your infrastructure with full control over data, queues, and
storage. Ideal for teams and creators who want a private deployment.
</p>
</div>
<ul className="space-y-3 text-sm text-gray-400">
<li className="flex items-start gap-3">
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-red-500" />
Docker image published to Docker Hub
</li>
<li className="flex items-start gap-3">
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-red-500" />
Source code available on Gitea
</li>
<li className="flex items-start gap-3">
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-red-500" />
Gitea Issues community support
</li>
<li className="flex items-start gap-3">
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-red-500" />
PostgreSQL, Redis, FFmpeg, and worker included
</li>
</ul>
<div className="rounded-xl border border-gray-700/50 bg-surface-dark p-4">
<p className="mb-3 text-xs font-medium text-gray-500">Quick start</p>
<pre className="overflow-x-auto text-sm leading-relaxed text-gray-300">
<code>{`docker pull atakanozban/songs2yt:latest\ndocker compose up -d`}</code>
</pre>
</div>
</div>
</ScrollReveal>
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-1">
{DEPLOY_CARDS.map((card, index) => (
<ScrollReveal key={card.name} delay={index * 120} direction="right">
<DeployCard {...card} />
</ScrollReveal>
))}
</div>
</div>
</div>
</section>
);
}
+192
View File
@@ -0,0 +1,192 @@
import Link from "next/link";
import type { ReactNode } from "react";
import { Logo } from "@/components/Logo";
import {
DOCKER_HUB_URL,
GITEA_ISSUES_URL,
GITEA_URL,
SUPPORT_EMAIL,
} from "@/lib/plans";
function GiteaIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" role="img" aria-hidden="true">
<path
fill="currentColor"
d="M4.209 4.603c-.247 0-.525.02-.84.088-.333.07-1.28.283-2.054 1.027C-.403 7.25.035 9.685.089 10.052c.065.446.263 1.687 1.21 2.768 1.749 2.141 5.513 2.092 5.513 2.092s.462 1.103 1.168 2.119c.955 1.263 1.936 2.248 2.89 2.367 2.406 0 7.212-.004 7.212-.004s.458.004 1.08-.394c.535-.324 1.013-.893 1.013-.893s.492-.527 1.18-1.73c.21-.37.385-.729.538-1.068 0 0 2.107-4.471 2.107-8.823-.042-1.318-.367-1.55-.443-1.627-.156-.156-.366-.153-.366-.153s-4.475.252-6.792.306c-.508.011-1.012.023-1.512.027v4.474l-.634-.301c0-1.39-.004-4.17-.004-4.17-1.107.016-3.405-.084-3.405-.084s-5.399-.27-5.987-.324c-.187-.011-.401-.032-.648-.032zm.354 1.832h.111s.271 2.269.6 3.597C5.549 11.147 6.22 13 6.22 13s-.996-.119-1.641-.348c-.99-.324-1.409-.714-1.409-.714s-.73-.511-1.096-1.52C1.444 8.73 2.021 7.7 2.021 7.7s.32-.859 1.47-1.145c.395-.106.863-.12 1.072-.12zm8.33 2.554c.26.003.509.127.509.127l.868.422-.529 1.075a.686.686 0 0 0-.614.359.685.685 0 0 0 .072.756l-.939 1.924a.69.69 0 0 0-.66.527.687.687 0 0 0 .347.763.686.686 0 0 0 .867-.206.688.688 0 0 0-.069-.882l.916-1.874a.667.667 0 0 0 .237-.02.657.657 0 0 0 .271-.137 8.826 8.826 0 0 1 1.016.512.761.761 0 0 1 .286.282c.073.21-.073.569-.073.569-.087.29-.702 1.55-.702 1.55a.692.692 0 0 0-.676.477.681.681 0 1 0 1.157-.252c.073-.141.141-.282.214-.431.19-.397.515-1.16.515-1.16.035-.066.218-.394.103-.814-.095-.435-.48-.638-.48-.638-.467-.301-1.116-.58-1.116-.58s0-.156-.042-.27a.688.688 0 0 0-.148-.241l.516-1.062 2.89 1.401s.48.218.583.619c.073.282-.019.534-.069.657-.24.587-2.1 4.317-2.1 4.317s-.232.554-.748.588a1.065 1.065 0 0 1-.393-.045l-.202-.08-4.31-2.1s-.417-.218-.49-.596c-.083-.31.104-.691.104-.691l2.073-4.272s.183-.37.466-.497a.855.855 0 0 1 .35-.077z"
/>
</svg>
);
}
function DockerIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" role="img" aria-hidden="true">
<path
fill="currentColor"
d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.184-.186h-2.12a.186.186 0 00-.186.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z"
/>
</svg>
);
}
function FooterLink({
href,
children,
external,
}: {
href: string;
children: ReactNode;
external?: boolean;
}) {
const className = "transition-colors duration-300 hover:text-white";
if (external || href.startsWith("#") || href.startsWith("mailto:")) {
return (
<a
href={href}
target={external ? "_blank" : undefined}
rel={external ? "noopener noreferrer" : undefined}
className={className}
>
{children}
</a>
);
}
return (
<Link href={href} className={className}>
{children}
</Link>
);
}
function LegalLink({
href,
children,
external,
}: {
href: string;
children: ReactNode;
external?: boolean;
}) {
const className = "transition-colors duration-300 hover:text-gray-300";
if (external) {
return (
<a href={href} target="_blank" rel="noopener noreferrer" className={className}>
{children}
</a>
);
}
return (
<Link href={href} className={className}>
{children}
</Link>
);
}
const STATUS_URL = "https://status.atakanozban.com/status/2";
export function Footer() {
const year = new Date().getFullYear();
return (
<footer className="mt-20 w-full border-t border-gray-800 bg-black/40 pb-8 pt-16 text-sm text-gray-400">
<div className="mx-auto max-w-6xl px-6">
<div className="grid grid-cols-1 gap-8 pb-12 md:grid-cols-12">
<div className="flex flex-col gap-4 md:col-span-5">
<Logo size="sm" />
<p className="max-w-sm text-gray-500">
Convert your audio tracks into stunning videos for YouTube. Beautiful, automated, and
fully open-source.
</p>
<div className="mt-2 flex items-center gap-4 text-gray-500">
<a
href={GITEA_URL}
target="_blank"
rel="noopener noreferrer"
title="Gitea"
className="transition-colors duration-300 hover:text-[#609926]"
>
<GiteaIcon className="h-5 w-5" />
</a>
<a
href={DOCKER_HUB_URL}
target="_blank"
rel="noopener noreferrer"
title="Docker Hub"
className="transition-colors duration-300 hover:text-[#2496ED]"
>
<DockerIcon className="h-5 w-5" />
</a>
</div>
</div>
<div className="grid grid-cols-2 gap-8 sm:grid-cols-3 md:col-span-7">
<div className="flex flex-col gap-3">
<span className="text-xs font-semibold uppercase tracking-wider text-gray-500">
Product
</span>
<FooterLink href="#pricing">Pricing</FooterLink>
<FooterLink href="#download">Download</FooterLink>
<FooterLink href="#benefits">Benefits</FooterLink>
<FooterLink href="/privacy">Privacy Policy</FooterLink>
<FooterLink href="/terms">Terms of Service</FooterLink>
<FooterLink href="/refund">Refund Policy</FooterLink>
<FooterLink href={STATUS_URL} external>
Service Status
</FooterLink>
</div>
<div className="flex flex-col gap-3">
<span className="text-xs font-semibold uppercase tracking-wider text-gray-500">
Open Source
</span>
<FooterLink href={GITEA_URL} external>
Gitea Instance
</FooterLink>
<FooterLink href={DOCKER_HUB_URL} external>
Docker Image
</FooterLink>
<FooterLink href={GITEA_ISSUES_URL} external>
Report a Bug
</FooterLink>
</div>
<div className="flex flex-col gap-3">
<span className="text-xs font-semibold uppercase tracking-wider text-gray-500">
Contact
</span>
<FooterLink href={`mailto:${SUPPORT_EMAIL}`}>Support Email</FooterLink>
<span className="text-xs text-gray-600">Response within 24h for Pro users</span>
</div>
</div>
</div>
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-2 border-t border-gray-900 pt-8 text-xs text-gray-500 md:justify-start">
<span>© {year} Songs2YT. All rights reserved.</span>
<span aria-hidden="true" className="hidden sm:inline">
|
</span>
<LegalLink href="/privacy">Privacy Policy</LegalLink>
<span aria-hidden="true" className="hidden sm:inline">
|
</span>
<LegalLink href="/terms">Terms of Service</LegalLink>
<span aria-hidden="true" className="hidden sm:inline">
|
</span>
<LegalLink href="/refund">Refund Policy</LegalLink>
<span aria-hidden="true" className="hidden sm:inline">
|
</span>
<LegalLink href={STATUS_URL} external>
Service Status
</LegalLink>
</div>
</div>
</footer>
);
}
+210
View File
@@ -0,0 +1,210 @@
"use client";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { displayJobItemError, isYouTubeUploadLimitError } from "@/lib/youtube/errors";
import type { JobResponse } from "@/lib/types";
import { YouTubeLimitBanner } from "./YouTubeLimitBanner";
const STATUS_LABELS: Record<string, string> = {
PENDING: "Queued",
ENCODING: "Encoding",
UPLOADING: "Uploading",
COMPLETED: "Completed",
FAILED: "Failed",
};
function sameDay(a: Date, b: Date) {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
);
}
function formatDayLabel(date: Date) {
const today = new Date();
const yesterday = new Date();
yesterday.setDate(today.getDate() - 1);
if (sameDay(date, today)) return "Today";
if (sameDay(date, yesterday)) return "Yesterday";
return date.toLocaleDateString(undefined, {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
});
}
function groupJobsByDay(jobs: JobResponse[]) {
const groups = new Map<string, { label: string; jobs: JobResponse[] }>();
for (const job of jobs) {
const date = new Date(job.createdAt);
const key = date.toLocaleDateString("en-CA");
const existing = groups.get(key);
if (existing) {
existing.jobs.push(job);
} else {
groups.set(key, { label: formatDayLabel(date), jobs: [job] });
}
}
return Array.from(groups.entries())
.sort(([a], [b]) => b.localeCompare(a))
.map(([, group]) => group);
}
function statusClass(status: string) {
if (status === "COMPLETED") return "bg-green-500/20 text-green-400";
if (status === "FAILED") return "bg-red-500/20 text-red-400";
return "bg-yellow-500/20 text-yellow-400";
}
export function JobHistory() {
const [jobs, setJobs] = useState<JobResponse[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let active = true;
async function load() {
try {
const res = await fetch("/api/jobs?limit=100");
if (!res.ok) throw new Error("Failed to load history");
const data = await res.json();
if (active) setJobs(data.jobs);
} catch (err) {
if (active) setError(err instanceof Error ? err.message : "Error loading history");
} finally {
if (active) setLoading(false);
}
}
load();
return () => {
active = false;
};
}, []);
const dayGroups = useMemo(() => groupJobsByDay(jobs), [jobs]);
const youtubeLimitHit = useMemo(
() =>
jobs.some((job) =>
job.items.some(
(item) => item.status === "FAILED" && item.error && isYouTubeUploadLimitError(item.error),
),
),
[jobs],
);
if (loading) {
return <div className="text-gray-400">Loading history</div>;
}
if (error) {
return (
<div className="rounded border border-red-500/50 bg-red-500/10 p-4 text-red-300">{error}</div>
);
}
if (dayGroups.length === 0) {
return (
<div className="rounded-lg border border-gray-700 bg-surface-light p-8 text-center">
<p className="text-gray-400">No videos created yet.</p>
<Link
href="/dashboard"
className="mt-4 inline-block rounded bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent-hover"
>
Create your first video
</Link>
</div>
);
}
return (
<div className="space-y-8">
{youtubeLimitHit && <YouTubeLimitBanner />}
{dayGroups.map((group) => (
<section key={group.label}>
<h2 className="mb-4 text-lg font-semibold text-white">{group.label}</h2>
<div className="space-y-4">
{group.jobs.map((job) => (
<div
key={job.id}
className="rounded-lg border border-gray-700 bg-surface-light p-4"
>
<div className="mb-3 flex items-center justify-between gap-4">
<div>
<p className="text-sm text-gray-400">
{new Date(job.createdAt).toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
})}
{" · "}
{job.items.length} video{job.items.length === 1 ? "" : "s"}
</p>
<p className="text-xs text-gray-500">
Status: <span className="text-gray-300">{job.status}</span>
</p>
</div>
<Link
href={`/jobs/${job.id}`}
className="text-sm text-accent transition-colors hover:text-accent-hover"
>
View job
</Link>
</div>
<div className="space-y-2">
{job.items.map((item) => {
const itemError =
item.status === "FAILED" ? displayJobItemError(item.error) : null;
return (
<div
key={item.id}
className="rounded border border-gray-800 bg-surface px-3 py-2"
>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<p className="truncate font-medium text-white">{item.title}</p>
<p className="truncate text-xs text-gray-500">{item.audioFilename}</p>
{item.youtubeVideoId && (
<a
href={`https://youtube.com/watch?v=${item.youtubeVideoId}`}
target="_blank"
rel="noopener noreferrer"
className="mt-1 inline-block text-xs text-accent hover:underline"
>
View on YouTube
</a>
)}
</div>
<span
className={`shrink-0 rounded px-2 py-1 text-xs font-medium ${statusClass(item.status)}`}
>
{STATUS_LABELS[item.status] || item.status}
</span>
</div>
{itemError && (
<p className="mt-2 text-xs leading-relaxed text-red-400">{itemError}</p>
)}
</div>
);
})}
</div>
</div>
))}
</div>
</section>
))}
</div>
);
}
+148
View File
@@ -0,0 +1,148 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { displayJobItemError, isYouTubeUploadLimitError } from "@/lib/youtube/errors";
import type { JobResponse } from "@/lib/types";
import { YouTubeLimitBanner } from "./YouTubeLimitBanner";
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;
plan: string;
resetsIn: string;
} | 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, plan: quotaData.plan, resetsIn: quotaData.resetsIn });
}
} 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");
const youtubeLimitHit = job.items.some(
(i) => i.status === "FAILED" && i.error && isYouTubeUploadLimitError(i.error),
);
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 ·{" "}
{quota.plan === "FREE"
? `resets in ${quota.resetsIn}`
: `monthly quota resets on ${quota.resetsIn}`}
</div>
)}
</div>
{youtubeLimitHit && <YouTubeLimitBanner />}
<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.status === "FAILED" && displayJobItemError(item.error) && (
<p className="mt-2 text-sm text-red-400">{displayJobItemError(item.error)}</p>
)}
</div>
))}
</div>
{allDone && (
<Link
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
</Link>
)}
</div>
);
}
+77
View File
@@ -0,0 +1,77 @@
"use client";
import { useState } from "react";
import { Logo } from "@/components/Logo";
import { MobileMenuButton, MobileSidebar, sidebarLinkClass } from "@/components/MobileSidebar";
const NAV_LINKS = [
{ href: "#benefits", label: "Benefits" },
{ href: "#download", label: "Download" },
{ href: "#pricing", label: "Pricing" },
{ href: "#support", label: "Support" },
] as const;
function NavAnchor({
href,
label,
onNavigate,
className = "text-base font-medium text-gray-300 transition-colors duration-200 hover:text-white",
}: {
href: string;
label: string;
onNavigate?: () => void;
className?: string;
}) {
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
e.preventDefault();
const id = href.replace("#", "");
document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" });
window.history.pushState(null, "", href);
onNavigate?.();
};
return (
<a href={href} onClick={handleClick} className={className}>
{label}
</a>
);
}
export function LandingNavbar() {
const [menuOpen, setMenuOpen] = useState(false);
return (
<>
<header className="group absolute left-0 right-0 top-0 z-20 bg-transparent transition-all duration-300 hover:bg-black/70 hover:backdrop-blur-md">
<div className="mx-auto flex max-w-6xl items-center justify-between px-6 py-4">
<Logo size="md" />
<div className="flex items-center gap-4">
<nav className="hidden items-center gap-10 sm:flex">
{NAV_LINKS.map(({ href, label }) => (
<NavAnchor key={href} href={href} label={label} />
))}
</nav>
<MobileMenuButton
open={menuOpen}
onClick={() => setMenuOpen((prev) => !prev)}
/>
</div>
</div>
</header>
<MobileSidebar open={menuOpen} onClose={() => setMenuOpen(false)} title="Menu">
{NAV_LINKS.map(({ href, label }) => (
<NavAnchor
key={href}
href={href}
label={label}
onNavigate={() => setMenuOpen(false)}
className={sidebarLinkClass()}
/>
))}
</MobileSidebar>
</>
);
}
+58
View File
@@ -0,0 +1,58 @@
import Link from "next/link";
import type { ReactNode } from "react";
function LegalLink({
href,
children,
external,
}: {
href: string;
children: ReactNode;
external?: boolean;
}) {
const className = "transition-colors duration-300 hover:text-gray-300";
if (external) {
return (
<a href={href} target="_blank" rel="noopener noreferrer" className={className}>
{children}
</a>
);
}
return (
<Link href={href} className={className}>
{children}
</Link>
);
}
export function LegalFooter() {
const year = new Date().getFullYear();
return (
<footer className="mt-auto w-full border-t border-gray-800 bg-black/40 py-8 text-xs text-gray-500">
<div className="mx-auto flex max-w-4xl flex-wrap items-center justify-center gap-x-4 gap-y-2 px-6 md:justify-start">
<span>© {year} Songs2YT. All rights reserved.</span>
<span aria-hidden="true" className="hidden sm:inline">
|
</span>
<LegalLink href="/privacy">Privacy Policy</LegalLink>
<span aria-hidden="true" className="hidden sm:inline">
|
</span>
<LegalLink href="/terms">Terms of Service</LegalLink>
<span aria-hidden="true" className="hidden sm:inline">
|
</span>
<LegalLink href="/refund">Refund Policy</LegalLink>
<span aria-hidden="true" className="hidden sm:inline">
|
</span>
<LegalLink href="https://status.atakanozban.com/status/2" external>
Service Status
</LegalLink>
</div>
</footer>
);
}
+62
View File
@@ -0,0 +1,62 @@
import Link from "next/link";
import type { ReactNode } from "react";
import { Logo } from "@/components/Logo";
import { LEGAL_LAST_UPDATED } from "@/lib/legal/constants";
type Props = {
title: string;
description?: string;
children: ReactNode;
};
export function LegalPageLayout({ title, description, children }: Props) {
return (
<div className="min-h-screen bg-surface-dark">
<header className="border-b border-gray-800 bg-black/30">
<div className="mx-auto flex max-w-3xl items-center justify-between px-6 py-6">
<Logo />
<Link
href="/"
className="text-sm text-gray-400 transition-colors duration-300 hover:text-white"
>
Back to home
</Link>
</div>
</header>
<main className="mx-auto max-w-3xl px-6 py-12">
<p className="mb-2 text-xs uppercase tracking-wider text-gray-500">
Last updated: {LEGAL_LAST_UPDATED}
</p>
<h1 className="mb-3 text-3xl font-bold text-white">{title}</h1>
{description && <p className="mb-10 text-gray-400">{description}</p>}
<article className="prose prose-invert prose-gray max-w-none prose-headings:font-semibold prose-headings:text-white prose-p:text-gray-300 prose-li:text-gray-300 prose-strong:text-gray-200 prose-a:text-accent prose-a:no-underline hover:prose-a:underline">
{children}
</article>
</main>
<footer className="border-t border-gray-800 py-8 text-center text-xs text-gray-500">
<div className="flex flex-wrap items-center justify-center gap-4">
<Link href="/privacy" className="hover:text-gray-300">
Privacy
</Link>
<Link href="/terms" className="hover:text-gray-300">
Terms
</Link>
<Link href="/refund" className="hover:text-gray-300">
Refund
</Link>
<a
href="https://status.atakanozban.com/status/2"
target="_blank"
rel="noopener noreferrer"
className="hover:text-gray-300"
>
Service Status
</a>
</div>
</footer>
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
import Link from "next/link";
type Props = {
href?: string;
size?: "sm" | "md";
};
export function Logo({ href = "/", size = "md" }: Props) {
const textSize = size === "sm" ? "text-xl" : "text-2xl";
const content = (
<span className={`inline-flex items-baseline font-bold tracking-tight ${textSize}`}>
<span className="text-white">Songs</span>
<span className="text-red-400">2YT</span>
</span>
);
if (href) {
return (
<Link href={href} className="inline-flex items-center" aria-label="Songs2YT">
{content}
</Link>
);
}
return (
<span className="inline-flex items-center" aria-label="Songs2YT">
{content}
</span>
);
}
+169
View File
@@ -0,0 +1,169 @@
"use client";
import { Children, useEffect, useState, type ReactNode } from "react";
type Props = {
open: boolean;
onClose: () => void;
title?: string;
children: ReactNode;
};
const PANEL_MS = 420;
const ITEM_BASE_MS = 90;
function CloseIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M6 6l12 12M18 6L6 18" strokeLinecap="round" />
</svg>
);
}
export function MobileSidebar({ open, onClose, title = "Menu", children }: Props) {
const [mounted, setMounted] = useState(false);
const [visible, setVisible] = useState(false);
useEffect(() => {
if (open) {
setMounted(true);
const frame = requestAnimationFrame(() => setVisible(true));
return () => cancelAnimationFrame(frame);
}
setVisible(false);
const timer = window.setTimeout(() => setMounted(false), PANEL_MS);
return () => window.clearTimeout(timer);
}, [open]);
useEffect(() => {
if (!mounted) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.body.style.overflow = "hidden";
window.addEventListener("keydown", onKeyDown);
return () => {
document.body.style.overflow = "";
window.removeEventListener("keydown", onKeyDown);
};
}, [mounted, onClose]);
if (!mounted) return null;
return (
<div className="fixed inset-0 z-50 sm:hidden">
<button
type="button"
aria-label="Close menu"
className={`absolute inset-0 bg-black/60 backdrop-blur-sm transition-opacity duration-300 ease-out ${
visible ? "opacity-100" : "opacity-0"
}`}
onClick={onClose}
/>
<button
type="button"
onClick={onClose}
aria-label="Close menu"
style={{ animationDelay: visible ? "120ms" : "0ms" }}
className={`fixed right-4 top-4 z-[60] flex h-10 w-10 items-center justify-center rounded-full border border-gray-700 bg-surface text-gray-300 shadow-lg transition-shadow hover:bg-surface-light hover:text-white ${
visible ? "mobile-sidebar-close-btn-open" : "mobile-sidebar-close-btn-close"
}`}
>
<CloseIcon className="h-5 w-5" />
</button>
<aside
className={`absolute right-0 top-0 flex h-full w-72 max-w-[85vw] flex-col border-l border-gray-800 bg-surface shadow-2xl ${
visible ? "mobile-sidebar-panel-open" : "mobile-sidebar-panel-close"
}`}
>
<div
className={`border-b border-gray-800 px-5 py-5 transition-all duration-300 ease-out ${
visible ? "translate-y-0 opacity-100" : "-translate-y-2 opacity-0"
}`}
style={{ transitionDelay: visible ? "100ms" : "0ms" }}
>
<span className="text-sm font-semibold uppercase tracking-wider text-gray-400">
{title}
</span>
</div>
<nav className="flex flex-1 flex-col gap-1 overflow-y-auto p-4">
{Children.map(children, (child, index) => (
<div
key={index}
className={`transition-all duration-300 ease-out ${
visible ? "translate-x-0 opacity-100" : "translate-x-8 opacity-0"
}`}
style={{
transitionDelay: visible ? `${ITEM_BASE_MS + index * 50}ms` : "0ms",
}}
>
{child}
</div>
))}
</nav>
</aside>
</div>
);
}
export function MobileMenuButton({
onClick,
open = false,
}: {
onClick: () => void;
open?: boolean;
}) {
return (
<button
type="button"
onClick={onClick}
aria-label={open ? "Close menu" : "Open menu"}
aria-expanded={open}
className="relative rounded p-2 text-gray-300 transition-all duration-300 hover:scale-105 hover:bg-white/10 hover:text-white active:scale-95 sm:hidden"
>
<span className="relative block h-6 w-6">
<span
className={`absolute left-0 top-[5px] block h-0.5 w-6 rounded-full bg-current transition-all duration-300 ease-out ${
open ? "top-[11px] rotate-45" : ""
}`}
/>
<span
className={`absolute left-0 top-[11px] block h-0.5 w-6 rounded-full bg-current transition-all duration-300 ease-out ${
open ? "scale-x-0 opacity-0" : ""
}`}
/>
<span
className={`absolute left-0 top-[17px] block h-0.5 w-6 rounded-full bg-current transition-all duration-300 ease-out ${
open ? "top-[11px] -rotate-45" : ""
}`}
/>
</span>
</button>
);
}
export function sidebarLinkClass(active = false) {
return `block rounded-lg px-4 py-3 text-base font-medium transition-all duration-200 hover:translate-x-0.5 ${
active ? "bg-surface-light text-white" : "text-gray-300 hover:bg-surface-light hover:text-white"
}`;
}
export function dashboardSidebarLinkClass(active = false, danger = false) {
const classes = ["mobile-sidebar-link"];
if (active) classes.push("mobile-sidebar-link-active");
if (danger) classes.push("mobile-sidebar-link-danger");
return classes.join(" ");
}
+199
View File
@@ -0,0 +1,199 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
type ExtensionRequest = {
id: string;
status: string;
message: string;
requestedAt: string;
processedAt: string | null;
adminNote: string | null;
};
type ExtensionUsage = {
used: number;
limit: number;
remaining: number;
requests: ExtensionRequest[];
};
type Props = {
initialUsage: ExtensionUsage;
};
export function PlanBillingActions({ initialUsage }: Props) {
const router = useRouter();
const [usage, setUsage] = useState(initialUsage);
const [cancelling, setCancelling] = useState(false);
const [requesting, setRequesting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
async function handleCancelPlan() {
if (
!confirm(
"Cancel your Pro plan? You will be moved to the free plan immediately and lose Pro benefits.",
)
) {
return;
}
setCancelling(true);
setError(null);
setSuccess(null);
try {
const res = await fetch("/api/account/cancel-subscription", { method: "POST" });
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Failed to cancel subscription");
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to cancel subscription");
} finally {
setCancelling(false);
}
}
async function handleQuotaRequest() {
if (usage.remaining <= 0) return;
const reason = prompt(
"Optional: tell us why you need a quota reset or extension (leave blank to skip).",
);
if (reason === null) return;
setRequesting(true);
setError(null);
setSuccess(null);
try {
const res = await fetch("/api/account/quota-extension-request", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: reason }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Failed to submit request");
setUsage({
used: data.used,
limit: data.limit,
remaining: data.remaining,
requests: data.requests ?? usage.requests,
});
setSuccess(
`Request submitted. ${data.used} of ${data.limit} extension requests used this year. Support will process it shortly.`,
);
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to submit request");
} finally {
setRequesting(false);
}
}
const hasPending = usage.requests.some((r) => r.status === "PENDING");
return (
<div className="mt-4 space-y-4">
<div className="rounded border border-gray-700 bg-surface px-4 py-3 text-sm">
<p className="text-gray-300">
Extension requests this year:{" "}
<span className="font-medium text-white">
{usage.used} / {usage.limit}
</span>
{usage.remaining > 0 ? (
<span className="text-gray-500"> · {usage.remaining} remaining</span>
) : (
<span className="text-yellow-400"> · limit reached</span>
)}
</p>
</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>
)}
{success && (
<div className="rounded border border-green-500/50 bg-green-500/10 px-4 py-3 text-sm text-green-300">
{success}
</div>
)}
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap">
<button
type="button"
onClick={handleCancelPlan}
disabled={cancelling}
className="inline-flex items-center justify-center rounded border border-yellow-600/50 px-4 py-2 text-sm font-medium text-yellow-300 transition-colors hover:bg-yellow-500/10 disabled:opacity-50"
>
{cancelling ? "Cancelling…" : "Cancel your plan"}
</button>
<button
type="button"
onClick={handleQuotaRequest}
disabled={requesting || usage.remaining <= 0 || hasPending}
className="inline-flex items-center justify-center rounded border border-accent/40 px-4 py-2 text-sm font-medium text-accent transition-colors hover:bg-accent/10 disabled:cursor-not-allowed disabled:opacity-50"
>
{requesting
? "Submitting…"
: hasPending
? "Request pending…"
: "Request quota reset & extension"}
</button>
</div>
{usage.requests.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wider text-gray-500">
Request history
</p>
<ul className="space-y-2">
{usage.requests.slice(0, 5).map((req) => (
<li
key={req.id}
className="rounded border border-gray-800 bg-surface px-3 py-2 text-xs text-gray-400"
>
<div className="flex items-center justify-between gap-2">
<span className="text-gray-300">
{new Date(req.requestedAt).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
})}
</span>
<span
className={
req.status === "APPROVED"
? "text-green-400"
: req.status === "REJECTED"
? "text-red-400"
: "text-yellow-400"
}
>
{req.status}
</span>
</div>
{req.message && <p className="mt-1 text-gray-500">{req.message}</p>}
<p className="mt-1 font-mono text-[10px] text-gray-600">ID: {req.id}</p>
</li>
))}
</ul>
</div>
)}
<p className="text-xs text-gray-500">
Pro users may request up to 5 manual quota resets or extensions per calendar year. See our{" "}
<a href="/terms" className="text-gray-400 underline hover:text-white">
Terms of Service
</a>
.
</p>
</div>
);
}
+195
View File
@@ -0,0 +1,195 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { UpgradeProLink } from "./UpgradeProLink";
type Playlist = {
id: string;
title: string;
itemCount: number;
};
type Props = {
value: string;
onChange: (playlistId: string) => void;
enabled: boolean;
};
export function PlaylistSelect({ value, onChange, enabled }: Props) {
const [playlists, setPlaylists] = useState<Playlist[]>([]);
const [loading, setLoading] = useState(false);
const [creating, setCreating] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showCreate, setShowCreate] = useState(false);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [privacy, setPrivacy] = useState<"public" | "unlisted" | "private">("private");
const loadPlaylists = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch("/api/youtube/playlists");
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Failed to load playlists");
setPlaylists(data.playlists ?? []);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load playlists");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (!enabled) return;
void loadPlaylists();
}, [enabled, loadPlaylists]);
async function handleCreate() {
if (!title.trim()) {
setError("Playlist title is required");
return;
}
setCreating(true);
setError(null);
try {
const res = await fetch("/api/youtube/playlists", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: title.trim(),
description: description.trim() || undefined,
privacy,
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Failed to create playlist");
const playlist = data.playlist as Playlist;
setPlaylists((prev) => [playlist, ...prev]);
onChange(playlist.id);
setShowCreate(false);
setTitle("");
setDescription("");
setPrivacy("private");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create playlist");
} finally {
setCreating(false);
}
}
if (!enabled) {
return (
<p className="text-sm text-gray-400">
Add uploaded videos to a YouTube playlist with{" "}
<UpgradeProLink className="text-accent hover:underline" />.
</p>
);
}
return (
<div className="space-y-3">
<div className="space-y-2">
<label className="block text-sm text-gray-400">Add to YouTube playlist (optional)</label>
<select
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={loading || creating}
className="input-field w-full"
>
<option value="">None (dont add to a playlist)</option>
{playlists.map((playlist) => (
<option key={playlist.id} value={playlist.id}>
{playlist.title} ({playlist.itemCount})
</option>
))}
</select>
</div>
<div className="flex flex-wrap gap-3">
<button
type="button"
onClick={() => setShowCreate((prev) => !prev)}
className="text-sm text-accent hover:underline"
>
{showCreate ? "Cancel" : "Create new playlist"}
</button>
<button
type="button"
onClick={() => void loadPlaylists()}
disabled={loading}
className="text-sm text-gray-400 hover:text-white"
>
Refresh list
</button>
</div>
{showCreate && (
<div className="space-y-3 rounded border border-gray-700 bg-surface-dark/60 p-4">
<div>
<label className="mb-1 block text-sm text-gray-400">Playlist title</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void handleCreate();
}
}}
className="input-field w-full"
placeholder="My Songs2YT uploads"
/>
</div>
<div>
<label className="mb-1 block text-sm text-gray-400">Description (optional)</label>
<input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void handleCreate();
}
}}
className="input-field w-full"
/>
</div>
<div>
<label className="mb-1 block text-sm text-gray-400">Privacy</label>
<select
value={privacy}
onChange={(e) => setPrivacy(e.target.value as typeof privacy)}
className="input-field w-full"
>
<option value="private">Private</option>
<option value="unlisted">Unlisted</option>
<option value="public">Public</option>
</select>
</div>
<button
type="button"
onClick={() => void handleCreate()}
disabled={creating || !title.trim()}
className="rounded bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent-hover disabled:opacity-50"
>
{creating ? "Creating…" : "Create & select"}
</button>
</div>
)}
{loading && <p className="text-xs text-gray-500">Loading playlists</p>}
{error && <p className="text-xs text-red-400">{error}</p>}
{!loading && !error && playlists.length === 0 && !showCreate && (
<p className="text-xs text-gray-500">
No playlists yet. Create one above. If playlist access was just added, sign out and sign
in again to grant the new Google permission.
</p>
)}
</div>
);
}
+330
View File
@@ -0,0 +1,330 @@
"use client";
import Link from "next/link";
import { useRef } from "react";
import { ScrollReveal } from "@/components/ScrollReveal";
import { SectionScrollTitle } from "@/components/SectionScrollTitle";
import { SignInButton } from "@/components/SignInButton";
import { SALES_EMAIL } from "@/lib/plans";
const FEATURE_LABELS = [
"Infrastructure",
"Limit",
"Batch mode",
"File type",
"Playlists",
"API support",
"ID3 tags",
"Support",
"Watermark",
] as const;
type PlanFeatures = Record<(typeof FEATURE_LABELS)[number], string>;
type InfrastructureChip = "cloud" | "self-hosted";
type PlanConfig = {
name: string;
badge: string;
price: string;
priceNote: string;
features: PlanFeatures;
infrastructureChips: InfrastructureChip[];
watermarkNote?: string;
cta:
| { type: "signin" }
| { type: "link"; label: string; href: string }
| { type: "disabled"; label: string }
| { type: "mailto"; label: string; email: string; subject: string };
highlighted: boolean;
hover: string;
accent: string;
badgeClass: string;
ctaClass?: string;
};
const PLANS: PlanConfig[] = [
{
name: "Bedroom Producer",
badge: "Free",
price: "€0",
priceNote: "/ forever",
features: {
Infrastructure: "Cloud",
Limit: "14 videos / 12 hours · 720p",
"Batch mode": "Limited batch · up to 3 files",
"File type": "MP3",
Playlists: "Not included",
"API support": "Not included",
"ID3 tags": "Auto-fill title & metadata from MP3 tags",
Support: "Community",
Watermark: "Optional · support badge",
},
infrastructureChips: ["cloud"],
watermarkNote:
"Show \"Uploaded through Songs2YT.com\" to support open-source development - opt out anytime for a clean video.",
cta: { type: "signin" },
highlighted: false,
hover:
"hover:border-red-500/40 hover:bg-red-500/[0.04] hover:shadow-lg hover:shadow-red-500/15",
accent: "text-red-400",
badgeClass: "bg-gray-800 text-gray-400",
},
{
name: "Independent Artist",
badge: "Pro",
price: "€5",
priceNote: "/ mo",
features: {
Infrastructure: "Cloud",
Limit: "50 videos* / month · 1080p",
"Batch mode": "Full batch · up to 5 files",
"File type": "MP3 / WAV / FLAC",
Playlists: "Create & add uploads to YouTube playlists",
"API support": "REST API · upload, batch & playlists",
"ID3 tags": "Extended metadata support",
Support: "E-Mail",
Watermark: "Fully customizable · remove completely",
},
infrastructureChips: ["cloud"],
cta: { type: "disabled", label: "Coming soon" },
highlighted: true,
hover:
"hover:border-accent/50 hover:bg-accent/[0.06] hover:shadow-lg hover:shadow-accent/20",
accent: "text-accent",
badgeClass: "bg-accent/15 text-accent",
},
{
name: "Record Label / Studio",
badge: "Enterprise",
price: "Custom",
priceNote: "/ contact sales",
features: {
Infrastructure: "Cloud or self-hosted",
Limit: "Unlimited 4K · zero limit",
"Batch mode": "Unlimited synchronized batch processing",
"File type": "WAV / FLAC / lossless",
Playlists: "Org-wide playlist workflows",
"API support": "Full API access · custom integrations & SLAs",
"ID3 tags": "Full metadata · custom mapping",
Support: "Top-priority**",
Watermark: "Fully customizable · remove completely",
},
infrastructureChips: ["cloud", "self-hosted"],
cta: {
type: "mailto",
label: "Contact Sales",
email: SALES_EMAIL,
subject: "Songs2YT Enterprise Inquiry",
},
highlighted: false,
hover:
"hover:border-[#609926]/45 hover:bg-[#609926]/[0.06] hover:shadow-lg hover:shadow-[#609926]/20",
accent: "text-[#609926]",
badgeClass: "bg-[#609926]/15 text-[#609926]",
ctaClass:
"border-[#609926]/40 bg-[#609926]/10 text-[#609926] hover:border-[#609926]/60 hover:bg-[#609926]/20",
},
];
function FeatureValue({ value }: { value: string }) {
return <span className="text-sm text-gray-200">{value}</span>;
}
function CloudIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
aria-hidden="true"
>
<path
d="M7.5 18.5h9.75a4.25 4.25 0 0 0 .55-8.46A5.75 5.75 0 0 0 6.9 8.8 4.5 4.5 0 0 0 7.5 18.5Z"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function ServerIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
aria-hidden="true"
>
<rect x="4" y="5" width="16" height="6" rx="2" />
<rect x="4" y="13" width="16" height="6" rx="2" />
<path d="M7.5 8h.01M7.5 16h.01" strokeLinecap="round" />
</svg>
);
}
function InfrastructureChips({
chips,
}: {
chips: InfrastructureChip[];
}) {
return (
<div className="mt-2 flex flex-wrap items-center gap-2">
{chips.includes("cloud") && (
<span className="inline-flex items-center gap-2 rounded-lg border border-gray-700/60 bg-surface-dark/40 px-3 py-2 text-sm text-gray-200">
<CloudIcon className="h-4 w-4 text-gray-300" />
Cloud
</span>
)}
{chips.includes("self-hosted") && (
<span className="inline-flex items-center gap-2 rounded-lg border border-gray-700/60 bg-surface-dark/40 px-3 py-2 text-sm text-gray-200">
<ServerIcon className="h-4 w-4 text-gray-300" />
Self-hosted
</span>
)}
</div>
);
}
function PricingCard({ plan }: { plan: PlanConfig }) {
const {
name,
badge,
price,
priceNote,
features,
watermarkNote,
infrastructureChips,
cta,
highlighted,
hover,
accent,
badgeClass,
ctaClass,
} = plan;
return (
<div
className={`group relative flex h-full flex-col rounded-2xl border bg-surface p-6 transition-all duration-300 sm:p-7 ${
highlighted
? "border-accent/40 shadow-[0_0_32px_rgba(74,158,255,0.12)]"
: "border-gray-700/50"
} ${hover}`}
>
{highlighted && (
<span className="absolute -top-3 left-1/2 -translate-x-1/2 rounded-full bg-accent px-3 py-1 text-[10px] font-semibold uppercase tracking-wider text-white">
Most popular
</span>
)}
<div className="mb-6">
<div className="mb-3 flex items-start justify-between gap-3">
<h3 className="text-lg font-semibold text-white">{name}</h3>
<span
className={`shrink-0 rounded px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider ${badgeClass}`}
>
{badge}
</span>
</div>
<div className="flex items-end gap-1">
<span className={`text-4xl font-bold ${accent}`}>{price}</span>
<span className="mb-1 text-sm text-gray-500">{priceNote}</span>
</div>
</div>
<ul className="mb-6 flex-1 space-y-4 border-t border-gray-700/50 pt-6">
{FEATURE_LABELS.map((label) => (
<li key={label}>
<p className="text-xs font-medium uppercase tracking-wider text-gray-500">{label}</p>
{label === "Infrastructure" ? (
<InfrastructureChips chips={infrastructureChips} />
) : (
<FeatureValue value={features[label]} />
)}
{label === "Watermark" && watermarkNote && (
<p className="mt-1.5 rounded-lg border border-gray-700/60 bg-surface-dark/80 px-3 py-2 text-xs leading-relaxed text-gray-400">
{watermarkNote}
</p>
)}
</li>
))}
</ul>
<div className="mt-auto">
{cta.type === "signin" && <SignInButton fullWidth />}
{cta.type === "link" && (
<Link
href={cta.href}
className={`inline-flex w-full items-center justify-center rounded border px-4 py-3 text-sm font-medium transition-all duration-300 ${ctaClass ?? "border-gray-600 text-white hover:border-gray-400"}`}
>
{cta.label}
</Link>
)}
{cta.type === "disabled" && (
<button
type="button"
disabled
className="w-full cursor-not-allowed rounded border border-gray-700 bg-surface-light px-4 py-3 text-sm font-medium text-gray-500"
>
{cta.label}
</button>
)}
{cta.type === "mailto" && (
<a
href={`mailto:${cta.email}?subject=${encodeURIComponent(cta.subject)}`}
className={`inline-flex w-full items-center justify-center rounded border px-4 py-3 text-sm font-medium transition-all duration-300 ${ctaClass ?? ""}`}
>
{cta.label}
</a>
)}
</div>
</div>
);
}
export function PricingSection() {
const sectionRef = useRef<HTMLElement>(null);
return (
<section
ref={sectionRef}
id="pricing"
className="relative scroll-mt-24 overflow-x-visible overflow-y-hidden px-6 py-24"
>
<SectionScrollTitle sectionRef={sectionRef} title="Pricing" />
<div className="relative z-10 mx-auto max-w-7xl">
<ScrollReveal>
<h2 className="mb-4 text-center text-3xl font-bold text-white">Pricing</h2>
<p className="mx-auto mb-10 max-w-2xl text-center text-gray-400">
Free for getting started, Pro for creators, or Enterprise for teams.
</p>
</ScrollReveal>
<div className="grid gap-6 sm:grid-cols-2 xl:grid-cols-3 xl:gap-6">
{PLANS.map((plan, index) => (
<ScrollReveal key={plan.name} delay={index * 100}>
<PricingCard plan={plan} />
</ScrollReveal>
))}
</div>
<ScrollReveal delay={360}>
<p className="mx-auto mt-10 max-w-2xl text-center text-xs text-gray-500">
*Pro is a monthly subscription with included quota (extensions via support). Self-hosted
open-source deployments have no quotas.
</p>
<p className="mx-auto mt-4 max-w-2xl text-center text-xs text-gray-500">
Technical support is strictly reserved for Managed Cloud and paid Professional Setup
agreements; independent self-hosted deployments are community-supported.
</p>
</ScrollReveal>
</div>
</section>
);
}
+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>
);
}
+34
View File
@@ -0,0 +1,34 @@
"use client";
import { useEffect, useState } from "react";
import { isYouTubeUploadLimitError } from "@/lib/youtube/errors";
import { YouTubeLimitBanner } from "./YouTubeLimitBanner";
/** Shows a dashboard alert when a recent job hit YouTube's upload limit. */
export function RecentYouTubeLimitAlert() {
const [show, setShow] = useState(false);
useEffect(() => {
let active = true;
fetch("/api/jobs?limit=10")
.then(async (res) => {
if (!res.ok) return;
const data = await res.json();
const hit = (data.jobs ?? []).some((job: { items?: Array<{ status: string; error?: string | null }> }) =>
(job.items ?? []).some(
(item) => item.status === "FAILED" && item.error && isYouTubeUploadLimitError(item.error),
),
);
if (active) setShow(hit);
})
.catch(() => {});
return () => {
active = false;
};
}, []);
if (!show) return null;
return <YouTubeLimitBanner className="mb-6" />;
}
+30
View File
@@ -0,0 +1,30 @@
"use client";
import { Plan } from "@prisma/client";
import { getResolutionsForPlan } from "@/lib/plans";
type Props = {
value: string;
onChange: (value: string) => void;
disabled?: boolean;
plan?: Plan;
};
export function ResolutionSelect({ value, onChange, disabled, plan = "FREE" }: Props) {
const resolutions = getResolutionsForPlan(plan);
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>
);
}
+58
View File
@@ -0,0 +1,58 @@
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
type Direction = "up" | "left" | "right";
type Props = {
children: ReactNode;
className?: string;
delay?: number;
direction?: Direction;
};
const OFFSET: Record<Direction, string> = {
up: "translate-y-10",
left: "-translate-x-10",
right: "translate-x-10",
};
export function ScrollReveal({
children,
className = "",
delay = 0,
direction = "up",
}: Props) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
observer.unobserve(el);
}
},
{ threshold: 0.15, rootMargin: "0px 0px -40px 0px" },
);
observer.observe(el);
return () => observer.disconnect();
}, []);
return (
<div
ref={ref}
className={`transition-all duration-700 ease-out ${className} ${
visible ? "translate-x-0 translate-y-0 opacity-100" : `opacity-0 ${OFFSET[direction]}`
}`}
style={{ transitionDelay: `${delay}ms` }}
>
{children}
</div>
);
}
+46
View File
@@ -0,0 +1,46 @@
"use client";
import { useEffect, useState, type RefObject } from "react";
type Props = {
sectionRef: RefObject<HTMLElement | null>;
title: string;
offset?: number;
};
export function SectionScrollTitle({ sectionRef, title, offset = 350 }: Props) {
const [offsetX, setOffsetX] = useState(0);
useEffect(() => {
const section = sectionRef.current;
if (!section) return;
const update = () => {
const rect = section.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const progress = Math.min(
1,
Math.max(0, (viewportHeight - rect.top) / (viewportHeight + rect.height * 0.5)),
);
setOffsetX(progress * offset);
};
update();
window.addEventListener("scroll", update, { passive: true });
window.addEventListener("resize", update);
return () => {
window.removeEventListener("scroll", update);
window.removeEventListener("resize", update);
};
}, [sectionRef, offset]);
return (
<span
className="pointer-events-none absolute bottom-0 left-1/2 z-0 select-none whitespace-nowrap text-7xl font-bold leading-none text-white/[0.04] sm:text-8xl lg:text-9xl"
style={{ transform: `translateX(calc(-50% + ${offsetX}px))` }}
aria-hidden="true"
>
{title}
</span>
);
}
+45
View File
@@ -0,0 +1,45 @@
"use client";
import { signIn } from "next-auth/react";
function GoogleIcon() {
return (
<svg className="h-5 w-5 shrink-0" viewBox="0 0 24 24" aria-hidden="true">
<path
fill="#4285F4"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="#34A853"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="#FBBC05"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="#EA4335"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
);
}
type Props = {
large?: boolean;
fullWidth?: boolean;
};
export function SignInButton({ large, fullWidth }: Props) {
return (
<button
onClick={() => signIn("google", { callbackUrl: "/dashboard" })}
className={`inline-flex items-center justify-center gap-3 rounded border border-gray-300 bg-white font-medium text-gray-800 transition-all duration-300 hover:scale-[1.02] hover:bg-gray-50 hover:shadow-lg hover:shadow-white/10 ${
large ? "px-8 py-3 text-base" : "px-4 py-2 text-sm"
} ${fullWidth ? "w-full" : ""}`}
>
<GoogleIcon />
Continue 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>
);
}
+234
View File
@@ -0,0 +1,234 @@
"use client";
import { ScrollReveal } from "@/components/ScrollReveal";
import { useInView } from "@/hooks/useInView";
import { useMockupProgress } from "@/hooks/useMockupProgress";
function LayersIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M12 2L2 7l10 5 10-5-10-5z" />
<path d="M2 17l10 5 10-5" />
<path d="M2 12l10 5 10-5" />
</svg>
);
}
function SlidersIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3" />
<circle cx="4" cy="14" r="2" />
<circle cx="12" cy="12" r="2" />
<circle cx="20" cy="16" r="2" />
</svg>
);
}
function CloudUploadIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M12 16V8M12 8l-3 3M12 8l3 3" />
<path d="M7 18a4 4 0 0 1 0-8 5 5 0 0 1 9.9-1A4 4 0 1 1 17 18H7z" />
</svg>
);
}
function ImageIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<path d="M21 15l-5-5L5 21" />
</svg>
);
}
function AudioIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M9 18V5l12-2v13" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" />
</svg>
);
}
const STEPS = [
{
number: 1,
title: "Single/Batch creation",
desc: "One image, many audios, each becomes a separate video.",
Icon: LayersIcon,
},
{
number: 2,
title: "Per-video settings",
desc: "Title, tags, privacy, and category for every upload.",
Icon: SlidersIcon,
},
{
number: 3,
title: "Direct upload",
desc: "Connect YouTube once and publish automatically.",
Icon: CloudUploadIcon,
},
] as const;
function CheckIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
aria-hidden="true"
>
<path d="M5 13l4 4L19 7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function AppMockup() {
const phaseOneMs = 3000;
const phaseTwoMs = 2000;
const initialWidth = "20%";
const midWidth = "45%";
const { ref, inView } = useInView();
const { phase, processed, transitionMs } = useMockupProgress(inView, phaseOneMs, phaseTwoMs);
const width = phase === 0 ? initialWidth : phase === 1 ? midWidth : "100%";
return (
<div
ref={ref}
className="mt-10 overflow-hidden rounded-2xl border border-gray-700/60 bg-surface-dark shadow-2xl shadow-black/40"
>
<div className="flex items-center gap-2 border-b border-gray-700/50 px-4 py-3">
<span className="h-2.5 w-2.5 rounded-full bg-gray-600" />
<span className="h-2.5 w-2.5 rounded-full bg-gray-600" />
<span className="h-2.5 w-2.5 rounded-full bg-gray-600" />
</div>
<div className="p-6">
<div className="rounded-xl border border-gray-700/50 bg-surface p-6">
<div className="grid grid-cols-2 gap-4">
<div className="mockup-upload-box mockup-upload-box-image flex flex-col items-center justify-center rounded-lg border border-dashed border-gray-600 bg-surface-dark px-4 py-8">
<ImageIcon className="mockup-icon-image mb-3 h-8 w-8 text-gray-500" />
<span className="text-xs font-semibold tracking-widest text-gray-500">IMAGE</span>
</div>
<div className="mockup-upload-box mockup-upload-box-audio flex flex-col items-center justify-center rounded-lg border border-dashed border-gray-600 bg-surface-dark px-4 py-8">
<AudioIcon className="mockup-icon-audio mb-3 h-8 w-8 text-gray-500" />
<span className="text-xs font-semibold tracking-widest text-gray-500">AUDIO</span>
</div>
</div>
<p className="mt-6 text-sm text-gray-400">
{processed ? (
"Processed"
) : (
<>
Processing
<span className="mockup-dot-1">.</span>
<span className="mockup-dot-2">.</span>
<span className="mockup-dot-3">.</span>
</>
)}
</p>
<div className="mt-2 flex items-center gap-2">
<div className="relative h-1 min-w-0 flex-1 overflow-hidden rounded-full bg-gray-700">
<div
className="relative h-full rounded-full bg-white/90 ease-out"
style={{
width,
transition: phase >= 1 ? `width ${transitionMs}ms ease-out` : "none",
}}
>
{!processed && (
<span className="mockup-progress-shimmer absolute inset-y-0 w-1/2 rounded-full bg-gradient-to-r from-transparent via-white/50 to-transparent" />
)}
</div>
</div>
<CheckIcon
className={`h-4 w-4 shrink-0 text-accent transition-all duration-300 ${
processed ? "scale-100 opacity-100" : "scale-75 opacity-0"
}`}
/>
</div>
</div>
</div>
</div>
);
}
function StepCard({
number,
title,
desc,
Icon,
}: {
number: number;
title: string;
desc: string;
Icon: typeof LayersIcon;
}) {
return (
<div className="group relative overflow-hidden rounded-2xl border border-gray-700/50 bg-surface transition-all duration-300 hover:border-gray-500 hover:bg-surface-light hover:shadow-xl hover:shadow-black/30">
<span
className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 select-none text-[7.5rem] font-bold leading-none text-gray-600/20 transition-all duration-500 ease-out group-hover:left-1/2 group-hover:-translate-x-1/2 group-hover:scale-[1.18] group-hover:text-red-500/25 sm:text-[8.5rem]"
aria-hidden="true"
>
{number}
</span>
<div className="relative flex items-center gap-6 px-8 py-9 pl-24 sm:pl-28">
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-xl border border-gray-600/50 bg-surface-dark text-gray-400 transition-all duration-300 group-hover:border-red-500/30 group-hover:text-red-400">
<Icon className="h-7 w-7" />
</div>
<div>
<h3 className="text-lg font-semibold text-white transition-colors duration-300 group-hover:text-red-400 sm:text-xl">
{title}
</h3>
<p className="mt-2 text-base leading-relaxed text-gray-400 transition-colors duration-300 group-hover:text-gray-300">
{desc}
</p>
</div>
</div>
</div>
);
}
export function StepsSection() {
return (
<section className="mx-auto max-w-6xl scroll-mt-20 px-6 pb-24 pt-32">
<div className="grid items-center gap-12 lg:grid-cols-2 lg:gap-16">
<ScrollReveal direction="left">
<div>
<h2 className="text-2xl font-bold leading-snug text-white sm:text-3xl">
Upload your content in 3 simple steps:
</h2>
<p className="mt-6 text-base leading-relaxed text-gray-400">
From a single track to a full batch, Songs2YT walks you through creating and
publishing videos to YouTube without touching a video editor.
</p>
<AppMockup />
</div>
</ScrollReveal>
<div className="flex flex-col gap-7">
{STEPS.map((step, index) => (
<ScrollReveal key={step.number} delay={index * 120} direction="right">
<StepCard
number={step.number}
title={step.title}
desc={step.desc}
Icon={step.Icon}
/>
</ScrollReveal>
))}
</div>
</div>
</section>
);
}
+116
View File
@@ -0,0 +1,116 @@
"use client";
import { useRef } from "react";
import { ScrollReveal } from "@/components/ScrollReveal";
import { SectionScrollTitle } from "@/components/SectionScrollTitle";
import { GITEA_ISSUES_URL, SUPPORT_EMAIL } from "@/lib/plans";
type SupportCardProps = {
title: string;
description: string;
href: string;
cta: string;
external?: boolean;
hover: string;
titleHover: string;
linkClass: string;
linkHover: string;
};
function SupportCard({
title,
description,
href,
cta,
external,
hover,
titleHover,
linkClass,
linkHover,
}: SupportCardProps) {
return (
<a
href={href}
target={external ? "_blank" : undefined}
rel={external ? "noopener noreferrer" : undefined}
className={`group block rounded-xl border border-gray-700/50 bg-surface/80 p-6 backdrop-blur-sm transition-all duration-300 ${hover}`}
>
<h3
className={`mb-2 text-lg font-semibold text-white transition-colors duration-300 ${titleHover}`}
>
{title}
</h3>
<p className="mb-4 text-sm leading-relaxed text-gray-400 transition-colors duration-300 group-hover:text-gray-300">
{description}
</p>
<span
className={`inline-flex text-sm font-medium transition-all duration-300 group-hover:underline ${linkClass} ${linkHover}`}
>
{cta}
</span>
</a>
);
}
const SUPPORT_CARDS: SupportCardProps[] = [
{
title: "🛠️ Community & Self-Hosting",
description:
"Found a bug, want to request a feature, or need help setting up your Docker instance? Open an issue on our self-hosted Gitea.",
href: GITEA_ISSUES_URL,
cta: "Open Gitea Issues",
external: true,
hover:
"hover:border-[#609926]/45 hover:bg-[#609926]/[0.06] hover:shadow-lg hover:shadow-[#609926]/20",
titleHover: "group-hover:text-[#609926]",
linkClass: "text-[#609926]",
linkHover: "group-hover:text-[#7ab33a]",
},
{
title: "📩 Billing & Premium Support",
description:
"Have questions about your Pro subscription, custom limits, or Enterprise inquiries? Drop us an email.",
href: `mailto:${SUPPORT_EMAIL}`,
cta: SUPPORT_EMAIL,
hover:
"hover:border-accent/50 hover:bg-accent/[0.06] hover:shadow-lg hover:shadow-accent/20",
titleHover: "group-hover:text-accent",
linkClass: "text-accent",
linkHover: "group-hover:text-accent-hover",
},
];
export function SupportSection() {
const sectionRef = useRef<HTMLElement>(null);
return (
<section
ref={sectionRef}
id="support"
className="relative scroll-mt-24 overflow-x-visible overflow-y-hidden px-6 pb-24 pt-24"
>
<SectionScrollTitle sectionRef={sectionRef} title="Support" />
<div className="relative z-10 mx-auto max-w-4xl">
<ScrollReveal>
<h2 className="mb-4 text-center text-3xl font-bold text-white">Support</h2>
<p className="mx-auto mb-10 max-w-2xl text-center text-gray-400">
Community help for self-hosters, or reach us directly for billing and premium support.
</p>
</ScrollReveal>
<div className="mx-auto mt-10 grid grid-cols-1 gap-8 text-left md:grid-cols-2">
{SUPPORT_CARDS.map((card, index) => (
<ScrollReveal
key={card.title}
direction={index === 0 ? "left" : "right"}
delay={index * 120}
>
<SupportCard {...card} />
</ScrollReveal>
))}
</div>
</div>
</section>
);
}
+33
View File
@@ -0,0 +1,33 @@
import Link from "next/link";
import type { ReactNode } from "react";
import { UPGRADE_URL } from "@/lib/plans";
const PRO_UPGRADE_SUFFIX = " Upload up to 50 videos with Pro!";
type Props = {
className?: string;
children?: ReactNode;
};
export function UpgradeProLink({ className = "text-accent hover:underline", children }: Props) {
return (
<Link href={UPGRADE_URL} className={className}>
{children ?? "Upgrade to Pro"}
</Link>
);
}
export function QuotaErrorMessage({ message }: { message: string }) {
if (message.endsWith(PRO_UPGRADE_SUFFIX)) {
return (
<>
{message.slice(0, -PRO_UPGRADE_SUFFIX.length)}{" "}
<UpgradeProLink className="font-medium text-red-200 underline hover:text-white">
Upload up to 50 videos with Pro!
</UpgradeProLink>
</>
);
}
return <>{message}</>;
}
+526
View File
@@ -0,0 +1,526 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Plan, Privacy } from "@prisma/client";
import { filenameWithoutExtension } from "@/lib/constants";
import { audioTagsToMetadata } from "@/lib/audio-tags";
import type { ItemMetadata } from "@/lib/types";
import { CategorySelect } from "./CategorySelect";
import { PlaylistSelect } from "./PlaylistSelect";
import { PrivacyToggle } from "./PrivacyToggle";
import { ResolutionSelect } from "./ResolutionSelect";
import { QuotaErrorMessage, UpgradeProLink } from "./UpgradeProLink";
const UPLOAD_CONCURRENCY = 4;
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,
playlistId: null,
};
}
export function UploadForm() {
const router = useRouter();
const uploadSessionRef = useRef<string>(crypto.randomUUID());
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 [playlistId, setPlaylistId] = useState("");
const [quota, setQuota] = useState<{
remaining: number;
limit: number;
plan: Plan;
maxBatchSize: number;
resetsIn: string;
videoCredits: number;
totalAvailable: number;
used: number;
selfHosted?: boolean;
} | 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,
plan: data.plan,
maxBatchSize: data.maxBatchSize,
resetsIn: data.resetsIn,
videoCredits: data.videoCredits ?? 0,
totalAvailable: data.totalAvailable ?? data.remaining,
used: data.used ?? 0,
selfHosted: Boolean(data.selfHosted),
});
}
}, []);
useEffect(() => {
loadQuota();
}, [loadQuota]);
async function uploadFile(
file: File,
type: "image" | "audio",
): Promise<{ path: string; audioTags?: Parameters<typeof audioTagsToMetadata>[0] | null }> {
const formData = new FormData();
formData.append("file", file);
formData.append("type", type);
formData.append("session", uploadSessionRef.current);
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 { path: data.path, audioTags: data.audioTags };
}
async function uploadFilesWithConcurrency(files: File[]) {
const pending = files.map((file) => {
const id = crypto.randomUUID();
const autoTitle = filenameWithoutExtension(file.name);
setAudioItems((prev) => [
...prev,
{
id,
file,
path: null,
uploading: true,
metadata: {
...defaultMetadata(autoTitle),
playlistId: playlistId || null,
},
},
]);
return { id, file, autoTitle };
});
for (let i = 0; i < pending.length; i += UPLOAD_CONCURRENCY) {
const chunk = pending.slice(i, i + UPLOAD_CONCURRENCY);
const results = await Promise.allSettled(
chunk.map(async ({ id, file, autoTitle }) => {
const upload = await uploadFile(file, "audio");
const tagMetadata = upload.audioTags
? audioTagsToMetadata(upload.audioTags, autoTitle)
: null;
setAudioItems((prev) =>
prev.map((item) =>
item.id === id
? {
...item,
path: upload.path,
uploading: false,
metadata: tagMetadata
? { ...item.metadata, ...tagMetadata }
: item.metadata,
}
: item,
),
);
}),
);
for (let j = 0; j < results.length; j++) {
if (results[j].status === "rejected") {
const { id } = chunk[j];
const reason = results[j] as PromiseRejectedResult;
setError(reason.reason instanceof Error ? reason.reason.message : "Audio upload failed");
setAudioItems((prev) => prev.filter((item) => item.id !== id));
}
}
}
}
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.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);
const maxBatch = quota?.maxBatchSize ?? 3;
if (audioItems.length + files.length > maxBatch) {
setError(`Your plan allows up to ${maxBatch} audio files per batch.`);
e.target.value = "";
return;
}
await uploadFilesWithConcurrency(files);
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 handlePlaylistChange(nextPlaylistId: string) {
setPlaylistId(nextPlaylistId);
setAudioItems((prev) =>
prev.map((item) => ({
...item,
metadata: {
...item.metadata,
playlistId: nextPlaylistId || null,
},
})),
);
}
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.selfHosted ? (
<>
Self-hosted · {quota.used} videos processed · up to {quota.maxBatchSize} files per
batch
</>
) : (
<>
{quota.remaining} of {quota.limit} plan videos remaining
{quota.plan === "FREE" && quota.videoCredits > 0
? ` · ${quota.videoCredits} pay-as-you-go credits`
: ""}{" "}
·{" "}
{quota.plan === "FREE"
? `resets in ${quota.resetsIn}`
: `monthly quota resets on ${quota.resetsIn}`}{" "}
· up to {quota.maxBatchSize} files per batch
</>
)}
</div>
)}
{error && (
<div className="rounded border border-red-500/50 bg-red-500/10 px-4 py-3 text-sm text-red-300">
<QuotaErrorMessage message={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={
quota?.selfHosted || quota?.plan === "PREMIUM"
? "audio/mpeg,audio/wav,audio/flac,.mp3,.wav,.flac"
: "audio/mpeg,.mp3"
}
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>
<div className="rounded-lg border border-gray-700 bg-surface p-6">
<PlaylistSelect
value={playlistId}
onChange={handlePlaylistChange}
enabled={Boolean(quota?.selfHosted || quota?.plan === "PREMIUM")}
/>
</div>
{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 })}
plan={quota?.plan}
/>
</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="Show 'Uploaded through Songs2YT.com' watermark (support open-source)"
checked={item.metadata.includeWatermark}
onChange={(v) => updateItemMetadata(item.id, { includeWatermark: v })}
/>
</div>
</div>
))}
</section>
)}
{quota && !quota.selfHosted && quota.plan === "FREE" && (
<>
<p className="text-sm text-gray-400">
Watermark is optional on the free plan - turn it off anytime for a clean video.
</p>
<p className="text-sm text-gray-400">
<UpgradeProLink className="text-accent hover:underline" /> for 50 videos/month, 1080p,
lossless audio, and full watermark control.
</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>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { YOUTUBE_UPLOAD_LIMIT_USER_MESSAGE } from "@/lib/youtube/errors";
type Props = {
className?: string;
};
export function YouTubeLimitBanner({ className = "" }: Props) {
return (
<div
className={`rounded border border-amber-500/40 bg-amber-500/10 px-4 py-3 text-sm text-amber-100 ${className}`}
>
<p className="font-medium text-amber-200">YouTube upload limit reached</p>
<p className="mt-1 text-amber-100/90">{YOUTUBE_UPLOAD_LIMIT_USER_MESSAGE}</p>
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
# Local infra only (Postgres + Redis). Use root docker-compose.yml for full stack.
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: s2yt
POSTGRES_PASSWORD: s2yt
POSTGRES_DB: s2yt
ports:
- "127.0.0.1:5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
restart: unless-stopped
ports:
- "127.0.0.1:6379:6379"
command: ["redis-server", "--appendonly", "yes"]
volumes:
- redis_data:/data
volumes:
postgres_data:
redis_data:
+74
View File
@@ -0,0 +1,74 @@
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: s2yt
POSTGRES_PASSWORD: s2yt
POSTGRES_DB: s2yt
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U s2yt -d s2yt"]
interval: 5s
timeout: 5s
retries: 10
redis:
image: redis:7-alpine
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes"]
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 10
web:
build: .
restart: unless-stopped
ports:
- "${S2YT_PORT:-3000}:3000"
env_file:
- .env
environment:
S2YT_EDITION: selfhosted
DATABASE_URL: postgresql://s2yt:s2yt@postgres:5432/s2yt
REDIS_URL: redis://redis:6379
UPLOAD_DIR: /app/uploads
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000}
volumes:
- uploads_data:/app/uploads
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
command: >
sh -c "npx prisma db push && node server.js"
worker:
build: .
restart: unless-stopped
env_file:
- .env
environment:
S2YT_EDITION: selfhosted
DATABASE_URL: postgresql://s2yt:s2yt@postgres:5432/s2yt
REDIS_URL: redis://redis:6379
UPLOAD_DIR: /app/uploads
volumes:
- uploads_data:/app/uploads
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
command: ["npx", "tsx", "worker/index.ts"]
volumes:
postgres_data:
redis_data:
uploads_data:
+40
View File
@@ -0,0 +1,40 @@
"use client";
import { useEffect, useRef, useState } from "react";
type Options = {
threshold?: number;
rootMargin?: string;
once?: boolean;
};
export function useInView<T extends HTMLElement = HTMLDivElement>({
threshold = 0.15,
rootMargin = "0px 0px -40px 0px",
once = true,
}: Options = {}) {
const ref = useRef<T>(null);
const [inView, setInView] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setInView(true);
if (once) observer.unobserve(el);
} else if (!once) {
setInView(false);
}
},
{ threshold, rootMargin },
);
observer.observe(el);
return () => observer.disconnect();
}, [threshold, rootMargin, once]);
return { ref, inView };
}
+30
View File
@@ -0,0 +1,30 @@
"use client";
import { useEffect, useState } from "react";
export function useMockupProgress(
active: boolean,
phaseOneMs = 3000,
phaseTwoMs = 2000,
) {
const [phase, setPhase] = useState<0 | 1 | 2>(0);
const [processed, setProcessed] = useState(false);
useEffect(() => {
if (!active) return;
const startFrame = requestAnimationFrame(() => setPhase(1));
const midTimer = setTimeout(() => setPhase(2), phaseOneMs);
const doneTimer = setTimeout(() => setProcessed(true), phaseOneMs + phaseTwoMs);
return () => {
cancelAnimationFrame(startFrame);
clearTimeout(midTimer);
clearTimeout(doneTimer);
};
}, [active, phaseOneMs, phaseTwoMs]);
const transitionMs = phase === 1 ? phaseOneMs : phase === 2 ? phaseTwoMs : 0;
return { phase, processed, transitionMs };
}
+105
View File
@@ -0,0 +1,105 @@
import { NextRequest, NextResponse } from "next/server";
import { findUserByApiKey } from "./api-keys";
import { checkApiRateLimit } from "./api-rate-limit";
import { hasProFeatures } from "./edition";
import { getSessionUser } from "./session";
function extractBearerToken(req: NextRequest) {
const auth = req.headers.get("authorization");
if (!auth?.startsWith("Bearer ")) return null;
return auth.slice(7).trim();
}
export async function requirePaidApiUser(req: NextRequest) {
const token = extractBearerToken(req);
if (!token) {
return {
error: NextResponse.json(
{ error: "Missing API key. Use Authorization: Bearer <your_api_key>" },
{ status: 401 },
),
user: null,
};
}
const user = await findUserByApiKey(token);
if (!user) {
return {
error: NextResponse.json({ error: "Invalid API key" }, { status: 401 }),
user: null,
};
}
if (!hasProFeatures(user.plan)) {
return {
error: NextResponse.json(
{ error: "API access requires an active Pro subscription" },
{ status: 403 },
),
user: null,
};
}
if (!user.youtubeConnection) {
return {
error: NextResponse.json(
{ error: "YouTube account not connected. Sign in via the dashboard first." },
{ status: 403 },
),
user: null,
};
}
const rateLimit = await checkApiRateLimit(user.id);
if (!rateLimit.ok) {
return {
error: NextResponse.json(
{
error: "API rate limit exceeded. Try again shortly.",
retryAfterSeconds: rateLimit.retryAfterSeconds,
},
{
status: 429,
headers: { "Retry-After": String(rateLimit.retryAfterSeconds) },
},
),
user: null,
};
}
return { error: null, user };
}
export async function requirePaidUserFromSessionOrApi(req: NextRequest) {
const apiResult = await requirePaidApiUser(req);
if (apiResult.user) return apiResult;
const sessionUser = await getSessionUser();
if (!sessionUser) {
return apiResult.error
? apiResult
: {
error: NextResponse.json({ error: "Unauthorized" }, { status: 401 }),
user: null,
};
}
if (!hasProFeatures(sessionUser.plan)) {
return {
error: NextResponse.json(
{ error: "API access requires an active Pro subscription" },
{ status: 403 },
),
user: null,
};
}
if (!sessionUser.youtubeConnection) {
return {
error: NextResponse.json({ error: "YouTube account not connected" }, { status: 403 }),
user: null,
};
}
return { error: null, user: sessionUser };
}
+64
View File
@@ -0,0 +1,64 @@
import { createHash, randomBytes } from "crypto";
import { prisma } from "./db";
const API_KEY_PREFIX = "s2yt_live_";
export function hashApiKey(token: string) {
return createHash("sha256").update(token).digest("hex");
}
export function generateApiKeyMaterial() {
const secret = randomBytes(24).toString("base64url");
const token = `${API_KEY_PREFIX}${secret}`;
return {
token,
hash: hashApiKey(token),
prefix: token.slice(0, 20),
};
}
export async function createUserApiKey(userId: string) {
const { token, hash, prefix } = generateApiKeyMaterial();
await prisma.user.update({
where: { id: userId },
data: {
apiKeyHash: hash,
apiKeyPrefix: prefix,
},
});
return { token, prefix };
}
export async function revokeUserApiKey(userId: string) {
await prisma.user.update({
where: { id: userId },
data: {
apiKeyHash: null,
apiKeyPrefix: null,
},
});
}
export async function getUserApiKeyStatus(userId: string) {
const user = await prisma.user.findUniqueOrThrow({
where: { id: userId },
select: { apiKeyPrefix: true, apiKeyHash: true },
});
return {
configured: Boolean(user.apiKeyHash),
prefix: user.apiKeyPrefix,
};
}
export async function findUserByApiKey(token: string) {
if (!token.startsWith(API_KEY_PREFIX)) return null;
const hash = hashApiKey(token);
return prisma.user.findFirst({
where: { apiKeyHash: hash },
include: { youtubeConnection: true },
});
}
+155
View File
@@ -0,0 +1,155 @@
import IORedis from "ioredis";
import { prisma } from "./db";
import { isSelfHostedEdition } from "./edition";
export const DEFAULT_API_RATE_LIMIT = 60;
export const API_RATE_WINDOW_SECONDS = 60;
export const MAX_ADMIN_API_RATE_BONUS = 120;
const SELFHOSTED_API_RATE_LIMIT = 100_000;
type MemoryBucket = {
count: number;
resetAt: number;
};
const memoryBuckets = new Map<string, MemoryBucket>();
let redis: IORedis | null | undefined;
function getRedis() {
if (redis !== undefined) return redis;
try {
const url = process.env.REDIS_URL || "redis://localhost:6379";
redis = new IORedis(url, {
maxRetriesPerRequest: 1,
enableOfflineQueue: false,
lazyConnect: true,
});
return redis;
} catch {
redis = null;
return null;
}
}
function rateKey(userId: string) {
return `s2yt:api-rate:${userId}`;
}
export async function getEffectiveApiRateLimit(userId: string) {
if (isSelfHostedEdition()) return SELFHOSTED_API_RATE_LIMIT;
const user = await prisma.user.findUnique({
where: { id: userId },
select: { apiRateLimitBonus: true, plan: true },
});
if (!user || user.plan !== "PREMIUM") return DEFAULT_API_RATE_LIMIT;
return DEFAULT_API_RATE_LIMIT + Math.max(0, user.apiRateLimitBonus);
}
function memoryStatus(userId: string, limit: number) {
const now = Date.now();
const bucket = memoryBuckets.get(userId);
if (!bucket || now >= bucket.resetAt) {
return {
limit,
used: 0,
remaining: limit,
windowSeconds: API_RATE_WINDOW_SECONDS,
resetsInSeconds: API_RATE_WINDOW_SECONDS,
bonus: Math.max(0, limit - DEFAULT_API_RATE_LIMIT),
};
}
const used = Math.min(bucket.count, limit);
return {
limit,
used,
remaining: Math.max(0, limit - used),
windowSeconds: API_RATE_WINDOW_SECONDS,
resetsInSeconds: Math.max(1, Math.ceil((bucket.resetAt - now) / 1000)),
bonus: Math.max(0, limit - DEFAULT_API_RATE_LIMIT),
};
}
export async function getApiRateLimitStatus(userId: string) {
const limit = await getEffectiveApiRateLimit(userId);
const client = getRedis();
if (!client) return memoryStatus(userId, limit);
try {
if (client.status !== "ready") {
await client.connect().catch(() => {});
}
const key = rateKey(userId);
const [countRaw, ttl] = await Promise.all([client.get(key), client.ttl(key)]);
const used = Math.min(Number(countRaw || 0), limit);
return {
limit,
used,
remaining: Math.max(0, limit - used),
windowSeconds: API_RATE_WINDOW_SECONDS,
resetsInSeconds: ttl > 0 ? ttl : API_RATE_WINDOW_SECONDS,
bonus: Math.max(0, limit - DEFAULT_API_RATE_LIMIT),
};
} catch {
return { ...memoryStatus(userId, limit), bonus: Math.max(0, limit - DEFAULT_API_RATE_LIMIT) };
}
}
function checkMemoryRateLimit(userId: string, limit: number) {
const now = Date.now();
const bucket = memoryBuckets.get(userId);
if (!bucket || now >= bucket.resetAt) {
memoryBuckets.set(userId, {
count: 1,
resetAt: now + API_RATE_WINDOW_SECONDS * 1000,
});
return { ok: true as const, limit, remaining: limit - 1 };
}
if (bucket.count >= limit) {
return {
ok: false as const,
retryAfterSeconds: Math.max(1, Math.ceil((bucket.resetAt - now) / 1000)),
limit,
remaining: 0,
};
}
bucket.count += 1;
return { ok: true as const, limit, remaining: Math.max(0, limit - bucket.count) };
}
export async function checkApiRateLimit(userId: string) {
if (isSelfHostedEdition()) {
return { ok: true as const, limit: SELFHOSTED_API_RATE_LIMIT, remaining: SELFHOSTED_API_RATE_LIMIT };
}
const limit = await getEffectiveApiRateLimit(userId);
const client = getRedis();
if (!client) return checkMemoryRateLimit(userId, limit);
try {
if (client.status !== "ready") {
await client.connect().catch(() => {});
}
const key = rateKey(userId);
const count = await client.incr(key);
if (count === 1) {
await client.expire(key, API_RATE_WINDOW_SECONDS);
}
if (count > limit) {
const ttl = await client.ttl(key);
return {
ok: false as const,
retryAfterSeconds: Math.max(1, ttl > 0 ? ttl : API_RATE_WINDOW_SECONDS),
limit,
remaining: 0,
};
}
return { ok: true as const, limit, remaining: Math.max(0, limit - count) };
} catch {
return checkMemoryRateLimit(userId, limit);
}
}
+47
View File
@@ -0,0 +1,47 @@
import { parseFile } from "music-metadata";
import type { ItemMetadata } from "./types";
export type AudioTagMetadata = {
title?: string;
artist?: string;
album?: string;
genre?: string;
year?: string;
};
export async function readAudioTags(filePath: string): Promise<AudioTagMetadata | null> {
try {
const { common } = await parseFile(filePath);
if (!common.title && !common.artist && !common.album && !common.genre?.length) {
return null;
}
return {
title: common.title,
artist: common.artist,
album: common.album,
genre: common.genre?.join(", "),
year: common.year?.toString(),
};
} catch {
return null;
}
}
export function audioTagsToMetadata(
tags: AudioTagMetadata,
fallbackTitle: string,
): Pick<ItemMetadata, "title" | "description" | "tags"> {
const title =
tags.title ||
(tags.artist ? `${tags.artist}${tags.album ? ` - ${tags.album}` : ""}` : fallbackTitle);
const description = [tags.artist, tags.album, tags.year].filter(Boolean).join(" · ");
const tagParts = [tags.genre, tags.artist].filter(Boolean);
return {
title,
description,
tags: tagParts.join(", "),
};
}
+107
View File
@@ -0,0 +1,107 @@
import { NextAuthOptions } from "next-auth";
import GoogleProvider from "next-auth/providers/google";
import { encryptSecret } from "./crypto/secrets";
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",
// Required to add uploaded videos to playlists
"https://www.googleapis.com/auth/youtube.force-ssl",
].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,
);
const accessToken = encryptSecret(account.access_token);
const refreshToken = encryptSecret(account.refresh_token);
await prisma.youTubeConnection.upsert({
where: { userId: dbUser.id },
create: {
userId: dbUser.id,
accessToken,
refreshToken,
expiresAt: account.expires_at
? new Date(account.expires_at * 1000)
: new Date(Date.now() + 3600 * 1000),
channelId: channel.channelId,
channelTitle: channel.channelTitle,
},
update: {
accessToken,
refreshToken,
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,
};
+7
View File
@@ -0,0 +1,7 @@
export const BRAND_NAME = "Songs2YT";
export const BRAND_DOMAIN = `${BRAND_NAME}.com`;
export const VIDEO_ATTRIBUTION_PREFIX = "Uploaded through";
export function getVideoAttributionText() {
return `${VIDEO_ATTRIBUTION_PREFIX} ${BRAND_DOMAIN}`;
}
+59
View File
@@ -0,0 +1,59 @@
export const FREE_PLAN = {
monthlyQuota: 14,
maxFileSizeBytes: 30 * 1024 * 1024,
maxBatchSize: 3,
watermarkOptional: true,
} as const;
export const RESOLUTIONS = [
{ value: "1920x1080", label: "1920x1080 (16:9)", width: 1920, height: 1080 },
{ 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);
}
+55
View File
@@ -0,0 +1,55 @@
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "crypto";
const ENC_PREFIX = "enc:v1:";
function getEncryptionKey() {
const secret = process.env.TOKEN_ENCRYPTION_KEY || process.env.NEXTAUTH_SECRET;
if (!secret) {
throw new Error("TOKEN_ENCRYPTION_KEY or NEXTAUTH_SECRET must be set to encrypt secrets");
}
return createHash("sha256").update(secret).digest();
}
/** Encrypt a secret for DB storage (AES-256-GCM). Idempotent if already encrypted. */
export function encryptSecret(plaintext: string): string {
if (!plaintext) return plaintext;
if (plaintext.startsWith(ENC_PREFIX)) return plaintext;
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", getEncryptionKey(), iv);
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
return [
ENC_PREFIX,
iv.toString("base64url"),
".",
tag.toString("base64url"),
".",
encrypted.toString("base64url"),
].join("");
}
/** Decrypt a stored secret. Legacy plaintext values are returned as-is. */
export function decryptSecret(value: string): string {
if (!value) return value;
if (!value.startsWith(ENC_PREFIX)) return value;
const payload = value.slice(ENC_PREFIX.length);
const [ivB64, tagB64, dataB64] = payload.split(".");
if (!ivB64 || !tagB64 || !dataB64) {
throw new Error("Invalid encrypted secret format");
}
const decipher = createDecipheriv(
"aes-256-gcm",
getEncryptionKey(),
Buffer.from(ivB64, "base64url"),
);
decipher.setAuthTag(Buffer.from(tagB64, "base64url"));
const decrypted = Buffer.concat([
decipher.update(Buffer.from(dataB64, "base64url")),
decipher.final(),
]);
return decrypted.toString("utf8");
}
+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;
+9
View File
@@ -0,0 +1,9 @@
import { Plan } from "@prisma/client";
export function isSelfHostedEdition(): boolean {
return process.env.S2YT_EDITION === "selfhosted";
}
export function hasProFeatures(plan: Plan): boolean {
return isSelfHostedEdition() || plan === "PREMIUM";
}
+111
View File
@@ -0,0 +1,111 @@
import { spawn } from "child_process";
import fs from "fs/promises";
import path from "path";
import ffmpegStatic from "ffmpeg-static";
import { getVideoAttributionText } from "../branding";
import { getResolution } from "../constants";
import { getWatermarkPath } from "../storage";
function getFfmpegPath(): string {
if (process.env.FFMPEG_PATH) return process.env.FFMPEG_PATH;
if (ffmpegStatic) return ffmpegStatic;
return "ffmpeg";
}
export { getFfmpegPath };
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 attributionText = getVideoAttributionText().replace(/:/g, "\\:").replace(/'/g, "\\'");
const watermarkWidth = Math.max(1, Math.round(res.width * 0.32));
// Base template: ffmpeg -loop 1 -r 1 -i image -i audio -c:v libx264 -tune stillimage -c:a copy -shortest -pix_fmt yuv420p output.mp4
const args = ["-y", "-loop", "1", "-r", "1", "-i", options.imagePath, "-i", options.audioPath];
if (useWatermark) {
args.push("-i", watermarkPath);
args.push(
"-filter_complex",
`[0:v]${scaleFilter}[scaled];[2:v]scale=${watermarkWidth}:-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='${attributionText}':fontsize=22:fontcolor=white@0.85:x=w-text_w-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",
"copy",
"-shortest",
"-pix_fmt",
"yuv420p",
options.outputPath,
);
await runFfmpeg(args);
}
function runFfmpeg(args: string[]): Promise<void> {
return new Promise((resolve, reject) => {
const proc = spawn(getFfmpegPath(), 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
}
}
}
+45
View File
@@ -0,0 +1,45 @@
import { createWriteStream } from "fs";
import fs from "fs/promises";
import { Readable } from "stream";
import { pipeline } from "stream/promises";
export async function writeUploadedFile(file: File, destPath: string) {
const webStream = file.stream();
const nodeStream = Readable.fromWeb(webStream as Parameters<typeof Readable.fromWeb>[0]);
await pipeline(nodeStream, createWriteStream(destPath));
}
export async function moveFile(src: string, dest: string) {
try {
await fs.rename(src, dest);
} catch (err) {
const code = err && typeof err === "object" && "code" in err ? err.code : null;
if (code === "EXDEV") {
await fs.copyFile(src, dest);
await fs.unlink(src).catch(() => {});
return;
}
throw err;
}
}
export async function mapWithConcurrency<T, R>(
items: T[],
concurrency: number,
fn: (item: T, index: number) => Promise<R>,
) {
const results: R[] = new Array(items.length);
let nextIndex = 0;
async function worker() {
while (nextIndex < items.length) {
const index = nextIndex;
nextIndex += 1;
results[index] = await fn(items[index], index);
}
}
const workers = Array.from({ length: Math.min(concurrency, items.length) }, () => worker());
await Promise.all(workers);
return results;
}
+218
View File
@@ -0,0 +1,218 @@
import fs from "fs/promises";
import path from "path";
import { Plan, Privacy } from "@prisma/client";
import { readAudioTags } from "../audio-tags";
import { isAllowedResolution } from "../constants";
import { prisma } from "../db";
import { hasProFeatures } from "../edition";
import { moveFile, writeUploadedFile } from "../fs-utils";
import { enqueueVideoJob } from "../queue/client";
import {
getPlanLimits,
isAudioExtensionAllowed,
isResolutionAllowedForPlan,
} from "../plans";
import { releaseReservationSplit, reserveQuota } from "../quota";
import { getJobDir } from "../storage";
import {
assertPathInUserUploads,
getUserStagingDir,
sanitizeUploadSessionKey,
} from "../upload-paths";
import type { CreateJobPayload } from "../types";
export 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 validateJobPayload(user: { id: string; plan: Plan }, body: CreateJobPayload) {
if (!body.imagePath || !body.items?.length) {
return "Image and at least one audio file required";
}
for (const item of body.items) {
const metaError = validateItemMetadata(item.metadata);
if (metaError) return metaError;
if (!isResolutionAllowedForPlan(item.metadata.resolution, user.plan)) {
return `Resolution ${item.metadata.resolution} is not available on your plan`;
}
if (item.metadata.playlistId && !hasProFeatures(user.plan)) {
return "Adding videos to a YouTube playlist requires the Pro plan";
}
}
const limits = getPlanLimits(user.plan);
try {
const imagePath = assertPathInUserUploads(user.id, body.imagePath);
await fs.access(imagePath);
const imageStat = await fs.stat(imagePath);
if (imageStat.size > limits.maxFileSizeBytes) {
return "Image exceeds size limit";
}
for (const item of body.items) {
if (!isAudioExtensionAllowed(item.audioFilename, user.plan)) {
return `Audio file ${item.audioFilename} is not supported on your plan`;
}
const audioPath = assertPathInUserUploads(user.id, item.audioPath);
await fs.access(audioPath);
const stat = await fs.stat(audioPath);
if (stat.size > limits.maxFileSizeBytes) {
return `Audio file ${item.audioFilename} exceeds size limit`;
}
}
} catch (err) {
if (err instanceof Error && err.message === "Invalid upload path") {
return "Invalid upload path";
}
return "One or more uploaded files not found";
}
return null;
}
export async function createVideoJob(
user: { id: string; plan: Plan },
body: CreateJobPayload,
) {
const validationError = await validateJobPayload(user, body);
if (validationError) {
throw new Error(validationError);
}
const imagePath = assertPathInUserUploads(user.id, body.imagePath);
const items = body.items.map((item) => ({
...item,
audioPath: assertPathInUserUploads(user.id, item.audioPath),
}));
const reservation = await reserveQuota(user.id, items.length);
try {
const job = await prisma.job.create({
data: {
userId: user.id,
imagePath,
items: {
create: items.map((item, index) => ({
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: item.metadata.includeWatermark,
playlistId: item.metadata.playlistId?.trim() || null,
billingSource: index < reservation.fromQuota ? "QUOTA" : "CREDIT",
})),
},
},
include: { items: true },
});
const jobDir = getJobDir(user.id, job.id);
await fs.mkdir(jobDir, { recursive: true });
const imageExt = path.extname(imagePath);
const newImagePath = path.join(jobDir, `image${imageExt}`);
await moveFile(imagePath, newImagePath);
await prisma.job.update({ where: { id: job.id }, data: { imagePath: newImagePath } });
await Promise.all(
job.items.map(async (item) => {
const audioExt = path.extname(item.audioPath);
const newAudioPath = path.join(jobDir, `${item.id}${audioExt}`);
await moveFile(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 job;
} catch (err) {
await releaseReservationSplit(user.id, reservation).catch(() => {});
throw err;
}
}
export async function saveUploadedFile(
userId: string,
file: File,
type: "image" | "audio",
plan: Plan,
options?: { sessionKey?: string },
) {
const limits = getPlanLimits(plan);
if (file.size > limits.maxFileSizeBytes) {
throw new Error(`File exceeds ${limits.maxFileSizeBytes / (1024 * 1024)} MB limit`);
}
const allowedImageTypes = ["image/jpeg", "image/png", "image/webp", "image/gif"];
const allowedAudioTypes = [
"audio/mpeg",
"audio/mp3",
"audio/wav",
"audio/x-wav",
"audio/ogg",
"audio/flac",
"audio/aac",
"audio/mp4",
"audio/x-m4a",
];
const allowedTypes = type === "image" ? allowedImageTypes : allowedAudioTypes;
if (
!allowedTypes.includes(file.type) &&
!file.name.match(/\.(mp3|wav|ogg|flac|aac|m4a|jpg|jpeg|png|webp|gif)$/i)
) {
throw new Error("Invalid file type");
}
if (type === "audio" && !isAudioExtensionAllowed(file.name, plan)) {
const allowed = limits.allowedAudioExtensions.join(", ");
throw new Error(`Your plan supports ${allowed} audio files only`);
}
const sessionKey = sanitizeUploadSessionKey(options?.sessionKey);
const sessionDir = getUserStagingDir(userId, sessionKey);
await fs.mkdir(sessionDir, { recursive: true });
const safeName = file.name.replace(/[^a-zA-Z0-9._-]/g, "_");
const uniqueName = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}-${safeName}`;
const filePath = path.join(sessionDir, uniqueName);
assertPathInUserUploads(userId, filePath);
await writeUploadedFile(file, filePath);
let audioTags = null;
if (type === "audio" && limits.id3TagSupport && file.name.toLowerCase().endsWith(".mp3")) {
audioTags = await readAudioTags(filePath);
}
return {
path: filePath,
filename: file.name,
size: file.size,
audioTags,
};
}
+47
View File
@@ -0,0 +1,47 @@
import type { Plan } from "@prisma/client";
import { hasProFeatures } from "../edition";
import type { CreateJobPayload, CreatePlaylistRequest, ItemMetadata } from "../types";
import { createYouTubePlaylist } from "../youtube/upload";
export function parseCreatePlaylistInput(raw: unknown): CreatePlaylistRequest | null {
if (!raw || typeof raw !== "object") return null;
const value = raw as Record<string, unknown>;
const title = typeof value.title === "string" ? value.title.trim() : "";
if (!title) return null;
const privacy =
value.privacy === "public" || value.privacy === "unlisted" || value.privacy === "private"
? value.privacy
: undefined;
return {
title,
description: typeof value.description === "string" ? value.description : undefined,
privacy,
};
}
export async function applyCreatePlaylistToItems(
user: { id: string; plan: Plan },
items: CreateJobPayload["items"],
createPlaylist: CreatePlaylistRequest | null | undefined,
) {
if (!createPlaylist) {
return { items, playlist: null as Awaited<ReturnType<typeof createYouTubePlaylist>> | null };
}
if (!hasProFeatures(user.plan)) {
throw new Error("Creating a YouTube playlist requires the Pro plan");
}
const playlist = await createYouTubePlaylist(user.id, createPlaylist);
const nextItems = items.map((item) => ({
...item,
metadata: {
...item.metadata,
playlistId: item.metadata.playlistId || playlist.id,
} satisfies ItemMetadata,
}));
return { items: nextItems, playlist };
}
+13
View File
@@ -0,0 +1,13 @@
import { SUPPORT_EMAIL, SALES_EMAIL } from "@/lib/plans";
export const LEGAL_LAST_UPDATED = "July 13, 2026";
export const LEGAL_OPERATOR = {
name: "Songs2YT",
legalName: "Atakan Doğan Özban",
address: "Universitas u. 2/A",
city: "7622 Pécs, Hungary",
email: SUPPORT_EMAIL,
salesEmail: SALES_EMAIL,
website: "https://www.songs2yt.com",
} as const;
+79
View File
@@ -0,0 +1,79 @@
import { Plan } from "@prisma/client";
import { getResolution, RESOLUTIONS } from "./constants";
import { isSelfHostedEdition } from "./edition";
export type PlanLimits = {
monthlyQuota: number;
maxBatchSize: number;
maxResolutionHeight: number;
maxFileSizeBytes: number;
watermarkOptional: boolean;
allowedAudioExtensions: readonly string[];
id3TagSupport: boolean;
};
export const PLAN_LIMITS: Record<Plan, PlanLimits> = {
FREE: {
monthlyQuota: 14,
maxBatchSize: 3,
maxResolutionHeight: 720,
maxFileSizeBytes: 30 * 1024 * 1024,
watermarkOptional: true,
allowedAudioExtensions: [".mp3"],
id3TagSupport: true,
},
PREMIUM: {
monthlyQuota: 50,
maxBatchSize: 5,
maxResolutionHeight: 1080,
maxFileSizeBytes: 30 * 1024 * 1024,
watermarkOptional: true,
allowedAudioExtensions: [".mp3", ".wav", ".flac"],
id3TagSupport: true,
},
};
export const SELFHOSTED_LIMITS: PlanLimits = {
monthlyQuota: 1_000_000,
maxBatchSize: 100,
maxResolutionHeight: Math.max(...RESOLUTIONS.map((r) => r.height)),
maxFileSizeBytes: 500 * 1024 * 1024,
watermarkOptional: true,
allowedAudioExtensions: [".mp3", ".wav", ".flac"],
id3TagSupport: true,
};
export const SALES_EMAIL = "songs2yt@atakanozban.com";
export const SUPPORT_EMAIL = "songs2yt@atakanozban.com";
export const UPGRADE_URL = "/#pricing";
export const GITEA_ISSUES_URL =
process.env.NEXT_PUBLIC_GITEA_ISSUES_URL ?? "https://git.atakanozban.com/Songs2YT/issues";
export const GITEA_URL =
process.env.NEXT_PUBLIC_GITEA_URL ?? "https://git.atakanozban.com/Songs2YT";
export const DOCKER_HUB_URL =
process.env.NEXT_PUBLIC_DOCKER_HUB_URL ?? "https://hub.docker.com/r/atakanozban/songs2yt";
export function getPlanLimits(plan: Plan): PlanLimits {
if (isSelfHostedEdition()) return SELFHOSTED_LIMITS;
return PLAN_LIMITS[plan];
}
export function getResolutionsForPlan(plan: Plan) {
const maxHeight = getPlanLimits(plan).maxResolutionHeight;
return RESOLUTIONS.filter((r) => r.height <= maxHeight);
}
export function isResolutionAllowedForPlan(resolution: string, plan: Plan): boolean {
const res = getResolution(resolution);
if (!res) return false;
return res.height <= getPlanLimits(plan).maxResolutionHeight;
}
export function isAudioExtensionAllowed(filename: string, plan: Plan): boolean {
const ext = filename.slice(filename.lastIndexOf(".")).toLowerCase();
return getPlanLimits(plan).allowedAudioExtensions.includes(ext);
}
export function getNextMonthlyQuotaReset(from: Date = new Date()): Date {
return new Date(from.getFullYear(), from.getMonth() + 1, 1, 0, 0, 0, 0);
}
+40
View File
@@ -0,0 +1,40 @@
import { Queue } from "bullmq";
import { QUEUE_NAME } from "../constants";
import type { VideoJobData } from "../types";
function getConnectionOptions() {
const url = process.env.REDIS_URL || "redis://localhost:6379";
const parsed = new URL(url);
return {
host: parsed.hostname,
port: Number(parsed.port) || 6379,
username: parsed.username || undefined,
password: parsed.password || undefined,
maxRetriesPerRequest: null as null,
};
}
let queue: Queue | null = null;
export function getRedisConnection() {
return getConnectionOptions();
}
export function getVideoQueue(): Queue {
if (!queue) {
queue = new Queue(QUEUE_NAME, {
connection: getConnectionOptions(),
});
}
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,
});
}
+186
View File
@@ -0,0 +1,186 @@
import { QuotaExtensionRequestStatus } from "@prisma/client";
import { MAX_ADMIN_API_RATE_BONUS } from "./api-rate-limit";
import { prisma } from "./db";
import { getPlanLimits } from "./plans";
export const QUOTA_EXTENSION_ANNUAL_LIMIT = 5;
/** Max bonus videos an admin may grant per approval. */
export const MAX_ADMIN_BONUS_QUOTA = 50;
export const EXTENSION_KIND = {
VIDEO_QUOTA: "VIDEO_QUOTA",
API_RATE_LIMIT: "API_RATE_LIMIT",
} as const;
export type ExtensionKind = (typeof EXTENSION_KIND)[keyof typeof EXTENSION_KIND];
function getCalendarYearBounds(year = new Date().getFullYear()) {
return {
start: new Date(year, 0, 1),
end: new Date(year + 1, 0, 1),
};
}
export async function getQuotaExtensionUsage(
userId: string,
kind: ExtensionKind = EXTENSION_KIND.VIDEO_QUOTA,
) {
const { start, end } = getCalendarYearBounds();
const requests = await prisma.quotaExtensionRequest.findMany({
where: {
userId,
kind,
requestedAt: { gte: start, lt: end },
},
orderBy: { requestedAt: "desc" },
});
const used = requests.filter((r) => r.status !== QuotaExtensionRequestStatus.REJECTED).length;
return {
used,
limit: QUOTA_EXTENSION_ANNUAL_LIMIT,
remaining: Math.max(0, QUOTA_EXTENSION_ANNUAL_LIMIT - used),
kind,
requests: requests.map((r) => ({
id: r.id,
kind: r.kind,
status: r.status,
message: r.message,
requestedAt: r.requestedAt.toISOString(),
processedAt: r.processedAt?.toISOString() ?? null,
adminNote: r.adminNote,
})),
};
}
export async function createQuotaExtensionRequest(
userId: string,
message = "",
kind: ExtensionKind = EXTENSION_KIND.VIDEO_QUOTA,
) {
const user = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
if (user.plan !== "PREMIUM") {
throw new Error("Only Pro subscribers can request quota resets or extensions.");
}
const usage = await getQuotaExtensionUsage(userId, kind);
if (usage.remaining <= 0) {
throw new Error(
`You have used all ${QUOTA_EXTENSION_ANNUAL_LIMIT} ${
kind === EXTENSION_KIND.API_RATE_LIMIT ? "API rate limit" : "quota"
} extension requests for this year.`,
);
}
const pending = await prisma.quotaExtensionRequest.findFirst({
where: { userId, kind, status: QuotaExtensionRequestStatus.PENDING },
});
if (pending) {
throw new Error("You already have a pending request. Please wait for it to be processed.");
}
const request = await prisma.quotaExtensionRequest.create({
data: {
userId,
kind,
message: message.trim().slice(0, 1000),
status: QuotaExtensionRequestStatus.PENDING,
},
});
const updatedUsage = await getQuotaExtensionUsage(userId, kind);
return {
requestId: request.id,
...updatedUsage,
};
}
/** Call when approving a request in the database (admin / support). */
export async function approveQuotaExtensionRequest(
requestId: string,
options?: { bonusQuota?: number; bonusRateLimit?: number; adminNote?: string },
) {
const request = await prisma.quotaExtensionRequest.findUniqueOrThrow({
where: { id: requestId },
include: { user: true },
});
if (request.status !== QuotaExtensionRequestStatus.PENDING) {
throw new Error("Request is not pending.");
}
if (request.kind === EXTENSION_KIND.API_RATE_LIMIT) {
const bonus = Math.min(
MAX_ADMIN_API_RATE_BONUS,
Math.max(0, Math.floor(options?.bonusRateLimit ?? 30)),
);
await prisma.$transaction([
prisma.quotaExtensionRequest.update({
where: { id: requestId },
data: {
status: QuotaExtensionRequestStatus.APPROVED,
processedAt: new Date(),
adminNote: options?.adminNote?.trim().slice(0, 500) ?? null,
},
}),
prisma.user.update({
where: { id: request.userId },
data: {
apiRateLimitBonus: request.user.apiRateLimitBonus + bonus,
},
}),
]);
return;
}
const bonus = Math.min(
MAX_ADMIN_BONUS_QUOTA,
Math.max(0, Math.floor(options?.bonusQuota ?? 0)),
);
await prisma.$transaction([
prisma.quotaExtensionRequest.update({
where: { id: requestId },
data: {
status: QuotaExtensionRequestStatus.APPROVED,
processedAt: new Date(),
adminNote: options?.adminNote?.trim().slice(0, 500) ?? null,
},
}),
prisma.user.update({
where: { id: request.userId },
data: {
videosUsed: 0,
bonusQuota: request.user.bonusQuota + bonus,
},
}),
]);
}
export async function rejectQuotaExtensionRequest(requestId: string, adminNote?: string) {
const request = await prisma.quotaExtensionRequest.findUniqueOrThrow({
where: { id: requestId },
});
if (request.status !== QuotaExtensionRequestStatus.PENDING) {
throw new Error("Request is not pending.");
}
await prisma.quotaExtensionRequest.update({
where: { id: requestId },
data: {
status: QuotaExtensionRequestStatus.REJECTED,
processedAt: new Date(),
adminNote: adminNote?.trim().slice(0, 500) ?? null,
},
});
}
export function getEffectiveQuotaLimit(plan: Parameters<typeof getPlanLimits>[0], bonusQuota: number) {
return getPlanLimits(plan).monthlyQuota + bonusQuota;
}
+320
View File
@@ -0,0 +1,320 @@
import { Plan } from "@prisma/client";
import { prisma } from "./db";
import { isSelfHostedEdition } from "./edition";
import { getEffectiveQuotaLimit } from "./quota-extensions";
import { getNextMonthlyQuotaReset, getPlanLimits } from "./plans";
const TWELVE_HOURS_MS = 12 * 60 * 60 * 1000;
const CREDIT_BALANCE_MAX = 1000;
function getNextFreeQuotaReset(from: Date = new Date()): Date {
return new Date(from.getTime() + TWELVE_HOURS_MS);
}
function getNextQuotaResetForPlan(plan: Plan, from: Date = new Date()): Date {
return plan === "FREE" ? getNextFreeQuotaReset(from) : getNextMonthlyQuotaReset(from);
}
function isMonthlyResetDate(date: Date): boolean {
return (
date.getDate() === 1 &&
date.getHours() === 0 &&
date.getMinutes() === 0 &&
date.getSeconds() === 0 &&
date.getMilliseconds() === 0
);
}
export function formatQuotaResetCountdown(resetsAt: Date | string): string {
const target = typeof resetsAt === "string" ? new Date(resetsAt) : resetsAt;
const ms = Math.max(0, target.getTime() - Date.now());
const totalSec = Math.ceil(ms / 1000);
const hours = Math.floor(totalSec / 3600);
const minutes = Math.floor((totalSec % 3600) / 60);
const seconds = totalSec % 60;
return `${hours}h ${minutes}m ${seconds}s`;
}
export function formatQuotaResetDisplay(plan: Plan, resetsAt: Date | string): string {
const target = typeof resetsAt === "string" ? new Date(resetsAt) : resetsAt;
if (plan === "PREMIUM") {
return target.toLocaleDateString(undefined, {
month: "long",
day: "numeric",
year: "numeric",
});
}
return formatQuotaResetCountdown(target);
}
function getQuotaExceededMessage(plan: Plan, resetsAt: Date, videoCredits: number): string {
if (plan === "PREMIUM") {
const resetLabel = formatQuotaResetDisplay(plan, resetsAt);
return `Quota exceeded. Your monthly Pro quota resets on ${resetLabel}. Request an extension in Settings if needed.`;
}
const countdown = formatQuotaResetCountdown(resetsAt);
const creditHint = videoCredits > 0 ? "" : " Credits are not available in this build.";
return `Not enough free quota or credits. Your free quota resets in ${countdown}.${creditHint}`;
}
export async function ensureQuotaReset(userId: string) {
const user = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
const now = new Date();
if (user.plan === "PREMIUM") {
if (now >= user.quotaResetAt) {
return prisma.user.update({
where: { id: userId },
data: {
videosUsed: 0,
bonusQuota: 0,
quotaResetAt: getNextMonthlyQuotaReset(now),
},
});
}
if (!isMonthlyResetDate(user.quotaResetAt)) {
return prisma.user.update({
where: { id: userId },
data: {
quotaResetAt: getNextMonthlyQuotaReset(now),
},
});
}
return user;
}
if (now >= user.quotaResetAt) {
return prisma.user.update({
where: { id: userId },
data: {
videosUsed: 0,
quotaResetAt: getNextQuotaResetForPlan(user.plan, now),
},
});
}
return user;
}
export async function getQuotaInfo(userId: string) {
const user = await ensureQuotaReset(userId);
const limits = getPlanLimits(user.plan);
const limit = isSelfHostedEdition()
? limits.monthlyQuota
: getEffectiveQuotaLimit(user.plan, user.bonusQuota);
const remaining = Math.max(0, limit - user.videosUsed);
const videoCredits =
isSelfHostedEdition() || user.plan !== "FREE" ? 0 : user.videoCredits;
return {
used: user.videosUsed,
limit,
baseLimit: limits.monthlyQuota,
bonusQuota: isSelfHostedEdition() ? 0 : user.bonusQuota,
remaining,
videoCredits,
creditBalanceMax: CREDIT_BALANCE_MAX,
totalAvailable: remaining + videoCredits,
resetsAt: user.quotaResetAt.toISOString(),
resetsIn: formatQuotaResetDisplay(user.plan, user.quotaResetAt),
plan: user.plan,
maxBatchSize: limits.maxBatchSize,
watermarkOptional: limits.watermarkOptional,
maxResolutionHeight: limits.maxResolutionHeight,
selfHosted: isSelfHostedEdition(),
};
}
export type ReservationSplit = {
fromQuota: number;
fromCredits: number;
};
/**
* Free plan: use included quota first, then pay-as-you-go credits.
* Pro plan: subscription quota only (PAYG is a separate product).
*/
export function planReservation(
plan: Plan,
remainingQuota: number,
videoCredits: number,
count: number,
): ReservationSplit | null {
if (count <= 0) return { fromQuota: 0, fromCredits: 0 };
if (plan === "PREMIUM") {
if (count > remainingQuota) return null;
return { fromQuota: count, fromCredits: 0 };
}
const fromQuota = Math.min(count, Math.max(0, remainingQuota));
const fromCredits = count - fromQuota;
if (fromCredits > videoCredits) return null;
return { fromQuota, fromCredits };
}
export async function checkQuota(userId: string, requestedCount: number) {
if (isSelfHostedEdition()) {
const info = await getQuotaInfo(userId);
if (requestedCount > info.maxBatchSize) {
return {
ok: false as const,
error: `Batch limit exceeded. Max ${info.maxBatchSize} files per batch.`,
used: info.used,
limit: info.limit,
remaining: info.remaining,
videoCredits: 0,
resetsAt: info.resetsAt,
};
}
return {
ok: true as const,
...info,
reservation: { fromQuota: requestedCount, fromCredits: 0 },
};
}
const user = await ensureQuotaReset(userId);
const limits = getPlanLimits(user.plan);
const limit = getEffectiveQuotaLimit(user.plan, user.bonusQuota);
const remaining = Math.max(0, limit - user.videosUsed);
if (requestedCount > limits.maxBatchSize) {
return {
ok: false as const,
error: `Batch limit exceeded. Your plan allows up to ${limits.maxBatchSize} files per batch.`,
used: user.videosUsed,
limit,
remaining,
videoCredits: user.videoCredits,
resetsAt: user.quotaResetAt.toISOString(),
};
}
const split = planReservation(user.plan, remaining, user.videoCredits, requestedCount);
if (!split) {
return {
ok: false as const,
error: getQuotaExceededMessage(user.plan, user.quotaResetAt, user.videoCredits),
used: user.videosUsed,
limit,
remaining,
videoCredits: user.plan === "FREE" ? user.videoCredits : 0,
resetsAt: user.quotaResetAt.toISOString(),
};
}
const info = await getQuotaInfo(userId);
return { ok: true as const, ...info, reservation: split };
}
/**
* Atomically reserve plan quota first, then prepaid credits.
* Returns how many of each were taken (for per-item billingSource).
*/
export async function reserveQuota(userId: string, count: number): Promise<ReservationSplit> {
if (count <= 0) return { fromQuota: 0, fromCredits: 0 };
if (isSelfHostedEdition()) {
const limits = getPlanLimits("FREE");
if (count > limits.maxBatchSize) {
throw new Error(`Batch limit exceeded. Max ${limits.maxBatchSize} files per batch.`);
}
await ensureQuotaReset(userId);
await prisma.user.update({
where: { id: userId },
data: { videosUsed: { increment: count } },
});
return { fromQuota: count, fromCredits: 0 };
}
await ensureQuotaReset(userId);
return prisma.$transaction(async (tx) => {
const rows = await tx.$queryRaw<
Array<{
id: string;
plan: Plan;
videosUsed: number;
bonusQuota: number;
videoCredits: number;
quotaResetAt: Date;
}>
>`SELECT id, plan, "videosUsed", "bonusQuota", "videoCredits", "quotaResetAt" FROM "User" WHERE id = ${userId} FOR UPDATE`;
const user = rows[0];
if (!user) throw new Error("User not found");
const limits = getPlanLimits(user.plan);
if (count > limits.maxBatchSize) {
throw new Error(
`Batch limit exceeded. Your plan allows up to ${limits.maxBatchSize} files per batch.`,
);
}
const limit = getEffectiveQuotaLimit(user.plan, user.bonusQuota);
const remaining = Math.max(0, limit - user.videosUsed);
const split = planReservation(user.plan, remaining, user.videoCredits, count);
if (!split) {
throw new Error(getQuotaExceededMessage(user.plan, user.quotaResetAt, user.videoCredits));
}
await tx.user.update({
where: { id: userId },
data: {
...(split.fromQuota > 0 ? { videosUsed: { increment: split.fromQuota } } : {}),
...(split.fromCredits > 0 ? { videoCredits: { decrement: split.fromCredits } } : {}),
},
});
return split;
});
}
export async function releaseQuota(userId: string, count: number) {
if (count <= 0) return;
await ensureQuotaReset(userId);
await prisma.$executeRaw`
UPDATE "User"
SET "videosUsed" = GREATEST(0, "videosUsed" - ${count})
WHERE id = ${userId}
`;
}
export async function releaseCredits(userId: string, count: number) {
if (count <= 0) return;
await prisma.$executeRaw`
UPDATE "User"
SET "videoCredits" = LEAST(${CREDIT_BALANCE_MAX}, "videoCredits" + ${count})
WHERE id = ${userId}
`;
}
/** Release one reserved unit based on how the job item was billed. */
export async function releaseReservation(
userId: string,
billingSource: "QUOTA" | "CREDIT",
count = 1,
) {
if (billingSource === "CREDIT") {
await releaseCredits(userId, count);
} else {
await releaseQuota(userId, count);
}
}
export async function releaseReservationSplit(userId: string, split: ReservationSplit) {
if (split.fromQuota > 0) await releaseQuota(userId, split.fromQuota);
if (split.fromCredits > 0) await releaseCredits(userId, split.fromCredits);
}
/** @deprecated Prefer reserveQuota at create; kept for callers that still increment on success. */
export async function incrementQuota(userId: string, count: number) {
await reserveQuota(userId, count);
}
export function getInitialQuotaResetAt(): Date {
return getNextFreeQuotaReset();
}

Some files were not shown because too many files have changed in this diff Show More