Initial open-source self-hosted Songs2YT edition
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { hasProFeatures } from "@/lib/edition";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { DashboardShell } from "@/components/DashboardShell";
|
||||
import { UpgradeProLink } from "@/components/UpgradeProLink";
|
||||
|
||||
function CodeBlock({ children }: { children: string }) {
|
||||
return (
|
||||
<pre className="overflow-x-auto rounded-lg border border-gray-800 bg-black/40 p-4 text-xs leading-relaxed text-gray-300">
|
||||
<code>{children}</code>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function ApiDocsPage() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.email) redirect("/");
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: session.user.email },
|
||||
include: { youtubeConnection: true },
|
||||
});
|
||||
if (!user) redirect("/");
|
||||
|
||||
const isPro = hasProFeatures(user.plan);
|
||||
|
||||
return (
|
||||
<DashboardShell channelTitle={user.youtubeConnection?.channelTitle}>
|
||||
<div className="mx-auto max-w-3xl space-y-8">
|
||||
<div>
|
||||
<Link
|
||||
href="/dashboard/settings"
|
||||
className="text-sm text-gray-400 transition-colors hover:text-white"
|
||||
>
|
||||
← Back to settings
|
||||
</Link>
|
||||
<h1 className="mt-4 text-2xl font-bold text-white">REST API</h1>
|
||||
<p className="mt-2 text-sm text-gray-400">
|
||||
Programmatic uploads and batch jobs for Pro subscribers. Generate your API key in{" "}
|
||||
<Link href="/dashboard/settings" className="text-accent hover:underline">
|
||||
Settings
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
{!isPro && (
|
||||
<p className="mt-3 rounded border border-accent/30 bg-accent/10 px-4 py-3 text-sm text-accent">
|
||||
API access requires Pro. <UpgradeProLink />
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-semibold text-white">Authentication</h2>
|
||||
<p className="text-sm text-gray-400">
|
||||
Send your API key on every request. Keys start with <code className="text-gray-300">s2yt_live_</code>.
|
||||
</p>
|
||||
<CodeBlock>{`Authorization: Bearer s2yt_live_your_key_here`}</CodeBlock>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-semibold text-white">Rate limits</h2>
|
||||
<p className="text-sm text-gray-400">
|
||||
Default is 60 requests per minute per account. Approved rate-limit extensions increase
|
||||
that ceiling. Check live usage and request an extension in{" "}
|
||||
<Link href="/dashboard/settings" className="text-accent hover:underline">
|
||||
Settings → API access
|
||||
</Link>
|
||||
. Video quota limits from your Pro plan still apply to job creation.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-semibold text-white">Choosing a flow</h2>
|
||||
<div className="rounded border border-gray-800 bg-surface p-4 text-sm text-gray-400 space-y-3">
|
||||
<p>
|
||||
<span className="font-medium text-white">Recommended for most jobs (especially 5+ audio files):</span>{" "}
|
||||
two-step: upload each file with <code className="text-gray-300">POST /api/v1/upload</code>, then
|
||||
create the job with <code className="text-gray-300">POST /api/v1/jobs</code> (JSON).
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-medium text-white">One-shot batch</span>{" "}
|
||||
(<code className="text-gray-300">POST /api/v1/jobs/batch</code>) is for small packs only, typically
|
||||
1 cover + up to a few audio files. Large multipart bodies often fail with{" "}
|
||||
<code className="text-gray-300">failed to parse body as FormData</code>. Prefer two-step for albums
|
||||
or long tracklists.
|
||||
</p>
|
||||
<ul className="list-disc space-y-1 pl-5">
|
||||
<li>Do not set <code className="text-gray-300">Content-Type</code> manually for multipart; the client must include the boundary.</li>
|
||||
<li>For Postman: Body → form-data, each audio row key must be exactly <code className="text-gray-300">audio</code> (type File).</li>
|
||||
<li>If a file field shows a warning triangle, re-select the file from disk.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-semibold text-white">Discovery</h2>
|
||||
<CodeBlock>{`GET /api/v1`}</CodeBlock>
|
||||
<p className="text-sm text-gray-400">Returns endpoint list and requirements (no auth).</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-semibold text-white">1. Upload a file (two-step)</h2>
|
||||
<CodeBlock>{`curl -X POST "$BASE_URL/api/v1/upload" \\
|
||||
-H "Authorization: Bearer $API_KEY" \\
|
||||
-F "file=@cover.jpg" \\
|
||||
-F "type=image"`}</CodeBlock>
|
||||
<CodeBlock>{`curl -X POST "$BASE_URL/api/v1/upload" \\
|
||||
-H "Authorization: Bearer $API_KEY" \\
|
||||
-F "file=@track1.mp3" \\
|
||||
-F "type=audio"`}</CodeBlock>
|
||||
<p className="text-sm text-gray-400">
|
||||
Response includes <code className="text-gray-300">path</code>,{" "}
|
||||
<code className="text-gray-300">filename</code>, and{" "}
|
||||
<code className="text-gray-300">size</code>. Upload the image once, then each audio file. Keep the{" "}
|
||||
<code className="text-gray-300">path</code> values for the next step.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-semibold text-white">2. Create job from paths (recommended)</h2>
|
||||
<p className="text-sm text-gray-400">
|
||||
Use the exact <code className="text-gray-300">path</code> strings returned by upload. Add one{" "}
|
||||
<code className="text-gray-300">items[]</code> entry per track.
|
||||
</p>
|
||||
<CodeBlock>{`curl -X POST "$BASE_URL/api/v1/jobs" \\
|
||||
-H "Authorization: Bearer $API_KEY" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"imagePath": "/uploads/.../cover.jpg",
|
||||
"items": [{
|
||||
"audioPath": "/uploads/.../track.mp3",
|
||||
"audioFilename": "track.mp3",
|
||||
"metadata": {
|
||||
"title": "My Track",
|
||||
"description": "",
|
||||
"tags": "electronic",
|
||||
"privacy": "PUBLIC",
|
||||
"categoryId": "10",
|
||||
"resolution": "1920x1080",
|
||||
"notifySubscribers": true,
|
||||
"madeForKids": false,
|
||||
"embeddable": true,
|
||||
"creativeCommons": false,
|
||||
"includeWatermark": false,
|
||||
"playlistId": null
|
||||
}
|
||||
}]
|
||||
}'`}</CodeBlock>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-semibold text-white">YouTube playlists (Pro)</h2>
|
||||
<p className="text-sm text-gray-400">
|
||||
List existing playlists, create a new one, or create one inline when starting a job.
|
||||
Pass <code className="text-gray-300">playlistId</code> in item metadata / batch{" "}
|
||||
<code className="text-gray-300">defaults</code>, or use{" "}
|
||||
<code className="text-gray-300">createPlaylist</code> to make a playlist and attach all
|
||||
videos to it. Privacy may be <code className="text-gray-300">public</code>,{" "}
|
||||
<code className="text-gray-300">unlisted</code>, or{" "}
|
||||
<code className="text-gray-300">private</code>. If playlist permission was just added,
|
||||
sign out and sign in again for <code className="text-gray-300">youtube.force-ssl</code>.
|
||||
</p>
|
||||
<CodeBlock>{`# List playlists
|
||||
curl "$BASE_URL/api/v1/playlists" \\
|
||||
-H "Authorization: Bearer $API_KEY"`}</CodeBlock>
|
||||
<CodeBlock>{`# Create a playlist
|
||||
curl -X POST "$BASE_URL/api/v1/playlists" \\
|
||||
-H "Authorization: Bearer $API_KEY" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"title":"My Album","description":"From Songs2YT","privacy":"unlisted"}'`}</CodeBlock>
|
||||
<CodeBlock>{`# Use an existing playlist ID in metadata / defaults:
|
||||
{ "playlistId": "PLxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" }
|
||||
|
||||
# Or create one inline with a job / batch request:
|
||||
{
|
||||
"createPlaylist": {
|
||||
"title": "My Album",
|
||||
"description": "Uploaded via Songs2YT",
|
||||
"privacy": "private"
|
||||
},
|
||||
"defaults": { "privacy": "PUBLIC" },
|
||||
"items": [{ "title": "Track One" }]
|
||||
}`}</CodeBlock>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-semibold text-white">One-shot batch (small packs only)</h2>
|
||||
<p className="text-sm text-gray-400">
|
||||
Upload one cover image and a few audio files in a single multipart request. Not recommended for
|
||||
large batches: use two-step instead if you see FormData parse errors.
|
||||
</p>
|
||||
<CodeBlock>{`curl -X POST "$BASE_URL/api/v1/jobs/batch" \\
|
||||
-H "Authorization: Bearer $API_KEY" \\
|
||||
-F "image=@cover.jpg" \\
|
||||
-F "audio=@track1.mp3" \\
|
||||
-F "audio=@track2.mp3" \\
|
||||
-F 'metadata={"createPlaylist":{"title":"My Album","privacy":"unlisted"},"defaults":{"privacy":"PUBLIC"},"items":[{"title":"Track One"},{"title":"Track Two"}]}'`}</CodeBlock>
|
||||
<p className="text-sm text-gray-400">
|
||||
Optional <code className="text-gray-300">metadata</code> JSON supports{" "}
|
||||
<code className="text-gray-300">defaults</code> applied to every item and per-item overrides in{" "}
|
||||
<code className="text-gray-300">items</code>. Item order should match the order of{" "}
|
||||
<code className="text-gray-300">audio</code> files.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-semibold text-white">Poll job status</h2>
|
||||
<CodeBlock>{`curl "$BASE_URL/api/v1/jobs/JOB_ID" \\
|
||||
-H "Authorization: Bearer $API_KEY"`}</CodeBlock>
|
||||
<CodeBlock>{`curl "$BASE_URL/api/v1/jobs?limit=10" \\
|
||||
-H "Authorization: Bearer $API_KEY"`}</CodeBlock>
|
||||
</section>
|
||||
</div>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { DashboardShell } from "@/components/DashboardShell";
|
||||
import { JobHistory } from "@/components/JobHistory";
|
||||
|
||||
export default async function HistoryPage() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) redirect("/");
|
||||
|
||||
return (
|
||||
<DashboardShell channelTitle={session.user.channelTitle}>
|
||||
<div className="mx-auto max-w-4xl px-6 py-8">
|
||||
<h1 className="mb-6 text-2xl font-bold text-white">History</h1>
|
||||
<JobHistory />
|
||||
</div>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { DashboardShell } from "@/components/DashboardShell";
|
||||
import { RecentYouTubeLimitAlert } from "@/components/RecentYouTubeLimitAlert";
|
||||
import { UploadForm } from "@/components/UploadForm";
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) redirect("/");
|
||||
|
||||
return (
|
||||
<DashboardShell channelTitle={session.user.channelTitle}>
|
||||
<div className="mx-auto max-w-4xl px-6 py-8">
|
||||
<h1 className="mb-6 text-2xl font-bold text-white">Create Videos</h1>
|
||||
<RecentYouTubeLimitAlert />
|
||||
<UploadForm />
|
||||
</div>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { AccountPrivacyActions } from "@/components/AccountPrivacyActions";
|
||||
import { PlanBillingActions } from "@/components/PlanBillingActions";
|
||||
import { DashboardShell } from "@/components/DashboardShell";
|
||||
import { UpgradeProLink } from "@/components/UpgradeProLink";
|
||||
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";
|
||||
|
||||
const PLAN_LABELS = {
|
||||
FREE: "Bedroom Producer",
|
||||
PREMIUM: "Pro",
|
||||
} as const;
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt className="text-gray-400">{label}</dt>
|
||||
<dd className="text-right text-white">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function SettingsPage() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.email) redirect("/");
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: session.user.email },
|
||||
include: { youtubeConnection: true },
|
||||
});
|
||||
if (!user) redirect("/");
|
||||
|
||||
const quota = await getQuotaInfo(user.id);
|
||||
const selfHosted = isSelfHostedEdition();
|
||||
const proFeatures = hasProFeatures(user.plan);
|
||||
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 youtube = user.youtubeConnection;
|
||||
const channelUrl = youtube ? `https://www.youtube.com/channel/${youtube.channelId}` : null;
|
||||
|
||||
return (
|
||||
<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>
|
||||
<dl className="space-y-3 text-sm">
|
||||
<InfoRow label="Name" value={user.name || "Not set"} />
|
||||
<InfoRow label="Email" value={user.email} />
|
||||
</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">
|
||||
{selfHosted ? "Usage" : "Plan & 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">
|
||||
Videos processed
|
||||
</p>
|
||||
<p className="mt-1 text-2xl font-semibold text-white">
|
||||
{selfHosted ? (
|
||||
quota.used
|
||||
) : (
|
||||
<>
|
||||
{quota.remaining}
|
||||
<span className="text-base font-normal text-gray-400">
|
||||
{" "}
|
||||
of {quota.limit} videos
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{!selfHosted && (
|
||||
<p className="text-sm text-gray-400">
|
||||
{quota.used} used
|
||||
{quota.bonusQuota > 0 ? ` · +${quota.bonusQuota} bonus` : ""}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{!selfHosted && (
|
||||
<>
|
||||
<div className="mt-3 h-2 overflow-hidden rounded bg-gray-800">
|
||||
<div
|
||||
className={`h-full rounded ${
|
||||
quota.remaining <= 0
|
||||
? "bg-red-500"
|
||||
: quota.remaining / Math.max(1, quota.limit) <= 0.2
|
||||
? "bg-amber-500"
|
||||
: "bg-accent"
|
||||
}`}
|
||||
style={{
|
||||
width: `${Math.min(100, Math.round((quota.used / Math.max(1, quota.limit)) * 100))}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-500">
|
||||
{user.plan === "PREMIUM"
|
||||
? `Monthly quota resets on ${quota.resetsIn}`
|
||||
: `Quota resets in ${quota.resetsIn}`}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{selfHosted && (
|
||||
<p className="mt-2 text-xs text-gray-500">
|
||||
Self-hosted edition: no video or API quotas.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<dl className="space-y-3 text-sm">
|
||||
<InfoRow label="Current plan" value={planLabel} />
|
||||
{!selfHosted && user.plan === "PREMIUM" && user.subscribedAt && (
|
||||
<InfoRow
|
||||
label="Subscribed since"
|
||||
value={user.subscribedAt.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
{!selfHosted && 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>
|
||||
|
||||
{!selfHosted && user.plan === "FREE" && (
|
||||
<p className="mt-4 text-sm text-gray-400">
|
||||
<UpgradeProLink /> for more videos, 1080p, and lossless audio.
|
||||
</p>
|
||||
)}
|
||||
{!selfHosted && user.plan === "PREMIUM" && extensionUsage && (
|
||||
<PlanBillingActions initialUsage={extensionUsage} />
|
||||
)}
|
||||
</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-2 text-lg font-semibold text-white">Account deletion & 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.
|
||||
</p>
|
||||
<AccountPrivacyActions email={user.email} />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user