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.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Atakan Doğan Özban
2026-08-03 06:52:00 +02:00
co-authored by Cursor
parent 935abfb22e
commit 925aadae75
108 changed files with 397 additions and 8061 deletions
+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>