Initial open-source self-hosted Songs2YT edition
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createUserApiKey, getUserApiKeyStatus, revokeUserApiKey } from "@/lib/api-keys";
|
||||
import { hasProFeatures } from "@/lib/edition";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
|
||||
async function requireApiKeyAccess() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return { error: NextResponse.json({ error: "Unauthorized" }, { status: 401 }), user: null };
|
||||
}
|
||||
if (!hasProFeatures(user.plan)) {
|
||||
return {
|
||||
error: NextResponse.json(
|
||||
{ error: "API keys are available on the Pro plan only" },
|
||||
{ status: 403 },
|
||||
),
|
||||
user: null,
|
||||
};
|
||||
}
|
||||
return { error: null, user };
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const { error, user } = await requireApiKeyAccess();
|
||||
if (error || !user) return error!;
|
||||
|
||||
const status = await getUserApiKeyStatus(user.id);
|
||||
return NextResponse.json(status);
|
||||
}
|
||||
|
||||
export async function POST() {
|
||||
const { error, user } = await requireApiKeyAccess();
|
||||
if (error || !user) return error!;
|
||||
|
||||
const { token, prefix } = await createUserApiKey(user.id);
|
||||
return NextResponse.json({
|
||||
apiKey: token,
|
||||
prefix,
|
||||
message: "Copy this key now. It will not be shown again.",
|
||||
});
|
||||
}
|
||||
|
||||
export async function DELETE() {
|
||||
const { error, user } = await requireApiKeyAccess();
|
||||
if (error || !user) return error!;
|
||||
|
||||
await revokeUserApiKey(user.id);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getApiRateLimitStatus } from "@/lib/api-rate-limit";
|
||||
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: "API rate limits are available on the Pro plan only" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const status = await getApiRateLimitStatus(user.id);
|
||||
return NextResponse.json(status);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
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 });
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
|
||||
export async function POST() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
await prisma.user.delete({ where: { id: user.id } });
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import NextAuth from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
|
||||
const handler = NextAuth(authOptions);
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireAuth } from "@/lib/session";
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { error, user } = await requireAuth();
|
||||
if (error || !user) return error!;
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const job = await prisma.job.findFirst({
|
||||
where: { id, userId: user.id },
|
||||
include: {
|
||||
items: {
|
||||
orderBy: { audioFilename: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
audioFilename: true,
|
||||
title: true,
|
||||
description: true,
|
||||
tags: true,
|
||||
privacy: true,
|
||||
categoryId: true,
|
||||
resolution: true,
|
||||
status: true,
|
||||
youtubeVideoId: true,
|
||||
error: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!job) {
|
||||
return NextResponse.json({ error: "Job not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
id: job.id,
|
||||
status: job.status,
|
||||
createdAt: job.createdAt.toISOString(),
|
||||
completedAt: job.completedAt?.toISOString() ?? null,
|
||||
items: job.items,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { createVideoJob } from "@/lib/jobs/create-job";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireAuth } from "@/lib/session";
|
||||
import type { CreateJobPayload } from "@/lib/types";
|
||||
|
||||
export const maxDuration = 120;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { error, user } = await requireAuth();
|
||||
if (error || !user) return error!;
|
||||
|
||||
const body = (await req.json()) as CreateJobPayload;
|
||||
|
||||
try {
|
||||
const job = await createVideoJob(user, body);
|
||||
return NextResponse.json({ jobId: job.id });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to create job";
|
||||
const status = message.includes("Quota exceeded") ? 403 : 400;
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const { error, user } = await requireAuth();
|
||||
if (error || !user) return error!;
|
||||
|
||||
const limit = Math.min(Number(req.nextUrl.searchParams.get("limit")) || 20, 100);
|
||||
|
||||
const jobs = await prisma.job.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
include: {
|
||||
items: {
|
||||
select: {
|
||||
id: true,
|
||||
audioFilename: true,
|
||||
title: true,
|
||||
status: true,
|
||||
youtubeVideoId: true,
|
||||
error: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
jobs: jobs.map((job) => ({
|
||||
id: job.id,
|
||||
status: job.status,
|
||||
createdAt: job.createdAt.toISOString(),
|
||||
completedAt: job.completedAt?.toISOString() ?? null,
|
||||
items: job.items,
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getQuotaInfo } from "@/lib/quota";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
|
||||
export async function GET() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const quota = await getQuotaInfo(user.id);
|
||||
return NextResponse.json(quota);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { saveUploadedFile } from "@/lib/jobs/create-job";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
|
||||
export const maxDuration = 120;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const formData = await req.formData();
|
||||
const file = formData.get("file") as File | null;
|
||||
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 });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await saveUploadedFile(user.id, file, type, user.plan, { sessionKey });
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Upload failed";
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requirePaidApiUser } from "@/lib/api-auth";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { error, user } = await requirePaidApiUser(_req);
|
||||
if (error || !user) return error!;
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const job = await prisma.job.findFirst({
|
||||
where: { id, userId: user.id },
|
||||
include: {
|
||||
items: {
|
||||
orderBy: { audioFilename: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
audioFilename: true,
|
||||
title: true,
|
||||
description: true,
|
||||
tags: true,
|
||||
privacy: true,
|
||||
categoryId: true,
|
||||
resolution: true,
|
||||
status: true,
|
||||
youtubeVideoId: true,
|
||||
error: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!job) {
|
||||
return NextResponse.json({ error: "Job not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
id: job.id,
|
||||
status: job.status,
|
||||
createdAt: job.createdAt.toISOString(),
|
||||
completedAt: job.completedAt?.toISOString() ?? null,
|
||||
items: job.items,
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requirePaidApiUser } from "@/lib/api-auth";
|
||||
import { createVideoJob } from "@/lib/jobs/create-job";
|
||||
import {
|
||||
applyCreatePlaylistToItems,
|
||||
parseCreatePlaylistInput,
|
||||
} from "@/lib/jobs/resolve-playlist";
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { CreateJobPayload } from "@/lib/types";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { error, user } = await requirePaidApiUser(req);
|
||||
if (error || !user) return error!;
|
||||
|
||||
const body = (await req.json()) as CreateJobPayload & { createPlaylist?: unknown };
|
||||
|
||||
try {
|
||||
const createPlaylist = parseCreatePlaylistInput(body.createPlaylist);
|
||||
if (body.createPlaylist && !createPlaylist) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"createPlaylist requires a title. Optional: description, privacy (public|unlisted|private)",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const { items, playlist } = await applyCreatePlaylistToItems(
|
||||
user,
|
||||
body.items,
|
||||
createPlaylist,
|
||||
);
|
||||
|
||||
const job = await createVideoJob(user, {
|
||||
imagePath: body.imagePath,
|
||||
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) {
|
||||
const message = err instanceof Error ? err.message : "Failed to create job";
|
||||
const status = message.includes("Quota exceeded") ? 403 : 400;
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const { error, user } = await requirePaidApiUser(req);
|
||||
if (error || !user) return error!;
|
||||
|
||||
const limit = Math.min(Number(req.nextUrl.searchParams.get("limit")) || 20, 100);
|
||||
|
||||
const jobs = await prisma.job.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
include: {
|
||||
items: {
|
||||
select: {
|
||||
id: true,
|
||||
audioFilename: true,
|
||||
title: true,
|
||||
status: true,
|
||||
youtubeVideoId: true,
|
||||
error: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
jobs: jobs.map((job) => ({
|
||||
id: job.id,
|
||||
status: job.status,
|
||||
createdAt: job.createdAt.toISOString(),
|
||||
completedAt: job.completedAt?.toISOString() ?? null,
|
||||
items: job.items,
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requirePaidApiUser } from "@/lib/api-auth";
|
||||
import { parseCreatePlaylistInput } from "@/lib/jobs/resolve-playlist";
|
||||
import { createYouTubePlaylist, listYouTubePlaylists } from "@/lib/youtube/upload";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const { error, user } = await requirePaidApiUser(req);
|
||||
if (error || !user) return error!;
|
||||
|
||||
try {
|
||||
const playlists = await listYouTubePlaylists(user.id);
|
||||
return NextResponse.json({ playlists });
|
||||
} catch (err) {
|
||||
console.error("List playlists failed:", err);
|
||||
const message = err instanceof Error ? err.message : "Failed to list playlists";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { error, user } = await requirePaidApiUser(req);
|
||||
if (error || !user) return error!;
|
||||
|
||||
try {
|
||||
const body = await req.json();
|
||||
const input = parseCreatePlaylistInput(body);
|
||||
if (!input) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Provide JSON body: { title, description?, privacy? } where privacy is public|unlisted|private",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const playlist = await createYouTubePlaylist(user.id, input);
|
||||
return NextResponse.json({ playlist });
|
||||
} catch (err) {
|
||||
console.error("Create playlist failed:", err);
|
||||
const message = err instanceof Error ? err.message : "Failed to create playlist";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
name: "Songs2YT API",
|
||||
version: "1.0",
|
||||
authentication: "Authorization: Bearer <api_key>",
|
||||
requirements: ["Pro subscription", "YouTube account connected"],
|
||||
rateLimit: "60 requests per minute per account (plus any approved bonus)",
|
||||
guidance: {
|
||||
recommended:
|
||||
"For most jobs (especially 5+ audio files): POST /api/v1/upload per file, then POST /api/v1/jobs with the returned paths",
|
||||
batch:
|
||||
"POST /api/v1/jobs/batch is for small packs only. Large multipart bodies may fail with 'failed to parse body as FormData'",
|
||||
},
|
||||
endpoints: [
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/v1/upload",
|
||||
description: "Upload a single image or audio file (use for two-step flow)",
|
||||
body: "multipart/form-data: file, type (image|audio)",
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/v1/jobs",
|
||||
description: "Create a video job from uploaded file paths (recommended for large batches)",
|
||||
body: "application/json: { imagePath, items[] }",
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/v1/jobs/batch",
|
||||
description:
|
||||
"One-shot batch for small packs only; large uploads may fail FormData parsing; prefer upload + jobs",
|
||||
body: "multipart/form-data: image, audio[] (repeatable), metadata? (JSON string)",
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/v1/playlists",
|
||||
description: "List YouTube playlists for the authenticated Pro account",
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/v1/playlists",
|
||||
description: "Create a YouTube playlist",
|
||||
body: "application/json: { title, description?, privacy? (public|unlisted|private) }",
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/v1/jobs",
|
||||
description: "List recent jobs",
|
||||
query: "limit (default 20, max 100)",
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/v1/jobs/:id",
|
||||
description: "Get job status and item details",
|
||||
},
|
||||
],
|
||||
docs: "/dashboard/api-docs",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requirePaidApiUser } from "@/lib/api-auth";
|
||||
import { saveUploadedFile } from "@/lib/jobs/create-job";
|
||||
|
||||
export const maxDuration = 120;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { error, user } = await requirePaidApiUser(req);
|
||||
if (error || !user) return error!;
|
||||
|
||||
const formData = await req.formData();
|
||||
const file = formData.get("file") as File | null;
|
||||
const type = formData.get("type") as string | null;
|
||||
|
||||
if (!file || !type || (type !== "image" && type !== "audio")) {
|
||||
return NextResponse.json(
|
||||
{ error: "Provide multipart fields: file, type (image|audio)" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await saveUploadedFile(user.id, file, type, user.plan);
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Upload failed";
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { hasProFeatures } from "@/lib/edition";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { parseCreatePlaylistInput } from "@/lib/jobs/resolve-playlist";
|
||||
import { createYouTubePlaylist, listYouTubePlaylists } from "@/lib/youtube/upload";
|
||||
|
||||
async function requirePremiumYouTubeUser() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return { error: NextResponse.json({ error: "Unauthorized" }, { status: 401 }), user: null };
|
||||
}
|
||||
if (!hasProFeatures(user.plan)) {
|
||||
return {
|
||||
error: NextResponse.json(
|
||||
{ error: "Playlist features are available on the Pro plan only" },
|
||||
{ status: 403 },
|
||||
),
|
||||
user: null,
|
||||
};
|
||||
}
|
||||
if (!user.youtubeConnection) {
|
||||
return {
|
||||
error: NextResponse.json({ error: "YouTube account not connected" }, { status: 403 }),
|
||||
user: null,
|
||||
};
|
||||
}
|
||||
return { error: null, user };
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const { error, user } = await requirePremiumYouTubeUser();
|
||||
if (error || !user) return error!;
|
||||
|
||||
try {
|
||||
const playlists = await listYouTubePlaylists(user.id);
|
||||
return NextResponse.json({ playlists });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to list playlists";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { error, user } = await requirePremiumYouTubeUser();
|
||||
if (error || !user) return error!;
|
||||
|
||||
try {
|
||||
const body = await req.json();
|
||||
const input = parseCreatePlaylistInput(body);
|
||||
if (!input) {
|
||||
return NextResponse.json(
|
||||
{ error: "Provide title, and optionally description and privacy (public|unlisted|private)" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const playlist = await createYouTubePlaylist(user.id, input);
|
||||
return NextResponse.json({ playlist });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to create playlist";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user