openvibers/openvibe.live Commits

PowerChat viewer count: push the total audience (OpenVibe + restream platforms + RS) with per-destination and per-slot RS opt-outs

OP OpenVibers committed 26 Aug 2026 · a659e10e3e · 7 files, +75 −12


Changes

ddiff --git a/public/index.html b/public/index.html
iindex 11e31c8a..a0447313 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -5034,7 +5034,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/js/broadcast-media-setup.js?v=1" defer></script>
<script src="/js/broadcast-devices.js?v=2" defer></script>
<script src="/js/broadcast-vods.js?v=2" defer></script>
--<script src="/js/broadcast-workspace.js?v=13" defer></script>
++<script src="/js/broadcast-workspace.js?v=14" defer></script>
<script src="/js/broadcast.js?v=26" defer></script>
<script src="/js/call.js?v=3" defer></script>
<script src="/js/voice-channels.js?v=2" defer></script>
@@@ -5042,7 +5042,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/js/staff-console.js?v=1" defer></script>
<script src="/js/dashboard-moderation.js?v=5" defer></script>
<script src="/js/ai-viewers.js?v=1" defer></script>
--<script src="/js/powerchat.js?v=7" defer></script>
++<script src="/js/powerchat.js?v=8" defer></script>
<script src="/js/pastes.js?v=14" defer></script>
<script src="/js/draggable-fab.js?v=2" defer></script>
<script src="/js/messenger.js?v=2" defer></script>
ddiff --git a/public/js/broadcast-workspace.js b/public/js/broadcast-workspace.js
iindex d8a63a9a..fbf849ce 100644
---- a/public/js/broadcast-workspace.js
++++ b/public/js/broadcast-workspace.js
@@@ -995,6 +995,18 @@ function _wsRenderPanel() {
</p>
</div>
</div>
++ <div class="bc-ws-row">
++ <div class="form-group" style="flex:1">
++ <label class="bc-toggle-label">
++ <input type="checkbox" id="bc-ws-powerchat-rs-views" ${ms.slot_powerchat_count_rs_views !== 0 ? 'checked' : ''}
++ onchange="_wsSlotSettingChanged()">
++ Count RobotStreamer viewers in my PowerChat viewer total
++ </label>
++ <p class="muted" style="font-size:0.78rem;margin:4px 0 0">
++ The viewer count sent to PowerChat is your <strong>total</strong> audience: OpenVibe viewers plus every restream platform's viewers. Untick <strong>count viewers</strong> on a restream destination below to leave that platform out.
++ </p>
++ </div>
++ </div>
</div>
</details>
@@@ -1952,6 +1964,7 @@ async function _wsSaveAll() {
slot_clip_recording_enabled: ms.slot_clip_recording_enabled !== undefined ? ms.slot_clip_recording_enabled : 1,
slot_clip_notify_enabled: ms.slot_clip_notify_enabled !== undefined ? ms.slot_clip_notify_enabled : 1,
slot_powerchat_relay: ms.slot_powerchat_relay !== undefined ? ms.slot_powerchat_relay : 1,
++ slot_powerchat_count_rs_views: ms.slot_powerchat_count_rs_views !== undefined ? ms.slot_powerchat_count_rs_views : 1,
weather_zip: ms.weather_zip || null,
weather_detail: ms.weather_detail || 'basic',
weather_show_location: ms.weather_show_location || 0,
@@@ -2101,6 +2114,7 @@ function _wsSlotSettingChanged() {
ms.slot_clip_recording_enabled = document.getElementById('bc-ws-clip-recording')?.checked ? 1 : 0;
ms.slot_clip_notify_enabled = document.getElementById('bc-ws-clip-notify')?.checked ? 1 : 0;
{ const pc = document.getElementById('bc-ws-powerchat-relay'); if (pc) ms.slot_powerchat_relay = pc.checked ? 1 : 0; }
++ { const pv = document.getElementById('bc-ws-powerchat-rs-views'); if (pv) ms.slot_powerchat_count_rs_views = pv.checked ? 1 : 0; }
ms.weather_zip = document.getElementById('bc-ws-weather-zip')?.value.trim() || null;
ms.weather_detail = document.getElementById('bc-ws-weather-detail')?.value || 'basic';
ms.weather_show_location = document.getElementById('bc-ws-weather-location')?.checked ? 1 : 0;
@@@ -2629,7 +2643,8 @@ function _wsRenderRestreamList(container) {
</span>
<span class="bc-ws-restream-badges">
${d.chat_relay ? '<span class="bc-ws-restream-badge" title="Chat relay enabled"><i class="fa-solid fa-comments"></i></span>' : ''}
-- ${d.chat_relay && d.powerchat_relay === false ? '<span class="bc-ws-restream-badge muted bc-ws-pc-only" title="Not relayed to your PowerChat overlay"><i class="fa-solid fa-bolt"></i><i class="fa-solid fa-slash" style="margin-left:-9px;font-size:0.7em"></i></span>' : ''}
++ ${d.chat_relay && d.powerchat_relay === false ? '<span class="bc-ws-restream-badge muted bc-ws-pc-only" title="Chat not relayed to your PowerChat overlay"><i class="fa-solid fa-bolt"></i><i class="fa-solid fa-slash" style="margin-left:-9px;font-size:0.7em"></i></span>' : ''}
++ ${d.powerchat_count_views === false ? '<span class="bc-ws-restream-badge muted bc-ws-pc-only" title="Viewers not counted toward your PowerChat viewer total"><i class="fa-solid fa-eye-slash"></i></span>' : ''}
${d.auto_start ? '<span class="bc-ws-restream-badge" title="Auto-start"><i class="fa-solid fa-bolt"></i></span>' : ''}
${!d.enabled ? '<span class="bc-ws-restream-badge muted" title="Disabled"><i class="fa-solid fa-pause"></i></span>' : ''}
</span>
@@@ -2914,7 +2929,11 @@ function _wsShowRestreamForm(existing) {
</label>
<label class="bc-toggle-label bc-ws-pc-only" style="flex:1" title="Forward this platform's relayed chat to your PowerChat overlay (needs Chat Relay)">
<input type="checkbox" id="ws-rs-powerchat-relay" ${(!existing || existing.powerchat_relay !== false) ? 'checked' : ''}>
-- <i class="fa-solid fa-bolt"></i> → PowerChat
++ <i class="fa-solid fa-bolt"></i> → PowerChat chat
++ </label>
++ <label class="bc-toggle-label bc-ws-pc-only" style="flex:1" title="Include this platform's viewers in the total viewer count sent to PowerChat">
++ <input type="checkbox" id="ws-rs-powerchat-views" ${(!existing || existing.powerchat_count_views !== false) ? 'checked' : ''}>
++ <i class="fa-solid fa-eye"></i> → PowerChat viewers
</label>
</div>
<details style="margin-top:8px">
@@@ -2995,6 +3014,7 @@ function _wsShowRestreamForm(existing) {
auto_start: document.getElementById('ws-rs-auto-start').checked,
chat_relay: document.getElementById('ws-rs-chat-relay').checked,
powerchat_relay: document.getElementById('ws-rs-powerchat-relay')?.checked !== false,
++ powerchat_count_views: document.getElementById('ws-rs-powerchat-views')?.checked !== false,
custom_video_bitrate: parseInt(document.getElementById('ws-rs-video-bitrate').value) || null,
custom_audio_bitrate: parseInt(document.getElementById('ws-rs-audio-bitrate').value) || null,
custom_fps: parseInt(document.getElementById('ws-rs-fps').value) || null,
ddiff --git a/public/js/powerchat.js b/public/js/powerchat.js
iindex e3a11b00..62918926 100644
---- a/public/js/powerchat.js
++++ b/public/js/powerchat.js
@@@ -87,7 +87,7 @@
} else if (hintEl) {
const base = st.last_error ? ('Note: ' + st.last_error) : 'Tips confirmed on PowerChat now flow into your goals, alerts, and chat automatically.';
// Relay routing lives on the Broadcast page — streamers rarely find it unprompted.
-- hintEl.innerHTML = `${esc(base)}<br><span class="pc-tip"><i class="fa-solid fa-lightbulb"></i> Tip: chat from every stream slot and restream (Twitch, Kick, YouTube, RobotStreamer) is merged into your PowerChat overlay by default. To turn that off for a specific slot or platform, open <a href="/broadcast" onclick="event.preventDefault();navigate('/broadcast')">Broadcast</a> → a slot's <strong>PowerChat Overlay</strong> settings, or the <strong>→ PowerChat</strong> switch in each restream destination.</span>`;
++ hintEl.innerHTML = `${esc(base)}<br><span class="pc-tip"><i class="fa-solid fa-lightbulb"></i> Tip: chat from every stream slot and restream (Twitch, Kick, YouTube, RobotStreamer) is merged into your PowerChat overlay by default. To turn that off for a specific slot or platform, open <a href="/broadcast" onclick="event.preventDefault();navigate('/broadcast')">Broadcast</a> → a slot's <strong>PowerChat Overlay</strong> settings, or the <strong>→ PowerChat</strong> switches in each restream destination. The viewer count on your overlay is your total audience (OpenVibe + restream platforms + RobotStreamer) — the same switches let you leave a platform's viewers out.</span>`;
}
} else {
_swap(statusEl, '<span class="pc-dot pc-dot-off"></span> Not connected.');
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 1b2db951..663d3f25 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -760,6 +760,9 @@ function initDb() {
// Per-destination: forward this platform's relayed chat to the streamer's
// PowerChat overlay (default on; only meaningful with chat_relay).
{ name: 'powerchat_relay', def: 'INTEGER DEFAULT 1' },
++ // Per-destination: include this platform's viewers in the total viewer count
++ // pushed to PowerChat (default on).
++ { name: 'powerchat_count_views', def: 'INTEGER DEFAULT 1' },
// Circuit breaker: persist repeated go-live failures so a broken destination
// (e.g. a YouTube strike) isn't hammered every time the streamer goes live.
{ name: 'consecutive_failures', def: 'INTEGER DEFAULT 0' },
@@@ -1302,6 +1305,10 @@ function initDb() {
if (!msCols.includes('slot_powerchat_relay')) {
database.exec('ALTER TABLE managed_streams ADD COLUMN slot_powerchat_relay INTEGER DEFAULT 1');
}
++ // Per-slot: count RobotStreamer viewers toward the PowerChat viewer total (default on).
++ if (!msCols.includes('slot_powerchat_count_rs_views')) {
++ database.exec('ALTER TABLE managed_streams ADD COLUMN slot_powerchat_count_rs_views INTEGER DEFAULT 1');
++ }
} catch (e) { console.warn('[DB] visibility migration:', e.message); }
// Cached SHORT overview (a concise, locally-derived version of the long AI
@@@ -3444,7 +3451,7 @@ function updateManagedStream(managedStreamId, userId, fields) {
'is_nsfw', 'control_config_id', 'sort_order',
'streaming_method', 'browser_mode',
'default_vod_visibility', 'default_clip_visibility', 'slot_vod_recording_enabled', 'slot_clip_recording_enabled',
-- 'slot_clip_notify_enabled', 'slot_powerchat_relay',
++ 'slot_clip_notify_enabled', 'slot_powerchat_relay', 'slot_powerchat_count_rs_views',
'weather_zip', 'weather_detail', 'weather_show_location', 'mic_only_image',
'pip_source_msid', 'pip_defaults',
]);
@@@ -4112,14 +4119,14 @@ function getRestreamDestinationById(id) {
function createRestreamDestination(userId, fields) {
const result = run(
`INSERT INTO restream_destinations (user_id, managed_stream_id, platform, name, server_url, stream_key, enabled, auto_start, quality_preset,
-- custom_video_bitrate, custom_audio_bitrate, custom_fps, custom_encoder_preset, channel_url, chat_relay, powerchat_relay)
-- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
++ custom_video_bitrate, custom_audio_bitrate, custom_fps, custom_encoder_preset, channel_url, chat_relay, powerchat_relay, powerchat_count_views)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[userId, fields.managed_stream_id || null, fields.platform, fields.name || null, fields.server_url || null,
fields.stream_key || null, fields.enabled ?? 1, fields.auto_start ?? 0,
fields.quality_preset || 'auto',
fields.custom_video_bitrate ?? null, fields.custom_audio_bitrate ?? null,
fields.custom_fps ?? null, fields.custom_encoder_preset || null,
-- fields.channel_url || null, fields.chat_relay ? 1 : 0, fields.powerchat_relay === 0 ? 0 : 1]
++ fields.channel_url || null, fields.chat_relay ? 1 : 0, fields.powerchat_relay === 0 ? 0 : 1, fields.powerchat_count_views === 0 ? 0 : 1]
);
return get('SELECT * FROM restream_destinations WHERE id = ?', [result.lastInsertRowid]);
}
@@@ -4127,7 +4134,7 @@ function createRestreamDestination(userId, fields) {
function updateRestreamDestination(id, fields) {
const allowed = new Set(['name', 'server_url', 'stream_key', 'enabled', 'auto_start', 'quality_preset',
'custom_video_bitrate', 'custom_audio_bitrate', 'custom_fps', 'custom_encoder_preset',
-- 'channel_url', 'chat_relay', 'powerchat_relay', 'managed_stream_id', 'connection_id']);
++ 'channel_url', 'chat_relay', 'powerchat_relay', 'powerchat_count_views', 'managed_stream_id', 'connection_id']);
const filtered = Object.entries(fields || {}).filter(([key]) => allowed.has(key));
if (!filtered.length) return getRestreamDestinationById(id);
ddiff --git a/server/integrations/powerchat-platform.js b/server/integrations/powerchat-platform.js
iindex 61b94a84..3d83722d 100644
---- a/server/integrations/powerchat-platform.js
++++ b/server/integrations/powerchat-platform.js
@@@ -241,6 +241,36 @@ async function sendViewCount(streamerUserId, count) {
}
}
++// The count pushed to PowerChat is the TOTAL audience of a live stream: OpenVibe
++// viewers + each restream platform's viewers (Twitch/Kick/YouTube, from the restream
++// manager's cache) + RobotStreamer viewers. Streamers can exclude any restream
++// destination (powerchat_count_views) or the slot's RS viewers
++// (slot_powerchat_count_rs_views) in the Broadcast page — those still show on
++// OpenVibe, they just don't count toward the PowerChat chip.
++function totalViewersForStream(s) {
++ let total = s.viewer_count || 0;
++ const slotId = s.managed_stream_id || null;
++ try {
++ const restreamManager = require('../streaming/restream-manager');
++ const ext = restreamManager.getExternalViewerCountsForUser(s.user_id, slotId);
++ for (const b of ext.breakdown || []) {
++ if (!b.count) continue;
++ const d = b.destId ? db.get('SELECT powerchat_count_views FROM restream_destinations WHERE id = ?', [b.destId]) : null;
++ if (d && d.powerchat_count_views === 0) continue;
++ total += b.count;
++ }
++ } catch { /* restream manager unavailable — OpenVibe count only */ }
++ try {
++ const ms = slotId ? db.get('SELECT slot_powerchat_count_rs_views FROM managed_streams WHERE id = ?', [slotId]) : null;
++ if (!ms || ms.slot_powerchat_count_rs_views !== 0) {
++ const rs = require('./robotstreamer-service');
++ const rsActive = rs.chatBridges?.has(s.id) || rs._activePublish?.has(s.id);
++ if (rsActive) total += rs.getRsViewerCount(s.user_id, slotId) || 0;
++ }
++ } catch { /* */ }
++ return total;
++}
++
// Periodic sweeper: push each connected live streamer's viewer count; push null once
// when they go offline so PowerChat drops the chip.
let _vcTimer = null;
@@@ -254,7 +284,7 @@ function startViewerCountSweeper() {
const liveOwners = new Map(); // userId → summed viewer count
for (const s of live) {
if (!s.user_id) continue;
-- liveOwners.set(s.user_id, (liveOwners.get(s.user_id) || 0) + (s.viewer_count || 0));
++ liveOwners.set(s.user_id, (liveOwners.get(s.user_id) || 0) + totalViewersForStream(s));
}
for (const [userId, count] of liveOwners) { seenLive.add(userId); sendViewCount(userId, count); }
// Owners that were live last tick but aren't now → send null (stream ended).
@@@ -410,7 +440,7 @@ async function sendCustomAlert(streamerUserId, { actorName, message, amountCents
}
module.exports = {
-- slotRelayEnabled, destRelayEnabled,
++ slotRelayEnabled, destRelayEnabled, totalViewersForStream,
CURRENCY_KEY, TIP_CURRENCY_KEY,
forwardChat, forwardFollow, forwardSubscription, forwardTip,
sendViewCount, startViewerCountSweeper,
ddiff --git a/server/streaming/restream-routes.js b/server/streaming/restream-routes.js
iindex d46159b7..c511e2df 100644
---- a/server/streaming/restream-routes.js
++++ b/server/streaming/restream-routes.js
@@@ -40,6 +40,7 @@ function sanitizeDest(d) {
channel_url: d.channel_url || '',
chat_relay: !!d.chat_relay,
powerchat_relay: d.powerchat_relay !== 0,
++ powerchat_count_views: d.powerchat_count_views !== 0,
// Circuit-breaker status — non-zero when the destination is paused after repeated failures.
cooldown_ms: (() => { try { return db.restreamDestinationCooldownMs(d); } catch { return 0; } })(),
cooldown_until: d.cooldown_until || null,
@@@ -185,6 +186,7 @@ router.post('/destinations', requireAuth, (req, res) => {
channel_url: req.body.channel_url?.trim() || null,
chat_relay: req.body.chat_relay ? 1 : 0,
powerchat_relay: (req.body.powerchat_relay === undefined || req.body.powerchat_relay) ? 1 : 0,
++ powerchat_count_views: (req.body.powerchat_count_views === undefined || req.body.powerchat_count_views) ? 1 : 0,
...parseCustomOverrides(req.body),
});
@@@ -231,6 +233,7 @@ router.put('/destinations/:id', requireAuth, (req, res) => {
if (req.body.channel_url !== undefined) updates.channel_url = req.body.channel_url?.trim() || null;
if (req.body.chat_relay !== undefined) updates.chat_relay = req.body.chat_relay ? 1 : 0;
if (req.body.powerchat_relay !== undefined) updates.powerchat_relay = req.body.powerchat_relay ? 1 : 0;
++ if (req.body.powerchat_count_views !== undefined) updates.powerchat_count_views = req.body.powerchat_count_views ? 1 : 0;
// Managed stream ID (slot assignment)
if (req.body.managed_stream_id !== undefined) {
ddiff --git a/server/streaming/routes.js b/server/streaming/routes.js
iindex fbe2e743..3f3b274a 100644
---- a/server/streaming/routes.js
++++ b/server/streaming/routes.js
@@@ -1656,6 +1656,9 @@ router.put('/managed/:id', requireAuth, (req, res) => {
if (hasOwn(req.body, 'slot_powerchat_relay')) {
fields.slot_powerchat_relay = cleanBooleanFlag(req.body.slot_powerchat_relay) ? 1 : 0;
}
++ if (hasOwn(req.body, 'slot_powerchat_count_rs_views')) {
++ fields.slot_powerchat_count_rs_views = cleanBooleanFlag(req.body.slot_powerchat_count_rs_views) ? 1 : 0;
++ }
if (hasOwn(req.body, 'weather_zip')) {
fields.weather_zip = req.body.weather_zip ? String(req.body.weather_zip).trim().slice(0, 20) : null;
}