openvibers/openvibe.live Commits
PowerChat viewer count: push the total audience (OpenVibe + restream platforms + RS) with per-destination and per-slot RS opt-outs
Changes
@@@ -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>@@@ -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,@@@ -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.');@@@ -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);@@@ -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 countfor (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,@@@ -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) {@@@ -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;}
@@ -5034,7 +5034,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
@@ -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-media-setup.js?v=1" defer></script>
<script src="/js/broadcast-devices.js?v=2" 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-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/broadcast.js?v=26" defer></script>
<script src="/js/call.js?v=3" defer></script>
<script src="/js/call.js?v=3" defer></script>
<script src="/js/voice-channels.js?v=2" 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
@@ -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/staff-console.js?v=1" defer></script>
<script src="/js/dashboard-moderation.js?v=5" 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/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/pastes.js?v=14" defer></script>
<script src="/js/draggable-fab.js?v=2" defer></script>
<script src="/js/draggable-fab.js?v=2" defer></script>
<script src="/js/messenger.js?v=2" defer></script>
<script src="/js/messenger.js?v=2" defer></script>
@@ -995,6 +995,18 @@ function _wsRenderPanel() {
@@ -995,6 +995,18 @@ function _wsRenderPanel() {
</p>
</p>
</div>
</div>
</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>
</div>
</details>
</details>
@@ -1952,6 +1964,7 @@ async function _wsSaveAll() {
@@ -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_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_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_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_zip: ms.weather_zip || null,
weather_detail: ms.weather_detail || 'basic',
weather_detail: ms.weather_detail || 'basic',
weather_show_location: ms.weather_show_location || 0,
weather_show_location: ms.weather_show_location || 0,
@@ -2101,6 +2114,7 @@ function _wsSlotSettingChanged() {
@@ -2101,6 +2114,7 @@ function _wsSlotSettingChanged() {
ms.slot_clip_recording_enabled = document.getElementById('bc-ws-clip-recording')?.checked ? 1 : 0;
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;
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 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_zip = document.getElementById('bc-ws-weather-zip')?.value.trim() || null;
ms.weather_detail = document.getElementById('bc-ws-weather-detail')?.value || 'basic';
ms.weather_detail = document.getElementById('bc-ws-weather-detail')?.value || 'basic';
ms.weather_show_location = document.getElementById('bc-ws-weather-location')?.checked ? 1 : 0;
ms.weather_show_location = document.getElementById('bc-ws-weather-location')?.checked ? 1 : 0;
@@ -2629,7 +2643,8 @@ function _wsRenderRestreamList(container) {
@@ -2629,7 +2643,8 @@ function _wsRenderRestreamList(container) {
</span>
</span>
<span class="bc-ws-restream-badges">
<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 ? '<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.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>' : ''}
${!d.enabled ? '<span class="bc-ws-restream-badge muted" title="Disabled"><i class="fa-solid fa-pause"></i></span>' : ''}
</span>
</span>
@@ -2914,7 +2929,11 @@ function _wsShowRestreamForm(existing) {
@@ -2914,7 +2929,11 @@ function _wsShowRestreamForm(existing) {
</label>
</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)">
<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' : ''}>
<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>
</label>
</div>
</div>
<details style="margin-top:8px">
<details style="margin-top:8px">
@@ -2995,6 +3014,7 @@ function _wsShowRestreamForm(existing) {
@@ -2995,6 +3014,7 @@ function _wsShowRestreamForm(existing) {
auto_start: document.getElementById('ws-rs-auto-start').checked,
auto_start: document.getElementById('ws-rs-auto-start').checked,
chat_relay: document.getElementById('ws-rs-chat-relay').checked,
chat_relay: document.getElementById('ws-rs-chat-relay').checked,
powerchat_relay: document.getElementById('ws-rs-powerchat-relay')?.checked !== false,
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_video_bitrate: parseInt(document.getElementById('ws-rs-video-bitrate').value) || null,
custom_audio_bitrate: parseInt(document.getElementById('ws-rs-audio-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,
custom_fps: parseInt(document.getElementById('ws-rs-fps').value) || null,
@@ -87,7 +87,7 @@
@@ -87,7 +87,7 @@
} else if (hintEl) {
} 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.';
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.
// 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 {
} else {
_swap(statusEl, '<span class="pc-dot pc-dot-off"></span> Not connected.');
_swap(statusEl, '<span class="pc-dot pc-dot-off"></span> Not connected.');
@@ -760,6 +760,9 @@ function initDb() {
@@ -760,6 +760,9 @@ function initDb() {
// Per-destination: forward this platform's relayed chat to the streamer's
// Per-destination: forward this platform's relayed chat to the streamer's
// PowerChat overlay (default on; only meaningful with chat_relay).
// PowerChat overlay (default on; only meaningful with chat_relay).
{ name: 'powerchat_relay', def: 'INTEGER DEFAULT 1' },
{ 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
// 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.
// (e.g. a YouTube strike) isn't hammered every time the streamer goes live.
{ name: 'consecutive_failures', def: 'INTEGER DEFAULT 0' },
{ name: 'consecutive_failures', def: 'INTEGER DEFAULT 0' },
@@ -1302,6 +1305,10 @@ function initDb() {
@@ -1302,6 +1305,10 @@ function initDb() {
if (!msCols.includes('slot_powerchat_relay')) {
if (!msCols.includes('slot_powerchat_relay')) {
database.exec('ALTER TABLE managed_streams ADD COLUMN slot_powerchat_relay INTEGER DEFAULT 1');
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); }
} catch (e) { console.warn('[DB] visibility migration:', e.message); }
// Cached SHORT overview (a concise, locally-derived version of the long AI
// Cached SHORT overview (a concise, locally-derived version of the long AI
@@ -3444,7 +3451,7 @@ function updateManagedStream(managedStreamId, userId, fields) {
@@ -3444,7 +3451,7 @@ function updateManagedStream(managedStreamId, userId, fields) {
'is_nsfw', 'control_config_id', 'sort_order',
'is_nsfw', 'control_config_id', 'sort_order',
'streaming_method', 'browser_mode',
'streaming_method', 'browser_mode',
'default_vod_visibility', 'default_clip_visibility', 'slot_vod_recording_enabled', 'slot_clip_recording_enabled',
'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',
'weather_zip', 'weather_detail', 'weather_show_location', 'mic_only_image',
'pip_source_msid', 'pip_defaults',
'pip_source_msid', 'pip_defaults',
]);
]);
@@ -4112,14 +4119,14 @@ function getRestreamDestinationById(id) {
@@ -4112,14 +4119,14 @@ function getRestreamDestinationById(id) {
function createRestreamDestination(userId, fields) {
function createRestreamDestination(userId, fields) {
const result = run(
const result = run(
`INSERT INTO restream_destinations (user_id, managed_stream_id, platform, name, server_url, stream_key, enabled, auto_start, quality_preset,
`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,
[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.stream_key || null, fields.enabled ?? 1, fields.auto_start ?? 0,
fields.quality_preset || 'auto',
fields.quality_preset || 'auto',
fields.custom_video_bitrate ?? null, fields.custom_audio_bitrate ?? null,
fields.custom_video_bitrate ?? null, fields.custom_audio_bitrate ?? null,
fields.custom_fps ?? null, fields.custom_encoder_preset || 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]);
return get('SELECT * FROM restream_destinations WHERE id = ?', [result.lastInsertRowid]);
}
}
@@ -4127,7 +4134,7 @@ function createRestreamDestination(userId, fields) {
@@ -4127,7 +4134,7 @@ function createRestreamDestination(userId, fields) {
function updateRestreamDestination(id, fields) {
function updateRestreamDestination(id, fields) {
const allowed = new Set(['name', 'server_url', 'stream_key', 'enabled', 'auto_start', 'quality_preset',
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',
'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));
const filtered = Object.entries(fields || {}).filter(([key]) => allowed.has(key));
if (!filtered.length) return getRestreamDestinationById(id);
if (!filtered.length) return getRestreamDestinationById(id);
@@ -241,6 +241,36 @@ async function sendViewCount(streamerUserId, count) {
@@ -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
// Periodic sweeper: push each connected live streamer's viewer count; push null once
// when they go offline so PowerChat drops the chip.
// when they go offline so PowerChat drops the chip.
let _vcTimer = null;
let _vcTimer = null;
@@ -254,7 +284,7 @@ function startViewerCountSweeper() {
@@ -254,7 +284,7 @@ function startViewerCountSweeper() {
const liveOwners = new Map(); // userId → summed viewer count
const liveOwners = new Map(); // userId → summed viewer count
for (const s of live) {
for (const s of live) {
if (!s.user_id) continue;
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); }
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).
// 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
@@ -410,7 +440,7 @@ async function sendCustomAlert(streamerUserId, { actorName, message, amountCents
}
}
module.exports = {
module.exports = {
- slotRelayEnabled, destRelayEnabled,
+ slotRelayEnabled, destRelayEnabled, totalViewersForStream,
CURRENCY_KEY, TIP_CURRENCY_KEY,
CURRENCY_KEY, TIP_CURRENCY_KEY,
forwardChat, forwardFollow, forwardSubscription, forwardTip,
forwardChat, forwardFollow, forwardSubscription, forwardTip,
sendViewCount, startViewerCountSweeper,
sendViewCount, startViewerCountSweeper,
@@ -40,6 +40,7 @@ function sanitizeDest(d) {
@@ -40,6 +40,7 @@ function sanitizeDest(d) {
channel_url: d.channel_url || '',
channel_url: d.channel_url || '',
chat_relay: !!d.chat_relay,
chat_relay: !!d.chat_relay,
powerchat_relay: d.powerchat_relay !== 0,
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.
// Circuit-breaker status — non-zero when the destination is paused after repeated failures.
cooldown_ms: (() => { try { return db.restreamDestinationCooldownMs(d); } catch { return 0; } })(),
cooldown_ms: (() => { try { return db.restreamDestinationCooldownMs(d); } catch { return 0; } })(),
cooldown_until: d.cooldown_until || null,
cooldown_until: d.cooldown_until || null,
@@ -185,6 +186,7 @@ router.post('/destinations', requireAuth, (req, res) => {
@@ -185,6 +186,7 @@ router.post('/destinations', requireAuth, (req, res) => {
channel_url: req.body.channel_url?.trim() || null,
channel_url: req.body.channel_url?.trim() || null,
chat_relay: req.body.chat_relay ? 1 : 0,
chat_relay: req.body.chat_relay ? 1 : 0,
powerchat_relay: (req.body.powerchat_relay === undefined || req.body.powerchat_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),
...parseCustomOverrides(req.body),
});
});
@@ -231,6 +233,7 @@ router.put('/destinations/:id', requireAuth, (req, res) => {
@@ -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.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.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_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)
// Managed stream ID (slot assignment)
if (req.body.managed_stream_id !== undefined) {
if (req.body.managed_stream_id !== undefined) {
@@ -1656,6 +1656,9 @@ router.put('/managed/:id', requireAuth, (req, res) => {
@@ -1656,6 +1656,9 @@ router.put('/managed/:id', requireAuth, (req, res) => {
if (hasOwn(req.body, 'slot_powerchat_relay')) {
if (hasOwn(req.body, 'slot_powerchat_relay')) {
fields.slot_powerchat_relay = cleanBooleanFlag(req.body.slot_powerchat_relay) ? 1 : 0;
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')) {
if (hasOwn(req.body, 'weather_zip')) {
fields.weather_zip = req.body.weather_zip ? String(req.body.weather_zip).trim().slice(0, 20) : null;
fields.weather_zip = req.body.weather_zip ? String(req.body.weather_zip).trim().slice(0, 20) : null;
}
}