From c06ffbbdf455d2f88432737c4d5046e96c52a3eb Mon Sep 17 00:00:00 2001 From: Keysat Date: Sat, 9 May 2026 10:39:09 -0500 Subject: [PATCH] =?UTF-8?q?Module=20split:=20library=20export/import=20?= =?UTF-8?q?=E2=86=92=20server/library.js?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • 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. --- server/index.js | 114 +-------------------------------- server/library.js | 160 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+), 111 deletions(-) create mode 100644 server/library.js diff --git a/server/index.js b/server/index.js index 3be5ccb..bb12046 100644 --- a/server/index.js +++ b/server/index.js @@ -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 ───────────────────────────────────────────────────────── diff --git a/server/library.js b/server/library.js new file mode 100644 index 0000000..61bb6d7 --- /dev/null +++ b/server/library.js @@ -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 }); + } + } + ); +}