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 506d56fa92
commit 6476bb42a4
108 changed files with 397 additions and 8061 deletions
@@ -1,90 +1,88 @@
-- CreateEnum
CREATE TYPE "Plan" AS ENUM ('FREE', 'PREMIUM');
-- CreateEnum
CREATE TYPE "Privacy" AS ENUM ('PUBLIC', 'PRIVATE', 'UNLISTED');
-- CreateEnum
CREATE TYPE "JobStatus" AS ENUM ('PENDING', 'PROCESSING', 'COMPLETED', 'FAILED', 'PARTIAL');
-- CreateEnum
CREATE TYPE "JobItemStatus" AS ENUM ('PENDING', 'ENCODING', 'UPLOADING', 'COMPLETED', 'FAILED');
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"name" TEXT,
"image" TEXT,
"plan" "Plan" NOT NULL DEFAULT 'FREE',
"videosUsed" INTEGER NOT NULL DEFAULT 0,
"quotaResetAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"name" TEXT,
"image" TEXT,
"createdVideoCount" INTEGER NOT NULL DEFAULT 0,
"apiKeyHash" TEXT,
"apiKeyPrefix" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "YouTubeConnection" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"accessToken" TEXT NOT NULL,
"refreshToken" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"channelId" TEXT NOT NULL,
"channelTitle" TEXT NOT NULL,
CONSTRAINT "YouTubeConnection_pkey" PRIMARY KEY ("id")
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"accessToken" TEXT NOT NULL,
"refreshToken" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"channelId" TEXT NOT NULL,
"channelTitle" TEXT NOT NULL,
CONSTRAINT "YouTubeConnection_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Job" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"status" "JobStatus" NOT NULL DEFAULT 'PENDING',
"imagePath" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"completedAt" TIMESTAMP(3),
CONSTRAINT "Job_pkey" PRIMARY KEY ("id")
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"status" "JobStatus" NOT NULL DEFAULT 'PENDING',
"imagePath" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"completedAt" TIMESTAMP(3),
CONSTRAINT "Job_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "JobItem" (
"id" TEXT NOT NULL,
"jobId" TEXT NOT NULL,
"audioPath" TEXT NOT NULL,
"audioFilename" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT NOT NULL DEFAULT '',
"tags" TEXT NOT NULL DEFAULT '',
"privacy" "Privacy" NOT NULL DEFAULT 'PUBLIC',
"categoryId" TEXT NOT NULL DEFAULT '10',
"resolution" TEXT NOT NULL DEFAULT '1280x720',
"notifySubscribers" BOOLEAN NOT NULL DEFAULT true,
"madeForKids" BOOLEAN NOT NULL DEFAULT false,
"embeddable" BOOLEAN NOT NULL DEFAULT true,
"creativeCommons" BOOLEAN NOT NULL DEFAULT false,
"includeWatermark" BOOLEAN NOT NULL DEFAULT true,
"status" "JobItemStatus" NOT NULL DEFAULT 'PENDING',
"outputPath" TEXT,
"youtubeVideoId" TEXT,
"error" TEXT,
CONSTRAINT "JobItem_pkey" PRIMARY KEY ("id")
"id" TEXT NOT NULL,
"jobId" TEXT NOT NULL,
"audioPath" TEXT NOT NULL,
"audioFilename" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT NOT NULL DEFAULT '',
"tags" TEXT NOT NULL DEFAULT '',
"privacy" "Privacy" NOT NULL DEFAULT 'PUBLIC',
"categoryId" TEXT NOT NULL DEFAULT '10',
"resolution" TEXT NOT NULL DEFAULT '1280x720',
"notifySubscribers" BOOLEAN NOT NULL DEFAULT true,
"madeForKids" BOOLEAN NOT NULL DEFAULT false,
"embeddable" BOOLEAN NOT NULL DEFAULT true,
"creativeCommons" BOOLEAN NOT NULL DEFAULT false,
"includeWatermark" BOOLEAN NOT NULL DEFAULT false,
"itemImagePath" TEXT,
"watermarkMode" TEXT NOT NULL DEFAULT 'none',
"watermarkText" TEXT,
"watermarkLogoPath" TEXT,
"watermarkFontKey" TEXT,
"watermarkFontPath" TEXT,
"watermarkPosition" TEXT NOT NULL DEFAULT 'bottom-right',
"watermarkOffsetX" INTEGER NOT NULL DEFAULT 20,
"watermarkOffsetY" INTEGER NOT NULL DEFAULT 20,
"artist" TEXT,
"songTitle" TEXT,
"layoutTemplate" TEXT,
"blurAmount" INTEGER NOT NULL DEFAULT 55,
"blurOpacity" INTEGER NOT NULL DEFAULT 100,
"textPadding" INTEGER NOT NULL DEFAULT 48,
"titleArtistGap" INTEGER NOT NULL DEFAULT 10,
"textOffsetX" INTEGER NOT NULL DEFAULT 0,
"textOffsetY" INTEGER NOT NULL DEFAULT 0,
"playlistId" TEXT,
"status" "JobItemStatus" NOT NULL DEFAULT 'PENDING',
"outputPath" TEXT,
"youtubeVideoId" TEXT,
"error" TEXT,
CONSTRAINT "JobItem_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "User_apiKeyHash_key" ON "User"("apiKeyHash");
CREATE UNIQUE INDEX "YouTubeConnection_userId_key" ON "YouTubeConnection"("userId");
-- AddForeignKey
ALTER TABLE "YouTubeConnection" ADD CONSTRAINT "YouTubeConnection_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Job" ADD CONSTRAINT "Job_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "JobItem" ADD CONSTRAINT "JobItem_jobId_fkey" FOREIGN KEY ("jobId") REFERENCES "Job"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "YouTubeConnection" ADD CONSTRAINT "YouTubeConnection_userId_fkey"
FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Job" ADD CONSTRAINT "Job_userId_fkey"
FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "JobItem" ADD CONSTRAINT "JobItem_jobId_fkey"
FOREIGN KEY ("jobId") REFERENCES "Job"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -1,3 +0,0 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "cardLast4" TEXT,
ADD COLUMN "subscribedAt" TIMESTAMP(3);
@@ -1,21 +0,0 @@
-- CreateEnum
CREATE TYPE "QuotaExtensionRequestStatus" AS ENUM ('PENDING', 'APPROVED', 'REJECTED');
-- AlterTable
ALTER TABLE "User" ADD COLUMN "bonusQuota" INTEGER NOT NULL DEFAULT 0;
-- CreateTable
CREATE TABLE "QuotaExtensionRequest" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"status" "QuotaExtensionRequestStatus" NOT NULL DEFAULT 'PENDING',
"message" TEXT NOT NULL DEFAULT '',
"requestedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"processedAt" TIMESTAMP(3),
"adminNote" TEXT,
CONSTRAINT "QuotaExtensionRequest_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "QuotaExtensionRequest" ADD CONSTRAINT "QuotaExtensionRequest_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -1,6 +0,0 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "apiKeyHash" TEXT,
ADD COLUMN "apiKeyPrefix" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "User_apiKeyHash_key" ON "User"("apiKeyHash");
@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "JobItem" ADD COLUMN "playlistId" TEXT;
@@ -1,8 +0,0 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "apiRateLimitBonus" INTEGER NOT NULL DEFAULT 0;
-- CreateEnum
CREATE TYPE "QuotaExtensionKind" AS ENUM ('VIDEO_QUOTA', 'API_RATE_LIMIT');
-- AlterTable
ALTER TABLE "QuotaExtensionRequest" ADD COLUMN "kind" "QuotaExtensionKind" NOT NULL DEFAULT 'VIDEO_QUOTA';
@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "JobItem" ADD COLUMN "songTitle" TEXT;
@@ -1,43 +0,0 @@
-- CreateTable
CREATE TABLE "FooterBadge" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"embedHtml" TEXT,
"imageUrl" TEXT,
"linkUrl" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "FooterBadge_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "FooterBadge_isActive_sortOrder_idx" ON "FooterBadge"("isActive", "sortOrder");
-- Seed current Product Hunt + LaunchBuff badges
INSERT INTO "FooterBadge" ("id", "name", "embedHtml", "imageUrl", "linkUrl", "isActive", "sortOrder", "createdAt", "updatedAt")
VALUES
(
'seed_producthunt',
'Product Hunt',
'<a href="https://www.producthunt.com/products/songs2vid?embed=true&utm_source=badge-featured&utm_medium=badge&utm_campaign=badge-songs2vid" target="_blank" rel="noopener noreferrer"><img alt="Songs2VID - Bulk render audio & artworks into videos in seconds | Product Hunt" width="250" height="54" src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1206681&theme=dark&t=1785422871345" /></a>',
'https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1206681&theme=dark&t=1785422871345',
'https://www.producthunt.com/products/songs2vid?embed=true&utm_source=badge-featured&utm_medium=badge&utm_campaign=badge-songs2vid',
true,
0,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
),
(
'seed_launchbuff',
'LaunchBuff',
'<a href="https://launchbuff.com" target="_blank" rel="noopener noreferrer" title="Featured on LaunchBuff"><img src="https://launchbuff.com/badge-featured-dark.svg" alt="Featured on LaunchBuff" width="256" height="80" /></a>',
'https://launchbuff.com/badge-featured-dark.svg',
'https://launchbuff.com',
true,
1,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
);
@@ -1,10 +0,0 @@
-- AlterEnum
CREATE TYPE "SubscriptionStatus" AS ENUM ('free', 'pro');
-- AlterTable
ALTER TABLE "User" ADD COLUMN "subscriptionStatus" "SubscriptionStatus" NOT NULL DEFAULT 'free';
ALTER TABLE "User" ADD COLUMN "createdVideoCount" INTEGER NOT NULL DEFAULT 0;
-- Backfill from existing Plan
UPDATE "User" SET "subscriptionStatus" = 'pro' WHERE "plan" = 'PREMIUM';
UPDATE "User" SET "subscriptionStatus" = 'free' WHERE "plan" = 'FREE';
+12 -124
View File
@@ -7,17 +7,6 @@ datasource db {
url = env("DATABASE_URL")
}
enum Plan {
FREE
PREMIUM
}
/// Public-facing subscription tier (kept in sync with `plan`: FREE→free, PREMIUM→pro)
enum SubscriptionStatus {
free
pro
}
enum Privacy {
PUBLIC
PRIVATE
@@ -40,86 +29,17 @@ enum JobItemStatus {
FAILED
}
enum QuotaExtensionRequestStatus {
PENDING
APPROVED
REJECTED
}
enum QuotaExtensionKind {
VIDEO_QUOTA
API_RATE_LIMIT
}
model User {
id String @id @default(cuid())
email String @unique
name String?
image String?
/// FREE = free tier; PREMIUM = Pro (€5/mo, formerly starter_5eur)
plan Plan @default(FREE)
/// Mirrors plan for product/UI (`free` | `pro`). Prefer `plan` for entitlements.
subscriptionStatus SubscriptionStatus @default(free)
/// Lifetime count of successfully completed video renders (not monthly quota).
createdVideoCount Int @default(0)
/// Credits consumed in the current billing cycle (credits_used)
videosUsed Int @default(0)
/// Monthly allocation for the current cycle (10 free / 50 pro)
monthlyCredits Int @default(10)
quotaResetAt DateTime
cardLast4 String?
subscribedAt DateTime?
bonusQuota Int @default(0)
apiRateLimitBonus Int @default(0)
/// Purchased extras that never expire (maps to legacy "videoCredits" column)
extraCredits Int @default(0) @map("videoCredits")
/// Free-tier one-time +15 top-up already purchased
freeTopUpPurchased Boolean @default(false)
stripeCustomerId String?
stripeSubscriptionId String?
apiKeyHash String? @unique
apiKeyPrefix String?
youtubeConnection YouTubeConnection?
jobs Job[]
quotaExtensionRequests QuotaExtensionRequest[]
creditPurchases CreditPurchase[]
createdAt DateTime @default(now())
}
enum CreditPurchaseStatus {
PENDING
COMPLETED
FAILED
}
model CreditPurchase {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
credits Int
amountCents Int
status CreditPurchaseStatus @default(PENDING)
stripeSessionId String? @unique
stripePaymentIntentId String?
createdAt DateTime @default(now())
completedAt DateTime?
}
enum JobItemBilling {
QUOTA
CREDIT
}
model QuotaExtensionRequest {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
kind QuotaExtensionKind @default(VIDEO_QUOTA)
status QuotaExtensionRequestStatus @default(PENDING)
message String @default("")
requestedAt DateTime @default(now())
processedAt DateTime?
adminNote String?
id String @id @default(cuid())
email String @unique
name String?
image String?
createdVideoCount Int @default(0)
apiKeyHash String? @unique
apiKeyPrefix String?
youtubeConnection YouTubeConnection?
jobs Job[]
createdAt DateTime @default(now())
}
model YouTubeConnection {
@@ -160,60 +80,28 @@ model JobItem {
madeForKids Boolean @default(false)
embeddable Boolean @default(true)
creativeCommons Boolean @default(false)
includeWatermark Boolean @default(true)
/// Optional per-item cover (Pro). Falls back to Job.imagePath when null.
includeWatermark Boolean @default(false)
itemImagePath String?
/// none | default | text | logo custom modes require Pro
watermarkMode String @default("default")
watermarkMode String @default("none")
watermarkText String?
watermarkLogoPath String?
/// curated key | custom | system
watermarkFontKey String?
/// uploaded .ttf/.otf when fontKey=custom
watermarkFontPath String?
/// top-left | top-right | bottom-left | bottom-right | center
watermarkPosition String @default("bottom-right")
watermarkOffsetX Int @default(20)
watermarkOffsetY Int @default(20)
/// Optional artist line for art-track layouts (Pro, on-video only)
artist String?
/// Song title burned into art-track layouts (Pro, on-video only)
songTitle String?
/// COVER_LEFT_TEXT_RIGHT | COVER_TOP_TEXT_BOTTOM | COVER_RIGHT_TEXT_LEFT | CENTERED_COMPACT | null=classic
layoutTemplate String?
/// 0100 Gaussian / boxblur intensity (Pro art-track)
blurAmount Int @default(55)
/// 0100 visibility of blurred fill vs black
blurOpacity Int @default(100)
/// Padding around cover + text in art-track layouts
textPadding Int @default(48)
/// Extra gap between title and artist lines (px)
titleArtistGap Int @default(10)
/// Fine-tune text block within template (-120..120)
textOffsetX Int @default(0)
textOffsetY Int @default(0)
playlistId String?
billingSource JobItemBilling @default(QUOTA)
status JobItemStatus @default(PENDING)
outputPath String?
youtubeVideoId String?
error String?
}
/// Site footer badge / launch-directory embeds (managed via /admin)
model FooterBadge {
id String @id @default(cuid())
name String
/// Raw paste from Product Hunt / LaunchBuff / etc. (preferred source)
embedHtml String? @db.Text
/// Parsed or manually set image URL (used when embedHtml is empty)
imageUrl String?
/// Parsed or manually set destination URL
linkUrl String?
isActive Boolean @default(true)
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([isActive, sortOrder])
}