openvibers/openvibe.live Commits
Keep Tours place context on broadcasts and clips (#705)
Changes
nnew file mode 100644@@@ -0,0 +1,9 @@++-- phase: expand++-- A broadcast may name the verified Tours place from which it starts. This stays on the++-- session, so later VOD and clip reads can keep the same location after the stream ends.++ALTER TABLE streams ADD COLUMN place_name text;++ALTER TABLE streams ADD COLUMN place_url text;++ALTER TABLE streams ADD CONSTRAINT streams_place_link CHECK (++ (place_name IS NULL AND place_url IS NULL) OR++ (length(place_name) BETWEEN 1 AND 120 AND place_url ~ '^https://openvibe\.tours/place/[a-z0-9-]+-plc_[0-9A-HJKMNP-TV-Z]{26}$')++);@@@ -3176,6 +3176,8 @@ body.bc-golive-dock-on #page-broadcast { padding-bottom: calc(104px + var(--ovna.gl-card { min-width: 0; padding: 16px; border-radius: 18px; border: 1px solid var(--border);background: color-mix(in srgb, var(--bg-card) 94%, transparent); box-shadow: 0 1px 0 color-mix(in srgb, var(--text-primary) 4%, transparent) inset; }.gl-title-row { display: flex; align-items: center; gap: 12px; min-width: 0; }++.gl-place-handoff { margin: 8px 0 14px; padding: 10px 12px; border-radius: 10px; background: var(--surface-2, rgba(255,255,255,.06)); color: var(--text-muted); font-size: .86rem; line-height: 1.4; }++.gl-place-handoff a { color: var(--text-primary, inherit); font-weight: 650; }.gl-title { flex: 1 1 auto; min-width: 0; width: 100%; box-sizing: border-box; margin: -5px -8px; padding: 5px 8px; font: inherit; font-size: 1.4rem; font-weight: 800;line-height: 1.25; letter-spacing: -0.01em; color: var(--text-primary); background: transparent; border: 1px solid transparent; border-radius: 10px;transition: border-color var(--ov-dur-2, .15s), background-color var(--ov-dur-2, .15s); }@@@ -184,6 +184,17 @@ function _wsShowEmpty() {if (panel) panel.style.display = 'none';}++/** A Tours link may fill the studio. The streamer still presses Go live and can edit the title. */++function toursPlaceHandoff(search) {++ try {++ const q = new URLSearchParams(search);++ const name = String(q.get('place') || '').trim().slice(0, 120);++ const url = String(q.get('place_url') || '').trim();++ if (!name || !/^https:\/\/openvibe\.tours\/place\/[a-z0-9-]+-plc_[0-9A-HJKMNP-TV-Z]{26}$/.test(url)) return null;++ return { name, url, title: `At ${name}`.slice(0, 200) };++ } catch { return null; }++}++function _wsRenderPanel() {const empty = document.getElementById('bc-ws-empty');const panel = document.getElementById('bc-ws-panel');@@@ -197,6 +208,7 @@ function _wsRenderPanel() {const p = _wsState.profile;const isLive = _wsIsManagedStreamLive(ms.id);++ const placeHandoff = isLive ? null : toursPlaceHandoff(window.location.search);const method = ms.streaming_method || 'browser';const streamKey = _wsState.streamKey || _wsState.streamKeyHint || '';@@@ -296,7 +308,7 @@ function _wsRenderPanel() {<section class="gl-card${isBrowser ? ' is-browser' : ''}" aria-label="Your stream, ready to go live"><div class="gl-title-row"><input type="text" id="bc-title" class="gl-title" aria-label="Title"-- value="${esc(ms.title || '')}"++ value="${esc(placeHandoff?.title || ms.title || '')}"placeholder="What are you streaming?" maxlength="200"oninput="_wsMarkDirty()"><span id="bc-ws-save-status" class="bc-ws-save-status" role="status" aria-live="polite"></span>@@@ -304,6 +316,7 @@ function _wsRenderPanel() {<span aria-hidden="true">\u2728</span> Suggest</button></div>++ ${placeHandoff ? `<p class="gl-place-handoff">From <a href="${esc(placeHandoff.url)}" rel="noopener">${esc(placeHandoff.name)} on OpenVibe.Tours</a>. Your stream will be linked to this place after you choose Go live.</p><input type="hidden" id="bc-place-name" value="${esc(placeHandoff.name)}"><input type="hidden" id="bc-place-url" value="${esc(placeHandoff.url)}">` : ''}<div class="gl-ideas" id="gl-ideas" role="group" aria-label="Title ideas" hidden></div><div class="gl-chips"><label class="gl-chip gl-chip--mature" title="Mature (18+): viewers confirm their age before the stream plays. The category is set automatically from what is on stream.">@@@ -1564,12 +1564,21 @@ async function createNewStream() {body: {title,description,++ place_name: document.getElementById('bc-place-name')?.value || null,++ place_url: document.getElementById('bc-place-url')?.value || null,protocol: method,is_nsfw: document.getElementById('bc-nsfw')?.checked || false,managed_stream_id: managedStreamId,}});const streamData = data.stream || data;++ if (streamData?.place_url) {++ try {++ const next = new URL(window.location.href);++ next.searchParams.delete('place'); next.searchParams.delete('place_url'); next.searchParams.delete('title');++ history.replaceState(history.state, '', next.pathname + next.search + next.hash);++ } catch { /* the stream is already created */ }++ }_diagStreamId = streamData?.id;// Create per-stream state@@@ -344,7 +344,7 @@ async function fireStreamLifecycle(kind, streamId) {try { await getDb().tx(() => streamLifecycleHook(kind, streamId)); } catch (err) { console.warn(`[Events] stream ${kind} event for ${streamId} not queued:`, err.message); }}--async function createStream({ user_id, channel_id, managed_stream_id, title, description, category, protocol, is_nsfw, thumbnail_url, content_labels = null }) {++async function createStream({ user_id, channel_id, managed_stream_id, title, description, category, protocol, is_nsfw, thumbnail_url, content_labels = null, place_name = null, place_url = null }) {return await getDb().tx(async () => {// The live session starts with the slot's creator labels; the AI's suggestions stay on the session.let labels = content_labels;@@@ -354,9 +354,9 @@ async function createStream({ user_id, channel_id, managed_stream_id, title, des}const labelJson = typeof labels === 'string' ? labels : JSON.stringify(labels || { labels: [], suggested: [] });const result = await run(-- `INSERT INTO streams (user_id, channel_id, managed_stream_id, title, description, category, protocol, is_nsfw, thumbnail_url, content_labels, is_live, started_at)-- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ov_now()) RETURNING id`,-- [user_id, channel_id || null, managed_stream_id || null, title || 'Untitled Stream', description || '', category || null, protocol || 'webrtc', is_nsfw ? 1 : 0, thumbnail_url || null, labelJson]++ `INSERT INTO streams (user_id, channel_id, managed_stream_id, title, description, category, protocol, is_nsfw, thumbnail_url, content_labels, place_name, place_url, is_live, started_at)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ov_now()) RETURNING id`,++ [user_id, channel_id || null, managed_stream_id || null, title || 'Untitled Stream', description || '', category || null, protocol || 'webrtc', is_nsfw ? 1 : 0, thumbnail_url || null, labelJson, place_name, place_url]);await fireStreamLifecycle('started', result.lastInsertRowid);return result;@@@ -48,6 +48,13 @@ function mediaErr(res, err, fallback) {// the clipped channel's streamer (source_streamer_* via channel_user_id).async function withUserFields(clip) {if (!clip) return clip;++ if (clip.stream_id) {++ const source = await db.getStreamById(clip.stream_id).catch(() => null);++ if (source?.place_url) {++ clip.place_name = source.place_name;++ clip.place_url = source.place_url;++ }++ }if (clip.user_id != null) {const u = await db.getUserById(clip.user_id);if (u) {@@@ -2146,13 +2146,17 @@ router.post('/', requireAuth, async (req, res) => {const protocol = cleanProtocol(req.body.protocol);const tags = cleanTags(req.body.tags);const callMode = cleanCallMode(req.body.call_mode);++ const placeName = cleanText(req.body.place_name, { maxLength: 120 });++ const placeUrl = typeof req.body.place_url === 'string' ? req.body.place_url.trim() : '';++ const placeLinkValid = !placeName && !placeUrl || Boolean(placeName && /^https:\/\/openvibe\.tours\/place\/[a-z0-9-]+-plc_[0-9A-HJKMNP-TV-Z]{26}$/.test(placeUrl));if ((hasOwn(req.body, 'title') && title === null)|| (hasOwn(req.body, 'description') && description === null)|| (hasOwn(req.body, 'category') && category === null)|| (hasOwn(req.body, 'protocol') && protocol === null)|| (hasOwn(req.body, 'tags') && tags === null)-- || (hasOwn(req.body, 'call_mode') && callMode === null)) {++ || (hasOwn(req.body, 'call_mode') && callMode === null)++ || !placeLinkValid) {return res.status(400).json({ error: 'Invalid stream settings' });}@@@ -2168,7 +2172,7 @@ router.post('/', requireAuth, async (req, res) => {const stream = await db.getStreamById(recent.id);return res.status(200).json({ stream, endpoint: goLiveEndpoint(stream, managedStream), reused: true });}-- const creating = createGoLive(req, managedStream, { title, description, category, protocol, tags, callMode });++ const creating = createGoLive(req, managedStream, { title, description, category, protocol, tags, callMode, placeName, placeUrl });_goLiveInFlight.set(managedStream.id, creating);try {const done = await creating;@@@ -2183,7 +2187,7 @@ router.post('/', requireAuth, async (req, res) => {});/** Create the stream row and start its integrations (POST /api/streams). → { status, body } */--async function createGoLive(req, managedStream, { title, description, category, protocol, tags, callMode }) {++async function createGoLive(req, managedStream, { title, description, category, protocol, tags, callMode, placeName, placeUrl }) {const channel = await db.ensureChannel(req.user.id);// Streamer role promotion deferred — applied on first real feed ingest@@@ -2202,6 +2206,8 @@ async function createGoLive(req, managedStream, { title, description, category,category: streamCategory,protocol: streamProtocol,is_nsfw: channel.force_nsfw ? 1 : (hasOwn(req.body, 'is_nsfw') ? cleanBooleanFlag(req.body.is_nsfw) : !!channel.is_nsfw),++ place_name: placeName || null,++ place_url: placeUrl || null,});const streamId = result.lastInsertRowid;nnew file mode 100644@@@ -0,0 +1,23 @@++'use strict';++const assert = require('node:assert/strict');++const fs = require('node:fs');++const path = require('node:path');++++const read = (name) => fs.readFileSync(path.join(__dirname, '..', name), 'utf8');++const workspace = read('public/js/broadcast-workspace.js');++const helper = workspace.match(/function toursPlaceHandoff\(search\) \{[\s\S]*?\n\}/)?.[0];++assert.ok(helper, 'the studio accepts a Tours handoff');++const handoff = new Function(`${helper}; return toursPlaceHandoff;`)();++const url = 'https://openvibe.tours/place/kubota-garden-plc_0123456789ABCDEFGHJKMNPQRS';++assert.deepEqual(handoff(`?place=Kubota+Garden&place_url=${encodeURIComponent(url)}`), {++ name: 'Kubota Garden', url, title: 'At Kubota Garden',++});++assert.equal(handoff('?place=Kubota+Garden&place_url=https%3A%2F%2Fevil.example%2F'), null);++assert.equal(handoff('?place=Kubota+Garden'), null);++assert.match(workspace, /bc-place-name/);++assert.match(workspace, /bc-place-url/);++assert.match(read('public/js/broadcast.js'), /place_name: document\.getElementById\('bc-place-name'\)/);++assert.match(read('server/streaming/routes.js'), /place_name: placeName \|\| null/);++assert.match(read('server/db/database.js'), /place_name, place_url, is_live/);++assert.match(read('server/media-proxy/clips.js'), /clip\.place_url = source\.place_url/);++console.log('Tours place handoff: studio prefill, validated link, stream and clip metadata pass');
new file mode 100644
new file mode 100644
@@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
+-- phase: expand
+-- A broadcast may name the verified Tours place from which it starts. This stays on the
+-- session, so later VOD and clip reads can keep the same location after the stream ends.
+ALTER TABLE streams ADD COLUMN place_name text;
+ALTER TABLE streams ADD COLUMN place_url text;
+ALTER TABLE streams ADD CONSTRAINT streams_place_link CHECK (
+ (place_name IS NULL AND place_url IS NULL) OR
+ (length(place_name) BETWEEN 1 AND 120 AND place_url ~ '^https://openvibe\.tours/place/[a-z0-9-]+-plc_[0-9A-HJKMNP-TV-Z]{26}$')
+);
@@ -3176,6 +3176,8 @@ body.bc-golive-dock-on #page-broadcast { padding-bottom: calc(104px + var(--ovna
@@ -3176,6 +3176,8 @@ body.bc-golive-dock-on #page-broadcast { padding-bottom: calc(104px + var(--ovna
.gl-card { min-width: 0; padding: 16px; border-radius: 18px; border: 1px solid var(--border);
.gl-card { min-width: 0; padding: 16px; border-radius: 18px; border: 1px solid var(--border);
background: color-mix(in srgb, var(--bg-card) 94%, transparent); box-shadow: 0 1px 0 color-mix(in srgb, var(--text-primary) 4%, transparent) inset; }
background: color-mix(in srgb, var(--bg-card) 94%, transparent); box-shadow: 0 1px 0 color-mix(in srgb, var(--text-primary) 4%, transparent) inset; }
.gl-title-row { display: flex; align-items: center; gap: 12px; min-width: 0; }
.gl-title-row { display: flex; align-items: center; gap: 12px; min-width: 0; }
+.gl-place-handoff { margin: 8px 0 14px; padding: 10px 12px; border-radius: 10px; background: var(--surface-2, rgba(255,255,255,.06)); color: var(--text-muted); font-size: .86rem; line-height: 1.4; }
+.gl-place-handoff a { color: var(--text-primary, inherit); font-weight: 650; }
.gl-title { flex: 1 1 auto; min-width: 0; width: 100%; box-sizing: border-box; margin: -5px -8px; padding: 5px 8px; font: inherit; font-size: 1.4rem; font-weight: 800;
.gl-title { flex: 1 1 auto; min-width: 0; width: 100%; box-sizing: border-box; margin: -5px -8px; padding: 5px 8px; font: inherit; font-size: 1.4rem; font-weight: 800;
line-height: 1.25; letter-spacing: -0.01em; color: var(--text-primary); background: transparent; border: 1px solid transparent; border-radius: 10px;
line-height: 1.25; letter-spacing: -0.01em; color: var(--text-primary); background: transparent; border: 1px solid transparent; border-radius: 10px;
transition: border-color var(--ov-dur-2, .15s), background-color var(--ov-dur-2, .15s); }
transition: border-color var(--ov-dur-2, .15s), background-color var(--ov-dur-2, .15s); }
@@ -184,6 +184,17 @@ function _wsShowEmpty() {
@@ -184,6 +184,17 @@ function _wsShowEmpty() {
if (panel) panel.style.display = 'none';
if (panel) panel.style.display = 'none';
}
}
+/** A Tours link may fill the studio. The streamer still presses Go live and can edit the title. */
+function toursPlaceHandoff(search) {
+ try {
+ const q = new URLSearchParams(search);
+ const name = String(q.get('place') || '').trim().slice(0, 120);
+ const url = String(q.get('place_url') || '').trim();
+ if (!name || !/^https:\/\/openvibe\.tours\/place\/[a-z0-9-]+-plc_[0-9A-HJKMNP-TV-Z]{26}$/.test(url)) return null;
+ return { name, url, title: `At ${name}`.slice(0, 200) };
+ } catch { return null; }
+}
+
function _wsRenderPanel() {
function _wsRenderPanel() {
const empty = document.getElementById('bc-ws-empty');
const empty = document.getElementById('bc-ws-empty');
const panel = document.getElementById('bc-ws-panel');
const panel = document.getElementById('bc-ws-panel');
@@ -197,6 +208,7 @@ function _wsRenderPanel() {
@@ -197,6 +208,7 @@ function _wsRenderPanel() {
const p = _wsState.profile;
const p = _wsState.profile;
const isLive = _wsIsManagedStreamLive(ms.id);
const isLive = _wsIsManagedStreamLive(ms.id);
+ const placeHandoff = isLive ? null : toursPlaceHandoff(window.location.search);
const method = ms.streaming_method || 'browser';
const method = ms.streaming_method || 'browser';
const streamKey = _wsState.streamKey || _wsState.streamKeyHint || '';
const streamKey = _wsState.streamKey || _wsState.streamKeyHint || '';
@@ -296,7 +308,7 @@ function _wsRenderPanel() {
@@ -296,7 +308,7 @@ function _wsRenderPanel() {
<section class="gl-card${isBrowser ? ' is-browser' : ''}" aria-label="Your stream, ready to go live">
<section class="gl-card${isBrowser ? ' is-browser' : ''}" aria-label="Your stream, ready to go live">
<div class="gl-title-row">
<div class="gl-title-row">
<input type="text" id="bc-title" class="gl-title" aria-label="Title"
<input type="text" id="bc-title" class="gl-title" aria-label="Title"
- value="${esc(ms.title || '')}"
+ value="${esc(placeHandoff?.title || ms.title || '')}"
placeholder="What are you streaming?" maxlength="200"
placeholder="What are you streaming?" maxlength="200"
oninput="_wsMarkDirty()">
oninput="_wsMarkDirty()">
<span id="bc-ws-save-status" class="bc-ws-save-status" role="status" aria-live="polite"></span>
<span id="bc-ws-save-status" class="bc-ws-save-status" role="status" aria-live="polite"></span>
@@ -304,6 +316,7 @@ function _wsRenderPanel() {
@@ -304,6 +316,7 @@ function _wsRenderPanel() {
<span aria-hidden="true">\u2728</span> Suggest
<span aria-hidden="true">\u2728</span> Suggest
</button>
</button>
</div>
</div>
+ ${placeHandoff ? `<p class="gl-place-handoff">From <a href="${esc(placeHandoff.url)}" rel="noopener">${esc(placeHandoff.name)} on OpenVibe.Tours</a>. Your stream will be linked to this place after you choose Go live.</p><input type="hidden" id="bc-place-name" value="${esc(placeHandoff.name)}"><input type="hidden" id="bc-place-url" value="${esc(placeHandoff.url)}">` : ''}
<div class="gl-ideas" id="gl-ideas" role="group" aria-label="Title ideas" hidden></div>
<div class="gl-ideas" id="gl-ideas" role="group" aria-label="Title ideas" hidden></div>
<div class="gl-chips">
<div class="gl-chips">
<label class="gl-chip gl-chip--mature" title="Mature (18+): viewers confirm their age before the stream plays. The category is set automatically from what is on stream.">
<label class="gl-chip gl-chip--mature" title="Mature (18+): viewers confirm their age before the stream plays. The category is set automatically from what is on stream.">
@@ -1564,12 +1564,21 @@ async function createNewStream() {
@@ -1564,12 +1564,21 @@ async function createNewStream() {
body: {
body: {
title,
title,
description,
description,
+ place_name: document.getElementById('bc-place-name')?.value || null,
+ place_url: document.getElementById('bc-place-url')?.value || null,
protocol: method,
protocol: method,
is_nsfw: document.getElementById('bc-nsfw')?.checked || false,
is_nsfw: document.getElementById('bc-nsfw')?.checked || false,
managed_stream_id: managedStreamId,
managed_stream_id: managedStreamId,
}
}
});
});
const streamData = data.stream || data;
const streamData = data.stream || data;
+ if (streamData?.place_url) {
+ try {
+ const next = new URL(window.location.href);
+ next.searchParams.delete('place'); next.searchParams.delete('place_url'); next.searchParams.delete('title');
+ history.replaceState(history.state, '', next.pathname + next.search + next.hash);
+ } catch { /* the stream is already created */ }
+ }
_diagStreamId = streamData?.id;
_diagStreamId = streamData?.id;
// Create per-stream state
// Create per-stream state
@@ -344,7 +344,7 @@ async function fireStreamLifecycle(kind, streamId) {
@@ -344,7 +344,7 @@ async function fireStreamLifecycle(kind, streamId) {
try { await getDb().tx(() => streamLifecycleHook(kind, streamId)); } catch (err) { console.warn(`[Events] stream ${kind} event for ${streamId} not queued:`, err.message); }
try { await getDb().tx(() => streamLifecycleHook(kind, streamId)); } catch (err) { console.warn(`[Events] stream ${kind} event for ${streamId} not queued:`, err.message); }
}
}
-async function createStream({ user_id, channel_id, managed_stream_id, title, description, category, protocol, is_nsfw, thumbnail_url, content_labels = null }) {
+async function createStream({ user_id, channel_id, managed_stream_id, title, description, category, protocol, is_nsfw, thumbnail_url, content_labels = null, place_name = null, place_url = null }) {
return await getDb().tx(async () => {
return await getDb().tx(async () => {
// The live session starts with the slot's creator labels; the AI's suggestions stay on the session.
// The live session starts with the slot's creator labels; the AI's suggestions stay on the session.
let labels = content_labels;
let labels = content_labels;
@@ -354,9 +354,9 @@ async function createStream({ user_id, channel_id, managed_stream_id, title, des
@@ -354,9 +354,9 @@ async function createStream({ user_id, channel_id, managed_stream_id, title, des
}
}
const labelJson = typeof labels === 'string' ? labels : JSON.stringify(labels || { labels: [], suggested: [] });
const labelJson = typeof labels === 'string' ? labels : JSON.stringify(labels || { labels: [], suggested: [] });
const result = await run(
const result = await run(
- `INSERT INTO streams (user_id, channel_id, managed_stream_id, title, description, category, protocol, is_nsfw, thumbnail_url, content_labels, is_live, started_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ov_now()) RETURNING id`,
- [user_id, channel_id || null, managed_stream_id || null, title || 'Untitled Stream', description || '', category || null, protocol || 'webrtc', is_nsfw ? 1 : 0, thumbnail_url || null, labelJson]
+ `INSERT INTO streams (user_id, channel_id, managed_stream_id, title, description, category, protocol, is_nsfw, thumbnail_url, content_labels, place_name, place_url, is_live, started_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ov_now()) RETURNING id`,
+ [user_id, channel_id || null, managed_stream_id || null, title || 'Untitled Stream', description || '', category || null, protocol || 'webrtc', is_nsfw ? 1 : 0, thumbnail_url || null, labelJson, place_name, place_url]
);
);
await fireStreamLifecycle('started', result.lastInsertRowid);
await fireStreamLifecycle('started', result.lastInsertRowid);
return result;
return result;
@@ -48,6 +48,13 @@ function mediaErr(res, err, fallback) {
@@ -48,6 +48,13 @@ function mediaErr(res, err, fallback) {
// the clipped channel's streamer (source_streamer_* via channel_user_id).
// the clipped channel's streamer (source_streamer_* via channel_user_id).
async function withUserFields(clip) {
async function withUserFields(clip) {
if (!clip) return clip;
if (!clip) return clip;
+ if (clip.stream_id) {
+ const source = await db.getStreamById(clip.stream_id).catch(() => null);
+ if (source?.place_url) {
+ clip.place_name = source.place_name;
+ clip.place_url = source.place_url;
+ }
+ }
if (clip.user_id != null) {
if (clip.user_id != null) {
const u = await db.getUserById(clip.user_id);
const u = await db.getUserById(clip.user_id);
if (u) {
if (u) {
@@ -2146,13 +2146,17 @@ router.post('/', requireAuth, async (req, res) => {
@@ -2146,13 +2146,17 @@ router.post('/', requireAuth, async (req, res) => {
const protocol = cleanProtocol(req.body.protocol);
const protocol = cleanProtocol(req.body.protocol);
const tags = cleanTags(req.body.tags);
const tags = cleanTags(req.body.tags);
const callMode = cleanCallMode(req.body.call_mode);
const callMode = cleanCallMode(req.body.call_mode);
+ const placeName = cleanText(req.body.place_name, { maxLength: 120 });
+ const placeUrl = typeof req.body.place_url === 'string' ? req.body.place_url.trim() : '';
+ const placeLinkValid = !placeName && !placeUrl || Boolean(placeName && /^https:\/\/openvibe\.tours\/place\/[a-z0-9-]+-plc_[0-9A-HJKMNP-TV-Z]{26}$/.test(placeUrl));
if ((hasOwn(req.body, 'title') && title === null)
if ((hasOwn(req.body, 'title') && title === null)
|| (hasOwn(req.body, 'description') && description === null)
|| (hasOwn(req.body, 'description') && description === null)
|| (hasOwn(req.body, 'category') && category === null)
|| (hasOwn(req.body, 'category') && category === null)
|| (hasOwn(req.body, 'protocol') && protocol === null)
|| (hasOwn(req.body, 'protocol') && protocol === null)
|| (hasOwn(req.body, 'tags') && tags === null)
|| (hasOwn(req.body, 'tags') && tags === null)
- || (hasOwn(req.body, 'call_mode') && callMode === null)) {
+ || (hasOwn(req.body, 'call_mode') && callMode === null)
+ || !placeLinkValid) {
return res.status(400).json({ error: 'Invalid stream settings' });
return res.status(400).json({ error: 'Invalid stream settings' });
}
}
@@ -2168,7 +2172,7 @@ router.post('/', requireAuth, async (req, res) => {
@@ -2168,7 +2172,7 @@ router.post('/', requireAuth, async (req, res) => {
const stream = await db.getStreamById(recent.id);
const stream = await db.getStreamById(recent.id);
return res.status(200).json({ stream, endpoint: goLiveEndpoint(stream, managedStream), reused: true });
return res.status(200).json({ stream, endpoint: goLiveEndpoint(stream, managedStream), reused: true });
}
}
- const creating = createGoLive(req, managedStream, { title, description, category, protocol, tags, callMode });
+ const creating = createGoLive(req, managedStream, { title, description, category, protocol, tags, callMode, placeName, placeUrl });
_goLiveInFlight.set(managedStream.id, creating);
_goLiveInFlight.set(managedStream.id, creating);
try {
try {
const done = await creating;
const done = await creating;
@@ -2183,7 +2187,7 @@ router.post('/', requireAuth, async (req, res) => {
@@ -2183,7 +2187,7 @@ router.post('/', requireAuth, async (req, res) => {
});
});
/** Create the stream row and start its integrations (POST /api/streams). → { status, body } */
/** Create the stream row and start its integrations (POST /api/streams). → { status, body } */
-async function createGoLive(req, managedStream, { title, description, category, protocol, tags, callMode }) {
+async function createGoLive(req, managedStream, { title, description, category, protocol, tags, callMode, placeName, placeUrl }) {
const channel = await db.ensureChannel(req.user.id);
const channel = await db.ensureChannel(req.user.id);
// Streamer role promotion deferred — applied on first real feed ingest
// Streamer role promotion deferred — applied on first real feed ingest
@@ -2202,6 +2206,8 @@ async function createGoLive(req, managedStream, { title, description, category,
@@ -2202,6 +2206,8 @@ async function createGoLive(req, managedStream, { title, description, category,
category: streamCategory,
category: streamCategory,
protocol: streamProtocol,
protocol: streamProtocol,
is_nsfw: channel.force_nsfw ? 1 : (hasOwn(req.body, 'is_nsfw') ? cleanBooleanFlag(req.body.is_nsfw) : !!channel.is_nsfw),
is_nsfw: channel.force_nsfw ? 1 : (hasOwn(req.body, 'is_nsfw') ? cleanBooleanFlag(req.body.is_nsfw) : !!channel.is_nsfw),
+ place_name: placeName || null,
+ place_url: placeUrl || null,
});
});
const streamId = result.lastInsertRowid;
const streamId = result.lastInsertRowid;
new file mode 100644
new file mode 100644
@@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
+'use strict';
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+
+const read = (name) => fs.readFileSync(path.join(__dirname, '..', name), 'utf8');
+const workspace = read('public/js/broadcast-workspace.js');
+const helper = workspace.match(/function toursPlaceHandoff\(search\) \{[\s\S]*?\n\}/)?.[0];
+assert.ok(helper, 'the studio accepts a Tours handoff');
+const handoff = new Function(`${helper}; return toursPlaceHandoff;`)();
+const url = 'https://openvibe.tours/place/kubota-garden-plc_0123456789ABCDEFGHJKMNPQRS';
+assert.deepEqual(handoff(`?place=Kubota+Garden&place_url=${encodeURIComponent(url)}`), {
+ name: 'Kubota Garden', url, title: 'At Kubota Garden',
+});
+assert.equal(handoff('?place=Kubota+Garden&place_url=https%3A%2F%2Fevil.example%2F'), null);
+assert.equal(handoff('?place=Kubota+Garden'), null);
+assert.match(workspace, /bc-place-name/);
+assert.match(workspace, /bc-place-url/);
+assert.match(read('public/js/broadcast.js'), /place_name: document\.getElementById\('bc-place-name'\)/);
+assert.match(read('server/streaming/routes.js'), /place_name: placeName \|\| null/);
+assert.match(read('server/db/database.js'), /place_name, place_url, is_live/);
+assert.match(read('server/media-proxy/clips.js'), /clip\.place_url = source\.place_url/);
+console.log('Tours place handoff: studio prefill, validated link, stream and clip metadata pass');