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
+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();
}
+30
View File
@@ -0,0 +1,30 @@
import { getServerSession } from "next-auth";
import { NextResponse } from "next/server";
import { authOptions } from "./auth";
import { prisma } from "./db";
export async function getSessionUser() {
const session = await getServerSession(authOptions);
if (!session?.user?.email) return null;
const user = await prisma.user.findUnique({
where: { email: session.user.email },
include: { youtubeConnection: true },
});
return user;
}
export async function requireAuth() {
const user = await getSessionUser();
if (!user) {
return { error: NextResponse.json({ error: "Unauthorized" }, { status: 401 }), user: null };
}
if (!user.youtubeConnection) {
return {
error: NextResponse.json({ error: "YouTube account not connected" }, { status: 403 }),
user: null,
};
}
return { error: null, user };
}
+13
View File
@@ -0,0 +1,13 @@
import path from "path";
export function getUploadDir(): string {
return process.env.UPLOAD_DIR || path.join(process.cwd(), "uploads");
}
export function getJobDir(userId: string, jobId: string): string {
return path.join(getUploadDir(), userId, jobId);
}
export function getWatermarkPath(): string {
return path.join(process.cwd(), "assets", "watermark.png");
}
+63
View File
@@ -0,0 +1,63 @@
import { Privacy } from "@prisma/client";
export type ItemMetadata = {
title: string;
description: string;
tags: string;
privacy: Privacy;
categoryId: string;
resolution: string;
notifySubscribers: boolean;
madeForKids: boolean;
embeddable: boolean;
creativeCommons: boolean;
includeWatermark: boolean;
/** YouTube playlist ID (Pro only). Video is added after upload. */
playlistId?: string | null;
};
export type UploadedAudio = {
path: string;
filename: string;
metadata: ItemMetadata;
};
export type CreatePlaylistRequest = {
title: string;
description?: string;
privacy?: "public" | "unlisted" | "private";
};
export type CreateJobPayload = {
imagePath: string;
items: Array<{
audioPath: string;
audioFilename: string;
metadata: ItemMetadata;
}>;
/** Create a new playlist and attach its ID to every item (Pro). */
createPlaylist?: CreatePlaylistRequest | null;
};
export type JobItemResponse = {
id: string;
audioFilename: string;
title: string;
status: string;
youtubeVideoId: string | null;
error: string | null;
};
export type JobResponse = {
id: string;
status: string;
createdAt: string;
completedAt: string | null;
items: JobItemResponse[];
};
export type VideoJobData = {
jobItemId: string;
userId: string;
jobId: string;
};
+47
View File
@@ -0,0 +1,47 @@
import path from "path";
import { randomBytes } from "crypto";
import { getUploadDir } from "./storage";
/** Allow only safe staging folder names from clients. */
export function sanitizeUploadSessionKey(raw?: string | null): string {
const cleaned = (raw ?? "").replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 64);
if (cleaned.length >= 8) return cleaned;
return `${Date.now().toString(36)}-${randomBytes(6).toString("hex")}`;
}
function userUploadRoot(userId: string) {
return path.resolve(getUploadDir(), userId);
}
function isInsideDir(filePath: string, dir: string) {
const resolvedFile = path.resolve(filePath);
const resolvedDir = path.resolve(dir);
return resolvedFile === resolvedDir || resolvedFile.startsWith(resolvedDir + path.sep);
}
/** Ensure a path stays under uploads/{userId}/ (blocks traversal & cross-user access). */
export function assertPathInUserUploads(userId: string, filePath: string): string {
if (!filePath?.trim()) {
throw new Error("Invalid upload path");
}
const resolved = path.resolve(filePath);
const root = userUploadRoot(userId);
if (!isInsideDir(resolved, root)) {
throw new Error("Invalid upload path");
}
return resolved;
}
export function getUserStagingDir(userId: string, sessionKey: string) {
const safeSession = sanitizeUploadSessionKey(sessionKey);
const dir = path.join(userUploadRoot(userId), "staging", safeSession);
// Defense in depth: resolve and re-check
const resolved = path.resolve(dir);
if (!isInsideDir(resolved, userUploadRoot(userId))) {
throw new Error("Invalid upload session");
}
return resolved;
}
+57
View File
@@ -0,0 +1,57 @@
/** Extract a readable message from googleapis / Gaxios errors. */
export function extractYouTubeErrorMessage(err: unknown): string {
if (!err || typeof err !== "object") {
return typeof err === "string" ? err : "Unknown YouTube error";
}
const anyErr = err as {
message?: string;
errors?: Array<{ message?: string; reason?: string }>;
response?: {
data?: {
error?: {
message?: string;
errors?: Array<{ message?: string; reason?: string }>;
};
};
};
};
const nested =
anyErr.response?.data?.error?.errors?.[0]?.message ||
anyErr.response?.data?.error?.message ||
anyErr.errors?.[0]?.message;
if (nested?.trim()) return nested.trim();
if (anyErr.message?.trim()) return anyErr.message.trim();
return "Unknown YouTube error";
}
export function isYouTubeUploadLimitError(message: string) {
const lower = message.toLowerCase();
return (
lower.includes("exceeded the number of videos") ||
lower.includes("uploadLimitExceeded") ||
lower.includes("upload limit") ||
(lower.includes("quota") && lower.includes("exceeded") && lower.includes("youtube"))
);
}
export const YOUTUBE_UPLOAD_LIMIT_USER_MESSAGE =
"YouTube upload limit reached: this Google/YouTube account has exceeded the number of videos " +
"it may upload right now. This is YouTube's own daily limit, not your Songs2YT plan quota. " +
"Try again later (often after 24 hours) or use a different YouTube channel.";
/** User-facing message for the dashboard (distinct from Songs2YT plan quota). */
export function formatYouTubeErrorForUser(err: unknown): string {
const raw = extractYouTubeErrorMessage(err);
if (isYouTubeUploadLimitError(raw)) return YOUTUBE_UPLOAD_LIMIT_USER_MESSAGE;
return raw;
}
/** Normalize a stored job-item error string for display in the UI. */
export function displayJobItemError(message: string | null | undefined): string | null {
if (!message?.trim()) return null;
if (isYouTubeUploadLimitError(message)) return YOUTUBE_UPLOAD_LIMIT_USER_MESSAGE;
return message.trim();
}
+231
View File
@@ -0,0 +1,231 @@
import { google } from "googleapis";
import fs from "fs";
import { prisma } from "../db";
import { parseTags } from "../constants";
import { decryptSecret, encryptSecret } from "../crypto/secrets";
import type { JobItem } from "@prisma/client";
import { formatYouTubeErrorForUser } from "./errors";
async function refreshAccessToken(refreshToken: string) {
const oauth2Client = new google.auth.OAuth2(
process.env.GOOGLE_CLIENT_ID,
process.env.GOOGLE_CLIENT_SECRET,
);
oauth2Client.setCredentials({ refresh_token: refreshToken });
const { credentials } = await oauth2Client.refreshAccessToken();
return credentials;
}
export async function getYouTubeClient(userId: string) {
const connection = await prisma.youTubeConnection.findUnique({
where: { userId },
});
if (!connection) throw new Error("YouTube account not connected");
const oauth2Client = new google.auth.OAuth2(
process.env.GOOGLE_CLIENT_ID,
process.env.GOOGLE_CLIENT_SECRET,
);
let accessToken = decryptSecret(connection.accessToken);
const refreshToken = decryptSecret(connection.refreshToken);
let expiresAt = connection.expiresAt;
if (new Date() >= expiresAt) {
const credentials = await refreshAccessToken(refreshToken);
if (!credentials.access_token) {
throw new Error("Failed to refresh YouTube access token");
}
accessToken = credentials.access_token;
expiresAt = credentials.expiry_date
? new Date(credentials.expiry_date)
: new Date(Date.now() + 3600 * 1000);
await prisma.youTubeConnection.update({
where: { userId },
data: {
accessToken: encryptSecret(accessToken),
// Re-encrypt refresh token if it was still plaintext legacy
refreshToken: encryptSecret(refreshToken),
expiresAt,
},
});
}
oauth2Client.setCredentials({
access_token: accessToken,
refresh_token: refreshToken,
});
return google.youtube({ version: "v3", auth: oauth2Client });
}
export async function uploadToYouTube(
userId: string,
videoPath: string,
item: JobItem,
): Promise<string> {
try {
const youtube = await getYouTubeClient(userId);
const tags = parseTags(item.tags);
const privacyStatus =
item.privacy === "PUBLIC"
? "public"
: item.privacy === "PRIVATE"
? "private"
: "unlisted";
const response = await youtube.videos.insert({
part: ["snippet", "status"],
notifySubscribers: item.notifySubscribers,
requestBody: {
snippet: {
title: item.title,
description: item.description,
tags: tags.length > 0 ? tags : undefined,
categoryId: item.categoryId,
},
status: {
privacyStatus,
embeddable: item.embeddable,
selfDeclaredMadeForKids: item.madeForKids,
license: item.creativeCommons ? "creativeCommon" : "youtube",
},
},
media: {
body: fs.createReadStream(videoPath),
},
});
const videoId = response.data.id;
if (!videoId) throw new Error("YouTube upload succeeded but no video ID returned");
if (item.playlistId) {
try {
await addVideoToPlaylist(youtube, item.playlistId, videoId);
} catch (playlistErr) {
throw new Error(
`Video uploaded (${videoId}) but failed to add to playlist: ${formatYouTubeErrorForUser(playlistErr)}`,
);
}
}
return videoId;
} catch (err) {
if (err instanceof Error && err.message.startsWith("Video uploaded (")) {
throw err;
}
throw new Error(formatYouTubeErrorForUser(err));
}
}
export type PlaylistPrivacy = "public" | "unlisted" | "private";
export type CreatePlaylistInput = {
title: string;
description?: string;
privacy?: PlaylistPrivacy;
};
function toPlaylistPrivacyStatus(privacy?: PlaylistPrivacy) {
if (privacy === "public") return "public";
if (privacy === "unlisted") return "unlisted";
return "private";
}
export async function listYouTubePlaylists(userId: string) {
const youtube = await getYouTubeClient(userId);
const playlists: Array<{ id: string; title: string; itemCount: number }> = [];
let pageToken: string | undefined;
do {
const response = await youtube.playlists.list({
part: ["snippet", "contentDetails"],
mine: true,
maxResults: 50,
pageToken,
});
for (const playlist of response.data.items ?? []) {
if (!playlist.id || !playlist.snippet?.title) continue;
playlists.push({
id: playlist.id,
title: playlist.snippet.title,
itemCount: playlist.contentDetails?.itemCount ?? 0,
});
}
pageToken = response.data.nextPageToken ?? undefined;
} while (pageToken);
return playlists;
}
export async function createYouTubePlaylist(userId: string, input: CreatePlaylistInput) {
const title = input.title?.trim();
if (!title) throw new Error("Playlist title is required");
try {
const youtube = await getYouTubeClient(userId);
const response = await youtube.playlists.insert({
part: ["snippet", "status"],
requestBody: {
snippet: {
title,
description: input.description?.trim() || "",
},
status: {
privacyStatus: toPlaylistPrivacyStatus(input.privacy),
},
},
});
const id = response.data.id;
if (!id) throw new Error("YouTube created the playlist but returned no ID");
return {
id,
title: response.data.snippet?.title || title,
itemCount: 0,
privacy: input.privacy ?? "private",
};
} catch (err) {
throw new Error(formatYouTubeErrorForUser(err));
}
}
async function addVideoToPlaylist(
youtube: Awaited<ReturnType<typeof getYouTubeClient>>,
playlistId: string,
videoId: string,
) {
await youtube.playlistItems.insert({
part: ["snippet"],
requestBody: {
snippet: {
playlistId,
resourceId: {
kind: "youtube#video",
videoId,
},
},
},
});
}
export async function fetchYouTubeChannel(accessToken: string, refreshToken: string) {
const oauth2Client = new google.auth.OAuth2(
process.env.GOOGLE_CLIENT_ID,
process.env.GOOGLE_CLIENT_SECRET,
);
oauth2Client.setCredentials({ access_token: accessToken, refresh_token: refreshToken });
const youtube = google.youtube({ version: "v3", auth: oauth2Client });
const response = await youtube.channels.list({ part: ["snippet"], mine: true });
const channel = response.data.items?.[0];
if (!channel?.id) throw new Error("No YouTube channel found for this account");
return {
channelId: channel.id,
channelTitle: channel.snippet?.title || "Unknown Channel",
};
}