// 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 { 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 }); } }