import * as http from "node:http"; import * as fs from "node:fs"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; import { createDatabase } from "./db.js"; import { generateSuggestions } from "./engine.js"; import { searchYouTube } from "./youtube.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const ROOT_DIR = path.resolve(__dirname, ".."); const PUBLIC_DIR = path.join(ROOT_DIR, "public"); const BUNDLED_DATA_DIR = path.join(ROOT_DIR, "data"); const ACTIVE_DATA_DIR = process.env.DATA_DIR ? path.resolve(process.env.DATA_DIR) : BUNDLED_DATA_DIR; const DEFAULT_DB_PATH = path.join(ACTIVE_DATA_DIR, "spectra.db"); const MIME_TYPES = { ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", ".js": "application/javascript; charset=utf-8", ".json": "application/json; charset=utf-8", ".svg": "image/svg+xml", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".ico": "image/x-icon" }; export function createApp(dbPath = DEFAULT_DB_PATH) { const db = createDatabase(dbPath); function sendJson(res, statusCode, data) { const payload = JSON.stringify(data); res.writeHead(statusCode, { "Content-Type": "application/json; charset=utf-8", "Content-Length": Buffer.byteLength(payload), "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS", "Access-Control-Allow-Headers": "Content-Type, Authorization" }); res.end(payload); } function sendError(res, statusCode, message) { sendJson(res, statusCode, { error: message }); } async function parseBody(req) { return new Promise((resolve, reject) => { let body = ""; req.on("data", chunk => { body += chunk; if (body.length > 10 * 1024 * 1024) { reject(new Error("Body too large")); } }); req.on("end", () => { if (!body) return resolve({}); try { resolve(JSON.parse(body)); } catch (e) { reject(new Error("Invalid JSON body")); } }); req.on("error", reject); }); } const server = http.createServer(async (req, res) => { // CORS pre-flight if (req.method === "OPTIONS") { res.writeHead(204, { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS", "Access-Control-Allow-Headers": "Content-Type, Authorization" }); res.end(); return; } const parsedUrl = new URL(req.url, `http://${req.headers.host || "localhost"}`); const pathname = parsedUrl.pathname; try { // 1. Health check if ((req.method === "GET" || req.method === "HEAD") && pathname === "/api/health") { if (req.method === "HEAD") { res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Access-Control-Allow-Origin": "*" }); return res.end(); } return sendJson(res, 200, { status: "ok", app: "spectra", version: "1.0.0", database: "healthy", uptime: process.uptime(), timestamp: new Date().toISOString() }); } // 2. Suggestions if (req.method === "GET" && pathname === "/api/suggestions") { const topic = parsedUrl.searchParams.get("topic"); const limit = parseInt(parsedUrl.searchParams.get("limit") || "12", 10); const forceFresh = parsedUrl.searchParams.get("force_fresh") === "true"; const fetchLive = parsedUrl.searchParams.get("live") !== "false"; const suggestions = await generateSuggestions(db, { topicFilter: topic || null, limit, forceFresh, fetchLive }); return sendJson(res, 200, suggestions); } // 3. Regenerate suggestions if (req.method === "POST" && pathname === "/api/suggestions/regenerate") { const body = await parseBody(req); const limit = parseInt(body.limit || "12", 10); const topic = body.topic || null; const fetchLive = body.fetchLive !== false; const suggestions = await generateSuggestions(db, { topicFilter: topic, limit, forceFresh: false, // Ensures no repeats! fetchLive }); return sendJson(res, 200, suggestions); } // 4. Feedback (Thumbs up, down, big nope) if (req.method === "POST" && pathname === "/api/feedback") { const body = await parseBody(req); if (!body.videoId || !body.action) { return sendError(res, 400, "Missing videoId or action"); } if (!["thumbs_up", "thumbs_down", "big_nope"].includes(body.action)) { return sendError(res, 400, "Invalid action. Must be thumbs_up, thumbs_down, or big_nope"); } const result = db.recordFeedback(body.videoId, body.action, { channel: body.channel, topic_id: body.topicId || body.topic_id }); return sendJson(res, 200, result); } if (req.method === "GET" && pathname === "/api/feedback") { const list = db.getFeedback(); const summary = db.getFeedbackSummary(); return sendJson(res, 200, { summary, list }); } // 5. Watchlist if (req.method === "GET" && pathname === "/api/watchlist") { const filter = parsedUrl.searchParams.get("filter") || "all"; const items = db.getWatchlist(filter); return sendJson(res, 200, { count: items.length, items }); } if (req.method === "POST" && pathname === "/api/watchlist") { const body = await parseBody(req); if (!body.id && !body.video_id) { return sendError(res, 400, "Missing video id"); } const item = db.addToWatchlist(body); return sendJson(res, 201, item); } // Specific watchlist item routes if (pathname.startsWith("/api/watchlist/")) { const videoId = pathname.replace("/api/watchlist/", "").split("/")[0]; if (req.method === "DELETE") { const removed = db.removeFromWatchlist(videoId); return sendJson(res, 200, { success: removed, videoId }); } if (req.method === "PATCH" && pathname.endsWith("/toggle")) { const updated = db.toggleWatchlistWatched(videoId); if (!updated) return sendError(res, 404, "Watchlist item not found"); return sendJson(res, 200, updated); } } // 6. Topics & People if (req.method === "GET" && pathname === "/api/topics") { const topics = db.getTopics(); return sendJson(res, 200, { count: topics.length, topics }); } if (req.method === "POST" && pathname === "/api/topics") { const body = await parseBody(req); if (!body.name) { return sendError(res, 400, "Missing topic name"); } const newTopic = db.addTopic(body); return sendJson(res, 201, newTopic); } if (pathname.startsWith("/api/topics/")) { const topicId = pathname.replace("/api/topics/", "").trim(); if (req.method === "PATCH") { const body = await parseBody(req); const updated = db.updateTopic(topicId, body); if (!updated) return sendError(res, 404, "Topic not found"); return sendJson(res, 200, updated); } if (req.method === "DELETE") { const removed = db.deleteTopic(topicId); return sendJson(res, 200, { success: removed, topicId }); } } // 7. Nopes (Blocklist) if (req.method === "GET" && pathname === "/api/nopes") { const nopes = db.getNopes(); return sendJson(res, 200, { count: nopes.length, nopes }); } if (req.method === "POST" && pathname === "/api/nopes") { const body = await parseBody(req); if (!body.target) return sendError(res, 400, "Missing target"); const nope = db.addNope(body.type || "channel", body.target, body.reason || ""); return sendJson(res, 201, nope); } if (pathname.startsWith("/api/nopes/")) { const nopeId = pathname.replace("/api/nopes/", "").trim(); if (req.method === "DELETE") { const removed = db.removeNope(nopeId); return sendJson(res, 200, { success: removed, nopeId }); } } // 8. Tuning & Telemetry if (req.method === "GET" && pathname === "/api/tuning") { const settings = db.getSettings(); const feedbackSummary = db.getFeedbackSummary(); const seenCount = db.getSeenCount(); const nopesCount = db.getNopes().length; const activeTopics = db.getActiveTopics(); return sendJson(res, 200, { settings, feedbackSummary, seenCount, nopesCount, activeTopics }); } if (req.method === "POST" && pathname === "/api/tuning/reset-seen") { db.clearSeenHistory(); return sendJson(res, 200, { success: true, message: "Seen video history cleared." }); } if (req.method === "POST" && pathname === "/api/tuning/settings") { const body = await parseBody(req); for (const [k, v] of Object.entries(body)) { db.setSetting(k, v); } return sendJson(res, 200, db.getSettings()); } // 9. Live YouTube Search if (req.method === "GET" && pathname === "/api/search") { const query = parsedUrl.searchParams.get("q"); if (!query) return sendError(res, 400, "Missing search query parameter q"); const items = await searchYouTube(query, { sort: "upload_date", timeout: 5000 }); return sendJson(res, 200, { count: items.length, items }); } // 10. Static files if (req.method === "GET" || req.method === "HEAD") { let filePath = pathname === "/" ? "/index.html" : pathname; const resolvedPath = path.join(PUBLIC_DIR, filePath); // Security check if (!resolvedPath.startsWith(PUBLIC_DIR)) { return sendError(res, 403, "Forbidden"); } if (fs.existsSync(resolvedPath) && fs.statSync(resolvedPath).isFile()) { const ext = path.extname(resolvedPath).toLowerCase(); const contentType = MIME_TYPES[ext] || "application/octet-stream"; const stat = fs.statSync(resolvedPath); res.writeHead(200, { "Content-Type": contentType, "Content-Length": stat.size, "Cache-Control": ext === ".html" ? "no-cache" : "public, max-age=3600" }); if (req.method === "HEAD") return res.end(); const content = fs.readFileSync(resolvedPath); return res.end(content); } // Fallback for SPA routing const indexHtmlPath = path.join(PUBLIC_DIR, "index.html"); if (fs.existsSync(indexHtmlPath)) { const stat = fs.statSync(indexHtmlPath); res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Content-Length": stat.size }); if (req.method === "HEAD") return res.end(); const content = fs.readFileSync(indexHtmlPath); return res.end(content); } return sendError(res, 404, "Not Found"); } sendError(res, 405, "Method Not Allowed"); } catch (err) { console.error("Server error:", err); sendError(res, 500, err.message || "Internal Server Error"); } }); return { server, db }; } // Start server if executed directly if (process.argv[1] === fileURLToPath(import.meta.url)) { const PORT = parseInt(process.env.PORT || "80", 10); const HOST = process.env.HOST || "0.0.0.0"; const { server } = createApp(); server.listen(PORT, HOST, () => { console.log(`[Spectra] YouTube Suggest Engine running at http://${HOST}:${PORT}`); }); }