openvibers/openvibe.live Commits
Post-split data plumbing: hero stats, recently-online thumbs, WHIP live thumbs, clip chat type
Changes
@@@ -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"@@@ -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' });@@@ -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 timeconst 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 {@@@ -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);
@@ -269,11 +269,12 @@ function initDb() {
@@ -269,11 +269,12 @@ function initDb() {
// table from its CURRENT definition (columns have grown over time).
// table from its CURRENT definition (columns have grown over time).
try {
try {
const tbl = database.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='chat_messages'").get();
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
const newSql = tbl.sql
.replace(/CREATE TABLE ["']?chat_messages["']?/, 'CREATE TABLE chat_messages_new')
.replace(/CREATE TABLE ["']?chat_messages["']?/, 'CREATE TABLE chat_messages_new')
.replace(/message_type IN \([^)]*\)/,
.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 cols = database.prepare("PRAGMA table_info('chat_messages')").all().map(c => `"${c.name}"`).join(', ');
const indexes = database.prepare(
const indexes = database.prepare(
"SELECT sql FROM sqlite_master WHERE type='index' AND tbl_name='chat_messages' AND sql IS NOT NULL"
"SELECT sql FROM sqlite_master WHERE type='index' AND tbl_name='chat_messages' AND sql IS NOT NULL"
@@ -155,10 +155,40 @@ function heroSlogans() {
@@ -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) => {
router.get('/hero', async (req, res) => {
try {
try {
res.set('Cache-Control', 'public, max-age=20');
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) {
} catch (err) {
console.error('[Home] hero error:', err.message);
console.error('[Home] hero error:', err.message);
res.status(500).json({ error: 'Failed to load hero' });
res.status(500).json({ error: 'Failed to load hero' });
@@ -1051,6 +1051,7 @@ async function start() {
@@ -1051,6 +1051,7 @@ async function start() {
// 8. Start stale stream heartbeat cleanup (every 60 seconds)
// 8. Start stale stream heartbeat cleanup (every 60 seconds)
let heartbeatCleanupRunning = false;
let heartbeatCleanupRunning = false;
+ const liveVodThumbGeneratedAt = new Map(); // streamId → last Media frame-grab time
const maintenanceInterval = setInterval(() => {
const maintenanceInterval = setInterval(() => {
if (heartbeatCleanupRunning) return;
if (heartbeatCleanupRunning) return;
heartbeatCleanupRunning = true;
heartbeatCleanupRunning = true;
@@ -1141,9 +1142,32 @@ async function start() {
@@ -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) {
} catch (err) {
console.error('[Heartbeat] Cleanup error:', err.message);
console.error('[Heartbeat] Cleanup error:', err.message);
} finally {
} finally {
@@ -1107,7 +1107,7 @@ router.get('/recent', (req, res) => {
@@ -1107,7 +1107,7 @@ router.get('/recent', (req, res) => {
});
});
// ── Recently Online (grouped by user) ────────────────────────
// ── Recently Online (grouped by user) ────────────────────────
-router.get('/recently-online', (req, res) => {
+router.get('/recently-online', async (req, res) => {
try {
try {
const limit = Math.min(Math.max(parseInt(req.query.limit || '20', 10), 1), 100);
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);
const offset = Math.max(parseInt(req.query.offset || '0', 10), 0);
@@ -1127,6 +1127,24 @@ router.get('/recently-online', (req, res) => {
@@ -1127,6 +1127,24 @@ router.get('/recently-online', (req, res) => {
} catch { s.managed_streams = []; }
} catch { s.managed_streams = []; }
delete s.managed_streams_json;
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 });
res.json({ streamers, total, limit, offset, hasMore: offset + streamers.length < total });
} catch (err) {
} catch (err) {
console.error('[Streaming]', err.message);
console.error('[Streaming]', err.message);