Module split: library export/import → server/library.js

• setupLibraryRoutes(app) — registers GET /api/library/export and
                              POST /api/library/import

The library module reads through history.js helpers (getHistoryDir,
loadMeta, saveMeta) and reads/writes subscriptions.json directly.
Subscriptions integration is via raw fs because (a) the library merge
logic is library-specific (skip-if-already-exists semantics), and (b)
the subscriptions module hasn't been extracted yet — the only thing
the import path needs is to merge dedupe-by-URL into the file.

server/index.js: 2079 → 1971 lines.

Smoke tested: server boots; /api/license-status, /api/health respond;
/api/library/export still returns 402 license_required for unlicensed
(unchanged Pro-gate behavior). 69 unit tests still pass.
This commit is contained in:
Keysat
2026-05-09 10:39:09 -05:00
parent a09ad9c429
commit c06ffbbdf4
2 changed files with 163 additions and 111 deletions
+3 -111
View File
@@ -54,6 +54,7 @@ import {
setupHistoryRoutes,
getHistoryDir,
} from "./history.js";
import { setupLibraryRoutes } from "./library.js";
const execFileAsync = promisify(execFile);
const app = express();
@@ -157,118 +158,9 @@ app.post("/api/update-ytdlp", async (req, res) => {
setupCookieRoutes(app);
// ── Library export/import ────────────────────────────────────────────────
// ── Library export/import ──── moved to ./library.js ─────────
setupLibraryRoutes(app);
app.get("/api/library/export", async (req, res) => {
try {
const meta = await loadMeta();
const files = await fs.readdir(historyDir);
const sessions = {};
for (const file of files) {
if (!file.endsWith(".json") || file === "_meta.json" || file === "subscriptions.json" || file === "auto-queue.json") continue;
try {
const raw = await fs.readFile(path.join(historyDir, file), "utf-8");
const id = file.replace(".json", "");
sessions[id] = JSON.parse(raw);
} catch {}
}
// Load subscriptions
let subscriptions = [];
try {
subscriptions = JSON.parse(await fs.readFile(path.join(historyDir, "subscriptions.json"), "utf-8")).subscriptions || [];
} catch {}
const exportData = {
version: 1,
exportedAt: new Date().toISOString(),
meta,
sessions,
subscriptions,
};
res.setHeader("Content-Type", "application/json");
res.setHeader("Content-Disposition", 'attachment; filename="recap-library.json"');
res.json(exportData);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.post("/api/library/import", express.json({ limit: "200mb" }), async (req, res) => {
try {
const data = req.body;
if (!data || !data.sessions) {
return res.status(400).json({ error: "Invalid library file — missing sessions data" });
}
let imported = 0;
let skipped = 0;
// Import sessions
for (const [id, session] of Object.entries(data.sessions)) {
const filePath = path.join(historyDir, `${id}.json`);
// Skip if session already exists (don't overwrite)
try {
await fs.access(filePath);
skipped++;
continue;
} catch {}
await fs.writeFile(filePath, JSON.stringify(session));
imported++;
}
// Merge meta (add imported sessions to uncategorized if not already placed)
if (data.meta) {
const existingMeta = await loadMeta();
const allExistingIds = new Set([
...existingMeta.uncategorized,
...existingMeta.folders.flatMap(f => f.items),
]);
// Import folders that don't exist
if (data.meta.folders) {
for (const folder of data.meta.folders) {
const existingFolder = existingMeta.folders.find(f => f.id === folder.id);
if (!existingFolder) {
existingMeta.folders.push(folder);
folder.items.forEach(id => allExistingIds.add(id));
}
}
}
// Add any uncategorized items that aren't already placed
if (data.meta.uncategorized) {
for (const id of data.meta.uncategorized) {
if (!allExistingIds.has(id)) {
existingMeta.uncategorized.unshift(id);
}
}
}
await saveMeta(existingMeta);
}
// Import subscriptions (merge, don't duplicate)
if (data.subscriptions && data.subscriptions.length > 0) {
let existingSubs = [];
try {
existingSubs = JSON.parse(await fs.readFile(path.join(historyDir, "subscriptions.json"), "utf-8")).subscriptions || [];
} catch {}
const existingUrls = new Set(existingSubs.map(s => s.url));
let subsAdded = 0;
for (const sub of data.subscriptions) {
if (!existingUrls.has(sub.url)) {
existingSubs.push(sub);
subsAdded++;
}
}
await fs.writeFile(path.join(historyDir, "subscriptions.json"), JSON.stringify({ subscriptions: existingSubs }));
}
res.json({ ok: true, imported, skipped, total: Object.keys(data.sessions).length });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// ── Subscriptions ─────────────────────────────────────────────────────────
+160
View File
@@ -0,0 +1,160 @@
// Library export/import — bulk JSON dump + restore of sessions, folder
// meta, and subscriptions. Only routes; no module state. Pulls history
// data from history.js helpers; reads/writes the subscriptions file
// directly because library import predates the subscriptions module
// having a public 'merge' helper (and the merge logic is library-
// specific anyway).
import fs from "fs/promises";
import path from "path";
import express from "express";
import { getHistoryDir, loadMeta, saveMeta } from "./history.js";
// ── Routes ──────────────────────────────────────────────────────────────────
// Both routes are gated by the Pro 'library' entitlement (the gate runs
// upstream in license-middleware.js). They assume initHistory() has
// already been called.
export function setupLibraryRoutes(app) {
// Bulk export everything: meta, sessions, subscriptions.
app.get("/api/library/export", async (req, res) => {
try {
const historyDir = getHistoryDir();
const meta = await loadMeta();
const files = await fs.readdir(historyDir);
const sessions = {};
for (const file of files) {
if (
!file.endsWith(".json") ||
file === "_meta.json" ||
file === "subscriptions.json" ||
file === "auto-queue.json" ||
file === "skip-list.json" ||
file === "seen-list.json"
) continue;
try {
const raw = await fs.readFile(path.join(historyDir, file), "utf-8");
const id = file.replace(".json", "");
sessions[id] = JSON.parse(raw);
} catch {}
}
let subscriptions = [];
try {
subscriptions = JSON.parse(
await fs.readFile(path.join(historyDir, "subscriptions.json"), "utf-8")
).subscriptions || [];
} catch {}
const exportData = {
version: 1,
exportedAt: new Date().toISOString(),
meta,
sessions,
subscriptions,
};
res.setHeader("Content-Type", "application/json");
res.setHeader(
"Content-Disposition",
'attachment; filename="recap-library.json"'
);
res.json(exportData);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Bulk import: skip sessions that already exist (don't overwrite),
// merge meta folders, add net-new subscriptions.
app.post(
"/api/library/import",
express.json({ limit: "200mb" }),
async (req, res) => {
try {
const data = req.body;
if (!data || !data.sessions) {
return res
.status(400)
.json({ error: "Invalid library file — missing sessions data" });
}
const historyDir = getHistoryDir();
let imported = 0;
let skipped = 0;
// Sessions — skip if already present.
for (const [id, session] of Object.entries(data.sessions)) {
const filePath = path.join(historyDir, `${id}.json`);
try {
await fs.access(filePath);
skipped++;
continue;
} catch {}
await fs.writeFile(filePath, JSON.stringify(session));
imported++;
}
// Meta — merge folders + add new uncategorized at the top.
if (data.meta) {
const existingMeta = await loadMeta();
const allExistingIds = new Set([
...existingMeta.uncategorized,
...existingMeta.folders.flatMap(f => f.items),
]);
if (data.meta.folders) {
for (const folder of data.meta.folders) {
const existingFolder = existingMeta.folders.find(
f => f.id === folder.id
);
if (!existingFolder) {
existingMeta.folders.push(folder);
folder.items.forEach(id => allExistingIds.add(id));
}
}
}
if (data.meta.uncategorized) {
for (const id of data.meta.uncategorized) {
if (!allExistingIds.has(id)) {
existingMeta.uncategorized.unshift(id);
}
}
}
await saveMeta(existingMeta);
}
// Subscriptions — merge, dedupe by URL.
if (data.subscriptions && data.subscriptions.length > 0) {
let existingSubs = [];
try {
existingSubs = JSON.parse(
await fs.readFile(
path.join(historyDir, "subscriptions.json"),
"utf-8"
)
).subscriptions || [];
} catch {}
const existingUrls = new Set(existingSubs.map(s => s.url));
for (const sub of data.subscriptions) {
if (!existingUrls.has(sub.url)) {
existingSubs.push(sub);
}
}
await fs.writeFile(
path.join(historyDir, "subscriptions.json"),
JSON.stringify({ subscriptions: existingSubs })
);
}
res.json({
ok: true,
imported,
skipped,
total: Object.keys(data.sessions).length,
});
} catch (err) {
res.status(500).json({ error: err.message });
}
}
);
}