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:
+3
-111
@@ -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 ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user