Initial commit

This commit is contained in:
twotalesanimation
2026-06-11 10:46:09 +02:00
commit 81ad7e4ea9
223 changed files with 39530 additions and 0 deletions
+394
View File
@@ -0,0 +1,394 @@
// app/api/admin/upload/route.ts
import { NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import { IncomingMessage } from "http";
import { spawn } from "child_process";
import { pipeline } from "stream/promises";
import formidable, { File as FormidableFile } from "formidable";
import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth-options";
// For 2GB uploads over Tailscale (~10-50 Mbps), need 30-45 minutes
export const maxDuration = 2700; // 45 minutes for very large uploads over slow connections
// Use ConfigurableUPLOADS_DIR from environment or default to /uploads
const UPLOADS_DIR = process.env.UPLOADS_DIR || "/uploads";
// Helper to extract video duration using FFprobe
async function getVideoDurationFromFile(filePath: string): Promise<number | null> {
return new Promise((resolve) => {
try {
const ffprobe = spawn("ffprobe", [
"-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1:nokey=1",
filePath,
]);
let output = "";
let timedOut = false;
// Large 2GB files may take longer to scan; timeout after 30s
const timeoutId = setTimeout(() => {
timedOut = true;
ffprobe.kill();
resolve(null); // Return null instead of blocking
}, 30000);
ffprobe.stdout.on("data", (data) => {
output += data.toString();
});
ffprobe.on("close", (code) => {
clearTimeout(timeoutId);
if (!timedOut && code === 0) {
const duration = parseFloat(output.trim());
if (!isNaN(duration) && isFinite(duration) && duration > 0) {
resolve(Math.round(duration));
} else {
resolve(null);
}
} else {
resolve(null);
}
});
ffprobe.on("error", () => {
clearTimeout(timeoutId);
resolve(null);
});
} catch (err) {
resolve(null);
}
});
}
function parseForm(req: IncomingMessage): Promise<{ fields: any; files: any }> {
const form = formidable({
multiples: false,
maxFileSize: 2.5 * 1024 * 1024 * 1024, // 2.5GB max file size
maxFieldsSize: 10 * 1024 * 1024, // 10MB for all fields combined
maxFields: 50,
keepExtensions: true,
});
return new Promise((resolve, reject) => {
form.parse(req, (err, fields, files) => {
if (err) {
console.error('[Upload] Formidable parse error:', err.code, err.message);
reject(err);
}
else resolve({ fields, files });
});
});
}
async function checkAdmin() {
const session = await getServerSession(authOptions);
if (!session?.user?.email) throw { status: 401, message: "Unauthorized" };
const allowed = (process.env.ALLOWED_ADMINS || "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
if (allowed.length && !allowed.includes(session.user.email))
throw { status: 403, message: "Forbidden" };
return session;
}
export async function POST(req: Request) {
// track saved paths so we can cleanup on error
let savedVideoDest: string | undefined;
let savedThumbDest: string | undefined;
try {
console.log('[Upload] Request received, content-type:', req.headers.get("content-type"));
const nodeReq = (req as any).req ?? (globalThis as any).__NEXT_INIT?.req ?? null;
const session = await checkAdmin();
console.log('[Upload] Admin check passed for:', session.user?.email);
// helper to stream a File directly to disk (handles large files efficiently)
async function saveVideoStream(file: File, origName?: string) {
const filename = `${Date.now()}-${String(origName ?? "upload.mp4")}`;
const dest = path.join(UPLOADS_DIR, "videos", filename);
// Ensure directory exists
await fs.promises.mkdir(path.dirname(dest), { recursive: true });
// Create write stream to destination
const writeStream = fs.createWriteStream(dest);
try {
// Stream the file directly to disk without buffering
// file.stream() returns a Web ReadableStream, convert to Node.js stream
const nodeStream = file.stream() as any;
await pipeline(nodeStream, writeStream);
// Ensure the file is readable by all processes (mode 644)
await fs.promises.chmod(dest, 0o644);
return { filename, dest };
} catch (err) {
// Clean up the partially written file if stream fails
try {
await fs.promises.unlink(dest);
} catch (_) {}
throw err;
}
}
// helper to save a thumbnail buffer to UPLOADS_DIR/thumbnails
async function saveThumbBuffer(buffer: Buffer, ext = "jpg") {
const filename = `${Date.now()}-thumb.${ext.replace(/^\./, "")}`;
const dest = path.join(UPLOADS_DIR, "thumbnails", filename);
await fs.promises.mkdir(path.dirname(dest), { recursive: true });
await fs.promises.writeFile(dest, buffer);
// Ensure the file is readable by all processes (mode 644)
await fs.promises.chmod(dest, 0o644);
return { filename, dest };
}
let title: string | undefined;
let playlistId: string | undefined;
let durationSec: number | null = null;
let savedFilename: string | undefined;
// **IMPORTANT**: single thumbnailUrl used in both branches
let thumbnailUrl: string | null = null;
if (!nodeReq) {
// Request.formData() flow (some Next.js environments)
let formData: FormData | null = null;
try {
formData = await req.formData();
} catch (e: any) {
console.error("Failed to parse body as FormData.", e, "content-type=", req.headers.get("content-type"));
return NextResponse.json(
{ error: "Failed to parse body as FormData. Ensure request is sent with multipart/form-data." },
{ status: 400 }
);
}
const isManualCopy = formData.get("manualFileCopy") === "true";
const file = isManualCopy ? null : (formData.get("file") as Blob | null);
title = (formData.get("title") as string) || undefined;
playlistId = (formData.get("playlistId") as string) || undefined;
const durationField = formData.get("durationSec") as string | null;
if (durationField) {
const n = Number(durationField);
if (!isNaN(n)) durationSec = Math.round(n);
}
// thumbnail (optional)
const thumb = formData.get("thumbnail") as Blob | null;
if (thumb) {
try {
const thumbArrayBuffer = await thumb.arrayBuffer();
const thumbBuffer = Buffer.from(thumbArrayBuffer);
// try to infer extension from name, fallback to jpg
const fName = (thumb as any).name ?? "";
const extMatch = fName.match(/\.([a-z0-9]+)$/i);
const ext = extMatch ? extMatch[1] : "jpg";
const saved = await saveThumbBuffer(thumbBuffer, ext);
thumbnailUrl = `/api/thumbnails/${saved.filename}`;
savedThumbDest = saved.dest;
} catch (err) {
console.warn("thumbnail save failed (formData)", err);
// not fatal — we simply leave thumbnailUrl null
}
}
if (!isManualCopy) {
if (!file) return NextResponse.json({ error: "no file" }, { status: 400 });
// Stream the large file directly to disk without buffering
const origName = (file as any).name ?? `upload-${Date.now()}.mp4`;
const saved = await saveVideoStream(file as File, origName);
savedFilename = saved.filename;
savedVideoDest = saved.dest;
} else {
// Manual copy mode: don't save file, just mark for manual copy
console.log('[Upload] Manual file copy mode enabled');
savedFilename = undefined;
savedVideoDest = undefined;
}
} else {
// formidable flow (Node IncomingMessage available)
console.log('[Upload] Using formidable flow for file upload');
const { fields, files } = await parseForm(nodeReq as IncomingMessage);
console.log('[Upload] Formidable parsing complete, fields:', Object.keys(fields), 'files:', Object.keys(files));
const f: FormidableFile | undefined =
(files && (files.file as FormidableFile)) || (files && Object.values(files)[0]);
if (!f) return NextResponse.json({ error: "no file found" }, { status: 400 });
// handle thumbnail file if present in formidable files
const thumbFile = (files && (files.thumbnail as FormidableFile)) || undefined;
if (thumbFile) {
try {
const tPath = (thumbFile as any).filepath || (thumbFile as any).path;
const originalThumbName = (thumbFile as any).originalFilename || path.basename(tPath);
const tExt = path.extname(originalThumbName) || ".jpg";
const thumbFilename = `${Date.now()}-thumb${tExt}`;
const thumbDest = path.join(UPLOADS_DIR, "thumbnails", thumbFilename);
await fs.promises.mkdir(path.dirname(thumbDest), { recursive: true });
await fs.promises.copyFile(tPath, thumbDest);
// Ensure the file is readable by all processes (mode 644)
await fs.promises.chmod(thumbDest, 0o644);
thumbnailUrl = `/api/thumbnails/${thumbFilename}`;
savedThumbDest = thumbDest;
} catch (err) {
console.warn("thumbnail save failed (formidable)", err);
}
}
// copy video file to UPLOADS_DIR/videos
const filePath = (f as any).filepath || (f as any).path;
const originalFilename = (f as any).originalFilename || (f as any).name || path.basename(filePath);
const filename = `${Date.now()}-${originalFilename}`;
const dest = path.join(UPLOADS_DIR, "videos", filename);
console.log('[Upload] Copying video file', { size: (f as any).size, path: filePath, dest });
await fs.promises.mkdir(path.dirname(dest), { recursive: true });
await fs.promises.copyFile(filePath, dest);
// Ensure the file is readable by all processes (mode 644)
await fs.promises.chmod(dest, 0o644);
console.log('[Upload] Video file copied successfully');
title = fields.title ?? originalFilename;
playlistId = fields.playlistId;
const durationField = fields.durationSec ?? fields.duration ?? null;
if (durationField) {
const n = Number(durationField);
if (!isNaN(n)) durationSec = Math.round(n);
}
savedFilename = filename;
savedVideoDest = dest;
}
// validate playlist: avoid FK errors
if (!playlistId) {
// cleanup if needed
if (savedVideoDest) await fs.promises.unlink(savedVideoDest).catch(() => {});
if (savedThumbDest) await fs.promises.unlink(savedThumbDest).catch(() => {});
return NextResponse.json({ error: "playlistId required" }, { status: 400 });
}
const playlist = await prisma.playlist.findUnique({ where: { id: playlistId } });
if (!playlist) {
if (savedVideoDest) await fs.promises.unlink(savedVideoDest).catch(() => {});
if (savedThumbDest) await fs.promises.unlink(savedThumbDest).catch(() => {});
return NextResponse.json({ error: "playlist not found" }, { status: 400 });
}
// Get current user
const user = await prisma.user.findUnique({
where: { email: session.user?.email ?? "" },
select: { id: true },
});
if (!user) {
if (savedVideoDest) await fs.promises.unlink(savedVideoDest).catch(() => {});
if (savedThumbDest) await fs.promises.unlink(savedThumbDest).catch(() => {});
return NextResponse.json({ error: "user not found" }, { status: 400 });
}
// If duration not provided by client, try to extract it from the saved video file
if (durationSec === null && savedVideoDest) {
try {
const extractedDuration = await getVideoDurationFromFile(savedVideoDest);
if (extractedDuration !== null) {
durationSec = extractedDuration;
console.log(`[Upload] Extracted duration: ${durationSec}s from ${savedVideoDest}`);
} else {
console.warn(`[Upload] Could not extract duration from ${savedVideoDest}`);
}
} catch (err) {
console.warn(`[Upload] Duration extraction error:`, err);
}
}
// For manual copy mode, check if we have a file
const isManualMode = !savedVideoDest;
// Calculate index: find max index in playlist and add 1
const maxIndexVideo = await prisma.video.findFirst({
where: { playlistId },
orderBy: { index: 'desc' },
select: { index: true },
});
const desiredIndex = (maxIndexVideo?.index ?? -1) + 1;
// First, create the video record to get its ID
const video = await prisma.video.create({
data: {
title: title ?? savedFilename ?? 'Untitled',
url: '', // Will be set below or after rename
thumbnail: thumbnailUrl,
index: desiredIndex,
playlistId,
userId: user.id,
transcodingStatus: isManualMode ? 'pending_manual_file' : 'uploaded',
...(durationSec !== null && { durationSec }),
},
});
// If manual copy mode, set the URL and return
if (isManualMode) {
const videoUrl = `/uploads/videos/${video.id}.mp4`;
await prisma.video.update({
where: { id: video.id },
data: { url: videoUrl },
});
console.log('[Upload] Manual copy mode: video created with ID', video.id, 'URL:', videoUrl);
return NextResponse.json({ video: { ...video, url: videoUrl } }, { status: 201 });
}
// Now rename the uploaded file to use the video ID
const finalFilename = `${video.id}.mp4`;
const finalDest = path.join(UPLOADS_DIR, "videos", finalFilename);
try {
if (savedVideoDest) {
await fs.promises.rename(savedVideoDest, finalDest);
}
// Update the URL in the database to reflect the final filename
await prisma.video.update({
where: { id: video.id },
data: { url: `/uploads/videos/${finalFilename}` },
});
} catch (err) {
console.error("Error renaming video file:", err);
// If rename fails, cleanup and delete the record
await prisma.video.delete({ where: { id: video.id } });
throw err;
}
return NextResponse.json({ video: { ...video, url: `/uploads/videos/${finalFilename}` } }, { status: 201 });
} catch (err: any) {
console.error("upload error", {
code: err?.code,
message: err?.message,
errno: err?.errno,
}, err);
// cleanup saved files if something failed
if (savedVideoDest) {
try {
await fs.promises.unlink(savedVideoDest);
} catch (_) {}
}
if (savedThumbDest) {
try {
await fs.promises.unlink(savedThumbDest);
} catch (_) {}
}
const status = err?.status ?? 500;
const message = err?.message ?? (err?.toString ? err.toString() : "server error");
return NextResponse.json({ error: message }, { status });
}
}