openvibers/openvibe.live Commits

PowerChat relay routing: per-slot + per-restream-destination toggles (default on), Broadcast UI, dashboard tip; AI backfill skips unprocessable rows

OP OpenVibers committed 26 Aug 2026 · 157fa99ab0 · 12 files, +112 −19


Changes

ddiff --git a/public/css/broadcast.css b/public/css/broadcast.css
iindex ebc7c300..d287ef06 100644
---- a/public/css/broadcast.css
++++ b/public/css/broadcast.css
@@@ -4160,3 +4160,7 @@
line-height: 1.45;
color: var(--text-muted, #9aa3b2);
}
++
++/* PowerChat relay controls: hidden unless the streamer has a connected PowerChat account
++ (broadcast-workspace.js toggles body.ws-has-powerchat from /powerchat/status). */
++body:not(.ws-has-powerchat) .bc-ws-pc-only { display: none !important; }
ddiff --git a/public/index.html b/public/index.html
iindex 95648552..11e31c8a 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -60,7 +60,7 @@
<!-- Stylesheets -->
<link rel="stylesheet" href="/css/style.css?v=135">
<link rel="stylesheet" href="/css/chat-shared.css?v=1">
-- <link rel="stylesheet" href="/css/broadcast.css?v=9">
++ <link rel="stylesheet" href="/css/broadcast.css?v=10">
<link rel="stylesheet" href="/css/cosmetics.css?v=2">
<link rel="stylesheet" href="/css/messenger.css?v=1">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
@@@ -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=12" defer></script>
++<script src="/js/broadcast-workspace.js?v=13" 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=6" defer></script>
++<script src="/js/powerchat.js?v=7" 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 193e5e68..d8a63a9a 100644
---- a/public/js/broadcast-workspace.js
++++ b/public/js/broadcast-workspace.js
@@@ -131,7 +131,17 @@ function _wsCollectVibeSettingsFromDom() {
/* ── Init ────────────────────────────────────────────────────── */
++// PowerChat relay controls only make sense for streamers with a connected PowerChat
++// account — everything with .bc-ws-pc-only stays hidden until /powerchat/status says so.
++async function _wsLoadPowerchatStatus() {
++ let connected = false;
++ try { const st = await api('/powerchat/status'); connected = !!(st && st.enabled && st.configured && st.connected); } catch { /* */ }
++ _wsState.powerchatConnected = connected;
++ document.body.classList.toggle('ws-has-powerchat', connected);
++}
++
async function initBroadcastWorkspace() {
++ _wsLoadPowerchatStatus();
await _wsLoadManagedStreams();
_wsRenderSidebar();
if (_wsState.managedStreams.length > 0) {
@@@ -968,6 +978,26 @@ function _wsRenderPanel() {
</div>
</details>
++ <!-- PowerChat overlay relay (shown only when PowerChat is connected) -->
++ <details class="bc-ws-slot-settings bc-ws-pc-only">
++ <summary><i class="fa-solid fa-bolt"></i> PowerChat Overlay</summary>
++ <div class="bc-ws-slot-settings-inner">
++ <div class="bc-ws-row">
++ <div class="form-group" style="flex:1">
++ <label class="bc-toggle-label">
++ <input type="checkbox" id="bc-ws-powerchat-relay" ${ms.slot_powerchat_relay !== 0 ? 'checked' : ''}
++ onchange="_wsSlotSettingChanged()">
++ Relay this slot's chat to my PowerChat overlay
++ </label>
++ <p class="muted" style="font-size:0.78rem;margin:4px 0 0">
++ Covers OpenVibe chat, RobotStreamer chat and every restream chat relay on this slot.
++ Each restream destination below also has its own <strong>→ PowerChat</strong> switch, so you can keep e.g. Twitch chat on the overlay but leave Kick off.
++ </p>
++ </div>
++ </div>
++ </div>
++ </details>
++
<!-- VOD / Clips Settings -->
<details class="bc-ws-slot-settings">
<summary><i class="fa-solid fa-film"></i> VOD &amp; Clips Settings</summary>
@@@ -1921,6 +1951,7 @@ async function _wsSaveAll() {
slot_vod_recording_enabled: ms.slot_vod_recording_enabled !== undefined ? ms.slot_vod_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_powerchat_relay: ms.slot_powerchat_relay !== undefined ? ms.slot_powerchat_relay : 1,
weather_zip: ms.weather_zip || null,
weather_detail: ms.weather_detail || 'basic',
weather_show_location: ms.weather_show_location || 0,
@@@ -2069,6 +2100,7 @@ function _wsSlotSettingChanged() {
ms.slot_vod_recording_enabled = document.getElementById('bc-ws-vod-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;
++ { const pc = document.getElementById('bc-ws-powerchat-relay'); if (pc) ms.slot_powerchat_relay = pc.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;
@@@ -2597,6 +2629,7 @@ 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.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>
@@@ -2879,6 +2912,10 @@ function _wsShowRestreamForm(existing) {
<input type="checkbox" id="ws-rs-chat-relay" ${existing?.chat_relay ? 'checked' : ''}>
<i class="fa-solid fa-comments"></i> Chat Relay
</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
++ </label>
</div>
<details style="margin-top:8px">
<summary style="font-size:0.82rem;color:var(--text-secondary);cursor:pointer"><i class="fa-solid fa-sliders"></i> Custom Encoding Overrides</summary>
@@@ -2957,6 +2994,7 @@ function _wsShowRestreamForm(existing) {
enabled: document.getElementById('ws-rs-enabled').checked,
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,
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 a9057a0a..e3a11b00 100644
---- a/public/js/powerchat.js
++++ b/public/js/powerchat.js
@@@ -85,7 +85,9 @@
</div>`);
if (hintEl) hintEl.innerHTML = `<span class="pc-warn">Your connection is missing: ${esc(missing.join(', '))}. Reconnect (one click, same account) to enable viewer count, chat, and alerts on your PowerChat overlay.</span>`;
} else if (hintEl) {
-- hintEl.textContent = 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.
++ 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>`;
}
} else {
_swap(statusEl, '<span class="pc-dot pc-dot-off"></span> Not connected.');
ddiff --git a/server/ai/backfill-job.js b/server/ai/backfill-job.js
iindex 320f1ee4..7087b570 100644
---- a/server/ai/backfill-job.js
++++ b/server/ai/backfill-job.js
@@@ -104,9 +104,13 @@ async function tick() {
// VOD overviews (frames + audio pulled from the Media playback URL; self-marks).
try {
-- for (const row of db.getVodsNeedingOverview(1)) {
++ // Take a few candidates and process the first usable one: the newest row is
++ // often a VOD still recording (skipped), and with LIMIT 1 it blocked the queue.
++ for (const row of db.getVodsNeedingOverview(6)) {
const vod = await _vodMeta(row);
-- if (vod) await ai.generateVodOverview(vod);
++ if (!vod) continue;
++ await ai.generateVodOverview(vod);
++ break;
}
} catch (e) { console.warn('[AI backfill] vod:', e.message); }
@@@ -120,9 +124,11 @@ async function tick() {
// Clip overviews + local transcripts (frames + audio; self-marks).
try {
-- for (const row of db.getClipsNeedingOverview(1)) {
++ for (const row of db.getClipsNeedingOverview(6)) {
const clip = await _clipMeta(row);
-- if (clip) await ai.generateClipOverview(clip);
++ if (!clip) continue;
++ await ai.generateClipOverview(clip);
++ break;
}
} catch (e) { console.warn('[AI backfill] clip:', e.message); }
} finally {
ddiff --git a/server/chat/chat-server.js b/server/chat/chat-server.js
iindex a7f6819e..73859b05 100644
---- a/server/chat/chat-server.js
++++ b/server/chat/chat-server.js
@@@ -905,11 +905,12 @@ class ChatServer {
// never saw them. Chat is a property of the channel; only TTS, soundboards and
// AI viewers genuinely need a live stream.
try {
-- if (client.channelUserId) {
++ const pc = require('../integrations/powerchat-platform');
++ if (client.channelUserId && pc.slotRelayEnabled(client.streamId)) {
let isMod = false, isSub = false;
try { isMod = !!(client.user && permissions.canModerateChannel(client.user, client.channelUserId)); } catch { /* */ }
try { isSub = !!(client.user && db.isActiveSubscriber(client.user.id, client.channelUserId)); } catch { /* */ }
-- require('../integrations/powerchat-platform').forwardChat(client.channelUserId, {
++ pc.forwardChat(client.channelUserId, {
chatterName: username,
externalChatterId: client.user?.id ? ('u' + client.user.id) : ('a' + (client.anonId || 'anon')),
message: text,
ddiff --git a/server/db/database.js b/server/db/database.js
iindex ee5eaf08..1b2db951 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -757,6 +757,9 @@ function initDb() {
const newCols = [
{ name: 'channel_url', def: 'TEXT DEFAULT NULL' },
{ name: 'chat_relay', def: 'INTEGER DEFAULT 0' },
++ // 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' },
// 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' },
@@@ -1294,6 +1297,11 @@ function initDb() {
if (!msCols.includes('slot_clip_notify_enabled')) {
database.exec('ALTER TABLE managed_streams ADD COLUMN slot_clip_notify_enabled INTEGER DEFAULT 1');
}
++ // Per-slot master switch: relay this slot's chat (native, RobotStreamer mirror and
++ // restream-destination relays) to the streamer's PowerChat overlay (default on).
++ if (!msCols.includes('slot_powerchat_relay')) {
++ database.exec('ALTER TABLE managed_streams ADD COLUMN slot_powerchat_relay INTEGER DEFAULT 1');
++ }
} catch (e) { console.warn('[DB] visibility migration:', e.message); }
// Cached SHORT overview (a concise, locally-derived version of the long AI
@@@ -3436,7 +3444,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_clip_notify_enabled', 'slot_powerchat_relay',
'weather_zip', 'weather_detail', 'weather_show_location', 'mic_only_image',
'pip_source_msid', 'pip_defaults',
]);
@@@ -4104,14 +4112,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)
-- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
++ custom_video_bitrate, custom_audio_bitrate, custom_fps, custom_encoder_preset, channel_url, chat_relay, powerchat_relay)
++ 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.channel_url || null, fields.chat_relay ? 1 : 0, fields.powerchat_relay === 0 ? 0 : 1]
);
return get('SELECT * FROM restream_destinations WHERE id = ?', [result.lastInsertRowid]);
}
@@@ -4119,7 +4127,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', 'managed_stream_id', 'connection_id']);
++ 'channel_url', 'chat_relay', 'powerchat_relay', '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/chat-relay-service.js b/server/integrations/chat-relay-service.js
iindex ee47715b..f1f1b81f 100644
---- a/server/integrations/chat-relay-service.js
++++ b/server/integrations/chat-relay-service.js
@@@ -437,9 +437,10 @@ class ChatRelayService {
// native OpenVibe messages were forwarded before, so a streamer whose audience
// mostly chats on Twitch/Kick saw an empty overlay.
try {
++ const pc = require('./powerchat-platform');
const stream = db.getStreamById(bridge.streamId);
-- if (stream?.user_id) {
-- require('./powerchat-platform').forwardChat(stream.user_id, {
++ if (stream?.user_id && pc.destRelayEnabled(bridge.destId) && pc.slotRelayEnabled(bridge.streamId)) {
++ pc.forwardChat(stream.user_id, {
chatterName: prefixedUsername,
externalChatterId: `${bridge.platform}:${username}`,
message: chatMsg.message,
ddiff --git a/server/integrations/powerchat-platform.js b/server/integrations/powerchat-platform.js
iindex a58e9133..61b94a84 100644
---- a/server/integrations/powerchat-platform.js
++++ b/server/integrations/powerchat-platform.js
@@@ -90,6 +90,31 @@ function _noteApiError(userId, scope, err) {
if (err && err.status === 403) _noteScopeGap(userId, `${scope} (disabled on PowerChat)`);
}
++// ── Relay routing preferences ────────────────────────────────
++// Streamers choose, per stream slot and per restream destination, which chat is
++// merged into their PowerChat overlay (Broadcast page → slot settings / destination
++// editor). Everything defaults to ON; a missing row/column means "relay".
++// Slot switch: covers the slot's native chat, its RobotStreamer mirror and every
++// restream-destination relay attached to it. `streamId` may be null (offline channel
++// chat) → allowed.
++function slotRelayEnabled(streamId) {
++ if (!streamId) return true;
++ try {
++ const stream = db.getStreamById(streamId);
++ if (!stream || !stream.managed_stream_id) return true;
++ const ms = db.get('SELECT slot_powerchat_relay FROM managed_streams WHERE id = ?', [stream.managed_stream_id]);
++ return !ms || ms.slot_powerchat_relay !== 0;
++ } catch { return true; }
++}
++// Destination switch: the Twitch/Kick/YouTube relay bridge for one restream destination.
++function destRelayEnabled(destId) {
++ if (!destId) return true;
++ try {
++ const d = db.get('SELECT powerchat_relay FROM restream_destinations WHERE id = ?', [destId]);
++ return !d || d.powerchat_relay !== 0;
++ } catch { return true; }
++}
++
// ── chat:write ───────────────────────────────────────────────
const _chatBuckets = new Map(); // userId → { count, resetAt } (~120/min limit; we cap at 100)
async function forwardChat(streamerUserId, { chatterName, externalChatterId, message, messageId, avatarUrl, isModerator, isSubscriber } = {}) {
@@@ -385,6 +410,7 @@ async function sendCustomAlert(streamerUserId, { actorName, message, amountCents
}
module.exports = {
++ slotRelayEnabled, destRelayEnabled,
CURRENCY_KEY, TIP_CURRENCY_KEY,
forwardChat, forwardFollow, forwardSubscription, forwardTip,
sendViewCount, startViewerCountSweeper,
ddiff --git a/server/integrations/robotstreamer-service.js b/server/integrations/robotstreamer-service.js
iindex 32db4b21..85ded2b5 100644
---- a/server/integrations/robotstreamer-service.js
++++ b/server/integrations/robotstreamer-service.js
@@@ -745,8 +745,9 @@ class RobotStreamerService {
// ...and into the streamer's PowerChat overlay (relayed chat was never forwarded).
try {
-- if (stream.user_id) {
-- require('./powerchat-platform').forwardChat(stream.user_id, {
++ const pc = require('./powerchat-platform');
++ if (stream.user_id && pc.slotRelayEnabled(stream.id)) {
++ pc.forwardChat(stream.user_id, {
chatterName: username,
externalChatterId: `rs:${username}`,
message: mirrored.message,
ddiff --git a/server/streaming/restream-routes.js b/server/streaming/restream-routes.js
iindex 1eeb666d..d46159b7 100644
---- a/server/streaming/restream-routes.js
++++ b/server/streaming/restream-routes.js
@@@ -39,6 +39,7 @@ function sanitizeDest(d) {
quality_preset: d.quality_preset || 'auto',
channel_url: d.channel_url || '',
chat_relay: !!d.chat_relay,
++ powerchat_relay: d.powerchat_relay !== 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,
@@@ -183,6 +184,7 @@ router.post('/destinations', requireAuth, (req, res) => {
quality_preset: quality_preset || 'auto',
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,
...parseCustomOverrides(req.body),
});
@@@ -228,6 +230,7 @@ router.put('/destinations/:id', requireAuth, (req, res) => {
// Channel URL + chat relay
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;
// 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 6224bacc..fbe2e743 100644
---- a/server/streaming/routes.js
++++ b/server/streaming/routes.js
@@@ -1653,6 +1653,9 @@ router.put('/managed/:id', requireAuth, (req, res) => {
if (hasOwn(req.body, 'slot_clip_notify_enabled')) {
fields.slot_clip_notify_enabled = cleanBooleanFlag(req.body.slot_clip_notify_enabled) ? 1 : 0;
}
++ if (hasOwn(req.body, 'slot_powerchat_relay')) {
++ fields.slot_powerchat_relay = cleanBooleanFlag(req.body.slot_powerchat_relay) ? 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;
}