134 lines
4.7 KiB
TypeScript
134 lines
4.7 KiB
TypeScript
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",
|
|
},
|
|
});
|
|
}
|