347 lines
12 KiB
JavaScript
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}`);
|
|
});
|
|
}
|