Initial commit
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user