Initial commit: Spectra YouTube Suggest Engine

This commit is contained in:
chrisf committed 2026-10-29 22:56:06 -06:00
commit e90505a39b
16 files changed
+4495

No files matched your search

+420
View File
@@ -0,0 +1,420 @@
import { DatabaseSync } from "node:sqlite";
import * as fs from "node:fs";
import * as path from "node:path";
import { DEFAULT_TOPICS, SEED_VIDEOS } from "./seed_data.js";
export function createDatabase(dbPath) {
const dir = path.dirname(dbPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const db = new DatabaseSync(dbPath);
// Initialize schema
db.exec(`
CREATE TABLE IF NOT EXISTS topics (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'topic',
query TEXT NOT NULL,
active INTEGER DEFAULT 1,
weight REAL DEFAULT 1.0,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS videos (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
channel TEXT NOT NULL,
channel_id TEXT,
thumbnail TEXT,
duration TEXT,
views TEXT,
published_at TEXT,
published_relative TEXT,
topic_id TEXT,
description TEXT,
url TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS seen_videos (
video_id TEXT PRIMARY KEY,
seen_at TEXT NOT NULL,
batch_id TEXT
);
CREATE TABLE IF NOT EXISTS feedback (
video_id TEXT PRIMARY KEY,
channel TEXT,
topic_id TEXT,
action TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS nopes (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
target TEXT NOT NULL,
reason TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS watchlist (
video_id TEXT PRIMARY KEY,
title TEXT NOT NULL,
channel TEXT NOT NULL,
thumbnail TEXT,
duration TEXT,
url TEXT NOT NULL,
topic_id TEXT,
watched INTEGER DEFAULT 0,
notes TEXT,
added_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
`);
// Populate default settings if missing
const getSettingStmt = db.prepare("SELECT value FROM settings WHERE key = ?");
const setSettingStmt = db.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)");
if (!getSettingStmt.get("recency_bias")) {
setSettingStmt.run("recency_bias", "recent"); // recent, fresh, ultra_fresh, broad, any
}
if (!getSettingStmt.get("batch_size")) {
setSettingStmt.run("batch_size", "12");
}
// Populate default topics if empty
const countTopics = db.prepare("SELECT COUNT(*) AS count FROM topics").get();
if (countTopics.count === 0) {
const insertTopic = db.prepare(`
INSERT INTO topics (id, name, type, query, active, weight, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
const now = new Date().toISOString();
for (const t of DEFAULT_TOPICS) {
insertTopic.run(t.id, t.name, t.type, t.query, t.active, t.weight, now);
}
}
// Populate seed videos if empty
const countVideos = db.prepare("SELECT COUNT(*) AS count FROM videos").get();
if (countVideos.count === 0) {
const insertVideo = db.prepare(`
INSERT OR REPLACE INTO videos (id, title, channel, channel_id, thumbnail, duration, views, published_at, published_relative, topic_id, description, url)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const v of SEED_VIDEOS) {
insertVideo.run(
v.id,
v.title,
v.channel,
v.channel_id || "",
v.thumbnail || `https://i.ytimg.com/vi/${v.id}/hqdefault.jpg`,
v.duration || "",
v.views || "",
v.published_at || new Date().toISOString(),
v.published_relative || "recent",
v.topic_id || "homelab-selfhost",
v.description || "",
v.url || `https://www.youtube.com/watch?v=${v.id}`
);
}
}
return {
rawDb: db,
// Topics API
getTopics() {
return db.prepare("SELECT * FROM topics ORDER BY weight DESC, name ASC").all();
},
getActiveTopics() {
return db.prepare("SELECT * FROM topics WHERE active = 1 ORDER BY weight DESC").all();
},
getTopicById(id) {
return db.prepare("SELECT * FROM topics WHERE id = ?").get(id);
},
addTopic(topic) {
const now = new Date().toISOString();
const id = topic.id || "topic-" + Date.now() + "-" + Math.random().toString(36).slice(2, 6);
const stmt = db.prepare(`
INSERT INTO topics (id, name, type, query, active, weight, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
stmt.run(
id,
topic.name,
topic.type || "topic",
topic.query || topic.name,
topic.active ?? 1,
topic.weight ?? 1.0,
now
);
return this.getTopicById(id);
},
updateTopic(id, updates) {
const topic = this.getTopicById(id);
if (!topic) return null;
const name = updates.name !== undefined ? updates.name : topic.name;
const type = updates.type !== undefined ? updates.type : topic.type;
const query = updates.query !== undefined ? updates.query : topic.query;
const active = updates.active !== undefined ? (updates.active ? 1 : 0) : topic.active;
const weight = updates.weight !== undefined ? Number(updates.weight) : topic.weight;
db.prepare(`
UPDATE topics
SET name = ?, type = ?, query = ?, active = ?, weight = ?
WHERE id = ?
`).run(name, type, query, active, weight, id);
return this.getTopicById(id);
},
deleteTopic(id) {
const info = db.prepare("DELETE FROM topics WHERE id = ?").run(id);
return info.changes > 0;
},
// Videos
upsertVideo(v) {
const stmt = db.prepare(`
INSERT INTO videos (id, title, channel, channel_id, thumbnail, duration, views, published_at, published_relative, topic_id, description, url)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
title = excluded.title,
channel = excluded.channel,
channel_id = COALESCE(excluded.channel_id, videos.channel_id),
thumbnail = excluded.thumbnail,
duration = excluded.duration,
views = excluded.views,
published_at = COALESCE(excluded.published_at, videos.published_at),
published_relative = COALESCE(excluded.published_relative, videos.published_relative),
topic_id = COALESCE(excluded.topic_id, videos.topic_id),
description = COALESCE(excluded.description, videos.description),
url = excluded.url
`);
stmt.run(
v.id,
v.title,
v.channel,
v.channel_id || "",
v.thumbnail || `https://i.ytimg.com/vi/${v.id}/hqdefault.jpg`,
v.duration || "",
v.views || "",
v.published_at || new Date().toISOString(),
v.published_relative || "recent",
v.topic_id || "",
v.description || "",
v.url || `https://www.youtube.com/watch?v=${v.id}`
);
},
getVideoById(id) {
return db.prepare("SELECT * FROM videos WHERE id = ?").get(id);
},
getAllVideos() {
return db.prepare("SELECT * FROM videos").all();
},
// Seen tracking (No Repeats)
recordSeenVideos(videoIds, batchId = null) {
const now = new Date().toISOString();
const stmt = db.prepare(`
INSERT OR REPLACE INTO seen_videos (video_id, seen_at, batch_id)
VALUES (?, ?, ?)
`);
for (const id of videoIds) {
stmt.run(id, now, batchId);
}
},
getSeenVideoIds() {
const rows = db.prepare("SELECT video_id FROM seen_videos").all();
return new Set(rows.map(r => r.video_id));
},
getSeenCount() {
const res = db.prepare("SELECT COUNT(*) as count FROM seen_videos").get();
return res.count;
},
clearSeenHistory() {
db.prepare("DELETE FROM seen_videos").run();
return true;
},
// Feedback & Steering
recordFeedback(videoId, action, meta = {}) {
const now = new Date().toISOString();
const video = this.getVideoById(videoId) || {};
const channel = meta.channel || video.channel || "";
const topicId = meta.topic_id || video.topic_id || "";
// Save feedback
db.prepare(`
INSERT OR REPLACE INTO feedback (video_id, channel, topic_id, action, created_at)
VALUES (?, ?, ?, ?, ?)
`).run(videoId, channel, topicId, action, now);
// Apply steering adjustments to topics
if (topicId) {
const topic = this.getTopicById(topicId);
if (topic) {
let delta = 0;
if (action === "thumbs_up") delta = 0.25;
if (action === "thumbs_down") delta = -0.2;
if (action === "big_nope") delta = -0.4;
const newWeight = Math.max(0.1, Math.min(3.0, (topic.weight || 1.0) + delta));
db.prepare("UPDATE topics SET weight = ? WHERE id = ?").run(newWeight, topicId);
}
}
// If Big Nope, also automatically add channel & video to nopes table
if (action === "big_nope") {
if (channel) {
this.addNope("channel", channel, `Big Nope on video: ${videoId}`);
}
this.addNope("video", videoId, `Direct Big Nope on video`);
}
return { success: true, action, videoId, channel, topicId };
},
getFeedback() {
return db.prepare("SELECT * FROM feedback ORDER BY created_at DESC").all();
},
getFeedbackSummary() {
const up = db.prepare("SELECT COUNT(*) as count FROM feedback WHERE action = 'thumbs_up'").get().count;
const down = db.prepare("SELECT COUNT(*) as count FROM feedback WHERE action = 'thumbs_down'").get().count;
const nope = db.prepare("SELECT COUNT(*) as count FROM feedback WHERE action = 'big_nope'").get().count;
return { thumbsUp: up, thumbsDown: down, bigNope: nope };
},
// Nopes (Blocklist)
getNopes() {
return db.prepare("SELECT * FROM nopes ORDER BY created_at DESC").all();
},
isNoped(videoId, channel = "", title = "") {
const videoNope = db.prepare("SELECT 1 FROM nopes WHERE type = 'video' AND target = ?").get(videoId);
if (videoNope) return true;
if (channel) {
const channelNope = db.prepare("SELECT 1 FROM nopes WHERE type = 'channel' AND LOWER(target) = LOWER(?)").get(channel);
if (channelNope) return true;
}
// Check keyword nopes
const keywordNopes = db.prepare("SELECT target FROM nopes WHERE type = 'keyword'").all();
const lowerTitle = (title || "").toLowerCase();
for (const k of keywordNopes) {
if (lowerTitle.includes(k.target.toLowerCase())) return true;
}
return false;
},
addNope(type, target, reason = "") {
const id = "nope-" + Date.now() + "-" + Math.random().toString(36).slice(2, 6);
const now = new Date().toISOString();
const existing = db.prepare("SELECT id FROM nopes WHERE type = ? AND LOWER(target) = LOWER(?)").get(type, target);
if (existing) return existing;
db.prepare(`
INSERT INTO nopes (id, type, target, reason, created_at)
VALUES (?, ?, ?, ?, ?)
`).run(id, type, target, reason, now);
return { id, type, target, reason, created_at: now };
},
removeNope(id) {
const info = db.prepare("DELETE FROM nopes WHERE id = ?").run(id);
return info.changes > 0;
},
// Watchlist API (Independent & Preserved across regeneration)
getWatchlist(filter = "all") {
let query = "SELECT * FROM watchlist";
if (filter === "unwatched") {
query += " WHERE watched = 0";
} else if (filter === "watched") {
query += " WHERE watched = 1";
}
query += " ORDER BY added_at DESC";
return db.prepare(query).all();
},
getWatchlistIds() {
const rows = db.prepare("SELECT video_id FROM watchlist").all();
return new Set(rows.map(r => r.video_id));
},
addToWatchlist(video) {
const now = new Date().toISOString();
db.prepare(`
INSERT INTO watchlist (video_id, title, channel, thumbnail, duration, url, topic_id, watched, notes, added_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?)
ON CONFLICT(video_id) DO UPDATE SET
title = excluded.title,
channel = excluded.channel,
thumbnail = excluded.thumbnail,
duration = excluded.duration,
url = excluded.url,
topic_id = COALESCE(excluded.topic_id, watchlist.topic_id)
`).run(
video.id || video.video_id,
video.title,
video.channel,
video.thumbnail || `https://i.ytimg.com/vi/${video.id || video.video_id}/hqdefault.jpg`,
video.duration || "",
video.url || `https://www.youtube.com/watch?v=${video.id || video.video_id}`,
video.topic_id || "",
video.notes || "",
now
);
return db.prepare("SELECT * FROM watchlist WHERE video_id = ?").get(video.id || video.video_id);
},
removeFromWatchlist(videoId) {
const info = db.prepare("DELETE FROM watchlist WHERE video_id = ?").run(videoId);
return info.changes > 0;
},
toggleWatchlistWatched(videoId) {
const item = db.prepare("SELECT watched FROM watchlist WHERE video_id = ?").get(videoId);
if (!item) return null;
const nextState = item.watched === 1 ? 0 : 1;
db.prepare("UPDATE watchlist SET watched = ? WHERE video_id = ?").run(nextState, videoId);
return db.prepare("SELECT * FROM watchlist WHERE video_id = ?").get(videoId);
},
// Settings
getSettings() {
const rows = db.prepare("SELECT key, value FROM settings").all();
const settings = {};
for (const r of rows) settings[r.key] = r.value;
return settings;
},
setSetting(key, value) {
db.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)").run(key, String(value));
return { key, value };
}
};
}
+225
View File
@@ -0,0 +1,225 @@
// Spectra Recommendation Engine
// Steerable suggestions, recency biasing, topic diversity balancing, and strict no-repeats guarantee
import { searchYouTube } from "./youtube.js";
export function calculateRecencyScore(publishedAt, recencyBias = "recent") {
const now = Date.now();
const pubTime = new Date(publishedAt).getTime();
const ageDays = Math.max(0, (now - pubTime) / (1000 * 60 * 60 * 24));
let baseMultiplier = 1.0;
if (ageDays <= 2) {
baseMultiplier = 2.5;
} else if (ageDays <= 7) {
baseMultiplier = 2.0;
} else if (ageDays <= 30) {
baseMultiplier = 1.5;
} else if (ageDays <= 90) {
baseMultiplier = 1.1;
} else if (ageDays <= 365) {
baseMultiplier = 0.8;
} else {
baseMultiplier = 0.5;
}
// Bias tuning
if (recencyBias === "ultra_fresh") {
if (ageDays > 3) return 0.1;
return baseMultiplier * 2.0;
} else if (recencyBias === "fresh") {
if (ageDays > 14) return 0.2;
return baseMultiplier * 1.5;
} else if (recencyBias === "broad") {
return Math.max(0.7, baseMultiplier);
} else if (recencyBias === "any") {
return 1.0;
}
return baseMultiplier;
}
export function generateRecommendationReason(video, topic, feedbackForChannel) {
if (feedbackForChannel === "thumbs_up") {
return `⭐ Highly rated creator: ${video.channel}`;
}
if (topic && topic.type === "person") {
return `👤 Featured creator: ${topic.name}`;
}
const now = Date.now();
const pubTime = new Date(video.published_at).getTime();
const ageDays = (now - pubTime) / (1000 * 60 * 60 * 24);
if (ageDays <= 3) {
return `🔥 Fresh release in ${topic ? topic.name : "your interests"}`;
}
if (topic && topic.weight > 1.2) {
return `🎯 Steered by your upvotes on ${topic.name}`;
}
return `✨ Curated for ${topic ? topic.name : "you"}`;
}
export async function generateSuggestions(db, options = {}) {
const {
limit = 12,
topicFilter = null,
fetchLive = true,
forceFresh = false
} = options;
const settings = db.getSettings();
const recencyBias = settings.recency_bias || "recent";
// 1. Get active topics
let activeTopics = db.getActiveTopics();
if (topicFilter) {
activeTopics = activeTopics.filter(t => t.id === topicFilter);
}
if (activeTopics.length === 0) {
activeTopics = db.getTopics().slice(0, 4);
}
// 2. Load seen video IDs (to strictly enforce NO REPEATS)
const seenIds = db.getSeenVideoIds();
// 3. Channel feedback affinity map
const allFeedback = db.getFeedback();
const channelFeedbackMap = {};
for (const fb of allFeedback) {
if (fb.channel) {
// most recent feedback wins
if (!channelFeedbackMap[fb.channel.toLowerCase()]) {
channelFeedbackMap[fb.channel.toLowerCase()] = fb.action;
}
}
}
// 4. Optionally fetch live YouTube results to supplement local catalog
if (fetchLive) {
// Pick 2-3 topics with highest weights to query live
const queryTopics = [...activeTopics]
.sort((a, b) => b.weight - a.weight)
.slice(0, 3);
for (const qt of queryTopics) {
try {
const liveItems = await searchYouTube(qt.query, {
sort: "upload_date",
timeout: 4000
});
for (const item of liveItems) {
item.topic_id = qt.id;
db.upsertVideo(item);
}
} catch (err) {
// Silently continue if YouTube search encounters a glitch
}
}
}
// 5. Query all candidates from database
const allDbVideos = db.getAllVideos();
const candidates = [];
for (const v of allDbVideos) {
// A. Check if Big Noped
if (db.isNoped(v.id, v.channel, v.title)) {
continue;
}
// B. Check if already seen (NO REPEATS)
if (!forceFresh && seenIds.has(v.id)) {
continue;
}
// C. Check if topic is active
let matchedTopic = activeTopics.find(t => t.id === v.topic_id);
if (!matchedTopic && !topicFilter) {
// Fallback matching by keyword
matchedTopic = activeTopics[0];
}
if (!matchedTopic && topicFilter) {
continue;
}
// Calculate score
const topicWeight = matchedTopic ? matchedTopic.weight : 1.0;
const recencyMult = calculateRecencyScore(v.published_at, recencyBias);
let channelBonus = 0;
const chFeedback = channelFeedbackMap[v.channel?.toLowerCase()];
if (chFeedback === "thumbs_up") channelBonus = 3.0;
if (chFeedback === "thumbs_down") channelBonus = -2.0;
// Small random jitter (0 to 0.4) to provide variation among ties
const jitter = Math.random() * 0.4;
const totalScore = (topicWeight * 2.0 + channelBonus + jitter) * recencyMult;
candidates.push({
...v,
topic_name: matchedTopic ? matchedTopic.name : "General",
topic_id: matchedTopic ? matchedTopic.id : v.topic_id,
score: totalScore,
why_suggested: generateRecommendationReason(v, matchedTopic, chFeedback)
});
}
// 6. Balance across topics to ensure a diverse spread over interesting topics & people
// Sort candidates by score descending
candidates.sort((a, b) => b.score - a.score);
const selected = [];
const selectedIds = new Set();
const topicCounts = {};
// First pass: pick best video per topic (max 2 per topic)
for (const item of candidates) {
if (selected.length >= limit) break;
const count = topicCounts[item.topic_id] || 0;
if (count < 2) {
selected.push(item);
selectedIds.add(item.id);
topicCounts[item.topic_id] = count + 1;
}
}
// Second pass: fill remainder with highest scoring remaining videos
if (selected.length < limit) {
for (const item of candidates) {
if (selected.length >= limit) break;
if (!selectedIds.has(item.id)) {
selected.push(item);
selectedIds.add(item.id);
}
}
}
// If still fewer than limit (e.g. all seen or small seed), fallback to top candidates regardless of seen if forceFresh
if (selected.length === 0 && candidates.length === 0 && seenIds.size > 0) {
// If every video has been seen, return a notification or top un-noped items
const allFallback = allDbVideos.filter(v => !db.isNoped(v.id, v.channel, v.title));
for (const item of allFallback.slice(0, limit)) {
selected.push({
...item,
topic_name: "Catalog",
score: 1.0,
why_suggested: "Restarted cycle from catalog"
});
selectedIds.add(item.id);
}
}
// 7. Record selected videos as SEEN so they will never repeat
const batchId = "batch-" + Date.now();
db.recordSeenVideos([...selectedIds], batchId);
return {
batchId,
recencyBias,
totalSeen: db.getSeenCount(),
count: selected.length,
suggestions: selected
};
}
+335
View File
@@ -0,0 +1,335 @@
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" && pathname === "/api/health") {
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") {
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 content = fs.readFileSync(resolvedPath);
res.writeHead(200, {
"Content-Type": contentType,
"Content-Length": content.length,
"Cache-Control": ext === ".html" ? "no-cache" : "public, max-age=3600"
});
return res.end(content);
}
// Fallback for SPA routing
const indexHtmlPath = path.join(PUBLIC_DIR, "index.html");
if (fs.existsSync(indexHtmlPath)) {
const content = fs.readFileSync(indexHtmlPath);
res.writeHead(200, {
"Content-Type": "text/html; charset=utf-8",
"Content-Length": content.length
});
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}`);
});
}
+414
View File
@@ -0,0 +1,414 @@
// Curated starter topics, people, and seed video catalog for Spectra
// Tailored to creator's passions: Vintage screenprinting, specialty espresso, ESP32/E-Ink, homelab, alpine skiing, lateral thinking
export const DEFAULT_TOPICS = [
{
id: "vintage-screenprint",
name: "Vintage Screenprint & Apparel",
type: "topic",
query: "vintage screenprint apparel t-shirt water based ink",
active: 1,
weight: 1.2
},
{
id: "specialty-espresso",
name: "Specialty Espresso & Craft Brewing",
type: "topic",
query: "specialty espresso extraction craft brewing puck prep",
active: 1,
weight: 1.2
},
{
id: "esp32-eink",
name: "ESP32 & Ambient E-Ink Displays",
type: "topic",
query: "ESP32 ambient e-ink display epaper home telemetry",
active: 1,
weight: 1.3
},
{
id: "homelab-selfhost",
name: "Self-Hosted Homelab & Traefik",
type: "topic",
query: "self hosted homelab docker traefik automation sqlite",
active: 1,
weight: 1.3
},
{
id: "alpine-skiing",
name: "Alpine Skiing & Mountain Culture",
type: "topic",
query: "backcountry alpine skiing mountain lines powder freeskier",
active: 1,
weight: 1.0
},
{
id: "lateral-thinking",
name: "Lateral Thinking & Creative Whack",
type: "topic",
query: "Roger von Oech lateral thinking creative problem solving invention",
active: 1,
weight: 1.1
},
{
id: "vinyl-audio",
name: "Vinyl Archives & Hi-Fi Audio",
type: "topic",
query: "audiophile vinyl turntable tube amp hi-fi audio archive",
active: 1,
weight: 1.0
},
{
id: "fast-prototyping",
name: "Maker Prototyping & Fast Builds",
type: "topic",
query: "hardware prototyping weekend build maker fast CAD 3D print",
active: 1,
weight: 1.1
},
// People & Creators
{
id: "person-james-hoffmann",
name: "James Hoffmann",
type: "person",
query: "James Hoffmann espresso coffee review",
active: 1,
weight: 1.4
},
{
id: "person-jeff-geerling",
name: "Jeff Geerling",
type: "person",
query: "Jeff Geerling homelab raspberry pi server networking",
active: 1,
weight: 1.4
},
{
id: "person-andreas-spiess",
name: "Andreas Spiess (The Guy with the Swiss Accent)",
type: "person",
query: "Andreas Spiess ESP32 IoT sensors display",
active: 1,
weight: 1.3
},
{
id: "person-struthless",
name: "Campbell Walker (Struthless)",
type: "person",
query: "Struthless creative thinking illustration sketchbook",
active: 1,
weight: 1.1
}
];
export const SEED_VIDEOS = [
// ESP32 & E-Ink
{
id: "eIcZZX10pa4",
title: "This Open-Source E-Ink Screen Is My Favorite New Gadget",
channel: "Snazzy Labs",
channel_id: "UCq6VARTxKATgc9qdAOZaqwg",
thumbnail: "https://i.ytimg.com/vi/eIcZZX10pa4/hqdefault.jpg",
duration: "13:55",
views: "986K views",
published_at: "2026-09-15T12:00:00Z",
published_relative: "1 month ago",
topic_id: "esp32-eink",
description: "Deep dive into low-power ambient E-Ink displays and customizable firmware for home automation.",
url: "https://www.youtube.com/watch?v=eIcZZX10pa4"
},
{
id: "lJyNEXPx5Fw",
title: "WHY THIS XIAO ESP32-S3 ePaper KIT is INSANE?",
channel: "Hacktuber",
channel_id: "UCHk869G1R8_p1uVbL_5qQ1Q",
thumbnail: "https://i.ytimg.com/vi/lJyNEXPx5Fw/hqdefault.jpg",
duration: "11:41",
views: "95K views",
published_at: "2026-10-22T15:30:00Z",
published_relative: "7 days ago",
topic_id: "esp32-eink",
description: "Hands-on build with the ultra compact XIAO ESP32-S3 ePaper dashboard running custom telemetry.",
url: "https://www.youtube.com/watch?v=lJyNEXPx5Fw"
},
{
id: "5zc-yjMQtoo",
title: "This Cheap ESP32 e-Paper Board Has EVERYTHING",
channel: "Volos Projects",
channel_id: "UCv_45h7Hk2yU7i7q0A_r68A",
thumbnail: "https://i.ytimg.com/vi/5zc-yjMQtoo/hqdefault.jpg",
duration: "11:45",
views: "44K views",
published_at: "2026-10-10T10:00:00Z",
published_relative: "2 weeks ago",
topic_id: "esp32-eink",
description: "Building an ambient desk weather clock and crypto ticker with an ESP32 E-paper module.",
url: "https://www.youtube.com/watch?v=5zc-yjMQtoo"
},
{
id: "V8jV3zM6R9s",
title: "ESP32 Ultra Low Power Sleep Modes: The Definitive Guide",
channel: "Andreas Spiess",
channel_id: "UCu7_D0o48KbfhpEohoP7YSQ",
thumbnail: "https://i.ytimg.com/vi/V8jV3zM6R9s/hqdefault.jpg",
duration: "16:20",
views: "210K views",
published_at: "2026-10-18T14:00:00Z",
published_relative: "11 days ago",
topic_id: "person-andreas-spiess",
description: "How to run an ESP32 sensor for over 3 years on two AA batteries with deep sleep and ULP.",
url: "https://www.youtube.com/watch?v=V8jV3zM6R9s"
},
// Homelab & Traefik
{
id: "jM7V1k6e3xY",
title: "Traefik 3.0 In-Depth: Automatic SSL, Middleware Chains & Docker Integration",
channel: "Techno Tim",
channel_id: "UClG5y3Z59_6c5m2s8T91bQw",
thumbnail: "https://i.ytimg.com/vi/jM7V1k6e3xY/hqdefault.jpg",
duration: "21:04",
views: "185K views",
published_at: "2026-10-24T18:00:00Z",
published_relative: "5 days ago",
topic_id: "homelab-selfhost",
description: "The complete setup guide for Traefik reverse proxy dynamic configuration with wildcard certificates.",
url: "https://www.youtube.com/watch?v=jM7V1k6e3xY"
},
{
id: "rP9k4L2x1mM",
title: "My 10Gbps All-SSD Silent Homelab Rack in 2026",
channel: "Jeff Geerling",
channel_id: "UCR-DXc1voovS8nhAvccRZhg",
thumbnail: "https://i.ytimg.com/vi/rP9k4L2x1mM/hqdefault.jpg",
duration: "18:42",
views: "420K views",
published_at: "2026-10-20T16:00:00Z",
published_relative: "9 days ago",
topic_id: "person-jeff-geerling",
description: "Touring the updated silent rack setup with low power mini PCs, Proxmox clustering, and Ceph.",
url: "https://www.youtube.com/watch?v=rP9k4L2x1mM"
},
{
id: "wN4mK8t1qLs",
title: "Building an Offline-First Smart Home with SQLite and Local Docker Agents",
channel: "Self-Hosted Show",
channel_id: "UCqG9r_v2k9T7g3a1m9_L1vA",
thumbnail: "https://i.ytimg.com/vi/wN4mK8t1qLs/hqdefault.jpg",
duration: "28:15",
views: "72K views",
published_at: "2026-10-25T13:00:00Z",
published_relative: "4 days ago",
topic_id: "homelab-selfhost",
description: "Why local SQLite and containerized agents are revolutionizing self-hosted infrastructure reliability.",
url: "https://www.youtube.com/watch?v=wN4mK8t1qLs"
},
// Specialty Espresso & Brewing
{
id: "xK3vM9p0s2Q",
title: "The Ultimate Guide to Espresso Extraction Yield & Puck Resistance",
channel: "James Hoffmann",
channel_id: "UCMb0O2CdPBNi-QqPk5T3gsQ",
thumbnail: "https://i.ytimg.com/vi/xK3vM9p0s2Q/hqdefault.jpg",
duration: "17:35",
views: "530K views",
published_at: "2026-10-26T11:00:00Z",
published_relative: "3 days ago",
topic_id: "person-james-hoffmann",
description: "Mastering flow rate profiling, 58mm basket geometry, and precision grinder burr alignment.",
url: "https://www.youtube.com/watch?v=xK3vM9p0s2Q"
},
{
id: "bL6vP8t2w1R",
title: "Blind Testing Flat vs Conical Burrs: Can Coffee Experts Tell?",
channel: "Lance Hedrick",
channel_id: "UC85kG_GfP3777W2V9bVfH5g",
thumbnail: "https://i.ytimg.com/vi/bL6vP8t2w1R/hqdefault.jpg",
duration: "24:12",
views: "310K views",
published_at: "2026-10-15T15:00:00Z",
published_relative: "2 weeks ago",
topic_id: "specialty-espresso",
description: "Double blind sensory analysis on particle distribution and clarity in specialty single origins.",
url: "https://www.youtube.com/watch?v=bL6vP8t2w1R"
},
{
id: "zN1mQ4v7x8S",
title: "Craft Nitro Cold Brew on Tap: Nitrogen Infusion at Home",
channel: "Sprometheus",
channel_id: "UCeA3k73pQ4J9jU0E2b0V5aw",
thumbnail: "https://i.ytimg.com/vi/zN1mQ4v7x8S/hqdefault.jpg",
duration: "12:48",
views: "89K views",
published_at: "2026-10-21T17:00:00Z",
published_relative: "8 days ago",
topic_id: "specialty-espresso",
description: "Building a dedicated mini-kegerator tap system with stout faucets for velvety nitro cascade.",
url: "https://www.youtube.com/watch?v=zN1mQ4v7x8S"
},
// Vintage Screenprint & Apparel
{
id: "qR4vL8t1w2Y",
title: "Printing 5-Color Vintage Halftones on Heavyweight Crewnecks",
channel: "Print Life Studio",
channel_id: "UC9p0v1m2_4k5l6r7t8y9u0w",
thumbnail: "https://i.ytimg.com/vi/qR4vL8t1w2Y/hqdefault.jpg",
duration: "19:08",
views: "64K views",
published_at: "2026-10-27T14:00:00Z",
published_relative: "2 days ago",
topic_id: "vintage-screenprint",
description: "Simulated process color separation, discharge underbase, and curing for ultra soft hand feel.",
url: "https://www.youtube.com/watch?v=qR4vL8t1w2Y"
},
{
id: "mK9vX2t4w1L",
title: "Left Chest & Full Back Print Alignment: Screen Printing Like a Pro",
channel: "Catspit Productions",
channel_id: "UC3k1l2m3_4k5l6r7t8y9u0x",
thumbnail: "https://i.ytimg.com/vi/mK9vX2t4w1L/hqdefault.jpg",
duration: "14:52",
views: "112K views",
published_at: "2026-10-12T16:00:00Z",
published_relative: "2 weeks ago",
topic_id: "vintage-screenprint",
description: "Precise pallet registration and laser alignment techniques for heavy cotton fleece and tees.",
url: "https://www.youtube.com/watch?v=mK9vX2t4w1L"
},
{
id: "kP3vM7t1w9R",
title: "Water-Based Inks vs Plastisol: The Honest Truth After 10 Years",
channel: "Shirt Agency",
channel_id: "UC1k2l3m4_5k6l7r8t9y0u1w",
thumbnail: "https://i.ytimg.com/vi/kP3vM7t1w9R/hqdefault.jpg",
duration: "16:40",
views: "82K views",
published_at: "2026-10-05T12:00:00Z",
published_relative: "3 weeks ago",
topic_id: "vintage-screenprint",
description: "Hands-on comparison of breathability, stretch, durability, and eco-impact across apparel inks.",
url: "https://www.youtube.com/watch?v=kP3vM7t1w9R"
},
// Alpine Skiing & Mountain Culture
{
id: "tM4vQ8r2x1K",
title: "LINES: Steep Spine Skiing in the Chugach Mountains, Alaska",
channel: "Salomon TV",
channel_id: "UCP8e6b12_1k2l3m4r5t6y7u",
thumbnail: "https://i.ytimg.com/vi/tM4vQ8r2x1K/hqdefault.jpg",
duration: "15:22",
views: "340K views",
published_at: "2026-10-23T16:00:00Z",
published_relative: "6 days ago",
topic_id: "alpine-skiing",
description: "Breathtaking high-definition mountain expedition chasing first tracks through technical couloirs.",
url: "https://www.youtube.com/watch?v=tM4vQ8r2x1K"
},
{
id: "gN7vP1t9w3L",
title: "The Art of the Turn: Powder Ski Geometry & Sidecut Physics",
channel: "Cody Townsend",
channel_id: "UCm1l2k3_4k5l6r7t8y9u01z",
thumbnail: "https://i.ytimg.com/vi/gN7vP1t9w3L/hqdefault.jpg",
duration: "18:14",
views: "195K views",
published_at: "2026-10-17T15:00:00Z",
published_relative: "12 days ago",
topic_id: "alpine-skiing",
description: "Analyzing edge hold, camber profiles, and rocker technology in modern freeride skis.",
url: "https://www.youtube.com/watch?v=gN7vP1t9w3L"
},
// Lateral Thinking & Creative Whack
{
id: "vB2mK8t1q3X",
title: "A Whack on the Side of the Head: How to Break Mental Locks",
channel: "The Creative Leap",
channel_id: "UC0k1l2m3_4k5l6r7t8y9u02a",
thumbnail: "https://i.ytimg.com/vi/vB2mK8t1q3X/hqdefault.jpg",
duration: "17:05",
views: "145K views",
published_at: "2026-10-21T13:00:00Z",
published_relative: "8 days ago",
topic_id: "lateral-thinking",
description: "Applying Roger von Oech's classic creative catalysts: The Explorer, The Artist, The Judge, and The Warrior.",
url: "https://www.youtube.com/watch?v=vB2mK8t1q3X"
},
{
id: "zQ8vN2m1k4L",
title: "How to Generate 100 Unique Ideas in 60 Minutes (The Lateral Method)",
channel: "Struthless",
channel_id: "UCbFL4rK1o9u_8k4m2r6t8w0",
thumbnail: "https://i.ytimg.com/vi/zQ8vN2m1k4L/hqdefault.jpg",
duration: "13:40",
views: "680K views",
published_at: "2026-10-19T17:00:00Z",
published_relative: "10 days ago",
topic_id: "person-struthless",
description: "Creative constraints, pen-and-paper exercises, and lateral thinking systems that beat creative block.",
url: "https://www.youtube.com/watch?v=zQ8vN2m1k4L"
},
// Vinyl & Audio Archives
{
id: "wP4vM8t2q1K",
title: "Tube Amplifiers vs Solid State: The Real Acoustic Differences",
channel: "Darko Audio",
channel_id: "UCm1l2k3_4k5l6r7t8y9u03b",
thumbnail: "https://i.ytimg.com/vi/wP4vM8t2q1K/hqdefault.jpg",
duration: "15:45",
views: "275K views",
published_at: "2026-10-25T15:00:00Z",
published_relative: "4 days ago",
topic_id: "vinyl-audio",
description: "Oscilloscope readings, harmonic distortion spectra, and listening tests on vinyl playback.",
url: "https://www.youtube.com/watch?v=wP4vM8t2q1K"
},
{
id: "jK2vL7m1q9P",
title: "Restoring a 1974 Technics Direct Drive Turntable",
channel: "The Vinyl Guide",
channel_id: "UC0k1l2m3_4k5l6r7t8y9u04c",
thumbnail: "https://i.ytimg.com/vi/jK2vL7m1q9P/hqdefault.jpg",
duration: "22:30",
views: "160K views",
published_at: "2026-10-14T11:00:00Z",
published_relative: "2 weeks ago",
topic_id: "vinyl-audio",
description: "Motor spindle maintenance, tonearm damping calibration, and cartridge alignment tips.",
url: "https://www.youtube.com/watch?v=jK2vL7m1q9P"
},
// Maker Fast Builds
{
id: "hL7vP3m1k9X",
title: "I Built an E-Ink Stock & Crypto Ticker in a Weekend",
channel: "Matt Welsh Makes",
channel_id: "UCm1l2k3_4k5l6r7t8y9u05d",
thumbnail: "https://i.ytimg.com/vi/hL7vP3m1k9X/hqdefault.jpg",
duration: "12:18",
views: "88K views",
published_at: "2026-10-27T19:00:00Z",
published_relative: "2 days ago",
topic_id: "fast-prototyping",
description: "3D printed walnut-finish enclosure, custom PCB shield, and battery optimization for 6-month runtime.",
url: "https://www.youtube.com/watch?v=hL7vP3m1k9X"
},
{
id: "pQ9vK2m4r1T",
title: "10 Fast Prototyping Tricks Every Maker Needs to Know",
channel: "Zack Freedman (Voidstar Lab)",
channel_id: "UC0k1l2m3_4k5l6r7t8y9u06e",
thumbnail: "https://i.ytimg.com/vi/pQ9vK2m4r1T/hqdefault.jpg",
duration: "19:54",
views: "490K views",
published_at: "2026-10-18T20:00:00Z",
published_relative: "11 days ago",
topic_id: "fast-prototyping",
description: "Heat-set threaded inserts, rapid CAD shortcuts, and designing for instant printability.",
url: "https://www.youtube.com/watch?v=pQ9vK2m4r1T"
}
];
+170
View File
@@ -0,0 +1,170 @@
// YouTube Fetcher & Parser for Spectra
// Handles live YouTube search scraping, RSS channel feeds, and fallback caching
export function parseRelativeTimeToDate(relativeText) {
const now = new Date();
if (!relativeText) return now.toISOString();
const text = relativeText.toLowerCase().trim();
let msAgo = 0;
const numMatch = text.match(/(\d+)/);
const num = numMatch ? parseInt(numMatch[1], 10) : 1;
if (text.includes("second")) {
msAgo = num * 1000;
} else if (text.includes("minute")) {
msAgo = num * 60 * 1000;
} else if (text.includes("hour")) {
msAgo = num * 60 * 60 * 1000;
} else if (text.includes("day")) {
msAgo = num * 24 * 60 * 60 * 1000;
} else if (text.includes("week")) {
msAgo = num * 7 * 24 * 60 * 60 * 1000;
} else if (text.includes("month")) {
msAgo = num * 30 * 24 * 60 * 60 * 1000;
} else if (text.includes("year")) {
msAgo = num * 365 * 24 * 60 * 60 * 1000;
} else if (text.includes("yesterday")) {
msAgo = 24 * 60 * 60 * 1000;
} else {
// Default to roughly recent
msAgo = 3 * 24 * 60 * 60 * 1000;
}
return new Date(now.getTime() - msAgo).toISOString();
}
export async function searchYouTube(query, options = {}) {
const { sort = "upload_date", timeout = 6000 } = options;
// sp=CAISAhAB sorts by upload date (newest first)
const spParam = sort === "upload_date" ? "&sp=CAISAhAB" : "";
const url = `https://www.youtube.com/results?search_query=${encodeURIComponent(query)}${spParam}`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
try {
const res = await fetch(url, {
signal: controller.signal,
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Accept-Language": "en-US,en;q=0.9",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
}
});
clearTimeout(timer);
if (!res.ok) {
return [];
}
const html = await res.text();
const match =
html.match(/var ytInitialData = ({.*?});<\/script>/s) ||
html.match(/ytInitialData\s*=\s*({.+?});/);
if (!match) return [];
const data = JSON.parse(match[1]);
const items = [];
const contents =
data?.contents?.twoColumnSearchResultsRenderer?.primaryContents?.sectionListRenderer?.contents || [];
for (const section of contents) {
const renderers = section?.itemSectionRenderer?.contents || [];
for (const item of renderers) {
if (item.videoRenderer) {
const vr = item.videoRenderer;
if (!vr.videoId) continue;
const title = vr.title?.runs?.[0]?.text || "Untitled";
const channel = vr.ownerText?.runs?.[0]?.text || "Creator";
const channelId = vr.ownerText?.runs?.[0]?.navigationEndpoint?.browseEndpoint?.browseId || "";
const views = vr.viewCountText?.simpleText || vr.shortViewCountText?.simpleText || "";
const relativeTime = vr.publishedTimeText?.simpleText || "Recent";
const duration = vr.lengthText?.simpleText || "";
const description = vr.detailedMetadataSnippets?.[0]?.snippetText?.runs?.map(r => r.text).join("") || "";
items.push({
id: vr.videoId,
title,
channel,
channel_id: channelId,
thumbnail: `https://i.ytimg.com/vi/${vr.videoId}/hqdefault.jpg`,
duration,
views,
published_relative: relativeTime,
published_at: parseRelativeTimeToDate(relativeTime),
description,
url: `https://www.youtube.com/watch?v=${vr.videoId}`
});
}
}
}
return items;
} catch (err) {
clearTimeout(timer);
// Ignore network timeouts / errors and return empty list for fallback handling
return [];
}
}
export async function fetchChannelRss(channelId, timeout = 5000) {
if (!channelId) return [];
const url = `https://www.youtube.com/feeds/videos.xml?channel_id=${channelId}`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
try {
const res = await fetch(url, { signal: controller.signal });
clearTimeout(timer);
if (!res.ok) return [];
const xml = await res.text();
const entries = [];
const entryMatches = xml.matchAll(/<entry>([\s\S]*?)<\/entry>/g);
for (const match of entryMatches) {
const block = match[1];
const videoId = (block.match(/<yt:videoId>([^<]+)<\/yt:videoId>/) || [])[1];
const title = (block.match(/<title>([^<]+)<\/title>/) || [])[1];
const author = (block.match(/<name>([^<]+)<\/name>/) || [])[1];
const published = (block.match(/<published>([^<]+)<\/published>/) || [])[1];
const desc = (block.match(/<media:description>([\s\S]*?)<\/media:description>/) || [])[1];
if (videoId && title) {
entries.push({
id: videoId,
title: decodeXmlEntities(title),
channel: decodeXmlEntities(author || ""),
channel_id: channelId,
thumbnail: `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`,
duration: "",
views: "",
published_at: published || new Date().toISOString(),
published_relative: "Recent",
description: decodeXmlEntities(desc || "").slice(0, 200),
url: `https://www.youtube.com/watch?v=${videoId}`
});
}
}
return entries;
} catch (err) {
clearTimeout(timer);
return [];
}
}
function decodeXmlEntities(str) {
if (!str) return "";
return str
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'");
}