Files

347 lines
12 KiB
JavaScript

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}`);
});
}