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
Changes
@@@ -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,@@@ -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;@@@ -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 clippinglet 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`;@@@ -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);@@@ -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.@@@ -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';@@@ -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 fallbackif (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}.` : ''}`,@@@ -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);@@@ -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_stateconst 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_colorFROM streams sJOIN (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_atJOIN 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 NULLORDER BY s.ended_at DESCLIMIT ?@@@ -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.categoryFROM streams s-- LEFT JOIN vods v ON v.stream_id = s.idWHERE s.managed_stream_id = ? AND s.user_id = ?ORDER BY s.started_at DESCLIMIT ?@@@ -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_countFROM streams sWHERE 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_idFROM users uLEFT 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 NULLOR (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 ms2WHERE 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.Mediastreamers: 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 streamconst 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 helpersgetRecentlyOnlineStreamers, countRecentlyOnlineStreamers,-- getRecentVods, countRecentVods, getHomeStats,-- // Filtered VODs/clips-- getVodsByUserFiltered, countVodsByUserFiltered, getPopularVodForUser, getPopularClipForUser, getTopContentRanges,-- getClipsOfUserStreamsPaginated, countClipsOfUserStreams,-- getClipsByUserPaginated,++ getHomeStats,// ChannelsgetChannelByUserId, 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,// ControlsgetStreamControls, createControl, bindStreamToControlConfig,// ONVIF Cameras@@@ -8416,14 +7589,7 @@ module.exports = {getChannelModerators, getChannelsByModerator,// Channel Moderation SettingsgetChannelModerationSettings, 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 MappingsgetOrCreateAnonNum, getAnonFirstSeen, loadAnonMappings,// Stream first chats (welcome messages)@@@ -8445,7 +7611,7 @@ module.exports = {getLatestIpForUser, getLatestIpForAnon, getIpLog, banAllAccountsOnIp,// Stream AnalyticsinsertViewerSnapshot, getViewerSnapshots, computeAndCacheStreamAnalytics,-- getStreamAnalytics, getChannelAnalyticsSummary, getRecentChatActivity,++ getStreamAnalytics, setStreamAnalyticsClipCount, getChannelAnalyticsSummary, getRecentChatActivity,// User PreferencesgetUserPreferences, saveUserPreferences,// Chat Log Management@@@ -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.nnew file mode 100644@@@ -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,++};@@@ -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 = 20headers['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;@@@ -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 }));@@@ -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 = [@@@ -61,7 +61,6 @@ const sIdle = stream(idle, 50, 120); // too old → not on the roster even withstream(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.`;nnew file mode 100644@@@ -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); });@@@ -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();
@@ -64,8 +64,10 @@ router.get('/diagnostics', (req, res) => {
@@ -64,8 +64,10 @@ router.get('/diagnostics', (req, res) => {
});
});
// ── Dashboard Stats ──────────────────────────────────────────
// ── Dashboard Stats ──────────────────────────────────────────
-router.get('/stats', (req, res) => {
+router.get('/stats', async (req, res) => {
try {
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 = {
const stats = {
users: {
users: {
total: db.get('SELECT COUNT(*) as c FROM users').c,
total: db.get('SELECT COUNT(*) as c FROM users').c,
@@ -88,8 +90,8 @@ router.get('/stats', (req, res) => {
@@ -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,
totalDonated: db.get("SELECT COALESCE(SUM(amount), 0) as c FROM transactions WHERE type = 'donation'").c,
},
},
vods: {
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: {
chat: {
totalMessages: db.get('SELECT COUNT(*) as c FROM chat_messages').c,
totalMessages: db.get('SELECT COUNT(*) as c FROM chat_messages').c,
@@ -574,7 +576,8 @@ router.get('/ai/status', async (req, res) => {
@@ -574,7 +576,8 @@ router.get('/ai/status', async (req, res) => {
});
});
// Browse a streamer's AI-relevant data: memories, AI-analyzed pastes, VODs, overview.
// 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 {
try {
const userId = parseInt(req.params.userId, 10);
const userId = parseInt(req.params.userId, 10);
const user = db.getUserById(userId);
const user = db.getUserById(userId);
@@ -583,13 +586,18 @@ router.get('/ai/explorer/:userId', (req, res) => {
@@ -583,13 +586,18 @@ router.get('/ai/explorer/:userId', (req, res) => {
id: m.id, stream_id: m.stream_id, description: m.description,
id: m.id, stream_id: m.stream_id, description: m.description,
tags: m.tags, created_at: m.created_at, thumbnail_url: m.thumbnail_url,
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,
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,
duration_seconds: v.duration_seconds, ai_overview: v.ai_overview || null,
ai_transcript: (v.ai_transcript && v.ai_transcript.trim()) ? v.ai_transcript : 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,
id: c.id, title: c.title, created_at: c.created_at,
duration_seconds: c.duration_seconds, ai_overview: c.ai_overview || null,
duration_seconds: c.duration_seconds, ai_overview: c.ai_overview || null,
ai_transcript: (c.ai_transcript && c.ai_transcript.trim()) ? c.ai_transcript : null,
ai_transcript: (c.ai_transcript && c.ai_transcript.trim()) ? c.ai_transcript : null,
@@ -243,10 +243,12 @@ async function generateStreamerOverview(userId) {
@@ -243,10 +243,12 @@ async function generateStreamerOverview(userId) {
const memories = (db.getStreamMemoriesByUser ? db.getStreamMemoriesByUser(userId, 60) : []) || [];
const memories = (db.getStreamMemoriesByUser ? db.getStreamMemoriesByUser(userId, 60) : []) || [];
const memLines = memories.slice(0, 40).map(m => `- ${m.description}`).filter(l => l.length > 2);
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 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}`);
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;
if (!memLines.length && !vodLines.length && !pasteLines.length) return null;
@@ -136,10 +136,11 @@ function _sample(arr, max) {
@@ -136,10 +136,11 @@ function _sample(arr, max) {
for (let i = 0; i < max; i++) out.push(arr[Math.floor(i * step)]);
for (let i = 0; i < max; i++) out.push(arr[Math.floor(i * step)]);
return out;
return out;
}
}
-function _momentContext(streamId, vodId) {
+async function _momentContext(streamId, vodId) {
const memories = _sample((db.getStreamMemories(streamId) || []).filter(m => m.description), 45);
const memories = _sample((db.getStreamMemories(streamId) || []).filter(m => m.description), 45);
const transcript = _sample(db.getStreamTranscriptSegments(streamId) || [], 60);
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) || [];
const spikes = db.getChatSpikeOffsets(streamId, 30, 8) || [];
// Non-speech sounds are strong moment candidates — an explosion or a burst of
// Non-speech sounds are strong moment candidates — an explosion or a burst of
// laughter marks a highlight as reliably as anything said out loud.
// laughter marks a highlight as reliably as anything said out loud.
@@ -163,7 +164,7 @@ const FLAVOR_HINT = {
@@ -163,7 +164,7 @@ const FLAVOR_HINT = {
};
};
async function _findBestMoment(vod, { flavor = 'paste', avoid = [] } = {}) {
async function _findBestMoment(vod, { flavor = 'paste', avoid = [] } = {}) {
const dur = Math.floor(vod.duration || 0);
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 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);
const farFromUsed = (t) => avoidList.every(a => Math.abs(a - t) >= registry.GAP_SEC);
if (!ctx.memories.length && !ctx.transcript.length) return null;
if (!ctx.memories.length && !ctx.transcript.length) return null;
@@ -262,7 +263,7 @@ function _sig(desc) {
@@ -262,7 +263,7 @@ function _sig(desc) {
function _dedupePastes() { /* moved to Media — sig-dedup happens pre-post */ }
function _dedupePastes() { /* moved to Media — sig-dedup happens pre-post */ }
// Build the moment-ranking VOD pool: Media list (most-viewed public VODs) joined with
// 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) {
async function _momentPool(limit) {
try {
try {
const r = await media.listVods({ limit, order: 'views' });
const r = await media.listVods({ limit, order: 'views' });
@@ -288,8 +289,8 @@ async function _momentPool(limit) {
@@ -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 = {}) {
async function tick(opts = {}) {
@@ -366,23 +367,17 @@ async function tick(opts = {}) {
@@ -366,23 +367,17 @@ async function tick(opts = {}) {
const slug = _slug();
const slug = _slug();
// Extract the real frame at this moment so the paste is a true IMAGE paste.
// 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 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}`;
let img = `/api/thumbnails/generate/vod/${v.vod_id}`;
try {
try {
let source = null;
let source = null;
try {
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;
momentSource = source || null;
if (thumb && source && thumb.extractFrameToFile) {
if (thumb && source && thumb.extractFrameToFile) {
const fname = `ai-moment-vod${v.vod_id}-${offset}.jpg`;
const fname = `ai-moment-vod${v.vod_id}-${offset}.jpg`;
@@ -183,8 +183,8 @@ function _backfillDue() {
@@ -183,8 +183,8 @@ function _backfillDue() {
catch { return true; }
catch { return true; }
}
}
// Historical-backfill candidate pool: most-viewed public VODs from OpenVibe.Media
// 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) {
async function _backfillPool(limit) {
try {
try {
const r = await media.listVods({ limit: limit * 2, order: 'views' });
const r = await media.listVods({ limit: limit * 2, order: 'views' });
@@ -208,17 +208,13 @@ async function _backfillPool(limit) {
@@ -208,17 +208,13 @@ async function _backfillPool(limit) {
view_count: Number(v.view_count) || 0,
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) {
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 {
try {
const meta = await media.getVod(vodId);
const meta = await media.getVod(vodId);
if (meta && meta.playback_url) return media.publicUrl(meta.playback_url);
if (meta && meta.playback_url) return media.publicUrl(meta.playback_url);
@@ -171,13 +171,19 @@ function _ensureSessionTitles(userId) {
@@ -171,13 +171,19 @@ function _ensureSessionTitles(userId) {
// Full AI timeline for a streamer's channel page (streamer overview + every session's AI
// 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),
// 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.
// 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 {
try {
const uname = String(req.params.username || '').trim();
const uname = String(req.params.username || '').trim();
const user = db.getUserByUsername ? db.getUserByUsername(uname) : null;
const user = db.getUserByUsername ? db.getUserByUsername(uname) : null;
if (!user) return res.status(404).json({ error: 'Channel not found' });
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 allSessions = timeline.sessions || [];
const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
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));
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) => {
@@ -338,16 +344,7 @@ router.get('/transcript/:streamId', async (req, res) => {
const out = await media.listVods({ stream_id: sid, limit: 1 });
const out = await media.listVods({ stream_id: sid, limit: 1 });
const rows = out?.vods || (Array.isArray(out) ? out : []);
const rows = out?.vods || (Array.isArray(out) ? out : []);
vodId = rows[0] ? rows[0].id : null;
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
// 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
// "what was heard" as well as "what was said". Empty for streams captured before
// the timeline existed, which the frontend treats as speech-only.
// the timeline existed, which the frontend treats as speech-only.
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
* Cadence (enforced in SQL by getStreamersNeedingOverview):
* Cadence (enforced in SQL by getStreamersNeedingOverview):
* - A "decent" overview (>= DECENT_LEN chars) is refreshed at most once per 12h.
* - A "decent" overview (>= DECENT_LEN chars) is refreshed at most once per 12h.
* - A sparse/missing overview is retried hourly until it fills out.
* - 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.
* entirely until AI is enabled in openvibe.network/admin → AI.
*/
*/
'use strict';
'use strict';
@@ -399,7 +399,7 @@ function imageUrlFor(row) {
@@ -399,7 +399,7 @@ function imageUrlFor(row) {
const base = path.basename(row.image_path);
const base = path.basename(row.image_path);
return fs.existsSync(path.join(ARENA_DIR, base)) ? `/data/arena/${base}` : null;
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
* 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
* 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
@@ -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.
* stream — their setup, their lighting, their gear, their face — not a generic hero.
*/
*/
const REF_MAX = 3;
const REF_MAX = 3;
-function referenceImagesFor(userId) {
+async function referenceImagesFor(userId) {
const out = [];
const out = [];
const push = (p) => { if (p && !out.includes(p) && out.length < REF_MAX) out.push(p); };
const push = (p) => { if (p && !out.includes(p) && out.length < REF_MAX) out.push(p); };
try {
try {
@@ -433,7 +433,16 @@ function referenceImagesFor(userId) {
@@ -433,7 +433,16 @@ function referenceImagesFor(userId) {
// Media-hosted thumbnails of their streams (what prod actually has: https://openvibe.media/t/vod-….jpg).
// 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 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 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;
return out;
}
}
async function loadImageBuffer(src) {
async function loadImageBuffer(src) {
@@ -455,14 +464,15 @@ async function generateImage(userId, { force = false } = {}) {
@@ -455,14 +464,15 @@ async function generateImage(userId, { force = false } = {}) {
const persona = parseJson(row?.persona_json) || (entry ? fallbackPersona(entry) : null);
const persona = parseJson(row?.persona_json) || (entry ? fallbackPersona(entry) : null);
if (!persona) return null;
if (!persona) return null;
let scene = '';
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 = ''; } }
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 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(', ') : '';
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
// 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.
// have frames; the text-only generation is the fallback for streamers with no frames yet.
const refs = [];
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 = [
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}.`,
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}.` : ''}`,
`Class: ${persona.class}. Element: ${persona.element}. Signature move: ${persona.signature_move?.name} (${persona.signature_move?.description}).${cs ? ` Their stats: ${cs}.` : ''}`,
@@ -242,21 +242,24 @@ router.post('/avatar', requireAuth, avatarUpload.single('avatar'), async (req, r
@@ -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 {
try {
const me = db.getUserById(req.user.id);
const me = db.getUserById(req.user.id);
const activePasteId = me?.avatar_paste_id || null;
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 });
res.json({ avatars: rows });
} catch (err) {
} catch (err) {
console.error('[Auth] Avatar history error:', err.message);
console.error('[Auth] Avatar history error:', err.message);
@@ -293,11 +293,11 @@ function initDb() {
@@ -293,11 +293,11 @@ function initDb() {
}
}
} catch (e) { console.warn('[DB] ai-overview column migration:', e.message); }
} 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 {
try {
for (const t of ['vod_ai_state', 'clip_ai_state']) {
for (const t of ['vod_ai_state', 'clip_ai_state']) {
const cols = database.prepare(`PRAGMA table_info(${t})`).all().map(c => c.name);
const cols = database.prepare(`PRAGMA table_info(${t})`).all().map(c => c.name);
@@ -1454,8 +1454,7 @@ function initDb() {
@@ -1454,8 +1454,7 @@ function initDb() {
try {
try {
for (const [t, col, scol] of [
for (const [t, col, scol] of [
['streams', 'ai_overview', 'ai_overview_short'],
['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'],
['streamer_overviews', 'overview', 'overview_short'],
]) {
]) {
const cols = database.prepare(`PRAGMA table_info(${t})`).all().map(c => c.name);
const cols = database.prepare(`PRAGMA table_info(${t})`).all().map(c => c.name);
@@ -1501,43 +1500,16 @@ function initDb() {
@@ -1501,43 +1500,16 @@ function initDb() {
// Timestamped transcript segments (JSON) — contextual data for the AI system +
// Timestamped transcript segments (JSON) — contextual data for the AI system +
// clickable timestamps in the VOD/clip transcript UI.
// clickable timestamps in the VOD/clip transcript UI.
try {
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);
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`);
if (!cols.includes(col)) database.exec(`ALTER TABLE ${t} ADD COLUMN ${col} TEXT`);
}
}
} catch (e) { console.warn('[DB] transcript-json migration:', e.message); }
} 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
// Donation goals: optional media (image/video → optimized webm/webp), a 1-hour
// "reached" celebration window (reached_at), and explicit ordering.
// "reached" celebration window (reached_at), and explicit ordering.
@@ -2187,11 +2159,7 @@ function initDb() {
@@ -2187,11 +2159,7 @@ function initDb() {
// Chat history: filter by channel_user_id / stream_id, ORDER BY timestamp DESC.
// 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_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)');
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); }
} 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
// Second pass of hot-path indexes, found by tracing what the home page and the always-on
@@ -2218,9 +2186,6 @@ function initDb() {
@@ -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_anon_ip_created ON anon_ip_mappings(created_at)',
'CREATE INDEX IF NOT EXISTS idx_goals_active ON donation_goals(is_active)',
'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).
// 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)',
'CREATE INDEX IF NOT EXISTS idx_viewer_samples_at ON viewer_samples(sampled_at)',
// getRecentlyOnlineStreamers() correlates streams by managed_stream_id inside a
// 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
// json_group_array, and there was no index on that column at all — so the subquery scanned
@@ -2824,11 +2789,11 @@ function getLiveStreams() {
@@ -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) {
function getRecentStreams(limit = 20) {
return all(`
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
FROM streams s
JOIN (
JOIN (
SELECT user_id, MAX(ended_at) AS latest_ended_at
SELECT user_id, MAX(ended_at) AS latest_ended_at
@@ -2837,9 +2802,6 @@ function getRecentStreams(limit = 20) {
@@ -2837,9 +2802,6 @@ function getRecentStreams(limit = 20) {
GROUP BY user_id
GROUP BY user_id
) latest ON latest.user_id = s.user_id AND latest.latest_ended_at = s.ended_at
) 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
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
WHERE s.is_live = 0 AND s.ended_at IS NOT NULL
ORDER BY s.ended_at DESC
ORDER BY s.ended_at DESC
LIMIT ?
LIMIT ?
@@ -2922,14 +2884,14 @@ function getStreamsByUserId(userId, limit = 50) {
@@ -2922,14 +2884,14 @@ function getStreamsByUserId(userId, limit = 50) {
`, [userId, limit]);
`, [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) {
function getStreamHistoryByManagedStream(managedStreamId, userId, limit = 20) {
return all(`
return all(`
SELECT s.id, s.title, s.started_at, s.ended_at, s.is_live,
SELECT s.id, s.title, s.started_at, s.ended_at, s.is_live,
s.peak_viewers, s.viewer_count, s.duration_seconds,
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
FROM streams s
- LEFT JOIN vods v ON v.stream_id = s.id
WHERE s.managed_stream_id = ? AND s.user_id = ?
WHERE s.managed_stream_id = ? AND s.user_id = ?
ORDER BY s.started_at DESC
ORDER BY s.started_at DESC
LIMIT ?
LIMIT ?
@@ -3202,9 +3164,6 @@ function bumpClipTranscriptAttempt(id) {
@@ -3202,9 +3164,6 @@ function bumpClipTranscriptAttempt(id) {
const r = get('SELECT transcript_attempts AS a FROM clip_ai_state WHERE clip_id = ?', [id]);
const r = get('SELECT transcript_attempts AS a FROM clip_ai_state WHERE clip_id = ?', [id]);
return r ? r.a : 0;
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.
// 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
// ── One-time cleanup: earlier builds stored raw (often malformed) model JSON like
// `{"description":"…","tags":[…]}` directly into text columns. Extract just the
// `{"description":"…","tags":[…]}` directly into text columns. Extract just the
@@ -3432,35 +3391,6 @@ function getStreamTranscriptSegments(streamId) {
@@ -3432,35 +3391,6 @@ function getStreamTranscriptSegments(streamId) {
return out;
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.
// 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.
// countAutoClipsSince() removed — the media subsystem (vods/clips/pastes writes) moved to OpenVibe.Media.
@@ -3493,36 +3423,6 @@ function getRecentChatText(streamId, sinceSec = 120, limit = 40) {
@@ -3493,36 +3423,6 @@ function getRecentChatText(streamId, sinceSec = 120, limit = 40) {
} catch { return []; }
} 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),
// 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.
// a proxy for "everyone reacted here". Offsets are relative to the stream's start.
function getChatSpikeOffsets(streamId, bucketSec = 30, topN = 8) {
function getChatSpikeOffsets(streamId, bucketSec = 30, topN = 8) {
@@ -3538,41 +3438,6 @@ function getChatSpikeOffsets(streamId, bucketSec = 30, topN = 8) {
@@ -3538,41 +3438,6 @@ function getChatSpikeOffsets(streamId, bucketSec = 30, topN = 8) {
} catch { return []; }
} 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 }) {
function upsertStreamerOverview(userId, { overview, model = null, sources = null }) {
return run(`INSERT INTO streamer_overviews (user_id, overview, overview_short, model, sources, generated_at)
return run(`INSERT INTO streamer_overviews (user_id, overview, overview_short, model, sources, generated_at)
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
@@ -3588,7 +3453,8 @@ function getStreamerOverview(userId) {
@@ -3588,7 +3453,8 @@ function getStreamerOverview(userId) {
// Assemble the full AI timeline for a streamer from already-generated AI data (no LLM cost):
// 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,
// 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.
// 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;
let overview = null;
try { overview = get('SELECT overview, overview_short, generated_at FROM streamer_overviews WHERE user_id = ?', [userId]) || null; } catch { /* */ }
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) {
@@ -3597,8 +3463,6 @@ function assembleStreamerAiTimeline(userId) {
const streams = all(`
const streams = all(`
SELECT s.id, s.title, s.ai_title, s.started_at, s.ended_at, s.created_at, s.duration_seconds,
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,
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
(SELECT COUNT(*) FROM stream_memories m WHERE m.stream_id = s.id) AS memory_count
FROM streams s
FROM streams s
WHERE s.user_id = ?
WHERE s.user_id = ?
@@ -3628,7 +3492,8 @@ function assembleStreamerAiTimeline(userId) {
@@ -3628,7 +3492,8 @@ function assembleStreamerAiTimeline(userId) {
}
}
delete m.transcript_json;
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 { /* */ }
} catch { /* */ }
@@ -3657,8 +3522,10 @@ function clearAiTimelineCache(userId) {
@@ -3657,8 +3522,10 @@ function clearAiTimelineCache(userId) {
try { return run('DELETE FROM ai_timeline_cache WHERE user_id = ?', [userId]); } catch { return null; }
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 {
try {
const row = get('SELECT payload, generated_at FROM ai_timeline_cache WHERE user_id = ?', [userId]);
const row = get('SELECT payload, generated_at FROM ai_timeline_cache WHERE user_id = ?', [userId]);
if (row && row.payload) {
if (row && row.payload) {
@@ -3668,12 +3535,17 @@ function getStreamerAiTimeline(userId, ttlMs = 15 * 60 * 1000) {
@@ -3668,12 +3535,17 @@ function getStreamerAiTimeline(userId, ttlMs = 15 * 60 * 1000) {
}
}
}
}
} catch { /* rebuild */ }
} 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 };
return { ...fresh, cached: false };
}
}
function getAllStreamerOverviews(limit = 100) {
function getAllStreamerOverviews(limit = 100) {
@@ -3683,17 +3555,15 @@ function getAllStreamerOverviews(limit = 100) {
@@ -3683,17 +3555,15 @@ function getAllStreamerOverviews(limit = 100) {
}
}
// Streamers whose aggregate AI overview is DUE for (re)generation. A "decent"
// Streamers whose aggregate AI overview is DUE for (re)generation. A "decent"
// overview (>= decentLen chars) refreshes at most every 12h; a sparse/missing one
// 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 } = {}) {
function getStreamersNeedingOverview({ decentLen = 220, limit = 4 } = {}) {
return all(`
return all(`
SELECT u.id AS user_id
SELECT u.id AS user_id
FROM users u
FROM users u
LEFT JOIN streamer_overviews o ON o.user_id = u.id
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 (
AND (
o.user_id IS NULL
o.user_id IS NULL
OR (LENGTH(TRIM(COALESCE(o.overview,''))) >= ? AND o.generated_at <= datetime('now','-12 hours'))
OR (LENGTH(TRIM(COALESCE(o.overview,''))) >= ? AND o.generated_at <= datetime('now','-12 hours'))
@@ -3925,7 +3795,8 @@ function getRecentlyOnlineStreamers(limit = 20, offset = 0) {
@@ -3925,7 +3795,8 @@ function getRecentlyOnlineStreamers(limit = 20, offset = 0) {
'title', ms2.title,
'title', ms2.title,
'protocol', ms2.protocol,
'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),
'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
FROM managed_streams ms2
WHERE ms2.user_id = u.id
WHERE ms2.user_id = u.id
@@ -3949,28 +3820,6 @@ function countRecentlyOnlineStreamers() {
@@ -3949,28 +3820,6 @@ function countRecentlyOnlineStreamers() {
`)?.count || 0;
`)?.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.
// Public-facing site totals for the home hero stats bar.
let _homeStatsCache = null;
let _homeStatsCache = null;
let _homeStatsCacheAt = 0;
let _homeStatsCacheAt = 0;
@@ -4107,9 +3956,7 @@ const HOME_SERIES = {
@@ -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' },
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(*)' },
sessions: { table: 'streams', ts: 'created_at', agg: 'COUNT(*)' },
streamers: { table: 'streams', ts: 'created_at', agg: 'COUNT(DISTINCT user_id)' },
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' },
hoursWatched:{ table: 'watch_time', ts: 'created_at', agg: 'COALESCE(SUM(minutes_watched), 0) / 60.0' },
aiMoments: { table: 'stream_memories', ts: 'created_at', agg: 'COUNT(*)' },
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 },
vibes: { table: 'transactions', ts: 'created_at', agg: 'COALESCE(SUM(amount), 0)', where: "type = 'donation'", vibesReset: true },
@@ -4214,8 +4061,6 @@ function _computeHomeStats() {
@@ -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 : ''}`);
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 };
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).
// Rolling day/week/month SUMS (for value metrics like Vibes tipped).
const winSum = (table, col, tsCol, extra = '') => {
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]);
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() {
@@ -4250,8 +4095,10 @@ function _computeHomeStats() {
// Donation goals: currently running + ever reached.
// Donation goals: currently running + ever reached.
goalsActive: c(`SELECT COUNT(*) AS count FROM donation_goals WHERE is_active = 1`),
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`),
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`),
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`),
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`),
chatMessages: c(`SELECT COUNT(*) AS count FROM chat_messages`),
@@ -4259,12 +4106,12 @@ function _computeHomeStats() {
@@ -4259,12 +4106,12 @@ function _computeHomeStats() {
anons: c(`SELECT COUNT(*) AS count FROM anon_ip_mappings`),
anons: c(`SELECT COUNT(*) AS count FROM anon_ip_mappings`),
follows: c(`SELECT COUNT(*) AS count FROM follows`),
follows: c(`SELECT COUNT(*) AS count FROM follows`),
emotes: c(`SELECT COUNT(*) AS count FROM emotes`),
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`),
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.
// Active chatters this week across EVERYONE — registered users, anons, and relay chatters.
weeklyActive: c(`SELECT COUNT(*) AS count FROM (
weeklyActive: c(`SELECT COUNT(*) AS count FROM (
SELECT DISTINCT 'u' || user_id AS id FROM chat_messages
SELECT DISTINCT 'u' || user_id AS id FROM chat_messages
@@ -4298,11 +4145,11 @@ function _computeHomeStats() {
@@ -4298,11 +4145,11 @@ function _computeHomeStats() {
users: winCount('users', 'created_at', 'COALESCE(is_banned, 0) = 0'),
users: winCount('users', 'created_at', 'COALESCE(is_banned, 0) = 0'),
anons: winCount('anon_ip_mappings', 'created_at'),
anons: winCount('anon_ip_mappings', 'created_at'),
sessions: winCount('streams', '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'),
aiMoments: winCount('stream_memories', 'created_at'),
messages: winCount('chat_messages', 'timestamp', 'COALESCE(is_deleted, 0) = 0'),
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(),
streamers: streamersWin(),
emotes: winCount('emotes', 'created_at'),
emotes: winCount('emotes', 'created_at'),
goals: winCount('donation_goals', 'created_at'),
goals: winCount('donation_goals', 'created_at'),
@@ -4322,153 +4169,6 @@ function _computeHomeStats() {
@@ -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 ──────────────────────────────────────────
// ── Channel helpers ──────────────────────────────────────────
function getChannelByUserId(userId) {
function getChannelByUserId(userId) {
@@ -5357,17 +5057,6 @@ function updateUserAvatar(userId, avatarUrl, pasteId = null) {
@@ -5357,17 +5057,6 @@ function updateUserAvatar(userId, avatarUrl, pasteId = null) {
// resetAvatarsForPaste() removed — the media subsystem (vods/clips/pastes writes) moved to OpenVibe.Media.
// 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 ───────────────────────────────────────────
// ── Follow helpers ───────────────────────────────────────────
function followUser(followerId, streamerId) {
function followUser(followerId, streamerId) {
@@ -5558,378 +5247,11 @@ function setSubscriptionStatus(id, status, fields = {}) {
@@ -5558,378 +5247,11 @@ function setSubscriptionStatus(id, status, fields = {}) {
[status, status === 'active' ? 1 : 0, cape, cpe, id]);
[status, status === 'active' ? 1 : 0, cape, cpe, id]);
return get('SELECT * FROM subscriptions WHERE id = ?', [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 ──────────────────────────────────────────
// ── Control helpers ──────────────────────────────────────────
@@ -7151,78 +6473,9 @@ function upsertChannelModerationSettings(channelId, fields) {
@@ -7151,78 +6473,9 @@ function upsertChannelModerationSettings(channelId, fields) {
return getChannelModerationSettings(channelId);
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).
* Get a user's total game level (sum of all skill levels).
@@ -7243,71 +6496,6 @@ function getUserTotalGameLevel(userId) {
@@ -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 ─────────────────────────────────
// ── Anon IP Mapping ─────────────────────────────────
/**
/**
@@ -7377,14 +6565,6 @@ function recordFirstChat(chatterKey, channelUserId) {
@@ -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 ────────────────────────────────
// ── Moderation Action Logging ────────────────────────────────
/**
/**
@@ -7958,11 +7138,11 @@ function computeAndCacheStreamAnalytics(streamId) {
@@ -7958,11 +7138,11 @@ function computeAndCacheStreamAnalytics(streamId) {
);
);
const totalWatchMinutes = watchRow?.total || 0;
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
// Coins earned during this stream
const coinsRow = get(
const coinsRow = get(
@@ -8018,6 +7198,11 @@ function getStreamAnalytics(streamId) {
@@ -8018,6 +7198,11 @@ function getStreamAnalytics(streamId) {
return get('SELECT * FROM stream_analytics WHERE stream_id = ?', [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) {
function getChannelAnalyticsSummary(userId, days) {
const cutoff = new Date(Date.now() - days * 86400000).toISOString();
const cutoff = new Date(Date.now() - days * 86400000).toISOString();
@@ -8298,22 +7483,17 @@ module.exports = {
@@ -8298,22 +7483,17 @@ module.exports = {
createStream, endStream, onStreamLifecycle, endOtherLiveStreamsForSlot, updateViewerCount,
createStream, endStream, onStreamLifecycle, endOtherLiveStreamsForSlot, updateViewerCount,
addStreamMemory, getStreamMemories, getLatestStreamMemory, updateStreamAiOverview,
addStreamMemory, getStreamMemories, getLatestStreamMemory, updateStreamAiOverview,
setVodAiOverview, setClipAiOverview, setVodTranscript, setClipTranscript, getStreamMemoriesInRange,
setVodAiOverview, setClipAiOverview, setVodTranscript, setClipTranscript, getStreamMemoriesInRange,
- getVodsNeedingOverview, getClipsNeedingOverview, getVodsNeedingTimeline, getVodsNeedingTranscript, getClipsNeedingTranscript, getPastesNeedingAnalysis,
+ getVodsNeedingOverview, getClipsNeedingOverview, getVodsNeedingTimeline, getVodsNeedingTranscript, getClipsNeedingTranscript,
setVodTranscriptStatus, setClipTranscriptStatus, bumpVodTranscriptAttempt, bumpClipTranscriptAttempt,
setVodTranscriptStatus, setClipTranscriptStatus, bumpVodTranscriptAttempt, bumpClipTranscriptAttempt,
cleanupMalformedAiText, recordAiUsage, getAiCostToday, getAiCostTodayForUser, getAiUsageSummary,
cleanupMalformedAiText, recordAiUsage, getAiCostToday, getAiCostTodayForUser, getAiUsageSummary,
- getStreamMemoriesByUser, countStreamMemoriesByUser, getAiMomentCandidates, getStreamTranscriptSegments, getUserPastesForAi,
+ getStreamMemoriesByUser, countStreamMemoriesByUser, getStreamTranscriptSegments,
addTimelineEvents, getTimeline, getTimelineText, getTimelineCoverage, linkTimelineToVod, getTimelineByVod, getTimelineVodId,
addTimelineEvents, getTimeline, getTimelineText, getTimelineCoverage, linkTimelineToVod, getTimelineByVod, getTimelineVodId,
- getVodsForMomentRanking, getClipStartTimesForStream, getChatSpikeOffsets,
- getLiveChatBuckets, getRecentChatText, getVodsWithoutAutoClip,
+ getChatSpikeOffsets, getLiveChatBuckets, getRecentChatText,
upsertStreamerOverview, getStreamerOverview, getAllStreamerOverviews, getStreamersNeedingOverview,
upsertStreamerOverview, getStreamerOverview, getAllStreamerOverviews, getStreamersNeedingOverview,
- getStreamerAiTimeline, assembleStreamerAiTimeline, setStreamAiTitle, getUntitledAiSessions, clearAiTimelineCache,
+ readStreamerAiTimelineCache, buildStreamerAiTimeline, assembleStreamerAiTimeline, setStreamAiTitle, getUntitledAiSessions, clearAiTimelineCache,
// Homepage helpers
// Homepage helpers
getRecentlyOnlineStreamers, countRecentlyOnlineStreamers,
getRecentlyOnlineStreamers, countRecentlyOnlineStreamers,
- getRecentVods, countRecentVods, getHomeStats,
- // Filtered VODs/clips
- getVodsByUserFiltered, countVodsByUserFiltered, getPopularVodForUser, getPopularClipForUser, getTopContentRanges,
- getClipsOfUserStreamsPaginated, countClipsOfUserStreams,
- getClipsByUserPaginated,
+ getHomeStats,
// Channels
// Channels
getChannelByUserId, getChannelsByUserIds, getChannelByUsername, createChannel, updateChannel, ensureChannel, setUserBio,
getChannelByUserId, getChannelsByUserIds, getChannelByUsername, createChannel, updateChannel, ensureChannel, setUserBio,
getChannelPointsConfig, setChannelPointsConfig,
getChannelPointsConfig, setChannelPointsConfig,
@@ -8346,7 +7526,7 @@ module.exports = {
@@ -8346,7 +7526,7 @@ module.exports = {
recordEasterEggSolve, hasSolvedEasterEgg, countEasterEggSolves,
recordEasterEggSolve, hasSolvedEasterEgg, countEasterEggSolves,
getTtsVoiceOverride, setTtsVoiceOverride, deleteTtsVoiceOverride,
getTtsVoiceOverride, setTtsVoiceOverride, deleteTtsVoiceOverride,
// Profiles
// Profiles
- getUserProfile, updateUserAvatar, getUserAvatarPastes,
+ getUserProfile, updateUserAvatar,
getKickChannelCache, setKickChannelCache,
getKickChannelCache, setKickChannelCache,
getChannelPoints, addChannelPoints, deductChannelPoints,
getChannelPoints, addChannelPoints, deductChannelPoints,
// Follows
// Follows
@@ -8369,13 +7549,6 @@ module.exports = {
@@ -8369,13 +7549,6 @@ module.exports = {
countPendingMediaRequestsForUser, getMediaRequestMaxQueuePosition,
countPendingMediaRequestsForUser, getMediaRequestMaxQueuePosition,
findActiveMediaRequestByCanonicalUrl, updateMediaRequest,
findActiveMediaRequestByCanonicalUrl, updateMediaRequest,
renormalizePendingMediaRequestPositions,
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
// Controls
getStreamControls, createControl, bindStreamToControlConfig,
getStreamControls, createControl, bindStreamToControlConfig,
// ONVIF Cameras
// ONVIF Cameras
@@ -8416,14 +7589,7 @@ module.exports = {
@@ -8416,14 +7589,7 @@ module.exports = {
getChannelModerators, getChannelsByModerator,
getChannelModerators, getChannelsByModerator,
// Channel Moderation Settings
// Channel Moderation Settings
getChannelModerationSettings, upsertChannelModerationSettings,
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
// Anon IP Mappings
getOrCreateAnonNum, getAnonFirstSeen, loadAnonMappings,
getOrCreateAnonNum, getAnonFirstSeen, loadAnonMappings,
// Stream first chats (welcome messages)
// Stream first chats (welcome messages)
@@ -8445,7 +7611,7 @@ module.exports = {
@@ -8445,7 +7611,7 @@ module.exports = {
getLatestIpForUser, getLatestIpForAnon, getIpLog, banAllAccountsOnIp,
getLatestIpForUser, getLatestIpForAnon, getIpLog, banAllAccountsOnIp,
// Stream Analytics
// Stream Analytics
insertViewerSnapshot, getViewerSnapshots, computeAndCacheStreamAnalytics,
insertViewerSnapshot, getViewerSnapshots, computeAndCacheStreamAnalytics,
- getStreamAnalytics, getChannelAnalyticsSummary, getRecentChatActivity,
+ getStreamAnalytics, setStreamAnalyticsClipCount, getChannelAnalyticsSummary, getRecentChatActivity,
// User Preferences
// User Preferences
getUserPreferences, saveUserPreferences,
getUserPreferences, saveUserPreferences,
// Chat Log Management
// Chat Log Management
@@ -155,20 +155,9 @@ function heroSlogans() {
@@ -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.
// 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.
// 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) {
@@ -205,19 +194,10 @@ async function heroStats(timing) {
// Media stats and the network coin ledger are independent upstream calls; they used to be awaited
// 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.
// one after the other, so a cold cache paid for both in sequence.
a = Date.now();
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),
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
// 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
// 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.
// OpenVibe.Network, so this is a cached internal call that is allowed to come back empty.
new file mode 100644
new file mode 100644
@@ -0,0 +1,392 @@
@@ -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,
+};
@@ -53,7 +53,8 @@ async function subjectForLiveUser(liveUserId) {
@@ -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
* 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 } = {}) {
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)]))}` : '';
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
@@ -67,6 +68,7 @@ async function request(method, path, { query, body, act = {}, ip, timeoutMs = 20
headers['X-OV-Origin'] = 'ai';
headers['X-OV-Origin'] = 'ai';
if (act.sourceRef) headers['X-OV-Source-Ref'] = JSON.stringify(act.sourceRef);
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;
if (ip) headers['X-Forwarded-For'] = ip;
let payload;
let payload;
if (body instanceof FormData) payload = body;
if (body instanceof FormData) payload = body;
@@ -221,7 +221,8 @@ async function _homeMeta() {
@@ -221,7 +221,8 @@ async function _homeMeta() {
try { vods = (await _vodList(12)) || []; } catch { /* */ }
try { vods = (await _vodList(12)) || []; } catch { /* */ }
try { clips = (await _clipList(12)) || []; } catch { /* */ }
try { clips = (await _clipList(12)) || []; } catch { /* */ }
try { pastes = (await _pasteList(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 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 }));
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 }));
@@ -464,10 +464,17 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {
@@ -464,10 +464,17 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {
delete publicChannel.vod_recording_enabled;
delete publicChannel.vod_recording_enabled;
delete publicChannel.force_vod_recording_disabled;
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;
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 { /* */ }
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,
// 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) => {
@@ -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.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 { /* */ }
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;
let ranges = null;
- try { ranges = db.getTopContentRanges(user.id); } catch { /* */ }
+ try { ranges = await require('../media-proxy/lookups').topContentRanges(user); } catch { /* */ }
res.json({
res.json({
// Singular kept for back-compat; arrays let the offline screen fill the space.
// Singular kept for back-compat; arrays let the offline screen fill the space.
vod: vods[0] || null,
vod: vods[0] || null,
@@ -1131,10 +1138,11 @@ router.get('/mine', requireAuth, (req, res) => {
@@ -1131,10 +1138,11 @@ router.get('/mine', requireAuth, (req, res) => {
});
});
// ── List Recently Ended Streams ──────────────────────────────
// ── List Recently Ended Streams ──────────────────────────────
-router.get('/recent', (req, res) => {
+router.get('/recent', async (req, res) => {
try {
try {
const limit = Math.min(parseInt(req.query.limit || '20'), 100);
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 enriched = streams.map(s => {
const channel = db.getChannelByUserId(s.user_id);
const channel = db.getChannelByUserId(s.user_id);
return publicStream({ ...s, channel: channel || null });
return publicStream({ ...s, channel: channel || null });
@@ -1455,11 +1463,18 @@ router.get('/channel/:username/resolve/:ref', optionalAuth, (req, res) => {
@@ -1455,11 +1463,18 @@ router.get('/channel/:username/resolve/:ref', optionalAuth, (req, res) => {
// ── Managed Stream CRUD ──────────────────────────────────────
// ── Managed Stream CRUD ──────────────────────────────────────
// Get past stream sessions for a managed stream (workspace history panel)
// 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);
const managedStreamId = parseInt(req.params.managedStreamId);
if (!Number.isFinite(managedStreamId)) return res.status(400).json({ error: 'Invalid ID' });
if (!Number.isFinite(managedStreamId)) return res.status(400).json({ error: 'Invalid ID' });
try {
try {
const sessions = db.getStreamHistoryByManagedStream(managedStreamId, req.user.id);
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 });
res.json({ sessions });
} catch (err) {
} catch (err) {
console.error('[ManagedStreams] History error:', err.message);
console.error('[ManagedStreams] History error:', err.message);
@@ -1544,7 +1559,8 @@ router.get('/setup-progress', requireAuth, async (req, res) => {
@@ -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 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 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 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 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 modRules = safe(() => channel.id ? !!db.get('SELECT 1 FROM channel_moderation_settings WHERE channel_id = ? LIMIT 1', [channel.id]) : false, false);
const tasks = [
const tasks = [
@@ -61,7 +61,6 @@ const sIdle = stream(idle, 50, 120); // too old → not on the roster even with
@@ -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
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.
// 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 = [];
const lines = [];
for (let i = 0; i < 40; i++) {
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.`;
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.`;
new file mode 100644
new file mode 100644
@@ -0,0 +1,327 @@
@@ -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); });
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
* edit and thumbnail routes answered 403, which confirms the id; comments on private items were
* 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
* 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
* /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 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
* 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) {
@@ -247,19 +247,31 @@ async function check(name, fn) {
assert.ok(await seo._pageMeta('/clip/200'), 'public clips still render');
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);
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();
server.close();