Initial open-source self-hosted Songs2YT edition
This commit is contained in:
+105
@@ -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 };
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
|
||||
|
||||
export const prisma =
|
||||
globalForPrisma.prisma ??
|
||||
new PrismaClient({
|
||||
log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"],
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
|
||||
@@ -0,0 +1,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";
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { NextResponse } from "next/server";
|
||||
import { authOptions } from "./auth";
|
||||
import { prisma } from "./db";
|
||||
|
||||
export async function getSessionUser() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.email) return null;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: session.user.email },
|
||||
include: { youtubeConnection: true },
|
||||
});
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function requireAuth() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return { error: NextResponse.json({ error: "Unauthorized" }, { status: 401 }), user: null };
|
||||
}
|
||||
if (!user.youtubeConnection) {
|
||||
return {
|
||||
error: NextResponse.json({ error: "YouTube account not connected" }, { status: 403 }),
|
||||
user: null,
|
||||
};
|
||||
}
|
||||
return { error: null, user };
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import path from "path";
|
||||
|
||||
export function getUploadDir(): string {
|
||||
return process.env.UPLOAD_DIR || path.join(process.cwd(), "uploads");
|
||||
}
|
||||
|
||||
export function getJobDir(userId: string, jobId: string): string {
|
||||
return path.join(getUploadDir(), userId, jobId);
|
||||
}
|
||||
|
||||
export function getWatermarkPath(): string {
|
||||
return path.join(process.cwd(), "assets", "watermark.png");
|
||||
}
|
||||
@@ -0,0 +1,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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user