openvibers/openvibe.live Commits
RobotStreamer is per stream slot only: a stream uses its slot's robotstreamer_integrations row and nothing else. getRobotStreamerIntegrationForStream no longer falls back to the account-level row (which sent every slot without its own row to the same robot); an unmigrated account-level row is logged once per user and skipped, never used and never a crash. validate, login, save and remove answer 400 without managed_stream_id (validate no longer borrows the account token), upserts refuse to write an account-level row, refreshIntegration has no account path, and GET without a slot answers only the server passthrough capability with exists:false. The broadcast page's RobotStreamer settings and viewer poll work on the page's slot, a stream without its slot's config starts no browser publish, and the slot workspace no longer offers to reuse an account token. Run scripts/rs-integrations-to-slots.js on production before deploying this. test/rs-slot-only.test.js
Changes
@@@ -1101,11 +1101,11 @@ https://www.101soundboards.com/sounds/12345 150p 2</code></pre></table><h3>RobotStreamer — <code>/api/robotstreamer</code></h3>--<p>Publish a server-ingested stream to a RobotStreamer robot and (optionally) mirror its chat. Config is per slot with an account-level default. All <code>requireAuth</code>.</p>++<p>Publish a server-ingested stream to a RobotStreamer robot and (optionally) mirror its chat. Config is per stream slot only: a slot without its own config does not restream to RobotStreamer, and every call except GET needs <code>managed_stream_id</code>. All <code>requireAuth</code>.</p><table class="doc-table"><thead><tr><th>Method</th><th>Path</th><th>Notes</th></tr></thead><tbody>--<tr><td>GET</td><td>/api/robotstreamer/integration</td><td>Load RS settings + available robots (<code>?managed_stream_id</code>)</td></tr>++<tr><td>GET</td><td>/api/robotstreamer/integration</td><td>Load a slot's RS settings + available robots (<code>?managed_stream_id</code>; without it only the server's passthrough capability, <code>exists:false</code>)</td></tr><tr><td>POST</td><td>/api/robotstreamer/integration/validate · /login</td><td>Validate token+robot / log in with RS credentials to auto-fetch token+robots</td></tr><tr><td>PUT / DELETE</td><td>/api/robotstreamer/integration</td><td>Save config (starts publish + optional chat bridge on live streams) / remove</td></tr></tbody>@@@ -3036,7 +3036,7 @@ function _wsShowRestreamForm(existing) {// RobotStreamer — saved as a per-slot integration, not an RTMP destination rowif (plat && plat.isRobotStreamer) {const payload = _wsGetSlotRsFormData();-- if (!payload.token && !_wsSlotRsIntegration?.has_token && !broadcastState?.robotStreamer?.hasToken) {++ if (!payload.token && !_wsSlotRsIntegration?.has_token) {toast('Paste your RobotStreamer token first', 'error');return;}@@@ -3312,11 +3312,10 @@ function _wsRestreamPlatformChanged() {const enabledEl = document.getElementById('ws-rs-rs-enabled');const mirrorEl = document.getElementById('ws-rs-rs-mirror-chat');if (tokenEl) {++ // Tokens are per slot (there is no account-level token to reuse any more).tokenEl.placeholder = rs?.has_token? 'Token saved for this slot — paste a new one only to replace it'-- : (broadcastState?.robotStreamer?.hasToken-- ? 'Leave empty to reuse your account token, or paste a slot-specific one'-- : 'Paste your robotstreamer-token cookie or JWT');++ : 'Paste your robotstreamer-token cookie or JWT';}if (robotEl && rs?.robot_id && !robotEl.value) robotEl.value = rs.robot_id;if (enabledEl && rs) enabledEl.checked = !!rs.enabled;@@@ -11,8 +11,8 @@/* ── Per-slot RobotStreamer config ───────────────────────────── */// Each stream slot (managed stream) can carry its own RobotStreamer// token + robot, configured in the slot's Restream Destinations panel.--// Slots with their own config auto-restream independently; slots without--// fall back to the account-level config + legacy single-slot reservation.++// RobotStreamer is per slot only: a slot without its own config does not++// restream to RobotStreamer (there is no account-level fallback).const _rsSlotIntegrationCache = new Map(); // managed_stream_id → { fetchedAt, integration|null }async function fetchSlotRsIntegration(managedStreamId, { force = false } = {}) {@@@ -297,28 +297,19 @@ function updateRsRestreamSlotUI() {panel.innerHTML = html;}--/** Conditionally start RS restream — slot-specific config first, legacy account slot second */++/** Conditionally start RS restream — only from the stream's own slot config */async function maybeStartRsRestream(streamId) {const ss = getStreamState(streamId);const msId = ss?.streamData?.managed_stream_id || null;const slotRs = await fetchSlotRsIntegration(msId);-- if (slotRs) {-- // This slot has its own RS config — it restreams independently of the-- // legacy single-slot reservation and of other slots.-- if (ss) ss._rsSlotIntegration = slotRs;-- if (rsSlotIntegrationUsable(slotRs)) {-- startRobotStreamerRestream(streamId).catch(() => {});-- updateRsRestreamSlotUI();-- }-- return;++ // No slot, or a slot without its own RobotStreamer config: nothing to restream.++ if (!slotRs) return;++ // This slot has its own RS config — it restreams independently of other slots.++ if (ss) ss._rsSlotIntegration = slotRs;++ if (rsSlotIntegrationUsable(slotRs)) {++ startRobotStreamerRestream(streamId).catch(() => {});++ updateRsRestreamSlotUI();}-- // Legacy account-level behavior-- if (!isRsRestreamAssigned(streamId)) return;-- // Auto-assign the slot on first live stream-- const currentSlot = getRsRestreamSlotStreamId();-- if (!currentSlot) setRsRestreamSlot(streamId);-- startRobotStreamerRestream(streamId).catch(() => {});-- updateRsRestreamSlotUI();}function sendBroadcastSignal(ss, msg) {@@@ -703,9 +694,11 @@ function syncRobotStreamerUI() {? 'Saved token on file — paste a new one only if you want to replace it': 'Paste your robotstreamer-token cookie or JWT';-- const summary = rs.ownerName || rs.streamName-- ? `Configured for ${rs.streamName || `Robot ${rs.robotId}`} as ${rs.ownerName || rs.ownerId || 'unknown owner'}.`-- : 'RobotStreamer restreaming works with browser WebRTC broadcasting. Paste your RobotStreamer token, validate a robot, then go live.';++ const summary = !rs.managedStreamId++ ? 'Pick a stream slot: RobotStreamer is set up per stream slot.'++ : rs.ownerName || rs.streamName++ ? `Configured for ${rs.streamName || `Robot ${rs.robotId}`} as ${rs.ownerName || rs.ownerId || 'unknown owner'}.`++ : 'RobotStreamer restreaming works with browser WebRTC broadcasting. Paste your RobotStreamer token, validate a robot, then go live.';setRobotStreamerStatus(summary, rs.hasToken ? 'success' : 'info', 'bc-rsStatus');// Update live status based on current state@@@ -739,10 +732,14 @@ function syncRobotStreamerUI() {async function loadRobotStreamerIntegration() {const doLoad = async () => {try {-- const data = await api('/robotstreamer/integration');++ // The settings here are the RobotStreamer config of the slot this page is on. Without a++ // slot the server answers only whether it relays the video (passthrough), exists:false.++ const managedStreamId = _restreamPanelSlotId();++ const data = await api(managedStreamId ? `/robotstreamer/integration?managed_stream_id=${managedStreamId}` : '/robotstreamer/integration');const integration = data.integration || {};broadcastState.robotStreamer = {loaded: true,++ managedStreamId: managedStreamId || null,enabled: !!integration.enabled,mirrorChat: integration.mirror_chat !== false,hasToken: !!integration.has_token,@@@ -772,11 +769,20 @@ function getRobotStreamerFormData() {mirror_chat: !!document.getElementById('bc-rsMirrorChat')?.checked,token: document.getElementById('bc-rsToken')?.value.trim() || '',robot_input: selectRobot || inputRobot,++ managed_stream_id: _restreamPanelSlotId() || null,};}++/** RobotStreamer settings belong to a stream slot; say so instead of calling the server without one. */++function _rsNeedsSlot(payload) {++ if (payload.managed_stream_id) return false;++ setRobotStreamerStatus('Pick a stream slot first: RobotStreamer is set up per stream slot.', 'error');++ return true;++}++async function validateRobotStreamerIntegration() {const payload = getRobotStreamerFormData();++ if (_rsNeedsSlot(payload)) return;setRobotStreamerStatus('Validating RobotStreamer settings…', 'info');try {const data = await api('/robotstreamer/integration/validate', { method: 'POST', body: payload });@@@ -802,6 +808,7 @@ async function validateRobotStreamerIntegration() {async function saveRobotStreamerIntegration() {const payload = getRobotStreamerFormData();++ if (_rsNeedsSlot(payload)) return;setRobotStreamerStatus('Saving RobotStreamer settings…', 'info');try {const data = await api('/robotstreamer/integration', { method: 'PUT', body: payload });@@@ -822,9 +829,15 @@ async function saveRobotStreamerIntegration() {if (tokenEl) tokenEl.value = '';syncRobotStreamerUI();setRobotStreamerStatus('RobotStreamer settings saved.', 'success');++ // The saved config is this slot's: refresh its cache and restart only its streams.++ _rsSlotIntegrationCache.delete(payload.managed_stream_id);const restreamTargets = [...broadcastState.streams.entries()]-- .filter(([, state]) => state?.localStream)++ .filter(([, state]) => state?.localStream && state.streamData?.managed_stream_id === payload.managed_stream_id).map(([streamId]) => streamId);++ for (const streamId of restreamTargets) {++ const ss = getStreamState(streamId);++ if (ss) ss._rsSlotIntegration = null;++ }for (const streamId of restreamTargets) {if (broadcastState.robotStreamer.enabled && isRsRestreamAssigned(streamId)) {stopRobotStreamerRestream(streamId, { quiet: true })@@@ -3511,21 +3524,18 @@ async function startRobotStreamerRestream(streamId) {// Wait for integration settings to load first (fixes race condition on page load)await ensureRobotStreamerLoaded();-- // Slot-specific config wins over the account-level integration++ // Only the stream's own slot config counts: no slot config, no RobotStreamer.const slotRs = ss._rsSlotIntegration|| await fetchSlotRsIntegration(ss.streamData?.managed_stream_id || null);if (slotRs) ss._rsSlotIntegration = slotRs;-- if (slotRs) {-- if (!rsSlotIntegrationUsable(slotRs)) {-- console.log('[RS Restream] Slot config not usable — enabled:', slotRs.enabled, 'hasToken:', slotRs.has_token, 'robotId:', slotRs.robot_id);-- if (!slotRs.enabled) setRobotStreamerStatus('RobotStreamer restream is disabled for this stream slot.', 'info', 'bc-rsLiveStatus');-- return null;-- }-- } else if (!canUseRobotStreamerRestream()) {-- const rs = broadcastState.robotStreamer;-- console.log('[RS Restream] Cannot start — enabled:', rs.enabled, 'hasToken:', rs.hasToken, 'robotId:', rs.robotId);-- if (!rs.enabled) setRobotStreamerStatus('RobotStreamer restream is disabled.', 'info', 'bc-rsLiveStatus');++ if (!slotRs) {++ console.log('[RS Restream] No RobotStreamer config on this stream slot — skipping');++ return null;++ }++ if (!rsSlotIntegrationUsable(slotRs)) {++ console.log('[RS Restream] Slot config not usable — enabled:', slotRs.enabled, 'hasToken:', slotRs.has_token, 'robotId:', slotRs.robot_id);++ if (!slotRs.enabled) setRobotStreamerStatus('RobotStreamer restream is disabled for this stream slot.', 'info', 'bc-rsLiveStatus');return null;}if (ss.robotStreamer?.active) return ss.robotStreamer;@@@ -3533,7 +3543,7 @@ async function startRobotStreamerRestream(streamId) {// Server-side RAW passthrough owns this robot — the server forwards our already-encoded// stream to RobotStreamer with zero re-encode, so the browser must NOT publish a second// (double) encode. Skip the client publish entirely.-- if (broadcastState.robotStreamer?.passthrough) {++ if (slotRs.passthrough || broadcastState.robotStreamer?.passthrough) {console.log('[RS Restream] Server passthrough active — skipping browser publish for stream', streamId);setRobotStreamerStatus('Restreaming to RobotStreamer via server (raw passthrough, no re-encode).', 'success', 'bc-rsLiveStatus');return null;@@@ -4609,10 +4619,10 @@ async function _startRsViewerPoll() {try { await _robotStreamerIntegrationPromise; } catch {}}const rs = broadcastState.robotStreamer;-- if (!rs?.enabled || !rs?.robotId) return;++ if (!rs?.enabled || !rs?.robotId || !rs.managedStreamId) return;const poll = async () => {try {-- const data = await api('/robotstreamer/integration/validate', { method: 'POST', body: { robot_input: rs.robotId } });++ const data = await api('/robotstreamer/integration/validate', { method: 'POST', body: { robot_input: rs.robotId, managed_stream_id: rs.managedStreamId } });const robots = data.integration?.available_robots || [];const robot = robots.find(r => String(r.robot_id) === String(rs.robotId));_rsViewerCount = robot ? Number(robot.viewers || 0) : 0;@@@ -4329,7 +4329,8 @@ function isStreamClipRecordingEnabled(stream) {// ── RobotStreamer integration helpers ───────────────────────function getRobotStreamerIntegrationByUserId(userId) {-- // Account-level default row (no slot binding)++ // A legacy account-level row (no slot binding). It applies to no stream any more:++ // scripts/rs-integrations-to-slots.js moves these onto slots. Read only to report them.return get('SELECT * FROM robotstreamer_integrations WHERE user_id = ? AND managed_stream_id IS NULL', [userId]);}@@@ -4338,10 +4339,28 @@ function getRobotStreamerIntegrationBySlot(userId, managedStreamId) {return get('SELECT * FROM robotstreamer_integrations WHERE user_id = ? AND managed_stream_id = ?', [userId, managedStreamId]);}++// Users already warned about an unmigrated account-level row (once per process each).++const _rsAccountRowWarned = new Set();++++/**++ * The RobotStreamer config a stream on this slot uses: the slot's own row, and nothing else.++ * No slot (a legacy session) or a slot without a row means no RobotStreamer. An account-level++ * row that has not been moved onto a slot yet is logged and skipped, never used: that fallback++ * sent every slot without its own row to the same robot.++ */function getRobotStreamerIntegrationForStream(userId, managedStreamId) {-- // Slot-specific config wins; fall back to the account-level default row-- return (managedStreamId ? getRobotStreamerIntegrationBySlot(userId, managedStreamId) : null)-- || getRobotStreamerIntegrationByUserId(userId);++ const row = managedStreamId ? getRobotStreamerIntegrationBySlot(userId, managedStreamId) : null;++ if (row) return row;++ try {++ if (!_rsAccountRowWarned.has(userId)) {++ const legacy = getRobotStreamerIntegrationByUserId(userId);++ if (legacy) {++ _rsAccountRowWarned.add(userId);++ console.warn(`[RS] User ${userId} has an account-level RobotStreamer row (${legacy.id}) that applies to no stream; skipped. Bind it to a slot with scripts/rs-integrations-to-slots.js.`);++ }++ }++ } catch { /* reporting only */ }++ return null;}function deleteRobotStreamerIntegrationForSlot(userId, managedStreamId) {@@@ -4363,10 +4382,10 @@ function upsertRobotStreamerIntegration(userId, fields, managedStreamId = null)'owner_name','last_validated_at',]);++ // RobotStreamer is configured per stream slot; account-level rows are no longer written.const slotId = managedStreamId || null;-- const existing = slotId-- ? getRobotStreamerIntegrationBySlot(userId, slotId)-- : getRobotStreamerIntegrationByUserId(userId);++ if (!slotId) throw new Error('RobotStreamer settings belong to a stream slot (managed_stream_id is required)');++ const existing = getRobotStreamerIntegrationBySlot(userId, slotId);const filtered = Object.entries(fields || {}).filter(([key, val]) => allowed.has(key) && val !== undefined);if (!filtered.length) return existing;@@@ -4391,9 +4410,7 @@ function upsertRobotStreamerIntegration(userId, fields, managedStreamId = null));}-- return slotId-- ? getRobotStreamerIntegrationBySlot(userId, slotId)-- : getRobotStreamerIntegrationByUserId(userId);++ return getRobotStreamerIntegrationBySlot(userId, slotId);}// ── Restream Destination helpers ─────────────────────────────@@@ -185,6 +185,9 @@ class RobotStreamerService {last_validated_at: null,managed_stream_id: null,available_robots: [],++ // Whether the server relays the video (a property of this server, not of a row):++ // the broadcast page needs it even before a slot has a RobotStreamer row.++ passthrough: this._passthroughEnabledFor(null),...extras,};}@@@ -220,14 +223,9 @@ class RobotStreamerService {} catch { return false; }}-- getClientIntegration(userId) {-- return this.sanitizeIntegration(db.getRobotStreamerIntegrationByUserId(userId));-- }--/**-- * Resolve the effective integration for a stream: the slot-specific row-- * (matching the stream's managed_stream_id) wins, otherwise the-- * account-level default row is used.++ * The integration for a stream: its slot's row, or null (no slot, or no row on the slot).++ * There is no account-level fallback; see db.getRobotStreamerIntegrationForStream.*/getIntegrationForStream(stream) {if (!stream?.user_id) return null;@@@ -452,13 +450,8 @@ class RobotStreamerService {async upsertIntegration(userId, payload = {}, managedStreamId = null) {const slotId = managedStreamId || null;-- const existing = slotId-- ? db.getRobotStreamerIntegrationBySlot(userId, slotId)-- : db.getRobotStreamerIntegrationByUserId(userId);-- // When creating a slot-specific config, allow token/robot to fall back to-- // the account-level default row so users don't have to re-paste the token-- // for every slot.-- const fallback = existing || (slotId ? db.getRobotStreamerIntegrationByUserId(userId) : null);++ if (!slotId) throw new Error('Choose a stream slot: RobotStreamer is set up per stream slot');++ const existing = db.getRobotStreamerIntegrationBySlot(userId, slotId);const updates = {enabled: normalizeBoolean(payload.enabled, existing ? !!existing.enabled : false) ? 1 : 0,mirror_chat: normalizeBoolean(payload.mirror_chat, existing ? existing.mirror_chat !== 0 : true) ? 1 : 0,@@@ -474,22 +467,11 @@ class RobotStreamerService {let availableRobots = [];if (needsValidation) {const validated = await this.validateConfiguration({-- token: providedToken || fallback?.token,-- robotInput: providedRobot || fallback?.robot_id,++ token: providedToken || existing?.token,++ robotInput: providedRobot || existing?.robot_id,});Object.assign(updates, validated.fields);availableRobots = validated.availableRobots;-- } else if (!existing && fallback) {-- // Creating a slot row without re-validating — copy config from the default row-- updates.token = fallback.token;-- updates.robot_id = fallback.robot_id;-- updates.owner_id = fallback.owner_id;-- updates.chat_url = fallback.chat_url;-- updates.control_url = fallback.control_url;-- updates.rtc_sfu_url = fallback.rtc_sfu_url;-- updates.stream_name = fallback.stream_name;-- updates.owner_name = fallback.owner_name;-- updates.last_validated_at = fallback.last_validated_at;}const row = db.upsertRobotStreamerIntegration(userId, updates, slotId);@@@ -501,9 +483,8 @@ class RobotStreamerService {async refreshIntegration(userId, managedStreamId = null) {const slotId = managedStreamId || null;-- const existing = slotId-- ? db.getRobotStreamerIntegrationBySlot(userId, slotId)-- : db.getRobotStreamerIntegrationByUserId(userId);++ if (!slotId) return null; // per slot only: there is no account-level row to refresh++ const existing = db.getRobotStreamerIntegrationBySlot(userId, slotId);if (!existing?.token || !existing?.robot_id) return existing;const validated = await this.validateConfiguration({ token: existing.token, robotInput: existing.robot_id });return db.upsertRobotStreamerIntegration(userId, {@@@ -28,24 +28,33 @@ function resolveSlotId(req, res) {}/**-- * Live streams whose effective RS config is the given row (slot or default).-- * For a slot row: only streams on that slot. For the default row: only streams-- * that do NOT have their own slot-specific config.++ * RobotStreamer is configured per stream slot only: the writes (validate, login, save, remove)++ * need a slot. Returns the slot id, or false with a 400 already sent.*/++function requireSlotId(req, res) {++ const slotId = resolveSlotId(req, res);++ if (slotId === false) return false;++ if (!slotId) {++ res.status(400).json({ error: 'Choose a stream slot: RobotStreamer is set up per stream slot (managed_stream_id is required)' });++ return false;++ }++ return slotId;++}++++/** Live streams on the slot whose RS config this is (a slot's row applies to that slot only). */function liveStreamsForConfig(userId, slotId) {const liveStreams = db.getLiveStreamsByUserId(userId) || [];-- if (slotId) return liveStreams.filter(s => s.managed_stream_id === slotId);-- return liveStreams.filter(s => !s.managed_stream_id || !db.getRobotStreamerIntegrationBySlot(userId, s.managed_stream_id));++ return liveStreams.filter(s => s.managed_stream_id === slotId);}++// GET without a slot answers what does not depend on one (the server passthrough capability),++// with exists:false — never the legacy account-level row, which applies to no stream.router.get('/integration', requireAuth, async (req, res) => {try {const slotId = resolveSlotId(req, res);if (slotId === false) return;-- const row = slotId-- ? db.getRobotStreamerIntegrationBySlot(req.user.id, slotId)-- : db.getRobotStreamerIntegrationByUserId(req.user.id);++ const row = slotId ? db.getRobotStreamerIntegrationBySlot(req.user.id, slotId) : null;let availableRobots = [];// If a saved token + robot exists, re-fetch available robots so the dropdown populates on reload@@@ -69,11 +78,10 @@ router.get('/integration', requireAuth, async (req, res) => {router.post('/integration/validate', requireAuth, async (req, res) => {try {-- const slotId = resolveSlotId(req, res);++ const slotId = requireSlotId(req, res);if (slotId === false) return;-- const existing = (slotId ? db.getRobotStreamerIntegrationBySlot(req.user.id, slotId) : null)-- || db.getRobotStreamerIntegrationByUserId(req.user.id);++ const existing = db.getRobotStreamerIntegrationBySlot(req.user.id, slotId);const token = typeof req.body.token === 'string' && req.body.token.trim()? req.body.token.trim(): existing?.token;@@@ -106,7 +114,7 @@ router.post('/integration/validate', requireAuth, async (req, res) => {// as an easier alternative to pasting the token manually. The password is never stored.router.post('/integration/login', requireAuth, async (req, res) => {try {-- const slotId = resolveSlotId(req, res);++ const slotId = requireSlotId(req, res);if (slotId === false) return;const login = await robotStreamerService.loginWithCredentials(req.body?.user_name, req.body?.password);@@@ -131,9 +139,7 @@ router.post('/integration/login', requireAuth, async (req, res) => {}}if (!integration) {-- const row = slotId-- ? db.getRobotStreamerIntegrationBySlot(req.user.id, slotId)-- : db.getRobotStreamerIntegrationByUserId(req.user.id);++ const row = db.getRobotStreamerIntegrationBySlot(req.user.id, slotId);integration = robotStreamerService.sanitizeIntegration(row, { available_robots: login.robots });}@@@ -151,7 +157,7 @@ router.post('/integration/login', requireAuth, async (req, res) => {router.put('/integration', requireAuth, async (req, res) => {try {-- const slotId = resolveSlotId(req, res);++ const slotId = requireSlotId(req, res);if (slotId === false) return;const result = await robotStreamerService.upsertIntegration(req.user.id, req.body || {}, slotId);@@@ -222,12 +228,11 @@ router.post('/restream/stop', requireAuth, (req, res) => {}});--// Remove a slot-specific RS config (the slot falls back to the account default)++// Remove a slot's RS config (the slot then has no RobotStreamer)router.delete('/integration', requireAuth, (req, res) => {try {-- const slotId = resolveSlotId(req, res);++ const slotId = requireSlotId(req, res);if (slotId === false) return;-- if (!slotId) return res.status(400).json({ error: 'managed_stream_id is required' });for (const stream of liveStreamsForConfig(req.user.id, slotId)) {robotStreamerService.stopForStream(stream.id);nnew file mode 100644@@@ -0,0 +1,138 @@++/**++ * RobotStreamer is configured per stream slot only.++ *++ * - A stream uses its slot's robotstreamer_integrations row and nothing else. An account-level++ * row (no managed_stream_id, 5 of 8 in production before scripts/rs-integrations-to-slots.js)++ * is logged and skipped, never used and never a crash: the old fallback sent every slot++ * without its own row to the same robot.++ * - validate / login / save / remove need a slot (400 without one); GET without a slot answers++ * only the passthrough capability, never the account-level row; no account-level row is++ * written any more.++ * (scripts/rs-integrations-to-slots.js, tested in rs-integrations-to-slots.test.js, moves the++ * account-level rows onto slots before this ships.)++ *++ * node test/rs-slot-only.test.js++ */++'use strict';++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-rs-slot-'));++process.env.DB_PATH = path.join(tmp, 'live.db');++process.env.NODE_ENV = 'test';++const quiet = console.log;++const warnings = [];++console.log = () => {};++console.warn = (...a) => { warnings.push(a.join(' ')); };++++const db = require('../server/db/database');++db.initDb();++const raw = db.getDb();++++const auth = require('../server/auth/auth');++const signIn = (req) => {++ const id = Number(req.headers['x-test-user'] || 0);++ const u = id ? db.getUserById(id) : null;++ if (u) { req.user = u; req.authSource = 'network'; }++ return u;++};++auth.requireAuth = (req, res, next) => (signIn(req) ? next() : res.status(401).json({ error: 'Authentication required' }));++++const addUser = (id, name) => raw.prepare(`INSERT INTO users (id, username, display_name, email, password_hash, role) VALUES (?, ?, ?, ?, 'x', 'streamer')`).run(id, name, name, `${name}@x`);++const slot = (userId, slug) => Number(db.createManagedStream({ user_id: userId, slug, title: slug, stream_key: `key-${userId}-${slug}` }).lastInsertRowid);++const rsRow = (userId, slotId, fields = {}) => Number(raw.prepare(++ `INSERT INTO robotstreamer_integrations (user_id, managed_stream_id, enabled, token, robot_id, stream_name, chat_url)++ VALUES (?, ?, ?, ?, ?, ?, ?)`).run(userId, slotId, fields.enabled ?? 1, fields.token ?? 'tok', fields.robot_id ?? null, fields.stream_name ?? null, 'wss://chat.example').lastInsertRowid);++const accountRow = (userId) => raw.prepare('SELECT * FROM robotstreamer_integrations WHERE user_id = ? AND managed_stream_id IS NULL').get(userId);++++// User 1 has three slots (like production's user 1: slots 1/60/85), an enabled account-level row++// and robot 777 configured on slot "second" only.++addUser(1, 'alex');++const a1 = slot(1, 'main'), a2 = slot(1, 'second'), a3 = slot(1, 'third');++const r1 = rsRow(1, null, { robot_id: '777', stream_name: 'Robot' });++const r1slot = rsRow(1, a2, { robot_id: '777' });++// User 2 owns slot b1 (for the ownership check).++addUser(2, 'solo');++const b1 = slot(2, 'only');++++let server;++(async () => {++ // ── Runtime: slot-only, account rows skipped (not crashing) ──────────────────────++ const rs = require('../server/integrations/robotstreamer-service');++ assert.strictEqual(db.getRobotStreamerIntegrationForStream(1, a2).id, r1slot, 'a slot uses its own row');++ assert.strictEqual(db.getRobotStreamerIntegrationForStream(1, a1), null, 'a slot without a row does NOT fall back to the account-level row');++ assert.strictEqual(db.getRobotStreamerIntegrationForStream(1, null), null, 'a slot-less stream gets no RobotStreamer');++ assert.strictEqual(warnings.filter((w) => /account-level RobotStreamer row/.test(w)).length, 1, 'the unmigrated row is logged once per user');++ assert.ok(warnings.some((w) => w.includes(`(${r1})`) && w.includes('rs-integrations-to-slots')), 'the log names the row and the script');++ assert.strictEqual(rs.getIntegrationForStream({ id: 1, user_id: 1, managed_stream_id: a3 }), null);++ assert.strictEqual(await rs.startForStream({ id: 99, user_id: 1, managed_stream_id: a1, protocol: 'webrtc' }), null, 'going live on a slot without a row starts nothing');++ assert.strictEqual(await rs.refreshIntegration(1, null), null, 'no account-level refresh');++ assert.throws(() => db.upsertRobotStreamerIntegration(1, { enabled: 1 }, null), /stream slot/, 'no account-level row is written');++ await assert.rejects(rs.upsertIntegration(1, { enabled: 1 }, null), /stream slot/);++++ // ── Routes ───────────────────────────────────────────────────────────────────────++ rs.validateConfiguration = async ({ token, robotInput }) => {++ if (!token) throw new Error('RobotStreamer token is required');++ return { fields: { token, robot_id: String(robotInput), stream_name: `Robot ${robotInput}`, last_validated_at: new Date().toISOString() }, availableRobots: [{ robot_id: String(robotInput), viewers: 3 }] };++ };++ const express = require('express');++ const app = express();++ app.use(express.json());++ app.use('/api/robotstreamer', require('../server/integrations/routes'));++ server = http.createServer(app).listen(0);++ const call = (method, p, user, body) => new Promise((resolve, reject) => {++ const data = body ? JSON.stringify(body) : null;++ const req = http.request({ port: server.address().port, path: p, method, headers: { 'content-type': 'application/json', 'x-test-user': String(user) } }, (res) => {++ let text = '';++ res.on('data', (c) => { text += c; });++ res.on('end', () => { let json = null; try { json = JSON.parse(text); } catch { /* */ } resolve({ status: res.statusCode, json }); });++ });++ req.on('error', reject);++ if (data) req.write(data);++ req.end();++ });++++ let r = await call('GET', '/api/robotstreamer/integration', 1);++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.json.exists, false, 'GET without a slot does not return the account-level row');++ assert.strictEqual(r.json.integration.robot_id, '', 'the account-level robot is not shown as if it applied');++ assert.ok('passthrough' in r.json.integration, 'GET without a slot still says whether the server relays the video');++ r = await call('GET', `/api/robotstreamer/integration?managed_stream_id=${a2}`, 1);++ assert.strictEqual(r.json.exists, true);++ assert.strictEqual(r.json.integration.robot_id, '777');++++ for (const [method, p, body] of [++ ['POST', '/api/robotstreamer/integration/validate', { robot_input: '777' }],++ ['POST', '/api/robotstreamer/integration/login', { user_name: 'u', password: 'p' }],++ ['PUT', '/api/robotstreamer/integration', { enabled: true, token: 't', robot_input: '1' }],++ ['DELETE', '/api/robotstreamer/integration', null],++ ]) {++ r = await call(method, p, 1, body);++ assert.strictEqual(r.status, 400, `${method} ${p} without a slot is refused`);++ assert.match(r.json.error, /stream slot/);++ }++ // The old validate-without-slot path read the account token; with a slot it uses only the slot's.++ r = await call('POST', '/api/robotstreamer/integration/validate', 1, { robot_input: '888', managed_stream_id: a1 });++ assert.strictEqual(r.status, 400, 'slot a1 has no token of its own, and the account token is not borrowed');++ r = await call('POST', '/api/robotstreamer/integration/validate', 1, { robot_input: '777', managed_stream_id: a2 });++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.json.integration.managed_stream_id, a2);++ r = await call('PUT', '/api/robotstreamer/integration', 1, { enabled: true, token: 'fresh', robot_input: '888', managed_stream_id: a1 });++ assert.strictEqual(r.status, 200);++ assert.strictEqual(db.getRobotStreamerIntegrationBySlot(1, a1).robot_id, '888', 'saving with a slot writes that slot\'s row');++ assert.strictEqual(accountRow(1).robot_id, '777', 'and leaves the account-level row alone');++ r = await call('PUT', '/api/robotstreamer/integration', 1, { enabled: true, robot_input: '1', managed_stream_id: b1 });++ assert.strictEqual(r.status, 403, 'another user\'s slot is refused');++ server.close();++++ fs.rmSync(tmp, { recursive: true, force: true });++ quiet('rs-slot-only: ok');++ process.exit(0);++})().catch((err) => {++ quiet(err);++ try { server && server.close(); } catch { /* */ }++ process.exit(1);++});
@@ -1101,11 +1101,11 @@ https://www.101soundboards.com/sounds/12345 150p 2</code></pre>
@@ -1101,11 +1101,11 @@ https://www.101soundboards.com/sounds/12345 150p 2</code></pre>
</table>
</table>
<h3>RobotStreamer — <code>/api/robotstreamer</code></h3>
<h3>RobotStreamer — <code>/api/robotstreamer</code></h3>
-<p>Publish a server-ingested stream to a RobotStreamer robot and (optionally) mirror its chat. Config is per slot with an account-level default. All <code>requireAuth</code>.</p>
+<p>Publish a server-ingested stream to a RobotStreamer robot and (optionally) mirror its chat. Config is per stream slot only: a slot without its own config does not restream to RobotStreamer, and every call except GET needs <code>managed_stream_id</code>. All <code>requireAuth</code>.</p>
<table class="doc-table">
<table class="doc-table">
<thead><tr><th>Method</th><th>Path</th><th>Notes</th></tr></thead>
<thead><tr><th>Method</th><th>Path</th><th>Notes</th></tr></thead>
<tbody>
<tbody>
-<tr><td>GET</td><td>/api/robotstreamer/integration</td><td>Load RS settings + available robots (<code>?managed_stream_id</code>)</td></tr>
+<tr><td>GET</td><td>/api/robotstreamer/integration</td><td>Load a slot's RS settings + available robots (<code>?managed_stream_id</code>; without it only the server's passthrough capability, <code>exists:false</code>)</td></tr>
<tr><td>POST</td><td>/api/robotstreamer/integration/validate · /login</td><td>Validate token+robot / log in with RS credentials to auto-fetch token+robots</td></tr>
<tr><td>POST</td><td>/api/robotstreamer/integration/validate · /login</td><td>Validate token+robot / log in with RS credentials to auto-fetch token+robots</td></tr>
<tr><td>PUT / DELETE</td><td>/api/robotstreamer/integration</td><td>Save config (starts publish + optional chat bridge on live streams) / remove</td></tr>
<tr><td>PUT / DELETE</td><td>/api/robotstreamer/integration</td><td>Save config (starts publish + optional chat bridge on live streams) / remove</td></tr>
</tbody>
</tbody>
@@ -3036,7 +3036,7 @@ function _wsShowRestreamForm(existing) {
@@ -3036,7 +3036,7 @@ function _wsShowRestreamForm(existing) {
// RobotStreamer — saved as a per-slot integration, not an RTMP destination row
// RobotStreamer — saved as a per-slot integration, not an RTMP destination row
if (plat && plat.isRobotStreamer) {
if (plat && plat.isRobotStreamer) {
const payload = _wsGetSlotRsFormData();
const payload = _wsGetSlotRsFormData();
- if (!payload.token && !_wsSlotRsIntegration?.has_token && !broadcastState?.robotStreamer?.hasToken) {
+ if (!payload.token && !_wsSlotRsIntegration?.has_token) {
toast('Paste your RobotStreamer token first', 'error');
toast('Paste your RobotStreamer token first', 'error');
return;
return;
}
}
@@ -3312,11 +3312,10 @@ function _wsRestreamPlatformChanged() {
@@ -3312,11 +3312,10 @@ function _wsRestreamPlatformChanged() {
const enabledEl = document.getElementById('ws-rs-rs-enabled');
const enabledEl = document.getElementById('ws-rs-rs-enabled');
const mirrorEl = document.getElementById('ws-rs-rs-mirror-chat');
const mirrorEl = document.getElementById('ws-rs-rs-mirror-chat');
if (tokenEl) {
if (tokenEl) {
+ // Tokens are per slot (there is no account-level token to reuse any more).
tokenEl.placeholder = rs?.has_token
tokenEl.placeholder = rs?.has_token
? 'Token saved for this slot — paste a new one only to replace it'
? 'Token saved for this slot — paste a new one only to replace it'
- : (broadcastState?.robotStreamer?.hasToken
- ? 'Leave empty to reuse your account token, or paste a slot-specific one'
- : 'Paste your robotstreamer-token cookie or JWT');
+ : 'Paste your robotstreamer-token cookie or JWT';
}
}
if (robotEl && rs?.robot_id && !robotEl.value) robotEl.value = rs.robot_id;
if (robotEl && rs?.robot_id && !robotEl.value) robotEl.value = rs.robot_id;
if (enabledEl && rs) enabledEl.checked = !!rs.enabled;
if (enabledEl && rs) enabledEl.checked = !!rs.enabled;
@@ -11,8 +11,8 @@
@@ -11,8 +11,8 @@
/* ── Per-slot RobotStreamer config ───────────────────────────── */
/* ── Per-slot RobotStreamer config ───────────────────────────── */
// Each stream slot (managed stream) can carry its own RobotStreamer
// Each stream slot (managed stream) can carry its own RobotStreamer
// token + robot, configured in the slot's Restream Destinations panel.
// token + robot, configured in the slot's Restream Destinations panel.
-// Slots with their own config auto-restream independently; slots without
-// fall back to the account-level config + legacy single-slot reservation.
+// RobotStreamer is per slot only: a slot without its own config does not
+// restream to RobotStreamer (there is no account-level fallback).
const _rsSlotIntegrationCache = new Map(); // managed_stream_id → { fetchedAt, integration|null }
const _rsSlotIntegrationCache = new Map(); // managed_stream_id → { fetchedAt, integration|null }
async function fetchSlotRsIntegration(managedStreamId, { force = false } = {}) {
async function fetchSlotRsIntegration(managedStreamId, { force = false } = {}) {
@@ -297,28 +297,19 @@ function updateRsRestreamSlotUI() {
@@ -297,28 +297,19 @@ function updateRsRestreamSlotUI() {
panel.innerHTML = html;
panel.innerHTML = html;
}
}
-/** Conditionally start RS restream — slot-specific config first, legacy account slot second */
+/** Conditionally start RS restream — only from the stream's own slot config */
async function maybeStartRsRestream(streamId) {
async function maybeStartRsRestream(streamId) {
const ss = getStreamState(streamId);
const ss = getStreamState(streamId);
const msId = ss?.streamData?.managed_stream_id || null;
const msId = ss?.streamData?.managed_stream_id || null;
const slotRs = await fetchSlotRsIntegration(msId);
const slotRs = await fetchSlotRsIntegration(msId);
- if (slotRs) {
- // This slot has its own RS config — it restreams independently of the
- // legacy single-slot reservation and of other slots.
- if (ss) ss._rsSlotIntegration = slotRs;
- if (rsSlotIntegrationUsable(slotRs)) {
- startRobotStreamerRestream(streamId).catch(() => {});
- updateRsRestreamSlotUI();
- }
- return;
+ // No slot, or a slot without its own RobotStreamer config: nothing to restream.
+ if (!slotRs) return;
+ // This slot has its own RS config — it restreams independently of other slots.
+ if (ss) ss._rsSlotIntegration = slotRs;
+ if (rsSlotIntegrationUsable(slotRs)) {
+ startRobotStreamerRestream(streamId).catch(() => {});
+ updateRsRestreamSlotUI();
}
}
- // Legacy account-level behavior
- if (!isRsRestreamAssigned(streamId)) return;
- // Auto-assign the slot on first live stream
- const currentSlot = getRsRestreamSlotStreamId();
- if (!currentSlot) setRsRestreamSlot(streamId);
- startRobotStreamerRestream(streamId).catch(() => {});
- updateRsRestreamSlotUI();
}
}
function sendBroadcastSignal(ss, msg) {
function sendBroadcastSignal(ss, msg) {
@@ -703,9 +694,11 @@ function syncRobotStreamerUI() {
@@ -703,9 +694,11 @@ function syncRobotStreamerUI() {
? 'Saved token on file — paste a new one only if you want to replace it'
? 'Saved token on file — paste a new one only if you want to replace it'
: 'Paste your robotstreamer-token cookie or JWT';
: 'Paste your robotstreamer-token cookie or JWT';
- const summary = rs.ownerName || rs.streamName
- ? `Configured for ${rs.streamName || `Robot ${rs.robotId}`} as ${rs.ownerName || rs.ownerId || 'unknown owner'}.`
- : 'RobotStreamer restreaming works with browser WebRTC broadcasting. Paste your RobotStreamer token, validate a robot, then go live.';
+ const summary = !rs.managedStreamId
+ ? 'Pick a stream slot: RobotStreamer is set up per stream slot.'
+ : rs.ownerName || rs.streamName
+ ? `Configured for ${rs.streamName || `Robot ${rs.robotId}`} as ${rs.ownerName || rs.ownerId || 'unknown owner'}.`
+ : 'RobotStreamer restreaming works with browser WebRTC broadcasting. Paste your RobotStreamer token, validate a robot, then go live.';
setRobotStreamerStatus(summary, rs.hasToken ? 'success' : 'info', 'bc-rsStatus');
setRobotStreamerStatus(summary, rs.hasToken ? 'success' : 'info', 'bc-rsStatus');
// Update live status based on current state
// Update live status based on current state
@@ -739,10 +732,14 @@ function syncRobotStreamerUI() {
@@ -739,10 +732,14 @@ function syncRobotStreamerUI() {
async function loadRobotStreamerIntegration() {
async function loadRobotStreamerIntegration() {
const doLoad = async () => {
const doLoad = async () => {
try {
try {
- const data = await api('/robotstreamer/integration');
+ // The settings here are the RobotStreamer config of the slot this page is on. Without a
+ // slot the server answers only whether it relays the video (passthrough), exists:false.
+ const managedStreamId = _restreamPanelSlotId();
+ const data = await api(managedStreamId ? `/robotstreamer/integration?managed_stream_id=${managedStreamId}` : '/robotstreamer/integration');
const integration = data.integration || {};
const integration = data.integration || {};
broadcastState.robotStreamer = {
broadcastState.robotStreamer = {
loaded: true,
loaded: true,
+ managedStreamId: managedStreamId || null,
enabled: !!integration.enabled,
enabled: !!integration.enabled,
mirrorChat: integration.mirror_chat !== false,
mirrorChat: integration.mirror_chat !== false,
hasToken: !!integration.has_token,
hasToken: !!integration.has_token,
@@ -772,11 +769,20 @@ function getRobotStreamerFormData() {
@@ -772,11 +769,20 @@ function getRobotStreamerFormData() {
mirror_chat: !!document.getElementById('bc-rsMirrorChat')?.checked,
mirror_chat: !!document.getElementById('bc-rsMirrorChat')?.checked,
token: document.getElementById('bc-rsToken')?.value.trim() || '',
token: document.getElementById('bc-rsToken')?.value.trim() || '',
robot_input: selectRobot || inputRobot,
robot_input: selectRobot || inputRobot,
+ managed_stream_id: _restreamPanelSlotId() || null,
};
};
}
}
+/** RobotStreamer settings belong to a stream slot; say so instead of calling the server without one. */
+function _rsNeedsSlot(payload) {
+ if (payload.managed_stream_id) return false;
+ setRobotStreamerStatus('Pick a stream slot first: RobotStreamer is set up per stream slot.', 'error');
+ return true;
+}
+
async function validateRobotStreamerIntegration() {
async function validateRobotStreamerIntegration() {
const payload = getRobotStreamerFormData();
const payload = getRobotStreamerFormData();
+ if (_rsNeedsSlot(payload)) return;
setRobotStreamerStatus('Validating RobotStreamer settings…', 'info');
setRobotStreamerStatus('Validating RobotStreamer settings…', 'info');
try {
try {
const data = await api('/robotstreamer/integration/validate', { method: 'POST', body: payload });
const data = await api('/robotstreamer/integration/validate', { method: 'POST', body: payload });
@@ -802,6 +808,7 @@ async function validateRobotStreamerIntegration() {
@@ -802,6 +808,7 @@ async function validateRobotStreamerIntegration() {
async function saveRobotStreamerIntegration() {
async function saveRobotStreamerIntegration() {
const payload = getRobotStreamerFormData();
const payload = getRobotStreamerFormData();
+ if (_rsNeedsSlot(payload)) return;
setRobotStreamerStatus('Saving RobotStreamer settings…', 'info');
setRobotStreamerStatus('Saving RobotStreamer settings…', 'info');
try {
try {
const data = await api('/robotstreamer/integration', { method: 'PUT', body: payload });
const data = await api('/robotstreamer/integration', { method: 'PUT', body: payload });
@@ -822,9 +829,15 @@ async function saveRobotStreamerIntegration() {
@@ -822,9 +829,15 @@ async function saveRobotStreamerIntegration() {
if (tokenEl) tokenEl.value = '';
if (tokenEl) tokenEl.value = '';
syncRobotStreamerUI();
syncRobotStreamerUI();
setRobotStreamerStatus('RobotStreamer settings saved.', 'success');
setRobotStreamerStatus('RobotStreamer settings saved.', 'success');
+ // The saved config is this slot's: refresh its cache and restart only its streams.
+ _rsSlotIntegrationCache.delete(payload.managed_stream_id);
const restreamTargets = [...broadcastState.streams.entries()]
const restreamTargets = [...broadcastState.streams.entries()]
- .filter(([, state]) => state?.localStream)
+ .filter(([, state]) => state?.localStream && state.streamData?.managed_stream_id === payload.managed_stream_id)
.map(([streamId]) => streamId);
.map(([streamId]) => streamId);
+ for (const streamId of restreamTargets) {
+ const ss = getStreamState(streamId);
+ if (ss) ss._rsSlotIntegration = null;
+ }
for (const streamId of restreamTargets) {
for (const streamId of restreamTargets) {
if (broadcastState.robotStreamer.enabled && isRsRestreamAssigned(streamId)) {
if (broadcastState.robotStreamer.enabled && isRsRestreamAssigned(streamId)) {
stopRobotStreamerRestream(streamId, { quiet: true })
stopRobotStreamerRestream(streamId, { quiet: true })
@@ -3511,21 +3524,18 @@ async function startRobotStreamerRestream(streamId) {
@@ -3511,21 +3524,18 @@ async function startRobotStreamerRestream(streamId) {
// Wait for integration settings to load first (fixes race condition on page load)
// Wait for integration settings to load first (fixes race condition on page load)
await ensureRobotStreamerLoaded();
await ensureRobotStreamerLoaded();
- // Slot-specific config wins over the account-level integration
+ // Only the stream's own slot config counts: no slot config, no RobotStreamer.
const slotRs = ss._rsSlotIntegration
const slotRs = ss._rsSlotIntegration
|| await fetchSlotRsIntegration(ss.streamData?.managed_stream_id || null);
|| await fetchSlotRsIntegration(ss.streamData?.managed_stream_id || null);
if (slotRs) ss._rsSlotIntegration = slotRs;
if (slotRs) ss._rsSlotIntegration = slotRs;
- if (slotRs) {
- if (!rsSlotIntegrationUsable(slotRs)) {
- console.log('[RS Restream] Slot config not usable — enabled:', slotRs.enabled, 'hasToken:', slotRs.has_token, 'robotId:', slotRs.robot_id);
- if (!slotRs.enabled) setRobotStreamerStatus('RobotStreamer restream is disabled for this stream slot.', 'info', 'bc-rsLiveStatus');
- return null;
- }
- } else if (!canUseRobotStreamerRestream()) {
- const rs = broadcastState.robotStreamer;
- console.log('[RS Restream] Cannot start — enabled:', rs.enabled, 'hasToken:', rs.hasToken, 'robotId:', rs.robotId);
- if (!rs.enabled) setRobotStreamerStatus('RobotStreamer restream is disabled.', 'info', 'bc-rsLiveStatus');
+ if (!slotRs) {
+ console.log('[RS Restream] No RobotStreamer config on this stream slot — skipping');
+ return null;
+ }
+ if (!rsSlotIntegrationUsable(slotRs)) {
+ console.log('[RS Restream] Slot config not usable — enabled:', slotRs.enabled, 'hasToken:', slotRs.has_token, 'robotId:', slotRs.robot_id);
+ if (!slotRs.enabled) setRobotStreamerStatus('RobotStreamer restream is disabled for this stream slot.', 'info', 'bc-rsLiveStatus');
return null;
return null;
}
}
if (ss.robotStreamer?.active) return ss.robotStreamer;
if (ss.robotStreamer?.active) return ss.robotStreamer;
@@ -3533,7 +3543,7 @@ async function startRobotStreamerRestream(streamId) {
@@ -3533,7 +3543,7 @@ async function startRobotStreamerRestream(streamId) {
// Server-side RAW passthrough owns this robot — the server forwards our already-encoded
// Server-side RAW passthrough owns this robot — the server forwards our already-encoded
// stream to RobotStreamer with zero re-encode, so the browser must NOT publish a second
// stream to RobotStreamer with zero re-encode, so the browser must NOT publish a second
// (double) encode. Skip the client publish entirely.
// (double) encode. Skip the client publish entirely.
- if (broadcastState.robotStreamer?.passthrough) {
+ if (slotRs.passthrough || broadcastState.robotStreamer?.passthrough) {
console.log('[RS Restream] Server passthrough active — skipping browser publish for stream', streamId);
console.log('[RS Restream] Server passthrough active — skipping browser publish for stream', streamId);
setRobotStreamerStatus('Restreaming to RobotStreamer via server (raw passthrough, no re-encode).', 'success', 'bc-rsLiveStatus');
setRobotStreamerStatus('Restreaming to RobotStreamer via server (raw passthrough, no re-encode).', 'success', 'bc-rsLiveStatus');
return null;
return null;
@@ -4609,10 +4619,10 @@ async function _startRsViewerPoll() {
@@ -4609,10 +4619,10 @@ async function _startRsViewerPoll() {
try { await _robotStreamerIntegrationPromise; } catch {}
try { await _robotStreamerIntegrationPromise; } catch {}
}
}
const rs = broadcastState.robotStreamer;
const rs = broadcastState.robotStreamer;
- if (!rs?.enabled || !rs?.robotId) return;
+ if (!rs?.enabled || !rs?.robotId || !rs.managedStreamId) return;
const poll = async () => {
const poll = async () => {
try {
try {
- const data = await api('/robotstreamer/integration/validate', { method: 'POST', body: { robot_input: rs.robotId } });
+ const data = await api('/robotstreamer/integration/validate', { method: 'POST', body: { robot_input: rs.robotId, managed_stream_id: rs.managedStreamId } });
const robots = data.integration?.available_robots || [];
const robots = data.integration?.available_robots || [];
const robot = robots.find(r => String(r.robot_id) === String(rs.robotId));
const robot = robots.find(r => String(r.robot_id) === String(rs.robotId));
_rsViewerCount = robot ? Number(robot.viewers || 0) : 0;
_rsViewerCount = robot ? Number(robot.viewers || 0) : 0;
@@ -4329,7 +4329,8 @@ function isStreamClipRecordingEnabled(stream) {
@@ -4329,7 +4329,8 @@ function isStreamClipRecordingEnabled(stream) {
// ── RobotStreamer integration helpers ───────────────────────
// ── RobotStreamer integration helpers ───────────────────────
function getRobotStreamerIntegrationByUserId(userId) {
function getRobotStreamerIntegrationByUserId(userId) {
- // Account-level default row (no slot binding)
+ // A legacy account-level row (no slot binding). It applies to no stream any more:
+ // scripts/rs-integrations-to-slots.js moves these onto slots. Read only to report them.
return get('SELECT * FROM robotstreamer_integrations WHERE user_id = ? AND managed_stream_id IS NULL', [userId]);
return get('SELECT * FROM robotstreamer_integrations WHERE user_id = ? AND managed_stream_id IS NULL', [userId]);
}
}
@@ -4338,10 +4339,28 @@ function getRobotStreamerIntegrationBySlot(userId, managedStreamId) {
@@ -4338,10 +4339,28 @@ function getRobotStreamerIntegrationBySlot(userId, managedStreamId) {
return get('SELECT * FROM robotstreamer_integrations WHERE user_id = ? AND managed_stream_id = ?', [userId, managedStreamId]);
return get('SELECT * FROM robotstreamer_integrations WHERE user_id = ? AND managed_stream_id = ?', [userId, managedStreamId]);
}
}
+// Users already warned about an unmigrated account-level row (once per process each).
+const _rsAccountRowWarned = new Set();
+
+/**
+ * The RobotStreamer config a stream on this slot uses: the slot's own row, and nothing else.
+ * No slot (a legacy session) or a slot without a row means no RobotStreamer. An account-level
+ * row that has not been moved onto a slot yet is logged and skipped, never used: that fallback
+ * sent every slot without its own row to the same robot.
+ */
function getRobotStreamerIntegrationForStream(userId, managedStreamId) {
function getRobotStreamerIntegrationForStream(userId, managedStreamId) {
- // Slot-specific config wins; fall back to the account-level default row
- return (managedStreamId ? getRobotStreamerIntegrationBySlot(userId, managedStreamId) : null)
- || getRobotStreamerIntegrationByUserId(userId);
+ const row = managedStreamId ? getRobotStreamerIntegrationBySlot(userId, managedStreamId) : null;
+ if (row) return row;
+ try {
+ if (!_rsAccountRowWarned.has(userId)) {
+ const legacy = getRobotStreamerIntegrationByUserId(userId);
+ if (legacy) {
+ _rsAccountRowWarned.add(userId);
+ console.warn(`[RS] User ${userId} has an account-level RobotStreamer row (${legacy.id}) that applies to no stream; skipped. Bind it to a slot with scripts/rs-integrations-to-slots.js.`);
+ }
+ }
+ } catch { /* reporting only */ }
+ return null;
}
}
function deleteRobotStreamerIntegrationForSlot(userId, managedStreamId) {
function deleteRobotStreamerIntegrationForSlot(userId, managedStreamId) {
@@ -4363,10 +4382,10 @@ function upsertRobotStreamerIntegration(userId, fields, managedStreamId = null)
@@ -4363,10 +4382,10 @@ function upsertRobotStreamerIntegration(userId, fields, managedStreamId = null)
'owner_name',
'owner_name',
'last_validated_at',
'last_validated_at',
]);
]);
+ // RobotStreamer is configured per stream slot; account-level rows are no longer written.
const slotId = managedStreamId || null;
const slotId = managedStreamId || null;
- const existing = slotId
- ? getRobotStreamerIntegrationBySlot(userId, slotId)
- : getRobotStreamerIntegrationByUserId(userId);
+ if (!slotId) throw new Error('RobotStreamer settings belong to a stream slot (managed_stream_id is required)');
+ const existing = getRobotStreamerIntegrationBySlot(userId, slotId);
const filtered = Object.entries(fields || {}).filter(([key, val]) => allowed.has(key) && val !== undefined);
const filtered = Object.entries(fields || {}).filter(([key, val]) => allowed.has(key) && val !== undefined);
if (!filtered.length) return existing;
if (!filtered.length) return existing;
@@ -4391,9 +4410,7 @@ function upsertRobotStreamerIntegration(userId, fields, managedStreamId = null)
@@ -4391,9 +4410,7 @@ function upsertRobotStreamerIntegration(userId, fields, managedStreamId = null)
);
);
}
}
- return slotId
- ? getRobotStreamerIntegrationBySlot(userId, slotId)
- : getRobotStreamerIntegrationByUserId(userId);
+ return getRobotStreamerIntegrationBySlot(userId, slotId);
}
}
// ── Restream Destination helpers ─────────────────────────────
// ── Restream Destination helpers ─────────────────────────────
@@ -185,6 +185,9 @@ class RobotStreamerService {
@@ -185,6 +185,9 @@ class RobotStreamerService {
last_validated_at: null,
last_validated_at: null,
managed_stream_id: null,
managed_stream_id: null,
available_robots: [],
available_robots: [],
+ // Whether the server relays the video (a property of this server, not of a row):
+ // the broadcast page needs it even before a slot has a RobotStreamer row.
+ passthrough: this._passthroughEnabledFor(null),
...extras,
...extras,
};
};
}
}
@@ -220,14 +223,9 @@ class RobotStreamerService {
@@ -220,14 +223,9 @@ class RobotStreamerService {
} catch { return false; }
} catch { return false; }
}
}
- getClientIntegration(userId) {
- return this.sanitizeIntegration(db.getRobotStreamerIntegrationByUserId(userId));
- }
-
/**
/**
- * Resolve the effective integration for a stream: the slot-specific row
- * (matching the stream's managed_stream_id) wins, otherwise the
- * account-level default row is used.
+ * The integration for a stream: its slot's row, or null (no slot, or no row on the slot).
+ * There is no account-level fallback; see db.getRobotStreamerIntegrationForStream.
*/
*/
getIntegrationForStream(stream) {
getIntegrationForStream(stream) {
if (!stream?.user_id) return null;
if (!stream?.user_id) return null;
@@ -452,13 +450,8 @@ class RobotStreamerService {
@@ -452,13 +450,8 @@ class RobotStreamerService {
async upsertIntegration(userId, payload = {}, managedStreamId = null) {
async upsertIntegration(userId, payload = {}, managedStreamId = null) {
const slotId = managedStreamId || null;
const slotId = managedStreamId || null;
- const existing = slotId
- ? db.getRobotStreamerIntegrationBySlot(userId, slotId)
- : db.getRobotStreamerIntegrationByUserId(userId);
- // When creating a slot-specific config, allow token/robot to fall back to
- // the account-level default row so users don't have to re-paste the token
- // for every slot.
- const fallback = existing || (slotId ? db.getRobotStreamerIntegrationByUserId(userId) : null);
+ if (!slotId) throw new Error('Choose a stream slot: RobotStreamer is set up per stream slot');
+ const existing = db.getRobotStreamerIntegrationBySlot(userId, slotId);
const updates = {
const updates = {
enabled: normalizeBoolean(payload.enabled, existing ? !!existing.enabled : false) ? 1 : 0,
enabled: normalizeBoolean(payload.enabled, existing ? !!existing.enabled : false) ? 1 : 0,
mirror_chat: normalizeBoolean(payload.mirror_chat, existing ? existing.mirror_chat !== 0 : true) ? 1 : 0,
mirror_chat: normalizeBoolean(payload.mirror_chat, existing ? existing.mirror_chat !== 0 : true) ? 1 : 0,
@@ -474,22 +467,11 @@ class RobotStreamerService {
@@ -474,22 +467,11 @@ class RobotStreamerService {
let availableRobots = [];
let availableRobots = [];
if (needsValidation) {
if (needsValidation) {
const validated = await this.validateConfiguration({
const validated = await this.validateConfiguration({
- token: providedToken || fallback?.token,
- robotInput: providedRobot || fallback?.robot_id,
+ token: providedToken || existing?.token,
+ robotInput: providedRobot || existing?.robot_id,
});
});
Object.assign(updates, validated.fields);
Object.assign(updates, validated.fields);
availableRobots = validated.availableRobots;
availableRobots = validated.availableRobots;
- } else if (!existing && fallback) {
- // Creating a slot row without re-validating — copy config from the default row
- updates.token = fallback.token;
- updates.robot_id = fallback.robot_id;
- updates.owner_id = fallback.owner_id;
- updates.chat_url = fallback.chat_url;
- updates.control_url = fallback.control_url;
- updates.rtc_sfu_url = fallback.rtc_sfu_url;
- updates.stream_name = fallback.stream_name;
- updates.owner_name = fallback.owner_name;
- updates.last_validated_at = fallback.last_validated_at;
}
}
const row = db.upsertRobotStreamerIntegration(userId, updates, slotId);
const row = db.upsertRobotStreamerIntegration(userId, updates, slotId);
@@ -501,9 +483,8 @@ class RobotStreamerService {
@@ -501,9 +483,8 @@ class RobotStreamerService {
async refreshIntegration(userId, managedStreamId = null) {
async refreshIntegration(userId, managedStreamId = null) {
const slotId = managedStreamId || null;
const slotId = managedStreamId || null;
- const existing = slotId
- ? db.getRobotStreamerIntegrationBySlot(userId, slotId)
- : db.getRobotStreamerIntegrationByUserId(userId);
+ if (!slotId) return null; // per slot only: there is no account-level row to refresh
+ const existing = db.getRobotStreamerIntegrationBySlot(userId, slotId);
if (!existing?.token || !existing?.robot_id) return existing;
if (!existing?.token || !existing?.robot_id) return existing;
const validated = await this.validateConfiguration({ token: existing.token, robotInput: existing.robot_id });
const validated = await this.validateConfiguration({ token: existing.token, robotInput: existing.robot_id });
return db.upsertRobotStreamerIntegration(userId, {
return db.upsertRobotStreamerIntegration(userId, {
@@ -28,24 +28,33 @@ function resolveSlotId(req, res) {
@@ -28,24 +28,33 @@ function resolveSlotId(req, res) {
}
}
/**
/**
- * Live streams whose effective RS config is the given row (slot or default).
- * For a slot row: only streams on that slot. For the default row: only streams
- * that do NOT have their own slot-specific config.
+ * RobotStreamer is configured per stream slot only: the writes (validate, login, save, remove)
+ * need a slot. Returns the slot id, or false with a 400 already sent.
*/
*/
+function requireSlotId(req, res) {
+ const slotId = resolveSlotId(req, res);
+ if (slotId === false) return false;
+ if (!slotId) {
+ res.status(400).json({ error: 'Choose a stream slot: RobotStreamer is set up per stream slot (managed_stream_id is required)' });
+ return false;
+ }
+ return slotId;
+}
+
+/** Live streams on the slot whose RS config this is (a slot's row applies to that slot only). */
function liveStreamsForConfig(userId, slotId) {
function liveStreamsForConfig(userId, slotId) {
const liveStreams = db.getLiveStreamsByUserId(userId) || [];
const liveStreams = db.getLiveStreamsByUserId(userId) || [];
- if (slotId) return liveStreams.filter(s => s.managed_stream_id === slotId);
- return liveStreams.filter(s => !s.managed_stream_id || !db.getRobotStreamerIntegrationBySlot(userId, s.managed_stream_id));
+ return liveStreams.filter(s => s.managed_stream_id === slotId);
}
}
+// GET without a slot answers what does not depend on one (the server passthrough capability),
+// with exists:false — never the legacy account-level row, which applies to no stream.
router.get('/integration', requireAuth, async (req, res) => {
router.get('/integration', requireAuth, async (req, res) => {
try {
try {
const slotId = resolveSlotId(req, res);
const slotId = resolveSlotId(req, res);
if (slotId === false) return;
if (slotId === false) return;
- const row = slotId
- ? db.getRobotStreamerIntegrationBySlot(req.user.id, slotId)
- : db.getRobotStreamerIntegrationByUserId(req.user.id);
+ const row = slotId ? db.getRobotStreamerIntegrationBySlot(req.user.id, slotId) : null;
let availableRobots = [];
let availableRobots = [];
// If a saved token + robot exists, re-fetch available robots so the dropdown populates on reload
// If a saved token + robot exists, re-fetch available robots so the dropdown populates on reload
@@ -69,11 +78,10 @@ router.get('/integration', requireAuth, async (req, res) => {
@@ -69,11 +78,10 @@ router.get('/integration', requireAuth, async (req, res) => {
router.post('/integration/validate', requireAuth, async (req, res) => {
router.post('/integration/validate', requireAuth, async (req, res) => {
try {
try {
- const slotId = resolveSlotId(req, res);
+ const slotId = requireSlotId(req, res);
if (slotId === false) return;
if (slotId === false) return;
- const existing = (slotId ? db.getRobotStreamerIntegrationBySlot(req.user.id, slotId) : null)
- || db.getRobotStreamerIntegrationByUserId(req.user.id);
+ const existing = db.getRobotStreamerIntegrationBySlot(req.user.id, slotId);
const token = typeof req.body.token === 'string' && req.body.token.trim()
const token = typeof req.body.token === 'string' && req.body.token.trim()
? req.body.token.trim()
? req.body.token.trim()
: existing?.token;
: existing?.token;
@@ -106,7 +114,7 @@ router.post('/integration/validate', requireAuth, async (req, res) => {
@@ -106,7 +114,7 @@ router.post('/integration/validate', requireAuth, async (req, res) => {
// as an easier alternative to pasting the token manually. The password is never stored.
// as an easier alternative to pasting the token manually. The password is never stored.
router.post('/integration/login', requireAuth, async (req, res) => {
router.post('/integration/login', requireAuth, async (req, res) => {
try {
try {
- const slotId = resolveSlotId(req, res);
+ const slotId = requireSlotId(req, res);
if (slotId === false) return;
if (slotId === false) return;
const login = await robotStreamerService.loginWithCredentials(req.body?.user_name, req.body?.password);
const login = await robotStreamerService.loginWithCredentials(req.body?.user_name, req.body?.password);
@@ -131,9 +139,7 @@ router.post('/integration/login', requireAuth, async (req, res) => {
@@ -131,9 +139,7 @@ router.post('/integration/login', requireAuth, async (req, res) => {
}
}
}
}
if (!integration) {
if (!integration) {
- const row = slotId
- ? db.getRobotStreamerIntegrationBySlot(req.user.id, slotId)
- : db.getRobotStreamerIntegrationByUserId(req.user.id);
+ const row = db.getRobotStreamerIntegrationBySlot(req.user.id, slotId);
integration = robotStreamerService.sanitizeIntegration(row, { available_robots: login.robots });
integration = robotStreamerService.sanitizeIntegration(row, { available_robots: login.robots });
}
}
@@ -151,7 +157,7 @@ router.post('/integration/login', requireAuth, async (req, res) => {
@@ -151,7 +157,7 @@ router.post('/integration/login', requireAuth, async (req, res) => {
router.put('/integration', requireAuth, async (req, res) => {
router.put('/integration', requireAuth, async (req, res) => {
try {
try {
- const slotId = resolveSlotId(req, res);
+ const slotId = requireSlotId(req, res);
if (slotId === false) return;
if (slotId === false) return;
const result = await robotStreamerService.upsertIntegration(req.user.id, req.body || {}, slotId);
const result = await robotStreamerService.upsertIntegration(req.user.id, req.body || {}, slotId);
@@ -222,12 +228,11 @@ router.post('/restream/stop', requireAuth, (req, res) => {
@@ -222,12 +228,11 @@ router.post('/restream/stop', requireAuth, (req, res) => {
}
}
});
});
-// Remove a slot-specific RS config (the slot falls back to the account default)
+// Remove a slot's RS config (the slot then has no RobotStreamer)
router.delete('/integration', requireAuth, (req, res) => {
router.delete('/integration', requireAuth, (req, res) => {
try {
try {
- const slotId = resolveSlotId(req, res);
+ const slotId = requireSlotId(req, res);
if (slotId === false) return;
if (slotId === false) return;
- if (!slotId) return res.status(400).json({ error: 'managed_stream_id is required' });
for (const stream of liveStreamsForConfig(req.user.id, slotId)) {
for (const stream of liveStreamsForConfig(req.user.id, slotId)) {
robotStreamerService.stopForStream(stream.id);
robotStreamerService.stopForStream(stream.id);
new file mode 100644
new file mode 100644
@@ -0,0 +1,138 @@
@@ -0,0 +1,138 @@
+/**
+ * RobotStreamer is configured per stream slot only.
+ *
+ * - A stream uses its slot's robotstreamer_integrations row and nothing else. An account-level
+ * row (no managed_stream_id, 5 of 8 in production before scripts/rs-integrations-to-slots.js)
+ * is logged and skipped, never used and never a crash: the old fallback sent every slot
+ * without its own row to the same robot.
+ * - validate / login / save / remove need a slot (400 without one); GET without a slot answers
+ * only the passthrough capability, never the account-level row; no account-level row is
+ * written any more.
+ * (scripts/rs-integrations-to-slots.js, tested in rs-integrations-to-slots.test.js, moves the
+ * account-level rows onto slots before this ships.)
+ *
+ * node test/rs-slot-only.test.js
+ */
+'use strict';
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+
+const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-rs-slot-'));
+process.env.DB_PATH = path.join(tmp, 'live.db');
+process.env.NODE_ENV = 'test';
+const quiet = console.log;
+const warnings = [];
+console.log = () => {};
+console.warn = (...a) => { warnings.push(a.join(' ')); };
+
+const db = require('../server/db/database');
+db.initDb();
+const raw = db.getDb();
+
+const auth = require('../server/auth/auth');
+const signIn = (req) => {
+ const id = Number(req.headers['x-test-user'] || 0);
+ const u = id ? db.getUserById(id) : null;
+ if (u) { req.user = u; req.authSource = 'network'; }
+ return u;
+};
+auth.requireAuth = (req, res, next) => (signIn(req) ? next() : res.status(401).json({ error: 'Authentication required' }));
+
+const addUser = (id, name) => raw.prepare(`INSERT INTO users (id, username, display_name, email, password_hash, role) VALUES (?, ?, ?, ?, 'x', 'streamer')`).run(id, name, name, `${name}@x`);
+const slot = (userId, slug) => Number(db.createManagedStream({ user_id: userId, slug, title: slug, stream_key: `key-${userId}-${slug}` }).lastInsertRowid);
+const rsRow = (userId, slotId, fields = {}) => Number(raw.prepare(
+ `INSERT INTO robotstreamer_integrations (user_id, managed_stream_id, enabled, token, robot_id, stream_name, chat_url)
+ VALUES (?, ?, ?, ?, ?, ?, ?)`).run(userId, slotId, fields.enabled ?? 1, fields.token ?? 'tok', fields.robot_id ?? null, fields.stream_name ?? null, 'wss://chat.example').lastInsertRowid);
+const accountRow = (userId) => raw.prepare('SELECT * FROM robotstreamer_integrations WHERE user_id = ? AND managed_stream_id IS NULL').get(userId);
+
+// User 1 has three slots (like production's user 1: slots 1/60/85), an enabled account-level row
+// and robot 777 configured on slot "second" only.
+addUser(1, 'alex');
+const a1 = slot(1, 'main'), a2 = slot(1, 'second'), a3 = slot(1, 'third');
+const r1 = rsRow(1, null, { robot_id: '777', stream_name: 'Robot' });
+const r1slot = rsRow(1, a2, { robot_id: '777' });
+// User 2 owns slot b1 (for the ownership check).
+addUser(2, 'solo');
+const b1 = slot(2, 'only');
+
+let server;
+(async () => {
+ // ── Runtime: slot-only, account rows skipped (not crashing) ──────────────────────
+ const rs = require('../server/integrations/robotstreamer-service');
+ assert.strictEqual(db.getRobotStreamerIntegrationForStream(1, a2).id, r1slot, 'a slot uses its own row');
+ assert.strictEqual(db.getRobotStreamerIntegrationForStream(1, a1), null, 'a slot without a row does NOT fall back to the account-level row');
+ assert.strictEqual(db.getRobotStreamerIntegrationForStream(1, null), null, 'a slot-less stream gets no RobotStreamer');
+ assert.strictEqual(warnings.filter((w) => /account-level RobotStreamer row/.test(w)).length, 1, 'the unmigrated row is logged once per user');
+ assert.ok(warnings.some((w) => w.includes(`(${r1})`) && w.includes('rs-integrations-to-slots')), 'the log names the row and the script');
+ assert.strictEqual(rs.getIntegrationForStream({ id: 1, user_id: 1, managed_stream_id: a3 }), null);
+ assert.strictEqual(await rs.startForStream({ id: 99, user_id: 1, managed_stream_id: a1, protocol: 'webrtc' }), null, 'going live on a slot without a row starts nothing');
+ assert.strictEqual(await rs.refreshIntegration(1, null), null, 'no account-level refresh');
+ assert.throws(() => db.upsertRobotStreamerIntegration(1, { enabled: 1 }, null), /stream slot/, 'no account-level row is written');
+ await assert.rejects(rs.upsertIntegration(1, { enabled: 1 }, null), /stream slot/);
+
+ // ── Routes ───────────────────────────────────────────────────────────────────────
+ rs.validateConfiguration = async ({ token, robotInput }) => {
+ if (!token) throw new Error('RobotStreamer token is required');
+ return { fields: { token, robot_id: String(robotInput), stream_name: `Robot ${robotInput}`, last_validated_at: new Date().toISOString() }, availableRobots: [{ robot_id: String(robotInput), viewers: 3 }] };
+ };
+ const express = require('express');
+ const app = express();
+ app.use(express.json());
+ app.use('/api/robotstreamer', require('../server/integrations/routes'));
+ server = http.createServer(app).listen(0);
+ const call = (method, p, user, body) => new Promise((resolve, reject) => {
+ const data = body ? JSON.stringify(body) : null;
+ const req = http.request({ port: server.address().port, path: p, method, headers: { 'content-type': 'application/json', 'x-test-user': String(user) } }, (res) => {
+ let text = '';
+ res.on('data', (c) => { text += c; });
+ res.on('end', () => { let json = null; try { json = JSON.parse(text); } catch { /* */ } resolve({ status: res.statusCode, json }); });
+ });
+ req.on('error', reject);
+ if (data) req.write(data);
+ req.end();
+ });
+
+ let r = await call('GET', '/api/robotstreamer/integration', 1);
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.json.exists, false, 'GET without a slot does not return the account-level row');
+ assert.strictEqual(r.json.integration.robot_id, '', 'the account-level robot is not shown as if it applied');
+ assert.ok('passthrough' in r.json.integration, 'GET without a slot still says whether the server relays the video');
+ r = await call('GET', `/api/robotstreamer/integration?managed_stream_id=${a2}`, 1);
+ assert.strictEqual(r.json.exists, true);
+ assert.strictEqual(r.json.integration.robot_id, '777');
+
+ for (const [method, p, body] of [
+ ['POST', '/api/robotstreamer/integration/validate', { robot_input: '777' }],
+ ['POST', '/api/robotstreamer/integration/login', { user_name: 'u', password: 'p' }],
+ ['PUT', '/api/robotstreamer/integration', { enabled: true, token: 't', robot_input: '1' }],
+ ['DELETE', '/api/robotstreamer/integration', null],
+ ]) {
+ r = await call(method, p, 1, body);
+ assert.strictEqual(r.status, 400, `${method} ${p} without a slot is refused`);
+ assert.match(r.json.error, /stream slot/);
+ }
+ // The old validate-without-slot path read the account token; with a slot it uses only the slot's.
+ r = await call('POST', '/api/robotstreamer/integration/validate', 1, { robot_input: '888', managed_stream_id: a1 });
+ assert.strictEqual(r.status, 400, 'slot a1 has no token of its own, and the account token is not borrowed');
+ r = await call('POST', '/api/robotstreamer/integration/validate', 1, { robot_input: '777', managed_stream_id: a2 });
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.json.integration.managed_stream_id, a2);
+ r = await call('PUT', '/api/robotstreamer/integration', 1, { enabled: true, token: 'fresh', robot_input: '888', managed_stream_id: a1 });
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(db.getRobotStreamerIntegrationBySlot(1, a1).robot_id, '888', 'saving with a slot writes that slot\'s row');
+ assert.strictEqual(accountRow(1).robot_id, '777', 'and leaves the account-level row alone');
+ r = await call('PUT', '/api/robotstreamer/integration', 1, { enabled: true, robot_input: '1', managed_stream_id: b1 });
+ assert.strictEqual(r.status, 403, 'another user\'s slot is refused');
+ server.close();
+
+ fs.rmSync(tmp, { recursive: true, force: true });
+ quiet('rs-slot-only: ok');
+ process.exit(0);
+})().catch((err) => {
+ quiet(err);
+ try { server && server.close(); } catch { /* */ }
+ process.exit(1);
+});