Strip Songs2VID to a payment-free self-hosted OSS core.

Remove Stripe/billing/pricing/marketing, simplify schema and entitlements for unlimited self-host use, and keep auth, encode, and YouTube upload.
This commit is contained in:
Atakan Doğan Özban
2026-08-03 06:52:00 +02:00
parent 7d7043b150
commit d9cdaff521
108 changed files with 397 additions and 8061 deletions
-26
View File
@@ -1,26 +0,0 @@
import { AdminBadgePanel } from "@/components/AdminBadgePanel";
export const metadata = {
title: "Admin · Footer badges · Songs2VID",
robots: { index: false, follow: false },
};
export default function AdminBadgesPage() {
return (
<main className="min-h-screen bg-surface-dark text-gray-100">
<div className="mx-auto max-w-3xl px-6 py-10">
<header className="mb-8">
<p className="text-xs font-semibold uppercase tracking-wider text-gray-500">
Admin
</p>
<h1 className="mt-1 text-2xl font-bold text-white">Footer badges</h1>
<p className="mt-2 text-sm text-gray-400">
Manage the infinite badge marquee on the public site footer. Only active badges are
shown.
</p>
</header>
<AdminBadgePanel />
</div>
</main>
);
}
-10
View File
@@ -1,6 +1,5 @@
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() {
@@ -8,15 +7,6 @@ async function requireApiKeyAccess() {
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 };
}
@@ -1,51 +0,0 @@
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 });
}
}
-8
View File
@@ -1,6 +1,5 @@
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() {
@@ -8,13 +7,6 @@ export async function GET() {
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
@@ -1,64 +0,0 @@
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 },
);
}
}
@@ -1,217 +0,0 @@
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
@@ -1,192 +0,0 @@
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 });
}
}
@@ -1,36 +0,0 @@
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
@@ -1,87 +0,0 @@
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
@@ -1,95 +0,0 @@
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
@@ -1,48 +0,0 @@
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 });
}
@@ -1,53 +0,0 @@
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
@@ -1,69 +0,0 @@
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,
},
});
}
-24
View File
@@ -1,24 +0,0 @@
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",
},
});
}
-95
View File
@@ -1,95 +0,0 @@
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);
}
+1 -6
View File
@@ -1,5 +1,4 @@
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";
@@ -17,12 +16,8 @@ export async function POST(req: NextRequest) {
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 });
return NextResponse.json({ error: message }, { status: 400 });
}
}
-255
View File
@@ -1,255 +0,0 @@
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 });
}
+1 -5
View File
@@ -1,5 +1,4 @@
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";
@@ -28,14 +27,11 @@ export async function POST(req: NextRequest) {
}
try {
const result = await saveUploadedFile(user.id, file, type as UploadFileType, user.plan, {
const result = await saveUploadedFile(user.id, file, type as UploadFileType, {
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 });
}
+2 -2
View File
@@ -1,12 +1,12 @@
import { NextRequest, NextResponse } from "next/server";
import { requirePaidApiUser } from "@/lib/api-auth";
import { requireApiUser } 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);
const { error, user } = await requireApiUser(_req);
if (error || !user) return error!;
const { id } = await params;
+7 -18
View File
@@ -1,7 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { Privacy } from "@prisma/client";
import { requirePaidApiUser } from "@/lib/api-auth";
import { PremiumRequiredError, premiumRequiredResponse } from "@/lib/entitlements";
import { requireApiUser } from "@/lib/api-auth";
import { createVideoJob, saveUploadedFile } from "@/lib/jobs/create-job";
import {
applyCreatePlaylistToItems,
@@ -41,7 +40,7 @@ function defaultMetadata(title: string): ItemMetadata {
export async function POST(req: NextRequest) {
try {
const { error, user } = await requirePaidApiUser(req);
const { error, user } = await requireApiUser(req);
if (error || !user) return error!;
const formData = await req.formData();
@@ -56,10 +55,10 @@ export async function POST(req: NextRequest) {
);
}
const limits = getPlanLimits(user.plan);
const limits = getPlanLimits();
if (audioFiles.length > limits.maxBatchSize) {
return NextResponse.json(
{ error: `Batch limit exceeded. Your plan allows up to ${limits.maxBatchSize} files per batch.` },
{ error: `Batch limit exceeded. Max ${limits.maxBatchSize} files per batch.` },
{ status: 400 },
);
}
@@ -98,10 +97,10 @@ export async function POST(req: NextRequest) {
}
const sessionKey = Date.now().toString();
const imageUpload = await saveUploadedFile(user.id, image, "image", user.plan, { sessionKey });
const imageUpload = await saveUploadedFile(user.id, image, "image", { sessionKey });
const uploads = await mapWithConcurrency(audioFiles, 4, (audio) =>
saveUploadedFile(user.id, audio, "audio", user.plan, { sessionKey }),
saveUploadedFile(user.id, audio, "audio", { sessionKey }),
);
const builtItems: CreateJobPayload["items"] = uploads.map((upload, i) => {
@@ -142,18 +141,8 @@ export async function POST(req: NextRequest) {
});
} 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;
const status = message.includes("Batch limit exceeded") ? 400 : 500;
return NextResponse.json({ error: message }, { status });
}
}
+4 -14
View File
@@ -1,6 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { requirePaidApiUser } from "@/lib/api-auth";
import { PremiumRequiredError, premiumRequiredResponse } from "@/lib/entitlements";
import { requireApiUser } from "@/lib/api-auth";
import { createVideoJob } from "@/lib/jobs/create-job";
import {
applyCreatePlaylistToItems,
@@ -10,7 +9,7 @@ import { prisma } from "@/lib/db";
import type { CreateJobPayload } from "@/lib/types";
export async function POST(req: NextRequest) {
const { error, user } = await requirePaidApiUser(req);
const { error, user } = await requireApiUser(req);
if (error || !user) return error!;
const body = (await req.json()) as CreateJobPayload & { createPlaylist?: unknown };
@@ -47,22 +46,13 @@ export async function POST(req: NextRequest) {
: 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 });
return NextResponse.json({ error: message }, { status: 400 });
}
}
export async function GET(req: NextRequest) {
const { error, user } = await requirePaidApiUser(req);
const { error, user } = await requireApiUser(req);
if (error || !user) return error!;
const limit = Math.min(Number(req.nextUrl.searchParams.get("limit")) || 20, 100);
+3 -3
View File
@@ -1,10 +1,10 @@
import { NextRequest, NextResponse } from "next/server";
import { requirePaidApiUser } from "@/lib/api-auth";
import { requireApiUser } 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);
const { error, user } = await requireApiUser(req);
if (error || !user) return error!;
try {
@@ -18,7 +18,7 @@ export async function GET(req: NextRequest) {
}
export async function POST(req: NextRequest) {
const { error, user } = await requirePaidApiUser(req);
const { error, user } = await requireApiUser(req);
if (error || !user) return error!;
try {
+5 -5
View File
@@ -6,8 +6,8 @@ export async function GET() {
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)",
requirements: ["YouTube account connected"],
rateLimit: "100,000 requests per minute per account",
guidance: {
recommended:
"For most jobs (especially 5+ audio files): POST /api/v1/upload per file, then POST /api/v1/jobs with the returned paths",
@@ -19,14 +19,14 @@ export async function GET() {
method: "POST",
path: "/api/v1/upload",
description:
"Upload image, audio, PNG logo, or .ttf/.otf font (Pro typography; font ≤10MB)",
"Upload image, audio, PNG logo, or .ttf/.otf font (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",
"Create a video job from uploaded file paths with layouts, watermark, and per-item covers",
body: "application/json: { imagePath, items[{ audioPath, audioFilename, metadata }] }",
},
{
@@ -39,7 +39,7 @@ export async function GET() {
{
method: "GET",
path: "/api/v1/playlists",
description: "List YouTube playlists for the authenticated Pro account",
description: "List YouTube playlists for the authenticated account",
},
{
method: "POST",
+3 -7
View File
@@ -1,12 +1,11 @@
import { NextRequest, NextResponse } from "next/server";
import { requirePaidApiUser } from "@/lib/api-auth";
import { PremiumRequiredError, premiumRequiredResponse } from "@/lib/entitlements";
import { requireApiUser } from "@/lib/api-auth";
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);
const { error, user } = await requireApiUser(req);
if (error || !user) return error!;
const formData = await req.formData();
@@ -25,12 +24,9 @@ export async function POST(req: NextRequest) {
}
try {
const result = await saveUploadedFile(user.id, file, type as UploadFileType, user.plan);
const result = await saveUploadedFile(user.id, file, type as UploadFileType);
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 });
}
-10
View File
@@ -1,5 +1,4 @@
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";
@@ -9,15 +8,6 @@ async function requirePremiumYouTubeUser() {
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 }),
+32 -180
View File
@@ -4,23 +4,10 @@ import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { AccountPrivacyActions } from "@/components/AccountPrivacyActions";
import { CreditPurchasePanel } from "@/components/CreditPurchasePanel";
import { PlanBillingActions } from "@/components/PlanBillingActions";
import { DashboardShell } from "@/components/DashboardShell";
import { UpgradeProButton } from "@/components/UpgradeProButton";
import { getUserApiKeyStatus } from "@/lib/api-keys";
import { getApiRateLimitStatus } from "@/lib/api-rate-limit";
import { ApiKeySettings } from "@/components/ApiKeySettings";
import { hasProFeatures, isSelfHostedEdition } from "@/lib/edition";
import { EXTENSION_KIND, getQuotaExtensionUsage } from "@/lib/quota-extensions";
import { getQuotaInfo } from "@/lib/quota";
import { isBillingDevMock, isStripeConfigured } from "@/lib/stripe";
import { CreditPurchaseStatus } from "@prisma/client";
const PLAN_LABELS = {
FREE: "Bedroom Producer",
PREMIUM: "Pro",
} as const;
function InfoRow({ label, value }: { label: string; value: string }) {
return (
@@ -41,22 +28,10 @@ export default async function SettingsPage() {
});
if (!user) redirect("/");
const quota = await getQuotaInfo(user.id);
const selfHosted = isSelfHostedEdition();
const proFeatures = hasProFeatures(user.plan);
const recentCreditPurchases = await prisma.creditPurchase.findMany({
where: { userId: user.id, status: CreditPurchaseStatus.COMPLETED },
orderBy: { completedAt: "desc" },
take: 5,
});
const extensionUsage =
!selfHosted && user.plan === "PREMIUM" ? await getQuotaExtensionUsage(user.id) : null;
const apiRateExtensionUsage = proFeatures
? await getQuotaExtensionUsage(user.id, EXTENSION_KIND.API_RATE_LIMIT)
: null;
const apiKeyStatus = proFeatures ? await getUserApiKeyStatus(user.id) : null;
const apiRateLimit = proFeatures ? await getApiRateLimitStatus(user.id) : null;
const planLabel = selfHosted ? "Self-hosted" : PLAN_LABELS[user.plan];
const [apiKeyStatus, apiRateLimit] = await Promise.all([
getUserApiKeyStatus(user.id),
getApiRateLimitStatus(user.id),
]);
const youtube = user.youtubeConnection;
const channelUrl = youtube ? `https://www.youtube.com/channel/${youtube.channelId}` : null;
@@ -64,174 +39,51 @@ export default async function SettingsPage() {
<DashboardShell channelTitle={youtube?.channelTitle}>
<div className="mx-auto max-w-4xl px-6 py-8">
<h1 className="mb-6 text-2xl font-bold text-white">Settings</h1>
<div className="space-y-6">
<section className="rounded-lg border border-gray-700 bg-surface-light p-6">
<h2 className="mb-4 text-lg font-semibold text-white">Account information</h2>
<h2 className="mb-4 text-lg font-semibold text-white">Account</h2>
<dl className="space-y-3 text-sm">
<InfoRow label="Name" value={user.name || "Not set"} />
<InfoRow label="Email" value={user.email} />
<InfoRow label="Videos created" value={String(user.createdVideoCount)} />
</dl>
<div className="mt-6 border-t border-gray-800 pt-6">
<h3 className="mb-4 text-sm font-semibold uppercase tracking-wider text-gray-500">
YouTube
</h3>
{youtube ? (
<dl className="space-y-3 text-sm">
<InfoRow label="Channel name" value={youtube.channelTitle} />
<InfoRow label="Channel ID" value={youtube.channelId} />
</dl>
) : (
<p className="text-sm text-red-400">YouTube account not connected.</p>
)}
{channelUrl && (
<a
href={channelUrl}
target="_blank"
rel="noopener noreferrer"
className="mt-4 inline-flex rounded bg-accent px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent-hover"
>
Go to your channel
</a>
)}
<p className="mt-4 text-sm text-gray-400">
To refresh your YouTube permissions,{" "}
<Link href="/" className="text-accent hover:underline">
sign out and sign in again
</Link>
.
</p>
</div>
</section>
<section className="rounded-lg border border-gray-700 bg-surface-light p-6">
<h2 className="mb-4 text-lg font-semibold text-white">Plan &amp; billing</h2>
<div className="mb-6 rounded border border-gray-800 bg-surface p-4">
<div className="flex flex-wrap items-end justify-between gap-2">
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-gray-500">
Video quota remaining
</p>
<p className="mt-1 text-2xl font-semibold text-white">
{quota.totalAvailable}
<span className="text-base font-normal text-gray-400">
{" "}
of {quota.limit + quota.extraCredits} videos
</span>
</p>
</div>
<p className="text-sm text-gray-400">
{quota.used} monthly used
{quota.extraCredits > 0 ? ` · ${quota.extraCredits} extras` : ""}
{quota.bonusQuota > 0 ? ` · +${quota.bonusQuota} bonus` : ""}
</p>
</div>
<div className="mt-3 h-2 overflow-hidden rounded bg-gray-800">
<div
className={`h-full rounded ${
quota.totalAvailable <= 0
? "bg-red-500"
: quota.totalAvailable / Math.max(1, quota.limit + quota.extraCredits) <= 0.2
? "bg-amber-500"
: "bg-accent"
}`}
style={{
width: `${Math.min(
100,
Math.round(
((quota.limit + quota.extraCredits - quota.totalAvailable) /
Math.max(1, quota.limit + quota.extraCredits)) *
100,
),
)}%`,
}}
/>
</div>
<p className="mt-2 text-xs text-gray-500">
{`Includes monthly + purchased extras · monthly resets on ${quota.resetsIn}`}
</p>
</div>
<dl className="space-y-3 text-sm">
<InfoRow label="Current plan" value={planLabel} />
<InfoRow
label="Monthly credits"
value={`${quota.monthlyCredits} / cycle`}
/>
<InfoRow label="Extra credits" value={`${quota.extraCredits}`} />
{user.plan === "PREMIUM" && user.subscribedAt && (
<InfoRow
label="Subscribed since"
value={user.subscribedAt.toLocaleDateString(undefined, {
year: "numeric",
month: "long",
day: "numeric",
})}
/>
)}
{user.plan === "PREMIUM" && (
<InfoRow
label="Payment method"
value={user.cardLast4 ? `•••• ${user.cardLast4}` : "No card on file"}
/>
)}
{extensionUsage && (
<InfoRow
label="Extension requests (this year)"
value={`${extensionUsage.used} / ${extensionUsage.limit}`}
/>
)}
<InfoRow label="Max batch size" value={`${quota.maxBatchSize} videos`} />
<InfoRow label="Max resolution" value={`${quota.maxResolutionHeight}p`} />
</dl>
{user.plan === "FREE" ? (
<>
<p className="mt-4 text-sm text-gray-400">
<UpgradeProButton /> for 50 videos/month, 1080p, API access, and lossless audio.
</p>
{!selfHosted && (
<CreditPurchasePanel
initialCredits={user.extraCredits}
stripeConfigured={isStripeConfigured() || isBillingDevMock()}
recentPurchases={recentCreditPurchases.map((p) => ({
id: p.id,
credits: p.credits,
amountCents: p.amountCents,
completedAt: p.completedAt?.toISOString() ?? null,
}))}
/>
)}
</>
<h2 className="mb-4 text-lg font-semibold text-white">YouTube</h2>
{youtube ? (
<dl className="space-y-3 text-sm">
<InfoRow label="Channel name" value={youtube.channelTitle} />
<InfoRow label="Channel ID" value={youtube.channelId} />
</dl>
) : (
<>
{extensionUsage && <PlanBillingActions initialUsage={extensionUsage} />}
<p className="mt-6 border-t border-gray-800 pt-6 text-sm text-gray-400">
Pro uses your monthly allocation first, then any never-expiring extra credits.
</p>
</>
<p className="text-sm text-red-400">YouTube account not connected.</p>
)}
{channelUrl && (
<a
href={channelUrl}
target="_blank"
rel="noopener noreferrer"
className="mt-4 inline-flex rounded bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent-hover"
>
Go to your channel
</a>
)}
<p className="mt-4 text-sm text-gray-400">
To refresh YouTube permissions,{" "}
<Link href="/" className="text-accent hover:underline">sign out and sign in again</Link>.
</p>
</section>
{proFeatures && apiKeyStatus && apiRateLimit && apiRateExtensionUsage && (
<section className="rounded-lg border border-gray-700 bg-surface-light p-6">
<h2 className="mb-4 text-lg font-semibold text-white">API access</h2>
<ApiKeySettings
initialStatus={apiKeyStatus}
initialRateLimit={apiRateLimit}
initialExtensionUsage={apiRateExtensionUsage}
/>
</section>
)}
<section className="rounded-lg border border-gray-700 bg-surface-light p-6">
<h2 className="mb-4 text-lg font-semibold text-white">API key</h2>
<ApiKeySettings initialStatus={apiKeyStatus} initialRateLimit={apiRateLimit} />
</section>
<section className="rounded-lg border border-gray-700 bg-surface-light p-6">
<h2 className="mb-2 text-lg font-semibold text-white">Account deletion &amp; data</h2>
<p className="mb-4 text-sm text-gray-400">
Request a copy of your data or permanently delete your account and associated uploads.
Request a copy of your data or permanently delete your account and uploads.
</p>
<AccountPrivacyActions email={user.email} />
</section>
+3 -62
View File
@@ -2,75 +2,18 @@ import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { Providers } from "./providers";
import { JsonLd } from "@/components/JsonLd";
import { Matomo } from "@/components/Matomo";
const inter = Inter({ subsets: ["latin"] });
const SITE_URL = "https://songs2vid.com";
const SITE_NAME = "Songs2VID";
const DEFAULT_TITLE =
"Songs2VID - Fast Audio to Video Converter for YouTube | TunesToTube Alternative";
const DEFAULT_TITLE = "Songs2VID";
const DEFAULT_DESCRIPTION =
"Convert MP3, WAV, and audio files into YouTube videos instantly. The fastest, privacy-focused online tool to upload music, beats, and podcasts directly to YouTube.";
const OG_TITLE = "Songs2VID - Fast Audio to Video Converter for YouTube";
const OG_DESCRIPTION =
"Convert MP3 & audio files into YouTube videos instantly without heavy editing.";
const TWITTER_DESCRIPTION = "Convert MP3 & audio files into YouTube videos instantly.";
"Self-hosted audio-to-video creation and YouTube uploading.";
export const metadata: Metadata = {
metadataBase: new URL(SITE_URL),
title: {
default: DEFAULT_TITLE,
template: `%s | ${SITE_NAME}`,
},
title: DEFAULT_TITLE,
description: DEFAULT_DESCRIPTION,
keywords: [
"mp3 to youtube",
"audio to video converter",
"upload audio to youtube",
"tunestotube alternative",
"song to video",
"podcast to youtube",
"convert mp3 to mp4",
],
authors: [{ name: SITE_NAME, url: SITE_URL }],
creator: SITE_NAME,
publisher: SITE_NAME,
applicationName: SITE_NAME,
alternates: {
canonical: SITE_URL,
},
openGraph: {
title: OG_TITLE,
description: OG_DESCRIPTION,
url: SITE_URL,
siteName: SITE_NAME,
type: "website",
locale: "en_US",
images: [
{
url: "/logo.png",
width: 512,
height: 512,
alt: SITE_NAME,
},
],
},
twitter: {
card: "summary_large_image",
title: OG_TITLE,
description: TWITTER_DESCRIPTION,
images: ["/logo.png"],
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
},
},
icons: {
icon: "/favicon.png",
},
@@ -84,8 +27,6 @@ export default function RootLayout({
return (
<html lang="en" suppressHydrationWarning>
<body className={inter.className} suppressHydrationWarning>
<JsonLd />
<Matomo />
<Providers>{children}</Providers>
</body>
</html>
+14 -81
View File
@@ -1,94 +1,27 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { BenefitsSection } from "@/components/BenefitsSection";
import { DownloadSection } from "@/components/DownloadSection";
import { LandingNavbar } from "@/components/LandingNavbar";
import { Logo } from "@/components/Logo";
import { SignInButton } from "@/components/SignInButton";
import { PricingSection } from "@/components/PricingSection";
import { StepsSection } from "@/components/StepsSection";
import { Footer } from "@/components/Footer";
import { SupportSection } from "@/components/SupportSection";
export default async function HomePage() {
const session = await getServerSession(authOptions);
if (session) redirect("/dashboard");
return (
<main className="min-h-screen">
<section className="relative min-h-dvh overflow-hidden">
<video
autoPlay
muted
loop
playsInline
className="absolute inset-0 h-full w-full object-cover"
aria-hidden="true"
>
<source src="/bg-video.mp4" type="video/mp4" />
</video>
<div className="absolute inset-0 bg-black/55" aria-hidden="true" />
<div
className="absolute inset-0 bg-gradient-to-b from-black/70 via-black/40 to-black/80"
aria-hidden="true"
/>
<LandingNavbar />
{/* Purpose + brand above the fold for Google OAuth verification */}
<div className="relative z-10 mx-auto flex min-h-dvh max-w-3xl flex-col justify-center px-6 pb-16 pt-28 text-center">
<h1 className="text-5xl font-bold tracking-tight sm:text-6xl lg:text-7xl">
<span className="text-white">Songs</span>
<span className="text-red-400">2VID</span>
</h1>
<p className="mx-auto mt-6 max-w-2xl text-base leading-relaxed text-gray-200 sm:text-lg md:text-xl">
Convert MP3, WAV, and audio files into YouTube videos instantly a fast, privacy-focused
audio to video converter and TunesToTube alternative that uploads music, beats, and
podcasts directly to your channel.
</p>
<div className="mt-10 flex flex-col items-center gap-3">
{session ? (
<Link
href="/dashboard"
className="inline-flex items-center gap-2 rounded bg-accent px-8 py-3.5 font-medium text-white transition-all duration-300 hover:scale-[1.02] hover:bg-accent-hover hover:shadow-lg hover:shadow-accent/20"
>
Go to Dashboard
</Link>
) : (
<>
<SignInButton large />
<p className="max-w-md text-xs leading-relaxed text-gray-400">
By clicking Continue with Google, you accept our{" "}
<Link
href="/terms"
className="text-gray-300 underline-offset-2 transition-colors hover:text-white hover:underline"
>
Terms of Service
</Link>{" "}
and{" "}
<Link
href="/privacy"
className="text-gray-300 underline-offset-2 transition-colors hover:text-white hover:underline"
>
Privacy Policy
</Link>
.
</p>
</>
)}
</div>
<main className="flex min-h-dvh items-center justify-center bg-surface px-6">
<div className="w-full max-w-sm rounded-xl border border-gray-700 bg-surface-light p-8 text-center shadow-xl">
<div className="mb-6 flex justify-center">
<Logo size="lg" />
</div>
</section>
<StepsSection />
<BenefitsSection />
<DownloadSection />
<PricingSection />
<SupportSection />
<Footer />
<SignInButton large />
<p className="mt-5 text-xs leading-relaxed text-gray-500">
By continuing, you accept the{" "}
<Link href="/terms" className="hover:text-gray-300">Terms</Link> and{" "}
<Link href="/privacy" className="hover:text-gray-300">Privacy Policy</Link>.
</p>
</div>
</main>
);
}
+5 -304
View File
@@ -1,314 +1,15 @@
import type { Metadata } from "next";
import { LegalPageLayout } from "@/components/LegalPageLayout";
import { LEGAL_OPERATOR } from "@/lib/legal/constants";
export const metadata: Metadata = {
title: "Privacy Policy",
description: "How Songs2VID collects, uses, and protects your personal data.",
alternates: { canonical: "https://songs2vid.com/privacy" },
};
export default function PrivacyPage() {
return (
<LegalPageLayout
title="Privacy Policy"
description="How we handle your personal data when you use Songs2VID."
>
<h2>1. Overview</h2>
<LegalPageLayout title="Privacy Policy">
<p>
This Privacy Policy explains how {LEGAL_OPERATOR.name} (&quot;we&quot;, &quot;us&quot;)
processes personal data when you use our website, hosted cloud service, and related features
that convert audio and images into videos for upload to YouTube.
Songs2VID is self-hosted software. Your operator controls the deployment, database, uploads,
logs, and Google OAuth configuration. Songs2VID does not include payment processing.
</p>
<p>
We process personal data in accordance with applicable data protection laws, including the
General Data Protection Regulation (GDPR) where it applies.
</p>
<h2>2. Data controller</h2>
<p>
{LEGAL_OPERATOR.legalName}
<br />
{LEGAL_OPERATOR.address}
<br />
{LEGAL_OPERATOR.city}
<br />
Email: <a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a>
</p>
<h2>3. What data we collect</h2>
<h3>3.1 Account and authentication data</h3>
<p>When you sign in with Google, we receive and store:</p>
<ul>
<li>Your name, email address, and profile image (from Google)</li>
<li>OAuth tokens required to authenticate your session</li>
<li>YouTube connection data, including channel ID and title</li>
<li>
Encrypted YouTube API access and refresh tokens needed to upload videos, create or list
playlists, and manage related YouTube actions you request
</li>
</ul>
<h3>3.2 Uploaded content and job data</h3>
<p>When you use the service, we temporarily process:</p>
<ul>
<li>Image and audio files you upload (including optional per-track cover images)</li>
<li>Optional custom watermark assets (text, PNG logo, or font files)</li>
<li>Generated video files prior to or during YouTube upload</li>
<li>
Per-video metadata you provide (title, artist, description, tags, privacy, category,
resolution, layout, watermark settings, Made for Kids / embedding / license flags, etc.)
</li>
<li>
Embedded audio tag metadata (for example ID3 title/artist/album) when we read it from
uploaded MP3 files to help prefill fields
</li>
<li>Playlist titles and IDs when you create or attach YouTube playlists through the Service</li>
</ul>
<h3>3.3 Usage, API, and technical data</h3>
<ul>
<li>
Plan type, monthly credit allocation, credits used, purchased extra credits, quota reset
dates, and job processing status
</li>
<li>
Quota reset / extension and API rate-limit extension request history (Pro) when you submit
a request
</li>
<li>
API key material for Pro users: we store a cryptographic hash and a short non-secret
prefix; the full key is shown once at creation and is not stored in plaintext
</li>
<li>IP address, browser type, device information, and request logs</li>
<li>Error reports and operational diagnostics</li>
<li>
Website and product analytics events collected via Matomo (self-hosted at
analytics.atakanozban.com) to understand traffic and improve the Service see section
10
</li>
</ul>
<h3>3.4 Payment data</h3>
<p>
If you purchase a Pro subscription or Free-plan extra credits, payment processing is handled
by Stripe. We do not store full payment card details on our servers. We may receive and store
billing status, Stripe customer and subscription identifiers, Checkout session or payment
references, purchase amounts, and credit pack size for fulfilled top-ups.
</p>
<h2>4. Why we process your data</h2>
<ul>
<li>
<strong>Contract performance:</strong> to provide video encoding, metadata handling,
playlist actions, API access, and YouTube upload features you request
</li>
<li>
<strong>Legitimate interests:</strong> to secure our service, prevent abuse, improve
reliability, and enforce our terms
</li>
<li>
<strong>Legal obligations:</strong> where required by tax, accounting, or regulatory law
</li>
<li>
<strong>Consent:</strong> where you have given explicit consent, such as optional
marketing communications if offered
</li>
</ul>
<h2>5. Third-party services</h2>
<p>We use trusted third parties to operate Songs2VID, including:</p>
<ul>
<li>
<strong>Google / YouTube:</strong> authentication and video uploads via Google OAuth and
the YouTube Data API. Your use of Google and YouTube is also subject to{" "}
<a
href="https://policies.google.com/privacy"
target="_blank"
rel="noopener noreferrer"
>
Google&apos;s Privacy Policy
</a>
,{" "}
<a
href="https://www.youtube.com/t/terms"
target="_blank"
rel="noopener noreferrer"
>
YouTube Terms of Service
</a>
, and related Google API terms
</li>
<li>
<strong>Hosting and infrastructure providers:</strong> servers, databases, queues, and
storage
</li>
<li>
<strong>Stripe:</strong> Pro subscriptions, Free-plan credit top-ups, and related billing
webhooks (
<a href="https://stripe.com/privacy" target="_blank" rel="noopener noreferrer">
Stripe Privacy Policy
</a>
)
</li>
<li>
<strong>Matomo (self-hosted analytics):</strong> privacy-friendly analytics we operate at
analytics.atakanozban.com to measure visits and improve Songs2VID. Analytics data stays on
infrastructure we control; we do not sell it to advertising networks.
</li>
</ul>
<p>
These providers process data only as necessary to deliver their services and under
appropriate contractual safeguards where required.
</p>
<h2>6. Google / YouTube user data (Limited Use)</h2>
<p>
Songs2VID&apos;s use and transfer to any other app of information received from Google APIs
will adhere to the{" "}
<a
href="https://developers.google.com/terms/api-services-user-data-policy"
target="_blank"
rel="noopener noreferrer"
>
Google API Services User Data Policy
</a>
, including the Limited Use requirements.
</p>
<p>
We request Google OAuth access (including the YouTube Data API scope needed to upload videos
and manage playlists on your connected channel) solely to provide prominent, user-facing
features of Songs2VID: signing you in, connecting your channel, encoding your media, uploading
videos you create, and creating or listing playlists you request. We do not use Google user
data for advertising, credit scoring, or unrelated profiling.
</p>
<p>
We do not sell, share, transfer, or disclose Google user data obtained via Google OAuth /
YouTube APIs to third parties, except as needed to operate the Service infrastructure under
our control or when required by law. Google / YouTube themselves process data when we call
their APIs on your behalf to perform actions you initiate.
</p>
<p>
You can revoke Songs2VID&apos;s access to your Google account at any time in{" "}
<a
href="https://security.google.com/settings/security/permissions"
target="_blank"
rel="noopener noreferrer"
>
Google Account Security Third-party access
</a>
. After revocation (or when tokens can no longer be refreshed), we will stop using those
credentials and delete or invalidate stored YouTube OAuth tokens and related connection data
associated with that consent, subject to short-term backup or security logs and any legal
retention duties.
</p>
<h2>7. Data retention and account deletion</h2>
<ul>
<li>
Uploaded source files and generated outputs are retained only as long as needed to complete
your jobs (typically removed after successful processing/upload or when no longer required)
</li>
<li>Account data is kept while your account remains active</li>
<li>Billing records may be retained as required by law</li>
<li>Logs are retained for a limited period for security and troubleshooting</li>
<li>
API key hashes are removed when you revoke the key, downgrade from Pro (where applicable),
or delete your account
</li>
</ul>
<p>
You may delete your account from <strong>Dashboard Settings</strong> (account deletion
control) or by emailing{" "}
<a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a>. Deletion removes
account and connection data from active systems subject to legal retention obligations
(for example certain billing records). Cancelling a subscription does not by itself delete
your account.
</p>
<h2>8. Self-hosted deployments</h2>
<p>
If you deploy Songs2VID on your own infrastructure, you are the data controller for data
processed on your instance. This Privacy Policy applies to the hosted cloud service
operated by us, not to independent self-hosted installations unless we provide managed
hosting for you under contract.
</p>
<h2>9. Your rights</h2>
<p>Depending on your location, you may have the right to:</p>
<ul>
<li>Access the personal data we hold about you</li>
<li>Request correction or deletion</li>
<li>Restrict or object to certain processing</li>
<li>Data portability</li>
<li>Withdraw consent where processing is consent-based</li>
<li>
Lodge a complaint with a supervisory authority (in Hungary, the Nemzeti Adatvédelmi és
Információszabadság Hatóság NAIH)
</li>
</ul>
<p>
To exercise these rights, contact{" "}
<a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a>, or use in-product
account deletion where available. You may also revoke Google access as described in section
6.
</p>
<h2>10. Cookies, analytics, and local storage</h2>
<p>
We use essential cookies and similar technologies for authentication, session management,
and security.
</p>
<p>
We also use <strong>Matomo</strong>, a self-hosted analytics tool on{" "}
<code>analytics.atakanozban.com</code>, on our website (including the landing page and
dashboard) and documentation site. Matomo helps us understand how people use Songs2VID so we
can improve functionality, reliability, and content. Typical data includes pages viewed,
approximate location derived from IP, device/browser type, referring site, and interaction
events. We configure Matomo for our domains under <code>*.songs2vid.com</code>.
</p>
<p>
We collect this analytics data to improve the Service, not to sell personal profiles to
advertisers. The legal basis is our legitimate interest in operating and improving
Songs2VID (and consent where required by local law). You can block analytics with browser
settings, extensions, or Do Not Track / equivalent controls where supported. For rights
requests related to analytics data, contact{" "}
<a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a>.
</p>
<h2>11. Security</h2>
<p>
We implement appropriate technical and organizational measures to protect your data,
including encryption in transit, encrypted storage of YouTube OAuth tokens at rest, access
controls, and isolated processing environments. No method of transmission or storage is
100% secure.
</p>
<h2>12. International transfers</h2>
<p>
If data is transferred outside your country, we ensure appropriate safeguards such as
standard contractual clauses or equivalent mechanisms where required by law. Google, Stripe,
and infrastructure providers may process data in other countries as described in their
policies.
</p>
<h2>13. Children</h2>
<p>
Songs2VID is not directed at children under 16. We do not knowingly collect personal data
from children. If you believe a child has provided us data, please contact us.
</p>
<h2>14. Changes to this policy</h2>
<p>
We may update this Privacy Policy from time to time. Material changes will be posted on
this page with an updated effective date. If we change how we use Google user data, we will
update this policy and, where required, notify you or obtain renewed consent.
</p>
<h2>15. Contact</h2>
<p>
Questions about this Privacy Policy or our privacy practices:{" "}
<a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a>
Google and YouTube process account and upload data according to their own policies. Contact
the operator of this instance for data access or deletion requests.
</p>
</LegalPageLayout>
);
-119
View File
@@ -1,119 +0,0 @@
import type { Metadata } from "next";
import Link from "next/link";
import { LegalPageLayout } from "@/components/LegalPageLayout";
import { LEGAL_OPERATOR } from "@/lib/legal/constants";
export const metadata: Metadata = {
title: "Refund Policy",
description: "Refund and cancellation policy for Songs2VID paid plans.",
alternates: { canonical: "https://songs2vid.com/refund" },
};
export default function RefundPage() {
return (
<LegalPageLayout
title="Refund Policy"
description="Our policy on refunds, cancellations, and billing for paid plans."
>
<h2>1. Overview</h2>
<p>
This Refund Policy explains how refunds and cancellations work for paid Songs2VID plans and
credit purchases. The free monthly Bedroom Producer allocation does not involve payment and
is not subject to refunds; optional Free-plan extra credit purchases are covered in section
2.
</p>
<h2>2. Free plan and extra credits</h2>
<p>
The Bedroom Producer (Free) monthly allocation is provided at no charge. Free users may
optionally purchase extra video credits (currently 0.25 each, 115 per checkout, Free extras
balance capped at 15). Once extra credits are granted to your account, those one-time
purchases are <strong>generally non-refundable</strong>, except where mandatory consumer law
requires otherwise. Credits trimmed under the account accumulation cap (see our{" "}
<Link href="/terms">Terms of Service</Link>) are not refundable.
</p>
<h2>3. Pro subscriptions</h2>
<h3>3.1 Billing cycle</h3>
<p>
Pro plans are billed on a recurring monthly basis unless otherwise stated at checkout.
Your subscription renews automatically until cancelled.
</p>
<h3>3.2 14-day refund window</h3>
<p>
If you are a new Pro subscriber, you may request a full refund within <strong>14 days</strong> of
your initial purchase, provided you have not substantially consumed paid entitlements
(for example, a large portion of your monthly video quota or premium-only features).
</p>
<h3>3.3 After the refund window</h3>
<p>
After 14 days, subscription fees are generally non-refundable for the current billing
period. You may cancel at any time to prevent future renewals. Access typically continues
until the end of the paid period.
</p>
<h3>3.4 Cancellation</h3>
<p>
You can cancel your subscription through your account billing settings (choose{" "}
<strong>cancel immediately</strong> or <strong>at the end of the billing period</strong>) or
via the Stripe customer portal / by contacting{" "}
<a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a>. Cancelling at period
end keeps Pro access until the paid period finishes and stops future renewals. Cancelling
immediately ends Pro access and moves you to the Free plan right away (including loss of Pro
API access). Cancellation does not automatically delete your account; use{" "}
<strong>Dashboard Settings</strong> or contact us if you want permanent account deletion.
</p>
<h2>4. Enterprise and custom agreements</h2>
<p>
Enterprise, managed cloud, and paid self-hosted setup fees are governed by the individual
quote or contract signed with us. Refund terms for those services are specified in your
agreement. Contact{" "}
<a href={`mailto:${LEGAL_OPERATOR.salesEmail}`}>{LEGAL_OPERATOR.salesEmail}</a> for
contract-related billing questions.
</p>
<h2>5. Non-refundable situations</h2>
<p>Refunds are generally not provided when:</p>
<ul>
<li>The refund request is made outside the applicable refund window</li>
<li>The account was terminated for violation of our <Link href="/terms">Terms of Service</Link></li>
<li>The issue is caused by third-party services outside our control (e.g. YouTube API outages, Google account restrictions)</li>
<li>You simply changed your mind after substantial use of paid quota or features</li>
<li>Purchased Free-plan extra credits have already been granted to your account</li>
<li>Credits expired or were trimmed under the 30-credit accumulation cap</li>
<li>One-time setup or license fees after delivery of agreed setup work, unless required by law or contract</li>
</ul>
<h2>6. Chargebacks</h2>
<p>
If you believe a charge is incorrect, please contact us before initiating a chargeback so
we can resolve the issue promptly. Unjustified chargebacks may result in account suspension.
</p>
<h2>7. How to request a refund</h2>
<p>Email us at <a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a> with:</p>
<ul>
<li>Your account email address</li>
<li>Date of purchase and invoice or transaction reference if available</li>
<li>Reason for the refund request</li>
</ul>
<p>We aim to respond within 5 business days. Approved refunds are issued to the original payment method where possible.</p>
<h2>8. Consumer rights</h2>
<p>
Nothing in this policy limits mandatory statutory rights you may have as a consumer under
applicable law, including withdrawal rights where required by EU or local consumer
protection regulations.
</p>
<h2>9. Changes</h2>
<p>
We may update this Refund Policy from time to time. The version published on this page
applies to purchases made after the effective date shown at the top.
</p>
</LegalPageLayout>
);
}
+5 -251
View File
@@ -1,261 +1,15 @@
import type { Metadata } from "next";
import Link from "next/link";
import { LegalPageLayout } from "@/components/LegalPageLayout";
import { LEGAL_OPERATOR } from "@/lib/legal/constants";
export const metadata: Metadata = {
title: "Terms of Service",
description: "Terms and conditions for using the Songs2VID hosted service.",
alternates: { canonical: "https://songs2vid.com/terms" },
};
export default function TermsPage() {
return (
<LegalPageLayout
title="Terms of Service"
description="Please read these terms carefully before using Songs2VID."
>
<h2>1. Agreement</h2>
<LegalPageLayout title="Terms of Use">
<p>
These Terms of Service (&quot;Terms&quot;) govern your access to and use of the Songs2VID
website and hosted cloud service (the &quot;Service&quot;) operated by{" "}
{LEGAL_OPERATOR.legalName} (&quot;we&quot;, &quot;us&quot;). By creating an account or using
the Service, you agree to these Terms.
Songs2VID is provided as open-source, self-hosted software without warranty. The operator of
each instance is responsible for availability, configuration, and user access.
</p>
<p>
If you do not agree, do not use the Service. If you self-host the open-source software on
your own infrastructure without using our hosted Service, these Terms apply only to the
extent you use our website, support channels, or paid services we provide.
</p>
<h2>2. The Service</h2>
<p>
Songs2VID converts user-provided images and audio files into videos and can upload them to
YouTube using your connected Google/YouTube account. Features, limits, and availability
depend on your plan.
</p>
<ul>
<li>
<strong>Bedroom Producer (Free):</strong> 10 video credits per calendar month, 720p, MP3,
optional Songs2VID watermark (bottom-right), one static cover image per batch; may purchase
limited extra credits as described in Clause 8.2
</li>
<li>
<strong>Independent Artist (Pro):</strong> 50 video credits per month, higher resolution and
batch limits, API access, custom watermark/logo/typography, blurred art-track layouts with
position controls, unique image per
track in bulk uploads, and other features as described on our pricing page (5/month
unless otherwise stated at checkout)
</li>
<li>
<strong>Enterprise (Record Label / Studio):</strong> custom terms as agreed in writing
</li>
</ul>
<p>
We may modify features, limits, or pricing with reasonable notice where required. The
open-source software is provided separately under its applicable open-source license.
</p>
<h2>3. Eligibility and accounts</h2>
<ul>
<li>You must be at least 16 years old or the age required in your jurisdiction</li>
<li>You must have a valid Google account and authorized access to the YouTube channel you connect</li>
<li>You are responsible for maintaining the security of your account and OAuth connection</li>
<li>You must provide accurate information and promptly update it if it changes</li>
</ul>
<h2>4. Your content and responsibilities</h2>
<p>You retain ownership of content you upload. You grant us a limited license to host, process, encode, transmit, and upload your content solely to provide the Service.</p>
<p>You represent and warrant that:</p>
<ul>
<li>You own or have all necessary rights to the content you upload</li>
<li>Your content and use of the Service comply with applicable law and YouTube policies</li>
<li>Your content does not infringe third-party rights or contain unlawful material</li>
<li>You have configured metadata (including &quot;Made for Kids&quot; and privacy settings) accurately</li>
</ul>
<p>
You are solely responsible for content published to your YouTube channel through the
Service.
</p>
<h2>5. Acceptable use</h2>
<p>You agree not to:</p>
<ul>
<li>Use the Service for unlawful, harmful, or abusive purposes</li>
<li>Upload malware, attempt unauthorized access, or interfere with the Service</li>
<li>Circumvent quotas, plan limits, or technical restrictions</li>
<li>Resell or commercially exploit the hosted Service without authorization</li>
<li>Use the Service in a way that violates Google, YouTube, or third-party terms</li>
</ul>
<p>We may suspend or terminate access for violations or risks to the Service or other users.</p>
<h2>6. YouTube and third-party services</h2>
<p>
The Service integrates with Google OAuth and the YouTube Data API to sign you in, connect
your channel, upload videos you create, and create or list playlists you request. Your use
of those services is also subject to{" "}
<a href="https://policies.google.com/terms" target="_blank" rel="noopener noreferrer">
Google Terms of Service
</a>
,{" "}
<a href="https://www.youtube.com/t/terms" target="_blank" rel="noopener noreferrer">
YouTube Terms of Service
</a>
,{" "}
<a
href="https://developers.google.com/youtube/terms/api-services-terms-of-service"
target="_blank"
rel="noopener noreferrer"
>
YouTube API Services Terms
</a>
, and{" "}
<a href="https://policies.google.com/privacy" target="_blank" rel="noopener noreferrer">
Google&apos;s Privacy Policy
</a>
. We are not responsible for changes, outages, quota limits, or enforcement actions taken by
Google or YouTube. How we handle Google user data is described in our{" "}
<Link href="/privacy">Privacy Policy</Link>.
</p>
<h2>7. Open-source software</h2>
<p>
Portions of Songs2VID are available as open-source software. Self-hosting is permitted under
the applicable open-source license. The hosted Service, enterprise features, managed
infrastructure, and certain premium capabilities may require a separate commercial license
or subscription.
</p>
<h2>8. Subscription, Billing &amp; Credits</h2>
<p>
Paid plans and credit top-ups are billed according to the pricing displayed at the time of
purchase. Taxes may apply. Payments are processed by Stripe. Subscriptions renew
automatically unless cancelled in accordance with our{" "}
<Link href="/refund">Refund Policy</Link>. Failure to pay may result in downgrade or
suspension.
</p>
<p>
<strong>Credit usage order:</strong> when you create video jobs, we deduct from your current
monthly allocation first, then from any purchased extra credits.
</p>
<h3>8.1 Pro plan quota and rate-limit extensions</h3>
<p>
Independent Artist (Pro) subscribers receive <strong>50 video credits</strong> per
subscription month. Unused credits from prior cycles may roll over into the next cycle
subject to the accumulation cap in Clause 8.3. Renewal and rollover occur on successful
Stripe subscription renewal (typically aligned with your billing period).
</p>
<p>
Pro subscribers may request a manual video-quota reset or temporary extension, and/or an API
rate-limit extension, via account settings or by emailing{" "}
<a href={`mailto:${LEGAL_OPERATOR.quotaRequestEmail}`}>
{LEGAL_OPERATOR.quotaRequestEmail}
</a>
. Each Pro account is entitled to up to{" "}
<strong>five (5) video-quota reset or extension requests per calendar year</strong> and up
to <strong>five (5) API rate-limit extension requests per calendar year</strong>. We review
requests in good faith and may decline requests that are abusive, repetitive without cause,
or inconsistent with fair use. Approved requests do not increase those annual limits.
</p>
<h3>8.2 Free plan extra credits</h3>
<p>
Bedroom Producer (Free) accounts receive <strong>10 video credits</strong> per calendar
month. Free users may purchase additional never-expiring extra credits at the price shown at
checkout (currently <strong>0.25 per credit</strong>), in packs of{" "}
<strong>1 to 15 credits</strong> per purchase. On the Free plan, the extras balance may not
exceed <strong>15 credits</strong>. After monthly and extra credits are exhausted, further
uploads require upgrading to Pro (or waiting for the next monthly reset). Extra credits are
available only on the Free plan purchase flow; Pro includes its monthly allocation and does
not sell the same Free top-up packs.
</p>
<h3>8.3 Credit rollover &amp; accumulation cap</h3>
<p>
Unused credits from previous cycles may roll over, but the total accumulated balance on any
account (remaining monthly allocation + extra credits) shall strictly not exceed{" "}
<strong>30 credits</strong> at any given time. Upon renewal (or when applying a new monthly
allocation), any excess above this 30-credit threshold is automatically trimmed and expires
permanently, without entitlement to refund or compensation.
</p>
<h3>8.4 Pro API access</h3>
<p>
Independent Artist (Pro) subscribers may generate an API key in account settings to upload
files and create batch video jobs programmatically. API access requires an active Pro
subscription, a connected YouTube account, and compliance with the same quota, file-type, and
resolution limits as the web interface. API keys are personal, must be kept confidential, and
may be revoked by you or by us if misused. We may apply rate limits and suspend API access
for abuse, security incidents, or plan downgrades. If your subscription ends or you are
moved to the Free plan, API keys and Pro-only API access are revoked or disabled.
</p>
<h2>9. Availability and support</h2>
<p>
We strive for high availability but do not guarantee uninterrupted access. Maintenance,
updates, and outages may occur. Support levels depend on your plan. Self-hosted DIY
deployments without a paid setup are community-supported unless otherwise agreed in
writing.
</p>
<h2>10. Disclaimer of warranties</h2>
<p>
THE SERVICE IS PROVIDED &quot;AS IS&quot; AND &quot;AS AVAILABLE&quot; TO THE MAXIMUM EXTENT
PERMITTED BY LAW. WE DISCLAIM ALL WARRANTIES, EXPRESS OR IMPLIED, INCLUDING MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. WE DO NOT WARRANT THAT ENCODING,
UPLOADS, OR METADATA TRANSFER WILL BE ERROR-FREE OR UNINTERRUPTED.
</p>
<h2>11. Limitation of liability</h2>
<p>
TO THE MAXIMUM EXTENT PERMITTED BY LAW, WE SHALL NOT BE LIABLE FOR INDIRECT, INCIDENTAL,
SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES, OR ANY LOSS OF PROFITS, REVENUE, DATA, OR
GOODWILL. OUR TOTAL LIABILITY FOR ANY CLAIM ARISING OUT OF THESE TERMS OR THE SERVICE IS
LIMITED TO THE AMOUNT YOU PAID US IN THE TWELVE (12) MONTHS BEFORE THE EVENT GIVING RISE TO
THE CLAIM, OR EUR 100 IF YOU USE THE FREE PLAN ONLY.
</p>
<p>
Some jurisdictions do not allow certain limitations, so some of the above may not apply to
you.
</p>
<h2>12. Indemnification</h2>
<p>
You agree to indemnify and hold us harmless from claims arising out of your content, your
use of the Service, or your violation of these Terms or applicable law.
</p>
<h2>13. Termination and account deletion</h2>
<p>
You may stop using the Service at any time. You may cancel a paid subscription (see our{" "}
<Link href="/refund">Refund Policy</Link>) without deleting your account. To permanently
delete your account and associated active data, use the account deletion control in{" "}
<strong>Dashboard Settings</strong> or contact{" "}
<a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a>. We may suspend or
terminate your access if you breach these Terms, create risk or legal exposure, or where
required by law. Upon termination or deletion, your right to use the hosted Service ends.
Provisions that by nature should survive will survive. Deletion is subject to legal
retention obligations described in our <Link href="/privacy">Privacy Policy</Link>.
</p>
<h2>14. Governing law</h2>
<p>
These Terms are governed by the laws of Hungary, excluding conflict
of law rules. Courts in Hungary shall have exclusive jurisdiction unless
mandatory consumer protection laws in your country provide otherwise.
</p>
<h2>15. Changes</h2>
<p>
We may update these Terms from time to time. Continued use after changes become effective
constitutes acceptance of the revised Terms, where permitted by law.
</p>
<h2>16. Contact</h2>
<p>
Questions about these Terms:{" "}
<a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a>
You are responsible for the media you process and upload, including compliance with
copyright law and YouTube&apos;s terms.
</p>
</LegalPageLayout>
);