openvibers/openvibe.chat Commits

Add OpenVibe Radio listening parties (#413)

OP OpenVibers committed 6 days ago · 1cb45a0c47 · 11 files, +500 −26

* Add licensed Radio listening parties with shared queue

* Record fixed Radio catalogue egress in SSRF audit

* Radio party: a now-playing card instead of a video box

Opus's design pass. A listening party's stage is the track itself: a cover tile, title, artist, AI label, licence and
original source, then the player and one status line. The queue panel holds only Up next (counted, short licence
labels, a plain sentence when empty) and the controls; Skip is a quiet button and appears only with a track. No
dead player or second 'nothing to play' line when the queue is empty; 'listening', not 'watching'; the guest's Start
listening sits in the card. Links from the catalogue are opened only when https. The line promising other music
services is gone.

Changes

ddiff --git a/migrations/0046_party_radio.sql b/migrations/0046_party_radio.sql
nnew file mode 100644
iindex 0000000..8ce7af1
---- /dev/null
++++ b/migrations/0046_party_radio.sql
@@@ -0,0 +1,13 @@
++-- phase: expand
++-- Radio parties share the media clock and hold an approved Radio track plus a durable queue.
++ALTER TABLE room_parties ADD COLUMN IF NOT EXISTS radio_track jsonb;
++ALTER TABLE room_parties ADD COLUMN IF NOT EXISTS radio_queue jsonb NOT NULL DEFAULT '[]'::jsonb;
++ALTER TABLE room_parties ADD COLUMN IF NOT EXISTS radio_allow_all boolean NOT NULL DEFAULT false;
++ALTER TABLE room_parties ADD COLUMN IF NOT EXISTS radio_version integer NOT NULL DEFAULT 0;
++ALTER TABLE room_parties DROP CONSTRAINT IF EXISTS room_parties_kind_allowed;
++ALTER TABLE room_parties ADD CONSTRAINT room_parties_kind_allowed CHECK (party_kind IN ('agent_stream', 'media', 'radio'));
++ALTER TABLE room_parties DROP CONSTRAINT IF EXISTS room_parties_kind_source;
++ALTER TABLE room_parties ADD CONSTRAINT room_parties_kind_source CHECK (
++ (party_kind = 'agent_stream' AND agent_stream_id IS NOT NULL AND media IS NULL)
++ OR (party_kind IN ('media', 'radio') AND agent_stream_id IS NULL AND media IS NOT NULL)
++);
ddiff --git a/public/web/party-home.css b/public/web/party-home.css
iindex 3b7eb9d..6600e0a 100644
---- a/public/web/party-home.css
++++ b/public/web/party-home.css
@@@ -53,3 +53,12 @@
.pty-mine-list a:hover, .pty-mine-list a:focus-visible { border-color: var(--oc-accent); }
.pty-mine-list strong { font-size: 15px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pty-mine-list span { font-size: 13px; color: var(--oc-muted); }
++.pty-radio { margin-top: 40px; padding: 22px; border-radius: 16px; background: var(--oc-panel); border: 1px solid var(--oc-line); }
++.pty-radio h2 { margin: 0 0 8px; font-size: 1.3rem; }
++.pty-radio p { color: var(--oc-muted); line-height: 1.5; }
++.pty-radio-list { list-style: none; padding: 0; display: grid; gap: 10px; }
++.pty-radio-list li { display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 12px; border: 1px solid var(--oc-line); border-radius: 12px; min-width: 0; }
++.pty-radio-list li div { display: grid; gap: 3px; min-width: 0; }
++.pty-radio-list li span, .pty-radio-list li small { color: var(--oc-muted); overflow-wrap: anywhere; }
++.pty-radio-list li a.oc-button { flex: none; min-height: 44px; display: inline-flex; align-items: center; padding: 0 16px; }
++@media (max-width: 600px) { .pty-radio { padding: 16px; } .pty-radio-list li { align-items: stretch; flex-direction: column; } .pty-radio-list li a.oc-button { align-self: flex-start; } }
ddiff --git a/public/web/party.css b/public/web/party.css
iindex ddd8aeb..23f351c 100644
---- a/public/web/party.css
++++ b/public/web/party.css
@@@ -46,6 +46,49 @@ main[data-party] [hidden] { display: none !important; }
box-shadow: 0 18px 50px rgba(0, 0, 0, .35);
}
.pt-frame video { display: block; width: 100%; height: 100%; object-fit: contain; background: #000; }
++.pt-frame.is-radio {
++ aspect-ratio: auto; width: 100%; display: grid; gap: 14px; padding: 18px;
++ background: var(--pt-panel); border: 1px solid var(--pt-line); box-shadow: none;
++}
++.pt-frame.is-radio audio { display: block; width: 100%; height: 44px; }
++.pt-frame.is-radio .pt-veil, .pt-frame.is-radio.is-guest .pt-veil {
++ position: static; inset: auto; display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px; padding: 0;
++ background: none; color: var(--pt-muted); font-size: 14px; font-weight: 650; text-align: left; pointer-events: auto;
++}
++.pt-frame.is-radio.is-guest .pt-veil { flex-direction: row; justify-content: space-between; }
++.pt-frame.is-radio .pt-veil:empty, .pt-frame.is-radio .pt-veil[hidden],
++.pt-frame.is-radio.is-empty audio, .pt-frame.is-radio.is-empty .pt-veil { display: none; }
++.pt-frame.is-radio .pt-veil-line { max-width: none; }
++.pt-frame.is-radio .pt-drift { left: auto; right: 12px; }
++.pt-np { display: flex; gap: 16px; align-items: center; min-width: 0; }
++.pt-np-cover {
++ flex: none; width: 104px; height: 104px; border-radius: 14px; display: grid; place-items: center;
++ font-size: 44px; font-weight: 800; color: #fff; text-shadow: 0 1px 2px rgba(0, 0, 0, .25);
++ background: linear-gradient(135deg, hsl(var(--np-h, 260) 68% 56%), hsl(calc(var(--np-h, 260) + 48) 64% 34%));
++}
++.pt-np-text { min-width: 0; }
++.pt-np-text .pt-h { margin-bottom: 4px; }
++.pt-np-title { display: block; font-size: clamp(20px, 2.4vw, 30px); line-height: 1.15; overflow-wrap: anywhere; }
++.pt-np-artist { margin: 4px 0 0; font-size: 15px; }
++.pt-np-proof { margin: 6px 0 0; font-size: 13px; color: var(--pt-muted); }
++.pt-np-proof a { color: var(--pt-accent); }
++.pt-radio { padding: 18px; border-radius: 16px; border: 1px solid var(--pt-line); background: var(--pt-panel); }
++.pt-radio .pt-h:not(:first-child) { margin-top: 18px; }
++.pt-radio-title { font-size: clamp(20px, 2vw, 28px); }
++.pt-radio-meta { margin: 6px 0; font-size: 14px; color: var(--pt-muted); }
++.pt-radio a { color: var(--pt-accent); }
++.pt-radio-queue { margin: 0; padding-left: 22px; font-size: 14px; line-height: 1.7; }
++.pt-radio-track { font-weight: 700; }
++.pt-radio-by, .pt-radio-lic { color: var(--pt-muted); font-size: 13px; }
++.pt-radio-empty { margin: 0; font-size: 14px; color: var(--pt-muted); }
++.pt-radio-add { margin-top: 14px; }
++.pt-ghost { min-height: 40px; padding: 0 16px; border: 1px solid var(--pt-line); border-radius: 999px; background: none; color: var(--pt-text); font: inherit; font-size: 14px; font-weight: 700; cursor: pointer; }
++.pt-ghost:hover { border-color: var(--pt-accent); }
++.pt-radio-actions { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-top: 16px; }
++.pt-radio-add { display: flex; gap: 8px; flex: 1 1 280px; }
++.pt-radio-add select { min-width: 0; flex: 1; min-height: 44px; border-radius: 9px; padding: 0 10px; background: var(--pt-bg); color: var(--pt-text); border: 1px solid var(--pt-line); }
++.pt-radio-permission { display: inline-flex; align-items: center; gap: 8px; font-size: 14px; }
++.pt-radio-permission input { width: 18px; height: 18px; vertical-align: middle; }
/* Honest states over the picture: waiting for the host, joining, reconnecting. */
.pt-veil {
position: absolute; inset: 0; display: grid; place-items: center; padding: 24px; text-align: center;
@@@ -137,6 +180,12 @@ main[data-party].is-guest [data-party-chat] { min-height: 0; flex: 0 0 auto; ali
[data-party-stage] { gap: 8px; }
.pt-head { order: 3; padding: 0 max(14px, env(safe-area-inset-left, 0px)); }
.pt-frame { order: 1; width: 100%; border-radius: 0; box-shadow: none; position: sticky; top: var(--pt-nav); z-index: 2; }
++ .pt-frame.is-radio { padding: 12px 14px; gap: 10px; border-radius: 0; border-left: 0; border-right: 0; }
++ .pt-np { gap: 12px; }
++ .pt-np-cover { width: 64px; height: 64px; font-size: 28px; border-radius: 12px; }
++ .pt-np-title { font-size: 19px; }
++ .pt-np-artist { font-size: 14px; }
++ .pt-radio { order: 4; margin: 0 12px; }
.pt-bar { order: 2; padding: 0 max(12px, env(safe-area-inset-left, 0px)); }
.pt-flash { order: 4; margin: 0 14px; }
.pt-title { font-size: 1.05rem; }
ddiff --git a/public/web/party.js b/public/web/party.js
iindex 7f331db..d5157ef 100644
---- a/public/web/party.js
++++ b/public/web/party.js
@@@ -23,7 +23,8 @@
if (data && data.party) {
var v = data.party;
data = {
-- slug: v.id, title: v.title, media: v.media, source_url: v.source_url, member: v.member,
++ slug: v.id, title: v.title, kind: v.kind, radio: v.radio || null, media: v.media, source_url: v.source_url, member: v.member,
++ server_time_ms: data.server_time_ms,
host: { subject: v.host, name: v.host_name || null },
members: (v.members || []).map(function (m) { return { subject: m.subject, name: m.name }; }),
// The shell's join_url never carries the key (the secret is not echoed into a page): only a URL that has one
@@@ -43,6 +44,10 @@
(data.members || []).forEach(function (m) { if (m && m.subject) names[m.subject] = m.name || 'Someone'; });
if (data.host && data.host.subject) names[data.host.subject] = data.host.name || names[data.host.subject] || 'The host';
if (self && self.subject) names[self.subject] = self.name || names[self.subject] || 'You';
++ var isRadio = data.kind === 'radio', radio = data.radio || null, guestListening = false;
++ var serverOffset = Number(data.server_time_ms) ? Number(data.server_time_ms) - Date.now() : 0;
++ var bestClockRtt = Infinity;
++ var catalogue = [];
var lease = data.lease || null, clock = data.clock || null, asked = null, everPlayed = !!(clock && (!clock.paused || clock.position_ms > 0));
var away = {}; // subject -> when they left (their seat waits a minute)
var ws = null, sync = null, delay = 1000, latest = 0, joined = false;
@@@ -79,12 +84,13 @@
if (guest) root.classList.add('is-guest');
// ── the stage ────────────────────────────────────────────────────────────────
-- var video = el('video');
-- video.setAttribute('data-ov-player', '');
++ var video = el(isRadio ? 'audio' : 'video');
++ if (!isRadio) video.setAttribute('data-ov-player', '');
video.setAttribute('playsinline', '');
video.preload = 'auto';
-- video.controls = !guest; // a guest's player would not follow the party: the invitation stands over it
++ video.controls = !guest || isRadio;
var src = typeof (clock && clock.media) === 'string' ? clock.media : (typeof data.media === 'string' ? data.media : null);
++ if (isRadio && radio && !radio.current) src = null;
if (src) video.src = src;
var frame = el('div', 'pt-frame');
frame.appendChild(video);
@@@ -95,6 +101,11 @@
var floats = el('div', 'pt-floats'); // reactions rising over the picture
floats.setAttribute('aria-hidden', 'true');
frame.appendChild(veil); frame.appendChild(drift); frame.appendChild(floats);
++ if (isRadio) frame.classList.add('is-radio');
++ // A listening party has no picture: the stage is the now-playing card (cover, title, artist, the licence), then
++ // the player, then the status line.
++ var np = null;
++ if (isRadio) { np = el('div', 'pt-np'); frame.insertBefore(np, video); }
// The shell's own header (the title and who started it) leads; without one, the page writes its own.
var shellHead = root.querySelector('.oc-party-head');
var head = shellHead || el('div', 'pt-head');
@@@ -113,6 +124,11 @@
stage.appendChild(head);
stage.appendChild(frame);
if (!guest) stage.appendChild(bar);
++ if (isRadio) {
++ var radioBox = el('section', 'pt-radio');
++ radioBox.setAttribute('aria-label', 'OpenVibe Radio queue');
++ stage.appendChild(radioBox);
++ }
if (remote && !guest) bar.appendChild(remote);
var react = el('div', 'pt-react');
react.setAttribute('aria-label', 'React');
@@@ -135,6 +151,20 @@
function holder() { return lease && lease.holder ? lease.holder : { kind: 'nobody', subject: null }; }
function iHold() { var h = holder(); return !!(self && h.kind === 'person' && h.subject === self.subject); }
function paintVeil() {
++ if (isRadio && guest) {
++ frame.classList.add('is-guest');
++ veil.textContent = '';
++ if (!guestListening) {
++ veil.appendChild(el('span', 'pt-veil-line', 'Listen at the party’s position, no account needed'));
++ var start = el('button', 'pt-join', 'Start listening');
++ start.type = 'button';
++ start.addEventListener('click', function () { guestListening = true; applyGuestClock(); paintVeil(); });
++ veil.appendChild(start);
++ }
++ veil.hidden = guestListening;
++ frame.classList.toggle('is-waiting', !guestListening);
++ return;
++ }
if (guest) {
veil.textContent = '';
var host = data.host && data.host.name ? data.host.name : null;
@@@ -153,6 +183,104 @@
frame.classList.toggle('is-waiting', !!text);
}
++ function applyGuestClock() {
++ if (!isRadio || !guest || !guestListening || !clock) return;
++ var serverNow = Date.now() + serverOffset;
++ var target = Math.max(0, Number(clock.position_ms || 0) + (!clock.paused ? Math.max(0, serverNow - Number(clock.at_ms || serverNow)) * Number(clock.rate || 1) : 0)) / 1000;
++ if (Math.abs(video.currentTime - target) > 1) { try { video.currentTime = target; } catch (e) { /* metadata loading */ } }
++ if (clock.paused) video.pause();
++ else video.play().catch(function () { /* browser gesture required */ });
++ }
++ function licenceLabel(value) {
++ return { creator_grant: 'Streamed with the artist’s permission', public_domain: 'Public domain', 'CC0-1.0': 'CC0 1.0',
++ 'CC-BY-4.0': 'CC BY 4.0', 'CC-BY-SA-4.0': 'CC BY-SA 4.0' }[value] || value;
++ }
++ function safeHref(u) { return typeof u === 'string' && /^https:\/\//i.test(u) ? u : null; }
++ function outLink(text, href) {
++ var url = safeHref(href), a = el(url ? 'a' : 'span', null, text);
++ if (url) { a.href = url; a.target = '_blank'; a.rel = 'noopener'; }
++ return a;
++ }
++ function mayChangeQueue() { return !guest && self && (self.subject === owner || (radio && radio.allow_everyone)); }
++ function paintNowPlaying() {
++ if (!np) return;
++ np.textContent = '';
++ var current = radio && radio.current;
++ frame.classList.toggle('is-empty', !current); // no track: no dead player, no second "nothing to play" line
++ var cover = el('div', 'pt-np-cover', current ? (String(current.title || '').trim().charAt(0) || '♪').toUpperCase() : '♪');
++ cover.setAttribute('aria-hidden', 'true');
++ var seed = String(current ? current.id || current.title : 'radio'), hue = 0;
++ for (var i = 0; i < seed.length; i++) hue = (hue * 31 + seed.charCodeAt(i)) % 360;
++ cover.style.setProperty('--np-h', String(hue));
++ np.appendChild(cover);
++ var text = el('div', 'pt-np-text');
++ text.appendChild(el('p', 'pt-h', current ? 'Now playing' : 'OpenVibe Radio'));
++ text.appendChild(el('strong', 'pt-np-title', current ? current.title : 'Nothing is playing yet'));
++ if (current) {
++ text.appendChild(el('p', 'pt-np-artist', current.artist + (current.ai_made ? ' · AI-made music' : '')));
++ var proof = el('p', 'pt-np-proof');
++ proof.appendChild(outLink(licenceLabel(current.licence), current.licence_url));
++ if (safeHref(current.source_url)) { proof.appendChild(document.createTextNode(' · ')); proof.appendChild(outLink('Original source', current.source_url)); }
++ if (current.changes_note) proof.appendChild(document.createTextNode(' · Changes: ' + current.changes_note));
++ text.appendChild(proof);
++ } else {
++ text.appendChild(el('p', 'pt-np-artist', mayChangeQueue() ? 'Pick a track below to start the music.' : 'The music starts when the host picks a track.'));
++ }
++ np.appendChild(text);
++ }
++
++ function paintRadio() {
++ if (!isRadio || !radioBox) return;
++ paintNowPlaying();
++ radioBox.textContent = '';
++ var queue = (radio && radio.queue) || [];
++ radioBox.appendChild(el('h2', 'pt-h', queue.length ? 'Up next · ' + queue.length : 'Up next'));
++ if (queue.length) {
++ var list = el('ol', 'pt-radio-queue');
++ queue.forEach(function (t) {
++ var item = el('li', null);
++ item.appendChild(el('span', 'pt-radio-track', t.title));
++ item.appendChild(el('span', 'pt-radio-by', ' · ' + t.artist + (t.ai_made ? ' · AI-made music' : '') + ' · '));
++ var lic = outLink(t.licence === 'creator_grant' ? 'Artist’s permission' : licenceLabel(t.licence), t.licence_url); lic.className = 'pt-radio-lic'; item.appendChild(lic);
++ list.appendChild(item);
++ });
++ radioBox.appendChild(list);
++ } else {
++ radioBox.appendChild(el('p', 'pt-radio-empty', mayChangeQueue() ? 'Nothing queued yet. Pick a track below.' : 'Nothing queued yet.'));
++ }
++ if (mayChangeQueue()) {
++ var add = el('form', 'pt-radio-add');
++ var id = el('select'); id.name = 'track_id'; id.required = true; id.setAttribute('aria-label', 'Choose a Radio track');
++ var placeholder = el('option', null, catalogue.length ? 'Choose a Radio track' : 'No Radio tracks yet'); placeholder.value = ''; id.appendChild(placeholder);
++ catalogue.forEach(function (t) { var option = el('option', null, t.title + ' · ' + t.artist); option.value = t.id; id.appendChild(option); });
++ var submit = el('button', 'pt-remote-btn', 'Add to queue'); submit.type = 'submit'; add.appendChild(id); add.appendChild(submit);
++ add.addEventListener('submit', function (event) { event.preventDefault(); radioAction('queue', { track_id: id.value }); id.value = ''; });
++ radioBox.appendChild(add);
++ var row = el('div', 'pt-radio-actions');
++ if (radio && radio.current) {
++ var skip = el('button', 'pt-ghost', 'Skip this track'); skip.type = 'button';
++ skip.addEventListener('click', function () { radioAction('skip', { expected_id: radio && radio.current && radio.current.id }); });
++ row.appendChild(skip);
++ }
++ if (self && self.subject === owner) {
++ var allow = el('label', 'pt-radio-permission');
++ var box = el('input'); box.type = 'checkbox'; box.checked = !!(radio && radio.allow_everyone);
++ box.addEventListener('change', function () { radioAction('allow', { allow_everyone: box.checked }); });
++ allow.appendChild(box); allow.appendChild(document.createTextNode(' Let everyone add and skip tracks'));
++ row.appendChild(allow);
++ }
++ if (row.children.length) radioBox.appendChild(row);
++ }
++ }
++ function radioAction(action, body) {
++ var headers = { 'Content-Type': 'application/json' }, t = token();
++ if (t) headers.Authorization = 'Bearer ' + t;
++ fetch('/api/chat/parties/' + encodeURIComponent(slug) + '/radio/' + action, { method: 'POST', headers: headers, credentials: 'same-origin', body: JSON.stringify(body) })
++ .then(function (r) { return r.json().then(function (v) { if (!r.ok) throw new Error(v.error || v.code || 'Queue change failed'); return v; }); })
++ .then(function (v) { radio = v.radio; paintRadio(); })
++ .catch(function (e) { flash(e.message); });
++ }
++
// ── the remote ───────────────────────────────────────────────────────────────
function pill(text, moves) {
remote.textContent = '';
@@@ -212,7 +340,7 @@
if (!people) return;
people.textContent = '';
if (guest) {
-- people.appendChild(el('h2', 'pt-h', 'Watching'));
++ people.appendChild(el('h2', 'pt-h', isRadio ? 'Listening' : 'Watching'));
people.appendChild(el('p', 'pt-guest-note', 'Sign in to see who is here.'));
return;
}
@@@ -222,7 +350,8 @@
// Here first, then the ones whose seat waits, then the members who are not here.
var rank = function (s) { return watching.indexOf(s) >= 0 ? 0 : away[s] ? 1 : 2; };
all.sort(function (a, b) { return rank(a) - rank(b) || nameOf(a).localeCompare(nameOf(b)); });
-- var h = people.appendChild(el('h2', 'pt-h', watching.length === 1 ? '1 watching' : watching.length + ' watching'));
++ var verb = isRadio ? ' listening' : ' watching';
++ var h = people.appendChild(el('h2', 'pt-h', watching.length + verb));
h.id = 'pt-people-h';
var list = el('ul', 'pt-faces');
list.setAttribute('aria-labelledby', 'pt-people-h');
@@@ -320,10 +449,16 @@
if (m.subject && !self) self = { subject: m.subject, name: names[m.subject] || 'You' };
if (!sync && window.OVSync && video) sync = window.OVSync.attach(video, { room: slug, socket: ws, self: self && self.subject });
catchUp();
++ paintRadio();
} else if (m.type === 'sync' && m.state) {
clock = m.state;
if (!clock.paused || clock.position_ms > 0) everPlayed = true;
-- if (!src && typeof clock.media === 'string') { src = clock.media; video.src = src; }
++ if ((!isRadio || radio && radio.current) && typeof clock.media === 'string' && src !== clock.media) { src = clock.media; video.src = src; }
++ if (guest) applyGuestClock();
++ } else if (m.type === 'radio' && m.state) {
++ radio = m.state;
++ if (!radio.current) { video.pause(); video.removeAttribute('src'); src = null; }
++ paintRadio();
} else if (m.type === 'lease' && m.state) {
var before = lease && lease.watchers ? lease.watchers : [];
lease = m.state;
@@@ -364,6 +499,31 @@
if (changed) { paintPeople(); paintRemote(); }
}, 500);
video.addEventListener('play', function () { everPlayed = true; paintVeil(); });
++ video.addEventListener('ended', function () {
++ if (isRadio && !guest && self && radio && radio.current && (self.subject === owner || radio.allow_everyone))
++ radioAction('skip', { expected_id: radio.current.id });
++ });
++ video.addEventListener('loadedmetadata', applyGuestClock);
++ if (isRadio && guest) {
++ setInterval(function () {
++ var key = null;
++ try { key = new URLSearchParams(location.search).get('k') || localStorage.getItem('ov-party-key:' + slug); } catch (e) { /* storage blocked */ }
++ var sentAt = Date.now();
++ fetch('/api/chat/parties/' + encodeURIComponent(slug) + (key ? '?k=' + encodeURIComponent(key) : ''), { credentials: 'same-origin' })
++ .then(function (r) { return r.ok ? r.json() : null; })
++ .then(function (v) {
++ if (!v || !v.party) return;
++ var rtt = Date.now() - sentAt;
++ if (Number(v.server_time_ms) && rtt < bestClockRtt) { bestClockRtt = rtt; serverOffset = Number(v.server_time_ms) - (sentAt + Date.now()) / 2; }
++ clock = v.party.clock; radio = v.party.radio;
++ if (radio && !radio.current) { video.pause(); video.removeAttribute('src'); src = null; }
++ else if (clock && typeof clock.media === 'string' && src !== clock.media) { src = clock.media; video.src = src; }
++ paintRadio(); applyGuestClock();
++ }).catch(function () { /* retry next poll */ });
++ }, 3000);
++ }
++ if (isRadio && !guest) fetch('/api/chat/parties/radio/tracks').then(function (r) { return r.ok ? r.json() : null; })
++ .then(function (v) { catalogue = v && v.tracks || []; paintRadio(); }).catch(function () { /* retry on refresh */ });
// ── invite ───────────────────────────────────────────────────────────────────
if (invite && !guest) {
@@@ -385,6 +545,6 @@
invite.appendChild(ib);
}
-- paintVeil(); paintRemote(); paintPeople();
++ paintVeil(); paintRemote(); paintPeople(); paintRadio();
connect();
})();
ddiff --git a/server/chat/chat-server.js b/server/chat/chat-server.js
iindex 5654c2a..23a4ed5 100644
---- a/server/chat/chat-server.js
++++ b/server/chat/chat-server.js
@@@ -645,6 +645,13 @@ class ChatServer {
const access = await rooms.access(room, client.user);
const subject = client.user.subject_id;
if (!access.read || !subject) { this.sendTo(ws, { type: 'sync_error', room: room.slug, code: 'sync.sign_in', message: 'Sign in to change the room\'s clock' }); break; }
++ const radioParty = await db.get("SELECT party_kind, radio_track FROM room_parties WHERE room_id = ? AND party_kind = 'radio'", [room.id]);
++ if (radioParty) {
++ const track = typeof radioParty.radio_track === 'string' ? JSON.parse(radioParty.radio_track) : radioParty.radio_track;
++ if (!track || (msg.media !== undefined && msg.media !== track.audio_url)) {
++ this.sendTo(ws, { type: 'sync_error', room: room.slug, code: 'radio.licence', message: 'Choose a licensed Radio track from the queue' }); break;
++ }
++ }
let holder;
try { holder = (await controlLease.state(room, this.roomWatchers(room.id))).holder; } catch (err) { this.sendTo(ws, { type: 'sync_error', room: room.slug, code: 'sync.lease', message: 'The room\'s remote could not be read' }); break; }
if (holder.kind !== 'person' || holder.subject !== subject) {
ddiff --git a/server/rooms/parties.js b/server/rooms/parties.js
iindex 46d344e..44c8d03 100644
---- a/server/rooms/parties.js
++++ b/server/rooms/parties.js
@@@ -74,10 +74,10 @@ function sourceUrlOf(value) {
function mediaFromRow(value) {
if (value == null) return null;
if (typeof value !== 'string') return value;
-- try { return JSON.parse(value); } catch { return null; }
++ try { return JSON.parse(value); } catch { return value.startsWith('https://') ? value : null; }
}
--const kindOfParty = (party) => (party && party.party_kind === 'media' ? 'media' : 'agent_stream');
++const kindOfParty = (party) => (party && ['media', 'radio'].includes(party.party_kind) ? party.party_kind : 'agent_stream');
/** The party's position now: on a VOD, a playing one moves with the clock; a live one is the live edge. */
function syncOf(party, now = Date.now()) {
@@@ -94,7 +94,7 @@ function syncOf(party, now = Date.now()) {
function joinUrl(row, kind, rawSecret = null) {
const slug = encodeURIComponent(row.slug);
-- const base = kind === 'media' ? `${PARTY_BASE_URL()}/p/${slug}` : `${BASE_URL()}/rooms/${slug}`;
++ const base = kind === 'media' || kind === 'radio' ? `${PARTY_BASE_URL()}/p/${slug}` : `${BASE_URL()}/rooms/${slug}`;
return rawSecret ? `${base}?k=${encodeURIComponent(rawSecret)}` : base;
}
@@@ -105,7 +105,8 @@ function view(row, party, { rawSecret = null, now = Date.now() } = {}) {
kind,
agent_stream_id: party.agent_stream_id || null,
vod_id: party.vod_id == null ? null : String(party.vod_id),
-- ...(kind === 'media' ? { media: mediaFromRow(party.media), source_url: party.source_url || null } : {}),
++ ...(kind === 'media' || kind === 'radio' ? { media: mediaFromRow(party.media), source_url: party.source_url || null } : {}),
++ ...(kind === 'radio' ? { radio_track: mediaFromRow(party.radio_track) } : {}),
host: party.host_subject,
title: row.name,
...(kind === 'agent_stream' ? { sync: syncOf(party, now) } : {}),
@@@ -133,7 +134,7 @@ async function detail(row, party, { user = null, key = null, now = Date.now() }
host_name: host ? (host.display_name || host.username) : null,
member,
};
-- if (kindOfParty(party) === 'media') {
++ if (kindOfParty(party) === 'media' || kindOfParty(party) === 'radio') {
const room = { id: row.id, slug: row.slug };
out.clock = await syncClock.get(room);
if (member) {
@@@ -143,6 +144,7 @@ async function detail(row, party, { user = null, key = null, now = Date.now() }
.map((m) => ({ subject: m.subject_id || null, name: m.display_name || m.username, role: m.role }));
}
}
++ if (kindOfParty(party) === 'radio') out.radio = await require('./radio').state(row, party, { user, key });
return out;
}
@@@ -151,6 +153,7 @@ async function detail(row, party, { user = null, key = null, now = Date.now() }
* Returns the party and the one time its raw join secret exists.
*/
async function create(user, input = {}) {
++ if (input && input.radio_track_id) return await require('./radio').start(user, input);
if (input && input.media !== undefined && input.media !== null && input.media !== '') return await createMedia(user, input);
return await createAgentStream(user, input);
}
@@@ -183,7 +186,7 @@ async function createAgentStream(user, { agent_stream_id: agentStreamId, vod_id:
* the remote granted to the person who started it. The join link is openvibe.party/p/<slug>?k=…, and the secret is
* in this answer only.
*/
--async function createMedia(user, { media, source_url: sourceUrl = null, title, listed = false } = {}) {
++async function createMedia(user, { media, source_url: sourceUrl = null, title, listed = false, partyKind = 'media', radioTrack = null } = {}) {
if (!user) fail(401, 'party.sign_in', 'Sign in to start a watch party');
if (!/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(user.subject_id || ''))) fail(409, 'party.no_subject', 'This account has no OpenVibe subject yet; sign in again');
const chosen = mediaOf(media);
@@@ -195,9 +198,9 @@ async function createMedia(user, { media, source_url: sourceUrl = null, title, l
const room = await rooms.create(user, { name: roomName, slug, kind: 'party', visibility: 'private', topic: name, party: true });
const id = room.id;
await db.run(
-- `INSERT INTO room_parties (room_id, agent_stream_id, vod_id, host_user_id, host_subject, mode, playing, listed, join_secret_hash, party_kind, media, source_url)
-- VALUES (?, NULL, NULL, ?, ?, 'vod', false, ?, ?, 'media', ?, ?)`,
-- [id, user.id, user.subject_id || '', !!listed, sha256(raw), JSON.stringify(chosen), source]
++ `INSERT INTO room_parties (room_id, agent_stream_id, vod_id, host_user_id, host_subject, mode, playing, listed, join_secret_hash, party_kind, media, source_url, radio_track)
++ VALUES (?, NULL, NULL, ?, ?, 'vod', false, ?, ?, ?, ?, ?, ?)`,
++ [id, user.id, user.subject_id || '', !!listed, sha256(raw), partyKind, JSON.stringify(chosen), source, radioTrack ? JSON.stringify(radioTrack) : null]
);
const shared = { id, slug: room.slug };
await syncClock.set(shared, user.subject_id, { media: chosen, position_ms: 0, rate: 1, paused: true });
@@@ -208,7 +211,7 @@ async function createMedia(user, { media, source_url: sourceUrl = null, title, l
}
async function bySlug(slug) {
-- const row = await db.get('SELECT r.*, p.room_id AS party_room_id, p.agent_stream_id, p.vod_id, p.host_user_id, p.host_subject, p.mode, p.position_s, p.playing, p.position_at, p.listed, p.join_secret_hash, p.ended_at, p.created_at AS party_created_at, p.party_kind, p.media, p.source_url FROM rooms r JOIN room_parties p ON p.room_id = r.id WHERE r.slug = ?', [String(slug || '').toLowerCase()]);
++ const row = await db.get('SELECT r.*, p.room_id AS party_room_id, p.agent_stream_id, p.vod_id, p.host_user_id, p.host_subject, p.mode, p.position_s, p.playing, p.position_at, p.listed, p.join_secret_hash, p.ended_at, p.created_at AS party_created_at, p.party_kind, p.media, p.source_url, p.radio_track FROM rooms r JOIN room_parties p ON p.room_id = r.id WHERE r.slug = ?', [String(slug || '').toLowerCase()]);
return row || null;
}
@@@ -218,7 +221,7 @@ function partyOf(row) {
host_user_id: row.host_user_id, host_subject: row.host_subject, mode: row.mode,
position_s: row.position_s, playing: row.playing, position_at: row.position_at, listed: row.listed,
join_secret_hash: row.join_secret_hash, ended_at: row.ended_at, created_at: row.party_created_at,
-- party_kind: row.party_kind || 'agent_stream', media: row.media, source_url: row.source_url,
++ party_kind: row.party_kind || 'agent_stream', media: row.media, source_url: row.source_url, radio_track: row.radio_track,
} : null;
}
@@@ -238,7 +241,7 @@ async function mine(user, { limit = 6 } = {}) {
const since = new Date(Date.now() - 14 * 86400_000).toISOString();
const rows = await db.all(`SELECT r.slug, r.name, p.host_user_id, p.created_at
FROM room_members m JOIN rooms r ON r.id = m.room_id JOIN room_parties p ON p.room_id = r.id
-- WHERE m.user_id = ? AND p.party_kind = 'media' AND p.ended_at IS NULL AND r.archived_at IS NULL AND p.created_at > ?
++ WHERE m.user_id = ? AND p.party_kind IN ('media', 'radio') AND p.ended_at IS NULL AND r.archived_at IS NULL AND p.created_at > ?
ORDER BY p.created_at DESC LIMIT ?`, [user.id, since, Math.max(1, Math.min(12, Number(limit) || 6))]);
return (rows || []).map((x) => ({
id: x.slug, title: x.name, hosting: Number(x.host_user_id) === Number(user.id),
@@@ -267,7 +270,7 @@ async function joinWithLink(row, party, user, key) {
/** The host moves the VOD (or pauses it). Live parties follow the live edge and take no position. */
async function setSync(row, party, user, { position_s: position, playing } = {}) {
if (!user || Number(user.id) !== Number(party.host_user_id)) fail(403, 'party.not_host', 'Only the host moves the party');
-- if (kindOfParty(party) === 'media') fail(409, 'party.clock', 'A media party follows the room\'s shared clock; the remote moves it');
++ if (kindOfParty(party) === 'media' || kindOfParty(party) === 'radio') fail(409, 'party.clock', 'A media party follows the room\'s shared clock; the remote moves it');
if (party.mode === 'live') fail(409, 'party.live_edge', 'A live party follows the live edge; there is no position to set');
let pos = party.position_s;
if (position !== undefined) {
ddiff --git a/server/rooms/party-routes.js b/server/rooms/party-routes.js
iindex 7d5b312..ae379d9 100644
---- a/server/rooms/party-routes.js
++++ b/server/rooms/party-routes.js
@@@ -16,6 +16,7 @@ const cache = require('openvibe-shared/cache-policy');
const { requireAuth, optionalAuth } = require('../auth/auth');
const { limits } = require('../net/actor-limits');
const parties = require('../rooms/parties');
++const radio = require('../rooms/radio');
const router = express.Router();
@@@ -35,13 +36,35 @@ router.post('/', requireAuth, createLimit, async (req, res) => {
} catch (err) { send(res, err); }
});
++router.get('/radio/tracks', async (_req, res) => {
++ res.set('Cache-Control', 'public, max-age=30').json({ tracks: await radio.catalogue() });
++});
++
++router.get('/:slug/radio', optionalAuth, async (req, res) => {
++ try {
++ const row = await parties.bySlug(req.params.slug), party = parties.partyOf(row);
++ const out = await radio.state(row, party, { user: req.user || null, key: req.query.k });
++ if (!out) return res.status(404).json({ code: 'rooms.not_found' });
++ res.set('Cache-Control', 'private, no-store').json({ radio: out });
++ } catch (err) { send(res, err); }
++});
++
++router.post('/:slug/radio/:action', requireAuth, createLimit, async (req, res) => {
++ try {
++ const row = await parties.bySlug(req.params.slug), party = parties.partyOf(row);
++ if (!row || !party || !await parties.mayView(row, party, req.user, null)) return res.status(404).json({ code: 'rooms.not_found' });
++ const out = await radio.change(row, party, req.user, req.params.action, req.body || {});
++ res.set('Cache-Control', 'private, no-store').json({ radio: out });
++ } catch (err) { send(res, err); }
++});
++
router.get('/:slug', optionalAuth, async (req, res) => {
try {
const row = await parties.bySlug(req.params.slug);
const party = parties.partyOf(row);
const out = row && party ? await parties.detail(row, party, { user: req.user || null, key: req.query.k }) : null;
if (!out) return res.status(404).json({ error: 'No such room', code: 'rooms.not_found' });
-- res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({ party: out });
++ res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({ party: out, server_time_ms: Date.now() });
} catch (err) { send(res, err); }
});
ddiff --git a/server/rooms/radio.js b/server/rooms/radio.js
nnew file mode 100644
iindex 0000000..a8d1556
---- /dev/null
++++ b/server/rooms/radio.js
@@@ -0,0 +1,96 @@
++'use strict';
++const db = require('../db/database');
++const rooms = require('./rooms');
++const parties = require('./parties');
++const clock = require('./sync-clock');
++
++const TRACK_ID = /^rad_[0-9A-HJKMNP-TV-Z]{26}$/;
++const LICENCES = new Set(['creator_grant', 'CC0-1.0', 'CC-BY-4.0', 'CC-BY-SA-4.0', 'public_domain']);
++const base = () => String(process.env.CHAT_RADIO_URL || (process.env.NODE_ENV === 'test' ? '' : 'https://openvibe.download')).replace(/\/+$/, '');
++const publicBase = 'https://openvibe.download';
++const fail = (status, code, message) => { throw new parties.PartyError(status, code, message); };
++const decode = (v) => { if (typeof v !== 'string') return v; try { return JSON.parse(v); } catch { return null; } };
++const trackView = (t) => t && ({ id: t.id, title: t.title, artist: t.artist, source: t.source, source_url: t.source_url,
++ licence: t.licence, licence_url: t.licence_url, changes_note: t.changes_note || null, ai_made: !!t.ai_made, audio_url: t.audio_url });
++
++async function catalogue() {
++ if (!base()) return [];
++ try {
++ const response = await fetch(`${base()}/api/v1/radio/tracks`, { redirect: 'error', signal: AbortSignal.timeout(2500) });
++ if (!response.ok) return [];
++ const body = await response.json();
++ return (Array.isArray(body.tracks) ? body.tracks : []).filter((t) => TRACK_ID.test(t.id)
++ && t.audio_url === `${publicBase}/api/v1/radio/tracks/${encodeURIComponent(t.id)}/audio`
++ && t.title && t.artist && LICENCES.has(t.licence) && /^https:\/\//.test(t.licence_url)).slice(0, 100).map(trackView);
++ } catch { return []; }
++}
++
++async function track(id) {
++ if (!TRACK_ID.test(String(id || ''))) fail(422, 'radio.track', 'Choose an OpenVibe Radio track');
++ if (!base()) fail(503, 'radio.unavailable', 'OpenVibe Radio is not configured');
++ let response;
++ try { response = await fetch(`${base()}/api/v1/radio/tracks/${encodeURIComponent(id)}`, { redirect: 'error', signal: AbortSignal.timeout(2500) }); }
++ catch { fail(503, 'radio.unavailable', 'OpenVibe Radio did not answer'); }
++ if (!response.ok) fail(422, 'radio.track', 'That track is not published on OpenVibe Radio');
++ const value = (await response.json().catch(() => null))?.track;
++ const expected = `${publicBase}/api/v1/radio/tracks/${encodeURIComponent(id)}/audio`;
++ if (!value || value.id !== id || value.audio_url !== expected || !value.title || !value.artist || !LICENCES.has(value.licence) || !/^https:\/\//.test(value.licence_url) || !['creator', 'creative_commons', 'public_domain', 'ai'].includes(value.source))
++ fail(502, 'radio.catalogue', 'OpenVibe Radio returned incomplete track rights');
++ return trackView(value);
++}
++
++async function start(user, input) {
++ const chosen = await track(input.radio_track_id);
++ return parties.createMedia(user, { media: chosen.audio_url, source_url: `${publicBase}/radio/tracks/${chosen.id}`,
++ title: input.title || `${chosen.title} listening party`, partyKind: 'radio', radioTrack: chosen });
++}
++
++async function state(row, party, { user = null, key = null } = {}) {
++ if (!row || !party || party.party_kind !== 'radio') return null;
++ if (!await parties.mayView(row, party, user, key)) return null;
++ const fresh = await db.get('SELECT radio_track, radio_queue, radio_allow_all, radio_version FROM room_parties WHERE room_id = ?', [row.id]);
++ return { current: decode(fresh.radio_track), queue: decode(fresh.radio_queue) || [], allow_everyone: !!fresh.radio_allow_all, version: Number(fresh.radio_version) || 0 };
++}
++
++async function change(row, party, user, action, input = {}) {
++ const access = user ? await rooms.access(row, user) : null;
++ if (!access || !access.read) fail(404, 'rooms.not_found', 'No such room');
++ if (!party || party.party_kind !== 'radio' || party.ended_at) fail(409, 'radio.party', 'This is not an active listening party');
++ const isHost = user.subject_id === party.host_subject;
++ const shared = { id: row.id, slug: row.slug };
++ for (let tries = 0; tries < 5; tries++) {
++ const old = await state(row, party, { user });
++ if (!isHost && !old.allow_everyone) fail(403, 'radio.rights', 'Only the host may change the queue');
++ const queue = old.queue.slice();
++ let current = old.current;
++ if (action === 'queue') {
++ if (queue.length >= 100) fail(409, 'radio.queue_full', 'The queue is full');
++ queue.push(await track(input.track_id));
++ } else if (action === 'skip') {
++ if (input.expected_id && input.expected_id !== (current && current.id)) return old;
++ current = null;
++ while (queue.length && !current) {
++ const next = queue.shift();
++ try { current = await track(next.id); } catch (err) { if (!(err instanceof parties.PartyError) || err.status >= 500) throw err; }
++ }
++ } else if (action === 'allow') {
++ if (!isHost) fail(403, 'radio.rights', 'Only the host changes queue rights');
++ } else fail(422, 'radio.action', 'Unknown queue action');
++ const allow = action === 'allow' ? input.allow_everyone === true : old.allow_everyone;
++ const updated = await db.get(`UPDATE room_parties SET radio_track = ?, radio_queue = ?, radio_allow_all = ?, radio_version = radio_version + 1
++ WHERE room_id = ? AND radio_version = ? RETURNING radio_version`, [current ? JSON.stringify(current) : null, JSON.stringify(queue), allow, row.id, old.version]);
++ if (!updated) continue;
++ if (action === 'skip') {
++ const sync = await clock.set(shared, user.subject_id, current
++ ? { media: current.audio_url, position_ms: 0, paused: false, rate: 1 }
++ : { position_ms: 0, paused: true });
++ require('../chat/chat-server').broadcastToRoom(row.id, { type: 'sync', room: row.slug, state: sync });
++ }
++ const result = { current, queue, allow_everyone: allow, version: Number(updated.radio_version) };
++ require('../chat/chat-server').broadcastToRoom(row.id, { type: 'radio', room: row.slug, state: result });
++ return result;
++ }
++ fail(409, 'radio.conflict', 'The queue changed; try again');
++}
++
++module.exports = { track, catalogue, start, state, change };
ddiff --git a/server/web/party-pages.js b/server/web/party-pages.js
iindex 3d9d5b9..df10c46 100644
---- a/server/web/party-pages.js
++++ b/server/web/party-pages.js
@@@ -26,6 +26,9 @@ const { renderPage, contentSecurityPolicy, esc } = require('./layout');
const { createSessionRoutes } = require('./session');
const { viewerOf } = require('./pages');
const parties = require('../rooms/parties');
++const radio = require('../rooms/radio');
++const radioSource = (v) => ({ creator: 'Creator upload', creative_commons: 'Creative Commons', public_domain: 'Public domain', ai: 'AI-made music' })[v] || v;
++const radioLicence = (v) => v === 'creator_grant' ? 'Creator’s permission to stream' : v === 'public_domain' ? 'Public domain mark' : v;
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public', 'web');
const SITE = Object.freeze({ name: 'OpenVibe.Party', service: 'party', links: [{ key: 'start', label: 'Start a party', href: '/' }] });
@@@ -138,7 +141,7 @@ function createPartyRoutes({ config }) {
const actor = await viewerOf(req);
const signedIn = actor.kind === 'user';
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
-- const picks = await latestWatchable();
++ const [picks, tracks] = await Promise.all([latestWatchable(), radio.catalogue()]);
const mine = signedIn ? await parties.mine(actor.user, { limit: 6 }).catch(() => []) : [];
const since = (iso) => {
const days = Math.floor((Date.now() - Date.parse(iso)) / 86400_000);
@@@ -176,6 +179,9 @@ ${error}
${start}
</header>
${yours}
++<section class="pty-radio" aria-labelledby="pty-radio-h"><h2 id="pty-radio-h">OpenVibe Radio</h2><p>Listen together using music its creator has shared for streaming. Every track shows its source and licence.</p>
++${tracks.length ? `<ul class="pty-radio-list">${tracks.slice(0, 12).map((t) => `<li><div><strong>${esc(t.title)}</strong><span>${esc(t.artist)}${t.ai_made ? ' · AI-made music' : ''}</span><small>Source: ${esc(radioSource(t.source))} · <a href="${esc(t.licence_url)}">${esc(radioLicence(t.licence))}</a>${t.source_url ? ` · <a href="${esc(t.source_url)}">Original source</a>` : ''}</small></div><a class="oc-button" href="/new?radio=${encodeURIComponent(t.id)}">Listen together</a></li>`).join('')}</ul>` : '<p>No Radio tracks are published yet. Creators can mark their own audio uploads as music on OpenVibe.Download.</p>'}
++<p>Spotify, Apple Music and YouTube Music listening is coming after those services approve it.</p></section>
${grid}
<section class="pty-how" aria-labelledby="pty-how-h">
<h2 id="pty-how-h">How it works</h2>
@@@ -193,6 +199,12 @@ ${grid}
router.get('/new', async (req, res) => {
const actor = await viewerOf(req);
if (actor.kind !== 'user') return res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
++ if (req.query.radio) {
++ let chosen;
++ try { chosen = await radio.track(String(req.query.radio)); } catch { return res.redirect(303, '/?error=That%20Radio%20track%20is%20not%20available'); }
++ return page(req, res, 200, { actor, title: 'Start a listening party', path: '/new', robots: 'noindex, nofollow', footer: 'compact', partyHome: true,
++ body: `<section class="oc-party-confirm pty-confirm"><h1>Start a listening party?</h1><p class="oc-lede"><strong>${esc(chosen.title)}</strong> by ${esc(chosen.artist)}</p><p>Source: ${esc(radioSource(chosen.source))} · <a href="${esc(chosen.licence_url)}">${esc(radioLicence(chosen.licence))}</a>${chosen.ai_made ? ' · AI-made music' : ''}</p><p>Everyone hears the track at the party’s position. You control the queue and can allow everyone to add and skip.</p><form method="post" action="/new"><input type="hidden" name="radio_track_id" value="${esc(chosen.id)}"><button class="oc-button" type="submit">Start listening</button></form></section>` });
++ }
const asked = String(req.query.media || '').trim();
if (!asked) return res.redirect(303, `/?error=${encodeURIComponent('Paste an OpenVibe link to start a party')}`);
const want = await playable({ media: asked, title: String(req.query.title || '').replace(/\s+/g, ' ').trim().slice(0, 200), source: req.query.source_url ? String(req.query.source_url).slice(0, 2048) : '' });
@@@ -227,6 +239,14 @@ ${source ? `<input type="hidden" name="source_url" value="${esc(source)}">` : ''
const origin = String(req.get('origin') || '');
const fetchSite = String(req.get('sec-fetch-site') || '');
if ((origin && origin !== base) || (fetchSite && fetchSite !== 'same-origin')) return res.status(403).type('text/plain').send('Start a party from openvibe.party itself.');
++ if (req.body && req.body.radio_track_id) {
++ try {
++ const out = await radio.start(actor.user, { radio_track_id: String(req.body.radio_track_id) });
++ return res.redirect(303, `/p/${encodeURIComponent(out.party.id)}?k=${encodeURIComponent(out.join_secret)}`);
++ } catch (err) {
++ return res.redirect(303, `/?error=${encodeURIComponent(err instanceof parties.PartyError ? err.message : 'That listening party could not be started')}`);
++ }
++ }
const asked = String((req.body && req.body.media) || '').trim();
if (!asked) return res.redirect(303, `/?error=${encodeURIComponent('Paste an OpenVibe link to start a party')}`);
// A watch page posted here directly (not through the confirmation) is made playable the same way.
@@@ -269,15 +289,15 @@ ${source ? `<input type="hidden" name="source_url" value="${esc(source)}">` : ''
actor, title: out.title, path: `/p/${out.id}`, robots: 'noindex, nofollow', footer: 'compact',
description: 'Watch together on OpenVibe.',
mainAttrs: `data-party="${esc(out.id)}"`, party: true, wide: true,
-- body: `<article class="oc-party">
++ body: `<article class="oc-party${out.kind === 'radio' ? ' is-radio' : ''}">
<header class="oc-party-head"><h1>${esc(out.title)}</h1><p class="oc-muted">Started by ${esc(out.host_name || 'someone')}</p></header>
--<div class="oc-party-stage" data-party-stage aria-label="The video"></div>
++<div class="oc-party-stage" data-party-stage aria-label="${out.kind === 'radio' ? 'The music stage' : 'The video'}"></div>
<div class="oc-party-people" data-party-people aria-label="People here"></div>
<div class="oc-party-chat" data-party-chat aria-label="Chat"></div>
<div class="oc-party-remote" data-party-remote aria-label="The remote"></div>
<div class="oc-party-invite" data-party-invite aria-label="Invite"></div>
</article>
--<script type="application/json" id="party-data">${escJson({ party: out })}</script>`,
++<script type="application/json" id="party-data">${escJson({ party: out, server_time_ms: Date.now() })}</script>`,
});
} catch (err) { next(err); }
});
ddiff --git a/test/party-radio.test.js b/test/party-radio.test.js
nnew file mode 100644
iindex 0000000..180da75
---- /dev/null
++++ b/test/party-radio.test.js
@@@ -0,0 +1,93 @@
++'use strict';
++const assert = require('assert');
++const http = require('http');
++const { ids } = require('openvibe-contracts');
++const { boot, suite } = require('./helpers');
++
++const t = suite('party radio');
++const ID = 'rad_01JAB2C3D4E5F6G7H8J9K0MNPQ';
++const AUDIO = `https://openvibe.download/api/v1/radio/tracks/${ID}/audio`;
++const TRACK = { id: ID, title: 'Friday Song', artist: 'Ann', source: 'creator', source_url: null,
++ licence: 'creator_grant', licence_url: 'https://openvibe.download/radio/licence', ai_made: false, audio_url: AUDIO };
++let service, h, ann, bob, cat, party, key;
++const api = (method, path, u, body) => h.http(method, `/api/chat/parties${path}`, { token: u && u.token, body });
++
++t('boot with the Radio catalogue', async () => {
++ service = http.createServer((req, res) => {
++ res.setHeader('content-type', 'application/json');
++ if (req.url === '/api/v1/radio/tracks') return res.end(JSON.stringify({ tracks: [TRACK] }));
++ if (req.url === `/api/v1/radio/tracks/${ID}`) return res.end(JSON.stringify({ track: TRACK }));
++ res.writeHead(404); res.end('{}');
++ });
++ await new Promise((r) => service.listen(0, '127.0.0.1', r));
++ process.env.CHAT_RADIO_URL = `http://127.0.0.1:${service.address().port}`;
++ h = await boot({ env: { CHAT_PARTY_URL: 'https://openvibe.party' } });
++ ann = h.addUser('ann', { subject: ids.newId('user') });
++ bob = h.addUser('bob', { subject: ids.newId('user') });
++ cat = h.addUser('cat', { subject: ids.newId('user') });
++ for (const u of [ann, bob, cat]) await h.ctx.upsertUser(h.live.users.get(u.id));
++});
++
++t('a published track starts a listening party with rights on the stage', async () => {
++ const made = await api('POST', '/', ann, { radio_track_id: ID });
++ assert.strictEqual(made.status, 201, made.text);
++ party = made.body.party;
++ key = made.body.join_secret;
++ assert.strictEqual(party.kind, 'radio');
++ assert.strictEqual(party.media, AUDIO);
++ const detail = await api('GET', `/${party.id}`, ann);
++ assert.strictEqual(detail.body.party.radio.current.licence, 'creator_grant');
++ assert.strictEqual(detail.body.party.clock.position_ms, 0);
++ assert.strictEqual(detail.body.party.clock.paused, true);
++ const guest = await api('GET', `/${party.id}?k=${made.body.join_secret}`, null);
++ assert.strictEqual(guest.status, 200);
++ assert.strictEqual(guest.body.party.radio.current.source, 'creator');
++});
++
++t('the host controls queue rights and a skip starts the next track at zero', async () => {
++ const join = await h.http('POST', `/api/chat/rooms/${party.id}/party/join`, { token: bob.token, body: { k: key } });
++ assert.strictEqual(join.status, 200, join.text);
++ const forbidden = await api('POST', `/${party.id}/radio/queue`, bob, { track_id: ID });
++ assert.strictEqual(forbidden.status, 403);
++ assert.strictEqual(forbidden.body.code, 'radio.rights');
++ assert.strictEqual((await api('POST', `/${party.id}/radio/queue`, ann, { track_id: 'https://example.org/song.mp3' })).body.code, 'radio.track');
++ const queued = await api('POST', `/${party.id}/radio/queue`, ann, { track_id: ID });
++ assert.strictEqual(queued.status, 200, queued.text);
++ assert.strictEqual(queued.body.radio.queue.length, 1);
++ assert.strictEqual((await api('POST', `/${party.id}/radio/allow`, bob, { allow_everyone: true })).status, 403);
++ const allowed = await api('POST', `/${party.id}/radio/allow`, ann, { allow_everyone: true });
++ assert.strictEqual(allowed.body.radio.allow_everyone, true);
++ assert.strictEqual((await api('POST', `/${party.id}/radio/queue`, bob, { track_id: ID })).status, 200);
++ const skipped = await api('POST', `/${party.id}/radio/skip`, bob, {});
++ assert.strictEqual(skipped.status, 200, skipped.text);
++ assert.strictEqual(skipped.body.radio.current.id, ID);
++ const now = await api('GET', `/${party.id}`, ann);
++ assert.strictEqual(now.body.party.clock.media, AUDIO);
++ assert.strictEqual(now.body.party.clock.position_ms, 0);
++ assert.strictEqual(now.body.party.clock.paused, false);
++});
++
++t('a late joiner receives the shared clock at the right second', async () => {
++ const clock = require('../server/rooms/sync-clock');
++ const row = await require('../server/rooms/parties').bySlug(party.id);
++ const room = { id: row.id, slug: row.slug };
++ await clock.set(room, ann.subject_id, { media: AUDIO, position_ms: 12000, paused: false, rate: 1 }, { now: Date.now() - 5000 });
++ const joined = await h.http('POST', `/api/chat/rooms/${party.id}/party/join`, { token: cat.token, body: { k: key } });
++ assert.strictEqual(joined.status, 200, joined.text);
++ const ws = await h.ws({ token: cat.token, origin: 'https://openvibe.party' });
++ ws.sendJson({ type: 'join_room', room: party.id });
++ await ws.next((m) => m.type === 'room_joined');
++ const sync = await ws.next((m) => m.type === 'sync');
++ const position = sync.state.position_ms + (Date.now() - sync.state.at_ms) * sync.state.rate;
++ assert.ok(position >= 17000 && position < 18500, `late joiner position ${position}`);
++ const host = await h.ws({ token: ann.token, origin: 'https://openvibe.party' });
++ host.sendJson({ type: 'join_room', room: party.id });
++ await host.next((m) => m.type === 'room_joined');
++ host.sendJson({ type: 'sync', media: 'https://openvibe.media/unlicensed.mp3', position_ms: 0 });
++ assert.strictEqual((await host.next((m) => m.type === 'sync_error')).code, 'radio.licence');
++ host.close();
++ ws.close();
++});
++
++t('close', async () => { await h.close(); await new Promise((r) => service.close(r)); delete process.env.CHAT_RADIO_URL; });
++t.run();
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex f3770d5..9b84b80 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -124,6 +124,7 @@ t('ratchet: every file that makes an outbound request itself is reviewed', () =>
'server/inbox/webhooks.js': "an inbox owner's webhook addresses: https only, assertPublicUrl when saved and safeLookup on every delivery, no redirects followed",
'server/inbox/actions.js': "an inbox owner's action addresses: https only, assertPublicUrl when saved and safeLookup on every call, no redirects followed, 8 s",
'server/web/party-pages.js': 'OpenVibe.Video (fixed: https://openvibe.video, or a test\'s CHAT_PARTY_VIDEO_URL), fixed /api/v1/video/watchable paths; a pasted link is only a query parameter, never fetched; redirects refused, 2.5 s, cached a minute',
++ 'server/rooms/radio.js': 'OpenVibe.Download Radio catalogue (fixed https origin, or a test\'s CHAT_RADIO_URL); track ids match rad_ ULIDs, redirects refused, 2.5 s',
'server/chat/link-cards.js': 'OpenVibe.Video and OpenVibe.Actor (fixed https origins, or a test\'s CHAT_LINK_VIDEO_URL / CHAT_LINK_ACTOR_URL), fixed paths (a watchable lookup, /voices/<id>.json, /skills/<slug>.json); a pasted link is a query parameter or a path segment only, never fetched; redirects refused, 2.5 s, cached a minute',
'server/web/room-embed-pages.js': 'not the server: the sign-in window\'s own script posts to /embed/bind on this same site',
};