- Turn your cover art + audio files into YouTube videos automatically. No manual rendering BS.
- The ultimate life-saver for creators who waste hours opening Premiere/Vegas just to upload a single track.
+ Turn your cover art + audio files into YouTube videos automatically.
+ Art-track layouts, blur backgrounds, typography, watermark studio, and per-track covers β unlocked for self-hosting.
---
-## π₯ What's the deal?
+## What's the deal?
-**Songs2VID** is a self-hosted automation beast that takes your audio files (MP3/WAV) and cover images, smashes them together with FFmpeg in a background queue, and uploads them straight to your YouTube channel.
+**Songs2VID** (npm package `songs2yt`) is a self-hosted automation app that takes your audio files (MP3/WAV/FLAC) and cover images, encodes them with FFmpeg in a background queue, and uploads them to your YouTube channel.
This is the **open-source (self-hosted) edition**:
-* π« **Zero limits:** No video upload quotas, no API rate limit bullshit.
-* π **Fully Unlocked:** Playlists and REST API are completely unlocked (`S2VID_EDITION=selfhosted`).
-* βοΈ **Full Control:** Run it on your own server. Your data, your rules.
+* **Zero practical limits:** no video quotas, credits, or Stripe paywalls
+* **Fully unlocked:** playlists, REST API, art-track layouts, custom watermarks/fonts, per-track covers (`S2VID_EDITION=selfhosted`)
+* **Your infra:** run it on your own server
-> Check out the hosted version: [songs2vid.com](https://songs2vid.com)
+> Hosted cloud product: [songs2vid.com](https://songs2vid.com) Β· Docs: [docs.songs2vid.com](https://docs.songs2vid.com) Β· API: [docs.songs2vid.com/docs/api/overview](https://docs.songs2vid.com/docs/api/overview)

+
---
-## π οΈ Requirements
+## Requirements
-* **Docker & Docker Compose** (highly recommended, saves your mental health)
+* **Docker & Docker Compose** (recommended)
* *Or:* Node 20+, Postgres, Redis, FFmpeg
* Google Cloud OAuth client with **YouTube Data API v3** enabled
---
-## π Quick Start (Docker)
-
-Skip the setup headache. Copy the env, spin it up, and go touch grass:
+## Quick Start (Docker)
```bash
-# Copy env template
cp .env.example .env
-
# Fill in GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, NEXTAUTH_SECRET, and NEXTAUTH_URL
-nano .env
-
-# Fire up the engine
docker compose up -d --build
```
+
* Open `http://localhost:3000`
-* OAuth Redirect URL on Google Console: `{NEXTAUTH_URL}/api/auth/callback/google`
+* OAuth redirect URL: `{NEXTAUTH_URL}/api/auth/callback/google`
+* `S2VID_EDITION=selfhosted` is set by compose (no Stripe keys required)
-## π» Local Development
-
-Want to hack on the codebase? Follow this:
+## Local Development
```bash
-# 1. Prepare env
cp .env.example .env
-
-# 2. Spin up Postgres & Redis
docker compose -f docker-compose.dev.yml up -d
-
-# 3. Get dependencies
npm install
-
-# 4. Push database schema
npm run db:push
-
-# 5. Run it (open two terminals)
-npm run dev # Terminal 1 (Next.js)
-npm run worker # Terminal 2 (BullMQ/FFmpeg Worker)
+npm run dev # Terminal 1
+npm run worker # Terminal 2
```
-## ποΈ Stack
+Optional: fetch curated watermark fonts into `assets/fonts/` with `node scripts/fetch-watermark-fonts.mjs`.
-* Next.js (Frontend & API)
-* Postgres (Database)
-* Redis / BullMQ (Background render queue)
-* NextAuth (Google OAuth)
-* FFmpeg (The engine merging audio + image)
-* YouTube Data API (The rocket launcher pushing to YT)
+## Features
-## π License
+* Batch audio β YouTube video jobs
+* Art-track layout templates + blur background + fine-tuning offsets
+* Watermark studio (text / logo / curated + custom fonts)
+* Per-track cover images and artist field
+* YouTube playlists + REST API (`/api/v1`)
-This project is licensed under the MIT License.
\ No newline at end of file
+## Stack
+
+* Next.js Β· Postgres Β· Redis / BullMQ Β· NextAuth (Google) Β· FFmpeg Β· YouTube Data API
+
+## Links
+
+* Docs: https://docs.songs2vid.com
+* Hosted: https://songs2vid.com
+* Gitea: https://git.atakanozban.com/Songs2YT/songs2yt
+* Docker Hub: https://hub.docker.com/r/atakanozban/songs2yt
+
+## License
+
+MIT
diff --git a/app/api/account/api-rate-limit-extension-request/route.ts b/app/api/account/api-rate-limit-extension-request/route.ts
deleted file mode 100644
index 78c95ca..0000000
--- a/app/api/account/api-rate-limit-extension-request/route.ts
+++ /dev/null
@@ -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 });
- }
-}
diff --git a/app/api/account/cancel-subscription/route.ts b/app/api/account/cancel-subscription/route.ts
deleted file mode 100644
index 5c40f1c..0000000
--- a/app/api/account/cancel-subscription/route.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { NextResponse } from "next/server";
-import { prisma } from "@/lib/db";
-import { getInitialQuotaResetAt } from "@/lib/quota";
-import { getSessionUser } from "@/lib/session";
-
-export async function POST() {
- 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 });
- }
-
- await prisma.user.update({
- where: { id: user.id },
- data: {
- plan: "FREE",
- cardLast4: null,
- subscribedAt: null,
- apiKeyHash: null,
- apiKeyPrefix: null,
- apiRateLimitBonus: 0,
- quotaResetAt: getInitialQuotaResetAt(),
- },
- });
-
- return NextResponse.json({ ok: true });
-}
diff --git a/app/api/account/quota-extension-request/route.ts b/app/api/account/quota-extension-request/route.ts
deleted file mode 100644
index 2111378..0000000
--- a/app/api/account/quota-extension-request/route.ts
+++ /dev/null
@@ -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 });
- }
-}
diff --git a/app/api/admin/quota-extension-request/[id]/route.ts b/app/api/admin/quota-extension-request/[id]/route.ts
deleted file mode 100644
index 48bdde5..0000000
--- a/app/api/admin/quota-extension-request/[id]/route.ts
+++ /dev/null
@@ -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 });
- }
-}
diff --git a/app/api/upload/route.ts b/app/api/upload/route.ts
index 8262965..1abdf42 100644
--- a/app/api/upload/route.ts
+++ b/app/api/upload/route.ts
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
-import { saveUploadedFile } from "@/lib/jobs/create-job";
+import { PremiumRequiredError, premiumRequiredResponse } from "@/lib/entitlements";
+import { saveUploadedFile, type UploadFileType } from "@/lib/jobs/create-job";
import { getSessionUser } from "@/lib/session";
export const maxDuration = 120;
@@ -15,14 +16,26 @@ export async function POST(req: NextRequest) {
const type = formData.get("type") as string | null;
const sessionKey = (formData.get("session") as string | null)?.trim() || undefined;
- if (!file || !type || (type !== "image" && type !== "audio")) {
- return NextResponse.json({ error: "Missing file or type" }, { status: 400 });
+ if (
+ !file ||
+ !type ||
+ (type !== "image" && type !== "audio" && type !== "logo" && type !== "font")
+ ) {
+ return NextResponse.json(
+ { error: "Missing file or type (image | audio | logo | font)" },
+ { status: 400 },
+ );
}
try {
- const result = await saveUploadedFile(user.id, file, type, user.plan, { sessionKey });
+ const result = await saveUploadedFile(user.id, file, type as UploadFileType, user.plan, {
+ sessionKey,
+ });
return NextResponse.json(result);
} catch (err) {
+ if (err instanceof PremiumRequiredError) {
+ return NextResponse.json(premiumRequiredResponse(err.message), { status: 403 });
+ }
const message = err instanceof Error ? err.message : "Upload failed";
return NextResponse.json({ error: message }, { status: 400 });
}
diff --git a/app/api/v1/route.ts b/app/api/v1/route.ts
index f00f845..32bfcf9 100644
--- a/app/api/v1/route.ts
+++ b/app/api/v1/route.ts
@@ -1,12 +1,13 @@
import { NextResponse } from "next/server";
+import { API_DOCS_URL } from "@/lib/plans";
export async function GET() {
return NextResponse.json({
- name: "Songs2YT API",
+ name: "Songs2VID API",
version: "1.0",
authentication: "Authorization: Bearer ",
- requirements: ["Pro subscription", "YouTube account connected"],
- rateLimit: "60 requests per minute per account (plus any approved bonus)",
+ requirements: ["Self-hosted edition", "YouTube account connected"],
+ rateLimit: "Generous self-hosted limits (see docs)",
guidance: {
recommended:
"For most jobs (especially 5+ audio files): POST /api/v1/upload per file, then POST /api/v1/jobs with the returned paths",
@@ -36,7 +37,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",
@@ -56,6 +57,6 @@ export async function GET() {
description: "Get job status and item details",
},
],
- docs: "/dashboard/api-docs",
+ docs: API_DOCS_URL,
});
}
diff --git a/app/api/v1/upload/route.ts b/app/api/v1/upload/route.ts
index 454b12a..2809834 100644
--- a/app/api/v1/upload/route.ts
+++ b/app/api/v1/upload/route.ts
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { requirePaidApiUser } from "@/lib/api-auth";
-import { saveUploadedFile } from "@/lib/jobs/create-job";
+import { PremiumRequiredError, premiumRequiredResponse } from "@/lib/entitlements";
+import { saveUploadedFile, type UploadFileType } from "@/lib/jobs/create-job";
export const maxDuration = 120;
@@ -12,17 +13,24 @@ export async function POST(req: NextRequest) {
const file = formData.get("file") as File | null;
const type = formData.get("type") as string | null;
- if (!file || !type || (type !== "image" && type !== "audio")) {
+ if (
+ !file ||
+ !type ||
+ (type !== "image" && type !== "audio" && type !== "logo" && type !== "font")
+ ) {
return NextResponse.json(
- { error: "Provide multipart fields: file, type (image|audio)" },
+ { error: "Provide multipart fields: file, type (image|audio|logo|font)" },
{ status: 400 },
);
}
try {
- const result = await saveUploadedFile(user.id, file, type, user.plan);
+ const result = await saveUploadedFile(user.id, file, type as UploadFileType, user.plan);
return NextResponse.json(result);
} catch (err) {
+ if (err instanceof PremiumRequiredError) {
+ return NextResponse.json(premiumRequiredResponse(err.message), { status: 403 });
+ }
const message = err instanceof Error ? err.message : "Upload failed";
return NextResponse.json({ error: message }, { status: 400 });
}
diff --git a/app/dashboard/api-docs/page.tsx b/app/dashboard/api-docs/page.tsx
deleted file mode 100644
index 6f13ade..0000000
--- a/app/dashboard/api-docs/page.tsx
+++ /dev/null
@@ -1,219 +0,0 @@
-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 (
-
- Default is 60 requests per minute per account. Approved rate-limit extensions increase
- that ceiling. Check live usage and request an extension in{" "}
-
- Settings β API access
-
- . Video quota limits from your Pro plan still apply to job creation.
-
-
-
-
-
Choosing a flow
-
-
- Recommended for most jobs (especially 5+ audio files):{" "}
- two-step: upload each file with POST /api/v1/upload, then
- create the job with POST /api/v1/jobs (JSON).
-
-
- One-shot batch{" "}
- (POST /api/v1/jobs/batch) is for small packs only, typically
- 1 cover + up to a few audio files. Large multipart bodies often fail with{" "}
- failed to parse body as FormData. Prefer two-step for albums
- or long tracklists.
-
-
-
Do not set Content-Type manually for multipart; the client must include the boundary.
-
For Postman: Body β form-data, each audio row key must be exactly audio (type File).
-
If a file field shows a warning triangle, re-select the file from disk.
- Response includes path,{" "}
- filename, and{" "}
- size. Upload the image once, then each audio file. Keep the{" "}
- path values for the next step.
-
-
-
-
-
2. Create job from paths (recommended)
-
- Use the exact path strings returned by upload. Add one{" "}
- items[] entry per track.
-
- List existing playlists, create a new one, or create one inline when starting a job.
- Pass playlistId in item metadata / batch{" "}
- defaults, or use{" "}
- createPlaylist to make a playlist and attach all
- videos to it. Privacy may be public,{" "}
- unlisted, or{" "}
- private. If playlist permission was just added,
- sign out and sign in again for youtube.force-ssl.
-
- {`# List playlists
-curl "$BASE_URL/api/v1/playlists" \\
- -H "Authorization: Bearer $API_KEY"`}
- {`# 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"}'`}
- {`# 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" }]
-}`}
-
-
-
-
One-shot batch (small packs only)
-
- 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.
-
- Optional metadata JSON supports{" "}
- defaults applied to every item and per-item overrides in{" "}
- items. Item order should match the order of{" "}
- audio files.
-
diff --git a/app/page.tsx b/app/page.tsx
index 4ff6d01..3c72c4b 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -5,10 +5,10 @@ import { BenefitsSection } from "@/components/BenefitsSection";
import { DownloadSection } from "@/components/DownloadSection";
import { LandingNavbar } from "@/components/LandingNavbar";
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";
+import { DOCS_URL, WEBSITE_URL } from "@/lib/plans";
export default async function HomePage() {
const session = await getServerSession(authOptions);
@@ -34,14 +34,13 @@ export default async function HomePage() {
- {/* Purpose + brand above the fold for Google OAuth verification */}
- Songs2YT
+ Songs2VID
- Songs2YT is an automation tool that converts your audio and image files into
- high-quality videos and uploads them directly to YouTube.
+ Self-hosted automation that turns your audio and cover art into YouTube videos β
+ including art-track layouts, typography, and watermark studio. No quotas or paywalls.