Initial OSS scaffold from Songs2VID (pre-strip)

This commit is contained in:
Songs2VID OSS
2026-08-03 06:15:05 +02:00
commit 935abfb22e
195 changed files with 25023 additions and 0 deletions
+49
View File
@@ -0,0 +1,49 @@
import { NextResponse } from "next/server";
import { createUserApiKey, getUserApiKeyStatus, revokeUserApiKey } from "@/lib/api-keys";
import { hasProFeatures } from "@/lib/edition";
import { getSessionUser } from "@/lib/session";
async function requireApiKeyAccess() {
const user = await getSessionUser();
if (!user) {
return { error: NextResponse.json({ error: "Unauthorized" }, { status: 401 }), user: null };
}
if (!hasProFeatures(user.plan)) {
return {
error: NextResponse.json(
{ error: "API keys are available on the Pro plan only" },
{ status: 403 },
),
user: null,
};
}
return { error: null, user };
}
export async function GET() {
const { error, user } = await requireApiKeyAccess();
if (error || !user) return error!;
const status = await getUserApiKeyStatus(user.id);
return NextResponse.json(status);
}
export async function POST() {
const { error, user } = await requireApiKeyAccess();
if (error || !user) return error!;
const { token, prefix } = await createUserApiKey(user.id);
return NextResponse.json({
apiKey: token,
prefix,
message: "Copy this key now. It will not be shown again.",
});
}
export async function DELETE() {
const { error, user } = await requireApiKeyAccess();
if (error || !user) return error!;
await revokeUserApiKey(user.id);
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from "next/server";
import {
createQuotaExtensionRequest,
EXTENSION_KIND,
getQuotaExtensionUsage,
} from "@/lib/quota-extensions";
import { hasProFeatures } from "@/lib/edition";
import { getSessionUser } from "@/lib/session";
export async function GET() {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!hasProFeatures(user.plan)) {
return NextResponse.json({ error: "Pro plan required" }, { status: 403 });
}
const usage = await getQuotaExtensionUsage(user.id, EXTENSION_KIND.API_RATE_LIMIT);
return NextResponse.json(usage);
}
export async function POST(req: NextRequest) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!hasProFeatures(user.plan)) {
return NextResponse.json({ error: "Pro plan required" }, { status: 403 });
}
let message = "";
try {
const body = await req.json();
if (typeof body.message === "string") message = body.message;
} catch {
// optional body
}
try {
const result = await createQuotaExtensionRequest(
user.id,
message,
EXTENSION_KIND.API_RATE_LIMIT,
);
return NextResponse.json(result);
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to submit request";
return NextResponse.json({ error: msg }, { status: 400 });
}
}
+20
View File
@@ -0,0 +1,20 @@
import { NextResponse } from "next/server";
import { getApiRateLimitStatus } from "@/lib/api-rate-limit";
import { hasProFeatures } from "@/lib/edition";
import { getSessionUser } from "@/lib/session";
export async function GET() {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!hasProFeatures(user.plan)) {
return NextResponse.json(
{ error: "API rate limits are available on the Pro plan only" },
{ status: 403 },
);
}
const status = await getApiRateLimitStatus(user.id);
return NextResponse.json(status);
}
+64
View File
@@ -0,0 +1,64 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getSessionUser } from "@/lib/session";
import { getStripe, isBillingDevMock, isStripeConfigured } from "@/lib/stripe";
/**
* Stripe Customer Portal update payment method, view invoices, cancel (per Dashboard config).
* @see https://docs.stripe.com/customer-management/integrate-customer-portal
*/
export async function POST(req: NextRequest) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (isBillingDevMock()) {
return NextResponse.json(
{ error: "Billing portal is unavailable in mock mode." },
{ status: 503 },
);
}
if (!isStripeConfigured()) {
return NextResponse.json(
{ error: "Payments are not configured yet. Set STRIPE_SECRET_KEY." },
{ status: 503 },
);
}
const dbUser = await prisma.user.findUniqueOrThrow({
where: { id: user.id },
select: { stripeCustomerId: true },
});
if (!dbUser.stripeCustomerId) {
return NextResponse.json(
{ error: "No Stripe customer on this account. Complete a checkout first." },
{ status: 400 },
);
}
const origin = process.env.NEXTAUTH_URL || req.nextUrl.origin;
const stripe = getStripe();
try {
const session = await stripe.billingPortal.sessions.create({
customer: dbUser.stripeCustomerId,
return_url: `${origin}/dashboard/settings`,
});
return NextResponse.json({ url: session.url });
} catch (err) {
const message = err instanceof Error ? err.message : "Could not open billing portal";
console.error("[stripe] billing portal failed", err);
return NextResponse.json(
{
error:
message.includes("No configuration") || message.includes("portal")
? "Billing portal is not configured in Stripe Dashboard yet. Enable it under Settings → Billing → Customer portal."
: message,
},
{ status: 502 },
);
}
}
@@ -0,0 +1,217 @@
import { NextRequest, NextResponse } from "next/server";
import { downgradeToFreePlan } from "@/lib/billing";
import { prisma } from "@/lib/db";
import { getSessionUser } from "@/lib/session";
import { getStripe, isBillingDevMock, isStripeConfigured } from "@/lib/stripe";
type CancelWhen = "immediate" | "period_end";
type CancelAction = "cancel" | "resume";
function periodEndIso(sub: {
cancel_at?: number | null;
current_period_end?: number;
items?: { data?: Array<{ current_period_end?: number }> };
}): string | null {
const fromItem = sub.items?.data?.[0]?.current_period_end;
const ts = sub.cancel_at ?? fromItem ?? sub.current_period_end ?? null;
return ts ? new Date(ts * 1000).toISOString() : null;
}
/**
* Cancel Pro subscription via Stripe.
* Body: { when: "immediate" | "period_end" } or { action: "resume" }
*/
export async function POST(req: NextRequest) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (user.plan !== "PREMIUM") {
return NextResponse.json({ error: "No active subscription to cancel" }, { status: 400 });
}
let action: CancelAction = "cancel";
let when: CancelWhen = "immediate";
try {
const body = await req.json();
if (body?.action === "resume") action = "resume";
if (body?.when === "period_end" || body?.when === "immediate") when = body.when;
} catch {
// empty body → default immediate cancel (legacy)
}
const dbUser = await prisma.user.findUniqueOrThrow({
where: { id: user.id },
select: { stripeSubscriptionId: true },
});
const subId = dbUser.stripeSubscriptionId;
const isMockSub = !subId || subId.startsWith("dev_sub_");
const useStripe =
Boolean(subId) &&
!isMockSub &&
isStripeConfigured() &&
!isBillingDevMock();
// Resume (undo cancel-at-period-end)
if (action === "resume") {
if (useStripe && subId) {
try {
const stripe = getStripe();
const sub = await stripe.subscriptions.update(subId, {
cancel_at_period_end: false,
});
return NextResponse.json({
ok: true,
action: "resume",
cancelAtPeriodEnd: false,
currentPeriodEnd: periodEndIso(sub as { current_period_end?: number }),
});
} catch (err) {
console.error("[stripe] resume subscription failed", err);
return NextResponse.json(
{ error: err instanceof Error ? err.message : "Failed to keep subscription" },
{ status: 502 },
);
}
}
return NextResponse.json({
ok: true,
action: "resume",
cancelAtPeriodEnd: false,
mocked: true,
});
}
// Cancel
if (when === "period_end") {
if (useStripe && subId) {
try {
const stripe = getStripe();
const sub = await stripe.subscriptions.update(subId, {
cancel_at_period_end: true,
});
const endsAt = periodEndIso(
sub as {
cancel_at?: number | null;
current_period_end?: number;
items?: { data?: Array<{ current_period_end?: number }> };
},
);
return NextResponse.json({
ok: true,
when: "period_end",
cancelAtPeriodEnd: true,
endsAt,
// Keep Pro until Stripe sends customer.subscription.deleted
});
} catch (err) {
console.error("[stripe] cancel at period end failed", err);
return NextResponse.json(
{ error: err instanceof Error ? err.message : "Failed to schedule cancellation" },
{ status: 502 },
);
}
}
// Mock / no Stripe id: schedule locally by leaving Pro and reporting next month
const endsAt = new Date();
endsAt.setMonth(endsAt.getMonth() + 1);
return NextResponse.json({
ok: true,
when: "period_end",
cancelAtPeriodEnd: true,
endsAt: endsAt.toISOString(),
mocked: true,
});
}
// immediate
if (useStripe && subId) {
try {
const stripe = getStripe();
await stripe.subscriptions.cancel(subId);
} catch (err) {
console.error("[stripe] cancel subscription failed", err);
return NextResponse.json(
{
error:
err instanceof Error
? err.message
: "Stripe could not cancel the subscription. Your plan was not changed.",
},
{ status: 502 },
);
}
}
await downgradeToFreePlan(user.id);
return NextResponse.json({
ok: true,
when: "immediate",
cancelAtPeriodEnd: false,
plan: "FREE",
});
}
/** Current Stripe subscription cancel status for settings UI. */
export async function GET() {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (user.plan !== "PREMIUM") {
return NextResponse.json({
plan: user.plan,
cancelAtPeriodEnd: false,
endsAt: null,
});
}
const dbUser = await prisma.user.findUniqueOrThrow({
where: { id: user.id },
select: { stripeSubscriptionId: true },
});
const subId = dbUser.stripeSubscriptionId;
if (
!subId ||
subId.startsWith("dev_sub_") ||
!isStripeConfigured() ||
isBillingDevMock()
) {
return NextResponse.json({
plan: "PREMIUM",
cancelAtPeriodEnd: false,
endsAt: null,
mocked: true,
});
}
try {
const stripe = getStripe();
const sub = await stripe.subscriptions.retrieve(subId);
return NextResponse.json({
plan: "PREMIUM",
status: sub.status,
cancelAtPeriodEnd: Boolean(sub.cancel_at_period_end),
endsAt: periodEndIso(
sub as {
cancel_at?: number | null;
current_period_end?: number;
items?: { data?: Array<{ current_period_end?: number }> };
},
),
});
} catch (err) {
console.error("[stripe] retrieve subscription failed", err);
return NextResponse.json({
plan: "PREMIUM",
cancelAtPeriodEnd: false,
endsAt: null,
error: "Could not load subscription status from Stripe",
});
}
}
+192
View File
@@ -0,0 +1,192 @@
import { NextRequest, NextResponse } from "next/server";
import {
CREDIT_PRICE_CENTS,
FREE_EXTRA_CREDITS_MAX,
FREE_TOP_UP_MAX,
FREE_TOP_UP_MIN,
formatCreditPrice,
validateFreeTopUpAmount,
} from "@/lib/credits";
import { prisma } from "@/lib/db";
import { getSessionUser } from "@/lib/session";
import {
checkoutTaxAndReferenceOptions,
ensureStripeCustomer,
priceDataTaxFields,
productDataWithTaxCode,
} from "@/lib/stripe-checkout";
import { getStripe, isBillingDevMock, isStripeConfigured } from "@/lib/stripe";
import { grantExtraCredits } from "@/lib/billing";
export async function GET() {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const dbUser = await prisma.user.findUniqueOrThrow({
where: { id: user.id },
select: {
extraCredits: true,
plan: true,
monthlyCredits: true,
videosUsed: true,
},
});
const purchases = await prisma.creditPurchase.findMany({
where: { userId: user.id, status: "COMPLETED" },
orderBy: { completedAt: "desc" },
take: 10,
});
const cap = FREE_EXTRA_CREDITS_MAX;
const room = Math.max(0, cap - dbUser.extraCredits);
return NextResponse.json({
extraCredits: dbUser.extraCredits,
videoCredits: dbUser.extraCredits,
topUpMin: FREE_TOP_UP_MIN,
topUpMax: FREE_TOP_UP_MAX,
priceCentsPerCredit: CREDIT_PRICE_CENTS,
priceLabelPerCredit: formatCreditPrice(1),
creditBalanceMax: cap,
monthlyCredits: dbUser.monthlyCredits,
creditsUsed: dbUser.videosUsed,
plan: dbUser.plan,
stripeConfigured: isStripeConfigured() || isBillingDevMock(),
room,
atExtraCap: room === 0,
purchases: purchases.map((p) => ({
id: p.id,
credits: p.credits,
amountCents: p.amountCents,
completedAt: p.completedAt?.toISOString() ?? null,
})),
});
}
/** Free tier: top up 115 extra credits (price = credits × €0.25). */
export async function POST(req: NextRequest) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (user.plan === "PREMIUM") {
return NextResponse.json(
{
error:
"Credit top-ups are for the Free plan. Pro includes 50 monthly credits; existing extras never expire.",
},
{ status: 403 },
);
}
let credits = 0;
try {
const body = await req.json();
credits = Math.floor(Number(body.credits));
} catch {
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
}
const dbUser = await prisma.user.findUniqueOrThrow({
where: { id: user.id },
select: { extraCredits: true, email: true, monthlyCredits: true, videosUsed: true, bonusQuota: true },
});
const monthlyRemaining = Math.max(
0,
dbUser.monthlyCredits + dbUser.bonusQuota - dbUser.videosUsed,
);
const validation = validateFreeTopUpAmount(credits, dbUser.extraCredits, monthlyRemaining);
if (!validation.ok) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { credits: amount, amountCents } = validation;
if (isBillingDevMock()) {
const purchase = await prisma.creditPurchase.create({
data: {
userId: user.id,
credits: amount,
amountCents,
status: "COMPLETED",
completedAt: new Date(),
stripeSessionId: `dev_topup_${Date.now()}`,
},
});
await grantExtraCredits(user.id, amount);
return NextResponse.json({
mocked: true,
granted: amount,
amountCents,
purchaseId: purchase.id,
});
}
if (!isStripeConfigured()) {
return NextResponse.json(
{ error: "Payments are not configured yet. Set STRIPE_SECRET_KEY." },
{ status: 503 },
);
}
const origin = process.env.NEXTAUTH_URL || req.nextUrl.origin;
const purchase = await prisma.creditPurchase.create({
data: {
userId: user.id,
credits: amount,
amountCents,
status: "PENDING",
},
});
try {
const stripe = getStripe();
const customerId = await ensureStripeCustomer(user.id, dbUser.email);
const session = await stripe.checkout.sessions.create({
mode: "payment",
customer: customerId,
line_items: [
{
quantity: amount,
price_data: {
currency: "eur",
unit_amount: CREDIT_PRICE_CENTS,
...priceDataTaxFields(),
product_data: productDataWithTaxCode(
"Songs2VID video credit",
`1 credit = 1 video upload (${formatCreditPrice(1)} each)`,
),
},
},
],
metadata: {
type: "free_top_up",
userId: user.id,
purchaseId: purchase.id,
credits: String(amount),
},
...checkoutTaxAndReferenceOptions(user.id),
success_url: `${origin}/dashboard/settings?credits=success`,
cancel_url: `${origin}/dashboard/settings?credits=cancelled`,
});
await prisma.creditPurchase.update({
where: { id: purchase.id },
data: { stripeSessionId: session.id },
});
return NextResponse.json({ url: session.url, sessionId: session.id });
} catch (err) {
await prisma.creditPurchase.update({
where: { id: purchase.id },
data: { status: "FAILED" },
});
const message = err instanceof Error ? err.message : "Checkout failed";
return NextResponse.json({ error: message }, { status: 500 });
}
}
+14
View File
@@ -0,0 +1,14 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getSessionUser } from "@/lib/session";
export async function POST() {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
await prisma.user.delete({ where: { id: user.id } });
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from "next/server";
import { createQuotaExtensionRequest, getQuotaExtensionUsage } from "@/lib/quota-extensions";
import { getSessionUser } from "@/lib/session";
export async function GET() {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const usage = await getQuotaExtensionUsage(user.id);
return NextResponse.json(usage);
}
export async function POST(req: NextRequest) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let message = "";
try {
const body = await req.json();
if (typeof body.message === "string") message = body.message;
} catch {
// optional body
}
try {
const result = await createQuotaExtensionRequest(user.id, message);
return NextResponse.json(result);
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to submit request";
return NextResponse.json({ error: msg }, { status: 400 });
}
}
+87
View File
@@ -0,0 +1,87 @@
import { NextRequest, NextResponse } from "next/server";
import { activateProPlan } from "@/lib/billing";
import { PRO_PRICE_CENTS } from "@/lib/credits";
import { prisma } from "@/lib/db";
import { getSessionUser } from "@/lib/session";
import {
checkoutTaxAndReferenceOptions,
ensureStripeCustomer,
priceDataTaxFields,
productDataWithTaxCode,
} from "@/lib/stripe-checkout";
import { getStripe, isBillingDevMock, isStripeConfigured } from "@/lib/stripe";
/** Start Pro subscription Checkout (€5/mo) or mock-upgrade in development. */
export async function POST(req: NextRequest) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (user.plan === "PREMIUM") {
return NextResponse.json({ error: "Already on Pro." }, { status: 400 });
}
const dbUser = await prisma.user.findUniqueOrThrow({
where: { id: user.id },
select: { email: true, stripeCustomerId: true },
});
if (isBillingDevMock()) {
await activateProPlan(user.id, {
stripeCustomerId: dbUser.stripeCustomerId,
stripeSubscriptionId: `dev_sub_${Date.now()}`,
cardLast4: "4242",
});
return NextResponse.json({ mocked: true, plan: "PREMIUM" });
}
if (!isStripeConfigured()) {
return NextResponse.json(
{ error: "Payments are not configured yet. Set STRIPE_SECRET_KEY." },
{ status: 503 },
);
}
const origin = process.env.NEXTAUTH_URL || req.nextUrl.origin;
const stripe = getStripe();
const customerId = await ensureStripeCustomer(user.id, dbUser.email);
const priceId = process.env.STRIPE_PRO_PRICE_ID;
const session = await stripe.checkout.sessions.create({
mode: "subscription",
customer: customerId,
line_items: priceId
? [{ price: priceId, quantity: 1 }]
: [
{
quantity: 1,
price_data: {
currency: "eur",
unit_amount: PRO_PRICE_CENTS,
recurring: { interval: "month" },
...priceDataTaxFields(),
product_data: productDataWithTaxCode(
"Songs2VID Pro",
"50 video credits / month · 1080p · API access · Includes Custom Branding, Watermark Positioning, and Bulk Image Matching",
),
},
},
],
metadata: {
type: "pro_subscription",
userId: user.id,
},
subscription_data: {
metadata: {
type: "pro_subscription",
userId: user.id,
},
},
...checkoutTaxAndReferenceOptions(user.id),
success_url: `${origin}/dashboard/settings?upgrade=success`,
cancel_url: `${origin}/dashboard/settings?upgrade=cancelled`,
});
return NextResponse.json({ url: session.url, sessionId: session.id });
}
+95
View File
@@ -0,0 +1,95 @@
import { NextRequest, NextResponse } from "next/server";
import { requireAdmin } from "@/lib/admin-auth";
import { prisma } from "@/lib/db";
import { normalizeBadgeFields, validateBadgeFields } from "@/lib/footer-badges";
type Ctx = { params: Promise<{ id: string }> };
export async function PATCH(req: NextRequest, ctx: Ctx) {
const denied = requireAdmin(req);
if (denied) return denied;
const { id } = await ctx.params;
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
const existing = await prisma.footerBadge.findUnique({ where: { id } });
if (!existing) {
return NextResponse.json({ error: "Badge not found" }, { status: 404 });
}
const input = (body ?? {}) as Record<string, unknown>;
const patch: {
name?: string;
embedHtml?: string | null;
imageUrl?: string | null;
linkUrl?: string | null;
isActive?: boolean;
sortOrder?: number;
} = {};
if ("name" in input) patch.name = typeof input.name === "string" ? input.name : existing.name;
if ("embedHtml" in input) {
patch.embedHtml = typeof input.embedHtml === "string" ? input.embedHtml : null;
}
if ("imageUrl" in input) {
patch.imageUrl = typeof input.imageUrl === "string" ? input.imageUrl : null;
}
if ("linkUrl" in input) {
patch.linkUrl = typeof input.linkUrl === "string" ? input.linkUrl : null;
}
if ("isActive" in input && typeof input.isActive === "boolean") {
patch.isActive = input.isActive;
}
if ("sortOrder" in input && typeof input.sortOrder === "number") {
patch.sortOrder = input.sortOrder;
}
const fields = normalizeBadgeFields({
name: patch.name ?? existing.name,
embedHtml: "embedHtml" in patch ? patch.embedHtml : existing.embedHtml,
imageUrl: "imageUrl" in patch ? patch.imageUrl : existing.imageUrl,
linkUrl: "linkUrl" in patch ? patch.linkUrl : existing.linkUrl,
isActive: patch.isActive ?? existing.isActive,
sortOrder: patch.sortOrder ?? existing.sortOrder,
});
// Toggle-only updates should not re-validate empty content
const contentChanging =
"name" in input || "embedHtml" in input || "imageUrl" in input || "linkUrl" in input;
if (contentChanging) {
const error = validateBadgeFields(fields);
if (error) return NextResponse.json({ error }, { status: 400 });
}
const badge = await prisma.footerBadge.update({
where: { id },
data: contentChanging
? fields
: {
...(patch.isActive !== undefined ? { isActive: patch.isActive } : {}),
...(patch.sortOrder !== undefined ? { sortOrder: fields.sortOrder } : {}),
},
});
return NextResponse.json({ badge });
}
export async function DELETE(req: NextRequest, ctx: Ctx) {
const denied = requireAdmin(req);
if (denied) return denied;
const { id } = await ctx.params;
const existing = await prisma.footerBadge.findUnique({ where: { id } });
if (!existing) {
return NextResponse.json({ error: "Badge not found" }, { status: 404 });
}
await prisma.footerBadge.delete({ where: { id } });
return NextResponse.json({ ok: true });
}
+48
View File
@@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from "next/server";
import { requireAdmin } from "@/lib/admin-auth";
import { prisma } from "@/lib/db";
import { normalizeBadgeFields, validateBadgeFields } from "@/lib/footer-badges";
export async function GET(req: NextRequest) {
const denied = requireAdmin(req);
if (denied) return denied;
const badges = await prisma.footerBadge.findMany({
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
});
return NextResponse.json({ badges });
}
export async function POST(req: NextRequest) {
const denied = requireAdmin(req);
if (denied) return denied;
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
const input = (body ?? {}) as Record<string, unknown>;
const fields = normalizeBadgeFields({
name: typeof input.name === "string" ? input.name : "",
embedHtml: typeof input.embedHtml === "string" ? input.embedHtml : null,
imageUrl: typeof input.imageUrl === "string" ? input.imageUrl : null,
linkUrl: typeof input.linkUrl === "string" ? input.linkUrl : null,
isActive: typeof input.isActive === "boolean" ? input.isActive : true,
sortOrder: typeof input.sortOrder === "number" ? input.sortOrder : undefined,
});
const error = validateBadgeFields(fields);
if (error) return NextResponse.json({ error }, { status: 400 });
if (fields.sortOrder === 0) {
const max = await prisma.footerBadge.aggregate({ _max: { sortOrder: true } });
fields.sortOrder = (max._max.sortOrder ?? -1) + 1;
}
const badge = await prisma.footerBadge.create({ data: fields });
return NextResponse.json({ badge }, { status: 201 });
}
@@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from "next/server";
import {
approveQuotaExtensionRequest,
rejectQuotaExtensionRequest,
} from "@/lib/quota-extensions";
function isAuthorized(req: NextRequest) {
const key = process.env.ADMIN_API_KEY;
if (!key) return false;
const auth = req.headers.get("authorization");
return auth === `Bearer ${key}`;
}
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
if (!isAuthorized(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
let action: "approve" | "reject" = "approve";
let bonusQuota: number | undefined;
let bonusRateLimit: number | undefined;
let adminNote: string | undefined;
try {
const body = await req.json();
if (body.action === "reject") action = "reject";
if (typeof body.bonusQuota === "number" && Number.isFinite(body.bonusQuota)) {
bonusQuota = body.bonusQuota;
}
if (typeof body.bonusRateLimit === "number" && Number.isFinite(body.bonusRateLimit)) {
bonusRateLimit = body.bonusRateLimit;
}
if (typeof body.adminNote === "string") adminNote = body.adminNote;
} catch {
// defaults
}
try {
if (action === "reject") {
await rejectQuotaExtensionRequest(id, adminNote);
} else {
await approveQuotaExtensionRequest(id, { bonusQuota, bonusRateLimit, adminNote });
}
return NextResponse.json({ ok: true });
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to process request";
return NextResponse.json({ error: msg }, { status: 400 });
}
}
+69
View File
@@ -0,0 +1,69 @@
import { NextRequest, NextResponse } from "next/server";
import { adminResetUserCredits } from "@/lib/billing";
import { prisma } from "@/lib/db";
/**
* Admin utility: reset / adjust a user's credits.
* Authorization: Bearer ${ADMIN_API_KEY}
*
* Body: { userId | email, plan?, monthlyCredits?, creditsUsed?, extraCredits?, clearFreeTopUp? }
*/
export async function POST(req: NextRequest) {
const adminKey = process.env.ADMIN_API_KEY;
if (!adminKey) {
return NextResponse.json({ error: "Admin API not configured" }, { status: 503 });
}
const auth = req.headers.get("authorization");
if (auth !== `Bearer ${adminKey}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let body: {
userId?: string;
email?: string;
plan?: "FREE" | "PREMIUM";
monthlyCredits?: number;
creditsUsed?: number;
extraCredits?: number;
clearFreeTopUp?: boolean;
};
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
let userId = body.userId;
if (!userId && body.email) {
const found = await prisma.user.findUnique({ where: { email: body.email } });
if (!found) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
userId = found.id;
}
if (!userId) {
return NextResponse.json({ error: "Provide userId or email" }, { status: 400 });
}
const updated = await adminResetUserCredits(userId, {
plan: body.plan,
monthlyCredits: body.monthlyCredits,
creditsUsed: body.creditsUsed,
extraCredits: body.extraCredits,
clearFreeTopUp: body.clearFreeTopUp,
});
return NextResponse.json({
ok: true,
user: {
id: updated.id,
email: updated.email,
plan: updated.plan,
monthlyCredits: updated.monthlyCredits,
creditsUsed: updated.videosUsed,
extraCredits: updated.extraCredits,
freeTopUpPurchased: updated.freeTopUpPurchased,
},
});
}
+6
View File
@@ -0,0 +1,6 @@
import NextAuth from "next-auth";
import { authOptions } from "@/lib/auth";
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };
+24
View File
@@ -0,0 +1,24 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
/** Public: active footer badges for the landing marquee. */
export async function GET() {
const badges = await prisma.footerBadge.findMany({
where: { isActive: true },
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
select: {
id: true,
name: true,
embedHtml: true,
imageUrl: true,
linkUrl: true,
sortOrder: true,
},
});
return NextResponse.json({ badges }, {
headers: {
"Cache-Control": "public, s-maxage=60, stale-while-revalidate=300",
},
});
}
+17
View File
@@ -0,0 +1,17 @@
import fs from "fs/promises";
import { NextResponse } from "next/server";
import { getWatermarkPath } from "@/lib/storage";
export async function GET() {
try {
const buf = await fs.readFile(getWatermarkPath());
return new NextResponse(buf, {
headers: {
"Content-Type": "image/png",
"Cache-Control": "public, max-age=86400, immutable",
},
});
} catch {
return NextResponse.json({ error: "Watermark asset not found" }, { status: 404 });
}
}
+95
View File
@@ -0,0 +1,95 @@
import { NextRequest, NextResponse } from "next/server";
import {
activateProPlan,
adminResetUserCredits,
grantExtraCredits,
markFreeTopUpPurchased,
} from "@/lib/billing";
import { FREE_TOP_UP_CREDITS } from "@/lib/credits";
import { isBillingDevMock } from "@/lib/stripe";
import { getSessionUser } from "@/lib/session";
/**
* Local-only billing helpers (no Stripe CLI required).
* Enabled when NODE_ENV=development (unless BILLING_DEV_MOCK=false) or BILLING_DEV_MOCK=true.
*/
function assertDev() {
if (!isBillingDevMock()) {
return NextResponse.json({ error: "Not available outside development mock mode" }, { status: 404 });
}
return null;
}
export async function POST(req: NextRequest) {
const blocked = assertDev();
if (blocked) return blocked;
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let body: {
action?: string;
credits?: number;
plan?: "FREE" | "PREMIUM";
};
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
const action = body.action ?? "grant-credits";
switch (action) {
case "grant-credits": {
const credits = Math.floor(Number(body.credits ?? FREE_TOP_UP_CREDITS));
if (!Number.isFinite(credits) || credits <= 0) {
return NextResponse.json({ error: "Invalid credits" }, { status: 400 });
}
await grantExtraCredits(user.id, credits);
return NextResponse.json({ ok: true, granted: credits });
}
case "free-top-up": {
await markFreeTopUpPurchased(user.id);
return NextResponse.json({ ok: true, granted: FREE_TOP_UP_CREDITS });
}
case "set-pro": {
await activateProPlan(user.id, {
stripeSubscriptionId: `dev_sub_${Date.now()}`,
cardLast4: "4242",
});
return NextResponse.json({ ok: true, plan: "PREMIUM" });
}
case "set-free": {
await adminResetUserCredits(user.id, { plan: "FREE", creditsUsed: 0 });
return NextResponse.json({ ok: true, plan: "FREE" });
}
case "reset-cycle": {
await adminResetUserCredits(user.id, {
plan: body.plan,
creditsUsed: 0,
});
return NextResponse.json({ ok: true, reset: true });
}
default:
return NextResponse.json({ error: `Unknown action: ${action}` }, { status: 400 });
}
}
/** Convenience GET for quick browser testing: /api/dev/grant-credits?action=set-pro */
export async function GET(req: NextRequest) {
const blocked = assertDev();
if (blocked) return blocked;
const action = req.nextUrl.searchParams.get("action") ?? "grant-credits";
const credits = Number(req.nextUrl.searchParams.get("credits") ?? FREE_TOP_UP_CREDITS);
const fake = new NextRequest(req.url, {
method: "POST",
headers: { "content-type": "application/json", cookie: req.headers.get("cookie") ?? "" },
body: JSON.stringify({ action, credits }),
});
return POST(fake);
}
+29
View File
@@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "fs/promises";
import { CURATED_FONTS, isCuratedFontKey } from "@/lib/fonts";
import { resolveCuratedFontPath } from "@/lib/fonts-server";
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ key: string }> },
) {
const { key } = await params;
if (!isCuratedFontKey(key)) {
return NextResponse.json({ error: "Unknown font" }, { status: 404 });
}
const fontPath = resolveCuratedFontPath(key);
try {
const buf = await fs.readFile(fontPath);
const meta = CURATED_FONTS.find((f) => f.key === key)!;
return new NextResponse(buf, {
headers: {
"Content-Type": "font/ttf",
"Content-Disposition": `inline; filename="${meta.file}"`,
"Cache-Control": "public, max-age=86400, immutable",
},
});
} catch {
return NextResponse.json({ error: "Font file not found" }, { status: 404 });
}
}
+47
View File
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { requireAuth } from "@/lib/session";
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { error, user } = await requireAuth();
if (error || !user) return error!;
const { id } = await params;
const job = await prisma.job.findFirst({
where: { id, userId: user.id },
include: {
items: {
orderBy: { audioFilename: "asc" },
select: {
id: true,
audioFilename: true,
title: true,
description: true,
tags: true,
privacy: true,
categoryId: true,
resolution: true,
status: true,
youtubeVideoId: true,
error: true,
},
},
},
});
if (!job) {
return NextResponse.json({ error: "Job not found" }, { status: 404 });
}
return NextResponse.json({
id: job.id,
status: job.status,
createdAt: job.createdAt.toISOString(),
completedAt: job.completedAt?.toISOString() ?? null,
items: job.items,
});
}
+62
View File
@@ -0,0 +1,62 @@
import { NextRequest, NextResponse } from "next/server";
import { PremiumRequiredError, premiumRequiredResponse } from "@/lib/entitlements";
import { createVideoJob } from "@/lib/jobs/create-job";
import { prisma } from "@/lib/db";
import { requireAuth } from "@/lib/session";
import type { CreateJobPayload } from "@/lib/types";
export const maxDuration = 120;
export async function POST(req: NextRequest) {
const { error, user } = await requireAuth();
if (error || !user) return error!;
const body = (await req.json()) as CreateJobPayload;
try {
const job = await createVideoJob(user, body);
return NextResponse.json({ jobId: job.id });
} catch (err) {
if (err instanceof PremiumRequiredError) {
return NextResponse.json(premiumRequiredResponse(err.message), { status: 403 });
}
const message = err instanceof Error ? err.message : "Failed to create job";
const status = message.includes("Quota exceeded") ? 403 : 400;
return NextResponse.json({ error: message }, { status });
}
}
export async function GET(req: NextRequest) {
const { error, user } = await requireAuth();
if (error || !user) return error!;
const limit = Math.min(Number(req.nextUrl.searchParams.get("limit")) || 20, 100);
const jobs = await prisma.job.findMany({
where: { userId: user.id },
orderBy: { createdAt: "desc" },
take: limit,
include: {
items: {
select: {
id: true,
audioFilename: true,
title: true,
status: true,
youtubeVideoId: true,
error: true,
},
},
},
});
return NextResponse.json({
jobs: jobs.map((job) => ({
id: job.id,
status: job.status,
createdAt: job.createdAt.toISOString(),
completedAt: job.completedAt?.toISOString() ?? null,
items: job.items,
})),
});
}
+13
View File
@@ -0,0 +1,13 @@
import { NextResponse } from "next/server";
import { getQuotaInfo } from "@/lib/quota";
import { getSessionUser } from "@/lib/session";
export async function GET() {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const quota = await getQuotaInfo(user.id);
return NextResponse.json(quota);
}
+255
View File
@@ -0,0 +1,255 @@
import { NextRequest, NextResponse } from "next/server";
import type Stripe from "stripe";
import {
activateProPlan,
applyMonthlyCreditRenewal,
downgradeToFreePlan,
grantExtraCredits,
} from "@/lib/billing";
import { monthlyCreditsForPlan } from "@/lib/credits";
import { prisma } from "@/lib/db";
import { getNextMonthlyQuotaReset } from "@/lib/plans";
import { getStripe } from "@/lib/stripe";
export const runtime = "nodejs";
/** Stripe API 2025+: subscription lives on parent.subscription_details, not invoice.subscription. */
function subscriptionIdFromInvoice(invoice: Stripe.Invoice): string | null {
const legacy = (invoice as Stripe.Invoice & {
subscription?: string | Stripe.Subscription | null;
}).subscription;
if (typeof legacy === "string" && legacy) return legacy;
if (legacy && typeof legacy === "object" && "id" in legacy && legacy.id) {
return String(legacy.id);
}
const parent = (
invoice as Stripe.Invoice & {
parent?: {
type?: string | null;
subscription_details?: { subscription?: string | Stripe.Subscription | null } | null;
} | null;
}
).parent;
const fromParent = parent?.subscription_details?.subscription;
if (typeof fromParent === "string" && fromParent) return fromParent;
if (fromParent && typeof fromParent === "object" && "id" in fromParent && fromParent.id) {
return String(fromParent.id);
}
return null;
}
async function fulfillFreeTopUp(session: Stripe.Checkout.Session) {
const purchaseId = session.metadata?.purchaseId;
const userId = session.metadata?.userId;
if (!purchaseId || !userId) {
console.error("[stripe] free_top_up missing metadata", session.id);
return;
}
const purchase = await prisma.creditPurchase.findUnique({ where: { id: purchaseId } });
if (!purchase || purchase.userId !== userId) {
throw new Error(`Purchase not found: ${purchaseId}`);
}
if (purchase.status === "COMPLETED") return;
await grantExtraCredits(userId, purchase.credits);
await prisma.creditPurchase.update({
where: { id: purchaseId },
data: {
status: "COMPLETED",
completedAt: new Date(),
stripeSessionId: session.id,
stripePaymentIntentId:
typeof session.payment_intent === "string"
? session.payment_intent
: session.payment_intent?.id ?? null,
},
});
}
async function fulfillProCheckout(session: Stripe.Checkout.Session) {
const userId = session.metadata?.userId;
if (!userId) {
console.error("[stripe] pro_subscription missing userId", session.id);
return;
}
const subscriptionId =
typeof session.subscription === "string"
? session.subscription
: session.subscription?.id ?? null;
const customerId =
typeof session.customer === "string" ? session.customer : session.customer?.id ?? null;
await activateProPlan(userId, {
stripeCustomerId: customerId,
stripeSubscriptionId: subscriptionId,
});
}
async function handleInvoicePaymentSucceeded(invoice: Stripe.Invoice) {
const subscriptionId = subscriptionIdFromInvoice(invoice);
if (!subscriptionId) {
console.warn("[stripe] invoice.payment_succeeded without subscription id", invoice.id);
return;
}
// Skip the first invoice if checkout already activated Pro (billing_reason subscription_create)
const user = await prisma.user.findFirst({
where: { stripeSubscriptionId: subscriptionId },
});
if (!user) {
// Fallback: metadata on subscription via Stripe retrieve is optional; try customer
const customerId =
typeof invoice.customer === "string" ? invoice.customer : invoice.customer?.id;
if (!customerId) return;
const byCustomer = await prisma.user.findFirst({
where: { stripeCustomerId: customerId },
});
if (!byCustomer) return;
await applyMonthlyCreditRenewal(byCustomer.id, {
plan: "PREMIUM",
newMonthlyCredits: monthlyCreditsForPlan("PREMIUM"),
quotaResetAt: getNextMonthlyQuotaReset(),
});
await prisma.user.update({
where: { id: byCustomer.id },
data: {
stripeSubscriptionId: subscriptionId,
subscribedAt: byCustomer.subscribedAt ?? new Date(),
},
});
return;
}
// Renewal: rollover unused + new monthly, trim to MAX_CREDIT_CAP (30)
await applyMonthlyCreditRenewal(user.id, {
plan: "PREMIUM",
newMonthlyCredits: monthlyCreditsForPlan("PREMIUM"),
quotaResetAt: getNextMonthlyQuotaReset(),
});
}
async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {
const user = await prisma.user.findFirst({
where: { stripeSubscriptionId: subscription.id },
});
if (!user) return;
await downgradeToFreePlan(user.id);
}
/** Stripe retries failed invoices; when status is unpaid/canceled, revoke Pro access. */
async function handleSubscriptionUpdated(subscription: Stripe.Subscription) {
const terminal = new Set(["canceled", "unpaid", "incomplete_expired"]);
if (!terminal.has(subscription.status)) return;
const user = await prisma.user.findFirst({
where: { stripeSubscriptionId: subscription.id },
});
if (!user || user.plan !== "PREMIUM") return;
await downgradeToFreePlan(user.id);
}
async function handleInvoicePaymentFailed(invoice: Stripe.Invoice) {
const subscriptionId = subscriptionIdFromInvoice(invoice);
if (!subscriptionId) return;
const user = await prisma.user.findFirst({
where: {
OR: [
{ stripeSubscriptionId: subscriptionId },
...(typeof invoice.customer === "string"
? [{ stripeCustomerId: invoice.customer }]
: invoice.customer?.id
? [{ stripeCustomerId: invoice.customer.id }]
: []),
],
},
});
if (!user) return;
// Soft signal only Stripe Smart Retries continue. Do not downgrade on first failure.
console.warn(
"[stripe] invoice.payment_failed",
invoice.id,
"user",
user.id,
"attempt",
invoice.attempt_count,
);
}
export async function POST(req: NextRequest) {
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
if (!webhookSecret) {
return NextResponse.json({ error: "Webhook not configured" }, { status: 503 });
}
const signature = req.headers.get("stripe-signature");
if (!signature) {
return NextResponse.json({ error: "Missing signature" }, { status: 400 });
}
const rawBody = await req.text();
let event: Stripe.Event;
try {
const stripe = getStripe();
event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret);
} catch (err) {
const message = err instanceof Error ? err.message : "Invalid signature";
return NextResponse.json({ error: message }, { status: 400 });
}
try {
if (event.type === "checkout.session.completed") {
const session = event.data.object as Stripe.Checkout.Session;
if (session.payment_status === "paid" || session.status === "complete") {
const type = session.metadata?.type;
if (type === "pro_subscription" || session.mode === "subscription") {
await fulfillProCheckout(session);
} else if (type === "free_top_up") {
await fulfillFreeTopUp(session);
} else if (session.metadata?.purchaseId) {
// Legacy PAYG purchases: grant metadata credits as extras
const userId = session.metadata.userId;
const credits = Number(session.metadata.credits);
const purchaseId = session.metadata.purchaseId;
if (userId && purchaseId && Number.isFinite(credits) && credits > 0) {
const purchase = await prisma.creditPurchase.findUnique({
where: { id: purchaseId },
});
if (purchase && purchase.status !== "COMPLETED") {
await grantExtraCredits(userId, credits);
await prisma.creditPurchase.update({
where: { id: purchaseId },
data: {
status: "COMPLETED",
completedAt: new Date(),
stripeSessionId: session.id,
},
});
}
}
}
}
} else if (event.type === "invoice.payment_succeeded") {
await handleInvoicePaymentSucceeded(event.data.object as Stripe.Invoice);
} else if (event.type === "invoice.payment_failed") {
await handleInvoicePaymentFailed(event.data.object as Stripe.Invoice);
} else if (event.type === "customer.subscription.updated") {
await handleSubscriptionUpdated(event.data.object as Stripe.Subscription);
} else if (event.type === "customer.subscription.deleted") {
await handleSubscriptionDeleted(event.data.object as Stripe.Subscription);
}
} catch (err) {
console.error("[stripe] webhook handler error", event.type, event.id, err);
return NextResponse.json({ error: "Handler failed" }, { status: 500 });
}
return NextResponse.json({ received: true });
}
+42
View File
@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from "next/server";
import { PremiumRequiredError, premiumRequiredResponse } from "@/lib/entitlements";
import { saveUploadedFile, type UploadFileType } from "@/lib/jobs/create-job";
import { getSessionUser } from "@/lib/session";
export const maxDuration = 120;
export async function POST(req: NextRequest) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const formData = await req.formData();
const file = formData.get("file") as File | null;
const type = formData.get("type") as string | null;
const sessionKey = (formData.get("session") as string | null)?.trim() || undefined;
if (
!file ||
!type ||
(type !== "image" && type !== "audio" && type !== "logo" && type !== "font")
) {
return NextResponse.json(
{ error: "Missing file or type (image | audio | logo | font)" },
{ status: 400 },
);
}
try {
const result = await saveUploadedFile(user.id, file, type as UploadFileType, user.plan, {
sessionKey,
});
return NextResponse.json(result);
} catch (err) {
if (err instanceof PremiumRequiredError) {
return NextResponse.json(premiumRequiredResponse(err.message), { status: 403 });
}
const message = err instanceof Error ? err.message : "Upload failed";
return NextResponse.json({ error: message }, { status: 400 });
}
}
+47
View File
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import { requirePaidApiUser } from "@/lib/api-auth";
import { prisma } from "@/lib/db";
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { error, user } = await requirePaidApiUser(_req);
if (error || !user) return error!;
const { id } = await params;
const job = await prisma.job.findFirst({
where: { id, userId: user.id },
include: {
items: {
orderBy: { audioFilename: "asc" },
select: {
id: true,
audioFilename: true,
title: true,
description: true,
tags: true,
privacy: true,
categoryId: true,
resolution: true,
status: true,
youtubeVideoId: true,
error: true,
},
},
},
});
if (!job) {
return NextResponse.json({ error: "Job not found" }, { status: 404 });
}
return NextResponse.json({
id: job.id,
status: job.status,
createdAt: job.createdAt.toISOString(),
completedAt: job.completedAt?.toISOString() ?? null,
items: job.items,
});
}
+159
View File
@@ -0,0 +1,159 @@
import { NextRequest, NextResponse } from "next/server";
import { Privacy } from "@prisma/client";
import { requirePaidApiUser } from "@/lib/api-auth";
import { PremiumRequiredError, premiumRequiredResponse } from "@/lib/entitlements";
import { createVideoJob, saveUploadedFile } from "@/lib/jobs/create-job";
import {
applyCreatePlaylistToItems,
parseCreatePlaylistInput,
} from "@/lib/jobs/resolve-playlist";
import { filenameWithoutExtension } from "@/lib/constants";
import { mapWithConcurrency } from "@/lib/fs-utils";
import { getPlanLimits } from "@/lib/plans";
import { checkQuota } from "@/lib/quota";
import type { CreateJobPayload, CreatePlaylistRequest, ItemMetadata } from "@/lib/types";
export const maxDuration = 300;
type BatchItemInput = Partial<ItemMetadata> & {
filename?: string;
title?: string;
};
function defaultMetadata(title: string): ItemMetadata {
return {
title,
songTitle: null,
description: "",
tags: "",
privacy: "PUBLIC",
categoryId: "10",
resolution: "1920x1080",
notifySubscribers: true,
madeForKids: false,
embeddable: true,
creativeCommons: false,
includeWatermark: false,
playlistId: null,
artist: null,
};
}
export async function POST(req: NextRequest) {
try {
const { error, user } = await requirePaidApiUser(req);
if (error || !user) return error!;
const formData = await req.formData();
const image = formData.get("image") as File | null;
const audioFiles = formData.getAll("audio").filter((f): f is File => f instanceof File);
const metadataRaw = formData.get("metadata");
if (!image || audioFiles.length === 0) {
return NextResponse.json(
{ error: "Provide multipart fields: image (file), audio (one or more files)" },
{ status: 400 },
);
}
const limits = getPlanLimits(user.plan);
if (audioFiles.length > limits.maxBatchSize) {
return NextResponse.json(
{ error: `Batch limit exceeded. Your plan allows up to ${limits.maxBatchSize} files per batch.` },
{ status: 400 },
);
}
const quotaCheck = await checkQuota(user.id, audioFiles.length);
if (!quotaCheck.ok) {
return NextResponse.json({ error: quotaCheck.error }, { status: 403 });
}
let itemMeta: BatchItemInput[] = [];
let defaults: Partial<ItemMetadata> = {};
let createPlaylist: CreatePlaylistRequest | null = null;
if (metadataRaw) {
try {
const parsed = JSON.parse(String(metadataRaw)) as {
items?: BatchItemInput[];
defaults?: Partial<ItemMetadata>;
createPlaylist?: unknown;
};
itemMeta = parsed.items ?? [];
defaults = parsed.defaults ?? {};
createPlaylist = parseCreatePlaylistInput(parsed.createPlaylist);
if (parsed.createPlaylist && !createPlaylist) {
return NextResponse.json(
{
error:
"createPlaylist requires a title. Optional: description, privacy (public|unlisted|private)",
},
{ status: 400 },
);
}
} catch {
return NextResponse.json({ error: "metadata must be valid JSON" }, { status: 400 });
}
}
const sessionKey = Date.now().toString();
const imageUpload = await saveUploadedFile(user.id, image, "image", user.plan, { sessionKey });
const uploads = await mapWithConcurrency(audioFiles, 4, (audio) =>
saveUploadedFile(user.id, audio, "audio", user.plan, { sessionKey }),
);
const builtItems: CreateJobPayload["items"] = uploads.map((upload, i) => {
const audio = audioFiles[i];
const metaInput = itemMeta[i] ?? itemMeta.find((m) => m.filename === audio.name) ?? {};
const base = defaultMetadata(
metaInput.title?.trim() || filenameWithoutExtension(audio.name),
);
const metadata: ItemMetadata = {
...base,
...defaults,
...metaInput,
title: (metaInput.title ?? defaults.title ?? base.title).trim(),
privacy: (metaInput.privacy ?? defaults.privacy ?? base.privacy) as Privacy,
};
return {
audioPath: upload.path,
audioFilename: upload.filename,
metadata,
};
});
const { items, playlist } = await applyCreatePlaylistToItems(user, builtItems, createPlaylist);
const job = await createVideoJob(user, {
imagePath: imageUpload.path,
items,
});
return NextResponse.json({
jobId: job.id,
itemCount: job.items.length,
status: job.status,
playlist: playlist
? { id: playlist.id, title: playlist.title, privacy: playlist.privacy }
: null,
});
} catch (err) {
console.error("Batch upload failed:", err);
if (err instanceof PremiumRequiredError) {
return NextResponse.json(premiumRequiredResponse(err.message), { status: 403 });
}
const message = err instanceof Error ? err.message : "Batch upload failed";
const status =
message.includes("Batch limit exceeded")
? 400
: message.includes("Quota exceeded") ||
message.includes("Not enough credits") ||
message.includes("Payment Required")
? 402
: 500;
return NextResponse.json({ error: message }, { status });
}
}
+97
View File
@@ -0,0 +1,97 @@
import { NextRequest, NextResponse } from "next/server";
import { requirePaidApiUser } from "@/lib/api-auth";
import { PremiumRequiredError, premiumRequiredResponse } from "@/lib/entitlements";
import { createVideoJob } from "@/lib/jobs/create-job";
import {
applyCreatePlaylistToItems,
parseCreatePlaylistInput,
} from "@/lib/jobs/resolve-playlist";
import { prisma } from "@/lib/db";
import type { CreateJobPayload } from "@/lib/types";
export async function POST(req: NextRequest) {
const { error, user } = await requirePaidApiUser(req);
if (error || !user) return error!;
const body = (await req.json()) as CreateJobPayload & { createPlaylist?: unknown };
try {
const createPlaylist = parseCreatePlaylistInput(body.createPlaylist);
if (body.createPlaylist && !createPlaylist) {
return NextResponse.json(
{
error:
"createPlaylist requires a title. Optional: description, privacy (public|unlisted|private)",
},
{ status: 400 },
);
}
const { items, playlist } = await applyCreatePlaylistToItems(
user,
body.items,
createPlaylist,
);
const job = await createVideoJob(user, {
imagePath: body.imagePath,
items,
});
return NextResponse.json({
jobId: job.id,
itemCount: job.items.length,
status: job.status,
playlist: playlist
? { id: playlist.id, title: playlist.title, privacy: playlist.privacy }
: null,
});
} catch (err) {
if (err instanceof PremiumRequiredError) {
return NextResponse.json(premiumRequiredResponse(err.message), { status: 403 });
}
const message = err instanceof Error ? err.message : "Failed to create job";
const status =
message.includes("Quota exceeded") ||
message.includes("Not enough credits") ||
message.includes("Payment Required")
? 402
: 400;
return NextResponse.json({ error: message }, { status });
}
}
export async function GET(req: NextRequest) {
const { error, user } = await requirePaidApiUser(req);
if (error || !user) return error!;
const limit = Math.min(Number(req.nextUrl.searchParams.get("limit")) || 20, 100);
const jobs = await prisma.job.findMany({
where: { userId: user.id },
orderBy: { createdAt: "desc" },
take: limit,
include: {
items: {
select: {
id: true,
audioFilename: true,
title: true,
status: true,
youtubeVideoId: true,
error: true,
},
},
},
});
return NextResponse.json({
jobs: jobs.map((job) => ({
id: job.id,
status: job.status,
createdAt: job.createdAt.toISOString(),
completedAt: job.completedAt?.toISOString() ?? null,
items: job.items,
})),
});
}
+44
View File
@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from "next/server";
import { requirePaidApiUser } from "@/lib/api-auth";
import { parseCreatePlaylistInput } from "@/lib/jobs/resolve-playlist";
import { createYouTubePlaylist, listYouTubePlaylists } from "@/lib/youtube/upload";
export async function GET(req: NextRequest) {
const { error, user } = await requirePaidApiUser(req);
if (error || !user) return error!;
try {
const playlists = await listYouTubePlaylists(user.id);
return NextResponse.json({ playlists });
} catch (err) {
console.error("List playlists failed:", err);
const message = err instanceof Error ? err.message : "Failed to list playlists";
return NextResponse.json({ error: message }, { status: 500 });
}
}
export async function POST(req: NextRequest) {
const { error, user } = await requirePaidApiUser(req);
if (error || !user) return error!;
try {
const body = await req.json();
const input = parseCreatePlaylistInput(body);
if (!input) {
return NextResponse.json(
{
error:
"Provide JSON body: { title, description?, privacy? } where privacy is public|unlisted|private",
},
{ status: 400 },
);
}
const playlist = await createYouTubePlaylist(user.id, input);
return NextResponse.json({ playlist });
} catch (err) {
console.error("Create playlist failed:", err);
const message = err instanceof Error ? err.message : "Failed to create playlist";
return NextResponse.json({ error: message }, { status: 500 });
}
}
+64
View File
@@ -0,0 +1,64 @@
import { NextResponse } from "next/server";
import { API_DOCS_URL } from "@/lib/plans";
export async function GET() {
return NextResponse.json({
name: "Songs2VID API",
version: "1.0",
authentication: "Authorization: Bearer <api_key>",
requirements: ["Pro subscription", "YouTube account connected"],
rateLimit: "60 requests per minute per account (plus any approved bonus)",
guidance: {
recommended:
"For most jobs (especially 5+ audio files): POST /api/v1/upload per file, then POST /api/v1/jobs with the returned paths",
batch:
"POST /api/v1/jobs/batch is for small packs only. Large multipart bodies may fail with 'failed to parse body as FormData'",
},
endpoints: [
{
method: "POST",
path: "/api/v1/upload",
description:
"Upload image, audio, PNG logo, or .ttf/.otf font (Pro typography; font ≤10MB)",
body: "multipart/form-data: file, type (image|audio|logo|font)",
},
{
method: "POST",
path: "/api/v1/jobs",
description:
"Create a video job from uploaded file paths. Pro: layout templates, blur, watermark, per-item covers",
body: "application/json: { imagePath, items[{ audioPath, audioFilename, metadata }] }",
},
{
method: "POST",
path: "/api/v1/jobs/batch",
description:
"One-shot batch for small packs only; large uploads may fail FormData parsing; prefer upload + jobs",
body: "multipart/form-data: image, audio[] (repeatable), metadata? (JSON string)",
},
{
method: "GET",
path: "/api/v1/playlists",
description: "List YouTube playlists for the authenticated Pro account",
},
{
method: "POST",
path: "/api/v1/playlists",
description: "Create a YouTube playlist",
body: "application/json: { title, description?, privacy? (public|unlisted|private) }",
},
{
method: "GET",
path: "/api/v1/jobs",
description: "List recent jobs",
query: "limit (default 20, max 100)",
},
{
method: "GET",
path: "/api/v1/jobs/:id",
description: "Get job status and item details",
},
],
docs: API_DOCS_URL,
});
}
+37
View File
@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from "next/server";
import { requirePaidApiUser } from "@/lib/api-auth";
import { PremiumRequiredError, premiumRequiredResponse } from "@/lib/entitlements";
import { saveUploadedFile, type UploadFileType } from "@/lib/jobs/create-job";
export const maxDuration = 120;
export async function POST(req: NextRequest) {
const { error, user } = await requirePaidApiUser(req);
if (error || !user) return error!;
const formData = await req.formData();
const file = formData.get("file") as File | null;
const type = formData.get("type") as string | null;
if (
!file ||
!type ||
(type !== "image" && type !== "audio" && type !== "logo" && type !== "font")
) {
return NextResponse.json(
{ error: "Provide multipart fields: file, type (image|audio|logo|font)" },
{ status: 400 },
);
}
try {
const result = await saveUploadedFile(user.id, file, type as UploadFileType, user.plan);
return NextResponse.json(result);
} catch (err) {
if (err instanceof PremiumRequiredError) {
return NextResponse.json(premiumRequiredResponse(err.message), { status: 403 });
}
const message = err instanceof Error ? err.message : "Upload failed";
return NextResponse.json({ error: message }, { status: 400 });
}
}
+63
View File
@@ -0,0 +1,63 @@
import { NextRequest, NextResponse } from "next/server";
import { hasProFeatures } from "@/lib/edition";
import { getSessionUser } from "@/lib/session";
import { parseCreatePlaylistInput } from "@/lib/jobs/resolve-playlist";
import { createYouTubePlaylist, listYouTubePlaylists } from "@/lib/youtube/upload";
async function requirePremiumYouTubeUser() {
const user = await getSessionUser();
if (!user) {
return { error: NextResponse.json({ error: "Unauthorized" }, { status: 401 }), user: null };
}
if (!hasProFeatures(user.plan)) {
return {
error: NextResponse.json(
{ error: "Playlist features are available on the Pro plan only" },
{ status: 403 },
),
user: null,
};
}
if (!user.youtubeConnection) {
return {
error: NextResponse.json({ error: "YouTube account not connected" }, { status: 403 }),
user: null,
};
}
return { error: null, user };
}
export async function GET() {
const { error, user } = await requirePremiumYouTubeUser();
if (error || !user) return error!;
try {
const playlists = await listYouTubePlaylists(user.id);
return NextResponse.json({ playlists });
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to list playlists";
return NextResponse.json({ error: message }, { status: 500 });
}
}
export async function POST(req: NextRequest) {
const { error, user } = await requirePremiumYouTubeUser();
if (error || !user) return error!;
try {
const body = await req.json();
const input = parseCreatePlaylistInput(body);
if (!input) {
return NextResponse.json(
{ error: "Provide title, and optionally description and privacy (public|unlisted|private)" },
{ status: 400 },
);
}
const playlist = await createYouTubePlaylist(user.id, input);
return NextResponse.json({ playlist });
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to create playlist";
return NextResponse.json({ error: message }, { status: 500 });
}
}