124 lines
4.7 KiB
TypeScript
124 lines
4.7 KiB
TypeScript
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<string | null> {
|
|
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<string | null> {
|
|
// 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<string | null> {
|
|
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<string | null> {
|
|
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<string, string>,
|
|
): Promise<string | null> {
|
|
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);
|
|
});
|
|
});
|
|
}
|