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:
+25
-65
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user