openvibers/openvibe.live Commits

Post-split data plumbing: hero stats, recently-online thumbs, WHIP live thumbs, clip chat type

OP OpenVibers committed 17 Aug 2026 · c13053c45a · 4 files, +80 −7

- Hero stats: VOD/clip/paste counters come from Media's /stats (5-min cache) —
  the local tables are empty post-split and rendered 0.
- recently-online: batch-resolve latest VOD thumbnail per managed stream from
  Media (the local SQL joined an empty vods table → always null).
- WebRTC/WHIP live streams: server-side thumbnail fallback — Media extracts a
  frame from the in-progress recording every ~2 min (covers OBS/WHIP publishers
  and hidden broadcaster tabs; the old PlainRTP grab was retired with the
  in-process recorder). Also fixes the blank RECORDING-card thumbnail.
- chat_messages: widen message_type CHECK to allow 'clip' (clip notifications
  failed the constraint and were dropped).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Xw6Sf5zzSALw8m93XNu5JE

Changes

ddiff --git a/server/db/database.js b/server/db/database.js
iindex e9dd8494..50ea4c5c 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -269,11 +269,12 @@ function initDb() {
// table from its CURRENT definition (columns have grown over time).
try {
const tbl = database.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='chat_messages'").get();
-- if (tbl && /message_type IN \([^)]*\)/.test(tbl.sql) && !tbl.sql.includes("'channel-sound'")) {
++ const REQUIRED_TYPES = ['channel-sound', 'soundboard', 'clip'];
++ if (tbl && /message_type IN \([^)]*\)/.test(tbl.sql) && REQUIRED_TYPES.some(t => !tbl.sql.includes(`'${t}'`))) {
const newSql = tbl.sql
.replace(/CREATE TABLE ["']?chat_messages["']?/, 'CREATE TABLE chat_messages_new')
.replace(/message_type IN \([^)]*\)/,
-- "message_type IN ('chat', 'system', 'donation', 'command', 'tts', 'channel-sound', 'soundboard')");
++ "message_type IN ('chat', 'system', 'donation', 'command', 'tts', 'channel-sound', 'soundboard', 'clip')");
const cols = database.prepare("PRAGMA table_info('chat_messages')").all().map(c => `"${c.name}"`).join(', ');
const indexes = database.prepare(
"SELECT sql FROM sqlite_master WHERE type='index' AND tbl_name='chat_messages' AND sql IS NOT NULL"
ddiff --git a/server/home/routes.js b/server/home/routes.js
iindex db544037..b4b9c201 100644
---- a/server/home/routes.js
++++ b/server/home/routes.js
@@@ -155,10 +155,40 @@ 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;
++}
++
++async function heroStats() {
++ const stats = { ...db.getHomeStats() };
++ const m = await _mediaStatsCached();
++ 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 };
++ }
++ return stats;
++}
++
router.get('/hero', async (req, res) => {
try {
res.set('Cache-Control', 'public, max-age=20');
-- res.json({ stats: db.getHomeStats(), media: await heroMedia(), moments: heroMoments(), slogans: heroSlogans() });
++ res.json({ stats: await heroStats(), media: await heroMedia(), moments: heroMoments(), slogans: heroSlogans() });
} catch (err) {
console.error('[Home] hero error:', err.message);
res.status(500).json({ error: 'Failed to load hero' });
ddiff --git a/server/index.js b/server/index.js
iindex fc3f5dfc..7957f7cb 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -1051,6 +1051,7 @@ async function start() {
// 8. Start stale stream heartbeat cleanup (every 60 seconds)
let heartbeatCleanupRunning = false;
++ const liveVodThumbGeneratedAt = new Map(); // streamId → last Media frame-grab time
const maintenanceInterval = setInterval(() => {
if (heartbeatCleanupRunning) return;
heartbeatCleanupRunning = true;
@@@ -1141,9 +1142,32 @@ async function start() {
}
}
-- // WebRTC/WHIP/browser live thumbnails come from the broadcaster client's periodic
-- // canvas-capture POST (see /api/thumbnails/live/:streamId). The old server-side
-- // PlainRTP frame grab was retired with the in-process recorder.
++ // WebRTC/WHIP/browser live thumbnails: the broadcaster client's periodic
++ // canvas-capture POST covers browser publishers with a visible tab, but WHIP
++ // publishers (OBS) and hidden tabs send nothing. Fallback: have Media extract
++ // a frame from the in-progress recording (fragmented mp4 — readable while
++ // growing) and use that as both the live card and RECORDING-card thumbnail.
++ const webrtcStreams = db.all(
++ `SELECT id FROM streams WHERE is_live = 1 AND protocol NOT IN ('rtmp', 'jsmpeg')`
++ );
++ for (const wsStream of webrtcStreams) {
++ if (liveThumbs.shouldRefreshLiveThumbnail(wsStream.id, 120000)) {
++ const lastGen = liveVodThumbGeneratedAt.get(wsStream.id) || 0;
++ if (Date.now() - lastGen < 120000) continue;
++ const rec = recorder.activeRecordings.get(wsStream.id);
++ if (!rec || !rec.vodId) continue;
++ liveVodThumbGeneratedAt.set(wsStream.id, Date.now());
++ mediaClient.generateThumbnail('vod', rec.vodId)
++ .then((out) => {
++ const url = mediaClient.publicUrl(out?.url);
++ if (url) db.run('UPDATE streams SET thumbnail_url = ? WHERE id = ?', [url, wsStream.id]);
++ })
++ .catch(() => {});
++ }
++ }
++ for (const [sid] of liveVodThumbGeneratedAt) {
++ if (!webrtcStreams.some(s => s.id === sid)) liveVodThumbGeneratedAt.delete(sid);
++ }
} catch (err) {
console.error('[Heartbeat] Cleanup error:', err.message);
} finally {
ddiff --git a/server/streaming/routes.js b/server/streaming/routes.js
iindex 0b69e143..1f7a8aee 100644
---- a/server/streaming/routes.js
++++ b/server/streaming/routes.js
@@@ -1107,7 +1107,7 @@ router.get('/recent', (req, res) => {
});
// ── Recently Online (grouped by user) ────────────────────────
--router.get('/recently-online', (req, res) => {
++router.get('/recently-online', async (req, res) => {
try {
const limit = Math.min(Math.max(parseInt(req.query.limit || '20', 10), 1), 100);
const offset = Math.max(parseInt(req.query.offset || '0', 10), 0);
@@@ -1127,6 +1127,24 @@ router.get('/recently-online', (req, res) => {
} catch { s.managed_streams = []; }
delete s.managed_streams_json;
}
++ // VOD rows (and their thumbnails) live in OpenVibe.Media — the local SQL
++ // can't join them, so batch-resolve the latest thumb per managed stream.
++ try {
++ const media = require('../media-client');
++ const msIds = streamers.flatMap(s => (s.managed_streams || []).map(ms => ms.managed_stream_id)).filter(Boolean);
++ if (msIds.length) {
++ const out = await media.request('GET', '/vods/latest-thumbs', { query: { managed_stream_ids: msIds.join(',') }, timeoutMs: 5000 });
++ const thumbs = out?.thumbs || {};
++ for (const s of streamers) {
++ for (const ms of (s.managed_streams || [])) {
++ const t = thumbs[ms.managed_stream_id];
++ if (t && !ms.vod_thumbnail) ms.vod_thumbnail = media.publicUrl(t.thumbnail_url);
++ }
++ }
++ }
++ } catch (err) {
++ console.warn('[Streaming] recently-online thumb enrichment failed:', err.message);
++ }
res.json({ streamers, total, limit, offset, hasMore: offset + streamers.length < total });
} catch (err) {
console.error('[Streaming]', err.message);