import { NextRequest, NextResponse } from "next/server"; import { spawn } from "node:child_process"; import path from "node:path"; import { assertLocalRequest } from "@/lib/api/localOnly"; import { trustSubtitlePath } from "@/lib/video/subtitleAccess"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; /** * Open a native OS file-picker dialog and return the absolute path of * the selected file. Mirrors /api/pick-folder. Currently scoped to * subtitle files — when a subtitle is picked, the path is added to the * session-trusted set so the subtitle track endpoint will serve it * even if it lives outside any indexed video root. */ export async function POST(req: NextRequest) { const blocked = assertLocalRequest(req); if (blocked) return blocked; const body = await req.json().catch(() => ({})); const startPath = typeof body.start === "string" ? body.start : ""; const purpose = typeof body.purpose === "string" ? body.purpose : "subtitle"; try { const picked = await runPicker(startPath, purpose); if (!picked) return NextResponse.json({ path: null, cancelled: true }); const abs = path.resolve(picked); if (purpose === "subtitle") { trustSubtitlePath(abs); } return NextResponse.json({ path: abs }); } catch (e) { return NextResponse.json({ error: (e as Error).message, path: null }, { status: 500 }); } } function runPicker(startPath: string, purpose: string): Promise { if (process.platform === "win32") return pickerWindows(startPath, purpose); if (process.platform === "darwin") return pickerMacOS(startPath, purpose); return pickerLinux(startPath, purpose); } function pickerWindows(startPath: string, purpose: string): Promise { // User-controlled values (startPath, filter) are passed via env vars so // PowerShell never parses them as code. The script body itself contains // no interpolation — only literal references to $env:PINKUDEX_PICK_*. const filter = purpose === "subtitle" ? "Subtitle files (*.srt;*.vtt;*.ass;*.ssa)|*.srt;*.vtt;*.ass;*.ssa|All files (*.*)|*.*" : "All files (*.*)|*.*"; const script = ` Add-Type -AssemblyName System.Windows.Forms | Out-Null $dlg = New-Object System.Windows.Forms.OpenFileDialog $dlg.Title = 'Pinkudex — pick a file' $dlg.Filter = $env:PINKUDEX_PICK_FILTER $dlg.Multiselect = $false if ($env:PINKUDEX_PICK_START) { try { $dlg.InitialDirectory = $env:PINKUDEX_PICK_START } catch {} } $owner = New-Object System.Windows.Forms.Form $owner.TopMost = $true $owner.Opacity = 0 $owner.ShowInTaskbar = $false $result = $dlg.ShowDialog($owner) if ($result -eq [System.Windows.Forms.DialogResult]::OK) { Write-Output $dlg.FileName } `.trim(); return runProcess("powershell.exe", ["-NoProfile", "-Sta", "-Command", script], { PINKUDEX_PICK_START: startPath, PINKUDEX_PICK_FILTER: filter, }); } function pickerMacOS(startPath: string, purpose: string): Promise { const startClause = startPath ? ` default location (POSIX file "${startPath.replace(/"/g, '\\"')}")` : ""; const typeClause = purpose === "subtitle" ? ` of type {"srt", "vtt", "ass", "ssa"}` : ""; const script = `try set f to choose file with prompt "Pinkudex — pick a file"${typeClause}${startClause} return POSIX path of f on error number -128 return "" end try`; return runProcess("osascript", ["-e", script]); } function pickerLinux(startPath: string, purpose: string): Promise { const args = ["--file-selection", "--title=Pinkudex — pick a file"]; if (purpose === "subtitle") { args.push("--file-filter=Subtitles | *.srt *.vtt *.ass *.ssa"); args.push("--file-filter=All files | *"); } if (startPath) args.push(`--filename=${startPath}`); return runProcess("zenity", args).catch((e) => { throw new Error(`Linux file pickers require zenity to be installed (${(e as Error).message})`); }); } function runProcess( cmd: string, args: string[], extraEnv?: Record, ): Promise { return new Promise((resolve, reject) => { const env = extraEnv ? { ...process.env, ...extraEnv } : process.env; const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"], env }); let out = ""; let err = ""; child.stdout.on("data", (b) => { out += b.toString(); }); child.stderr.on("data", (b) => { err += b.toString(); }); child.on("error", (e) => reject(e)); child.on("close", (code) => { if (code !== 0 && code !== 1 && code !== null) { reject(new Error(err.trim() || `picker exited with code ${code}`)); return; } const trimmed = out.trim().replace(/\r/g, ""); resolve(trimmed || null); }); }); }