Initial commit

This commit is contained in:
admin
2026-05-26 22:46:00 +02:00
commit 7e2c2ff89c
256 changed files with 51523 additions and 0 deletions
+68
View File
@@ -0,0 +1,68 @@
import { NextRequest, NextResponse } from "next/server";
import path from "node:path";
import fs from "node:fs/promises";
import crypto from "node:crypto";
import { revalidatePath } from "next/cache";
import { rawDb } from "@/lib/db/client";
import { safeJoin } from "@/lib/safePath";
import { assertLocalRequest } from "@/lib/api/localOnly";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const PORTRAIT_ROOT = path.join(process.cwd(), "data", "portraits");
const ALLOWED_EXT = new Set([".jpg", ".jpeg", ".png", ".webp"]);
const SLOT_COLS: Record<string, { path: string; zoom: string; ox: string; oy: string }> = {
"1": { path: "portrait_path", zoom: "portrait_zoom", ox: "portrait_offset_x", oy: "portrait_offset_y" },
"2": { path: "portrait2_path", zoom: "portrait2_zoom", ox: "portrait2_offset_x", oy: "portrait2_offset_y" },
"3": { path: "portrait3_path", zoom: "portrait3_zoom", ox: "portrait3_offset_x", oy: "portrait3_offset_y" },
"4": { path: "portrait4_path", zoom: "portrait4_zoom", ox: "portrait4_offset_x", oy: "portrait4_offset_y" },
"h": { path: "portraith_path", zoom: "portraith_zoom", ox: "portraith_offset_x", oy: "portraith_offset_y" },
};
export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
const { id } = await ctx.params;
const numId = Number(id);
if (!Number.isFinite(numId)) return NextResponse.json({ error: "bad id" }, { status: 400 });
const url = new URL(req.url);
const slot = (url.searchParams.get("slot") ?? "1") as keyof typeof SLOT_COLS;
const cols = SLOT_COLS[slot];
if (!cols) return NextResponse.json({ error: "bad slot" }, { status: 400 });
const actress = rawDb.prepare(`SELECT id, slug, ${cols.path} AS prevPath FROM actresses WHERE id = ?`).get(numId) as
| { id: number; slug: string; prevPath: string | null }
| undefined;
if (!actress) return NextResponse.json({ error: "actress not found" }, { status: 404 });
const form = await req.formData();
const file = form.get("file");
if (!(file instanceof File)) return NextResponse.json({ error: "missing file" }, { status: 400 });
const ext = path.extname(file.name).toLowerCase();
if (!ALLOWED_EXT.has(ext)) return NextResponse.json({ error: "unsupported format" }, { status: 415 });
const buf = Buffer.from(await file.arrayBuffer());
const sha = crypto.createHash("sha256").update(buf).digest("hex").slice(0, 16);
const filename = `${actress.id}-${slot}-${sha}${ext}`;
await fs.mkdir(PORTRAIT_ROOT, { recursive: true });
await fs.writeFile(path.join(PORTRAIT_ROOT, filename), buf);
if (actress.prevPath && actress.prevPath !== filename) {
const prevAbs = safeJoin(PORTRAIT_ROOT, actress.prevPath);
if (prevAbs) await fs.rm(prevAbs, { force: true }).catch(() => {});
}
rawDb.prepare(`
UPDATE actresses
SET ${cols.path} = ?, ${cols.zoom} = 1, ${cols.ox} = 0, ${cols.oy} = 0
WHERE id = ?
`).run(filename, actress.id);
revalidatePath("/actress");
revalidatePath(`/actress/${actress.slug}`);
return NextResponse.json({ portraitPath: filename });
}
+55
View File
@@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from "next/server";
import { rawDb } from "@/lib/db/client";
import { assertLocalRequest } from "@/lib/api/localOnly";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const TABLES = [
"images",
"studios",
"labels",
"series",
"actresses",
"genres",
"tag_categories",
"tags",
"collections",
"image_actresses",
"image_genres",
"image_tags",
"collection_images",
"actress_categories",
"actress_categories_map",
"app_settings",
];
export async function GET(req: NextRequest) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
const data: Record<string, unknown[]> = {};
for (const t of TABLES) {
try {
data[t] = rawDb.prepare(`SELECT * FROM ${t}`).all();
} catch {
data[t] = [];
}
}
const payload = {
app: "Pinkudex",
version: 1,
exportedAt: new Date().toISOString(),
tables: data,
};
const json = JSON.stringify(payload, null, 2);
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
return new NextResponse(json, {
headers: {
"Content-Type": "application/json; charset=utf-8",
"Content-Disposition": `attachment; filename="pinkudex-backup-${stamp}.json"`,
"Cache-Control": "no-store",
},
});
}
+55
View File
@@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from "next/server";
import path from "node:path";
import { assertLocalRequest } from "@/lib/api/localOnly";
import { importDatabaseTables } from "@/lib/backup/importDb";
// Strip absolute-path noise — only the basename is useful to the client
// and absolute paths leak filesystem layout to anything that pings the
// API on the local network.
const baseOnly = (p: string | null | undefined): string | null =>
p ? path.basename(p) : null;
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST(req: NextRequest) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
let payload: { tables?: Record<string, unknown[]>; version?: number; app?: string };
try {
payload = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
const tables = payload.tables;
if (!tables || typeof tables !== "object") {
return NextResponse.json({ error: "Missing 'tables' object" }, { status: 400 });
}
if (!Array.isArray(tables.actresses) && !Array.isArray(tables.images)) {
return NextResponse.json({ error: "Backup payload is missing core tables." }, { status: 400 });
}
const result = await importDatabaseTables(tables);
const snapshotName = baseOnly(result.snapshotPath);
if (!result.ok) {
return NextResponse.json(
{
error: result.error,
snapshotName,
hint: snapshotName
? `Live DB rolled back. A pre-import snapshot was saved as ${snapshotName}.`
: undefined,
},
{ status: 500 },
);
}
return NextResponse.json({
ok: true,
counts: result.counts,
errors: result.errors,
snapshotName,
});
}
+133
View File
@@ -0,0 +1,133 @@
import { NextRequest, NextResponse } from "next/server";
import path from "node:path";
import fs from "node:fs";
import fsp from "node:fs/promises";
import os from "node:os";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import yazl from "yazl";
import { rawDb } from "@/lib/db/client";
import { assertLocalRequest } from "@/lib/api/localOnly";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export const maxDuration = 3600;
const ROOT = process.cwd();
const SOURCES: Array<{ absDir: string; zipPrefix: string; skipRel?: (rel: string) => boolean }> = [
{
absDir: path.join(ROOT, "library"),
zipPrefix: "library",
skipRel: (rel) => rel === ".superseded" || rel.startsWith(".superseded/"),
},
{ absDir: path.join(ROOT, "data", "thumbs"), zipPrefix: "data/thumbs" },
{ absDir: path.join(ROOT, "data", "portraits"), zipPrefix: "data/portraits" },
{ absDir: path.join(ROOT, "data", "category-covers"), zipPrefix: "data/category-covers" },
{ absDir: path.join(ROOT, "data", "collection-covers"), zipPrefix: "data/collection-covers" },
];
const DB_TABLES = [
"images", "studios", "labels", "series", "actresses", "genres",
"tag_categories", "tags", "collections", "image_actresses", "image_genres",
"image_tags", "collection_images", "actress_categories",
"actress_categories_map", "app_settings",
];
type Entry = { abs: string; rel: string; size: number; mtime: Date };
async function walk(absDir: string, zipPrefix: string, skipRel?: (rel: string) => boolean): Promise<Entry[]> {
const out: Entry[] = [];
const stack: Array<{ abs: string; rel: string }> = [{ abs: absDir, rel: "" }];
while (stack.length) {
const { abs, rel } = stack.pop()!;
let dirents: fs.Dirent[];
try {
dirents = await fsp.readdir(abs, { withFileTypes: true });
} catch (e) {
if ((e as NodeJS.ErrnoException).code === "ENOENT") continue;
throw e;
}
for (const d of dirents) {
const childRel = rel ? `${rel}/${d.name}` : d.name;
if (skipRel?.(childRel)) continue;
const childAbs = path.join(abs, d.name);
if (d.isDirectory()) {
stack.push({ abs: childAbs, rel: childRel });
} else if (d.isFile()) {
const st = await fsp.stat(childAbs);
out.push({ abs: childAbs, rel: `${zipPrefix}/${childRel}`, size: st.size, mtime: st.mtime });
}
}
}
return out;
}
function buildDatabaseJson(): Buffer {
const data: Record<string, unknown[]> = {};
for (const t of DB_TABLES) {
try { data[t] = rawDb.prepare(`SELECT * FROM ${t}`).all(); }
catch { data[t] = []; }
}
const payload = {
app: "Pinkudex",
version: 1,
exportedAt: new Date().toISOString(),
tables: data,
};
return Buffer.from(JSON.stringify(payload, null, 2), "utf8");
}
export async function GET(req: NextRequest) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
let entries: Entry[] = [];
for (const src of SOURCES) {
entries = entries.concat(await walk(src.absDir, src.zipPrefix, src.skipRel));
}
// Materialise the zip to a temp file first. Streaming yazl's outputStream
// straight through NextResponse hung mid-download (turbopack streaming /
// adapter chunking weirdness). A temp file guarantees: exact Content-Length
// from fs.stat, full backpressure handling by Node's createReadStream, and
// resumability if the browser retries. Costs one extra pass over disk —
// acceptable for an explicit user-triggered backup.
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "pinkudex-export-"));
const tmpZip = path.join(tmpDir, "library.zip");
const zip = new yazl.ZipFile();
zip.addBuffer(buildDatabaseJson(), "database.json", { compress: false });
for (const e of entries) {
zip.addReadStream(fs.createReadStream(e.abs), e.rel, {
compress: false,
size: e.size,
mtime: e.mtime,
});
}
zip.end();
await pipeline(
zip.outputStream as unknown as Readable,
fs.createWriteStream(tmpZip),
);
const { size } = await fsp.stat(tmpZip);
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const fileStream = fs.createReadStream(tmpZip);
// Best-effort cleanup once the stream is fully consumed (success or abort).
fileStream.on("close", () => {
fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
});
const webStream = Readable.toWeb(fileStream) as unknown as ReadableStream<Uint8Array>;
return new NextResponse(webStream, {
headers: {
"Content-Type": "application/zip",
"Content-Length": String(size),
"Content-Disposition": `attachment; filename="pinkudex-library-${stamp}.zip"`,
"Cache-Control": "no-store",
},
});
}
+257
View File
@@ -0,0 +1,257 @@
import { NextRequest, NextResponse } from "next/server";
import path from "node:path";
import fs from "node:fs";
import fsp from "node:fs/promises";
import os from "node:os";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import yauzl from "yauzl";
import { assertLocalRequest } from "@/lib/api/localOnly";
import { importDatabaseTables, restoreDatabaseSnapshot } from "@/lib/backup/importDb";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export const maxDuration = 3600;
const ROOT = process.cwd();
// Folders the export route ships and that we expect to restore. zipPrefix is
// the path inside the archive; absDir is where it lives on disk.
const TARGETS: Array<{ zipPrefix: string; absDir: string }> = [
{ zipPrefix: "library", absDir: path.join(ROOT, "library") },
{ zipPrefix: "data/thumbs", absDir: path.join(ROOT, "data", "thumbs") },
{ zipPrefix: "data/portraits", absDir: path.join(ROOT, "data", "portraits") },
{ zipPrefix: "data/category-covers", absDir: path.join(ROOT, "data", "category-covers") },
{ zipPrefix: "data/collection-covers", absDir: path.join(ROOT, "data", "collection-covers") },
];
function openZip(zipPath: string): Promise<yauzl.ZipFile> {
return new Promise((resolve, reject) => {
yauzl.open(zipPath, { lazyEntries: true, autoClose: false }, (err, zf) => {
if (err || !zf) return reject(err ?? new Error("Failed to open zip"));
resolve(zf);
});
});
}
function readEntryStream(zf: yauzl.ZipFile, entry: yauzl.Entry): Promise<Readable> {
return new Promise((resolve, reject) => {
zf.openReadStream(entry, (err, stream) => {
if (err || !stream) return reject(err ?? new Error("Failed to open entry stream"));
resolve(stream);
});
});
}
// Reject zip-slip / path-traversal entries: the resolved destination must
// stay strictly inside the staging root. Without this, a crafted entry
// named "../../etc/passwd" would write outside the staging folder.
function safeJoin(root: string, rel: string): string | null {
const resolved = path.resolve(root, rel);
const rel2 = path.relative(root, resolved);
if (rel2.startsWith("..") || path.isAbsolute(rel2)) return null;
return resolved;
}
async function extractAll(zipPath: string, stagingRoot: string): Promise<void> {
const zf = await openZip(zipPath);
try {
await new Promise<void>((resolve, reject) => {
zf.on("error", reject);
zf.on("end", resolve);
zf.on("entry", async (entry: yauzl.Entry) => {
try {
const isDir = /\/$/.test(entry.fileName);
const dest = safeJoin(stagingRoot, entry.fileName);
if (!dest) {
// Skip suspicious entries silently and continue.
zf.readEntry();
return;
}
if (isDir) {
await fsp.mkdir(dest, { recursive: true });
zf.readEntry();
return;
}
await fsp.mkdir(path.dirname(dest), { recursive: true });
const rs = await readEntryStream(zf, entry);
await pipeline(rs, fs.createWriteStream(dest));
zf.readEntry();
} catch (e) {
reject(e);
}
});
zf.readEntry();
});
} finally {
zf.close();
}
}
async function rollbackMediaSwap(
renamedBackups: Array<{ from: string; to: string }>,
movedTargets: string[],
ts: string,
): Promise<string[]> {
const errors: string[] = [];
for (const tgt of [...TARGETS].reverse()) {
const backup = renamedBackups.find((r) => r.from === tgt.absDir);
const wasMoved = movedTargets.includes(tgt.zipPrefix);
if (!backup && !wasMoved) continue;
if (wasMoved) {
try {
await fsp.access(tgt.absDir);
await fsp.rename(tgt.absDir, `${tgt.absDir}.failed-restore-${ts}`);
} catch (e) {
if ((e as NodeJS.ErrnoException).code !== "ENOENT") {
errors.push(`Could not move failed ${tgt.zipPrefix}: ${(e as Error).message}`);
}
}
}
if (backup) {
try {
await fsp.rename(backup.to, backup.from);
} catch (e) {
errors.push(`Could not restore backup ${backup.to}: ${(e as Error).message}`);
}
}
}
return errors;
}
export async function POST(req: NextRequest) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
if (!req.body) {
return NextResponse.json({ error: "Missing request body" }, { status: 400 });
}
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "pinkudex-import-"));
const tmpZip = path.join(tmpDir, "upload.zip");
const staging = path.join(tmpDir, "staging");
try {
// 1) Stream upload to disk. Buffering a multi-GB upload in memory is a
// non-starter; piping the request body straight to a file is constant-RAM.
await pipeline(
Readable.fromWeb(req.body as unknown as import("node:stream/web").ReadableStream),
fs.createWriteStream(tmpZip),
);
// 2) Extract everything to staging. If anything throws here we abort
// before touching live state.
await fsp.mkdir(staging, { recursive: true });
await extractAll(tmpZip, staging);
// 3) Read database.json and validate.
const dbJsonPath = path.join(staging, "database.json");
let dbJsonRaw: string;
try {
dbJsonRaw = await fsp.readFile(dbJsonPath, "utf8");
} catch {
return NextResponse.json(
{ error: "Archive is missing database.json — not a Pinkudex library export." },
{ status: 400 },
);
}
let parsed: { tables?: Record<string, unknown[]> };
try {
parsed = JSON.parse(dbJsonRaw);
} catch {
return NextResponse.json({ error: "database.json is not valid JSON." }, { status: 400 });
}
const tables = parsed.tables;
if (!tables || typeof tables !== "object") {
return NextResponse.json({ error: "database.json missing 'tables' object." }, { status: 400 });
}
if (!Array.isArray(tables.actresses) && !Array.isArray(tables.images)) {
return NextResponse.json(
{ error: "database.json is missing core tables." },
{ status: 400 },
);
}
// 4) Import DB. On failure the helper restores a pre-import .bak snapshot
// and we abort BEFORE swapping any folders so live media is untouched.
const dbResult = await importDatabaseTables(tables);
if (!dbResult.ok) {
return NextResponse.json(
{
error: `Database import failed: ${dbResult.error}`,
snapshotName: dbResult.snapshotPath ? path.basename(dbResult.snapshotPath) : null,
hint: "Media folders were not modified. The DB was rolled back from the pre-import snapshot.",
},
{ status: 500 },
);
}
// 5) Swap media folders. Existing folders renamed to *.pre-restore-<ts>
// for manual rollback. New folders moved out of staging into place.
const ts = new Date().toISOString().replace(/[:.]/g, "-");
const renamedBackups: Array<{ from: string; to: string }> = [];
const movedTargets: string[] = [];
try {
for (const tgt of TARGETS) {
const stagedSrc = path.join(staging, tgt.zipPrefix);
let stagedExists = false;
try {
const st = await fsp.stat(stagedSrc);
stagedExists = st.isDirectory();
} catch {}
if (!stagedExists) continue;
// Rename existing folder if present.
try {
await fsp.access(tgt.absDir);
const backupPath = `${tgt.absDir}.pre-restore-${ts}`;
await fsp.rename(tgt.absDir, backupPath);
renamedBackups.push({ from: tgt.absDir, to: backupPath });
} catch {
// Didn't exist — fine.
}
await fsp.mkdir(path.dirname(tgt.absDir), { recursive: true });
await fsp.rename(stagedSrc, tgt.absDir);
movedTargets.push(tgt.zipPrefix);
}
} catch (e) {
const rollbackErrors = await rollbackMediaSwap(renamedBackups, movedTargets, ts);
if (dbResult.snapshotPath) {
try {
await restoreDatabaseSnapshot(dbResult.snapshotPath);
} catch (restoreErr) {
rollbackErrors.push(`Could not restore DB snapshot: ${(restoreErr as Error).message}`);
}
}
return NextResponse.json(
{
error: `Library restore failed during media swap: ${(e as Error).message}`,
dbSnapshotName: dbResult.snapshotPath ? path.basename(dbResult.snapshotPath) : null,
mediaBackupNames: renamedBackups.map((r) => path.basename(r.to)),
rollbackErrors,
},
{ status: 500 },
);
}
return NextResponse.json({
ok: true,
counts: dbResult.counts,
errors: dbResult.errors,
dbSnapshotName: dbResult.snapshotPath ? path.basename(dbResult.snapshotPath) : null,
mediaRestored: movedTargets,
mediaBackupNames: renamedBackups.map((r) => path.basename(r.to)),
});
} catch (e) {
return NextResponse.json(
{ error: `Library restore failed: ${(e as Error).message}` },
{ status: 500 },
);
} finally {
fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
}
}
@@ -0,0 +1,50 @@
import { NextRequest } from "next/server";
import path from "node:path";
import fs from "node:fs/promises";
import crypto from "node:crypto";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const COVER_ROOT = path.join(process.cwd(), "data", "category-covers");
const IMAGE_MIME: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
};
export async function GET(_req: NextRequest, ctx: { params: Promise<{ file: string }> }) {
const { file } = await ctx.params;
const name = decodeURIComponent(file);
// Stored filenames are always `${id}-${slot}-${sha16}.${ext}`. Reject
// anything containing path separators or traversal segments before
// touching the disk.
if (name.includes("/") || name.includes("\\") || name.includes("..")) {
return new Response("not found", { status: 404 });
}
const abs = path.join(COVER_ROOT, name);
try {
const buf = await fs.readFile(abs);
const ext = path.extname(abs).toLowerCase();
const etag = `"${crypto.createHash("sha256").update(buf).digest("hex")}"`;
return new Response(new Uint8Array(buf), {
headers: {
"Content-Type": IMAGE_MIME[ext] ?? "application/octet-stream",
"Content-Disposition": rfc5987Disposition(name),
"Cache-Control": "public, max-age=0, must-revalidate",
ETag: etag,
},
});
} catch {
return new Response("not found", { status: 404 });
}
}
function rfc5987Disposition(filename: string): string {
// ASCII-safe fallback: strip non-ASCII + escape quotes/backslashes for
// the legacy `filename=` token. UTF-8 path uses RFC 5987 percent-encoding.
const ascii = filename.replace(/[^\x20-\x7e]/g, "_").replace(/["\\]/g, "_");
const utf8 = encodeURIComponent(filename);
return `inline; filename="${ascii}"; filename*=UTF-8''${utf8}`;
}
+67
View File
@@ -0,0 +1,67 @@
import { NextRequest, NextResponse } from "next/server";
import path from "node:path";
import fs from "node:fs/promises";
import crypto from "node:crypto";
import { revalidatePath } from "next/cache";
import { rawDb } from "@/lib/db/client";
import { safeJoin } from "@/lib/safePath";
import { assertLocalRequest } from "@/lib/api/localOnly";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const COVER_ROOT = path.join(process.cwd(), "data", "category-covers");
const ALLOWED_EXT = new Set([".jpg", ".jpeg", ".png", ".webp"]);
const SLOT_COLS: Record<string, { path: string; zoom: string; ox: string; oy: string }> = {
portrait: { path: "cover_portrait_path", zoom: "cover_portrait_zoom", ox: "cover_portrait_offset_x", oy: "cover_portrait_offset_y" },
landscape: { path: "cover_landscape_path", zoom: "cover_landscape_zoom", ox: "cover_landscape_offset_x", oy: "cover_landscape_offset_y" },
};
export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
const { id } = await ctx.params;
const numId = Number(id);
if (!Number.isFinite(numId)) return NextResponse.json({ error: "bad id" }, { status: 400 });
const url = new URL(req.url);
const slot = (url.searchParams.get("slot") ?? "portrait") as keyof typeof SLOT_COLS;
const cols = SLOT_COLS[slot];
if (!cols) return NextResponse.json({ error: "bad slot" }, { status: 400 });
const cat = rawDb.prepare(`SELECT id, slug, ${cols.path} AS prevPath FROM tag_categories WHERE id = ?`).get(numId) as
| { id: number; slug: string; prevPath: string | null }
| undefined;
if (!cat) return NextResponse.json({ error: "category not found" }, { status: 404 });
const form = await req.formData();
const file = form.get("file");
if (!(file instanceof File)) return NextResponse.json({ error: "missing file" }, { status: 400 });
const ext = path.extname(file.name).toLowerCase();
if (!ALLOWED_EXT.has(ext)) return NextResponse.json({ error: "unsupported format" }, { status: 415 });
const buf = Buffer.from(await file.arrayBuffer());
const sha = crypto.createHash("sha256").update(buf).digest("hex").slice(0, 16);
const filename = `${cat.id}-${slot}-${sha}${ext}`;
await fs.mkdir(COVER_ROOT, { recursive: true });
await fs.writeFile(path.join(COVER_ROOT, filename), buf);
// Replace any previous file in this slot, unless the bytes happened to
// hash to the same name (in which case we just kept it).
if (cat.prevPath && cat.prevPath !== filename) {
const prevAbs = safeJoin(COVER_ROOT, cat.prevPath);
if (prevAbs) await fs.rm(prevAbs, { force: true }).catch(() => {});
}
rawDb.prepare(`
UPDATE tag_categories
SET ${cols.path} = ?, ${cols.zoom} = 1, ${cols.ox} = 0, ${cols.oy} = 0
WHERE id = ?
`).run(filename, cat.id);
revalidatePath("/category");
revalidatePath(`/category/${cat.slug}`);
return NextResponse.json({ coverPath: filename });
}
@@ -0,0 +1,45 @@
import { NextRequest } from "next/server";
import path from "node:path";
import fs from "node:fs/promises";
import crypto from "node:crypto";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const COVER_ROOT = path.join(process.cwd(), "data", "collection-covers");
const IMAGE_MIME: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
};
export async function GET(_req: NextRequest, ctx: { params: Promise<{ file: string }> }) {
const { file } = await ctx.params;
const name = decodeURIComponent(file);
if (name.includes("/") || name.includes("\\") || name.includes("..")) {
return new Response("not found", { status: 404 });
}
const abs = path.join(COVER_ROOT, name);
try {
const buf = await fs.readFile(abs);
const ext = path.extname(abs).toLowerCase();
const etag = `"${crypto.createHash("sha256").update(buf).digest("hex")}"`;
return new Response(new Uint8Array(buf), {
headers: {
"Content-Type": IMAGE_MIME[ext] ?? "application/octet-stream",
"Content-Disposition": rfc5987Disposition(name),
"Cache-Control": "public, max-age=0, must-revalidate",
ETag: etag,
},
});
} catch {
return new Response("not found", { status: 404 });
}
}
function rfc5987Disposition(filename: string): string {
const ascii = filename.replace(/[^\x20-\x7e]/g, "_").replace(/["\\]/g, "_");
const utf8 = encodeURIComponent(filename);
return `inline; filename="${ascii}"; filename*=UTF-8''${utf8}`;
}
+65
View File
@@ -0,0 +1,65 @@
import { NextRequest, NextResponse } from "next/server";
import path from "node:path";
import fs from "node:fs/promises";
import crypto from "node:crypto";
import { revalidatePath } from "next/cache";
import { rawDb } from "@/lib/db/client";
import { safeJoin } from "@/lib/safePath";
import { assertLocalRequest } from "@/lib/api/localOnly";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const COVER_ROOT = path.join(process.cwd(), "data", "collection-covers");
const ALLOWED_EXT = new Set([".jpg", ".jpeg", ".png", ".webp"]);
const SLOT_COLS: Record<string, { path: string; zoom: string; ox: string; oy: string }> = {
portrait: { path: "cover_portrait_path", zoom: "cover_portrait_zoom", ox: "cover_portrait_offset_x", oy: "cover_portrait_offset_y" },
landscape: { path: "cover_landscape_path", zoom: "cover_landscape_zoom", ox: "cover_landscape_offset_x", oy: "cover_landscape_offset_y" },
};
export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
const { id } = await ctx.params;
const numId = Number(id);
if (!Number.isFinite(numId)) return NextResponse.json({ error: "bad id" }, { status: 400 });
const url = new URL(req.url);
const slot = (url.searchParams.get("slot") ?? "portrait") as keyof typeof SLOT_COLS;
const cols = SLOT_COLS[slot];
if (!cols) return NextResponse.json({ error: "bad slot" }, { status: 400 });
const coll = rawDb.prepare(`SELECT id, slug, ${cols.path} AS prevPath FROM collections WHERE id = ?`).get(numId) as
| { id: number; slug: string; prevPath: string | null }
| undefined;
if (!coll) return NextResponse.json({ error: "collection not found" }, { status: 404 });
const form = await req.formData();
const file = form.get("file");
if (!(file instanceof File)) return NextResponse.json({ error: "missing file" }, { status: 400 });
const ext = path.extname(file.name).toLowerCase();
if (!ALLOWED_EXT.has(ext)) return NextResponse.json({ error: "unsupported format" }, { status: 415 });
const buf = Buffer.from(await file.arrayBuffer());
const sha = crypto.createHash("sha256").update(buf).digest("hex").slice(0, 16);
const filename = `${coll.id}-${slot}-${sha}${ext}`;
await fs.mkdir(COVER_ROOT, { recursive: true });
await fs.writeFile(path.join(COVER_ROOT, filename), buf);
if (coll.prevPath && coll.prevPath !== filename) {
const prevAbs = safeJoin(COVER_ROOT, coll.prevPath);
if (prevAbs) await fs.rm(prevAbs, { force: true }).catch(() => {});
}
rawDb.prepare(`
UPDATE collections
SET ${cols.path} = ?, ${cols.zoom} = 1, ${cols.ox} = 0, ${cols.oy} = 0
WHERE id = ?
`).run(filename, coll.id);
revalidatePath("/collection");
revalidatePath(`/collection/${coll.slug}`);
return NextResponse.json({ coverPath: filename });
}
+75
View File
@@ -0,0 +1,75 @@
import { NextRequest, NextResponse } from "next/server";
import { assertLocalRequest } from "@/lib/api/localOnly";
import { listImages, countImages } from "@/lib/db/queries";
import { resolveSort } from "@/lib/sortServer";
import { parseFilterCriteria, statusToFlags } from "@/lib/filters";
import { getAppSetting } from "@/lib/db/appSettings";
import type { LibraryView } from "@/components/grid/ViewToggle";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* Paginated covers feed for client-side infinite-scroll appends.
* Mirrors the SSR filter shape in app/page.tsx — every filter the
* grid supports (letter, search, sort, marks, multi-select tabs)
* resolves through the same listImages/countImages path.
*/
export async function GET(req: NextRequest) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
const sp = req.nextUrl.searchParams;
// Ape Object.fromEntries for plain access matching page params.
const params: Record<string, string | string[] | undefined> = {};
for (const [k, v] of sp.entries()) {
const cur = params[k];
if (cur == null) params[k] = v;
else if (Array.isArray(cur)) cur.push(v);
else params[k] = [cur, v];
}
const criteria = parseFilterCriteria(params);
const sort = await resolveSort(typeof params.sort === "string" ? params.sort : undefined);
const rawLetter = (typeof params.letter === "string" ? params.letter : "").toUpperCase();
const letter = rawLetter === "#" ? "#" : (/^[A-Z]$/.test(rawLetter) ? rawLetter : null);
const search = (typeof params.q === "string" ? params.q.trim() : "") || undefined;
// view is purely a presentational hint; included for symmetry but
// doesn't affect query.
void (params.view === "portrait" ? "portrait" : "landscape" as LibraryView);
const rawPage = typeof params.page === "string" ? Number(params.page) : NaN;
const page = Number.isFinite(rawPage) && rawPage >= 1 ? Math.floor(rawPage) : 1;
const filterOpts = {
sort,
letter: letter ?? undefined,
search,
...statusToFlags(criteria.status),
marks: criteria.marks,
actressIds: criteria.ids.actresses,
actressMode: criteria.mode.actresses,
studioIds: criteria.ids.studios,
seriesIds: criteria.ids.series,
genreIds: criteria.ids.genres,
genreMode: criteria.mode.genres,
collectionIds: criteria.ids.collections,
collectionMode: criteria.mode.collections,
tagIds: criteria.ids.tags,
tagMode: criteria.mode.tags,
categoryIds: criteria.ids.categories,
categoryMode: criteria.mode.categories,
};
const PAGE_SIZE = Math.max(25, Math.min(500, getAppSetting("coverPageSize") ?? 100));
const totalCount = countImages(filterOpts);
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE));
const effectivePage = Math.min(page, totalPages);
const offset = (effectivePage - 1) * PAGE_SIZE;
const items = listImages({ ...filterOpts, limit: PAGE_SIZE, offset });
return NextResponse.json(
{ items, page: effectivePage, totalPages, totalCount, hasMore: effectivePage < totalPages },
{ headers: { "Cache-Control": "no-store" } },
);
}
+14
View File
@@ -0,0 +1,14 @@
import { NextRequest } from "next/server";
import { serveImage } from "@/lib/api/serveAssets";
export const runtime = "nodejs";
export async function GET(req: NextRequest, ctx: { params: Promise<{ file: string }> }) {
const { file } = await ctx.params;
const url = new URL(req.url);
const id = url.searchParams.get("id");
const codeFromPath = decodeURIComponent(file).replace(/\.[^.]+$/, "");
// Don't try to look up by code when the path is the "image-<id>" fallback.
const code = codeFromPath.startsWith("image-") ? null : codeFromPath;
return serveImage(req, { id, code });
}
+84
View File
@@ -0,0 +1,84 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "node:fs/promises";
import path from "node:path";
import { revalidatePath } from "next/cache";
import { assertLocalRequest } from "@/lib/api/localOnly";
import {
attachManualSubtitle,
detachManualSubtitle,
listManualSubtitlesForVariant,
} from "@/lib/video/manualSubtitles";
import { SUBTITLE_EXTS } from "@/lib/video/subtitles";
import { isAllowedSubtitlePath } from "@/lib/video/subtitleAccess";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
interface AttachBody {
partIdx?: number;
abs?: string;
}
export async function POST(req: NextRequest, ctx: { params: Promise<{ code: string }> }) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
const { code } = await ctx.params;
const decoded = decodeURIComponent(code);
const body = (await req.json().catch(() => ({}))) as AttachBody;
const partIdx = typeof body.partIdx === "number" && Number.isFinite(body.partIdx) ? Math.max(0, body.partIdx) : 0;
const abs = typeof body.abs === "string" ? body.abs.trim() : "";
if (!abs) return NextResponse.json({ error: "Missing abs" }, { status: 400 });
const ext = path.extname(abs).toLowerCase();
if (!(SUBTITLE_EXTS as readonly string[]).includes(ext)) {
return NextResponse.json({ error: "Unsupported subtitle extension" }, { status: 415 });
}
// Containment: only attach paths that already pass the same allowlist
// the track endpoint enforces (configured roots / generated-subtitles /
// session-trusted via /api/pick-file). Without this check, any local
// POST could persist an arbitrary on-disk path into manual_subtitles
// and gain permanent read access through the track endpoint.
const absResolved = path.resolve(abs);
if (!isAllowedSubtitlePath(absResolved)) {
return NextResponse.json({ error: "Subtitle path not allowed" }, { status: 403 });
}
// Sanity-check the file is readable. Rejecting now beats silent
// failure later when the picker tries to fetch the track.
try {
await fs.access(absResolved);
} catch {
return NextResponse.json({ error: "File not accessible" }, { status: 404 });
}
attachManualSubtitle(decoded, partIdx, absResolved);
revalidatePath("/id/[code]", "page");
return NextResponse.json({ ok: true });
}
export async function DELETE(req: NextRequest, ctx: { params: Promise<{ code: string }> }) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
const { code } = await ctx.params;
const decoded = decodeURIComponent(code);
const partRaw = req.nextUrl.searchParams.get("part");
const partIdx = partRaw == null ? 0 : Math.max(0, parseInt(partRaw, 10) || 0);
const abs = req.nextUrl.searchParams.get("abs") ?? "";
if (!abs) return NextResponse.json({ error: "Missing abs" }, { status: 400 });
detachManualSubtitle(decoded, partIdx, abs);
return NextResponse.json({ ok: true });
}
export async function GET(req: NextRequest, ctx: { params: Promise<{ code: string }> }) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
const { code } = await ctx.params;
const decoded = decodeURIComponent(code);
const partRaw = req.nextUrl.searchParams.get("part");
const partIdx = partRaw == null ? 0 : Math.max(0, parseInt(partRaw, 10) || 0);
return NextResponse.json({ entries: listManualSubtitlesForVariant(decoded, partIdx) });
}
+123
View File
@@ -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);
});
});
}
+107
View File
@@ -0,0 +1,107 @@
import { NextRequest, NextResponse } from "next/server";
import { spawn } from "node:child_process";
import { assertLocalRequest } from "@/lib/api/localOnly";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* Open a native OS folder-picker dialog and return the absolute path the
* user selected. Works because Pinkudex is local-only — the Next.js
* server has the same desktop session as the browser. Returns
* `{ path: null }` if the user cancels.
*
* Windows: PowerShell + WinForms FolderBrowserDialog.
* macOS: osascript "choose folder".
* Linux: zenity (must be installed).
*/
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 : "";
try {
const path = await runPicker(startPath);
return NextResponse.json({ path });
} catch (e) {
return NextResponse.json({ error: (e as Error).message, path: null }, { status: 500 });
}
}
function runPicker(startPath: string): Promise<string | null> {
if (process.platform === "win32") return pickerWindows(startPath);
if (process.platform === "darwin") return pickerMacOS(startPath);
return pickerLinux(startPath);
}
function pickerWindows(startPath: string): Promise<string | null> {
// STA threading is required for WinForms dialogs in PowerShell.
// -Sta keeps it; -NoProfile avoids whatever the user's profile prints.
// startPath is passed via env var so PowerShell never parses it as code.
const script = `
Add-Type -AssemblyName System.Windows.Forms | Out-Null
$dlg = New-Object System.Windows.Forms.FolderBrowserDialog
$dlg.Description = 'Pinkudex — pick a folder'
$dlg.ShowNewFolderButton = $false
if ($env:PINKUDEX_PICK_START) { try { $dlg.SelectedPath = $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.SelectedPath
}
`.trim();
return runProcess("powershell.exe", ["-NoProfile", "-Sta", "-Command", script], {
PINKUDEX_PICK_START: startPath,
});
}
function pickerMacOS(startPath: string): Promise<string | null> {
const startClause = startPath
? ` default location (POSIX file "${startPath.replace(/"/g, '\\"')}")`
: "";
const script = `try
set f to choose folder with prompt "Pinkudex — pick a folder"${startClause}
return POSIX path of f
on error number -128
return ""
end try`;
return runProcess("osascript", ["-e", script]);
}
function pickerLinux(startPath: string): Promise<string | null> {
const args = ["--file-selection", "--directory", "--title=Pinkudex — pick a folder"];
if (startPath) args.push(`--filename=${startPath.endsWith("/") ? startPath : startPath + "/"}`);
return runProcess("zenity", args).catch((e) => {
throw new Error(`Linux folder 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) => {
// Cancel paths return non-zero (zenity) or empty stdout — treat as null.
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);
});
});
}
+10
View File
@@ -0,0 +1,10 @@
import { NextRequest } from "next/server";
import { servePortrait } from "@/lib/api/serveAssets";
export const runtime = "nodejs";
export async function GET(req: NextRequest) {
const p = new URL(req.url).searchParams.get("p");
if (!p) return new Response("not found", { status: 404 });
return servePortrait(p);
}
+10
View File
@@ -0,0 +1,10 @@
import { NextRequest } from "next/server";
import { serveThumb } from "@/lib/api/serveAssets";
export const runtime = "nodejs";
export async function GET(req: NextRequest) {
const p = new URL(req.url).searchParams.get("p");
if (!p) return new Response("not found", { status: 404 });
return serveThumb(p);
}
+90
View File
@@ -0,0 +1,90 @@
import { NextRequest, NextResponse } from "next/server";
import { ingestFile } from "@/lib/ingest/ingest";
import { assertLocalRequest } from "@/lib/api/localOnly";
import { rawDb } from "@/lib/db/client";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
// Hard cap on a single uploaded file. Pinkudex stores images and short
// covers; anything beyond this is almost certainly a mistake (or an
// attack). Without the cap, `await file.arrayBuffer()` happily buffers
// multi-GB POSTs and OOMs the Node process.
const MAX_UPLOAD_BYTES = 512 * 1024 * 1024;
export async function POST(req: NextRequest) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
const contentLength = Number(req.headers.get("content-length") ?? "");
if (Number.isFinite(contentLength) && contentLength > MAX_UPLOAD_BYTES) {
return NextResponse.json({ error: "Upload too large" }, { status: 413 });
}
const form = await req.formData();
const file = form.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ error: "missing file" }, { status: 400 });
}
if (file.size > MAX_UPLOAD_BYTES) {
return NextResponse.json({ error: "Upload too large" }, { status: 413 });
}
const buf = Buffer.from(await file.arrayBuffer());
const nfoFile = form.get("nfo");
const nfoXml = nfoFile instanceof File ? await nfoFile.text() : undefined;
const autoTag = form.get("autoTag");
const autoCollection = form.get("autoCollection");
let autoCollectionId: number | undefined;
if (typeof autoCollection === "string" && autoCollection.trim()) {
const parsed = Number(autoCollection);
if (!Number.isInteger(parsed) || parsed <= 0) {
return NextResponse.json({ error: "invalid collection" }, { status: 400 });
}
const exists = rawDb.prepare(`SELECT id FROM collections WHERE id = ?`).get(parsed) as { id: number } | undefined;
if (!exists) {
return NextResponse.json({ error: "collection not found" }, { status: 400 });
}
autoCollectionId = parsed;
}
const autoAssign = (typeof autoTag === "string" && autoTag.trim()) || autoCollectionId != null
? {
tagName: typeof autoTag === "string" ? autoTag : undefined,
collectionId: autoCollectionId,
}
: undefined;
const parentImageIdRaw = form.get("parentImageId");
const parentImageId = typeof parentImageIdRaw === "string" && parentImageIdRaw ? Number(parentImageIdRaw) : undefined;
const targetFilenameRaw = form.get("targetFilename");
const targetFilename = typeof targetFilenameRaw === "string" && targetFilenameRaw.trim() ? targetFilenameRaw.trim() : undefined;
const actressNamesRaw = form.get("actressNames");
let actressNames: string[] | undefined;
if (typeof actressNamesRaw === "string" && actressNamesRaw.trim()) {
try {
const parsed = JSON.parse(actressNamesRaw);
if (Array.isArray(parsed)) actressNames = parsed.filter((s): s is string => typeof s === "string");
} catch {
// ignore
}
}
const onCollisionRaw = form.get("onCollision");
const onCollision = onCollisionRaw === "replace" || onCollisionRaw === "skip" ? onCollisionRaw : "detect";
try {
const result = await ingestFile(buf, file.name, {
nfoXml,
autoAssign,
parentImageId,
targetFilename,
actressNames,
onCollision,
});
return NextResponse.json(result);
} catch (err) {
console.error("ingest failed", err);
return NextResponse.json({ error: (err as Error).message }, { status: 500 });
}
}
+143
View File
@@ -0,0 +1,143 @@
import { NextRequest, NextResponse } from "next/server";
import path from "node:path";
import { findVideosForCode } from "@/lib/video";
import { assertLocalRequest } from "@/lib/api/localOnly";
import { getStoredVideoMetadata, serializeVideoMetadata } from "@/lib/video/metadata";
import { variantLabel } from "@/lib/video/partClassify";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
interface VariantOut {
/** Absolute 0-based index into the original findVideosForCode result.
* Used as the `?part=` query value for stream/HLS endpoints. */
partIdx: number;
abs: string;
rel: string;
filename: string;
size: number;
label: string;
metadata: ReturnType<typeof serializeVideoMetadata>;
}
interface PartOut {
/** 1-based display index for the parts strip. */
partIndex: number;
/** Index into `variants[]` to use when no user pick has been made. */
defaultIdx: number;
variants: VariantOut[];
}
function stemOf(filename: string): string {
const ext = path.extname(filename);
return ext ? filename.slice(0, -ext.length) : filename;
}
/**
* Group raw video files into parts (sequential CDs/discs) with
* variants (alt encodes of the same part). Uses classification from
* the metadata table; falls back to "every file is its own part" when
* classification hasn't run yet.
*/
export async function GET(req: NextRequest, ctx: { params: Promise<{ code: string }> }) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
const { code } = await ctx.params;
const decoded = decodeURIComponent(code);
const files = findVideosForCode(decoded);
// Build per-part groups.
const partMap = new Map<string, VariantOut[]>();
const orderedKeys: string[] = [];
files.forEach((f, i) => {
const meta = getStoredVideoMetadata(f.abs);
const stem = stemOf(f.filename);
const kind = meta?.partKind;
const idx = meta?.partIndex ?? null;
const group = meta?.variantGroup ?? null;
// Group key strategy:
// - "part" → group by the part's variantGroup (variants attach via dot-prefix)
// - "variant" → group by their attached variantGroup
// - "single" / unclassified → a singleton group keyed by abs path
let key: string;
if ((kind === "part" || kind === "variant") && group != null) {
key = `g:${group}`;
} else {
key = `s:${f.abs}`;
}
const variant: VariantOut = {
partIdx: i,
abs: f.abs,
rel: f.rel,
filename: f.filename,
size: f.size,
label: group ? variantLabel(stem, group) : "original",
metadata: serializeVideoMetadata(meta),
};
// Stash the underlying part index for sorting; non-parts get +Infinity.
(variant as VariantOut & { __sort: number }).__sort = idx ?? (kind === "variant" ? -1 : Number.MAX_SAFE_INTEGER);
let arr = partMap.get(key);
if (!arr) {
arr = [];
partMap.set(key, arr);
orderedKeys.push(key);
}
arr.push(variant);
});
// Build the ordered parts list. Sort parts by their lowest known
// partIndex (singles fall to the end), preserving insertion order
// as a tiebreak.
const partEntries = orderedKeys.map((k) => {
const variants = partMap.get(k)!;
const minSort = Math.min(...variants.map((v) => (v as VariantOut & { __sort: number }).__sort));
return { key: k, variants, sort: minSort };
});
partEntries.sort((a, b) => {
if (a.sort !== b.sort) return a.sort - b.sort;
return a.variants[0]!.partIdx - b.variants[0]!.partIdx;
});
const parts: PartOut[] = partEntries.map((entry, i) => {
const variants = entry.variants;
// Strip the sort helper field.
for (const v of variants) delete (v as Partial<VariantOut & { __sort: number }>).__sort;
// Default = the variant whose stem == group (the "base" file). If
// none, alphabetically first by filename.
const groupKey = entry.key.startsWith("g:") ? entry.key.slice(2) : null;
let defaultIdx = 0;
if (groupKey != null) {
const exact = variants.findIndex((v) => stemOf(v.filename) === groupKey);
if (exact >= 0) defaultIdx = exact;
else {
const sortedAlpha = [...variants].sort((a, b) => a.filename.localeCompare(b.filename));
defaultIdx = variants.indexOf(sortedAlpha[0]!);
}
}
return {
partIndex: i + 1,
defaultIdx,
variants,
};
});
// Backwards-compatible flat list — the default variant of each part
// in display order. Existing consumers that only need one entry per
// part keep working without changes.
const flat = parts.map((p) => p.variants[p.defaultIdx]!);
return NextResponse.json({
parts,
files: flat.map((v) => ({
abs: v.abs,
rel: v.rel,
filename: v.filename,
size: v.size,
metadata: v.metadata,
})),
});
}
@@ -0,0 +1,83 @@
import { NextRequest } from "next/server";
import fsp from "node:fs/promises";
import { findVideosForCode, getVideoIndex, rescanVideoIndex } from "@/lib/video";
import { getAppSetting } from "@/lib/db/appSettings";
import { probeDuration } from "@/lib/video/duration";
import { assertLocalRequest } from "@/lib/api/localOnly";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* HLS playlist generator. Returns an m3u8 with N segment URLs covering
* the full video duration. Segments are produced on demand by the
* sibling /segment endpoint (each one is a fresh NVENC transcode of a
* fixed time window). Player (hls.js) requests segments as needed for
* playback and seeking.
*/
const SEGMENT_SECONDS = 6;
export async function GET(req: NextRequest, ctx: { params: Promise<{ code: string }> }) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
const { code } = await ctx.params;
const decoded = decodeURIComponent(code);
const url = new URL(req.url);
const partRaw = url.searchParams.get("part");
const part = partRaw == null ? 0 : Math.max(0, parseInt(partRaw, 10) || 0);
let files = findVideosForCode(decoded);
if (files.length === 0) {
const main = (getAppSetting("videoLibraryPath") || "").trim();
const extras = getAppSetting("videoExtraPaths") ?? [];
const expected = [main, ...extras].filter(Boolean);
const idx = getVideoIndex();
const haveAll = expected.length === idx.rootsScanned.length
&& expected.every((r, i) => r === idx.rootsScanned[i]);
if (expected.length > 0 && !haveAll) {
await rescanVideoIndex();
files = findVideosForCode(decoded);
}
}
if (files.length === 0) return new Response("not found", { status: 404 });
const file = files[Math.min(part, files.length - 1)];
try {
await fsp.stat(file.abs);
} catch {
return new Response("not found", { status: 404 });
}
const duration = await probeDuration(file.abs, req.signal);
if (duration == null) {
return new Response("ffprobe failed", { status: 500 });
}
const segCount = Math.ceil(duration / SEGMENT_SECONDS);
const lines: string[] = [
"#EXTM3U",
"#EXT-X-VERSION:3",
`#EXT-X-TARGETDURATION:${SEGMENT_SECONDS}`,
"#EXT-X-MEDIA-SEQUENCE:0",
"#EXT-X-PLAYLIST-TYPE:VOD",
];
for (let i = 0; i < segCount; i++) {
const remaining = duration - i * SEGMENT_SECONDS;
const segDur = Math.min(SEGMENT_SECONDS, remaining);
lines.push(`#EXTINF:${segDur.toFixed(3)},`);
// Relative URL — resolves against the playlist URL's directory.
// Playlist is at /api/video-hls/[code]/playlist, so its directory is
// /api/video-hls/[code]/ and `segment?...` resolves to the sibling.
lines.push(`segment?part=${part}&i=${i}`);
}
lines.push("#EXT-X-ENDLIST");
return new Response(lines.join("\n"), {
status: 200,
headers: {
"Content-Type": "application/vnd.apple.mpegurl",
"Cache-Control": "no-store",
},
});
}
+160
View File
@@ -0,0 +1,160 @@
import { NextRequest } from "next/server";
import { spawn, type ChildProcess } from "node:child_process";
import fsp from "node:fs/promises";
import { findVideosForCode, getVideoIndex, rescanVideoIndex } from "@/lib/video";
import { getAppSetting } from "@/lib/db/appSettings";
import { probeDuration } from "@/lib/video/duration";
import { assertLocalRequest } from "@/lib/api/localOnly";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* HLS segment endpoint. Each request transcodes a single 6-second
* window of the source via NVENC into MPEG-TS and pipes to the
* response. -bf 0 keeps Chromium's H.264 sink happy. -force_key_frames
* 0 (and NVENC's -forced-idr) ensure the segment opens with an IDR so
* it's independently decodable — required by HLS.
*/
const SEGMENT_SECONDS = 6;
export async function GET(req: NextRequest, ctx: { params: Promise<{ code: string }> }) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
const { code } = await ctx.params;
const decoded = decodeURIComponent(code);
const url = new URL(req.url);
const partRaw = url.searchParams.get("part");
const part = partRaw == null ? 0 : Math.max(0, parseInt(partRaw, 10) || 0);
const iRaw = url.searchParams.get("i");
const segmentIndex = iRaw == null ? 0 : Math.max(0, parseInt(iRaw, 10) || 0);
let files = findVideosForCode(decoded);
if (files.length === 0) {
const main = (getAppSetting("videoLibraryPath") || "").trim();
const extras = getAppSetting("videoExtraPaths") ?? [];
const expected = [main, ...extras].filter(Boolean);
const idx = getVideoIndex();
const haveAll = expected.length === idx.rootsScanned.length
&& expected.every((r, i) => r === idx.rootsScanned[i]);
if (expected.length > 0 && !haveAll) {
await rescanVideoIndex();
files = findVideosForCode(decoded);
}
}
if (files.length === 0) return new Response("not found", { status: 404 });
const file = files[Math.min(part, files.length - 1)];
try {
await fsp.stat(file.abs);
} catch {
return new Response("not found", { status: 404 });
}
const duration = await probeDuration(file.abs, req.signal);
if (duration == null) {
return new Response("ffprobe failed", { status: 500 });
}
const startTime = segmentIndex * SEGMENT_SECONDS;
if (startTime >= duration) {
return new Response("segment out of range", { status: 416 });
}
const segDur = Math.min(SEGMENT_SECONDS, duration - startTime);
const ffmpegArgs: string[] = [
"-hide_banner", "-loglevel", "error",
// -ss before -i = fast container-level seek (lands on the prior key
// frame, NVENC's first emitted frame is an IDR by spec).
"-ss", startTime.toFixed(3),
"-t", segDur.toFixed(3),
"-i", file.abs,
"-map", "0:v:0",
"-map", "0:a:0?",
"-c:v", "h264_nvenc",
"-preset", "p4",
"-tune", "ll",
"-profile:v", "high",
"-bf", "0",
"-forced-idr", "1",
"-rc", "cbr",
"-b:v", "8M",
"-maxrate", "8M",
"-bufsize", "16M",
"-pix_fmt", "yuv420p",
"-c:a", "aac",
"-b:a", "192k",
"-ac", "2",
"-f", "mpegts",
"-mpegts_flags", "+resend_headers",
// Shift output timestamps so segment N's PTS starts at N*SEGMENT_SECONDS.
// Without this, every segment would emit at PTS≈0 and hls.js / MSE
// can't lay them out on a continuous timeline (would need
// #EXT-X-DISCONTINUITY markers for that). Continuous PTS = clean
// append, smooth playback across segment boundaries.
"-output_ts_offset", startTime.toFixed(3),
"pipe:1",
];
let ffmpeg: ChildProcess;
try {
ffmpeg = spawn("ffmpeg", ffmpegArgs, { stdio: ["ignore", "pipe", "pipe"] });
} catch (e) {
return new Response(`ffmpeg spawn failed: ${(e as Error).message}`, { status: 500 });
}
ffmpeg.stderr?.on("data", (chunk: Buffer) => {
const text = chunk.toString();
if (text.trim()) console.error(`[hls ${decoded} seg=${segmentIndex}] ${text.trim()}`);
});
return new Response(streamFromFfmpeg(ffmpeg, req.signal), {
status: 200,
headers: {
"Content-Type": "video/mp2t",
// Allow short-term caching — within a single playback session hls.js
// may re-request a segment if its buffer was evicted, and a cache
// hit avoids re-spawning ffmpeg.
"Cache-Control": "private, max-age=300",
},
});
}
function streamFromFfmpeg(proc: ChildProcess, signal: AbortSignal): ReadableStream<Uint8Array> {
return new ReadableStream<Uint8Array>({
start(controller) {
let closed = false;
const finish = () => {
if (closed) return;
closed = true;
try { controller.close(); } catch { /* already closed */ }
};
const fail = (err: Error) => {
if (closed) return;
closed = true;
try { controller.error(err); } catch { /* already closed */ }
};
proc.stdout?.on("data", (chunk: Buffer) => {
if (closed) return;
try {
controller.enqueue(new Uint8Array(chunk));
} catch {
closed = true;
try { proc.kill("SIGKILL"); } catch { /* ignore */ }
}
});
proc.stdout?.on("end", finish);
proc.on("error", (e) => fail(e));
proc.on("exit", finish);
const onAbort = () => {
try { proc.kill("SIGKILL"); } catch { /* ignore */ }
};
if (signal.aborted) onAbort();
else signal.addEventListener("abort", onAbort, { once: true });
},
cancel() {
try { proc.kill("SIGKILL"); } catch { /* ignore */ }
},
});
}
+87
View File
@@ -0,0 +1,87 @@
import { NextRequest, NextResponse } from "next/server";
import { findVideosForCode, getVideoIndex, rescanVideoIndex } from "@/lib/video";
import { getAppSetting } from "@/lib/db/appSettings";
import { assertLocalRequest } from "@/lib/api/localOnly";
import { probeVideoMetadata, serializeVideoMetadata, setVideoPlaybackMode } from "@/lib/video/metadata";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
interface ProbeResponse {
codec: string | null;
bFrames: number | null;
cachedMode: string | null;
metadata: ReturnType<typeof serializeVideoMetadata>;
}
async function resolveFile(decoded: string, partIdx: number) {
let files = findVideosForCode(decoded);
if (files.length === 0) {
const main = (getAppSetting("videoLibraryPath") || "").trim();
const extras = getAppSetting("videoExtraPaths") ?? [];
const expected = [main, ...extras].filter(Boolean);
const idx = getVideoIndex();
const haveAll = expected.length === idx.rootsScanned.length
&& expected.every((r, i) => r === idx.rootsScanned[i]);
if (expected.length > 0 && !haveAll) {
await rescanVideoIndex();
files = findVideosForCode(decoded);
}
}
if (files.length === 0) return null;
return files[Math.min(Math.max(0, partIdx), files.length - 1)];
}
export async function GET(req: NextRequest, ctx: { params: Promise<{ code: string }> }) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
const { code } = await ctx.params;
const decoded = decodeURIComponent(code);
const url = new URL(req.url);
const partRaw = url.searchParams.get("part");
const partIdx = partRaw == null ? 0 : Math.max(0, parseInt(partRaw, 10) || 0);
const file = await resolveFile(decoded, partIdx);
if (!file) {
return NextResponse.json<ProbeResponse>({ codec: null, bFrames: null, cachedMode: null, metadata: null });
}
try {
const meta = await probeVideoMetadata(file, req.signal);
return NextResponse.json<ProbeResponse>({
codec: meta.videoCodec,
bFrames: meta.videoBFrames,
cachedMode: meta.playbackMode,
metadata: serializeVideoMetadata(meta),
});
} catch (e) {
console.error("[video-probe] failed:", e);
return NextResponse.json<ProbeResponse>(
{ codec: null, bFrames: null, cachedMode: null, metadata: null },
{ status: 200 },
);
}
}
export async function POST(req: NextRequest, ctx: { params: Promise<{ code: string }> }) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
const { code } = await ctx.params;
const decoded = decodeURIComponent(code);
const url = new URL(req.url);
const partRaw = url.searchParams.get("part");
const partIdx = partRaw == null ? 0 : Math.max(0, parseInt(partRaw, 10) || 0);
const body = await req.json().catch(() => ({})) as { mode?: string | null };
const mode = body.mode;
if (mode !== "direct" && mode !== "transcode" && mode !== null && mode !== undefined) {
return NextResponse.json({ error: "invalid mode" }, { status: 400 });
}
const file = await resolveFile(decoded, partIdx);
if (!file) return NextResponse.json({ updated: 0 });
setVideoPlaybackMode(file, mode ?? null);
return NextResponse.json({ updated: 1 });
}
+33
View File
@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from "next/server";
import { revalidatePath } from "next/cache";
import { rescanVideoIndex } from "@/lib/video";
import { rawDb } from "@/lib/db/client";
import { assertLocalRequest } from "@/lib/api/localOnly";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST(req: NextRequest) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
const t0 = Date.now();
const force = req.nextUrl.searchParams.get("force") === "1";
const idx = await rescanVideoIndex({ force });
// Bust the RSC cache for detail pages so file-size / duration
// refresh without a navigation. Skip the layout invalidation —
// it triggers a full-app re-render and isn't needed for the
// metadata badges we actually changed.
revalidatePath("/id/[code]", "page");
// codes count comes from the DB now, not an in-memory Map. Cheap.
const distinctCodesRow = rawDb
.prepare(`SELECT COUNT(DISTINCT upper(code)) AS n FROM video_metadata`)
.get() as { n: number };
return NextResponse.json({
ok: true,
count: idx.count,
codes: distinctCodesRow.n,
rootsScanned: idx.rootsScanned,
elapsedMs: Date.now() - t0,
});
}
+42
View File
@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from "next/server";
import { spawn } from "node:child_process";
import { findVideosForCode } from "@/lib/video";
import { assertLocalRequest } from "@/lib/api/localOnly";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* Open the OS file manager pre-selected on the cover's video file.
* Local-only — explicitly gated by assertLocalRequest.
*/
export async function POST(req: NextRequest, ctx: { params: Promise<{ code: string }> }) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
const { code } = await ctx.params;
const decoded = decodeURIComponent(code);
const url = new URL(req.url);
const partRaw = url.searchParams.get("part");
const part = partRaw == null ? 0 : Math.max(0, parseInt(partRaw, 10) || 0);
const files = findVideosForCode(decoded);
if (files.length === 0) return NextResponse.json({ error: "not found" }, { status: 404 });
const file = files[Math.min(part, files.length - 1)];
try {
if (process.platform === "win32") {
// explorer doesn't return zero-exit even on success; detach and don't await.
spawn("explorer", ["/select,", file.abs], { detached: true, stdio: "ignore" }).unref();
} else if (process.platform === "darwin") {
spawn("open", ["-R", file.abs], { detached: true, stdio: "ignore" }).unref();
} else {
// Linux: open the parent dir; most file managers don't have a select API.
const parent = file.abs.replace(/[/\\][^/\\]*$/, "");
spawn("xdg-open", [parent], { detached: true, stdio: "ignore" }).unref();
}
return NextResponse.json({ ok: true, path: file.abs });
} catch (e) {
return NextResponse.json({ error: (e as Error).message }, { status: 500 });
}
}
+39
View File
@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from "next/server";
import { getVideoIndex, rescanVideoIndex, getCodesWithVideos, getCodesWithSubtitles } from "@/lib/video";
import { getAppSetting } from "@/lib/db/appSettings";
import { assertLocalRequest } from "@/lib/api/localOnly";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* Lightweight enumeration of every JAV code that has at least one
* playable file in the index. The client uses this to show "has video"
* badges on cover cards. Returned as a plain array for JSON portability.
*
* Auto-builds the index on first hit if a video folder is configured but
* the index is empty — avoids requiring a manual rescan on a fresh
* server boot.
*/
export async function GET(req: NextRequest) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
let idx = getVideoIndex();
const main = (getAppSetting("videoLibraryPath") || "").trim();
const extras = getAppSetting("videoExtraPaths") ?? [];
const expected = [main, ...extras].filter(Boolean);
const haveAll = expected.length === idx.rootsScanned.length
&& expected.every((r, i) => r === idx.rootsScanned[i]);
if (expected.length > 0 && !haveAll) {
idx = await rescanVideoIndex();
}
return NextResponse.json({
codes: Array.from(getCodesWithVideos()),
subtitleCodes: Array.from(getCodesWithSubtitles()),
count: idx.count,
lastScannedAt: idx.lastScannedAt,
rootsScanned: idx.rootsScanned,
});
}
+164
View File
@@ -0,0 +1,164 @@
import { NextRequest } from "next/server";
import path from "node:path";
import fs from "node:fs";
import fsp from "node:fs/promises";
import { findVideosForCode } from "@/lib/video";
import { assertLocalRequest } from "@/lib/api/localOnly";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const MIME_BY_EXT: Record<string, string> = {
".mp4": "video/mp4",
".m4v": "video/mp4",
".mov": "video/quicktime",
".webm": "video/webm",
".mkv": "video/x-matroska",
".avi": "video/x-msvideo",
".wmv": "video/x-ms-wmv",
".ts": "video/mp2t",
".mpg": "video/mpeg",
".mpeg": "video/mpeg",
".flv": "video/x-flv",
};
export async function GET(req: NextRequest, ctx: { params: Promise<{ code: string }> }) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
const { code } = await ctx.params;
const decoded = decodeURIComponent(code);
const url = new URL(req.url);
const partRaw = url.searchParams.get("part");
const part = partRaw == null ? 0 : Math.max(0, parseInt(partRaw, 10) || 0);
const files = findVideosForCode(decoded);
if (files.length === 0) return new Response("not found", { status: 404 });
const file = files[Math.min(part, files.length - 1)];
let stat: import("node:fs").Stats;
try {
stat = await fsp.stat(file.abs);
} catch {
return new Response("not found", { status: 404 });
}
const total = stat.size;
const ext = path.extname(file.abs).toLowerCase();
const mime = MIME_BY_EXT[ext] ?? "application/octet-stream";
// Stable identity for the byte stream — lets the browser's HTTP cache
// hold onto previously fetched ranges (the moov tail in particular)
// instead of re-hitting our endpoint on every seek / buffer-ahead.
const etag = `"${stat.size.toString(36)}-${Math.floor(stat.mtimeMs).toString(36)}"`;
const lastModified = new Date(stat.mtimeMs).toUTCString();
const range = req.headers.get("range");
const baseHeaders: Record<string, string> = {
"Content-Type": mime,
"Accept-Ranges": "bytes",
"Cache-Control": "private, max-age=3600",
"ETag": etag,
"Last-Modified": lastModified,
"Content-Disposition": `inline; filename="${encodeURIComponent(file.filename)}"`,
};
if (!range) {
return new Response(streamFile(file.abs, undefined, undefined, req.signal), {
status: 200,
headers: { ...baseHeaders, "Content-Length": String(total) },
});
}
// Parse "bytes=START-END"; END may be empty for "until end", and
// START may be empty for HTTP suffix ranges ("last N bytes").
const m = /^bytes=(\d*)-(\d*)$/.exec(range);
if (!m) return new Response("bad range", { status: 416 });
let start: number;
let end: number;
if (m[1] === "") {
const suffixLen = Number(m[2]);
if (!Number.isFinite(suffixLen) || suffixLen <= 0) {
return new Response("bad range", { status: 416, headers: { "Content-Range": `bytes */${total}` } });
}
start = Math.max(total - suffixLen, 0);
end = total - 1;
} else {
start = Number(m[1]);
end = m[2] === "" ? total - 1 : Number(m[2]);
}
if (!Number.isFinite(start) || !Number.isFinite(end) || start > end || end >= total) {
return new Response("bad range", { status: 416, headers: { "Content-Range": `bytes */${total}` } });
}
const len = end - start + 1;
return new Response(streamFile(file.abs, start, end, req.signal), {
status: 206,
headers: {
...baseHeaders,
"Content-Range": `bytes ${start}-${end}/${total}`,
"Content-Length": String(len),
},
});
}
/**
* Pipe a file slice into a Web ReadableStream that the runtime can hand
* to fetch's Response. Tying the read stream to the request's AbortSignal
* is the bit that fixes "Invalid state: Controller is already closed":
* when the browser cancels (modal close, seek, network blip) the Node
* stream is destroyed before it can push more bytes into a stream the
* runtime has already closed.
*/
function streamFile(
abs: string,
start: number | undefined,
end: number | undefined,
signal: AbortSignal,
): ReadableStream<Uint8Array> {
let node: fs.ReadStream | null = null;
let closed = false;
return new ReadableStream<Uint8Array>({
start(controller) {
node = fs.createReadStream(abs, { start, end });
const finish = () => {
if (closed) return;
closed = true;
try { controller.close(); } catch { /* already closed */ }
};
const fail = (err: Error) => {
if (closed) return;
closed = true;
try { controller.error(err); } catch { /* already closed */ }
};
node.on("data", (chunk: unknown) => {
if (closed) return;
try {
const u8 = chunk instanceof Uint8Array
? chunk
: new Uint8Array(chunk as ArrayBufferLike);
controller.enqueue(u8);
} catch {
closed = true;
node?.destroy();
}
});
node.on("end", finish);
node.on("error", (err) => fail(err as Error));
const onAbort = () => {
closed = true;
node?.destroy();
};
if (signal.aborted) onAbort();
else signal.addEventListener("abort", onAbort, { once: true });
},
cancel() {
// ReadableStream.cancel() fires when the consumer is done before
// req.signal aborts (e.g. browser closes the response body cleanly
// after a Range fulfill). Without destroying the node stream here,
// the open file handle leaks until GC.
closed = true;
node?.destroy();
},
});
}
+221
View File
@@ -0,0 +1,221 @@
import { NextRequest, NextResponse } from "next/server";
import path from "node:path";
import { findVideosForCode, getVideoIndex, rescanVideoIndex } from "@/lib/video";
import { assertLocalRequest } from "@/lib/api/localOnly";
import {
walkSubtitles,
detectLanguageFromName,
normalizeLanguageTag,
languageDisplay,
stemOf,
type LangIso,
} from "@/lib/video/subtitles";
import { runFfprobeSubtitles } from "@/lib/video/metadata";
import { getAppSetting } from "@/lib/db/appSettings";
import { listManualSubtitlesForVariant } from "@/lib/video/manualSubtitles";
import fs from "node:fs";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/** Sidecar (external file) subtitle source. */
interface SidecarOut {
/** Stable client-side id; encodes the abs path so the track endpoint
* can resolve it. */
id: string;
abs: string;
filename: string;
ext: string; // ".srt" | ".vtt" | ".ass" | ".ssa"
language: LangIso | null;
label: string;
origin: "same-folder" | "library" | "manual";
}
/** Embedded-stream subtitle source (filled in once ffprobe is wired up
* in phase 2). */
interface EmbeddedOut {
id: string;
streamIndex: number;
codec: string;
language: LangIso | null;
label: string;
renderable: boolean;
}
function formatCodecLabel(codec: string): string | null {
switch (codec) {
case "subrip": return "SRT";
case "ass": return "ASS";
case "ssa": return "SSA";
case "mov_text": return "mov_text";
case "webvtt": return "VTT";
case "hdmv_pgs_subtitle": return "PGS";
case "dvd_subtitle": return "DVDSub";
case "dvb_subtitle": return "DVBSub";
default: return codec ? codec.toUpperCase() : null;
}
}
function encodeSideId(abs: string): string {
return `side:${Buffer.from(abs, "utf8").toString("base64url")}`;
}
/** Filter walkSubtitles results to entries that look like they belong
* to this specific video — stem prefix is the strong signal; code
* substring is the fallback. Both case-insensitive. */
function matchesVideo(filename: string, stem: string, code: string): boolean {
const lowerName = filename.toLowerCase();
const lowerStem = stem.toLowerCase();
const lowerCode = code.toLowerCase();
if (lowerName.startsWith(lowerStem + ".")) return true;
if (lowerName === lowerStem) return true;
return lowerName.includes(lowerCode);
}
export async function GET(req: NextRequest, ctx: { params: Promise<{ code: string }> }) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
const { code } = await ctx.params;
const decoded = decodeURIComponent(code);
const partParam = req.nextUrl.searchParams.get("part");
const partIdx = partParam == null ? 0 : Number.parseInt(partParam, 10);
if (!Number.isFinite(partIdx) || partIdx < 0) {
return NextResponse.json({ error: "Invalid part index" }, { status: 400 });
}
let files = findVideosForCode(decoded);
if (files.length === 0) {
// Cold-boot path: VideoIndexProvider may not have triggered the
// initial scan yet. Build it once so the picker doesn't appear
// empty on first modal open after server start.
const main = (getAppSetting("videoLibraryPath") || "").trim();
const extras = getAppSetting("videoExtraPaths") ?? [];
const expected = [main, ...extras].filter(Boolean);
const idx = getVideoIndex();
const haveAll = expected.length === idx.rootsScanned.length
&& expected.every((r, i) => r === idx.rootsScanned[i]);
if (expected.length > 0 && !haveAll) {
await rescanVideoIndex();
files = findVideosForCode(decoded);
}
}
const variant = files[partIdx];
if (!variant) {
return NextResponse.json({ embedded: [], sidecar: [] });
}
const variantStem = stemOf(variant.filename);
const dir = path.dirname(variant.abs);
// Phase 1: same-folder sidecars only. Embedded streams + library scan
// are added in later phases via additive concat into these arrays.
const sidecar: SidecarOut[] = [];
const seen = new Set<string>();
const pushEntry = (
entry: { abs: string; filename: string },
origin: "same-folder" | "library",
) => {
if (seen.has(entry.abs)) return;
if (!matchesVideo(entry.filename, variantStem, decoded)) return;
seen.add(entry.abs);
const detected = detectLanguageFromName(entry.filename);
const ext = path.extname(entry.filename).toLowerCase();
sidecar.push({
id: encodeSideId(entry.abs),
abs: entry.abs,
filename: entry.filename,
ext,
language: detected.lang,
label: detected.label,
origin,
});
};
try {
for (const entry of await walkSubtitles(dir, 1)) pushEntry(entry, "same-folder");
} catch { /* ignore */ }
// Library scan: persistent extra paths from settings. Slightly deeper
// walk because users typically point these at organized hierarchies.
const extraPaths = (getAppSetting("subtitleExtraPaths") ?? []).filter(Boolean);
for (const root of extraPaths) {
try {
for (const entry of await walkSubtitles(root, 3)) pushEntry(entry, "library");
} catch { /* missing or unreadable root */ }
}
// Implicit always-on root: data/generated-subtitles/<code>/ catches
// WhisperJAV-produced .srt when the video folder isn't writable.
const generatedDir = path.join(process.cwd(), "data", "generated-subtitles", decoded);
try {
for (const entry of await walkSubtitles(generatedDir, 1)) pushEntry(entry, "library");
} catch { /* nothing generated yet */ }
// Manually attached files via Browse... in the player. Persisted
// across sessions; only included when the file still exists on disk.
for (const m of listManualSubtitlesForVariant(decoded, partIdx)) {
if (seen.has(m.absPath)) continue;
if (!fs.existsSync(m.absPath)) continue;
const filename = path.basename(m.absPath);
if (!filename) continue;
const detected = detectLanguageFromName(filename);
const ext = path.extname(filename).toLowerCase();
seen.add(m.absPath);
sidecar.push({
id: encodeSideId(m.absPath),
abs: m.absPath,
filename,
ext,
language: detected.lang,
label: detected.label,
origin: "manual",
});
}
// Stable order: same-folder before library, then by language priority
// (EN, CN, JP, Unknown), then by filename.
const langRank: Record<string, number> = { eng: 0, zho: 1, jpn: 2 };
sidecar.sort((a, b) => {
if (a.origin !== b.origin) return a.origin === "same-folder" ? -1 : 1;
const ra = a.language ? (langRank[a.language] ?? 9) : 9;
const rb = b.language ? (langRank[b.language] ?? 9) : 9;
if (ra !== rb) return ra - rb;
return a.filename.localeCompare(b.filename);
});
const embedded: EmbeddedOut[] = [];
let streams: Awaited<ReturnType<typeof runFfprobeSubtitles>> = [];
try {
streams = await runFfprobeSubtitles(variant.abs);
} catch {
streams = [];
}
for (const s of streams) {
const iso = normalizeLanguageTag(s.language);
const codecLabel = formatCodecLabel(s.codec);
const trailing: string[] = [];
if (s.title) trailing.push(s.title);
if (codecLabel) trailing.push(codecLabel);
const base = iso ? languageDisplay(iso) : (s.title ?? "Unknown");
const label = trailing.length > 0 && !iso
? `${base}${codecLabel ? ` (${codecLabel})` : ""}`
: codecLabel
? `${base} (${codecLabel})`
: base;
embedded.push({
id: `emb:${s.index}`,
streamIndex: s.index,
codec: s.codec,
language: iso,
label,
renderable: s.isTextBased,
});
}
return NextResponse.json(
{ embedded, sidecar },
{ headers: { "Cache-Control": "no-store" } },
);
}
@@ -0,0 +1,242 @@
import { NextRequest, NextResponse } from "next/server";
import path from "node:path";
import fs from "node:fs/promises";
import { spawn } from "node:child_process";
import { assertLocalRequest } from "@/lib/api/localOnly";
import { srtToVtt, SUBTITLE_EXTS, decodeSubtitleBuffer } from "@/lib/video/subtitles";
import { isAllowedSubtitlePath } from "@/lib/video/subtitleAccess";
import { cachePath, readCache, writeCache } from "@/lib/video/subtitleCache";
import { findVideosForCode } from "@/lib/video";
import { runFfprobeSubtitles } from "@/lib/video/metadata";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const VTT_HEADERS = {
"Content-Type": "text/vtt; charset=utf-8",
"Cache-Control": "no-store",
} as const;
function decodeSide(src: string): string | null {
if (!src.startsWith("side:")) return null;
const b64 = src.slice("side:".length);
try {
const decoded = Buffer.from(b64, "base64url").toString("utf8");
if (!decoded) return null;
return path.resolve(decoded);
} catch {
return null;
}
}
export async function GET(req: NextRequest, ctx: { params: Promise<{ code: string }> }) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
const src = req.nextUrl.searchParams.get("src") ?? "";
if (!src) {
return NextResponse.json({ error: "Missing src" }, { status: 400 });
}
if (src.startsWith("emb:")) {
return handleEmbedded(req, ctx, src);
}
const abs = decodeSide(src);
if (!abs) {
return NextResponse.json({ error: "Invalid src" }, { status: 400 });
}
if (!isAllowedSubtitlePath(abs)) {
return NextResponse.json({ error: "Subtitle path not allowed" }, { status: 403 });
}
const ext = path.extname(abs).toLowerCase();
if (!(SUBTITLE_EXTS as readonly string[]).includes(ext)) {
return NextResponse.json({ error: "Unsupported subtitle extension" }, { status: 415 });
}
let stat;
try {
stat = await fs.stat(abs);
} catch {
return NextResponse.json({ error: "Subtitle file not found" }, { status: 404 });
}
if (ext === ".vtt") {
// VTT spec mandates UTF-8 but real-world files occasionally ship
// as UTF-16 BOM or a legacy Asian encoding. Run through the same
// decoder as .srt so the output is consistent UTF-8.
let buf: Buffer;
try {
buf = await fs.readFile(abs);
} catch {
return NextResponse.json({ error: "Read failed" }, { status: 500 });
}
const text = decodeSubtitleBuffer(buf);
return new NextResponse(text, { headers: VTT_HEADERS });
}
if (ext === ".srt") {
const file = cachePath({
abs,
size: stat.size,
mtimeMs: stat.mtimeMs,
kind: "srt",
streamOrExt: "srt",
});
const cached = await readCache(file);
if (cached) return new NextResponse(new Uint8Array(cached), { headers: VTT_HEADERS });
let buf: Buffer;
try {
buf = await fs.readFile(abs);
} catch {
return NextResponse.json({ error: "Read failed" }, { status: 500 });
}
// decodeSubtitleBuffer auto-detects UTF-8 / UTF-16 / shift_jis /
// gb18030 / big5 — a bare `toString("utf8")` mojibakes legacy CN
// and JP fansub SRTs.
const raw = decodeSubtitleBuffer(buf);
const vtt = srtToVtt(raw);
try {
await writeCache(file, vtt);
} catch {
// Cache miss + failed write isn't fatal; still serve the conversion.
}
return new NextResponse(vtt, { headers: VTT_HEADERS });
}
if (ext === ".ass" || ext === ".ssa") {
const file = cachePath({
abs,
size: stat.size,
mtimeMs: stat.mtimeMs,
kind: ext === ".ass" ? "ass" : "ssa",
streamOrExt: ext.slice(1),
});
const cached = await readCache(file);
if (cached) return new NextResponse(new Uint8Array(cached), { headers: VTT_HEADERS });
let buf;
try {
buf = await ffmpegToVtt(["-i", abs, "-map", "0:s:0", "-c:s", "webvtt", "-f", "webvtt", "pipe:1"], req.signal);
} catch {
return NextResponse.json({ error: "Subtitle conversion failed" }, { status: 500 });
}
if (buf.length === 0) return new NextResponse(null, { status: 204 });
try { await writeCache(file, buf); } catch { /* ignore */ }
return new NextResponse(new Uint8Array(buf), { headers: VTT_HEADERS });
}
return NextResponse.json({ error: "Unsupported subtitle extension" }, { status: 415 });
}
async function handleEmbedded(
req: NextRequest,
ctx: { params: Promise<{ code: string }> },
src: string,
): Promise<NextResponse> {
const streamIdx = Number.parseInt(src.slice("emb:".length), 10);
if (!Number.isFinite(streamIdx) || streamIdx < 0) {
return NextResponse.json({ error: "Invalid stream index" }, { status: 400 });
}
const partParam = req.nextUrl.searchParams.get("part");
const partIdx = partParam == null ? 0 : Number.parseInt(partParam, 10);
if (!Number.isFinite(partIdx) || partIdx < 0) {
return NextResponse.json({ error: "Invalid part index" }, { status: 400 });
}
const { code } = await ctx.params;
const decoded = decodeURIComponent(code);
const variant = findVideosForCode(decoded)[partIdx];
if (!variant) {
return NextResponse.json({ error: "Video not found" }, { status: 404 });
}
// Re-probe to validate the requested stream is real and text-based.
// Cheap (sub-100ms) and avoids serving image-based subtitles that
// would render as garbled text or hang ffmpeg.
const streams = await runFfprobeSubtitles(variant.abs);
const target = streams.find((s) => s.index === streamIdx);
if (!target) {
return NextResponse.json({ error: "Stream not found" }, { status: 404 });
}
if (target.isImageBased) {
return NextResponse.json({ error: "Image-based subtitles not supported" }, { status: 415 });
}
if (!target.isTextBased) {
return NextResponse.json({ error: "Subtitle codec not supported" }, { status: 415 });
}
let stat;
try {
stat = await fs.stat(variant.abs);
} catch {
return NextResponse.json({ error: "Video not readable" }, { status: 404 });
}
const file = cachePath({
abs: variant.abs,
size: stat.size,
mtimeMs: stat.mtimeMs,
kind: "embedded",
streamOrExt: streamIdx,
});
const cached = await readCache(file);
if (cached) return new NextResponse(new Uint8Array(cached), { headers: VTT_HEADERS });
let buf: Buffer;
try {
buf = await ffmpegToVtt([
"-i", variant.abs,
"-map", `0:s:${streamIdx}`,
"-c:s", "webvtt",
"-f", "webvtt",
"pipe:1",
], req.signal);
} catch {
return NextResponse.json({ error: "Subtitle extraction failed" }, { status: 500 });
}
if (buf.length === 0) return new NextResponse(null, { status: 204 });
try { await writeCache(file, buf); } catch { /* ignore */ }
return new NextResponse(new Uint8Array(buf), { headers: VTT_HEADERS });
}
const FFMPEG_TIMEOUT_MS = 15_000;
function ffmpegToVtt(args: string[], signal?: AbortSignal): Promise<Buffer> {
return new Promise((resolve, reject) => {
const proc = spawn("ffmpeg", ["-hide_banner", "-loglevel", "error", ...args]);
const chunks: Buffer[] = [];
let err = "";
let settled = false;
const settle = (fn: () => void) => {
if (settled) return;
settled = true;
clearTimeout(t);
if (signal && onAbort) signal.removeEventListener("abort", onAbort);
fn();
};
const t = setTimeout(() => {
try { proc.kill("SIGKILL"); } catch {}
settle(() => reject(new Error("ffmpeg timed out")));
}, FFMPEG_TIMEOUT_MS);
// Tear down the subprocess on client disconnect so a 15-second
// ghost ffmpeg doesn't keep CPU after the user closes the modal.
const onAbort = signal
? () => {
try { proc.kill("SIGKILL"); } catch {}
settle(() => reject(new Error("client aborted")));
}
: null;
if (signal && onAbort) {
if (signal.aborted) onAbort();
else signal.addEventListener("abort", onAbort, { once: true });
}
proc.stdout?.on("data", (d: Buffer) => { chunks.push(d); });
proc.stderr?.on("data", (d) => { err += d.toString(); });
proc.on("error", (e) => settle(() => reject(e)));
proc.on("close", (code) => {
settle(() => {
if (code !== 0) { reject(new Error(err.trim() || `ffmpeg exited ${code}`)); return; }
resolve(Buffer.concat(chunks));
});
});
});
}
+55
View File
@@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from "next/server";
import { assertLocalRequest } from "@/lib/api/localOnly";
import { rawDb } from "@/lib/db/client";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
interface CandidateRow {
id: number;
code: string;
title: string | null;
thumb_path: string;
}
/**
* Codes with a playable video but no discoverable subtitle. The user
* picks from this list when running batch WhisperJAV generation.
*
* has_subtitle is the cheap signal — populated by the video index
* scan (sidecar files / generated subs / library roots).
*/
export async function GET(req: NextRequest) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
const limit = Math.min(500, Math.max(1, Number(req.nextUrl.searchParams.get("limit") ?? "200")));
const offset = Math.max(0, Number(req.nextUrl.searchParams.get("offset") ?? "0"));
const includeAlreadyHasSubs = req.nextUrl.searchParams.get("all") === "1";
const where = includeAlreadyHasSubs
? `i.has_video = 1 AND i.code IS NOT NULL AND i.deleted_at IS NULL AND i.parent_image_id IS NULL`
: `i.has_video = 1 AND i.has_subtitle = 0 AND i.code IS NOT NULL AND i.deleted_at IS NULL AND i.parent_image_id IS NULL`;
const rows = rawDb.prepare(`
SELECT i.id, i.code, i.title, i.thumb_path
FROM images i
WHERE ${where}
ORDER BY UPPER(i.code) ASC
LIMIT ? OFFSET ?
`).all(limit, offset) as CandidateRow[];
const totalRow = rawDb.prepare(`
SELECT COUNT(*) AS n FROM images i WHERE ${where}
`).get() as { n: number };
return NextResponse.json({
candidates: rows.map((r) => ({
id: r.id,
code: r.code,
title: r.title,
thumbPath: r.thumb_path,
})),
total: totalRow.n,
});
}
@@ -0,0 +1,16 @@
import { NextRequest, NextResponse } from "next/server";
import { assertLocalRequest } from "@/lib/api/localOnly";
import { cancelJob } from "@/lib/whisperjav/queue";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
const { id } = await ctx.params;
const ok = cancelJob(id);
if (!ok) return NextResponse.json({ error: "Not found or not cancellable" }, { status: 404 });
return NextResponse.json({ ok: true });
}
+42
View File
@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "node:fs/promises";
import { assertLocalRequest } from "@/lib/api/localOnly";
import { getJob, estimateRealtimeMultiplier } from "@/lib/whisperjav/db";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const LOG_TAIL_LINES = 50;
export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
const { id } = await ctx.params;
const job = getJob(id);
if (!job) return NextResponse.json({ error: "Not found" }, { status: 404 });
let logTail: string[] = [];
try {
const raw = await fs.readFile(job.logPath, "utf8");
const lines = raw.split(/\r?\n/);
logTail = lines.slice(-LOG_TAIL_LINES - 1).filter(Boolean);
} catch { /* log may not exist yet */ }
// ETA: per-mode multiplier from history × video duration elapsed.
// Returns null when we can't compute (no duration / not running yet).
let etaSec: number | null = null;
if (
(job.status === "queued" || job.status === "running") &&
job.videoDurationSec && job.videoDurationSec > 0 &&
job.mode
) {
const multiplier = estimateRealtimeMultiplier(job.mode);
const totalProjected = job.videoDurationSec * multiplier;
const start = job.startedAt ?? job.enqueuedAt;
const elapsedSec = (Date.now() - start) / 1000;
etaSec = Math.max(0, totalProjected - elapsedSec);
}
return NextResponse.json({ ...job, logTail, etaSec });
}
+68
View File
@@ -0,0 +1,68 @@
import { NextRequest, NextResponse } from "next/server";
import { assertLocalRequest } from "@/lib/api/localOnly";
import { enqueueJob, cancelAllQueued } from "@/lib/whisperjav/queue";
import { rawDb } from "@/lib/db/client";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/** Enqueue WhisperJAV for many codes at once. Each code becomes a
* separate row in whisperjav_jobs; the single-worker loop processes
* them sequentially. Codes that already have a generated subtitle
* are skipped (alreadyExists), not failed. */
export async function POST(req: NextRequest) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
const body = await req.json().catch(() => ({}));
const rawCodes = Array.isArray(body.codes) ? body.codes : [];
const codes = rawCodes
.filter((c: unknown): c is string => typeof c === "string" && c.trim().length > 0)
.map((c: string) => c.trim());
if (codes.length === 0) {
return NextResponse.json({ enqueued: 0, skipped: 0, errors: [] });
}
let enqueued = 0;
let skipped = 0;
const errors: Array<{ code: string; error: string }> = [];
for (const code of codes) {
try {
// Always part 0 for batch — multi-part videos are uncommon and
// the user can hit individual codes via the player picker for
// those edge cases.
const result = await enqueueJob({ code, partIdx: 0, overwrite: false });
if ("alreadyExists" in result) skipped++;
else enqueued++;
} catch (e) {
errors.push({ code, error: (e as Error).message });
}
}
return NextResponse.json({ enqueued, skipped, errors });
}
/** Cancel every queued (not-yet-running) job. Useful when the user
* wants to stop a batch mid-flight. The currently-running job is
* left alone — kill it via the per-job cancel endpoint. */
export async function DELETE(req: NextRequest) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
const cancelled = cancelAllQueued();
return NextResponse.json({ cancelled });
}
/** Lightweight queue-state probe used by the batch UI: how many jobs
* are queued/running right now, plus the active row's id. */
export async function GET(req: NextRequest) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
const queued = (rawDb
.prepare(`SELECT COUNT(*) AS n FROM whisperjav_jobs WHERE status = 'queued'`)
.get() as { n: number }).n;
const running = rawDb
.prepare(`SELECT id, code, started_at, stage, stage_index, stage_total FROM whisperjav_jobs WHERE status = 'running' ORDER BY started_at DESC LIMIT 1`)
.get() as { id: string; code: string; started_at: number | null; stage: string | null; stage_index: number | null; stage_total: number | null } | undefined;
return NextResponse.json({ queued, running: running ?? null });
}
+55
View File
@@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from "next/server";
import { assertLocalRequest } from "@/lib/api/localOnly";
import { enqueueJob, clearAllJobHistory, runRetentionSweep } from "@/lib/whisperjav/queue";
import { listJobsForCode } from "@/lib/whisperjav/db";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST(req: NextRequest) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
const body = await req.json().catch(() => ({}));
const code = typeof body.code === "string" ? body.code.trim() : "";
const rawPartIdx = typeof body.partIdx === "number" && Number.isFinite(body.partIdx) ? body.partIdx : 0;
const partIdx = Math.max(0, Math.floor(rawPartIdx));
const overwrite = body.overwrite === true;
if (!code) return NextResponse.json({ error: "Missing code" }, { status: 400 });
try {
const result = await enqueueJob({ code, partIdx, overwrite });
if ("alreadyExists" in result) {
return NextResponse.json(result, { status: 409 });
}
return NextResponse.json(result, { status: 202 });
} catch (e) {
return NextResponse.json({ error: (e as Error).message }, { status: 500 });
}
}
export async function GET(req: NextRequest) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
const code = req.nextUrl.searchParams.get("code") ?? "";
if (!code) return NextResponse.json({ jobs: [] });
const jobs = listJobsForCode(code, 5);
return NextResponse.json({ jobs });
}
/** Clear-all-history. Wipes every non-running row + every temp dir. */
export async function DELETE(req: NextRequest) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
const result = await clearAllJobHistory();
return NextResponse.json(result);
}
/** Manual retention sweep trigger. */
export async function PATCH(req: NextRequest) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
const result = await runRetentionSweep();
return NextResponse.json(result);
}
+31
View File
@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from "next/server";
import { assertLocalRequest } from "@/lib/api/localOnly";
import { verifyCli, autoDetectCli } from "@/lib/whisperjav/spawn";
import { getAppSetting } from "@/lib/db/appSettings";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST(req: NextRequest) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
const body = await req.json().catch(() => ({}));
const explicit = typeof body.path === "string" ? body.path.trim() : "";
const autodetect = body.autodetect === true;
let cliPath = explicit;
if (!cliPath && !autodetect) {
cliPath = (getAppSetting("whisperjav").cliPath ?? "").trim();
}
if (!cliPath) {
const detected = await autoDetectCli();
if (!detected) {
return NextResponse.json({ ok: false, error: "whisperjav not found on PATH" });
}
cliPath = detected;
}
const result = await verifyCli(cliPath);
return NextResponse.json(result);
}