Initial open-source self-hosted Songs2YT edition

This commit is contained in:
Songs2YT
2026-07-17 02:23:59 +02:00
commit 2aa8a84781
134 changed files with 17758 additions and 0 deletions
+148
View File
@@ -0,0 +1,148 @@
import { NextRequest, NextResponse } from "next/server";
import { Privacy } from "@prisma/client";
import { requirePaidApiUser } from "@/lib/api-auth";
import { createVideoJob, saveUploadedFile } from "@/lib/jobs/create-job";
import {
applyCreatePlaylistToItems,
parseCreatePlaylistInput,
} from "@/lib/jobs/resolve-playlist";
import { filenameWithoutExtension } from "@/lib/constants";
import { mapWithConcurrency } from "@/lib/fs-utils";
import { getPlanLimits } from "@/lib/plans";
import { checkQuota } from "@/lib/quota";
import type { CreateJobPayload, CreatePlaylistRequest, ItemMetadata } from "@/lib/types";
export const maxDuration = 300;
type BatchItemInput = Partial<ItemMetadata> & {
filename?: string;
title?: string;
};
function defaultMetadata(title: string): ItemMetadata {
return {
title,
description: "",
tags: "",
privacy: "PUBLIC",
categoryId: "10",
resolution: "1920x1080",
notifySubscribers: true,
madeForKids: false,
embeddable: true,
creativeCommons: false,
includeWatermark: false,
playlistId: null,
};
}
export async function POST(req: NextRequest) {
try {
const { error, user } = await requirePaidApiUser(req);
if (error || !user) return error!;
const formData = await req.formData();
const image = formData.get("image") as File | null;
const audioFiles = formData.getAll("audio").filter((f): f is File => f instanceof File);
const metadataRaw = formData.get("metadata");
if (!image || audioFiles.length === 0) {
return NextResponse.json(
{ error: "Provide multipart fields: image (file), audio (one or more files)" },
{ status: 400 },
);
}
const limits = getPlanLimits(user.plan);
if (audioFiles.length > limits.maxBatchSize) {
return NextResponse.json(
{ error: `Batch limit exceeded. Your plan allows up to ${limits.maxBatchSize} files per batch.` },
{ status: 400 },
);
}
const quotaCheck = await checkQuota(user.id, audioFiles.length);
if (!quotaCheck.ok) {
return NextResponse.json({ error: quotaCheck.error }, { status: 403 });
}
let itemMeta: BatchItemInput[] = [];
let defaults: Partial<ItemMetadata> = {};
let createPlaylist: CreatePlaylistRequest | null = null;
if (metadataRaw) {
try {
const parsed = JSON.parse(String(metadataRaw)) as {
items?: BatchItemInput[];
defaults?: Partial<ItemMetadata>;
createPlaylist?: unknown;
};
itemMeta = parsed.items ?? [];
defaults = parsed.defaults ?? {};
createPlaylist = parseCreatePlaylistInput(parsed.createPlaylist);
if (parsed.createPlaylist && !createPlaylist) {
return NextResponse.json(
{
error:
"createPlaylist requires a title. Optional: description, privacy (public|unlisted|private)",
},
{ status: 400 },
);
}
} catch {
return NextResponse.json({ error: "metadata must be valid JSON" }, { status: 400 });
}
}
const sessionKey = Date.now().toString();
const imageUpload = await saveUploadedFile(user.id, image, "image", user.plan, { sessionKey });
const uploads = await mapWithConcurrency(audioFiles, 4, (audio) =>
saveUploadedFile(user.id, audio, "audio", user.plan, { sessionKey }),
);
const builtItems: CreateJobPayload["items"] = uploads.map((upload, i) => {
const audio = audioFiles[i];
const metaInput = itemMeta[i] ?? itemMeta.find((m) => m.filename === audio.name) ?? {};
const base = defaultMetadata(
metaInput.title?.trim() || filenameWithoutExtension(audio.name),
);
const metadata: ItemMetadata = {
...base,
...defaults,
...metaInput,
title: (metaInput.title ?? defaults.title ?? base.title).trim(),
privacy: (metaInput.privacy ?? defaults.privacy ?? base.privacy) as Privacy,
};
return {
audioPath: upload.path,
audioFilename: upload.filename,
metadata,
};
});
const { items, playlist } = await applyCreatePlaylistToItems(user, builtItems, createPlaylist);
const job = await createVideoJob(user, {
imagePath: imageUpload.path,
items,
});
return NextResponse.json({
jobId: job.id,
itemCount: job.items.length,
status: job.status,
playlist: playlist
? { id: playlist.id, title: playlist.title, privacy: playlist.privacy }
: null,
});
} catch (err) {
console.error("Batch upload failed:", err);
const message = err instanceof Error ? err.message : "Batch upload failed";
const status = message.includes("Quota exceeded") || message.includes("Batch limit exceeded")
? 403
: 500;
return NextResponse.json({ error: message }, { status });
}
}