// History storage + routes. Sessions are written as one JSON file per // summary in /data/history/.json. Folder structure / ordering lives // in a sidecar `_meta.json`. // // Module-private state: just the historyDir path, set by initHistory(). // The DELETE route needs to add the deleted videoId to the skip list // (so subscriptions don't re-queue it) — that's a cross-module concern, // so it's injected as a callback by setupHistoryRoutes. import fs from "fs/promises"; import path from "path"; let historyDir = null; let metaPath = null; // ── Initialization ────────────────────────────────────────────────────────── // Call once at boot. Creates the directory and stores the path. Idempotent. export async function initHistory({ dataDir }) { historyDir = path.join(dataDir, "history"); metaPath = path.join(historyDir, "_meta.json"); await fs.mkdir(historyDir, { recursive: true }).catch(() => {}); } // ── Storage ───────────────────────────────────────────────────────────────── // saveToHistory persists a completed summary. Returns the generated id. // Used by /api/process. The id encodes the timestamp + a content hint // (videoId for YouTube, base64-truncated guid/url for podcasts) so files // sort chronologically by name. export async function saveToHistory(videoId, url, title, chunks, entries, logs, uploadDate, type) { const idSuffix = type === "podcast" ? Buffer.from(videoId).toString("base64url").slice(0, 16) : videoId; const id = `${Date.now()}-${idSuffix}`; const record = { id, videoId, url, title: title || "Untitled", type: type || "youtube", topicCount: chunks.length, segmentCount: entries.length, createdAt: new Date().toISOString(), uploadDate: uploadDate || "", chunks, entries, logs, }; await fs.writeFile(path.join(historyDir, `${id}.json`), JSON.stringify(record)); return id; } // ── Meta ──────────────────────────────────────────────────────────────────── // `_meta.json` shape: { folders: [{ id, name, order, collapsed, // items: [sessionId, ...] }], uncategorized: [sessionId, ...] } export async function loadMeta() { try { return JSON.parse(await fs.readFile(metaPath, "utf-8")); } catch { return { folders: [], uncategorized: [] }; } } export async function saveMeta(meta) { await fs.writeFile(metaPath, JSON.stringify(meta, null, 2)); } export function getHistoryDir() { return historyDir; } // ── Routes ────────────────────────────────────────────────────────────────── // Pass `addToSkipList` so the DELETE route can suppress re-queueing of // videos the user has explicitly removed. Decoupled from subscriptions // to keep this module standalone. export function setupHistoryRoutes(app, { addToSkipList } = {}) { // Get all history: sessions + folder structure app.get("/api/history", async (req, res) => { try { const files = await fs.readdir(historyDir); const sessionsMap = {}; // Skip the meta + state files; everything else is a session. for (const file of files.filter(f => f.endsWith(".json") && !f.startsWith("_") && f !== "subscriptions.json" && f !== "skip-list.json" && f !== "seen-list.json" && f !== "auto-queue.json" )) { try { const raw = await fs.readFile(path.join(historyDir, file), "utf-8"); const data = JSON.parse(raw); sessionsMap[data.id] = { id: data.id, videoId: data.videoId, url: data.url, title: data.title, topicCount: data.topicCount, type: data.type || "youtube", segmentCount: data.segmentCount, createdAt: data.createdAt, uploadDate: data.uploadDate || "", }; } catch {} } const meta = await loadMeta(); // Clean up: remove references to deleted sessions for (const folder of meta.folders) { folder.items = folder.items.filter(id => sessionsMap[id]); } meta.uncategorized = meta.uncategorized.filter(id => sessionsMap[id]); // Add any sessions not in meta (newly created) const allReferenced = new Set([ ...meta.uncategorized, ...meta.folders.flatMap(f => f.items), ]); const allIds = Object.keys(sessionsMap); const orphans = allIds.filter(id => !allReferenced.has(id)) .sort((a, b) => new Date(sessionsMap[b].createdAt) - new Date(sessionsMap[a].createdAt)); meta.uncategorized = [...orphans, ...meta.uncategorized]; await saveMeta(meta); res.json({ sessions: sessionsMap, meta }); } catch (err) { res.json({ sessions: {}, meta: { folders: [], uncategorized: [] } }); } }); // Get a single session (full data) app.get("/api/history/:id", async (req, res) => { try { const raw = await fs.readFile(path.join(historyDir, `${req.params.id}.json`), "utf-8"); res.json(JSON.parse(raw)); } catch { res.status(404).json({ error: "Session not found" }); } }); // Rename a session title app.put("/api/history/:id/title", async (req, res) => { try { const filePath = path.join(historyDir, `${req.params.id}.json`); const raw = await fs.readFile(filePath, "utf-8"); const data = JSON.parse(raw); data.title = req.body.title || data.title; await fs.writeFile(filePath, JSON.stringify(data)); res.json({ ok: true, title: data.title }); } catch { res.status(404).json({ error: "Session not found" }); } }); // Delete a session — also adds the videoId to the skip list so any // subscriptions don't re-queue it. app.delete("/api/history/:id", async (req, res) => { try { const filePath = path.join(historyDir, `${req.params.id}.json`); let videoId = null; try { const raw = await fs.readFile(filePath, "utf-8"); videoId = JSON.parse(raw).videoId; } catch {} await fs.unlink(filePath); if (videoId && typeof addToSkipList === "function") { await addToSkipList(videoId); } const meta = await loadMeta(); meta.uncategorized = meta.uncategorized.filter(id => id !== req.params.id); for (const folder of meta.folders) { folder.items = folder.items.filter(id => id !== req.params.id); } await saveMeta(meta); res.json({ ok: true }); } catch { res.status(404).json({ error: "Session not found" }); } }); // Update meta (folders, ordering) — the frontend sends the full structure app.put("/api/history/meta", async (req, res) => { try { const meta = req.body; await saveMeta(meta); res.json({ ok: true }); } catch (err) { res.status(500).json({ error: err.message }); } }); app.post("/api/history/folders", async (req, res) => { try { const meta = await loadMeta(); const folder = { id: `folder-${Date.now()}`, name: req.body.name || "New Folder", collapsed: false, items: [] }; meta.folders.push(folder); await saveMeta(meta); res.json(folder); } catch (err) { res.status(500).json({ error: err.message }); } }); app.put("/api/history/folders/:id", async (req, res) => { try { const meta = await loadMeta(); const folder = meta.folders.find(f => f.id === req.params.id); if (!folder) return res.status(404).json({ error: "Folder not found" }); folder.name = req.body.name || folder.name; await saveMeta(meta); res.json(folder); } catch (err) { res.status(500).json({ error: err.message }); } }); app.put("/api/history/folders/:id/collapsed", async (req, res) => { try { const meta = await loadMeta(); const folder = meta.folders.find(f => f.id === req.params.id); if (!folder) return res.status(404).json({ error: "Folder not found" }); folder.collapsed = !!req.body.collapsed; await saveMeta(meta); res.json({ ok: true, collapsed: folder.collapsed }); } catch (err) { res.status(500).json({ error: err.message }); } }); // Delete a folder — items move back to uncategorized app.delete("/api/history/folders/:id", async (req, res) => { try { const meta = await loadMeta(); const idx = meta.folders.findIndex(f => f.id === req.params.id); if (idx === -1) return res.status(404).json({ error: "Folder not found" }); const [folder] = meta.folders.splice(idx, 1); meta.uncategorized = [...folder.items, ...meta.uncategorized]; await saveMeta(meta); res.json({ ok: true }); } catch (err) { res.status(500).json({ error: err.message }); } }); // Move a session to a folder (or uncategorized if folderId is null) app.put("/api/history/move", async (req, res) => { try { const { sessionId, folderId, index } = req.body; const meta = await loadMeta(); // Remove from current location meta.uncategorized = meta.uncategorized.filter(id => id !== sessionId); for (const folder of meta.folders) { folder.items = folder.items.filter(id => id !== sessionId); } // Add to new location if (folderId) { const folder = meta.folders.find(f => f.id === folderId); if (folder) { const i = typeof index === "number" ? index : folder.items.length; folder.items.splice(i, 0, sessionId); } } else { const i = typeof index === "number" ? index : 0; meta.uncategorized.splice(i, 0, sessionId); } await saveMeta(meta); res.json({ ok: true }); } catch (err) { res.status(500).json({ error: err.message }); } }); }