openvibers/openvibe.live Commits

Schedules from OpenVibe.Today on the channel and the dashboard (#429)

OP OpenVibers committed 16 days ago · ace23caa71 · 11 files, +262 −1

* Schedules from OpenVibe.Today: an offline channel shows its next stream (Remind me), and the dashboard's Schedule card lists yours and links to Today's form; /api/today/channel/:username reads Today's public API with a minute's cache

* The Today schedule read is a reviewed outbound site (OpenVibe.Today, configured)

Changes

ddiff --git a/docs/dashboard.md b/docs/dashboard.md
iindex 56f1d79e..ddb7b38c 100644
---- a/docs/dashboard.md
++++ b/docs/dashboard.md
@@@ -2,6 +2,14 @@
The dashboard is your control center for managing your channel. Access it by logging in and clicking your username.
++## Schedule
++
++The home tab's Schedule card lists your next streams from [OpenVibe.Today](https://openvibe.today/) and links to its
++schedule form. A scheduled stream shows on your channel while you are offline ("Next stream", with Remind me), in your
++followers' bells (at most three new schedules a day) and in calendars that subscribe to `openvibe.today/ics/@you.ics`;
++going live marks it live. Live keeps no copy: `GET /api/today/channel/:username` reads Today's public API and keeps the
++answer a minute.
++
## Stream Key
Your RTMP stream key is shown (hidden by default) for use with OBS, Streamlabs, or other RTMP software. You can regenerate it at any time — this invalidates the old key.
ddiff --git a/public/css/features/channel.css b/public/css/features/channel.css
iindex b7b7a432..d788f517 100644
---- a/public/css/features/channel.css
++++ b/public/css/features/channel.css
@@@ -1606,3 +1606,13 @@ button.viewer-badge:focus-visible { outline: 2px solid #93c5fd; outline-offset:
#page-channel .mobile-chat-toggle { top: 10px; bottom: auto; right: 10px; width: 42px; height: 42px; font-size: 0.95rem; }
body:has(#page-channel.active) #messenger-toggle { display: none !important; }
}
++
++/* The next scheduled stream, from OpenVibe.Today (offline header). */
++.ch-next { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; margin: 8px 0 2px; padding: 8px 12px; border-radius: 12px; font-size: .9rem; background: color-mix(in srgb, var(--accent) 9%, var(--bg-card)); border: 1px solid color-mix(in srgb, var(--accent) 30%, var(--border)); }
++.ch-next[hidden] { display: none; }
++.ch-next > i { color: var(--accent-light, var(--accent)); }
++.ch-next-k { font-size: .72rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted); }
++.ch-next time { font-weight: 700; color: var(--text-primary); font-variant-numeric: tabular-nums; }
++.ch-next-t { min-width: 0; color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; }
++.ch-next-a, .ch-next-more { margin-left: auto; font-weight: 700; text-decoration: none; color: var(--accent-light, var(--accent)); white-space: nowrap; }
++.ch-next-more { margin-left: 0; font-weight: 600; color: var(--text-muted); }
ddiff --git a/public/css/features/dashboard.css b/public/css/features/dashboard.css
iindex cd176f34..5f3c5570 100644
---- a/public/css/features/dashboard.css
++++ b/public/css/features/dashboard.css
@@@ -507,3 +507,11 @@
.vibe-word .form-input { max-width: 280px; }
.vibe-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
#dash-card-vibe [data-vibe-word] { color: var(--accent-light, var(--accent)); }
++
++/* Schedule card (OpenVibe.Today). */
++.dash-schedule-list { list-style: none; margin: 0 0 12px; padding: 0; display: grid; gap: 8px; }
++.dash-schedule-list li { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; }
++.dash-schedule-list time { font-weight: 700; font-variant-numeric: tabular-nums; color: var(--text-primary); }
++.dash-schedule-list a { color: var(--text-secondary); text-decoration: none; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++.dash-schedule-list a:hover { color: var(--text-primary); text-decoration: underline; }
++.dash-schedule-actions { display: flex; flex-wrap: wrap; gap: 8px; }
ddiff --git a/public/fragments/channel.html b/public/fragments/channel.html
iindex 7133493b..163a75a8 100644
---- a/public/fragments/channel.html
++++ b/public/fragments/channel.html
@@@ -238,6 +238,7 @@
<p class="ch-username" id="ch-username-offline">username</p>
<p class="ch-description" id="ch-description-offline"></p>
<div class="ch-social-pills" id="ch-socials-offline" hidden></div>
++ <div class="ch-next" id="ch-next-offline" hidden></div>
<div class="ch-meta">
<span class="ch-follower-count" id="ch-follower-count-offline">0 followers</span>
<a class="ch-game-badge" id="ch-game-badge-offline" href="https://openvibe.games/" target="_blank" rel="noopener" hidden></a>
ddiff --git a/public/fragments/dashboard.html b/public/fragments/dashboard.html
iindex a61bced0..ed7a947a 100644
---- a/public/fragments/dashboard.html
++++ b/public/fragments/dashboard.html
@@@ -365,6 +365,17 @@
</div>
<!-- Profile / Account (migrated from /settings) -->
++ <!-- Schedule (OpenVibe.Today): the next streams on this channel, and where to plan one -->
++ <div class="dash-card" id="dash-card-schedule">
++ <h3><i class="fa-regular fa-calendar"></i> Schedule</h3>
++ <p class="muted" style="margin-bottom:12px">Plan your next streams on OpenVibe.Today. They show on your channel and in your followers&rsquo; bells and calendars; going live marks them live.</p>
++ <ul class="dash-schedule-list" id="dash-schedule-list"></ul>
++ <div class="dash-schedule-actions">
++ <a class="btn btn-primary btn-small" href="https://openvibe.today/schedule" target="_blank" rel="noopener"><i class="fa-solid fa-plus"></i> Schedule a stream</a>
++ <a class="btn btn-outline btn-small" id="dash-schedule-cal" href="https://openvibe.today/" target="_blank" rel="noopener"><i class="fa-regular fa-calendar"></i> Your calendar</a>
++ </div>
++ </div>
++
<div class="dash-card dash-card-wide" id="dash-card-profile">
<h3 style="display:flex;align-items:center;justify-content:space-between;gap:8px">
<span><i class="fa-solid fa-id-card"></i> Profile</span>
ddiff --git a/public/js/app-channel.js b/public/js/app-channel.js
iindex ad3e8e66..e45b2bc0 100644
---- a/public/js/app-channel.js
++++ b/public/js/app-channel.js
@@@ -679,6 +679,55 @@ function _channelReady(ready = true) {
clearTimeout(_channelReadyTimer);
}
++/* The channel's next scheduled stream, from OpenVibe.Today (/api/today/channel/:username): "Next stream · Fri, Oct 30,
++ 8:00 PM · Robot build, part 2 · Remind me". DOM nodes only; hidden when nothing is scheduled or Today is unreachable. */
++async function _loadNextStream(username) {
++ const box = document.getElementById('ch-next-offline');
++ if (!box) return;
++ box.hidden = true;
++ box.replaceChildren();
++ let events = [];
++ try {
++ const r = await fetch(`/api/today/channel/${encodeURIComponent(username)}`);
++ if (r.ok) events = (await r.json()).events || [];
++ } catch { /* nothing to show */ }
++ if (username !== currentChannelUsername) return;
++ const next = events.find((e) => e.status === 'scheduled' || e.status === 'live');
++ if (!next) return;
++ const when = new Date(next.starts_at);
++ let label = '';
++ try { label = new Intl.DateTimeFormat(undefined, { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }).format(when); } catch { label = when.toLocaleString(); }
++ const icon = document.createElement('i');
++ icon.className = 'fa-regular fa-calendar';
++ icon.setAttribute('aria-hidden', 'true');
++ const kicker = document.createElement('span');
++ kicker.className = 'ch-next-k';
++ kicker.textContent = next.status === 'live' ? 'On now' : 'Next stream';
++ const time = document.createElement('time');
++ time.dateTime = next.starts_at;
++ time.textContent = label;
++ const title = document.createElement('span');
++ title.className = 'ch-next-t';
++ title.textContent = next.title;
++ const remind = document.createElement('a');
++ remind.className = 'ch-next-a';
++ remind.href = next.url;
++ remind.target = '_blank';
++ remind.rel = 'noopener';
++ remind.textContent = 'Remind me';
++ box.append(icon, kicker, time, title, remind);
++ if (events.length > 1) {
++ const more = document.createElement('a');
++ more.className = 'ch-next-more';
++ more.href = `https://openvibe.today/@${encodeURIComponent(username)}`;
++ more.target = '_blank';
++ more.rel = 'noopener';
++ more.textContent = `+${events.length - 1} more`;
++ box.append(more);
++ }
++ box.hidden = false;
++}
++
async function loadChannelPage(username, managedStreamRef = null, legacySessionId = null) {
_restoreChannelChat();
_watchTabsClip();
@@@ -888,6 +937,7 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
document.getElementById('ch-username-offline').textContent = '@' + ch.username;
document.getElementById('ch-description-offline').textContent = ch.description || '';
if (window.ChannelSocials) ChannelSocials.pills(document.getElementById('ch-socials-offline'), ch.social_links || []);
++ _loadNextStream(ch.username);
document.getElementById('ch-follower-count-offline').textContent = `${ch.follower_count || 0} followers`;
_loadGameBadge(ch.username);
_setCategoryBadge(document.getElementById('ch-category-badge-offline'), ch.ai_category || ch.category || 'Offline', !!ch.ai_category);
ddiff --git a/public/js/dashboard.js b/public/js/dashboard.js
iindex b76a0eeb..eb660938 100644
---- a/public/js/dashboard.js
++++ b/public/js/dashboard.js
@@@ -11,7 +11,7 @@ let activeStreams = [];
(so the dashboard paints instantly and only does the work you look at). Other
modules (e.g. moderation) register their loader on window._dashTabLoaders. */
const _DASH_TAB_CARDS = {
-- home: ['dash-card-profile', 'dash-card-ai-derivation', 'dash-card-offline'],
++ home: ['dash-card-profile', 'dash-card-schedule', 'dash-card-ai-derivation', 'dash-card-offline'],
chatai: ['dash-card-vibe', 'dash-aibot-card', 'dash-card-overlay', 'dash-card-chatlogs'],
moderation: [], // injected by dashboard-moderation.js into #dash-grid-moderation
controls: ['dash-card-bot-embed', 'dash-card-tokens'],
@@@ -138,12 +138,46 @@ window._dashTabLoaders.content = () => { switchDashContentTab('videos', document
/**
* Load the dashboard page — build the tab layout, then load only the default (Home) tab.
*/
++/* The Schedule card: this channel's next streams from OpenVibe.Today (/api/today/channel/:username). DOM nodes only. */
++async function loadDashSchedule() {
++ const list = document.getElementById('dash-schedule-list');
++ if (!list || !currentUser || !currentUser.username) return;
++ const cal = document.getElementById('dash-schedule-cal');
++ if (cal) cal.href = `https://openvibe.today/@${encodeURIComponent(currentUser.username)}`;
++ let events = [];
++ try {
++ const r = await fetch(`/api/today/channel/${encodeURIComponent(currentUser.username)}`);
++ if (r.ok) events = (await r.json()).events || [];
++ } catch { /* Today unreachable: the empty line below */ }
++ list.replaceChildren();
++ if (!events.length) {
++ const li = document.createElement('li');
++ li.className = 'muted';
++ li.textContent = 'Nothing scheduled yet.';
++ list.append(li);
++ return;
++ }
++ for (const e of events) {
++ const li = document.createElement('li');
++ const a = document.createElement('a');
++ a.href = e.url; a.target = '_blank'; a.rel = 'noopener';
++ a.textContent = e.title;
++ const time = document.createElement('time');
++ time.dateTime = e.starts_at;
++ try { time.textContent = new Intl.DateTimeFormat(undefined, { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }).format(new Date(e.starts_at)); } catch { time.textContent = e.starts_at; }
++ li.append(time, a);
++ if (e.status === 'live') { const b = document.createElement('span'); b.className = 'badge'; b.textContent = 'Live'; li.append(b); }
++ list.append(li);
++ }
++}
++
async function loadDashboard() {
if (!currentUser) {
toast('Login required', 'error');
return navigate('/');
}
loadDashChannel();
++ loadDashSchedule();
_dashRelocateCards();
_dashLoadedTabs.clear();
const fromUrl = (location.pathname.match(/^\/dashboard\/([a-z]+)/) || [])[1];
ddiff --git a/server/index.js b/server/index.js
iindex 46cf0646..6ee6e619 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -789,6 +789,7 @@ app.use('/api/clips', searchMedia.afterChange('clip'), clipRoutes);
app.use('/api/chat-ai', require('./ai/chat-ai-routes'));
app.use('/api/easter-egg', require('./ai/easter-egg-routes'));
app.use('/api/rundown', require('./rundown/routes')); // what streamers talked about, written up (docs/rundown.md)
++app.use('/api/today', require('./today/routes').createTodayRoutes()); // a channel's schedule, read from OpenVibe.Today
app.use('/api/recap', require('./recap/routes')); // after-show reports (docs/recap.md)
app.use('/api/social', require('./social/routes').router); // channel social links and their previews (server/social/)
app.get('/embed/x-timeline', require('./social/routes').xTimelinePage);
ddiff --git a/server/today/routes.js b/server/today/routes.js
nnew file mode 100644
iindex 00000000..77764516
---- /dev/null
++++ b/server/today/routes.js
@@@ -0,0 +1,76 @@
++'use strict';
++/**
++ * A channel's schedule from OpenVibe.Today (plan 2026-10-28 §4.2: streamers schedule in Today; Live shows it on the
++ * channel). Live keeps no copy: it reads Today's public API and keeps each answer a minute.
++ *
++ * GET /api/today/channel/:username → { events: [{ id, title, starts_at, ends_at, tz, status, kind, url }] }
++ * the next three public events, soonest first; [] when Today is unreachable
++ *
++ * OV_TODAY_INTERNAL_URL (default http://127.0.0.1:5100) is where Live asks; links point at the public
++ * https://openvibe.today. Nothing runs at boot: every read happens on a request (drill-safe).
++ */
++const express = require('express');
++
++const TODAY_PUBLIC = 'https://openvibe.today';
++const CACHE_MS = 60_000;
++const FAIL_CACHE_MS = 15_000;
++const CACHE_MAX = 500;
++const USERNAME = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}$/;
++const EVENT_ID = /^tev_[0-9A-Za-z]{10,40}$/;
++
++/** Only what a channel page shows; never Today's owner or internal fields. */
++function publicEvent(e) {
++ if (!e || !EVENT_ID.test(String(e.id || ''))) return null;
++ const starts = Date.parse(e.starts_at || '');
++ if (!Number.isFinite(starts)) return null;
++ return {
++ id: e.id,
++ title: String(e.title || '').slice(0, 120),
++ starts_at: new Date(starts).toISOString(),
++ ends_at: e.ends_at && Number.isFinite(Date.parse(e.ends_at)) ? new Date(e.ends_at).toISOString() : null,
++ tz: String(e.tz || 'UTC').slice(0, 64),
++ status: ['scheduled', 'live', 'done', 'cancelled'].includes(e.status) ? e.status : 'scheduled',
++ kind: e.kind === 'event' ? 'event' : 'stream',
++ url: `${TODAY_PUBLIC}/e/${encodeURIComponent(e.id)}`,
++ };
++}
++
++function createTodaySchedule({ todayUrl = process.env.OV_TODAY_INTERNAL_URL || 'http://127.0.0.1:5100', fetchImpl = (...a) => fetch(...a), now = () => Date.now(), timeoutMs = 2000 } = {}) {
++ const cache = new Map();
++ async function forChannel(username) {
++ const key = String(username).toLowerCase();
++ const hit = cache.get(key);
++ if (hit && now() < hit.until) return hit.events;
++ let events = [];
++ let ok = false;
++ try {
++ const from = new Date(now() - 2 * 3600e3).toISOString();
++ const res = await fetchImpl(`${String(todayUrl).replace(/\/+$/, '')}/api/v1/events?channel=${encodeURIComponent(username)}&from=${encodeURIComponent(from)}&limit=6`, {
++ headers: { accept: 'application/json', 'user-agent': 'OpenVibe.Live (+https://openvibe.live)' }, signal: AbortSignal.timeout(timeoutMs),
++ });
++ if (res.ok) {
++ const body = await res.json();
++ events = (Array.isArray(body && body.events) ? body.events : []).map(publicEvent).filter((e) => e && e.status !== 'cancelled' && e.status !== 'done').slice(0, 3);
++ ok = true;
++ }
++ } catch { /* Today unreachable: an empty schedule, asked again soon */ }
++ cache.set(key, { events, until: now() + (ok ? CACHE_MS : FAIL_CACHE_MS) });
++ if (cache.size > CACHE_MAX) cache.delete(cache.keys().next().value);
++ return events;
++ }
++ return { forChannel, _cache: cache };
++}
++
++function createTodayRoutes(schedule = createTodaySchedule()) {
++ const router = express.Router();
++ router.get('/channel/:username', async (req, res) => {
++ const username = String(req.params.username || '');
++ if (!USERNAME.test(username)) return res.status(404).json({ error: 'Channel not found' });
++ const events = await schedule.forChannel(username);
++ res.set('Cache-Control', 'public, max-age=60');
++ res.json({ events });
++ });
++ return router;
++}
++
++module.exports = { createTodayRoutes, createTodaySchedule, publicEvent };
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex f8635b61..5cca3bfe 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -344,6 +344,7 @@ async function check(name, fn) {
'server/lineage/media-source.js': 'OpenVibe.Media (configured)',
'server/pastes-client.js': 'OpenVibe.Community (configured)',
'server/comments-client.js': 'OpenVibe.Community (configured)',
++ 'server/today/routes.js': 'OpenVibe.Today public events API (OV_TODAY_INTERNAL_URL, configured; the channel is a checked username)',
'server/chat/moderation-client.js': 'OpenVibe.Chat internal read API (configured)',
'server/social/preview.js': 'OpenVibe.Network public profile API (configured OV_NETWORK_INTERNAL_URL, fixed path, username checked); every other preview goes through egress',
'server/monetization/inventory-client.js': 'OpenVibe.Inventory API for Live cosmetics (configured INVENTORY_URL)',
ddiff --git a/test/today-schedule.test.js b/test/today-schedule.test.js
nnew file mode 100644
iindex 00000000..35c5e212
---- /dev/null
++++ b/test/today-schedule.test.js
@@@ -0,0 +1,61 @@
++'use strict';
++// A channel's schedule from OpenVibe.Today (server/today/routes.js): only public fields and Today's own page as the link,
++// cancelled and ended events left out, at most three, a minute's cache, and an empty answer (cached briefly) when Today
++// is unreachable; a bad username is 404.
++const assert = require('assert');
++const http = require('http');
++const express = require('express');
++const { createTodayRoutes, createTodaySchedule, publicEvent } = require('../server/today/routes');
++
++(async () => {
++ let clock = Date.parse('2026-10-28T15:00:00Z');
++ const asked = [];
++ let down = false;
++ const fetchImpl = async (url) => {
++ asked.push(String(url));
++ if (down) throw new Error('connect ECONNREFUSED');
++ const ev = (id, o) => ({ id, owner_subject: 'usr_x', title: `Stream ${id}`, starts_at: '2026-10-30T03:00:00Z', ends_at: null, tz: 'America/Los_Angeles', status: 'scheduled', kind: 'stream', seq: 2, ...o });
++ return new Response(JSON.stringify({ events: [
++ ev('tev_AAAAAAAAAAAAAAAAAAA1'), ev('tev_AAAAAAAAAAAAAAAAAAA2', { status: 'cancelled' }), ev('tev_AAAAAAAAAAAAAAAAAAA3', { status: 'live', title: '<b>Live</b> now' }),
++ ev('tev_AAAAAAAAAAAAAAAAAAA4'), ev('tev_AAAAAAAAAAAAAAAAAAA5'), ev('bad id'),
++ ] }), { status: 200, headers: { 'content-type': 'application/json' } });
++ };
++ const schedule = createTodaySchedule({ todayUrl: 'http://today.test', fetchImpl, now: () => clock });
++ const app = express();
++ app.use('/api/today', createTodayRoutes(schedule));
++ const server = http.createServer(app);
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ const base = `http://127.0.0.1:${server.address().port}/api/today`;
++ const get = async (p) => { const r = await fetch(base + p); return { status: r.status, cache: r.headers.get('cache-control'), body: await r.json() }; };
++
++ try {
++ let r = await get('/channel/carol');
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.cache, 'public, max-age=60');
++ assert.deepStrictEqual(r.body.events.map((e) => e.id), ['tev_AAAAAAAAAAAAAAAAAAA1', 'tev_AAAAAAAAAAAAAAAAAAA3', 'tev_AAAAAAAAAAAAAAAAAAA4'], 'cancelled and malformed left out, three at most');
++ assert.deepStrictEqual(Object.keys(r.body.events[0]).sort(), ['ends_at', 'id', 'kind', 'starts_at', 'status', 'title', 'tz', 'url'], 'public fields only (no owner, no seq)');
++ assert.strictEqual(r.body.events[0].url, 'https://openvibe.today/e/tev_AAAAAAAAAAAAAAAAAAA1', 'the link is Today\'s public page');
++ assert.ok(/^http:\/\/today\.test\/api\/v1\/events\?channel=carol&from=/.test(asked[0]), asked[0]);
++
++ await get('/channel/carol');
++ assert.strictEqual(asked.length, 1, 'a minute\'s cache');
++ clock += 61_000;
++ down = true;
++ r = await get('/channel/carol');
++ assert.deepStrictEqual([r.status, r.body.events], [200, []], 'Today unreachable: an empty schedule, never an error');
++ assert.strictEqual(asked.length, 2);
++ down = false;
++ clock += 5_000;
++ await get('/channel/carol');
++ assert.strictEqual(asked.length, 2, 'a failure is cached briefly too');
++ clock += 11_000;
++ r = await get('/channel/carol');
++ assert.strictEqual(r.body.events.length, 3, 'asked again after the short failure cache');
++
++ assert.strictEqual((await get('/channel/..%2Fx')).status, 404);
++ assert.strictEqual(publicEvent({ id: 'tev_AAAAAAAAAAAAAAAAAAA1', starts_at: 'not a date' }), null);
++ } finally {
++ server.close();
++ }
++ console.log('today schedule: ok');
++})().catch((e) => { console.error(e); process.exit(1); });