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
+25 -65
View File
@@ -1,15 +1,13 @@
import fs from "fs/promises";
import path from "path";
import { Plan, Privacy } from "@prisma/client";
import { Privacy } from "@prisma/client";
import { readAudioTags } from "../audio-tags";
import { filenameWithoutExtension, isAllowedResolution } from "../constants";
import { prisma } from "../db";
import { hasProFeatures } from "../edition";
import {
assertArtTrackLayoutAllowed,
assertCustomWatermarkAllowed,
assertPerItemImagesAllowed,
PremiumRequiredError,
} from "../entitlements";
import { FONT_UPLOAD_MAX_BYTES } from "../fonts";
import { assertFontFile } from "../fonts-server";
@@ -28,7 +26,7 @@ import {
isAudioExtensionAllowed,
isResolutionAllowedForPlan,
} from "../plans";
import { releaseReservationSplit, reserveQuota } from "../quota";
import { reserveQuota } from "../quota";
import { getJobDir } from "../storage";
import {
assertPathInUserUploads,
@@ -88,9 +86,8 @@ export function resolveLayoutFromMetadata(metadata: ItemMetadata): LayoutSetting
export function validateItemMetadata(
metadata: CreateJobPayload["items"][0]["metadata"],
plan: Plan,
) {
const titleErr = validateYouTubeTitle(metadata, plan);
const titleErr = validateYouTubeTitle(metadata);
if (titleErr) return titleErr;
if (!isAllowedResolution(metadata.resolution)) return "Invalid resolution";
if (!["PUBLIC", "PRIVATE", "UNLISTED"].includes(metadata.privacy)) {
@@ -116,7 +113,7 @@ export function validateItemMetadata(
return null;
}
export async function validateJobPayload(user: { id: string; plan: Plan }, body: CreateJobPayload) {
export async function validateJobPayload(user: { id: string }, body: CreateJobPayload) {
if (!body.imagePath || !body.items?.length) {
return "Image and at least one audio file required";
}
@@ -124,13 +121,10 @@ export async function validateJobPayload(user: { id: string; plan: Plan }, body:
const itemImagePaths: Array<string | null | undefined> = [];
for (const item of body.items) {
const metaError = validateItemMetadata(item.metadata, user.plan);
const metaError = validateItemMetadata(item.metadata);
if (metaError) return metaError;
if (!isResolutionAllowedForPlan(item.metadata.resolution, user.plan)) {
return `Resolution ${item.metadata.resolution} is not available on your plan`;
}
if (item.metadata.playlistId && !hasProFeatures(user.plan)) {
return "Adding videos to a YouTube playlist requires the Pro plan";
if (!isResolutionAllowedForPlan(item.metadata.resolution)) {
return `Resolution ${item.metadata.resolution} is not available`;
}
const wm = normalizeWatermarkSettings(
@@ -138,10 +132,9 @@ export async function validateJobPayload(user: { id: string; plan: Plan }, body:
item.metadata.includeWatermark,
);
try {
assertCustomWatermarkAllowed(user.plan, wm);
assertArtTrackLayoutAllowed(user.plan, resolveLayoutFromMetadata(item.metadata));
assertCustomWatermarkAllowed(wm);
assertArtTrackLayoutAllowed(resolveLayoutFromMetadata(item.metadata));
} catch (err) {
if (err instanceof PremiumRequiredError) return err.message;
if (err instanceof Error && err.message === INVALID_LAYOUT_TEMPLATE_MESSAGE) {
return INVALID_LAYOUT_TEMPLATE_MESSAGE;
}
@@ -152,13 +145,12 @@ export async function validateJobPayload(user: { id: string; plan: Plan }, body:
}
try {
assertPerItemImagesAllowed(user.plan, body.imagePath, itemImagePaths);
assertPerItemImagesAllowed(body.imagePath, itemImagePaths);
} catch (err) {
if (err instanceof PremiumRequiredError) return err.message;
throw err;
}
const limits = getPlanLimits(user.plan);
const limits = getPlanLimits();
try {
const imagePath = assertPathInUserUploads(user.id, body.imagePath);
@@ -169,8 +161,8 @@ export async function validateJobPayload(user: { id: string; plan: Plan }, body:
}
for (const item of body.items) {
if (!isAudioExtensionAllowed(item.audioFilename, user.plan)) {
return `Audio file ${item.audioFilename} is not supported on your plan`;
if (!isAudioExtensionAllowed(item.audioFilename)) {
return `Audio file ${item.audioFilename} is not supported`;
}
const audioPath = assertPathInUserUploads(user.id, item.audioPath);
await fs.access(audioPath);
@@ -218,7 +210,6 @@ export async function validateJobPayload(user: { id: string; plan: Plan }, body:
}
}
} catch (err) {
if (err instanceof PremiumRequiredError) return err.message;
if (err instanceof Error && err.message === "Invalid upload path") {
return "Invalid upload path";
}
@@ -239,7 +230,7 @@ export async function validateJobPayload(user: { id: string; plan: Plan }, body:
}
export async function createVideoJob(
user: { id: string; plan: Plan },
user: { id: string },
body: CreateJobPayload,
) {
const validationError = await validateJobPayload(user, body);
@@ -247,21 +238,9 @@ export async function createVideoJob(
if (validationError === INVALID_LAYOUT_TEMPLATE_MESSAGE) {
throw new Error(validationError);
}
const isPremium =
validationError.includes("requires Pro") ||
validationError.includes("Pro plan");
const err = isPremium
? new PremiumRequiredError(validationError)
: new Error(validationError);
throw err;
throw new Error(validationError);
}
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: { createdVideoCount: true },
});
const createdVideoCount = dbUser?.createdVideoCount ?? 0;
const imagePath = assertPathInUserUploads(user.id, body.imagePath);
const items = body.items.map((item) => ({
...item,
@@ -278,7 +257,7 @@ export async function createVideoJob(
: null,
}));
const reservation = await reserveQuota(user.id, items.length);
await reserveQuota(user.id, items.length);
try {
const job = await prisma.job.create({
@@ -296,21 +275,13 @@ export async function createVideoJob(
}
: null,
item.metadata.includeWatermark,
{
plan: user.plan,
createdVideoCount,
itemIndex: index,
},
);
const layout = resolveLayoutFromMetadata(item.metadata);
const pro = hasProFeatures(user.plan);
const youtubeTitle = resolveYouTubeTitle(item.metadata, user.plan);
const burnedTitle = pro
? resolveBurnedSongTitle(
item.metadata,
filenameWithoutExtension(item.audioFilename),
)
: null;
const youtubeTitle = resolveYouTubeTitle(item.metadata);
const burnedTitle = resolveBurnedSongTitle(
item.metadata,
filenameWithoutExtension(item.audioFilename),
);
return {
audioPath: item.audioPath,
audioFilename: item.audioFilename,
@@ -336,9 +307,7 @@ export async function createVideoJob(
watermarkPosition: wm.position,
watermarkOffsetX: wm.offsetX,
watermarkOffsetY: wm.offsetY,
artist: pro
? item.metadata.artist?.trim().slice(0, ARTIST_MAX) || null
: null,
artist: item.metadata.artist?.trim().slice(0, ARTIST_MAX) || null,
layoutTemplate: layout.template,
blurAmount: layout.blurAmount,
blurOpacity: layout.blurOpacity,
@@ -347,7 +316,6 @@ export async function createVideoJob(
textOffsetX: layout.textOffsetX,
textOffsetY: layout.textOffsetY,
playlistId: item.metadata.playlistId?.trim() || null,
billingSource: index < reservation.fromQuota ? "QUOTA" : "CREDIT",
};
}),
},
@@ -430,7 +398,6 @@ export async function createVideoJob(
return job;
} catch (err) {
await releaseReservationSplit(user.id, reservation).catch(() => {});
throw err;
}
}
@@ -441,15 +408,11 @@ export async function saveUploadedFile(
userId: string,
file: File,
type: UploadFileType,
plan: Plan,
options?: { sessionKey?: string },
) {
const limits = getPlanLimits(plan);
const limits = getPlanLimits();
if (type === "font") {
if (!limits.customWatermark && !hasProFeatures(plan)) {
throw new PremiumRequiredError("Custom watermark fonts require Pro.");
}
if (file.size > FONT_UPLOAD_MAX_BYTES) {
throw new Error("Font file must be 10 MB or smaller");
}
@@ -461,9 +424,6 @@ export async function saveUploadedFile(
}
if (type === "logo") {
if (!limits.customWatermark && !hasProFeatures(plan)) {
throw new PremiumRequiredError("Custom logo watermarks require Pro.");
}
const nameOk = /\.png$/i.test(file.name);
const typeOk = file.type === "image/png" || file.type === "";
if (!nameOk && !typeOk) {
@@ -498,9 +458,9 @@ export async function saveUploadedFile(
) {
throw new Error("Invalid audio file type");
}
if (!isAudioExtensionAllowed(file.name, plan)) {
if (!isAudioExtensionAllowed(file.name)) {
const allowed = limits.allowedAudioExtensions.join(", ");
throw new Error(`Your plan supports ${allowed} audio files only`);
throw new Error(`Supported audio formats: ${allowed}`);
}
}
+1 -7
View File
@@ -1,5 +1,3 @@
import type { Plan } from "@prisma/client";
import { hasProFeatures } from "../edition";
import type { CreateJobPayload, CreatePlaylistRequest, ItemMetadata } from "../types";
import { createYouTubePlaylist } from "../youtube/upload";
@@ -22,7 +20,7 @@ export function parseCreatePlaylistInput(raw: unknown): CreatePlaylistRequest |
}
export async function applyCreatePlaylistToItems(
user: { id: string; plan: Plan },
user: { id: string },
items: CreateJobPayload["items"],
createPlaylist: CreatePlaylistRequest | null | undefined,
) {
@@ -30,10 +28,6 @@ export async function applyCreatePlaylistToItems(
return { items, playlist: null as Awaited<ReturnType<typeof createYouTubePlaylist>> | null };
}
if (!hasProFeatures(user.plan)) {
throw new Error("Creating a YouTube playlist requires the Pro plan");
}
const playlist = await createYouTubePlaylist(user.id, createPlaylist);
const nextItems = items.map((item) => ({
...item,