Initial commit: Spectra YouTube Suggest Engine
This commit is contained in:
commit
e90505a39b
16 files changed
+4495
No files matched your search
+420
@@ -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 };
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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}`);
|
||||
});
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
];
|
||||
@@ -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(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
Reference in new issue
Block a user