openvibers/openvibe.live Commits
Schedules from OpenVibe.Today on the channel and the dashboard (#429)
Changes
@@@ -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 KeyYour 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.@@@ -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); }@@@ -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; }@@@ -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>@@@ -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’ 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>@@@ -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, legacySessionIdocument.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);@@@ -11,7 +11,7 @@ let activeStreams = [];(so the dashboard paints instantly and only does the work you look at). Othermodules (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-moderationcontrols: ['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];@@@ -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.Todayapp.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);nnew file mode 100644@@@ -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 };@@@ -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)',nnew file mode 100644@@@ -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); });
@@ -2,6 +2,14 @@
@@ -2,6 +2,14 @@
The dashboard is your control center for managing your channel. Access it by logging in and clicking your username.
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
## 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.
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.
@@ -1606,3 +1606,13 @@ button.viewer-badge:focus-visible { outline: 2px solid #93c5fd; outline-offset:
@@ -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; }
#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; }
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); }
@@ -507,3 +507,11 @@
@@ -507,3 +507,11 @@
.vibe-word .form-input { max-width: 280px; }
.vibe-word .form-input { max-width: 280px; }
.vibe-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.vibe-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
#dash-card-vibe [data-vibe-word] { color: var(--accent-light, var(--accent)); }
#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; }
@@ -238,6 +238,7 @@
@@ -238,6 +238,7 @@
<p class="ch-username" id="ch-username-offline">username</p>
<p class="ch-username" id="ch-username-offline">username</p>
<p class="ch-description" id="ch-description-offline"></p>
<p class="ch-description" id="ch-description-offline"></p>
<div class="ch-social-pills" id="ch-socials-offline" hidden></div>
<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">
<div class="ch-meta">
<span class="ch-follower-count" id="ch-follower-count-offline">0 followers</span>
<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>
<a class="ch-game-badge" id="ch-game-badge-offline" href="https://openvibe.games/" target="_blank" rel="noopener" hidden></a>
@@ -365,6 +365,17 @@
@@ -365,6 +365,17 @@
</div>
</div>
<!-- Profile / Account (migrated from /settings) -->
<!-- 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’ 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">
<div class="dash-card dash-card-wide" id="dash-card-profile">
<h3 style="display:flex;align-items:center;justify-content:space-between;gap:8px">
<h3 style="display:flex;align-items:center;justify-content:space-between;gap:8px">
<span><i class="fa-solid fa-id-card"></i> Profile</span>
<span><i class="fa-solid fa-id-card"></i> Profile</span>
@@ -679,6 +679,55 @@ function _channelReady(ready = true) {
@@ -679,6 +679,55 @@ function _channelReady(ready = true) {
clearTimeout(_channelReadyTimer);
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) {
async function loadChannelPage(username, managedStreamRef = null, legacySessionId = null) {
_restoreChannelChat();
_restoreChannelChat();
_watchTabsClip();
_watchTabsClip();
@@ -888,6 +937,7 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
@@ -888,6 +937,7 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
document.getElementById('ch-username-offline').textContent = '@' + ch.username;
document.getElementById('ch-username-offline').textContent = '@' + ch.username;
document.getElementById('ch-description-offline').textContent = ch.description || '';
document.getElementById('ch-description-offline').textContent = ch.description || '';
if (window.ChannelSocials) ChannelSocials.pills(document.getElementById('ch-socials-offline'), ch.social_links || []);
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`;
document.getElementById('ch-follower-count-offline').textContent = `${ch.follower_count || 0} followers`;
_loadGameBadge(ch.username);
_loadGameBadge(ch.username);
_setCategoryBadge(document.getElementById('ch-category-badge-offline'), ch.ai_category || ch.category || 'Offline', !!ch.ai_category);
_setCategoryBadge(document.getElementById('ch-category-badge-offline'), ch.ai_category || ch.category || 'Offline', !!ch.ai_category);
@@ -11,7 +11,7 @@ let activeStreams = [];
@@ -11,7 +11,7 @@ let activeStreams = [];
(so the dashboard paints instantly and only does the work you look at). Other
(so the dashboard paints instantly and only does the work you look at). Other
modules (e.g. moderation) register their loader on window._dashTabLoaders. */
modules (e.g. moderation) register their loader on window._dashTabLoaders. */
const _DASH_TAB_CARDS = {
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'],
chatai: ['dash-card-vibe', 'dash-aibot-card', 'dash-card-overlay', 'dash-card-chatlogs'],
moderation: [], // injected by dashboard-moderation.js into #dash-grid-moderation
moderation: [], // injected by dashboard-moderation.js into #dash-grid-moderation
controls: ['dash-card-bot-embed', 'dash-card-tokens'],
controls: ['dash-card-bot-embed', 'dash-card-tokens'],
@@ -138,12 +138,46 @@ window._dashTabLoaders.content = () => { switchDashContentTab('videos', document
@@ -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.
* 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() {
async function loadDashboard() {
if (!currentUser) {
if (!currentUser) {
toast('Login required', 'error');
toast('Login required', 'error');
return navigate('/');
return navigate('/');
}
}
loadDashChannel();
loadDashChannel();
+ loadDashSchedule();
_dashRelocateCards();
_dashRelocateCards();
_dashLoadedTabs.clear();
_dashLoadedTabs.clear();
const fromUrl = (location.pathname.match(/^\/dashboard\/([a-z]+)/) || [])[1];
const fromUrl = (location.pathname.match(/^\/dashboard\/([a-z]+)/) || [])[1];
@@ -789,6 +789,7 @@ app.use('/api/clips', searchMedia.afterChange('clip'), clipRoutes);
@@ -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/chat-ai', require('./ai/chat-ai-routes'));
app.use('/api/easter-egg', require('./ai/easter-egg-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/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/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.use('/api/social', require('./social/routes').router); // channel social links and their previews (server/social/)
app.get('/embed/x-timeline', require('./social/routes').xTimelinePage);
app.get('/embed/x-timeline', require('./social/routes').xTimelinePage);
new file mode 100644
new file mode 100644
@@ -0,0 +1,76 @@
@@ -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 };
@@ -344,6 +344,7 @@ async function check(name, fn) {
@@ -344,6 +344,7 @@ async function check(name, fn) {
'server/lineage/media-source.js': 'OpenVibe.Media (configured)',
'server/lineage/media-source.js': 'OpenVibe.Media (configured)',
'server/pastes-client.js': 'OpenVibe.Community (configured)',
'server/pastes-client.js': 'OpenVibe.Community (configured)',
'server/comments-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/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/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)',
'server/monetization/inventory-client.js': 'OpenVibe.Inventory API for Live cosmetics (configured INVENTORY_URL)',
new file mode 100644
new file mode 100644
@@ -0,0 +1,61 @@
@@ -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); });