openvibers/openvibe.live Commits

Keep Tours place context on broadcasts and clips (#705)

OP OpenVibers committed 5 days ago · aa979d228b · 8 files, +77 −8

Changes

ddiff --git a/migrations/0037_stream_place.sql b/migrations/0037_stream_place.sql
nnew file mode 100644
iindex 00000000..733a00f1
---- /dev/null
++++ b/migrations/0037_stream_place.sql
@@@ -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}$')
++);
ddiff --git a/public/css/features/broadcast.css b/public/css/features/broadcast.css
iindex 910dc7af..eb3f2aa6 100644
---- a/public/css/features/broadcast.css
++++ b/public/css/features/broadcast.css
@@@ -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); }
ddiff --git a/public/js/broadcast-workspace.js b/public/js/broadcast-workspace.js
iindex 17a633db..70310720 100644
---- a/public/js/broadcast-workspace.js
++++ b/public/js/broadcast-workspace.js
@@@ -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.">
ddiff --git a/public/js/broadcast.js b/public/js/broadcast.js
iindex f597befa..e5d07b82 100644
---- a/public/js/broadcast.js
++++ b/public/js/broadcast.js
@@@ -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
ddiff --git a/server/db/database.js b/server/db/database.js
iindex b0e0c611..7e74cac5 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -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;
ddiff --git a/server/media-proxy/clips.js b/server/media-proxy/clips.js
iindex b62ab192..7fb892b0 100644
---- a/server/media-proxy/clips.js
++++ b/server/media-proxy/clips.js
@@@ -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) {
ddiff --git a/server/streaming/routes.js b/server/streaming/routes.js
iindex 6d40353a..ccdae64d 100644
---- a/server/streaming/routes.js
++++ b/server/streaming/routes.js
@@@ -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;
ddiff --git a/test/tours-place-handoff.test.js b/test/tours-place-handoff.test.js
nnew file mode 100644
iindex 00000000..5e1e7ee0
---- /dev/null
++++ b/test/tours-place-handoff.test.js
@@@ -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');