openvibers/openvibe.live Commits

C-73 step 2: nothing in Live reads the frozen vods/clips/pastes tables any more; every reader asks OpenVibe.Media (VODs, clips) or OpenVibe.Community (pastes) through the new server/media-proxy/lookups.js. Recently ended streams get their public VOD from Media per stream (cached 5 min, 4 s budget; never a private or unlisted id or thumbnail); the workspace history gets each session's VOD from Media by slot (owner only, private included); the channel page's paste and clips-taken badges come from Community and Media (hidden ones only for the owner, asked as them, or staff, vouched with X-OV-Staff, which pastes-client now sends); the offline screen's week/month/all-time top VOD and clip are cut from Media's newest 100 plus its most-viewed; the AI timeline's session links are public Media VODs and a timeline built while Media is down is not cached; the transcript view, the AI moments and auto-clip jobs lose their local fallbacks (clip start times come from Media); avatar history and the setup hub's paste count come from Community; home hero, kiosk and SEO archive counters come from Media /stats with pastes from Community; admin VOD totals and the AI explorer read Media and Community; stream analytics' clips_created is filled from Media right after the stream ends; arena portraits use Media VOD thumbnails; the streamer overview job needs stream memories. Response shapes are unchanged (null where the upstream does not answer). database.js loses the 45 uncalled VOD/clip/paste/paste-comment helpers, the 11 whose callers moved, the boot-time migrations that still rewrote frozen vods/clips rows, and the indexes that served only the removed listings. Tests: test/media-lookups.test.js (every moved reader and route against in-process Media and Community stand-ins, including who is asked for hidden items), media-privacy's recently-ended check now runs against Media

OP OpenVibers committed 23 Sept 2026 · 9b80d2b338 · 17 files, +938 −1032


Changes

ddiff --git a/server/admin/routes.js b/server/admin/routes.js
iindex 1309c85c..13ff0d19 100644
---- a/server/admin/routes.js
++++ b/server/admin/routes.js
@@@ -64,8 +64,10 @@ router.get('/diagnostics', (req, res) => {
});
// ── Dashboard Stats ──────────────────────────────────────────
--router.get('/stats', (req, res) => {
++router.get('/stats', async (req, res) => {
try {
++ // VODs live in OpenVibe.Media (null when it does not answer); Live's vods table is frozen.
++ const vodCounts = await require('../media-proxy/lookups').vodCounts();
const stats = {
users: {
total: db.get('SELECT COUNT(*) as c FROM users').c,
@@@ -88,8 +90,8 @@ router.get('/stats', (req, res) => {
totalDonated: db.get("SELECT COALESCE(SUM(amount), 0) as c FROM transactions WHERE type = 'donation'").c,
},
vods: {
-- total: db.get('SELECT COUNT(*) as c FROM vods').c,
-- public: db.get('SELECT COUNT(*) as c FROM vods WHERE is_public = 1').c,
++ total: vodCounts.total,
++ public: vodCounts.public,
},
chat: {
totalMessages: db.get('SELECT COUNT(*) as c FROM chat_messages').c,
@@@ -574,7 +576,8 @@ router.get('/ai/status', async (req, res) => {
});
// Browse a streamer's AI-relevant data: memories, AI-analyzed pastes, VODs, overview.
--router.get('/ai/explorer/:userId', (req, res) => {
++// Pastes come from OpenVibe.Community and VODs/clips from OpenVibe.Media, any visibility (staff).
++router.get('/ai/explorer/:userId', async (req, res) => {
try {
const userId = parseInt(req.params.userId, 10);
const user = db.getUserById(userId);
@@@ -583,13 +586,18 @@ router.get('/ai/explorer/:userId', (req, res) => {
id: m.id, stream_id: m.stream_id, description: m.description,
tags: m.tags, created_at: m.created_at, thumbnail_url: m.thumbnail_url,
}));
-- const pastes = db.getUserPastesForAi(userId, 40);
-- const vods = (db.getVodsByUser ? db.getVodsByUser(userId, true, 40, 0) : []).map(v => ({
++ const lookups = require('../media-proxy/lookups');
++ const [pastes, userVods, userClips] = await Promise.all([
++ lookups.userPastesForAi(user, 40),
++ lookups.userVods(userId, { includePrivate: true, limit: 40 }),
++ lookups.userClips(userId, { includePrivate: true, limit: 40 }),
++ ]);
++ const vods = userVods.map(v => ({
id: v.id, title: v.title, category: v.category, created_at: v.created_at,
duration_seconds: v.duration_seconds, ai_overview: v.ai_overview || null,
ai_transcript: (v.ai_transcript && v.ai_transcript.trim()) ? v.ai_transcript : null,
}));
-- const clips = (db.getClipsByUser ? db.getClipsByUser(userId, true, 40, 0) : []).map(c => ({
++ const clips = userClips.map(c => ({
id: c.id, title: c.title, created_at: c.created_at,
duration_seconds: c.duration_seconds, ai_overview: c.ai_overview || null,
ai_transcript: (c.ai_transcript && c.ai_transcript.trim()) ? c.ai_transcript : null,
ddiff --git a/server/ai/ai-analysis.js b/server/ai/ai-analysis.js
iindex 70297da6..5b318985 100644
---- a/server/ai/ai-analysis.js
++++ b/server/ai/ai-analysis.js
@@@ -243,10 +243,12 @@ async function generateStreamerOverview(userId) {
const memories = (db.getStreamMemoriesByUser ? db.getStreamMemoriesByUser(userId, 60) : []) || [];
const memLines = memories.slice(0, 40).map(m => `- ${m.description}`).filter(l => l.length > 2);
-- const vods = (db.getVodsByUser ? db.getVodsByUser(userId, false, 20, 0) : []) || [];
++ // VODs from OpenVibe.Media (public ones) and pastes from OpenVibe.Community (any visibility:
++ // this overview is for site staff), both through media-proxy/lookups.js.
++ const lookups = require('../media-proxy/lookups');
++ const [vods, pastes] = await Promise.all([lookups.userVods(userId, { limit: 20 }), lookups.userPastesForAi(user, 25)]);
const vodLines = vods.map(v => `- ${v.title || 'Untitled VOD'}${(v.ai_category || v.category) ? ` [${v.ai_category || v.category}]` : ''}`);
-- const pastes = (db.getUserPastesForAi ? db.getUserPastesForAi(userId, 25) : []) || [];
const pasteLines = pastes.filter(p => p.ai_summary).map(p => `- "${p.title || 'paste'}": ${p.ai_summary}`);
if (!memLines.length && !vodLines.length && !pasteLines.length) return null;
ddiff --git a/server/ai/ai-moments-job.js b/server/ai/ai-moments-job.js
iindex 58dd3be8..b94e546d 100644
---- a/server/ai/ai-moments-job.js
++++ b/server/ai/ai-moments-job.js
@@@ -136,10 +136,11 @@ function _sample(arr, max) {
for (let i = 0; i < max; i++) out.push(arr[Math.floor(i * step)]);
return out;
}
--function _momentContext(streamId, vodId) {
++async function _momentContext(streamId, vodId) {
const memories = _sample((db.getStreamMemories(streamId) || []).filter(m => m.description), 45);
const transcript = _sample(db.getStreamTranscriptSegments(streamId) || [], 60);
-- const clipTimes = db.getClipStartTimesForStream(streamId, vodId) || [];
++ // Where viewers clipped: the clips live in OpenVibe.Media.
++ const clipTimes = await require('../media-proxy/lookups').clipStartTimes(streamId, vodId);
const spikes = db.getChatSpikeOffsets(streamId, 30, 8) || [];
// Non-speech sounds are strong moment candidates — an explosion or a burst of
// laughter marks a highlight as reliably as anything said out loud.
@@@ -163,7 +164,7 @@ const FLAVOR_HINT = {
};
async function _findBestMoment(vod, { flavor = 'paste', avoid = [] } = {}) {
const dur = Math.floor(vod.duration || 0);
-- const ctx = _momentContext(vod.stream_id, vod.vod_id);
++ const ctx = await _momentContext(vod.stream_id, vod.vod_id);
const avoidList = [...new Set([...(avoid || []), ...registry.usedOffsets(vod.vod_id, vod.stream_id)])].sort((a, b) => a - b);
const farFromUsed = (t) => avoidList.every(a => Math.abs(a - t) >= registry.GAP_SEC);
if (!ctx.memories.length && !ctx.transcript.length) return null;
@@@ -262,7 +263,7 @@ function _sig(desc) {
function _dedupePastes() { /* moved to Media — sig-dedup happens pre-post */ }
// Build the moment-ranking VOD pool: Media list (most-viewed public VODs) joined with
--// local AI state + stream memories. Shapes rows like the old getVodsForMomentRanking.
++// local AI state + stream memories. Media down → an empty pool (the run makes nothing).
async function _momentPool(limit) {
try {
const r = await media.listVods({ limit, order: 'views' });
@@@ -288,8 +289,8 @@ async function _momentPool(limit) {
};
});
}
-- } catch { /* fall through to legacy local rows */ }
-- try { return db.getVodsForMomentRanking ? (db.getVodsForMomentRanking(limit) || []) : []; } catch { return []; }
++ } catch { /* Media down */ }
++ return [];
}
async function tick(opts = {}) {
@@@ -366,23 +367,17 @@ async function tick(opts = {}) {
const slug = _slug();
// Extract the real frame at this moment so the paste is a true IMAGE paste.
-- // The source is the still-present legacy local file, or the OpenVibe.Media
-- // playback URL — ffmpeg range-seeks the remote file, so cold VODs still work.
++ // The source is the OpenVibe.Media playback URL — ffmpeg range-seeks the remote
++ // file, so cold VODs still work.
let screenshotPath = null;
-- let momentSource = null; // resolved media (local path or Media URL) for clipping
++ let momentSource = null; // resolved Media URL, for clipping
let img = `/api/thumbnails/generate/vod/${v.vod_id}`;
try {
let source = null;
try {
-- const vod = db.getVodById ? db.getVodById(v.vod_id) : null;
-- if (vod && vod.file_path && fs.existsSync(vod.file_path)) source = vod.file_path;
-- } catch { /* */ }
-- if (!source) {
-- try {
-- const meta = await media.getVod(v.vod_id);
-- source = media.publicUrl(meta && meta.playback_url) || media.vodPlaybackUrl(v.vod_id);
-- } catch { source = media.vodPlaybackUrl(v.vod_id); }
-- }
++ const meta = await media.getVod(v.vod_id);
++ source = media.publicUrl(meta && meta.playback_url) || media.vodPlaybackUrl(v.vod_id);
++ } catch { source = media.vodPlaybackUrl(v.vod_id); }
momentSource = source || null;
if (thumb && source && thumb.extractFrameToFile) {
const fname = `ai-moment-vod${v.vod_id}-${offset}.jpg`;
ddiff --git a/server/ai/auto-clip-job.js b/server/ai/auto-clip-job.js
iindex 90428637..bbf612b4 100644
---- a/server/ai/auto-clip-job.js
++++ b/server/ai/auto-clip-job.js
@@@ -183,8 +183,8 @@ function _backfillDue() {
catch { return true; }
}
// Historical-backfill candidate pool: most-viewed public VODs from OpenVibe.Media
--// that have stream memories and no auto-clip in the local log yet (shaped like the
--// old getVodsWithoutAutoClip rows). Falls back to legacy local rows if Media is down.
++// that have stream memories and no auto-clip in the local log yet. Media down → an
++// empty pool (the clips are cut by Media anyway).
async function _backfillPool(limit) {
try {
const r = await media.listVods({ limit: limit * 2, order: 'views' });
@@@ -208,17 +208,13 @@ async function _backfillPool(limit) {
view_count: Number(v.view_count) || 0,
}));
}
-- } catch { /* fall through */ }
-- try { return db.getVodsWithoutAutoClip ? (db.getVodsWithoutAutoClip(limit) || []) : []; } catch { return []; }
++ } catch { /* Media down */ }
++ return [];
}
--// ffmpeg-consumable source for a VOD: legacy local file if still present, else the
--// Media playback URL (ffmpeg range-reads it — no full download).
++// ffmpeg-consumable source for a VOD: the Media playback URL (ffmpeg range-reads it —
++// no full download).
async function _resolveVodSource(vodId) {
-- try {
-- const vod = db.getVodById ? db.getVodById(vodId) : null;
-- if (vod && vod.file_path && require('node:fs').existsSync(vod.file_path)) return vod.file_path;
-- } catch { /* */ }
try {
const meta = await media.getVod(vodId);
if (meta && meta.playback_url) return media.publicUrl(meta.playback_url);
ddiff --git a/server/ai/chat-ai-routes.js b/server/ai/chat-ai-routes.js
iindex a76a32bd..db6f5eba 100644
---- a/server/ai/chat-ai-routes.js
++++ b/server/ai/chat-ai-routes.js
@@@ -171,13 +171,19 @@ function _ensureSessionTitles(userId) {
// Full AI timeline for a streamer's channel page (streamer overview + every session's AI
// overview + memory moments with VOD timestamps). Lazily assembled + cached (15 min TTL),
// so it only rebuilds when the tab is actually opened and the cache is stale — no LLM cost.
--router.get('/timeline/:username', (req, res) => {
++router.get('/timeline/:username', async (req, res) => {
try {
const uname = String(req.params.username || '').trim();
const user = db.getUserByUsername ? db.getUserByUsername(uname) : null;
if (!user) return res.status(404).json({ error: 'Channel not found' });
-- const timeline = db.getStreamerAiTimeline(user.id); // full, cached
++ // Full timeline, cached 15 min. On a miss, each session's public VOD comes from OpenVibe.Media;
++ // a timeline built while Media did not answer is served but not cached.
++ let timeline = db.readStreamerAiTimelineCache(user.id);
++ if (!timeline) {
++ const vods = await require('../media-proxy/lookups').publicVodIdsByStream(user.id);
++ timeline = db.buildStreamerAiTimeline(user.id, vods.byStream, { store: vods.complete });
++ }
const allSessions = timeline.sessions || [];
const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
const limit = Math.min(30, Math.max(1, parseInt(req.query.limit, 10) || 12));
@@@ -338,16 +344,7 @@ router.get('/transcript/:streamId', async (req, res) => {
const out = await media.listVods({ stream_id: sid, limit: 1 });
const rows = out?.vods || (Array.isArray(out) ? out : []);
vodId = rows[0] ? rows[0].id : null;
-- } catch { /* */ }
-- if (!vodId) {
-- try {
-- // Legacy pre-migration rows; never a private one (Media's list above is public-only too).
-- const v = db.get(`SELECT id FROM vods WHERE stream_id = ? AND COALESCE(is_recording, 0) = 0
-- AND COALESCE(visibility, CASE WHEN is_public = 1 THEN 'public' ELSE 'private' END) != 'private'
-- ORDER BY COALESCE(is_public,1) DESC, id DESC LIMIT 1`, [sid]);
-- vodId = v ? v.id : null;
-- } catch { /* */ }
-- }
++ } catch { /* Media down: the transcript still loads, without the VOD link */ }
// Sound events ride alongside the speech segments so the transcript view can show
// "what was heard" as well as "what was said". Empty for streams captured before
// the timeline existed, which the frontend treats as speech-only.
ddiff --git a/server/ai/streamer-overview-job.js b/server/ai/streamer-overview-job.js
iindex c5f30bc1..27cbee32 100644
---- a/server/ai/streamer-overview-job.js
++++ b/server/ai/streamer-overview-job.js
@@@ -6,7 +6,7 @@
* Cadence (enforced in SQL by getStreamersNeedingOverview):
* - A "decent" overview (>= DECENT_LEN chars) is refreshed at most once per 12h.
* - A sparse/missing overview is retried hourly until it fills out.
-- * Only streamers with some signal (memories or VODs) are considered, and it no-ops
++ * Only streamers with stream memories are considered, and it no-ops
* entirely until AI is enabled in openvibe.network/admin → AI.
*/
'use strict';
ddiff --git a/server/arena/arena-service.js b/server/arena/arena-service.js
iindex 48a8022a..58d82648 100644
---- a/server/arena/arena-service.js
++++ b/server/arena/arena-service.js
@@@ -399,7 +399,7 @@ function imageUrlFor(row) {
const base = path.basename(row.image_path);
return fs.existsSync(path.join(ARENA_DIR, base)) ? `/data/arena/${base}` : null;
}
--function latestThumbnailFor(userId) { const r = referenceImagesFor(userId); return r.length ? r[0] : null; }
++async function latestThumbnailFor(userId) { const r = await referenceImagesFor(userId); return r.length ? r[0] : null; }
/**
* Up to REF_MAX real frames of THIS streamer's streams, newest first: the live thumbnail, AI-moment
* frames the vision job persisted (data/ai-moments/<streamId>/<offset>.jpg), VOD thumbnails. Local
@@@ -407,7 +407,7 @@ function latestThumbnailFor(userId) { const r = referenceImagesFor(userId); retu
* stream — their setup, their lighting, their gear, their face — not a generic hero.
*/
const REF_MAX = 3;
--function referenceImagesFor(userId) {
++async function referenceImagesFor(userId) {
const out = [];
const push = (p) => { if (p && !out.includes(p) && out.length < REF_MAX) out.push(p); };
try {
@@@ -433,7 +433,16 @@ function referenceImagesFor(userId) {
// Media-hosted thumbnails of their streams (what prod actually has: https://openvibe.media/t/vod-….jpg).
try { for (const r of db.all(`SELECT thumbnail_url FROM streams WHERE user_id = ? AND thumbnail_url LIKE 'http%' ORDER BY started_at DESC LIMIT 3`, [userId])) push(r.thumbnail_url); } catch { /* */ }
try { for (const r of db.all(`SELECT m.thumbnail_url FROM stream_memories m WHERE m.user_id = ? AND m.thumbnail_url LIKE 'http%' AND (LOWER(m.description) LIKE '%person%' OR LOWER(m.description) LIKE '%face%' OR LOWER(m.description) LIKE '%wearing%' OR LOWER(m.description) LIKE '%headphones%') ORDER BY m.id DESC LIMIT 3`, [userId])) push(r.thumbnail_url); } catch { /* */ }
-- try { for (const v of db.all('SELECT thumbnail_url FROM vods WHERE user_id = ? AND thumbnail_url IS NOT NULL AND is_public = 1 ORDER BY created_at DESC LIMIT 3', [userId])) { if (/^https?:\/\//i.test(v.thumbnail_url)) push(v.thumbnail_url); else { const local = path.resolve('.' + v.thumbnail_url); if (fs.existsSync(local)) push(local); } } } catch { /* */ }
++ // Their newest public VODs' thumbnails, from OpenVibe.Media.
++ if (out.length < REF_MAX) {
++ try {
++ const media = require('../media-client');
++ for (const v of await require('../media-proxy/lookups').userVods(userId, { limit: 3 })) {
++ const url = media.publicUrl(v.thumbnail_url);
++ if (url && /^https?:\/\//i.test(url)) push(url);
++ }
++ } catch { /* */ }
++ }
return out;
}
async function loadImageBuffer(src) {
@@@ -455,14 +464,15 @@ async function generateImage(userId, { force = false } = {}) {
const persona = parseJson(row?.persona_json) || (entry ? fallbackPersona(entry) : null);
if (!persona) return null;
let scene = '';
-- const thumb = referenceImagesFor(userId).length ? null : latestThumbnailFor(userId); // scene text only for the no-frames fallback
++ const refSources = await referenceImagesFor(userId);
++ const thumb = refSources.length ? null : await latestThumbnailFor(userId); // scene text only for the no-frames fallback
if (thumb) { try { const d = await llm.complete({ role: 'vision', kind: 'arena_scene', source: 'arena', ownerUserId: userId, system: SCENE_SYSTEM, user: 'Describe the scene.', image: thumb, maxTokens: 120, temperature: 0.4, timeoutMs: 30000 }); scene = (d && d.text || '').trim(); } catch { scene = ''; } }
const color = entry?.user?.profile_color || '#8b5cf6';
const cs = Array.isArray(persona.custom_stats) ? persona.custom_stats.slice(0, 4).map(x => `${x.name} ${x.value}`).join(', ') : '';
// Reference frames: the portrait is drawn FROM the streamer's own stream (image edit) whenever we
// have frames; the text-only generation is the fallback for streamers with no frames yet.
const refs = [];
-- for (const src of referenceImagesFor(userId)) { const buf = await loadImageBuffer(src); if (buf && buf.length > 4000) refs.push({ src, buf }); }
++ for (const src of refSources) { const buf = await loadImageBuffer(src); if (buf && buf.length > 4000) refs.push({ src, buf }); }
const prompt = [
refs.length ? `Turn the attached frames from this streamer's live stream into ONE fighting-game character-select portrait of them as "${persona.fighter_name}" — ${persona.title}. Keep what makes their stream recognisable: their setup, room, gear, lighting, clothing, silhouette, hair, headphones, camera angle, the vibe of the scene — exaggerated into a stylised caricature-hero (like a Street Fighter select screen), never a photo.` : `Fighting-game character-select portrait of an original stylised hero called "${persona.fighter_name}" — ${persona.title}.`,
`Class: ${persona.class}. Element: ${persona.element}. Signature move: ${persona.signature_move?.name} (${persona.signature_move?.description}).${cs ? ` Their stats: ${cs}.` : ''}`,
ddiff --git a/server/auth/routes.js b/server/auth/routes.js
iindex 92e7604b..6e522173 100644
---- a/server/auth/routes.js
++++ b/server/auth/routes.js
@@@ -242,21 +242,24 @@ router.post('/avatar', requireAuth, avatarUpload.single('avatar'), async (req, r
}
});
--// The user's avatar upload history (avatar-tagged screenshot pastes).
--router.get('/avatar/history', requireAuth, (req, res) => {
++// The user's avatar upload history (avatar-tagged screenshot pastes, in OpenVibe.Community — or
++// OpenVibe.Media while PASTES_AUTHORITY is unset). Pastes have no metadata filter upstream, so
++// lookups.avatarPastes lists the person's screenshots and keeps the avatar-tagged ones.
++router.get('/avatar/history', requireAuth, async (req, res) => {
try {
const me = db.getUserById(req.user.id);
const activePasteId = me?.avatar_paste_id || null;
-- // TODO(contract): avatar history reads the legacy local pastes rows only —
-- // Media API v1 has no metadata-filtered paste listing yet, so avatars
-- // uploaded after the media split appear once Media exposes such a filter.
-- const rows = db.getUserAvatarPastes(req.user.id).map(r => ({
-- slug: r.slug,
-- title: r.title,
-- created_at: r.created_at,
-- url: r.screenshot_path ? `/data/pastes/screenshots/${path.basename(r.screenshot_path)}` : null,
-- active: r.id === activePasteId,
-- })).filter(r => r.url);
++ const media = require('../media-client');
++ const rows = (await require('../media-proxy/lookups').avatarPastes(me)).map(r => {
++ const url = media.publicUrl(r.screenshot_url) || (r.screenshot_path ? media.screenshotUrl(path.basename(r.screenshot_path)) : null);
++ return {
++ slug: r.slug,
++ title: r.title,
++ created_at: r.created_at,
++ url,
++ active: (activePasteId != null && r.id === activePasteId) || (!!url && url === me.avatar_url),
++ };
++ }).filter(r => r.url);
res.json({ avatars: rows });
} catch (err) {
console.error('[Auth] Avatar history error:', err.message);
ddiff --git a/server/db/database.js b/server/db/database.js
iindex ebf756f8..10572259 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -293,11 +293,11 @@ function initDb() {
}
} catch (e) { console.warn('[DB] ai-overview column migration:', e.message); }
-- // Transcript job-state recovery — on the tables that actually hold it. The older
-- // recovery loop above targets the legacy local `vods`/`clips` tables, which moved to
-- // OpenVibe.Media; it throws on the first PRAGMA and silently does nothing, which is
-- // why rows killed mid-flight by a deploy stayed 'processing' forever and 'failed' was
-- // permanent. Also adds resumable-progress columns (finished windows survive restarts).
++ // Transcript job-state recovery — on the tables that actually hold it. An older recovery
++ // loop targeted the legacy local `vods`/`clips` tables (which moved to OpenVibe.Media), so
++ // rows killed mid-flight by a deploy stayed 'processing' forever and 'failed' was
++ // permanent; that loop is gone. Also adds resumable-progress columns (finished windows
++ // survive restarts).
try {
for (const t of ['vod_ai_state', 'clip_ai_state']) {
const cols = database.prepare(`PRAGMA table_info(${t})`).all().map(c => c.name);
@@@ -1454,8 +1454,7 @@ function initDb() {
try {
for (const [t, col, scol] of [
['streams', 'ai_overview', 'ai_overview_short'],
-- ['vods', 'ai_overview', 'ai_overview_short'],
-- ['clips', 'ai_overview', 'ai_overview_short'],
++ // (vods/clips are frozen: their overviews live in vod_ai_state/clip_ai_state.)
['streamer_overviews', 'overview', 'overview_short'],
]) {
const cols = database.prepare(`PRAGMA table_info(${t})`).all().map(c => c.name);
@@@ -1501,43 +1500,16 @@ function initDb() {
// Timestamped transcript segments (JSON) — contextual data for the AI system +
// clickable timestamps in the VOD/clip transcript UI.
try {
-- for (const [t, col] of [['vods', 'ai_transcript_json'], ['clips', 'ai_transcript_json'], ['stream_memories', 'transcript_json']]) {
++ for (const [t, col] of [['stream_memories', 'transcript_json']]) { // VOD/clip transcripts: vod_ai_state/clip_ai_state
const cols = database.prepare(`PRAGMA table_info(${t})`).all().map(c => c.name);
if (!cols.includes(col)) database.exec(`ALTER TABLE ${t} ADD COLUMN ${col} TEXT`);
}
} catch (e) { console.warn('[DB] transcript-json migration:', e.message); }
-- // Transcription reliability: real job-state so an interrupted or failed run is
-- // RETRIED (with a bounded attempt counter) instead of being poisoned to
-- // "permanently silent". transcript_status: NULL/'pending'/'retry' = needs work;
-- // 'processing' = in flight; 'done' = has speech; 'empty' = ran clean, no speech
-- // (terminal); 'failed' = exhausted retries (terminal).
-- try {
-- for (const t of ['vods', 'clips']) {
-- const cols = database.prepare(`PRAGMA table_info(${t})`).all().map(c => c.name);
-- if (!cols.includes('transcript_status')) database.exec(`ALTER TABLE ${t} ADD COLUMN transcript_status TEXT`);
-- if (!cols.includes('transcript_attempts')) database.exec(`ALTER TABLE ${t} ADD COLUMN transcript_attempts INTEGER DEFAULT 0`);
-- if (!cols.includes('transcript_error')) database.exec(`ALTER TABLE ${t} ADD COLUMN transcript_error TEXT`);
-- // Retry backoff: earliest time a 'retry' row may be attempted again, so a
-- // persistently-unreadable source doesn't burn all its attempts in minutes.
-- if (!cols.includes('transcript_next_at')) database.exec(`ALTER TABLE ${t} ADD COLUMN transcript_next_at DATETIME`);
-- // Seed status from the legacy sentinels so the new queue is consistent:
-- database.exec(`UPDATE ${t} SET transcript_status='done'
-- WHERE transcript_status IS NULL AND ai_transcript IS NOT NULL AND TRIM(ai_transcript) != ''`);
-- // Recover RECENTLY-poisoned items (the abruptly-interrupted VODs the user hit):
-- // clear the ' ' poison so they re-transcribe with the hardened, retrying pipeline.
-- database.exec(`UPDATE ${t} SET transcript_status=NULL, ai_transcript=NULL, ai_transcript_json=NULL
-- WHERE transcript_status IS NULL AND ai_transcript = ' ' AND created_at >= datetime('now','-60 days')`);
-- // Older poisoned items: assume genuinely silent (terminal) so we don't churn the whole archive.
-- database.exec(`UPDATE ${t} SET transcript_status='empty' WHERE transcript_status IS NULL AND ai_transcript = ' '`);
-- // (Legacy local tables — see the vod_ai_state/clip_ai_state recovery below.)
-- database.exec(`UPDATE ${t} SET transcript_status=NULL, transcript_next_at=NULL WHERE transcript_status='processing'`);
-- // Give previously-'failed' rows a fresh chance after a deploy — transient issues
-- // (whisper temporarily missing, storage hiccup, a bad build) shouldn't be permanent.
-- // Reset the attempt counter so the bounded retry ladder starts over.
-- database.exec(`UPDATE ${t} SET transcript_status='retry', transcript_attempts=0, transcript_next_at=NULL WHERE transcript_status='failed'`);
-- }
-- } catch (e) { console.warn('[DB] transcript-status migration:', e.message); }
++ // Transcription job-state (transcript_status/attempts/next_at) lives in vod_ai_state and
++ // clip_ai_state, whose recovery runs near the top of initDb(). This block used to add the same
++ // columns to the frozen legacy vods/clips tables and rewrite their rows on every boot; it is
++ // gone because nothing may write those tables (register C-73).
// Donation goals: optional media (image/video → optimized webm/webp), a 1-hour
// "reached" celebration window (reached_at), and explicit ordering.
@@@ -2187,11 +2159,7 @@ function initDb() {
// Chat history: filter by channel_user_id / stream_id, ORDER BY timestamp DESC.
database.exec('CREATE INDEX IF NOT EXISTS idx_chat_channel_user_ts ON chat_messages(channel_user_id, timestamp)');
database.exec('CREATE INDEX IF NOT EXISTS idx_chat_stream_ts ON chat_messages(stream_id, timestamp)');
-- // VOD/clip listings: filter by owner + visibility, ORDER BY created_at / view_count.
-- database.exec('CREATE INDEX IF NOT EXISTS idx_vods_user_created ON vods(user_id, is_public, created_at)');
-- database.exec('CREATE INDEX IF NOT EXISTS idx_vods_public_created ON vods(is_public, created_at)');
-- database.exec('CREATE INDEX IF NOT EXISTS idx_vods_stream_recording ON vods(stream_id, is_recording)');
-- database.exec('CREATE INDEX IF NOT EXISTS idx_clips_user_created ON clips(user_id, is_public, created_at)');
++ // (The VOD/clip listing indexes went with the listings: VODs and clips are listed by OpenVibe.Media.)
} catch (e) { console.warn('[DB] performance index migration:', e.message); }
// Second pass of hot-path indexes, found by tracing what the home page and the always-on
@@@ -2218,9 +2186,6 @@ function initDb() {
'CREATE INDEX IF NOT EXISTS idx_anon_ip_created ON anon_ip_mappings(created_at)',
'CREATE INDEX IF NOT EXISTS idx_goals_active ON donation_goals(is_active)',
// idx_arena_moments_said: migration 003 (its table is created later, by the arena job).
-- // Boot-time transcript backfill sweeps.
-- 'CREATE INDEX IF NOT EXISTS idx_vods_transcript ON vods(transcript_status)',
-- 'CREATE INDEX IF NOT EXISTS idx_clips_transcript ON clips(transcript_status)',
'CREATE INDEX IF NOT EXISTS idx_viewer_samples_at ON viewer_samples(sampled_at)',
// getRecentlyOnlineStreamers() correlates streams by managed_stream_id inside a
// json_group_array, and there was no index on that column at all — so the subquery scanned
@@@ -2824,11 +2789,11 @@ function getLiveStreams() {
`);
}
++// The latest ended session per streamer. The VOD fields (vod_id, vod_thumbnail_url, …) come from
++// OpenVibe.Media: media-proxy/lookups.js attachPublicVods.
function getRecentStreams(limit = 20) {
return all(`
-- SELECT s.*, COALESCE(NULLIF(s.ai_category, ''), s.category) AS category, s.category AS chosen_category, u.username, u.display_name, u.avatar_url, u.profile_color,
-- v.id AS vod_id, v.is_public AS vod_is_public, v.thumbnail_url AS vod_thumbnail_url,
-- v.duration_seconds AS vod_duration
++ SELECT s.*, COALESCE(NULLIF(s.ai_category, ''), s.category) AS category, s.category AS chosen_category, u.username, u.display_name, u.avatar_url, u.profile_color
FROM streams s
JOIN (
SELECT user_id, MAX(ended_at) AS latest_ended_at
@@@ -2837,9 +2802,6 @@ function getRecentStreams(limit = 20) {
GROUP BY user_id
) latest ON latest.user_id = s.user_id AND latest.latest_ended_at = s.ended_at
JOIN users u ON s.user_id = u.id
-- LEFT JOIN vods v ON v.stream_id = s.id AND COALESCE(v.is_recording, 0) = 0
-- -- a public list: only a public VOD's id and thumbnail ride along (never a private or unlisted one)
-- AND COALESCE(v.visibility, CASE WHEN v.is_public = 1 THEN 'public' ELSE 'private' END) = 'public'
WHERE s.is_live = 0 AND s.ended_at IS NOT NULL
ORDER BY s.ended_at DESC
LIMIT ?
@@@ -2922,14 +2884,14 @@ function getStreamsByUserId(userId, limit = 50) {
`, [userId, limit]);
}
++// A slot's past sessions. Each one's VOD (vod_id, vod_file_path) comes from OpenVibe.Media:
++// media-proxy/lookups.js vodsForManagedStream.
function getStreamHistoryByManagedStream(managedStreamId, userId, limit = 20) {
return all(`
SELECT s.id, s.title, s.started_at, s.ended_at, s.is_live,
s.peak_viewers, s.viewer_count, s.duration_seconds,
-- s.protocol, s.category,
-- v.id AS vod_id, v.file_path AS vod_file_path
++ s.protocol, s.category
FROM streams s
-- LEFT JOIN vods v ON v.stream_id = s.id
WHERE s.managed_stream_id = ? AND s.user_id = ?
ORDER BY s.started_at DESC
LIMIT ?
@@@ -3202,9 +3164,6 @@ function bumpClipTranscriptAttempt(id) {
const r = get('SELECT transcript_attempts AS a FROM clip_ai_state WHERE clip_id = ?', [id]);
return r ? r.a : 0;
}
--function getPastesNeedingAnalysis(limit = 5) {
-- return all("SELECT * FROM pastes WHERE ai_summary IS NULL AND type IN ('paste','screenshot') ORDER BY created_at DESC LIMIT ?", [limit]);
--}
// deleteAiMomentTextPastes() removed — the media subsystem (vods/clips/pastes writes) moved to OpenVibe.Media.
// ── One-time cleanup: earlier builds stored raw (often malformed) model JSON like
// `{"description":"…","tags":[…]}` directly into text columns. Extract just the
@@@ -3432,35 +3391,6 @@ function getStreamTranscriptSegments(streamId) {
return out;
}
--// ── AI "crazy moments" v2: rank whole VODs by their AI overview, then mine the winner's
--// timeline + transcript for its single best moment. ─────────────────────────────────────
--
--// Every public, finished VOD that has an AI overview or a timeline — ordered by an objective
--// popularity prior (views, clips taken, peak viewers) so the AI ranker sees the strongest first
--// and cost stays bounded when there are many VODs.
--function getVodsForMomentRanking(limit = 120) {
-- try {
-- return all(`
-- SELECT v.id AS vod_id, v.stream_id, v.user_id, u.username, v.title,
-- COALESCE(v.ai_overview, '') AS ai_overview,
-- COALESCE(v.ai_overview_short, '') AS ai_overview_short,
-- COALESCE(v.view_count, 0) AS view_count,
-- COALESCE(NULLIF(v.duration_seconds, 0), v.probe_duration_seconds, 0) AS duration,
-- COALESCE(s.peak_viewers, 0) AS peak_viewers, v.created_at,
-- (SELECT COUNT(*) FROM clips c WHERE c.vod_id = v.id OR c.stream_id = v.stream_id) AS clip_count,
-- (SELECT COUNT(*) FROM stream_memories m WHERE m.stream_id = v.stream_id) AS memory_count
-- FROM vods v
-- JOIN users u ON u.id = v.user_id
-- LEFT JOIN streams s ON s.id = v.stream_id
-- WHERE v.is_public = 1 AND COALESCE(v.is_recording, 0) = 0 AND COALESCE(v.clips_only, 0) = 0
-- AND ( (v.ai_overview IS NOT NULL AND v.ai_overview <> '')
-- OR EXISTS (SELECT 1 FROM stream_memories m WHERE m.stream_id = v.stream_id) )
-- ORDER BY COALESCE(v.view_count, 0) DESC, clip_count DESC, COALESCE(s.peak_viewers, 0) DESC, v.created_at DESC
-- LIMIT ?
-- `, [Math.max(1, limit)]) || [];
-- } catch { return []; }
--}
--
// getRecentAutoClips() removed — the media subsystem (vods/clips/pastes writes) moved to OpenVibe.Media.
// countAutoClipsSince() removed — the media subsystem (vods/clips/pastes writes) moved to OpenVibe.Media.
@@@ -3493,36 +3423,6 @@ function getRecentChatText(streamId, sinceSec = 120, limit = 40) {
} catch { return []; }
}
--// Eligible finished VODs that do NOT yet have an auto-generated clip — the work-list for the
--// historical auto-clip backfill. Requires a timeline (memories) so Stage-2 can find a moment.
--// Best-first by views so the most-watched history gets clipped first. Idempotent.
--function getVodsWithoutAutoClip(limit = 20) {
-- try {
-- return all(`
-- SELECT v.id AS vod_id, v.stream_id, v.user_id, u.username, v.title,
-- COALESCE(NULLIF(v.duration_seconds, 0), v.probe_duration_seconds, 0) AS duration,
-- COALESCE(v.ai_overview, '') AS ai_overview, COALESCE(v.ai_overview_short, '') AS ai_overview_short,
-- COALESCE(v.view_count, 0) AS view_count
-- FROM vods v JOIN users u ON u.id = v.user_id
-- WHERE v.is_public = 1 AND COALESCE(v.is_recording, 0) = 0 AND COALESCE(v.clips_only, 0) = 0
-- AND EXISTS (SELECT 1 FROM stream_memories m WHERE m.stream_id = v.stream_id)
-- AND NOT EXISTS (SELECT 1 FROM clips c WHERE (c.vod_id = v.id OR c.stream_id = v.stream_id)
-- AND COALESCE(c.auto_generated, 0) = 1)
-- ORDER BY COALESCE(v.view_count, 0) DESC, v.created_at DESC
-- LIMIT ?
-- `, [Math.max(1, limit)]) || [];
-- } catch { return []; }
--}
--
--// Timestamps (seconds into the VOD) that viewers CLIPPED — the strongest "this was a moment"
--// signal we have.
--function getClipStartTimesForStream(streamId, vodId) {
-- try {
-- return (all(`SELECT start_time FROM clips WHERE (stream_id = ? OR vod_id = ?) AND start_time > 0 ORDER BY start_time`,
-- [streamId, vodId || -1]) || []).map(r => Math.floor(r.start_time));
-- } catch { return []; }
--}
--
// Chat-message spikes: the busiest time-buckets of a stream (offset seconds → message count),
// a proxy for "everyone reacted here". Offsets are relative to the stream's start.
function getChatSpikeOffsets(streamId, bucketSec = 30, topN = 8) {
@@@ -3538,41 +3438,6 @@ function getChatSpikeOffsets(streamId, bucketSec = 30, topN = 8) {
} catch { return []; }
}
--// Candidate memories for the daily AI "crazy moments" picker: recent, substantive, and from
--// a stream that has a public VOD (so we can link + extract the moment frame).
--function getAiMomentCandidates(days = 30, limit = 150) {
-- try {
-- // Only mine FINISHED streams (real VOD history) — never the currently-live
-- // stream, so we don't spam the pastes tab with live frames. The picked VOD is the
-- // recording of that same session, so offset_seconds lines up with its timeline;
-- // vod_duration lets the caller drop offsets that fall past the VOD's end.
-- return all(`
-- SELECT m.id AS memory_id, m.stream_id, m.offset_seconds, m.description, m.tags,
-- m.thumbnail_url, m.user_id, s.title AS stream_title, s.peak_viewers, u.username,
-- (SELECT v.id FROM vods v WHERE v.stream_id = m.stream_id
-- AND COALESCE(v.is_public, 1) = 1 AND COALESCE(v.is_recording, 0) = 0
-- ORDER BY v.id DESC LIMIT 1) AS vod_id,
-- (SELECT COALESCE(NULLIF(v.duration_seconds, 0), v.probe_duration_seconds, 0)
-- FROM vods v WHERE v.stream_id = m.stream_id
-- AND COALESCE(v.is_recording, 0) = 0 ORDER BY v.id DESC LIMIT 1) AS vod_duration,
-- (SELECT COALESCE(v.view_count, 0) FROM vods v WHERE v.stream_id = m.stream_id
-- AND COALESCE(v.is_recording, 0) = 0 ORDER BY v.id DESC LIMIT 1) AS vod_views
-- FROM stream_memories m
-- JOIN users u ON u.id = m.user_id
-- JOIN streams s ON s.id = m.stream_id
-- WHERE m.description IS NOT NULL AND length(m.description) > 45
-- AND m.created_at >= datetime('now', ?)
-- AND COALESCE(s.is_live, 0) = 0 AND s.ended_at IS NOT NULL
-- ORDER BY COALESCE(s.peak_viewers, 0) DESC, length(m.description) DESC, m.created_at DESC
-- LIMIT ?
-- `, [`-${Math.max(1, days)} days`, limit]) || [];
-- } catch { return []; }
--}
--// A user's pastes including AI fields (the explorer + overview want ai_summary/ai_tags).
--function getUserPastesForAi(userId, limit = 30) {
-- return all(`SELECT id, slug, type, title, ai_summary, ai_tags, ai_analyzed_at, created_at
-- FROM pastes WHERE user_id = ? ORDER BY created_at DESC LIMIT ?`, [userId, limit]);
--}
function upsertStreamerOverview(userId, { overview, model = null, sources = null }) {
return run(`INSERT INTO streamer_overviews (user_id, overview, overview_short, model, sources, generated_at)
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
@@@ -3588,7 +3453,8 @@ function getStreamerOverview(userId) {
// Assemble the full AI timeline for a streamer from already-generated AI data (no LLM cost):
// the whole-streamer overview + every session that has an AI overview or captured memories,
// newest first, each with its VOD (for timestamped links) and its ordered memory moments.
--function assembleStreamerAiTimeline(userId) {
++// vodIdByStream: Map(stream id → public VOD id) from OpenVibe.Media (lookups.publicVodIdsByStream).
++function assembleStreamerAiTimeline(userId, vodIdByStream = null) {
let overview = null;
try { overview = get('SELECT overview, overview_short, generated_at FROM streamer_overviews WHERE user_id = ?', [userId]) || null; } catch { /* */ }
@@@ -3597,8 +3463,6 @@ function assembleStreamerAiTimeline(userId) {
const streams = all(`
SELECT s.id, s.title, s.ai_title, s.started_at, s.ended_at, s.created_at, s.duration_seconds,
s.ai_overview, s.ai_overview_short, s.thumbnail_url, s.peak_viewers, s.category,
-- (SELECT v.id FROM vods v WHERE v.stream_id = s.id AND COALESCE(v.is_recording, 0) = 0
-- ORDER BY COALESCE(v.is_public, 1) DESC, v.id DESC LIMIT 1) AS vod_id,
(SELECT COUNT(*) FROM stream_memories m WHERE m.stream_id = s.id) AS memory_count
FROM streams s
WHERE s.user_id = ?
@@@ -3628,7 +3492,8 @@ function assembleStreamerAiTimeline(userId) {
}
delete m.transcript_json;
}
-- return { ...s, memories, word_count: wordCount, has_transcript: hasTranscript };
++ const vodId = vodIdByStream && vodIdByStream.get(Number(s.id));
++ return { ...s, vod_id: vodId || null, memories, word_count: wordCount, has_transcript: hasTranscript };
});
} catch { /* */ }
@@@ -3657,8 +3522,10 @@ function clearAiTimelineCache(userId) {
try { return run('DELETE FROM ai_timeline_cache WHERE user_id = ?', [userId]); } catch { return null; }
}
--// Lazy, TTL-cached accessor: re-assemble only when the tab is viewed AND the cache is stale.
--function getStreamerAiTimeline(userId, ttlMs = 15 * 60 * 1000) {
++// Lazy, TTL-cached timeline: re-assemble only when the tab is viewed AND the cache is stale.
++// The route (ai/chat-ai-routes.js) reads the cache first, asks Media for the VOD ids only on a
++// miss, then builds; `store: false` skips caching a timeline built while Media was unreachable.
++function readStreamerAiTimelineCache(userId, ttlMs = 15 * 60 * 1000) {
try {
const row = get('SELECT payload, generated_at FROM ai_timeline_cache WHERE user_id = ?', [userId]);
if (row && row.payload) {
@@@ -3668,12 +3535,17 @@ function getStreamerAiTimeline(userId, ttlMs = 15 * 60 * 1000) {
}
}
} catch { /* rebuild */ }
-- const fresh = assembleStreamerAiTimeline(userId);
-- try {
-- run(`INSERT INTO ai_timeline_cache (user_id, payload, generated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
-- ON CONFLICT(user_id) DO UPDATE SET payload = excluded.payload, generated_at = CURRENT_TIMESTAMP`,
-- [userId, JSON.stringify(fresh)]);
-- } catch { /* cache is best-effort */ }
++ return null;
++}
++function buildStreamerAiTimeline(userId, vodIdByStream = null, { store = true } = {}) {
++ const fresh = assembleStreamerAiTimeline(userId, vodIdByStream);
++ if (store) {
++ try {
++ run(`INSERT INTO ai_timeline_cache (user_id, payload, generated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
++ ON CONFLICT(user_id) DO UPDATE SET payload = excluded.payload, generated_at = CURRENT_TIMESTAMP`,
++ [userId, JSON.stringify(fresh)]);
++ } catch { /* cache is best-effort */ }
++ }
return { ...fresh, cached: false };
}
function getAllStreamerOverviews(limit = 100) {
@@@ -3683,17 +3555,15 @@ function getAllStreamerOverviews(limit = 100) {
}
// Streamers whose aggregate AI overview is DUE for (re)generation. A "decent"
// overview (>= decentLen chars) refreshes at most every 12h; a sparse/missing one
--// retries hourly until it fills out. Only streamers with some signal (memories or
--// VODs) are considered, so we never spend calls on users with nothing to summarize.
++// retries hourly until it fills out. Only streamers with stream memories are considered,
++// so we never spend calls on users with nothing to summarize. (VODs used to count too,
++// through Live's frozen vods table, whose rows moved to OpenVibe.Media at the split.)
function getStreamersNeedingOverview({ decentLen = 220, limit = 4 } = {}) {
return all(`
SELECT u.id AS user_id
FROM users u
LEFT JOIN streamer_overviews o ON o.user_id = u.id
-- WHERE (
-- EXISTS (SELECT 1 FROM stream_memories m WHERE m.user_id = u.id)
-- OR EXISTS (SELECT 1 FROM vods v WHERE v.user_id = u.id)
-- )
++ WHERE EXISTS (SELECT 1 FROM stream_memories m WHERE m.user_id = u.id)
AND (
o.user_id IS NULL
OR (LENGTH(TRIM(COALESCE(o.overview,''))) >= ? AND o.generated_at <= datetime('now','-12 hours'))
@@@ -3925,7 +3795,8 @@ function getRecentlyOnlineStreamers(limit = 20, offset = 0) {
'title', ms2.title,
'protocol', ms2.protocol,
'last_live_at', (SELECT MAX(s2.ended_at) FROM streams s2 WHERE s2.managed_stream_id = ms2.id AND s2.ended_at IS NOT NULL),
-- 'vod_thumbnail', (SELECT v.thumbnail_url FROM vods v JOIN streams s3 ON v.stream_id = s3.id WHERE s3.managed_stream_id = ms2.id AND COALESCE(v.is_recording, 0) = 0 AND v.is_public = 1 ORDER BY v.created_at DESC LIMIT 1)
++ -- filled by the route from OpenVibe.Media (GET /vods/latest-thumbs)
++ 'vod_thumbnail', NULL
))
FROM managed_streams ms2
WHERE ms2.user_id = u.id
@@@ -3949,28 +3820,6 @@ function countRecentlyOnlineStreamers() {
`)?.count || 0;
}
--function getRecentVods(limit = 12, offset = 0) {
-- return all(`
-- SELECT v.*, u.username, u.display_name, u.avatar_url, u.profile_color,
-- s.protocol AS stream_protocol, s.peak_viewers AS stream_peak_viewers,
-- ms.slug AS managed_stream_slug, ms.id AS managed_stream_id
-- FROM vods v
-- JOIN users u ON v.user_id = u.id
-- LEFT JOIN streams s ON v.stream_id = s.id
-- LEFT JOIN managed_streams ms ON s.managed_stream_id = ms.id
-- WHERE v.is_public = 1 AND COALESCE(v.is_recording, 0) = 0
-- ORDER BY v.created_at DESC
-- LIMIT ? OFFSET ?
-- `, [limit, offset]);
--}
--
--function countRecentVods() {
-- return get(`
-- SELECT COUNT(*) AS count FROM vods
-- WHERE is_public = 1 AND COALESCE(is_recording, 0) = 0
-- `)?.count || 0;
--}
--
// Public-facing site totals for the home hero stats bar.
let _homeStatsCache = null;
let _homeStatsCacheAt = 0;
@@@ -4107,9 +3956,7 @@ const HOME_SERIES = {
active: { table: 'chat_messages', ts: 'timestamp', agg: "COUNT(DISTINCT COALESCE('u' || user_id, 'a' || anon_id, 'r' || source_platform || '|' || username))", where: 'COALESCE(is_deleted, 0) = 0' },
sessions: { table: 'streams', ts: 'created_at', agg: 'COUNT(*)' },
streamers: { table: 'streams', ts: 'created_at', agg: 'COUNT(DISTINCT user_id)' },
-- vods: { table: 'vods', ts: 'created_at', agg: 'COUNT(*)', where: 'is_public = 1 AND COALESCE(is_recording, 0) = 0' },
-- clips: { table: 'clips', ts: 'created_at', agg: 'COUNT(*)', where: 'COALESCE(is_public, 1) = 1' },
-- hours: { table: 'vods', ts: 'created_at', agg: 'COALESCE(SUM(duration_seconds), 0) / 3600.0', where: 'COALESCE(is_recording, 0) = 0' },
++ // vods, clips, hours and pastes are OpenVibe.Media's series (home/routes.js MEDIA_SERIES).
hoursWatched:{ table: 'watch_time', ts: 'created_at', agg: 'COALESCE(SUM(minutes_watched), 0) / 60.0' },
aiMoments: { table: 'stream_memories', ts: 'created_at', agg: 'COUNT(*)' },
vibes: { table: 'transactions', ts: 'created_at', agg: 'COALESCE(SUM(amount), 0)', where: "type = 'donation'", vibesReset: true },
@@@ -4214,8 +4061,6 @@ function _computeHomeStats() {
const prev = c(`SELECT COUNT(*) AS count FROM ${table} WHERE ${col} >= datetime('now', '-14 days') AND ${col} < datetime('now', '-7 days')${extra ? ' AND ' + extra : ''}`);
return { d: q('-1 day'), w: q('-7 days'), m: q('-30 days'), pw: prev };
};
-- const hoursSince = (w) => Math.round(c(`SELECT COALESCE(SUM(duration_seconds), 0) AS count FROM vods WHERE COALESCE(is_recording, 0) = 0 AND created_at >= datetime('now', ?)`, [w]) / 3600);
-- const hoursPrevWeek = () => Math.round(c(`SELECT COALESCE(SUM(duration_seconds), 0) AS count FROM vods WHERE COALESCE(is_recording, 0) = 0 AND created_at >= datetime('now', '-14 days') AND created_at < datetime('now', '-7 days')`) / 3600);
// Rolling day/week/month SUMS (for value metrics like Vibes tipped).
const winSum = (table, col, tsCol, extra = '') => {
const q = (w) => c(`SELECT COALESCE(SUM(${col}), 0) AS count FROM ${table} WHERE ${tsCol} >= datetime('now', ?)${extra ? ' AND ' + extra : ''}`, [w]);
@@@ -4250,8 +4095,10 @@ function _computeHomeStats() {
// Donation goals: currently running + ever reached.
goalsActive: c(`SELECT COUNT(*) AS count FROM donation_goals WHERE is_active = 1`),
goalsReached: c(`SELECT COUNT(*) AS count FROM donation_goals WHERE reached_at IS NOT NULL OR current_amount >= target_amount`),
-- vods: c(`SELECT COUNT(*) AS count FROM vods WHERE is_public = 1 AND COALESCE(is_recording, 0) = 0`),
-- clips: c(`SELECT COUNT(*) AS count FROM clips WHERE COALESCE(is_public, 1) = 1`),
++ // VODs, clips, pastes and archived hours are counted by OpenVibe.Media and OpenVibe.Community
++ // (media-proxy/lookups.js withArchiveStats fills these); null until one of them answers.
++ vods: null,
++ clips: null,
liveSessions: c(`SELECT COUNT(*) AS count FROM streams`),
streamers: c(`SELECT COUNT(DISTINCT user_id) AS count FROM streams WHERE user_id IS NOT NULL`),
chatMessages: c(`SELECT COUNT(*) AS count FROM chat_messages`),
@@@ -4259,12 +4106,12 @@ function _computeHomeStats() {
anons: c(`SELECT COUNT(*) AS count FROM anon_ip_mappings`),
follows: c(`SELECT COUNT(*) AS count FROM follows`),
emotes: c(`SELECT COUNT(*) AS count FROM emotes`),
-- pastes: c(`SELECT COUNT(*) AS count FROM pastes`),
++ pastes: null,
aiMemories: c(`SELECT COUNT(*) AS count FROM stream_memories`),
-- pasteImages: c(`SELECT COUNT(*) AS count FROM pastes WHERE type = 'screenshot'`),
-- pasteText: c(`SELECT COUNT(*) AS count FROM pastes WHERE COALESCE(type, 'paste') <> 'screenshot'`),
-- // Total hours of video the platform has archived (VODs, excluding in-progress recordings).
-- streamHours: Math.round(c(`SELECT COALESCE(SUM(duration_seconds), 0) AS count FROM vods WHERE COALESCE(is_recording, 0) = 0`) / 3600),
++ pasteImages: null,
++ pasteText: null,
++ // Total hours of video the platform has archived (OpenVibe.Media's figure).
++ streamHours: null,
// Active chatters this week across EVERYONE — registered users, anons, and relay chatters.
weeklyActive: c(`SELECT COUNT(*) AS count FROM (
SELECT DISTINCT 'u' || user_id AS id FROM chat_messages
@@@ -4298,11 +4145,11 @@ function _computeHomeStats() {
users: winCount('users', 'created_at', 'COALESCE(is_banned, 0) = 0'),
anons: winCount('anon_ip_mappings', 'created_at'),
sessions: winCount('streams', 'created_at'),
-- vods: winCount('vods', 'created_at', 'is_public = 1 AND COALESCE(is_recording, 0) = 0'),
-- clips: winCount('clips', 'created_at', 'COALESCE(is_public, 1) = 1'),
++ vods: null, // OpenVibe.Media (withArchiveStats)
++ clips: null,
aiMoments: winCount('stream_memories', 'created_at'),
messages: winCount('chat_messages', 'timestamp', 'COALESCE(is_deleted, 0) = 0'),
-- hours: { d: hoursSince('-1 day'), w: hoursSince('-7 days'), m: hoursSince('-30 days'), pw: hoursPrevWeek() },
++ hours: null, // OpenVibe.Media
streamers: streamersWin(),
emotes: winCount('emotes', 'created_at'),
goals: winCount('donation_goals', 'created_at'),
@@@ -4322,153 +4169,6 @@ function _computeHomeStats() {
};
}
--function getVodsByUserFiltered(userId, { includePrivate = false, managedStreamId = null, orderBy = 'newest', limit = 12, offset = 0 } = {}) {
-- // COALESCE(v.clips_only,0)=0 hides ephemeral clips-only recordings (they're deleted on
-- // stream end; this guards against a rare orphan surviving a crash before cleanup).
-- const conditions = ['v.user_id = ?', 'COALESCE(v.is_recording, 0) = 0', 'COALESCE(v.clips_only, 0) = 0'];
-- const params = [userId];
--
-- if (!includePrivate) {
-- conditions.push('v.is_public = 1');
-- }
-- if (managedStreamId) {
-- conditions.push('s.managed_stream_id = ?');
-- params.push(managedStreamId);
-- }
--
-- const orderClauses = {
-- newest: 'v.created_at DESC',
-- oldest: 'v.created_at ASC',
-- views: 'v.view_count DESC, v.created_at DESC',
-- peak_viewers: 's.peak_viewers DESC, v.created_at DESC',
-- };
-- const order = orderClauses[orderBy] || orderClauses.newest;
--
-- params.push(limit, offset);
-- return all(`
-- SELECT v.*, u.username, u.display_name, u.avatar_url,
-- s.protocol AS stream_protocol, s.peak_viewers AS stream_peak_viewers,
-- ms.slug AS managed_stream_slug, ms.id AS ms_id, ms.title AS ms_title
-- FROM vods v
-- JOIN users u ON v.user_id = u.id
-- LEFT JOIN streams s ON v.stream_id = s.id
-- LEFT JOIN managed_streams ms ON s.managed_stream_id = ms.id
-- WHERE ${conditions.join(' AND ')}
-- ORDER BY ${order}
-- LIMIT ? OFFSET ?
-- `, params);
--}
--
--// Most-popular RECENT VOD/clip for a streamer — highest view_count within the last
--// week, else the last month, else all-time. Powers the offline-screen "explore" cards.
--function getPopularVodForUser(userId) {
-- const base = `SELECT v.*, u.username, u.display_name, u.avatar_url, u.profile_color
-- FROM vods v JOIN users u ON v.user_id = u.id
-- WHERE v.user_id = ? AND v.is_public = 1 AND COALESCE(v.is_recording,0)=0`;
-- for (const win of ['-7 days', '-1 month', null]) {
-- const sql = base + (win ? ` AND v.created_at >= datetime('now', ?)` : '') + ` ORDER BY v.view_count DESC, v.created_at DESC LIMIT 1`;
-- const row = get(sql, win ? [userId, win] : [userId]);
-- if (row) return row;
-- }
-- return null;
--}
--function getPopularClipForUser(userId) {
-- // Clips taken OF this streamer's streams (regardless of who clipped them).
-- const base = `SELECT c.*, su.username, su.display_name, su.avatar_url, su.profile_color
-- FROM clips c
-- JOIN streams s ON c.stream_id = s.id
-- JOIN users su ON s.user_id = su.id
-- WHERE s.user_id = ? AND c.is_public = 1`;
-- for (const win of ['-7 days', '-1 month', null]) {
-- const sql = base + (win ? ` AND c.created_at >= datetime('now', ?)` : '') + ` ORDER BY c.view_count DESC, c.created_at DESC LIMIT 1`;
-- const row = get(sql, win ? [userId, win] : [userId]);
-- if (row) return row;
-- }
-- return null;
--}
--
--// Single most-viewed VOD / clip within a time window (win = "-7 days" | "-1 month" | null=all-time).
--function _topVodForUser(userId, win) {
-- const sql = `SELECT v.*, u.username, u.display_name, u.avatar_url, u.profile_color
-- FROM vods v JOIN users u ON v.user_id = u.id
-- WHERE v.user_id = ? AND v.is_public = 1 AND COALESCE(v.is_recording,0)=0 AND COALESCE(v.clips_only,0)=0`
-- + (win ? ` AND v.created_at >= datetime('now', ?)` : '')
-- + ` ORDER BY v.view_count DESC, v.created_at DESC LIMIT 1`;
-- return get(sql, win ? [userId, win] : [userId]) || null;
--}
--function _topClipForUser(userId, win) {
-- const sql = `SELECT c.*, su.username, su.display_name, su.avatar_url, su.profile_color
-- FROM clips c JOIN streams s ON c.stream_id = s.id JOIN users su ON s.user_id = su.id
-- WHERE s.user_id = ? AND c.is_public = 1`
-- + (win ? ` AND c.created_at >= datetime('now', ?)` : '')
-- + ` ORDER BY c.view_count DESC, c.created_at DESC LIMIT 1`;
-- return get(sql, win ? [userId, win] : [userId]) || null;
--}
--// Top VOD + top clip for each of week / month / all-time, for the offline-screen cycler.
--function getTopContentRanges(userId) {
-- const out = {};
-- for (const [key, win] of [['week', '-7 days'], ['month', '-1 month'], ['all', null]]) {
-- out[key] = { vod: _topVodForUser(userId, win), clip: _topClipForUser(userId, win) };
-- }
-- return out;
--}
--function countVodsByUserFiltered(userId, { includePrivate = false, managedStreamId = null } = {}) {
-- const conditions = ['v.user_id = ?', 'COALESCE(v.is_recording, 0) = 0', 'COALESCE(v.clips_only, 0) = 0'];
-- const params = [userId];
-- if (!includePrivate) conditions.push('v.is_public = 1');
-- if (managedStreamId) {
-- conditions.push('s.managed_stream_id = ?');
-- params.push(managedStreamId);
-- }
-- return get(`
-- SELECT COUNT(*) AS count
-- FROM vods v
-- LEFT JOIN streams s ON v.stream_id = s.id
-- WHERE ${conditions.join(' AND ')}
-- `, params)?.count || 0;
--}
--
--function getClipsOfUserStreamsPaginated(userId, limit = 12, offset = 0) {
-- return all(`
-- SELECT c.*, u.username AS clip_creator_username, u.display_name AS clip_creator_display_name, u.avatar_url AS clip_creator_avatar,
-- s.title AS stream_title, s.started_at AS stream_started_at, s.protocol AS stream_protocol,
-- su.username AS streamer_username, su.display_name AS streamer_display_name
-- FROM clips c
-- JOIN users u ON c.user_id = u.id
-- JOIN streams s ON c.stream_id = s.id
-- JOIN users su ON s.user_id = su.id
-- WHERE s.user_id = ? AND c.is_public = 1
-- ORDER BY c.created_at DESC
-- LIMIT ? OFFSET ?
-- `, [userId, limit, offset]);
--}
--
--function countClipsOfUserStreams(userId) {
-- return get(`
-- SELECT COUNT(*) AS count
-- FROM clips c
-- JOIN streams s ON c.stream_id = s.id
-- WHERE s.user_id = ? AND c.is_public = 1
-- `, [userId])?.count || 0;
--}
--
--function getClipsByUserPaginated(userId, includePrivate = false, limit = 12, offset = 0) {
-- const publicFilter = includePrivate ? '' : 'AND c.is_public = 1';
-- return all(`
-- SELECT c.*, u.username, u.display_name, u.avatar_url, u.is_owner AS owner_is_owner,
-- s.title AS stream_title, s.started_at AS stream_started_at, s.protocol AS stream_protocol,
-- su.username AS streamer_username, su.display_name AS streamer_display_name, su.avatar_url AS streamer_avatar_url,
-- su.is_owner AS streamer_is_owner
-- FROM clips c
-- JOIN users u ON c.user_id = u.id
-- LEFT JOIN streams s ON c.stream_id = s.id
-- LEFT JOIN users su ON s.user_id = su.id
-- WHERE c.user_id = ? ${publicFilter}
-- ORDER BY c.created_at DESC
-- LIMIT ? OFFSET ?
-- `, [userId, limit, offset]);
--}
--
// ── Channel helpers ──────────────────────────────────────────
function getChannelByUserId(userId) {
@@@ -5357,17 +5057,6 @@ function updateUserAvatar(userId, avatarUrl, pasteId = null) {
// resetAvatarsForPaste() removed — the media subsystem (vods/clips/pastes writes) moved to OpenVibe.Media.
--// A user's avatar-upload history: the screenshot pastes tagged as avatar uploads.
--function getUserAvatarPastes(userId, limit = 60) {
-- return all(
-- `SELECT id, slug, screenshot_path, title, created_at
-- FROM pastes
-- WHERE user_id = ? AND type = 'screenshot' AND json_extract(metadata, '$.kind') = 'avatar'
-- ORDER BY created_at DESC LIMIT ?`,
-- [userId, limit]
-- );
--}
--
// ── Follow helpers ───────────────────────────────────────────
function followUser(followerId, streamerId) {
@@@ -5558,378 +5247,11 @@ function setSubscriptionStatus(id, status, fields = {}) {
[status, status === 'active' ? 1 : 0, cape, cpe, id]);
return get('SELECT * FROM subscriptions WHERE id = ?', [id]);
}
--// ── VOD helpers ──────────────────────────────────────────────
--
--// createVod() removed — the media subsystem (vods/clips/pastes writes) moved to OpenVibe.Media.
--// The legacy vods/clips/pastes tables are read-only here: test/frozen-tables.test.js fails on a new write.
--
--function getVodHealthById(id) {
-- return get(`SELECT * FROM vods WHERE id = ?`, [id]);
--}
--
--function getVodScanCandidates({ userId, since, limit }) {
-- const conditions = ['COALESCE(is_recording, 0) = 0'];
-- const params = [];
-- if (userId) {
-- conditions.push('user_id = ?');
-- params.push(userId);
-- }
-- if (since) {
-- conditions.push('created_at >= datetime(?, ?)');
-- params.push(since, 'localtime');
-- }
-- let sql = `SELECT * FROM vods WHERE ${conditions.join(' AND ')} ORDER BY created_at DESC`;
-- if (limit) {
-- sql += ' LIMIT ?';
-- params.push(limit);
-- }
-- return all(sql, params);
--}
--
--// VODs the periodic health job should scan: finished (not recording), and either never
--// scanned or last scanned longer ago than `staleDays`. Recently-quarantined rows are
--// skipped (they were just assessed). Never-scanned + oldest-scanned first.
--function getVodsNeedingHealthScan({ staleDays = 30, limit = 3 } = {}) {
-- return all(`SELECT * FROM vods
-- WHERE COALESCE(is_recording, 0) = 0
-- AND (health_status IS NULL OR health_status NOT IN ('corrupt','zero_byte','missing_file'))
-- AND (last_health_scan_at IS NULL OR last_health_scan_at <= datetime('now', ?))
-- ORDER BY (last_health_scan_at IS NULL) DESC, last_health_scan_at ASC
-- LIMIT ?`, [`-${Math.max(1, staleDays)} days`, limit]);
--}
--
--// Genuinely-broken VODs quarantined long enough that they should be cleaned up (files
--// freed). Only unrecoverably-dead states — never 'needs_review'/'short_duration', which
--// may still be watchable.
--function getQuarantinedVodsForCleanup({ graceDays = 14, limit = 5 } = {}) {
-- return all(`SELECT * FROM vods
-- WHERE quarantined_at IS NOT NULL
-- AND quarantined_at <= datetime('now', ?)
-- AND health_status IN ('corrupt','zero_byte','missing_file')
-- AND COALESCE(is_recording, 0) = 0
-- ORDER BY quarantined_at ASC
-- LIMIT ?`, [`-${Math.max(1, graceDays)} days`, limit]);
--}
--
--function getVodById(id) {
-- return get(`
-- SELECT v.*, COALESCE(v.duration_seconds, v.probe_duration_seconds, 0) AS duration_seconds,
-- u.username, u.display_name, u.avatar_url,
-- s.title AS stream_title, s.protocol AS stream_protocol
-- FROM vods v
-- JOIN users u ON v.user_id = u.id
-- LEFT JOIN streams s ON v.stream_id = s.id
-- WHERE v.id = ?
-- `, [id]);
--}
--
--function getVodsByUser(userId, includePrivate = false, limit = null, offset = 0) {
-- const clause = includePrivate ? '' : ' AND v.is_public = 1';
-- const usePaging = Number.isFinite(limit);
-- const pagingSql = usePaging ? ' LIMIT ? OFFSET ?' : '';
-- const params = [userId];
-- if (usePaging) params.push(limit, offset);
-- return all(`
-- SELECT v.*, COALESCE(v.duration_seconds, v.probe_duration_seconds, 0) AS duration_seconds,
-- u.username, u.display_name, u.avatar_url, u.is_owner AS owner_is_owner,
-- s.protocol AS stream_protocol
-- FROM vods v JOIN users u ON v.user_id = u.id
-- LEFT JOIN streams s ON v.stream_id = s.id
-- WHERE v.user_id = ?${clause} AND COALESCE(v.is_recording, 0) = 0
-- ORDER BY v.created_at DESC${pagingSql}
-- `, params);
--}
--
--function countVodsByUser(userId, includePrivate = false) {
-- const clause = includePrivate ? '' : ' AND v.is_public = 1';
-- return get(`
-- SELECT COUNT(*) AS count
-- FROM vods v
-- WHERE v.user_id = ?${clause} AND COALESCE(v.is_recording, 0) = 0
-- `, [userId])?.count || 0;
--}
--
--function getPublicVods(limit = 50, offset = 0, { username = null, sort = 'newest' } = {}) {
-- const conditions = ['v.is_public = 1', 'COALESCE(v.is_recording, 0) = 0'];
-- const params = [];
--
-- if (username) {
-- conditions.push('LOWER(u.username) = LOWER(?)');
-- params.push(String(username).trim());
-- }
--
-- const dir = sort === 'oldest' ? 'ASC' : 'DESC';
-- params.push(limit, offset);
-- return all(`
-- SELECT v.*, COALESCE(v.duration_seconds, v.probe_duration_seconds, 0) AS duration_seconds,
-- u.username, u.display_name, u.avatar_url, u.is_owner AS owner_is_owner,
-- s.protocol AS stream_protocol
-- FROM vods v JOIN users u ON v.user_id = u.id
-- LEFT JOIN streams s ON v.stream_id = s.id
-- WHERE ${conditions.join(' AND ')}
-- ORDER BY v.created_at ${dir}
-- LIMIT ? OFFSET ?
-- `, params);
--}
--
--function countPublicVods({ username = null } = {}) {
-- const conditions = ['v.is_public = 1', 'COALESCE(v.is_recording, 0) = 0'];
-- const params = [];
--
-- if (username) {
-- conditions.push('LOWER(u.username) = LOWER(?)');
-- params.push(String(username).trim());
-- }
--
-- return get(`
-- SELECT COUNT(*) AS count
-- FROM vods v
-- JOIN users u ON v.user_id = u.id
-- WHERE ${conditions.join(' AND ')}
-- `, params)?.count || 0;
--}
--
--function listVodStreamers(includeUserId = null) {
-- const params = [];
-- let visibilityClause = 'v.is_public = 1';
-- if (includeUserId) {
-- visibilityClause = '(v.is_public = 1 OR v.user_id = ?)';
-- params.push(includeUserId);
-- }
--
-- return all(`
-- SELECT u.id AS user_id, u.username, u.display_name, COUNT(*) AS vod_count
-- FROM vods v
-- JOIN users u ON v.user_id = u.id
-- WHERE ${visibilityClause}
-- AND COALESCE(v.is_recording, 0) = 0
-- GROUP BY u.id, u.username, u.display_name
-- ORDER BY LOWER(COALESCE(u.display_name, u.username)) ASC
-- `, params);
--}
--
--function getActiveVodByStream(streamId) {
-- return get(`
-- SELECT v.*, u.username, u.display_name, u.avatar_url
-- FROM vods v JOIN users u ON v.user_id = u.id
-- WHERE v.stream_id = ? AND v.is_recording = 1
-- ORDER BY v.created_at DESC LIMIT 1
-- `, [streamId]);
--}
--
--// getOrphanedRecordingVods() removed — the media subsystem (vods/clips/pastes writes) moved to OpenVibe.Media.
--
--// ── Clip helpers ─────────────────────────────────────────────
--
--// createClip() removed — the media subsystem (vods/clips/pastes writes) moved to OpenVibe.Media.
--
--function getClipById(id) {
-- return get(`
-- SELECT c.*, u.username, u.display_name, u.avatar_url,
-- s.title AS stream_title, s.started_at AS stream_started_at, s.protocol AS stream_protocol
-- FROM clips c
-- JOIN users u ON c.user_id = u.id
-- LEFT JOIN streams s ON c.stream_id = s.id
-- WHERE c.id = ?
-- `, [id]);
--}
--
--function getClipsByUser(userId, includePrivate = false, limit = null, offset = 0) {
-- const publicFilter = includePrivate ? '' : 'AND c.is_public = 1';
-- const usePaging = Number.isFinite(limit);
-- const pagingSql = usePaging ? ' LIMIT ? OFFSET ?' : '';
-- const params = [userId];
-- if (usePaging) params.push(limit, offset);
-- return all(`
-- SELECT c.*, u.username, u.display_name, u.avatar_url,
-- s.title AS stream_title, s.started_at AS stream_started_at, s.protocol AS stream_protocol
-- FROM clips c
-- JOIN users u ON c.user_id = u.id
-- LEFT JOIN streams s ON c.stream_id = s.id
-- WHERE c.user_id = ? ${publicFilter}
-- ORDER BY c.created_at DESC${pagingSql}
-- `, params);
--}
--
--// ── "Clips Taken" tab: clips a user CREATED, of various source streamers ──
--// Each clip's "source streamer" is the owner of the clipped stream (or VOD). Supports
--// sort, filtering by source streamer, and hiding self-clips (of one's own content).
--const _CLIPS_TAKEN_ORDER = {
-- newest: 'c.created_at DESC',
-- oldest: 'c.created_at ASC',
-- views: 'c.view_count DESC, c.created_at DESC',
--};
--function _clipsTakenWhere(userId, { includePrivate = false, sourceStreamerId = null, hideSelf = false }) {
-- const conds = ['c.user_id = ?'];
-- const params = [userId];
-- if (!includePrivate) conds.push('c.is_public = 1');
-- if (sourceStreamerId) { conds.push('COALESCE(s.user_id, v.user_id) = ?'); params.push(sourceStreamerId); }
-- else if (hideSelf) { conds.push('COALESCE(s.user_id, v.user_id) IS NOT NULL'); conds.push('COALESCE(s.user_id, v.user_id) != ?'); params.push(userId); }
-- return { where: conds.join(' AND '), params };
--}
--const _CLIPS_TAKEN_JOINS = `
-- FROM clips c
-- JOIN users u ON c.user_id = u.id
-- LEFT JOIN streams s ON c.stream_id = s.id
-- LEFT JOIN vods v ON c.vod_id = v.id
-- LEFT JOIN users su ON s.user_id = su.id
-- LEFT JOIN users vu ON v.user_id = vu.id`;
--function getClipsTakenByUser(userId, { includePrivate = false, orderBy = 'newest', sourceStreamerId = null, hideSelf = true, limit = 12, offset = 0 } = {}) {
-- const order = _CLIPS_TAKEN_ORDER[orderBy] || _CLIPS_TAKEN_ORDER.newest;
-- const { where, params } = _clipsTakenWhere(userId, { includePrivate, sourceStreamerId, hideSelf });
-- params.push(limit, offset);
-- return all(`
-- SELECT c.*, u.username, u.display_name, u.avatar_url,
-- s.title AS stream_title, s.protocol AS stream_protocol,
-- COALESCE(s.user_id, v.user_id) AS source_streamer_id,
-- COALESCE(su.username, vu.username) AS source_streamer_username,
-- COALESCE(su.display_name, vu.display_name) AS source_streamer_display_name,
-- COALESCE(su.avatar_url, vu.avatar_url) AS source_streamer_avatar
-- ${_CLIPS_TAKEN_JOINS}
-- WHERE ${where}
-- ORDER BY ${order}
-- LIMIT ? OFFSET ?
-- `, params);
--}
--function countClipsTakenByUser(userId, { includePrivate = false, sourceStreamerId = null, hideSelf = true } = {}) {
-- const { where, params } = _clipsTakenWhere(userId, { includePrivate, sourceStreamerId, hideSelf });
-- return get(`SELECT COUNT(*) AS count ${_CLIPS_TAKEN_JOINS} WHERE ${where}`, params)?.count || 0;
--}
--// Facets for the filter badges — every source streamer this user has clipped, with counts.
--function getClipsTakenFacets(userId, { includePrivate = false } = {}) {
-- const publicFilter = includePrivate ? '' : 'AND c.is_public = 1';
-- return all(`
-- SELECT COALESCE(s.user_id, v.user_id) AS streamer_id,
-- COALESCE(su.username, vu.username) AS username,
-- COALESCE(su.display_name, vu.display_name) AS display_name,
-- COALESCE(su.avatar_url, vu.avatar_url) AS avatar_url,
-- COUNT(*) AS count
-- ${_CLIPS_TAKEN_JOINS}
-- WHERE c.user_id = ? ${publicFilter}
-- GROUP BY streamer_id
-- HAVING streamer_id IS NOT NULL
-- ORDER BY count DESC, display_name ASC
-- `, [userId]);
--}
--
--function countClipsByUser(userId, includePrivate = false) {
-- const publicFilter = includePrivate ? '' : 'AND c.is_public = 1';
-- return get(`
-- SELECT COUNT(*) AS count
-- FROM clips c
-- WHERE c.user_id = ? ${publicFilter}
-- `, [userId])?.count || 0;
--}
--
--// setClipPublic() removed — the media subsystem (vods/clips/pastes writes) moved to OpenVibe.Media.
--const VALID_VISIBILITY = new Set(['public', 'unlisted', 'private']);
--function _normVisibility(v) { return VALID_VISIBILITY.has(v) ? v : 'public'; }
--// setClipVisibility() removed — the media subsystem (vods/clips/pastes writes) moved to OpenVibe.Media.
--// A specific user's pastes for their channel page. Non-owners see public only;
--// the owner/admin also sees their unlisted + private. sort: 'newest' | 'oldest'.
--function getUserPastesForChannel(userId, { includeHidden = false, sort = 'newest', limit = 30, offset = 0 } = {}) {
-- const dir = sort === 'oldest' ? 'ASC' : 'DESC';
-- const visClause = includeHidden ? '' : "AND p.visibility = 'public'";
-- return all(`
-- SELECT p.id, p.slug, p.type, p.title, p.language, p.visibility, p.pinned, p.views, p.copies, p.likes, p.created_at,
-- p.screenshot_path, p.ai_summary, substr(p.content, 1, 300) AS content
-- FROM pastes p
-- WHERE p.user_id = ? ${visClause}
-- ORDER BY p.pinned DESC, p.created_at ${dir}
-- LIMIT ? OFFSET ?
-- `, [userId, limit, offset]);
--}
--function countUserPastesForChannel(userId, { includeHidden = false } = {}) {
-- const visClause = includeHidden ? '' : "AND visibility = 'public'";
-- return get(`SELECT COUNT(*) AS count FROM pastes WHERE user_id = ? ${visClause}`, [userId]).count;
--}
--
--function getPublicClips(limit = 50, offset = 0, { username = null, sort = 'newest' } = {}) {
-- const conditions = ['c.is_public = 1'];
-- const params = [];
--
-- if (username) {
-- conditions.push('LOWER(COALESCE(su.username, u.username)) = LOWER(?)');
-- params.push(String(username).trim());
-- }
--
-- const dir = sort === 'oldest' ? 'ASC' : 'DESC';
-- params.push(limit, offset);
-- return all(`
-- SELECT c.*, u.username, u.display_name, u.avatar_url, u.is_owner AS owner_is_owner,
-- s.title AS stream_title, s.started_at AS stream_started_at, s.protocol AS stream_protocol,
-- su.username AS streamer_username, su.display_name AS streamer_display_name, su.avatar_url AS streamer_avatar_url,
-- su.is_owner AS streamer_is_owner
-- FROM clips c
-- JOIN users u ON c.user_id = u.id
-- LEFT JOIN streams s ON c.stream_id = s.id
-- LEFT JOIN users su ON s.user_id = su.id
-- WHERE ${conditions.join(' AND ')}
-- ORDER BY c.created_at ${dir}
-- LIMIT ? OFFSET ?
-- `, params);
--}
--
--function countPublicClips({ username = null } = {}) {
-- const conditions = ['c.is_public = 1'];
-- const params = [];
--
-- if (username) {
-- conditions.push('LOWER(COALESCE(su.username, u.username)) = LOWER(?)');
-- params.push(String(username).trim());
-- }
--
-- return get(`
-- SELECT COUNT(*) AS count
-- FROM clips c
-- JOIN users u ON c.user_id = u.id
-- LEFT JOIN streams s ON c.stream_id = s.id
-- LEFT JOIN users su ON s.user_id = su.id
-- WHERE ${conditions.join(' AND ')}
-- `, params)?.count || 0;
--}
--
--function listClipStreamers() {
-- return all(`
-- SELECT COALESCE(su.id, u.id) AS user_id,
-- COALESCE(su.username, u.username) AS username,
-- COALESCE(su.display_name, u.display_name) AS display_name,
-- COUNT(*) AS clip_count
-- FROM clips c
-- JOIN users u ON c.user_id = u.id
-- LEFT JOIN streams s ON c.stream_id = s.id
-- LEFT JOIN users su ON s.user_id = su.id
-- WHERE c.is_public = 1
-- GROUP BY COALESCE(su.id, u.id), COALESCE(su.username, u.username), COALESCE(su.display_name, u.display_name)
-- ORDER BY LOWER(COALESCE(COALESCE(su.display_name, u.display_name), COALESCE(su.username, u.username))) ASC
-- `, []);
--}
--
--function getClipsByStream(streamId) {
-- return all(`
-- SELECT c.*, u.username, u.display_name, u.avatar_url,
-- s.title AS stream_title, s.started_at AS stream_started_at, s.protocol AS stream_protocol
-- FROM clips c
-- JOIN users u ON c.user_id = u.id
-- LEFT JOIN streams s ON c.stream_id = s.id
-- WHERE c.stream_id = ? AND c.is_public = 1
-- ORDER BY c.created_at DESC
-- `, [streamId]);
--}
--
--function getClipsOfUserStreams(userId) {
-- return all(`
-- SELECT c.*, u.username, u.display_name, u.avatar_url,
-- s.title AS stream_title, s.started_at AS stream_started_at, s.protocol AS stream_protocol
-- FROM clips c
-- JOIN users u ON c.user_id = u.id
-- JOIN streams s ON c.stream_id = s.id
-- WHERE s.user_id = ?
-- ORDER BY c.created_at DESC
-- `, [userId]);
--}
--
--// findDuplicateClip() removed — the media subsystem (vods/clips/pastes writes) moved to OpenVibe.Media.
++// ── VODs and clips ───────────────────────────────────────────
++// They live in OpenVibe.Media. Live asks for them through server/media-client.js and
++// server/media-proxy/lookups.js; Live's own AI state for them is vod_ai_state / clip_ai_state.
++// The legacy local vods/clips tables are frozen and unread: test/frozen-tables.test.js fails on
++// a new write or read (register C-73; the drop procedure is in docs/vods-and-clips.md).
// ── Control helpers ──────────────────────────────────────────
@@@ -7151,78 +6473,9 @@ function upsertChannelModerationSettings(channelId, fields) {
return getChannelModerationSettings(channelId);
}
--// ── Paste helpers ────────────────────────────────────────────
--
--// createPaste() removed — the media subsystem (vods/clips/pastes writes) moved to OpenVibe.Media.
--
--function getPasteBySlug(slug) {
-- return get(`
-- SELECT p.*, u.username, u.avatar_url, u.display_name
-- FROM pastes p
-- LEFT JOIN users u ON p.user_id = u.id
-- WHERE p.slug = ?
-- `, [slug]);
--}
--
--function getPasteById(id) {
-- return get(`
-- SELECT p.*, u.username, u.avatar_url, u.display_name
-- FROM pastes p
-- LEFT JOIN users u ON p.user_id = u.id
-- WHERE p.id = ?
-- `, [id]);
--}
--
--function listPastes({ visibility = 'public', type, search, limit = 30, offset = 0 } = {}) {
-- let where = 'WHERE p.visibility = ?';
-- const params = [visibility];
--
-- if (type && type !== 'all') {
-- where += ' AND p.type = ?';
-- params.push(type);
-- }
-- if (search) {
-- where += ' AND (p.title LIKE ? OR p.content LIKE ?)';
-- params.push(`%${search}%`, `%${search}%`);
-- }
--
-- const total = get(`SELECT COUNT(*) as c FROM pastes p ${where}`, params).c;
-- const pastes = all(`
-- SELECT p.id, p.slug, p.user_id, p.type, p.title, p.language, p.visibility,
-- p.screenshot_path, p.burn_after_read, p.pinned, p.views, p.copies, p.likes, p.created_at,
-- u.username, u.avatar_url, u.display_name,
-- SUBSTR(p.content, 1, 220) as content
-- FROM pastes p
-- LEFT JOIN users u ON p.user_id = u.id
-- ${where}
-- ORDER BY p.pinned DESC, p.created_at DESC
-- LIMIT ? OFFSET ?
-- `, [...params, limit, offset]);
--
-- return { pastes, total };
--}
--
--function getUserPastes(userId, limit = 50) {
-- return all(`
-- SELECT id, slug, type, title, language, visibility, burn_after_read, pinned, views, copies, likes, created_at
-- FROM pastes WHERE user_id = ? ORDER BY created_at DESC LIMIT ?
-- `, [userId, limit]);
--}
--
--function hasUserLikedPaste(pasteId, userId) {
-- const row = get('SELECT 1 FROM paste_likes WHERE paste_id = ? AND user_id = ?', [pasteId, userId]);
-- return !!row;
--}
--
--function countUserPastesToday(userId, ip) {
-- if (userId) {
-- return get("SELECT COUNT(*) as c FROM pastes WHERE user_id = ? AND created_at > datetime('now', '-1 day')", [userId])?.c || 0;
-- }
-- if (ip) {
-- return get("SELECT COUNT(*) as c FROM pastes WHERE ip_address = ? AND created_at > datetime('now', '-1 day')", [ip])?.c || 0;
-- }
-- return 0;
--}
++// ── Pastes ───────────────────────────────────────────────────
++// They live in OpenVibe.Community (server/pastes-client.js, media-proxy/lookups.js). The legacy
++// local pastes/paste_likes/paste_comments tables are frozen and unread, like vods/clips above.
/**
* Get a user's total game level (sum of all skill levels).
@@@ -7243,71 +6496,6 @@ function getUserTotalGameLevel(userId) {
}
}
--function getLastPasteTime(userId, ip) {
-- let row;
-- if (userId) {
-- row = get('SELECT created_at FROM pastes WHERE user_id = ? ORDER BY created_at DESC LIMIT 1', [userId]);
-- } else if (ip) {
-- row = get('SELECT created_at FROM pastes WHERE ip_address = ? ORDER BY created_at DESC LIMIT 1', [ip]);
-- }
-- return row ? new Date(row.created_at + (row.created_at.includes('Z') ? '' : 'Z')).getTime() : 0;
--}
--
--function getPasteStats() {
-- const total = get('SELECT COUNT(*) as c FROM pastes')?.c || 0;
-- const textPastes = get("SELECT COUNT(*) as c FROM pastes WHERE type = 'paste'")?.c || 0;
-- const screenshots = get("SELECT COUNT(*) as c FROM pastes WHERE type = 'screenshot'")?.c || 0;
-- const forks = get('SELECT COUNT(*) as c FROM pastes WHERE forked_from IS NOT NULL')?.c || 0;
-- const totalViews = get('SELECT SUM(views) as s FROM pastes')?.s || 0;
-- const totalCopies = get('SELECT SUM(copies) as s FROM pastes')?.s || 0;
-- const totalLikes = get('SELECT SUM(likes) as s FROM pastes')?.s || 0;
-- return { total, textPastes, screenshots, forks, totalViews, totalCopies, totalLikes };
--}
--
--// ── Paste Comment helpers ────────────────────────────────────
--
--function createPasteComment({ paste_id, user_id, parent_id, anon_name, message, ip_address }) {
-- return run(
-- `INSERT INTO paste_comments (paste_id, user_id, parent_id, anon_name, message, ip_address)
-- VALUES (?, ?, ?, ?, ?, ?)`,
-- [paste_id, user_id || null, parent_id || null, anon_name || null, message, ip_address || null]
-- );
--}
--
--function getPasteComments(pasteId, limit = 50, offset = 0) {
-- return all(`
-- SELECT c.*, u.username, u.display_name, u.avatar_url, u.profile_color, u.role
-- FROM paste_comments c
-- LEFT JOIN users u ON c.user_id = u.id
-- WHERE c.paste_id = ? AND c.is_deleted = 0 AND c.parent_id IS NULL
-- ORDER BY c.created_at DESC
-- LIMIT ? OFFSET ?
-- `, [pasteId, limit, offset]);
--}
--
--function getPasteCommentReplies(parentId) {
-- return all(`
-- SELECT c.*, u.username, u.display_name, u.avatar_url, u.profile_color, u.role
-- FROM paste_comments c
-- LEFT JOIN users u ON c.user_id = u.id
-- WHERE c.parent_id = ? AND c.is_deleted = 0
-- ORDER BY c.created_at ASC
-- `, [parentId]);
--}
--
--function getPasteCommentById(commentId) {
-- return get('SELECT * FROM paste_comments WHERE id = ?', [commentId]);
--}
--
--function getPasteCommentCount(pasteId) {
-- const row = get('SELECT COUNT(*) as count FROM paste_comments WHERE paste_id = ? AND is_deleted = 0', [pasteId]);
-- return row ? row.count : 0;
--}
--
--function deletePasteComment(commentId) {
-- return run('UPDATE paste_comments SET is_deleted = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [commentId]);
--}
--
// ── Anon IP Mapping ─────────────────────────────────
/**
@@@ -7377,14 +6565,6 @@ function recordFirstChat(chatterKey, channelUserId) {
);
}
--function getRecentPasteCommentsByIp(ip, seconds = 10) {
-- return all(`
-- SELECT * FROM paste_comments
-- WHERE ip_address = ? AND created_at > datetime('now', '-' || ? || ' seconds')
-- ORDER BY created_at DESC
-- `, [ip, seconds]);
--}
--
// ── Moderation Action Logging ────────────────────────────────
/**
@@@ -7958,11 +7138,11 @@ function computeAndCacheStreamAnalytics(streamId) {
);
const totalWatchMinutes = watchRow?.total || 0;
-- // Clips created during this stream
-- const clipsRow = get(
-- 'SELECT COUNT(*) as cnt FROM clips WHERE stream_id = ?', [streamId]
-- );
-- const clipsCreated = clipsRow?.cnt || 0;
++ // Clips created during this stream: they live in OpenVibe.Media. This runs synchronously when a
++ // stream ends, so it keeps the last count it has and asks Media right after (lookups.js
++ // refreshStreamClipCount writes the answer back with setStreamAnalyticsClipCount).
++ const clipsCreated = get('SELECT clips_created FROM stream_analytics WHERE stream_id = ?', [streamId])?.clips_created || 0;
++ setImmediate(() => { try { require('../media-proxy/lookups').refreshStreamClipCount(streamId).catch(() => {}); } catch { /* */ } });
// Coins earned during this stream
const coinsRow = get(
@@@ -8018,6 +7198,11 @@ function getStreamAnalytics(streamId) {
return get('SELECT * FROM stream_analytics WHERE stream_id = ?', [streamId]);
}
++// The clip count OpenVibe.Media reported for a stream (media-proxy/lookups.js refreshStreamClipCount).
++function setStreamAnalyticsClipCount(streamId, count) {
++ return run('UPDATE stream_analytics SET clips_created = ? WHERE stream_id = ?', [Math.max(0, Math.floor(Number(count) || 0)), streamId]);
++}
++
function getChannelAnalyticsSummary(userId, days) {
const cutoff = new Date(Date.now() - days * 86400000).toISOString();
@@@ -8298,22 +7483,17 @@ module.exports = {
createStream, endStream, onStreamLifecycle, endOtherLiveStreamsForSlot, updateViewerCount,
addStreamMemory, getStreamMemories, getLatestStreamMemory, updateStreamAiOverview,
setVodAiOverview, setClipAiOverview, setVodTranscript, setClipTranscript, getStreamMemoriesInRange,
-- getVodsNeedingOverview, getClipsNeedingOverview, getVodsNeedingTimeline, getVodsNeedingTranscript, getClipsNeedingTranscript, getPastesNeedingAnalysis,
++ getVodsNeedingOverview, getClipsNeedingOverview, getVodsNeedingTimeline, getVodsNeedingTranscript, getClipsNeedingTranscript,
setVodTranscriptStatus, setClipTranscriptStatus, bumpVodTranscriptAttempt, bumpClipTranscriptAttempt,
cleanupMalformedAiText, recordAiUsage, getAiCostToday, getAiCostTodayForUser, getAiUsageSummary,
-- getStreamMemoriesByUser, countStreamMemoriesByUser, getAiMomentCandidates, getStreamTranscriptSegments, getUserPastesForAi,
++ getStreamMemoriesByUser, countStreamMemoriesByUser, getStreamTranscriptSegments,
addTimelineEvents, getTimeline, getTimelineText, getTimelineCoverage, linkTimelineToVod, getTimelineByVod, getTimelineVodId,
-- getVodsForMomentRanking, getClipStartTimesForStream, getChatSpikeOffsets,
-- getLiveChatBuckets, getRecentChatText, getVodsWithoutAutoClip,
++ getChatSpikeOffsets, getLiveChatBuckets, getRecentChatText,
upsertStreamerOverview, getStreamerOverview, getAllStreamerOverviews, getStreamersNeedingOverview,
-- getStreamerAiTimeline, assembleStreamerAiTimeline, setStreamAiTitle, getUntitledAiSessions, clearAiTimelineCache,
++ readStreamerAiTimelineCache, buildStreamerAiTimeline, assembleStreamerAiTimeline, setStreamAiTitle, getUntitledAiSessions, clearAiTimelineCache,
// Homepage helpers
getRecentlyOnlineStreamers, countRecentlyOnlineStreamers,
-- getRecentVods, countRecentVods, getHomeStats,
-- // Filtered VODs/clips
-- getVodsByUserFiltered, countVodsByUserFiltered, getPopularVodForUser, getPopularClipForUser, getTopContentRanges,
-- getClipsOfUserStreamsPaginated, countClipsOfUserStreams,
-- getClipsByUserPaginated,
++ getHomeStats,
// Channels
getChannelByUserId, getChannelsByUserIds, getChannelByUsername, createChannel, updateChannel, ensureChannel, setUserBio,
getChannelPointsConfig, setChannelPointsConfig,
@@@ -8346,7 +7526,7 @@ module.exports = {
recordEasterEggSolve, hasSolvedEasterEgg, countEasterEggSolves,
getTtsVoiceOverride, setTtsVoiceOverride, deleteTtsVoiceOverride,
// Profiles
-- getUserProfile, updateUserAvatar, getUserAvatarPastes,
++ getUserProfile, updateUserAvatar,
getKickChannelCache, setKickChannelCache,
getChannelPoints, addChannelPoints, deductChannelPoints,
// Follows
@@@ -8369,13 +7549,6 @@ module.exports = {
countPendingMediaRequestsForUser, getMediaRequestMaxQueuePosition,
findActiveMediaRequestByCanonicalUrl, updateMediaRequest,
renormalizePendingMediaRequestPositions,
-- // VODs
-- getVodById, getVodsByUser, countVodsByUser, getPublicVods, countPublicVods, listVodStreamers, getActiveVodByStream,
-- getVodHealthById, getVodScanCandidates,
-- getVodsNeedingHealthScan, getQuarantinedVodsForCleanup,
-- // Clips
-- getClipById, getClipsByUser, countClipsByUser, getPublicClips, countPublicClips, listClipStreamers, getClipsByStream, getClipsOfUserStreams,
-- getClipsTakenByUser, countClipsTakenByUser, getClipsTakenFacets,
// Controls
getStreamControls, createControl, bindStreamToControlConfig,
// ONVIF Cameras
@@@ -8416,14 +7589,7 @@ module.exports = {
getChannelModerators, getChannelsByModerator,
// Channel Moderation Settings
getChannelModerationSettings, upsertChannelModerationSettings,
-- // Pastes
-- getPasteBySlug, getPasteById, listPastes,
-- getUserPastes, getUserPastesForChannel, countUserPastesForChannel,
-- hasUserLikedPaste, countUserPastesToday, getLastPasteTime, getPasteStats, getUserTotalGameLevel,
-- // Paste Comments
-- createPasteComment, getPasteComments, getPasteCommentReplies,
-- getPasteCommentById, getPasteCommentCount, deletePasteComment,
-- getRecentPasteCommentsByIp,
++ getUserTotalGameLevel,
// Anon IP Mappings
getOrCreateAnonNum, getAnonFirstSeen, loadAnonMappings,
// Stream first chats (welcome messages)
@@@ -8445,7 +7611,7 @@ module.exports = {
getLatestIpForUser, getLatestIpForAnon, getIpLog, banAllAccountsOnIp,
// Stream Analytics
insertViewerSnapshot, getViewerSnapshots, computeAndCacheStreamAnalytics,
-- getStreamAnalytics, getChannelAnalyticsSummary, getRecentChatActivity,
++ getStreamAnalytics, setStreamAnalyticsClipCount, getChannelAnalyticsSummary, getRecentChatActivity,
// User Preferences
getUserPreferences, saveUserPreferences,
// Chat Log Management
ddiff --git a/server/home/routes.js b/server/home/routes.js
iindex bc51fd44..14441e14 100644
---- a/server/home/routes.js
++++ b/server/home/routes.js
@@@ -155,20 +155,9 @@ function heroSlogans() {
};
}
--// ── Media-side counters (VODs/clips/pastes live in OpenVibe.Media now — the
--// local tables are empty post-split, so hero counts come from Media, cached).
--let _mediaStats = { at: 0, data: null };
--const MEDIA_STATS_TTL_MS = 5 * 60_000;
--async function _mediaStatsCached() {
-- if (Date.now() - _mediaStats.at < MEDIA_STATS_TTL_MS) return _mediaStats.data;
-- try {
-- _mediaStats = { at: Date.now(), data: await media.request('GET', '/stats') };
-- } catch (err) {
-- console.warn('[Home] Media stats unavailable:', err.message);
-- _mediaStats.at = Date.now() - MEDIA_STATS_TTL_MS + 30_000; // retry in 30s
-- }
-- return _mediaStats.data;
--}
++// ── Archive counters (VODs, clips, hours from OpenVibe.Media; pastes from OpenVibe.Community):
++// server/media-proxy/lookups.js fetches and caches them. Live's own frozen tables are not counted.
++const lookups = require('../media-proxy/lookups');
// GET /api/home/stats/series/:metric?days=30 — daily values behind a hero stat.
// VODs, clips, pastes and archived hours live in OpenVibe.Media; the local tables stopped at the split.
@@@ -205,19 +194,10 @@ async function heroStats(timing) {
// Media stats and the network coin ledger are independent upstream calls; they used to be awaited
// one after the other, so a cold cache paid for both in sequence.
a = Date.now();
-- const [m, c] = await Promise.all([
-- _mediaStatsCached().then(v => { mark('media_stats', a); return v; }),
++ const [, c] = await Promise.all([
++ lookups.withArchiveStats(stats).then(v => { mark('media_stats', a); return v; }),
require('../monetization/wallet-client').networkCoinStats().then(v => { mark('coins', a); return v; }).catch(() => null),
]);
-- if (m) {
-- stats.vods = m.vods;
-- stats.clips = m.clips;
-- stats.pastes = m.pastes;
-- stats.pasteImages = m.pasteImages;
-- stats.pasteText = m.pasteText;
-- stats.streamHours = Math.round((m.durationSeconds || 0) / 3600);
-- stats.recent = { ...stats.recent, vods: m.recent?.vods, clips: m.recent?.clips, hours: m.recent?.hours };
-- }
// Site-wide OpenCoins sit next to channel points on the board: one is the network-wide
// currency people earn everywhere, the other is per-channel. The ledger lives on
// OpenVibe.Network, so this is a cached internal call that is allowed to come back empty.
ddiff --git a/server/media-proxy/lookups.js b/server/media-proxy/lookups.js
nnew file mode 100644
iindex 00000000..039fb55e
---- /dev/null
++++ b/server/media-proxy/lookups.js
@@@ -0,0 +1,392 @@
++/**
++ * OpenVibe.Live — the reads that used to hit Live's frozen `vods`/`clips`/`pastes` tables, answered
++ * by OpenVibe.Media (VODs, clips) and OpenVibe.Community (pastes) instead (register C-73 step 2).
++ *
++ * The local tables stopped at the Media split: nothing recorded, clipped or pasted since then was
++ * ever in them, and Media and Community hold the old rows as well (same VOD/clip ids; pastes by
++ * slug). Every lookup here is best-effort. When the upstream is down it answers "nothing" (a null
++ * id, an empty list, a zero or null count), never a stale local copy.
++ *
++ * Privacy follows access.js. A list anyone can read carries public items only. Hidden items are
++ * asked for explicitly (include_private for Media; the owner or staff for pastes), and only on
++ * paths that have already checked the caller is the owner or staff.
++ *
++ * Media lists have no batch-by-stream or date filter yet, so a few lookups ask per stream or cut
++ * windows client-side (see the notes on each). docs/vods-and-clips.md lists the API additions.
++ */
++'use strict';
++const db = require('../db/database');
++const media = require('../media-client');
++const pastesClient = require('../pastes-client');
++
++const TIMEOUT_MS = 5000;
++const rowsOf = (r, key) => (r && Array.isArray(r[key]) ? r[key] : (Array.isArray(r) ? r : []));
++const totalOf = (r, rows) => (r && Number.isFinite(Number(r.total)) ? Number(r.total) : rows.length);
++
++/** Listed for everyone. A row without `visibility` is a legacy one, where is_public decides. */
++function isPublic(row) {
++ if (!row) return false;
++ if (row.visibility) return row.visibility === 'public';
++ return row.is_public === true || row.is_public === 1 || row.is_public === '1';
++}
++/** A finished recording (not in progress, not an ephemeral clips-only one). */
++const finished = (v) => !!v && !v.is_recording && !v.clips_only;
++/** SQLite 'YYYY-MM-DD HH:MM:SS' (UTC) or ISO → ms. */
++const ts = (s) => { const n = Date.parse(String(s || '').replace(' ', 'T').replace(/(T\d\d:\d\d:\d\d)$/, '$1Z')); return Number.isFinite(n) ? n : 0; };
++
++/** Successful answers are kept for ttlMs. A failure is not cached, so the next caller retries. */
++function ttlCache(ttlMs, max = 1000) {
++ const map = new Map();
++ return {
++ async get(key, load) {
++ const hit = map.get(key);
++ if (hit && Date.now() - hit.at < ttlMs) return hit.value;
++ const value = await load();
++ if (map.size >= max) map.delete(map.keys().next().value);
++ map.set(key, { at: Date.now(), value });
++ return value;
++ },
++ clear() { map.clear(); },
++ };
++}
++
++/** fn over items, at most n at a time. */
++async function pool(items, n, fn) {
++ let next = 0;
++ await Promise.all(Array.from({ length: Math.min(n, items.length) }, async () => {
++ while (next < items.length) { const i = next++; await fn(items[i], i); }
++ }));
++}
++
++function transcriptText(json) {
++ try {
++ const segs = JSON.parse(json || '[]');
++ return Array.isArray(segs) ? segs.map((s) => String((s && s.text) || '').trim()).filter(Boolean).join(' ') : '';
++ } catch { return ''; }
++}
++/** Live's own AI text (vod_ai_state / clip_ai_state) laid over a Media row. */
++function withAiState(row, state) {
++ const out = { ...row, duration_seconds: Number(row.duration_seconds ?? row.duration) || 0 };
++ if (state) {
++ if (state.ai_overview || state.ai_overview_short) out.ai_overview = state.ai_overview || state.ai_overview_short;
++ if (state.ai_overview_short) out.ai_overview_short = state.ai_overview_short;
++ const text = transcriptText(state.ai_transcript_json);
++ if (text) out.ai_transcript = text;
++ }
++ return out;
++}
++const aiState = (fn, id) => { try { return fn(id) || null; } catch { return null; } };
++
++// ── VODs ─────────────────────────────────────────────────────────────────────
++
++const _streamVod = ttlCache(5 * 60_000);
++/**
++ * The public, finished VOD of each stream (the newest if there are several). Media has no batch
++ * filter by stream id, so this asks per stream, six at a time, and remembers each answer (also "no
++ * VOD") for five minutes. The whole lookup gets `budgetMs`: a stream not asked about by then goes
++ * without its VOD this time, so a slow Media cannot hold a list for minutes. → Map(streamId → row)
++ */
++async function publicVodsForStreams(streamIds, { budgetMs = 4000 } = {}) {
++ const ids = [...new Set((streamIds || []).map(Number).filter((n) => Number.isFinite(n) && n > 0))];
++ const out = new Map();
++ const deadline = Date.now() + budgetMs;
++ await pool(ids, 6, async (sid) => {
++ const left = deadline - Date.now();
++ if (left <= 0) return;
++ try {
++ const vod = await _streamVod.get(sid, async () => {
++ const r = await media.listVods({ stream_id: sid, limit: 10 }, { timeoutMs: Math.min(TIMEOUT_MS, left) });
++ return rowsOf(r, 'vods').filter((v) => isPublic(v) && finished(v))
++ .sort((a, b) => Number(b.id) - Number(a.id))[0] || null;
++ });
++ if (vod) out.set(sid, vod);
++ } catch { /* Media down: no VOD for this stream */ }
++ });
++ return out;
++}
++
++/**
++ * The recently-ended list's VOD fields (vod_id, vod_is_public, vod_thumbnail_url, vod_duration),
++ * which getRecentStreams used to join from the local table. A public VOD or nothing: never the id
++ * or thumbnail of a private or unlisted one.
++ */
++async function attachPublicVods(streams) {
++ const list = Array.isArray(streams) ? streams : [];
++ const vods = await publicVodsForStreams(list.map((s) => s && s.id));
++ for (const s of list) {
++ const v = vods.get(Number(s.id)) || null;
++ s.vod_id = v ? v.id : null;
++ s.vod_is_public = v ? 1 : null;
++ s.vod_thumbnail_url = v ? media.publicUrl(v.thumbnail_url) : null;
++ s.vod_duration = v ? (Number(v.duration_seconds ?? v.duration) || 0) : null;
++ }
++ return list;
++}
++
++/**
++ * stream id → public VOD id for one streamer (the AI timeline's links), newest VOD per stream.
++ * Pages through Media's list, 500 at a time, up to 2,000 VODs. `complete` is false when Media did
++ * not answer, so the caller does not cache a timeline with every link missing.
++ */
++async function publicVodIdsByStream(userId) {
++ const byStream = new Map();
++ const PAGE = 500;
++ try {
++ for (let offset = 0; offset < 2000; offset += PAGE) {
++ const rows = rowsOf(await media.listVods({ user_id: userId, limit: PAGE, offset }, { timeoutMs: TIMEOUT_MS }), 'vods');
++ for (const v of rows) {
++ if (!v || !v.stream_id || !isPublic(v) || !finished(v)) continue;
++ const sid = Number(v.stream_id);
++ if (!byStream.has(sid) || Number(v.id) > byStream.get(sid)) byStream.set(sid, Number(v.id));
++ }
++ if (rows.length < PAGE) break;
++ }
++ return { byStream, complete: true };
++ } catch {
++ return { byStream, complete: false };
++ }
++}
++
++/**
++ * stream id → VOD for one slot's sessions (its owner's workspace history), newest VOD per stream.
++ * Private VODs and recordings in progress are included: only the slot's owner reads this.
++ */
++async function vodsForManagedStream(managedStreamId) {
++ const out = new Map();
++ try {
++ const r = await media.listVods({ managed_stream_id: managedStreamId, include_private: 1, include_recording: 1, limit: 200 }, { timeoutMs: TIMEOUT_MS });
++ for (const v of rowsOf(r, 'vods')) {
++ if (!v || !v.stream_id) continue;
++ const sid = Number(v.stream_id);
++ const cur = out.get(sid);
++ if (!cur || Number(v.id) > Number(cur.id)) out.set(sid, v);
++ }
++ } catch { /* Media down: the sessions show without a VOD link */ }
++ return out;
++}
++
++/**
++ * A streamer's finished VODs, newest first, with Live's AI text (ai_overview, ai_transcript) laid
++ * over Media's row. includePrivate is for staff tools only.
++ */
++async function userVods(userId, { includePrivate = false, limit = 20 } = {}) {
++ let rows;
++ try { rows = rowsOf(await media.listVods({ user_id: userId, include_private: includePrivate ? 1 : undefined, limit }, { timeoutMs: TIMEOUT_MS }), 'vods'); }
++ catch { return []; }
++ return rows.filter((v) => finished(v) && (includePrivate || isPublic(v))).map((v) => withAiState(v, aiState(db.getVodAiState, v.id)));
++}
++
++/** Every finished VOD, and the public ones (the admin dashboard). null when Media did not answer. */
++async function vodCounts() {
++ const [all, pub] = await Promise.all([
++ media.listVods({ include_private: 1, limit: 1 }, { timeoutMs: TIMEOUT_MS }).catch(() => null),
++ media.listVods({ limit: 1 }, { timeoutMs: TIMEOUT_MS }).catch(() => null),
++ ]);
++ const n = (r) => (r && Number.isFinite(Number(r.total)) ? Number(r.total) : null);
++ return { total: n(all), public: n(pub) };
++}
++
++// ── Clips ────────────────────────────────────────────────────────────────────
++
++/** Clips a user made, newest first, with Live's AI text. includePrivate is for staff tools only. */
++async function userClips(userId, { includePrivate = false, limit = 20 } = {}) {
++ let rows;
++ try { rows = rowsOf(await media.listClips({ user_id: userId, include_private: includePrivate ? 1 : undefined, limit }, { timeoutMs: TIMEOUT_MS }), 'clips'); }
++ catch { return []; }
++ return rows.filter((c) => includePrivate || isPublic(c)).map((c) => withAiState(c, aiState(db.getClipAiState, c.id)));
++}
++
++/** Clips this user took of other streamers: the Clips Taken tab badge, counted like the tab lists them. */
++async function countClipsTaken(userId, { includePrivate = false } = {}) {
++ try {
++ const r = await media.listClips({ user_id: userId, include_private: includePrivate ? 1 : undefined, hide_self: 1, limit: 1 }, { timeoutMs: TIMEOUT_MS });
++ return totalOf(r, rowsOf(r, 'clips'));
++ } catch { return 0; }
++}
++
++/** Every clip of one stream (by the stream or by its VOD), any visibility. An internal signal, never listed. */
++async function streamClips(streamId, vodId) {
++ const asks = [];
++ if (streamId) asks.push(media.listClips({ stream_id: streamId, include_private: 1, limit: 500 }, { timeoutMs: TIMEOUT_MS }));
++ if (vodId) asks.push(media.listClips({ vod_id: vodId, include_private: 1, limit: 500 }, { timeoutMs: TIMEOUT_MS }));
++ const byId = new Map();
++ for (const r of await Promise.all(asks.map((p) => p.catch(() => null)))) {
++ for (const c of rowsOf(r, 'clips')) if (c && c.id != null) byId.set(c.id, c);
++ }
++ return [...byId.values()];
++}
++
++/** Seconds into the VOD where viewers clipped (the AI moment picker's strongest signal). */
++async function clipStartTimes(streamId, vodId) {
++ return (await streamClips(streamId, vodId))
++ .map((c) => Number(c.start_time)).filter((t) => t > 0).map(Math.floor).sort((a, b) => a - b);
++}
++
++/**
++ * stream_analytics.clips_created from Media. computeAndCacheStreamAnalytics (database.js) keeps the
++ * last value it has and calls this, since it runs synchronously when a stream ends.
++ */
++async function refreshStreamClipCount(streamId) {
++ try {
++ const r = await media.listClips({ stream_id: streamId, include_private: 1, limit: 1 }, { timeoutMs: TIMEOUT_MS });
++ const n = totalOf(r, rowsOf(r, 'clips'));
++ db.setStreamAnalyticsClipCount(streamId, n);
++ return n;
++ } catch { return null; }
++}
++
++/**
++ * The most-viewed public VOD and clip of a streamer for this week, this month and all time (the
++ * offline screen's cycler). Media lists have no date filter, so the week and month are cut from the
++ * newest 100 VODs and clips (more than 100 in a month loses only that month's oldest), and all time
++ * also asks Media for its most-viewed. The streamer's name rides along on each row as it used to.
++ */
++async function topContentRanges(user) {
++ const ask = (fn, query) => fn(query, { timeoutMs: TIMEOUT_MS }).then((r) => r, () => null);
++ const [vNew, vTop, cNew, cTop] = await Promise.all([
++ ask(media.listVods, { user_id: user.id, order: 'newest', limit: 100 }),
++ ask(media.listVods, { user_id: user.id, order: 'views', limit: 1 }),
++ ask(media.listClips, { channel_user_id: user.id, order: 'newest', limit: 100 }),
++ ask(media.listClips, { channel_user_id: user.id, order: 'views', limit: 1 }),
++ ]);
++ const who = { username: user.username, display_name: user.display_name, avatar_url: user.avatar_url, profile_color: user.profile_color };
++ const top = (rows, since) => {
++ const best = rows.filter((r) => r && isPublic(r) && finished(r) && (since == null || ts(r.created_at) >= since))
++ .sort((a, b) => (Number(b.view_count) || 0) - (Number(a.view_count) || 0) || ts(b.created_at) - ts(a.created_at))[0];
++ return best ? { ...best, ...who } : null;
++ };
++ const vods = rowsOf(vNew, 'vods'), clips = rowsOf(cNew, 'clips');
++ const week = Date.now() - 7 * 86400_000;
++ const month = (() => { const d = new Date(); d.setUTCMonth(d.getUTCMonth() - 1); return d.getTime(); })();
++ return {
++ week: { vod: top(vods, week), clip: top(clips, week) },
++ month: { vod: top(vods, month), clip: top(clips, month) },
++ all: { vod: top([...rowsOf(vTop, 'vods'), ...vods], null), clip: top([...rowsOf(cTop, 'clips'), ...clips], null) },
++ };
++}
++
++// ── Pastes (OpenVibe.Community; OpenVibe.Media while PASTES_AUTHORITY is unset) ──────────────
++
++/** Throws when the upstream fails (so a count cache never keeps a failure as zero). */
++async function _listUserPastes(user, { limit = 30, type, hidden = false }) {
++ let out;
++ if (pastesClient.onCommunity()) {
++ // Community lists one person's pastes by username. Hidden ones only for the owner (act as
++ // them) or staff (X-OV-Staff): Live says which, after its own check of the caller.
++ const act = hidden === 'owner' ? { liveUserId: user.id } : (hidden === 'staff' ? { staff: true } : {});
++ out = await pastesClient.request('GET', '', { query: { username: user.username, include_unlisted: hidden ? 1 : undefined, type, limit }, act, timeoutMs: TIMEOUT_MS });
++ } else {
++ out = await media.listPastes({ user_id: user.id, include_unlisted: hidden ? 1 : undefined, type, limit }, { timeoutMs: TIMEOUT_MS });
++ }
++ let pastes = rowsOf(out, 'pastes');
++ if (!hidden) pastes = pastes.filter((p) => p && p.visibility === 'public');
++ return { pastes, total: totalOf(out, pastes) };
++}
++
++/**
++ * One person's pastes, newest first (pinned first, as the upstream lists them). `hidden`: false
++ * (public only), 'owner' or 'staff'. Pass 'owner'/'staff' only once the caller has been checked to
++ * be that. `type`: 'paste' | 'screenshot'. → { pastes, total }, empty when the upstream is down.
++ */
++async function userPastes(user, opts = {}) {
++ if (!user || !user.id || !user.username) return { pastes: [], total: 0 };
++ try { return await _listUserPastes(user, opts); } catch { return { pastes: [], total: 0 }; }
++}
++
++const _pasteCounts = ttlCache(60_000);
++/** How many pastes a person has (tab badges, the setup hub). Cached for a minute. */
++async function countUserPastes(user, { hidden = false } = {}) {
++ if (!user || !user.id || !user.username) return 0;
++ try {
++ return await _pasteCounts.get(`${user.id}:${hidden || 'public'}`, async () => (await _listUserPastes(user, { hidden, limit: 1 })).total);
++ } catch { return 0; }
++}
++
++/** A streamer's pastes with their AI fields, any visibility: the staff-side overview and AI explorer. */
++async function userPastesForAi(user, limit = 30) {
++ const { pastes } = await userPastes(user, { hidden: 'staff', limit });
++ return pastes.map((p) => ({
++ id: p.id, slug: p.slug, type: p.type, title: p.title,
++ ai_summary: p.ai_summary || null, ai_tags: p.ai_tags || null, ai_analyzed_at: p.ai_analyzed_at || null,
++ created_at: p.created_at,
++ }));
++}
++
++/** The person's avatar uploads: their screenshot pastes tagged metadata.kind = 'avatar'. */
++async function avatarPastes(user, limit = 60) {
++ const { pastes } = await userPastes(user, { type: 'screenshot', hidden: 'owner', limit: 200 });
++ const kind = (p) => {
++ let m = p && p.metadata;
++ if (typeof m === 'string') { try { m = JSON.parse(m); } catch { m = null; } }
++ return m && typeof m === 'object' ? m.kind : null;
++ };
++ return pastes.filter((p) => kind(p) === 'avatar').slice(0, limit);
++}
++
++// ── Site totals (home hero, SEO) ─────────────────────────────────────────────
++
++let _mediaStats = { at: 0, data: null };
++const SITE_STATS_TTL_MS = 5 * 60_000;
++/** Media's per-app counters (VODs, clips, archived hours). Cached five minutes; retried 30 s after a failure. */
++async function siteMediaStats() {
++ if (Date.now() - _mediaStats.at < SITE_STATS_TTL_MS) return _mediaStats.data;
++ try {
++ _mediaStats = { at: Date.now(), data: await media.request('GET', '/stats', { timeoutMs: TIMEOUT_MS }) };
++ } catch (err) {
++ console.warn('[Home] Media stats unavailable:', err.message);
++ _mediaStats.at = Date.now() - SITE_STATS_TTL_MS + 30_000;
++ }
++ return _mediaStats.data;
++}
++
++let _pasteStats = { at: 0, data: null };
++/**
++ * Paste totals from the service that owns pastes: Community's counts under PASTES_AUTHORITY=community
++ * ({ pastes, pasteText, pasteImages }), else null (Media's /stats already carries them).
++ */
++async function sitePasteStats() {
++ if (!pastesClient.onCommunity()) return null;
++ if (Date.now() - _pasteStats.at < SITE_STATS_TTL_MS) return _pasteStats.data;
++ try {
++ const out = await pastesClient.request('GET', '/admin/stats', { act: { staff: true }, timeoutMs: TIMEOUT_MS });
++ const s = (out && out.stats) || {};
++ _pasteStats = { at: Date.now(), data: { pastes: Number(s.total) || 0, pasteText: Number(s.textPastes) || 0, pasteImages: Number(s.screenshots) || 0 } };
++ } catch (err) {
++ console.warn('[Home] Community paste stats unavailable:', err.message);
++ _pasteStats.at = Date.now() - SITE_STATS_TTL_MS + 30_000;
++ }
++ return _pasteStats.data;
++}
++
++/**
++ * The archive counters on top of Live's own stats: VODs, clips, hours from Media; pastes from
++ * Community (or Media). Fields stay null when neither answered.
++ */
++async function withArchiveStats(stats) {
++ const [m, p] = await Promise.all([siteMediaStats(), sitePasteStats()]);
++ if (m) {
++ stats.vods = m.vods;
++ stats.clips = m.clips;
++ stats.pastes = m.pastes;
++ stats.pasteImages = m.pasteImages;
++ stats.pasteText = m.pasteText;
++ stats.streamHours = Math.round((m.durationSeconds || 0) / 3600);
++ stats.recent = { ...stats.recent, vods: m.recent?.vods, clips: m.recent?.clips, hours: m.recent?.hours };
++ }
++ if (p) Object.assign(stats, p);
++ return stats;
++}
++
++function _resetCaches() {
++ _streamVod.clear(); _pasteCounts.clear();
++ _mediaStats = { at: 0, data: null }; _pasteStats = { at: 0, data: null };
++}
++
++module.exports = {
++ isPublic,
++ publicVodsForStreams, attachPublicVods, publicVodIdsByStream, vodsForManagedStream, userVods, vodCounts,
++ userClips, countClipsTaken, streamClips, clipStartTimes, refreshStreamClipCount, topContentRanges,
++ userPastes, countUserPastes, userPastesForAi, avatarPastes,
++ siteMediaStats, sitePasteStats, withArchiveStats,
++ _resetCaches,
++};
ddiff --git a/server/pastes-client.js b/server/pastes-client.js
iindex c2b2f6a0..66d3e1c5 100644
---- a/server/pastes-client.js
++++ b/server/pastes-client.js
@@@ -53,7 +53,8 @@ async function subjectForLiveUser(liveUserId) {
/**
* One call to Community's paste API. body: plain object (JSON) or FormData. act: { liveUserId } to act
-- * as a person, { origin: 'ai', sourceRef } for derived pastes, {} for Live itself (moderation paths).
++ * as a person, { origin: 'ai', sourceRef } for derived pastes, { staff: true } when Live has already
++ * decided the caller is staff (X-OV-Staff; needs community.paste.moderate), {} for Live itself.
*/
async function request(method, path, { query, body, act = {}, ip, timeoutMs = 20000, retried = false } = {}) {
const qs = query ? `?${new URLSearchParams(Object.entries(query).filter(([, v]) => v != null && v !== '').map(([k, v]) => [k, String(v)]))}` : '';
@@@ -67,6 +68,7 @@ async function request(method, path, { query, body, act = {}, ip, timeoutMs = 20
headers['X-OV-Origin'] = 'ai';
if (act.sourceRef) headers['X-OV-Source-Ref'] = JSON.stringify(act.sourceRef);
}
++ if (act.staff) headers['X-OV-Staff'] = '1';
if (ip) headers['X-Forwarded-For'] = ip;
let payload;
if (body instanceof FormData) payload = body;
ddiff --git a/server/seo/seo.js b/server/seo/seo.js
iindex 1e54d4f8..a1bdb6d4 100644
---- a/server/seo/seo.js
++++ b/server/seo/seo.js
@@@ -221,7 +221,8 @@ async function _homeMeta() {
try { vods = (await _vodList(12)) || []; } catch { /* */ }
try { clips = (await _clipList(12)) || []; } catch { /* */ }
try { pastes = (await _pasteList(12)) || []; } catch { /* */ }
-- try { stats = db.getHomeStats(); } catch { /* */ }
++ // VODs, clips and pastes are counted by the services that hold them (Media, Community), not Live's frozen tables.
++ try { stats = await require('../media-proxy/lookups').withArchiveStats({ ...db.getHomeStats() }); } catch { /* */ }
const liveItems = live.map(s => ({ url: `/@${s.username}`, name: s.title || `${s.display_name || s.username} live`, by: s.display_name || s.username, meta: s.category || 'live' }));
const vodItems = vods.map(v => ({ url: `/vod/${v.id}`, name: v.title || 'VOD', by: v.display_name || v.username, meta: _fmtDur(v.duration_seconds || v.duration), desc: v.ai_overview_short }));
ddiff --git a/server/streaming/routes.js b/server/streaming/routes.js
iindex 2c5d8569..41cbbac3 100644
---- a/server/streaming/routes.js
++++ b/server/streaming/routes.js
@@@ -464,10 +464,17 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {
delete publicChannel.vod_recording_enabled;
delete publicChannel.force_vod_recording_disabled;
-- // Counts for tab badges. Owner/mods see hidden ones too.
++ // Counts for tab badges. Owner/mods see hidden ones too. Pastes are counted by
++ // OpenVibe.Community and taken clips by OpenVibe.Media (media-proxy/lookups.js).
let pasteTotal = 0, clipsTakenTotal = 0, aiEventTotal = 0;
-- try { pasteTotal = pollOnly ? 0 : db.countUserPastesForChannel(channel.user_id, { includeHidden: canSeeHidden }); } catch { /* */ }
-- try { clipsTakenTotal = pollOnly ? 0 : db.countClipsTakenByUser(channel.user_id, { includePrivate: canSeeHidden }); } catch { /* */ }
++ if (!pollOnly) {
++ const lookups = require('../media-proxy/lookups');
++ const owner = db.getUserById(channel.user_id);
++ [pasteTotal, clipsTakenTotal] = await Promise.all([
++ lookups.countUserPastes(owner, { hidden: canSeeHidden ? (isOwner ? 'owner' : 'staff') : false }),
++ lookups.countClipsTaken(channel.user_id, { includePrivate: canSeeHidden }),
++ ]);
++ }
try { aiEventTotal = pollOnly ? 0 : db.countStreamMemoriesByUser(channel.user_id); } catch { /* */ }
// Tab-hide flags: when a streamer defaults ALL their slots' VODs (or clips) to private,
@@@ -537,7 +544,7 @@ router.get('/channel/:username/popular', async (req, res) => {
try { const r = await media.listVods({ user_id: user.id, order: 'views', limit: 12 }); vods = r?.vods || (Array.isArray(r) ? r : []); } catch { /* */ }
try { const r = await media.listClips({ channel_user_id: user.id, order: 'views', limit: 12 }); clips = r?.clips || (Array.isArray(r) ? r : []); } catch { /* */ }
let ranges = null;
-- try { ranges = db.getTopContentRanges(user.id); } catch { /* */ }
++ try { ranges = await require('../media-proxy/lookups').topContentRanges(user); } catch { /* */ }
res.json({
// Singular kept for back-compat; arrays let the offline screen fill the space.
vod: vods[0] || null,
@@@ -1131,10 +1138,11 @@ router.get('/mine', requireAuth, (req, res) => {
});
// ── List Recently Ended Streams ──────────────────────────────
--router.get('/recent', (req, res) => {
++router.get('/recent', async (req, res) => {
try {
const limit = Math.min(parseInt(req.query.limit || '20'), 100);
-- const streams = db.getRecentStreams(limit);
++ // Each stream's public VOD (id, thumbnail, duration) comes from OpenVibe.Media.
++ const streams = await require('../media-proxy/lookups').attachPublicVods(db.getRecentStreams(limit));
const enriched = streams.map(s => {
const channel = db.getChannelByUserId(s.user_id);
return publicStream({ ...s, channel: channel || null });
@@@ -1455,11 +1463,18 @@ router.get('/channel/:username/resolve/:ref', optionalAuth, (req, res) => {
// ── Managed Stream CRUD ──────────────────────────────────────
// Get past stream sessions for a managed stream (workspace history panel)
--router.get('/managed/:managedStreamId/history', requireAuth, (req, res) => {
++router.get('/managed/:managedStreamId/history', requireAuth, async (req, res) => {
const managedStreamId = parseInt(req.params.managedStreamId);
if (!Number.isFinite(managedStreamId)) return res.status(400).json({ error: 'Invalid ID' });
try {
const sessions = db.getStreamHistoryByManagedStream(managedStreamId, req.user.id);
++ // Each session's VOD comes from OpenVibe.Media; only asked when the slot has sessions of this user.
++ const vods = sessions.length ? await require('../media-proxy/lookups').vodsForManagedStream(managedStreamId) : new Map();
++ for (const s of sessions) {
++ const v = vods.get(Number(s.id));
++ s.vod_id = v ? v.id : null;
++ s.vod_file_path = v ? (v.file_path || null) : null;
++ }
res.json({ sessions });
} catch (err) {
console.error('[ManagedStreams] History error:', err.message);
@@@ -1544,7 +1559,8 @@ router.get('/setup-progress', requireAuth, async (req, res) => {
const mods = safe(() => channel.id ? db.get('SELECT COUNT(*) AS n FROM channel_moderators WHERE channel_id = ?', [channel.id]).n : 0, 0);
const controls = safe(() => db.get('SELECT COUNT(*) AS n FROM control_configs WHERE user_id = ?', [uid]).n, 0);
const aibot = safe(() => db.get('SELECT COUNT(*) AS n FROM channel_ai_bots WHERE channel_user_id = ?', [uid]).n, 0);
-- const pastes = safe(() => db.get('SELECT COUNT(*) AS n FROM pastes WHERE user_id = ?', [uid]).n, 0);
++ // Pastes live in OpenVibe.Community: the person's own count, unlisted and private included.
++ const pastes = await require('../media-proxy/lookups').countUserPastes(user && user.id ? user : null, { hidden: 'owner' });
const requests = safe(() => !!db.get('SELECT 1 FROM media_request_settings WHERE user_id = ? LIMIT 1', [uid]), false);
const modRules = safe(() => channel.id ? !!db.get('SELECT 1 FROM channel_moderation_settings WHERE channel_id = ? LIMIT 1', [channel.id]) : false, false);
const tasks = [
ddiff --git a/test/arena.test.js b/test/arena.test.js
iindex f648d102..3e0dc8a7 100644
---- a/test/arena.test.js
++++ b/test/arena.test.js
@@@ -61,7 +61,6 @@ const sIdle = stream(idle, 50, 120); // too old → not on the roster even with
stream(mute, 50, 1); // streams a lot, never transcribed → not on the roster
// Transcript for alpha: 40 lines over the 6 h stream, some hype, all linked to a VOD.
--db.run(`INSERT INTO vods (id, user_id, stream_id, title, file_path, is_public, created_at) VALUES (901, ?, ?, 'alpha vod', '/x/alpha.webm', 1, datetime('now', '-2 days'))`, [u1, s1]);
const lines = [];
for (let i = 0; i < 40; i++) {
const t = i % 4 === 0 ? `Let's go chat, that was insane, no way we just did that number ${i}!` : `Okay so here is the current situation with the setup number ${i}, we are rebuilding it live.`;
ddiff --git a/test/media-lookups.test.js b/test/media-lookups.test.js
nnew file mode 100644
iindex 00000000..13507934
---- /dev/null
++++ b/test/media-lookups.test.js
@@@ -0,0 +1,327 @@
++/**
++ * The readers that used to query Live's frozen vods/clips/pastes tables now ask OpenVibe.Media and
++ * OpenVibe.Community (register C-73 step 2, server/media-proxy/lookups.js). This runs them against
++ * in-process stand-ins for both and checks what they ask for (hidden items only on owner/staff paths),
++ * what they answer, and that the routes built on them keep their response shapes.
++ *
++ * node test/media-lookups.test.js
++ */
++'use strict';
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++
++const tmp = path.join(os.tmpdir(), `ov-media-lookups-${process.pid}.db`);
++process.env.DB_PATH = tmp;
++process.env.NODE_ENV = 'test';
++const quiet = console.log;
++console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
++console.warn = () => {};
++
++const db = require('../server/db/database');
++db.initDb();
++const raw = db.getDb();
++
++// ── Sign-in stub (before any router captures the middleware) ──
++const auth = require('../server/auth/auth');
++const signIn = (req) => {
++ const id = Number(req.headers['x-test-user'] || 0);
++ const u = id ? db.getUserById(id) : null;
++ if (u) { req.user = u; req.authSource = 'network'; }
++ return u;
++};
++auth.requireAuth = (req, res, next) => (signIn(req) ? next() : res.status(401).json({ error: 'Authentication required' }));
++auth.optionalAuth = (req, res, next) => { signIn(req); next(); };
++
++const addUser = (id, username, role) => raw.prepare(
++ `INSERT INTO users (id, username, display_name, email, password_hash, role, created_at)
++ VALUES (?, ?, ?, ?, 'x', ?, '2025-01-01 00:00:00')`).run(id, username, username.toUpperCase(), `${username}@x`, role);
++addUser(1, 'admin', 'admin');
++addUser(3, 'alice', 'streamer');
++addUser(7, 'mallory', 'user');
++db.ensureChannel(3);
++const chanA = db.getChannelByUserId(3);
++raw.prepare("INSERT INTO managed_streams (user_id, channel_id, slug, title, protocol, stream_key) VALUES (3, ?, 'main', 'Main', 'rtmp', ?)").run(chanA.id, 'k'.repeat(40));
++const msA = raw.prepare('SELECT id FROM managed_streams WHERE user_id = 3').get().id;
++const mkStream = (title) => {
++ const id = Number(db.createStream({ user_id: 3, channel_id: chanA.id, managed_stream_id: msA, title, protocol: 'rtmp' }).lastInsertRowid);
++ db.endStream(id);
++ return id;
++};
++const s1 = mkStream('first'), s2 = mkStream('second');
++
++// ── OpenVibe.Media stand-in (the media-client functions lookups.js calls) ──
++const media = require('../server/media-client');
++const asked = [];
++const day = (n) => new Date(Date.now() - n * 86400_000).toISOString().replace('T', ' ').slice(0, 19);
++let VODS = [], CLIPS = [];
++const matches = (row, q) => ['user_id', 'stream_id', 'vod_id', 'managed_stream_id', 'channel_user_id']
++ .every((k) => q[k] == null || String(row[k]) === String(q[k]));
++const visible = (row, q) => q.include_private || row.visibility === 'public';
++media.listVods = async (q = {}) => {
++ asked.push({ kind: 'vods', ...q });
++ let rows = VODS.filter((v) => matches(v, q) && visible(v, q) && (q.include_recording || !v.is_recording));
++ if (q.order === 'views') rows = rows.sort((a, b) => b.view_count - a.view_count);
++ return { vods: rows.slice(q.offset || 0, (q.offset || 0) + (q.limit || 50)), total: rows.length };
++};
++media.listClips = async (q = {}) => {
++ asked.push({ kind: 'clips', ...q });
++ let rows = CLIPS.filter((c) => matches(c, q) && visible(c, q) && (!q.hide_self || c.channel_user_id !== c.user_id));
++ if (q.order === 'views') rows = rows.sort((a, b) => b.view_count - a.view_count);
++ return { clips: rows.slice(0, q.limit || 50), total: rows.length };
++};
++media.request = async (method, p) => {
++ if (p === '/stats') return { vods: 11, clips: 22, pastes: 33, pasteImages: 3, pasteText: 30, durationSeconds: 7200, recent: { vods: { d: 1, w: 2, m: 3 } } };
++ throw new media.MediaApiError('unexpected', 500);
++};
++media.listPastes = async (q = {}) => { asked.push({ kind: 'media-pastes', ...q }); return { pastes: [{ slug: 'm1', visibility: 'public' }, { slug: 'm2', visibility: 'private' }], total: 2 }; };
++
++// ── OpenVibe.Community stand-in (pastes-client.request) ──
++const pastesClient = require('../server/pastes-client');
++const PASTES = [
++ { id: 1, slug: 'pub', type: 'paste', title: 'Public', visibility: 'public', ai_summary: 'about cats', created_at: day(1) },
++ { id: 2, slug: 'hid', type: 'paste', title: 'Hidden', visibility: 'private', ai_summary: 'secret', created_at: day(2) },
++ { id: 3, slug: 'ava', type: 'screenshot', title: 'Avatar upload', visibility: 'unlisted', screenshot_url: 'https://media.test/f/a.png', metadata: '{"kind":"avatar"}', created_at: day(3) },
++ { id: 4, slug: 'shot', type: 'screenshot', title: 'Shot', visibility: 'public', screenshot_url: 'https://media.test/f/s.png', metadata: null, created_at: day(4) },
++];
++const communityCalls = [];
++pastesClient.request = async (method, p, { query = {}, act = {} } = {}) => {
++ communityCalls.push({ method, path: p, query, act });
++ if (p === '/admin/stats') return { stats: { total: 44, textPastes: 40, screenshots: 4 } };
++ if (query.username !== 'alice') return { pastes: [], total: 0 };
++ const hidden = query.include_unlisted && (act.staff || act.liveUserId === 3);
++ const rows = PASTES.filter((x) => (hidden || x.visibility === 'public') && (!query.type || x.type === query.type));
++ return { pastes: rows.slice(0, query.limit || 50), total: rows.length };
++};
++
++const lookups = require('../server/media-proxy/lookups');
++
++const express = require('express');
++const app = express();
++app.use(express.json());
++app.use('/api/streams', require('../server/streaming/routes'));
++app.use('/api/auth', require('../server/auth/routes'));
++app.use('/api/chat-ai', require('../server/ai/chat-ai-routes'));
++app.use('/api/admin', require('../server/admin/routes'));
++const server = http.createServer(app).listen(0);
++function call(method, p, user) {
++ return new Promise((resolve, reject) => {
++ const headers = { 'content-type': 'application/json' };
++ if (user) headers['x-test-user'] = String(user);
++ const req = http.request({ port: server.address().port, path: p, method, headers }, (res) => {
++ let text = '';
++ res.on('data', (c) => { text += c; });
++ res.on('end', () => { let json = null; try { json = JSON.parse(text); } catch { /* */ } resolve({ status: res.statusCode, json, text }); });
++ });
++ req.on('error', reject);
++ req.end();
++ });
++}
++
++let failures = 0;
++async function check(name, fn) {
++ try { asked.length = 0; communityCalls.length = 0; lookups._resetCaches(); await fn(); quiet(' ✓', name); }
++ catch (e) { failures++; quiet(' ✗', name, '\n ', e.stack.split('\n').slice(0, 3).join('\n ')); }
++}
++
++(async () => {
++ await new Promise((r) => server.once('listening', r));
++
++ VODS = [
++ { id: 10, user_id: 3, stream_id: s1, managed_stream_id: msA, visibility: 'public', is_public: true, view_count: 5, created_at: day(2), thumbnail_url: 'https://media.test/t/10.jpg', duration_seconds: 60, file_path: 'a.webm' },
++ { id: 11, user_id: 3, stream_id: s1, managed_stream_id: msA, visibility: 'private', is_public: false, view_count: 99, created_at: day(1), thumbnail_url: 'https://media.test/t/11.jpg' },
++ { id: 12, user_id: 3, stream_id: s2, managed_stream_id: msA, visibility: 'unlisted', is_public: false, view_count: 1, created_at: day(40) },
++ { id: 13, user_id: 3, stream_id: s2, managed_stream_id: msA, visibility: 'public', is_public: true, view_count: 50, created_at: day(40), thumbnail_url: 'https://media.test/t/13.jpg' },
++ ];
++ CLIPS = [
++ { id: 20, user_id: 7, channel_user_id: 3, stream_id: s1, vod_id: 10, visibility: 'public', start_time: 42.7, view_count: 3, created_at: day(1) },
++ { id: 21, user_id: 3, channel_user_id: 3, stream_id: s2, vod_id: 13, visibility: 'private', start_time: 0, view_count: 0, created_at: day(1) },
++ { id: 22, user_id: 3, channel_user_id: 9, stream_id: 999, vod_id: 10, visibility: 'public', start_time: 7.2, view_count: 9, created_at: day(20) },
++ { id: 23, user_id: 3, channel_user_id: 9, stream_id: 998, visibility: 'private', start_time: 1, view_count: 0, created_at: day(2) },
++ ];
++
++ await check('stream VODs: public only, newest per stream; hidden ones never asked for on public paths', async () => {
++ const m = await lookups.publicVodIdsByStream(3);
++ assert.strictEqual(m.complete, true);
++ assert.deepStrictEqual([...m.byStream.entries()].sort(), [[s1, 10], [s2, 13]]);
++ assert.ok(asked.every((q) => !q.include_private));
++ const saved = media.listVods;
++ media.listVods = async () => { throw new media.MediaApiError('down', 0); };
++ try { assert.strictEqual((await lookups.publicVodIdsByStream(3)).complete, false, 'Media down is reported'); }
++ finally { media.listVods = saved; }
++ });
++
++ await check('GET /api/streams/recent keeps its VOD fields, filled from Media (public VOD only)', async () => {
++ const r = await call('GET', '/api/streams/recent');
++ assert.strictEqual(r.status, 200);
++ const row = r.json.streams.find((s) => s.user_id === 3);
++ assert.strictEqual(row.id, s2, 'the latest ended session');
++ assert.strictEqual(row.vod_id, 13);
++ assert.strictEqual(row.vod_is_public, 1);
++ assert.strictEqual(row.vod_thumbnail_url, 'https://media.test/t/13.jpg');
++ assert.ok('vod_duration' in row);
++ });
++
++ await check('workspace history: each session gets its VOD (private included, owner only)', async () => {
++ const r = await call('GET', `/api/streams/managed/${msA}/history`, 3);
++ assert.strictEqual(r.status, 200);
++ const byId = Object.fromEntries(r.json.sessions.map((s) => [s.id, s]));
++ assert.strictEqual(byId[s1].vod_id, 11, 'newest VOD of the session, private included');
++ assert.strictEqual(byId[s2].vod_id, 13);
++ assert.ok('vod_file_path' in byId[s1]);
++ assert.ok(asked.some((q) => q.managed_stream_id === msA && q.include_private && q.include_recording));
++ assert.deepStrictEqual((await call('GET', `/api/streams/managed/${msA}/history`, 7)).json.sessions, [], 'someone else sees no sessions');
++ });
++
++ await check('clip start times: by stream and by VOD, any visibility, sorted, zero dropped', async () => {
++ assert.deepStrictEqual(await lookups.clipStartTimes(s1, 10), [7, 42]);
++ assert.ok(asked.every((q) => q.include_private), 'an internal signal: all clips');
++ });
++
++ await check('stream analytics: the clip count arrives from Media after the stream ends', async () => {
++ db.computeAndCacheStreamAnalytics(s1);
++ assert.strictEqual(db.getStreamAnalytics(s1).clips_created, 0, 'the synchronous pass keeps the last value');
++ await new Promise((r) => setTimeout(r, 30));
++ assert.strictEqual(db.getStreamAnalytics(s1).clips_created, 1, 'Media counted one clip of the stream');
++ });
++
++ await check('clips taken: counted like the tab lists them, private only when asked', async () => {
++ assert.strictEqual(await lookups.countClipsTaken(3), 1);
++ assert.strictEqual(await lookups.countClipsTaken(3, { includePrivate: true }), 2);
++ assert.ok(asked.every((q) => String(q.hide_self) === '1'));
++ });
++
++ await check('channel badges: pastes from Community, taken clips from Media; hidden ones for owner/staff only', async () => {
++ process.env.PASTES_AUTHORITY = 'community';
++ try {
++ const anon = await call('GET', '/api/streams/channel/alice');
++ assert.strictEqual(anon.status, 200, anon.text.slice(0, 200));
++ assert.strictEqual(anon.json.pasteTotal, 2);
++ assert.strictEqual(anon.json.clipsTakenTotal, 1);
++ lookups._resetCaches();
++ const owner = await call('GET', '/api/streams/channel/alice', 3);
++ assert.strictEqual(owner.json.pasteTotal, 4);
++ assert.strictEqual(owner.json.clipsTakenTotal, 2);
++ assert.ok(communityCalls.some((c) => c.act.liveUserId === 3 && c.query.include_unlisted === 1), 'the owner is asked as themselves');
++ lookups._resetCaches();
++ const staff = await call('GET', '/api/streams/channel/alice', 1);
++ assert.strictEqual(staff.json.pasteTotal, 4);
++ assert.ok(communityCalls.some((c) => c.act.staff && c.query.include_unlisted === 1), 'staff are vouched for with X-OV-Staff');
++ lookups._resetCaches();
++ communityCalls.length = 0;
++ await call('GET', '/api/streams/channel/alice', 7);
++ assert.ok(communityCalls.every((c) => !c.query.include_unlisted && !c.act.staff && !c.act.liveUserId), 'anyone else: public only');
++ const poll = await call('GET', '/api/streams/channel/alice?pollOnly=1');
++ assert.strictEqual(poll.json.pasteTotal, 0, 'the status poll asks nobody');
++ } finally { delete process.env.PASTES_AUTHORITY; }
++ });
++
++ await check('pastes while Media still holds them (PASTES_AUTHORITY unset): Media list, public filter', async () => {
++ const out = await lookups.userPastes(db.getUserById(3));
++ assert.deepStrictEqual(out.pastes.map((p) => p.slug), ['m1']);
++ assert.ok(asked.some((q) => q.kind === 'media-pastes' && q.user_id === 3 && q.include_unlisted === undefined));
++ });
++
++ await check('setup hub: the paste task counts the person\'s own pastes', async () => {
++ process.env.PASTES_AUTHORITY = 'community';
++ try {
++ const r = await call('GET', '/api/streams/setup-progress', 3);
++ assert.strictEqual(r.status, 200);
++ const task = r.json.tasks.find((t) => t.id === 'paste');
++ assert.strictEqual(task.count, 4);
++ assert.strictEqual(task.done, true);
++ } finally { delete process.env.PASTES_AUTHORITY; }
++ });
++
++ await check('avatar history: the avatar-tagged screenshots, from Community', async () => {
++ process.env.PASTES_AUTHORITY = 'community';
++ try {
++ raw.prepare("UPDATE users SET avatar_url = 'https://media.test/f/a.png' WHERE id = 3").run();
++ const r = await call('GET', '/api/auth/avatar/history', 3);
++ assert.strictEqual(r.status, 200, r.text);
++ assert.deepStrictEqual(r.json.avatars.map((a) => [a.slug, a.url, a.active]), [['ava', 'https://media.test/f/a.png', true]]);
++ assert.ok(communityCalls.every((c) => c.query.type === 'screenshot' && c.act.liveUserId === 3));
++ } finally { delete process.env.PASTES_AUTHORITY; }
++ });
++
++ await check('offline-screen ranges: most-viewed public VOD and clip per window', async () => {
++ const r = await call('GET', '/api/streams/channel/alice/popular');
++ assert.strictEqual(r.status, 200);
++ const { ranges } = r.json;
++ assert.strictEqual(ranges.week.vod.id, 10, 'this week: the private 11 is never a candidate');
++ assert.strictEqual(ranges.all.vod.id, 13, 'all time: 13 has the most views');
++ assert.strictEqual(ranges.month.vod.id, 10, 'the 40-day-old VOD is outside the month');
++ assert.strictEqual(ranges.week.clip.id, 20);
++ assert.strictEqual(ranges.all.vod.username, 'alice', "the streamer's name rides along");
++ });
++
++ await check('AI timeline: session VOD links from Media; not cached while Media is down', async () => {
++ db.addStreamMemory({ stream_id: s1, user_id: 3, offset_seconds: 5, description: 'a memory', tags: '[]' });
++ raw.prepare("UPDATE streams SET ai_overview = 'what happened' WHERE id IN (?, ?)").run(s1, s2);
++ const saved = media.listVods;
++ media.listVods = async () => { throw new media.MediaApiError('down', 0); };
++ try {
++ const r = await call('GET', '/api/chat-ai/timeline/alice');
++ assert.strictEqual(r.status, 200, r.text.slice(0, 200));
++ assert.ok(r.json.sessions.every((s) => s.vod_id === null));
++ assert.strictEqual(db.readStreamerAiTimelineCache(3), null, 'nothing cached');
++ } finally { media.listVods = saved; }
++ const r = await call('GET', '/api/chat-ai/timeline/alice');
++ const byId = Object.fromEntries(r.json.sessions.map((s) => [s.id, s]));
++ assert.strictEqual(byId[s1].vod_id, 10, 'the public VOD, never the private 11');
++ assert.strictEqual(byId[s2].vod_id, 13);
++ assert.ok(db.readStreamerAiTimelineCache(3), 'cached once Media answered');
++ });
++
++ await check('admin: VOD counts from Media; AI explorer pastes from Community and VODs/clips from Media', async () => {
++ process.env.PASTES_AUTHORITY = 'community';
++ try {
++ raw.prepare('INSERT INTO vod_ai_state (vod_id, ai_overview, ai_transcript_json) VALUES (11, ?, ?)').run('private overview', JSON.stringify([{ start: 0, text: 'hello' }, { start: 2, text: 'there' }]));
++ const st = await call('GET', '/api/admin/stats', 1);
++ assert.strictEqual(st.status, 200, st.text.slice(0, 200));
++ assert.deepStrictEqual(st.json.vods, { total: 4, public: 2 });
++ const ex = await call('GET', '/api/admin/ai/explorer/3', 1);
++ assert.strictEqual(ex.status, 200, ex.text.slice(0, 200));
++ assert.deepStrictEqual(ex.json.pastes.map((p) => p.slug), ['pub', 'hid', 'ava', 'shot'], 'staff see every visibility');
++ const v11 = ex.json.vods.find((v) => v.id === 11);
++ assert.strictEqual(v11.ai_overview, 'private overview');
++ assert.strictEqual(v11.ai_transcript, 'hello there');
++ assert.strictEqual(ex.json.counts.clips, 3);
++ assert.strictEqual((await call('GET', '/api/admin/stats', 7)).status, 403, 'still admin-only');
++ } finally { delete process.env.PASTES_AUTHORITY; }
++ });
++
++ await check('home stats: Live counts none of the archive itself; Media and Community fill it in', async () => {
++ const local = db._computeHomeStats();
++ for (const k of ['vods', 'clips', 'pastes', 'pasteImages', 'pasteText', 'streamHours']) assert.strictEqual(local[k], null, k);
++ assert.strictEqual(db.getHomeStatSeries('vods'), null, 'the vods series is Media\'s');
++ process.env.PASTES_AUTHORITY = 'community';
++ try {
++ const s = await lookups.withArchiveStats({ ...local });
++ assert.strictEqual(s.vods, 11);
++ assert.strictEqual(s.streamHours, 2);
++ assert.strictEqual(s.pastes, 44, 'Community owns the paste count');
++ assert.strictEqual(s.pasteImages, 4);
++ assert.strictEqual(s.recent.vods.w, 2);
++ } finally { delete process.env.PASTES_AUTHORITY; }
++ });
++
++ await check('streamer overview job: memories are the signal', async () => {
++ const due = db.getStreamersNeedingOverview({ limit: 10 }).map((r) => r.user_id);
++ assert.deepStrictEqual(due, [3], 'alice has a memory; nobody else has any signal');
++ });
++
++ await check('recently online: the slot thumbnail comes from Media (null in SQL)', async () => {
++ const rows = db.getRecentlyOnlineStreamers(10, 0);
++ const slots = JSON.parse(rows.find((r) => r.user_id === 3).managed_streams_json);
++ assert.ok(slots.length && slots.every((s) => 'vod_thumbnail' in s && s.vod_thumbnail === null));
++ });
++
++ server.close();
++ for (const ext of ['', '-wal', '-shm']) { try { fs.unlinkSync(tmp + ext); } catch { /* */ } }
++ if (failures) { quiet(`\n${failures} check(s) failed`); process.exit(1); }
++ quiet('media lookups: all checks passed');
++ process.exit(0);
++})().catch((err) => { quiet(err); server.close(); process.exit(1); });
ddiff --git a/test/media-privacy.test.js b/test/media-privacy.test.js
iindex 0fb4344d..515883a4 100644
---- a/test/media-privacy.test.js
++++ b/test/media-privacy.test.js
@@@ -6,7 +6,7 @@
* edit and thumbnail routes answered 403, which confirms the id; comments on private items were
* readable; the batch transcript endpoint returned any VOD's transcript; the server-rendered
* /vod/:id and /clip/:id pages put a private item's title and overview into shared HTML; and the
-- * recently-ended list carried a private legacy VOD's id and thumbnail. Every one of those now gives
++ * recently-ended list carried a private legacy VOD's id and thumbnail (it now asks Media, public only). Every one of those now gives
* the exact answer an unknown id gets.
*
* The real routers run on a temp database with Media stubbed in-process and sign-in stubbed by an
@@@ -247,19 +247,31 @@ async function check(name, fn) {
assert.ok(await seo._pageMeta('/clip/200'), 'public clips still render');
});
-- await check('recently-ended streams (legacy vods table): only a public VOD rides along', async () => {
++ await check('recently-ended streams: only a public VOD from Media rides along', async () => {
db.endStream(streamA);
-- // Test fixture rows in the temp database; server code never writes the frozen table.
-- const legacy = raw.prepare(`INSERT INTO vods (stream_id, user_id, title, file_path, is_public, visibility, thumbnail_url) VALUES (?, 3, ?, '/x', ?, ?, ?)`);
-- legacy.run(streamA, 'Secret legacy', 0, 'private', '/t/secret.jpg');
-- let row = db.getRecentStreams(10).find((s) => s.id === streamA);
-- assert.ok(row, 'the stream is listed');
-- assert.strictEqual(row.vod_id, null, 'no private VOD id');
-- assert.strictEqual(row.vod_thumbnail_url, null, 'no private VOD thumbnail');
-- raw.prepare('DELETE FROM vods').run();
-- legacy.run(streamA, 'Public legacy', 1, 'public', '/t/pub.jpg');
-- row = db.getRecentStreams(10).find((s) => s.id === streamA);
-- assert.ok(row.vod_id, 'a public VOD is still attached');
++ const lookups = require('../server/media-proxy/lookups');
++ const saved = media.listVods;
++ const asked = [];
++ let answer = [
++ { id: 101, stream_id: streamA, user_id: 3, visibility: 'private', is_public: 0, thumbnail_url: 'https://media.test/t/secret.jpg', duration_seconds: 9 },
++ { id: 103, stream_id: streamA, user_id: 3, visibility: 'unlisted', is_public: 0, thumbnail_url: 'https://media.test/t/unlisted.jpg', duration_seconds: 9 },
++ { id: 102, stream_id: streamA, user_id: 3, is_public: 0, thumbnail_url: 'https://media.test/t/legacy.jpg' }, // legacy private
++ ];
++ media.listVods = async (q) => { asked.push(q); return { vods: answer }; };
++ try {
++ let row = (await lookups.attachPublicVods(db.getRecentStreams(10))).find((s) => s.id === streamA);
++ assert.ok(row, 'the stream is listed');
++ assert.strictEqual(row.vod_id, null, 'no private or unlisted VOD id');
++ assert.strictEqual(row.vod_thumbnail_url, null, 'no private or unlisted VOD thumbnail');
++ assert.ok(asked.length && asked.every((q) => !q.include_private), 'never asks Media for hidden VODs');
++ lookups._resetCaches();
++ answer = [{ id: 100, stream_id: streamA, user_id: 3, visibility: 'public', is_public: 1, thumbnail_url: 'https://media.test/t/pub.jpg', duration_seconds: 42 }];
++ row = (await lookups.attachPublicVods(db.getRecentStreams(10))).find((s) => s.id === streamA);
++ assert.strictEqual(row.vod_id, 100, 'a public VOD is attached');
++ assert.strictEqual(row.vod_is_public, 1);
++ assert.strictEqual(row.vod_thumbnail_url, 'https://media.test/t/pub.jpg');
++ assert.strictEqual(row.vod_duration, 42);
++ } finally { media.listVods = saved; lookups._resetCaches(); }
});
server.close();