openvibers/openvibe.mediahub Commits

MediaHub: OpenVibe.Video watch pages show the stream's chat replay and its comments (#29)

OP OpenVibers committed 12 Oct 2026 · 2c10e3e4e7 · 9 files, +343 −14

OpenVibe.Video step B: the VOD watch page gains the stream's chat and its comments.

- **Chat replay** (side column, under Chapters): the stream's chat from OpenVibe.Chat's public replay (`MEDIAHUB_CHAT_URL`; production uses Chat's loopback address).
  - Up to 1,000 messages, oldest first, each with its offset into the stream as a seek button. Messages before the start, after the end plus 60 s, deleted or filtered ones, and non-chat types are dropped.
  - Only the name and the text are shown; no ids, roles, colours or metadata reach the page.
  - Above 1,000, a note says how many were left out.
  - As the video plays, the script marks past and upcoming rows and keeps the newest in view inside the panel's own scroll box, pausing for 5 s while someone scrolls it.
  - Chat being down never breaks the page.
- **Comments** (main column, under the transcript): the newest 20 from the OpenVibe.Community thread (author, relative date, text, reply count), then "Join the discussion on OpenVibe.Community". It reuses the comments call the page already made for the thread link.
- Styles follow the watch page's design (`ovv-chat-*`, `ovv-comment-*`); no horizontal scroll at 390 px. Checked against production data: VOD 5721 shows its 5 chat messages.

Tests: the full suite, 24 files. `video-pages` gains a stub chat replay with deleted, filtered, out-of-window, markup-carrying and private-field messages, a 1,005-message stream for the cap, comments, and Chat answering 503.

Changes

ddiff --git a/.env.example b/.env.example
iindex 7106442..8d72212 100644
---- a/.env.example
++++ b/.env.example
@@@ -57,9 +57,10 @@ MEDIAHUB_MEDIA_APP=media-hub
# COOKIE_SECURE=true
# ── OpenVibe.Video: public watch pages for OpenVibe.Live's recordings and clips ──
--# Served by Host header on openvibe.video; Live is called server-side, signed out. liveUrl is the
--# host-internal address (production sets it to the loopback address); links people click use
--# livePublicUrl.
++# Served by Host header on openvibe.video; Live is called server-side, signed out. liveUrl and chatUrl
++# are the host-internal addresses (production sets them to the loopback address); links people click
++# use livePublicUrl.
MEDIAHUB_VIDEO_URL=https://openvibe.video
MEDIAHUB_LIVE_URL=https://openvibe.live
MEDIAHUB_LIVE_PUBLIC_URL=https://openvibe.live
++MEDIAHUB_CHAT_URL=https://openvibe.live
ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 2edc630..9bdebbd 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -10,6 +10,9 @@ What changed in OpenVibe.MediaHub, newest first. Each site also publishes its pa
- OpenVibe.Video step A: openvibe.video now serves public, read-only watch pages for OpenVibe.Live's stream
recordings and clips (`/v/:id`, `/c/:id`, `/@:username`, WebVTT captions and chapters), reading Live's public
JSON API server-side, signed out. No uploads and no new storage.
++- OpenVibe.Video step B: the VOD watch page now shows a chat replay of the stream (read from OpenVibe.Chat,
++ oldest first, the first 1,000 messages) and read-only comments with the OpenVibe.Community thread link, plus the
++ `MEDIAHUB_CHAT_URL` setting.
- openvibe.pics is still parked: its vhost answers 302 to https://openvibe.network/ and proxies nothing. The app
no longer has "coming" pages or host-based brand handling.
ddiff --git a/README.md b/README.md
iindex f91f831..201a86d 100644
---- a/README.md
++++ b/README.md
@@@ -115,10 +115,13 @@ public on OpenVibe.Live. There are no uploads and no new storage — the media f
(`playback_url`), and the pages read Live's public JSON API server-side, signed out (Live already applies
visibility: a private item answers 404, an unlisted one is reachable by id but noindex and never listed).
++The VOD page also shows a chat replay of the stream (from OpenVibe.Chat) and read-only comments with a link to
++the OpenVibe.Community thread.
++
| Path | What |
|---|---|
| `/` | what OpenVibe.Video is, then the latest recordings and clips |
--| `/v/:id` | a VOD watch page: player, AI chapters and captions, its clips, the Community thread link |
++| `/v/:id` | a VOD watch page: player, AI chapters and captions, chat replay, comments, its clips |
| `/c/:id` | a clip watch page: player, attribution, the full-recording link, the Community thread link |
| `/@:username` | a channel's recordings (and clips), newest first, 24 per page |
| `/v/:id/captions.vtt` | the AI transcript as WebVTT captions |
@@@ -126,13 +129,14 @@ visibility: a private item answers 404, an unlisted one is reachable by id but n
| `/sitemap.xml`, `/robots.txt`, `/llms.txt` | crawl artifacts for the video host |
Configuration: `MEDIAHUB_VIDEO_URL` (default `https://openvibe.video`; its hostname is the video host),
--`MEDIAHUB_LIVE_URL` (default `https://openvibe.live`; production points it at the loopback address) and
--`MEDIAHUB_LIVE_PUBLIC_URL` (default `https://openvibe.live`; links people click). The video host is chosen by
--`Host` header in `server/app.js`, so the drive's pages, API and `/auth` never answer there. Only https URLs from
--Live (player source, thumbnails, the thread link) reach a page; anything else is dropped.
--
--Not built yet: chat replay, comments on the page (only the OpenVibe.Community thread link is offered), playlists,
--and uploads.
++`MEDIAHUB_LIVE_URL` (default `https://openvibe.live`; production points it at the loopback address),
++`MEDIAHUB_LIVE_PUBLIC_URL` (default `https://openvibe.live`; links people click) and `MEDIAHUB_CHAT_URL`
++(default `https://openvibe.live`, which proxies `/api/chat/` to OpenVibe.Chat; production points it at the loopback
++address). The video host is chosen by `Host` header in `server/app.js`, so the drive's pages, API and `/auth` never
++answer there. Only https URLs from Live (player source, thumbnails, the thread link) reach a page; anything else is
++dropped.
++
++Not built yet: playlists and uploads.
## Pages
ddiff --git a/public/css/video.css b/public/css/video.css
iindex 7a1060b..4e94d49 100644
---- a/public/css/video.css
++++ b/public/css/video.css
@@@ -95,6 +95,25 @@ a.ovv-channel-name:hover { text-decoration: underline; }
.ovv-channel-head .ovv-meta { margin: 0; }
.ovv-pagination { display: flex; gap: 10px; margin-top: 1.2rem; }
++/* Chat replay */
++.ovv-chat-body { max-height: 26rem; overflow-y: auto; display: grid; gap: 2px; }
++.ovv-chat-row { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 10px; align-items: baseline; padding: 4px 0; line-height: 1.5; font-size: .9rem; }
++.ovv-chat-row.is-ahead { opacity: .42; }
++.ovv-chat-row.is-past { opacity: 1; }
++.ovv-chat-name { font-weight: 700; margin-right: 6px; color: var(--text-primary, #e6edf7); }
++.ovv-chat-msg { overflow-wrap: anywhere; }
++
++/* Comments (read only) */
++.ovv-comments .ovv-comment { padding: 10px 0; }
++.ovv-comments .ovv-comment + .ovv-comment { border-top: 1px solid var(--ovv-line); }
++.ovv-comment-header { display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; }
++.ovv-comment-author { font-weight: 700; overflow-wrap: anywhere; }
++.ovv-comment-date { font-size: .8rem; color: var(--ovv-faint); }
++.ovv-comment-text { margin: .3rem 0 0; line-height: 1.6; overflow-wrap: anywhere; }
++.ovv-comment-replies { margin: .3rem 0 0; font-size: .8rem; color: var(--ovv-faint); }
++.ovv-comments-empty { color: var(--ovv-dim); }
++.ovv-comments-link { margin: .9rem 0 0; }
++
@media (max-width: 1000px) {
.ovv-watch { grid-template-columns: minmax(0, 1fr); }
.ovv-chapters ol { max-height: none; }
ddiff --git a/public/js/video.js b/public/js/video.js
iindex fbb75c2..7797481 100644
---- a/public/js/video.js
++++ b/public/js/video.js
@@@ -60,5 +60,39 @@
current = at;
if (current) { current.classList.add('is-current'); current.setAttribute('aria-current', 'true'); }
});
++ // Chat replay: mark rows up to the current time as past and dim the ones still ahead, keeping
++ // the newest past row in view inside the panel's own scroll box (never the page).
++ const chatBody = document.querySelector('[data-ovv-chat]');
++ if (chatBody) {
++ const chatRows = Array.prototype.slice.call(chatBody.querySelectorAll('[data-ovv-at]'));
++ let userScrollUntil = 0;
++ let lastAutoScroll = 0;
++ chatBody.addEventListener('scroll', function () {
++ if (Date.now() - lastAutoScroll > 250) userScrollUntil = Date.now() + 5000;
++ });
++ video.addEventListener('timeupdate', function () {
++ const t = video.currentTime;
++ let newest = null;
++ for (let i = 0; i < chatRows.length; i++) {
++ const row = chatRows[i];
++ if (Number(row.getAttribute('data-ovv-at')) <= t + 0.25) {
++ row.classList.add('is-past');
++ row.classList.remove('is-ahead');
++ newest = row;
++ } else {
++ row.classList.remove('is-past');
++ row.classList.add('is-ahead');
++ }
++ }
++ if (!newest || Date.now() < userScrollUntil) return;
++ lastAutoScroll = Date.now();
++ const pr = chatBody.getBoundingClientRect();
++ const rr = newest.getBoundingClientRect();
++ const top = rr.top - pr.top + chatBody.scrollTop;
++ const bottom = top + rr.height;
++ if (top < chatBody.scrollTop) chatBody.scrollTop = top;
++ else if (bottom > chatBody.scrollTop + chatBody.clientHeight) chatBody.scrollTop = bottom - chatBody.clientHeight;
++ });
++ }
});
})();
ddiff --git a/server/config.js b/server/config.js
iindex 134320a..bc8d1cc 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -56,11 +56,13 @@ function load(env = process.env) {
// OpenVibe.Video: public, read-only watch pages for OpenVibe.Live's stream recordings and clips. The
// app serves openvibe.video by Host header and reads Live's public JSON API server-side, signed out.
// `url` is the public origin (its hostname is the video host); `liveUrl` is where the app calls Live
-- // (production sets it to the loopback address) and `livePublicUrl` is what links people click.
++ // (production sets it to the loopback address), `livePublicUrl` is what links people click, and
++ // `chatUrl` is where it reads a stream's chat replay (production sets it to the loopback address).
video: {
url: trim(env.MEDIAHUB_VIDEO_URL || 'https://openvibe.video'),
liveUrl: trim(env.MEDIAHUB_LIVE_URL || 'https://openvibe.live'),
livePublicUrl: trim(env.MEDIAHUB_LIVE_PUBLIC_URL || 'https://openvibe.live'),
++ chatUrl: trim(env.MEDIAHUB_CHAT_URL || 'https://openvibe.live'),
},
// What one person may keep and do here. Media's own quotas are per tenant (this whole service is one
ddiff --git a/server/video/chat-client.js b/server/video/chat-client.js
nnew file mode 100644
iindex 0000000..52af013
---- /dev/null
++++ b/server/video/chat-client.js
@@@ -0,0 +1,73 @@
++'use strict';
++
++/**
++ * OpenVibe.Video's read-only client for OpenVibe.Chat's public chat replay. Like live-client.js it
++ * is signed out, never sends cookies or credentials, only accepts JSON, and caches answers in memory
++ * for a minute. Unlike live-client it never throws to the pages: Chat is optional, so anything wrong
++ * — a timeout, a non-2xx, a body that is not JSON, an invalid stream id — becomes null (no chat panel)
++ * and the page still renders.
++ */
++const { isId } = require('./live-client');
++
++const TIMEOUT_MS = 5000;
++const CACHE_TTL_MS = 60_000;
++const CACHE_MAX = 500;
++
++function createChatClient({ config, fetchImpl = globalThis.fetch, log = console } = {}) {
++ const cache = new Map(); // url → { at, value }
++ const base = String(config.video.chatUrl).replace(/\/+$/, '');
++
++ function hit(key) {
++ const entry = cache.get(key);
++ if (!entry) return undefined;
++ if (Date.now() - entry.at < CACHE_TTL_MS) {
++ cache.delete(key);
++ cache.set(key, entry); // touch: this entry is now the most recent
++ return entry.value;
++ }
++ cache.delete(key);
++ return undefined;
++ }
++
++ function remember(key, value) {
++ cache.delete(key);
++ cache.set(key, { at: Date.now(), value });
++ if (cache.size > CACHE_MAX) cache.delete(cache.keys().next().value);
++ return value;
++ }
++
++ /** The chat history for a stream, or null when it cannot be read (never throws). */
++ async function chatReplay(streamId) {
++ if (!isId(streamId)) return null;
++
++ const url = `${base}/api/chat/${streamId}/replay`;
++ const cached = hit(url);
++ if (cached !== undefined) return cached;
++
++ let res;
++ try {
++ res = await fetchImpl(url, {
++ method: 'GET',
++ headers: { Accept: 'application/json' },
++ credentials: 'omit',
++ cache: 'no-store',
++ signal: AbortSignal.timeout(TIMEOUT_MS),
++ });
++ } catch (err) {
++ log.warn('[OpenVibe.Video] Chat did not answer', err && err.name);
++ return null;
++ }
++
++ if (!res.ok) return null;
++
++ try {
++ return remember(url, await res.json());
++ } catch {
++ return null;
++ }
++ }
++
++ return { chatReplay };
++}
++
++module.exports = { createChatClient };
ddiff --git a/server/video/pages.js b/server/video/pages.js
iindex b22ae00..a891da4 100644
---- a/server/video/pages.js
++++ b/server/video/pages.js
@@@ -25,6 +25,7 @@ const { asyncRouter } = require('../http/router');
const { html } = require('../render/html');
const { send, assetVersion, SITE_NAME } = require('./layout');
const { createLiveClient } = require('./live-client');
++const { createChatClient } = require('./chat-client');
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
const HOME_LIMIT = 10;
@@@ -56,6 +57,77 @@ function dateLabel(iso) {
if (Number.isNaN(d.getTime())) return String(iso);
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
}
++/** A UTC timestamp: chat uses 'YYYY-MM-DD HH:MM:SS'; Live's stream times may be ISO. Returns seconds or NaN. */
++function utcSeconds(v) {
++ if (v == null) return NaN;
++ const s = String(v).trim();
++ if (!s) return NaN;
++ const iso = /[TtZz]/.test(s) ? s : `${s.replace(' ', 'T')}Z`;
++ const t = Date.parse(iso);
++ return Number.isFinite(t) ? t / 1000 : NaN;
++}
++
++/** An offset into the stream as H:MM:SS (no leading zero on the hour). */
++function streamTime(s) {
++ const n = Math.max(0, Math.floor(Number(s) || 0));
++ const h = Math.floor(n / 3600);
++ const m = Math.floor((n % 3600) / 60);
++ const sec = n % 60;
++ return `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
++}
++
++/** A short "2 hours ago" for a comment's created time. */
++function relativeDate(iso) {
++ if (!iso) return '';
++ const t = Date.parse(iso);
++ if (Number.isNaN(t)) return '';
++ const s = Math.floor((Date.now() - t) / 1000);
++ if (s < 60) return 'just now';
++ const m = Math.floor(s / 60);
++ if (m < 60) return `${m} min ago`;
++ const h = Math.floor(m / 60);
++ if (h < 24) return `${h} hour${h === 1 ? '' : 's'} ago`;
++ const d = Math.floor(h / 24);
++ if (d < 30) return `${d} day${d === 1 ? '' : 's'} ago`;
++ const mo = Math.floor(d / 30);
++ if (mo < 12) return `${mo} month${mo === 1 ? '' : 's'} ago`;
++ const y = Math.floor(mo / 12);
++ return `${y} year${y === 1 ? '' : 's'} ago`;
++}
++
++/** The replay messages that belong in the chat panel: oldest first, at most 1,000, null when none. */
++function chatMessageRows(vod, messages) {
++ const start = utcSeconds(vod && vod.stream_started_at);
++ if (!Number.isFinite(start) || !Array.isArray(messages)) return null;
++ const end = utcSeconds(vod.stream_ended_at);
++ const rows = [];
++ for (const m of messages) {
++ if (!m || typeof m !== 'object') continue;
++ if (m.is_deleted || m.is_filtered) continue;
++ if (m.message_type !== 'chat') continue;
++ const at = utcSeconds(m.timestamp);
++ if (!Number.isFinite(at)) continue;
++ const offset = at - start;
++ if (offset < 0) continue;
++ if (Number.isFinite(end) && offset > end - start + 60) continue;
++ rows.push({ offset: Math.floor(offset), name: m.display_name || m.username || '', text: m.message == null ? '' : String(m.message) });
++ }
++ rows.sort((a, b) => a.offset - b.offset);
++ if (!rows.length) return null;
++ const leftOut = rows.length > 1000 ? rows.length - 1000 : 0;
++ return { rows: rows.slice(0, 1000), leftOut };
++}
++
++function commentItem(c) {
++ const name = c.display_name || c.username || '';
++ const text = c.message == null ? '' : String(c.message);
++ const replies = Number(c.reply_count) || 0;
++ return html`<article class="ovv-comment">
++<header class="ovv-comment-header"><span class="ovv-comment-author">${name}</span>${c.created_at ? html`<time class="ovv-comment-date" datetime="${c.created_at}">${relativeDate(c.created_at)}</time>` : ''}</header>
++<p class="ovv-comment-text">${text}</p>
++${replies > 0 ? html`<p class="ovv-comment-replies"><i class="fa-regular fa-comments" aria-hidden="true"></i> ${replies} ${replies === 1 ? 'reply' : 'replies'}</p>` : ''}
++</article>`;
++}
/** WebVTT timestamp: HH:MM:SS.mmm. */
function vttTime(s) {
@@@ -216,6 +288,7 @@ const grid = (cards, empty = 'Nothing here yet.') => (cards && cards.length ? ht
function createVideoRoutes(ctx) {
const { config, log } = ctx;
const live = createLiveClient({ config, fetchImpl: ctx.fetchImpl || globalThis.fetch, log });
++ const chat = createChatClient({ config, fetchImpl: ctx.fetchImpl || globalThis.fetch, log });
const r = asyncRouter();
const base = String(config.video.url).replace(/\/+$/, '');
const livePublic = String(config.video.livePublicUrl).replace(/\/+$/, '');
@@@ -304,11 +377,22 @@ function createVideoRoutes(ctx) {
mems = chapters(moments(m && m.memories), vod.duration_seconds);
} catch { mems = []; }
let threadUrl = null;
++ let commentRows = [];
++ let commentsOk = false;
try {
const cm = await live.comments('vod', id);
threadUrl = httpsUrl(cm && cm.thread && cm.thread.url);
++ const list = Array.isArray(cm && cm.comments) ? cm.comments : [];
++ commentRows = list.filter((c) => c && !c.is_deleted && !c.deleted).slice(0, 20);
++ commentsOk = true;
} catch { threadUrl = null; }
++ let chatRows = null;
++ try {
++ const replay = await chat.chatReplay(vod.stream_id);
++ chatRows = chatMessageRows(vod, replay && Array.isArray(replay.messages) ? replay.messages : []);
++ } catch { chatRows = null; }
++
const unlisted = vod.visibility === 'unlisted';
const channelName = vod.display_name || vod.username || null;
const localChannel = vod.username ? `/@${vod.username}` : null;
@@@ -339,9 +423,11 @@ ${metaParts.length ? html`<span class="ovv-meta">${metaParts.map((p, i) => (i ==
${vod.is_recording ? html`<p class="ovv-recording" role="status"><i class="fa-solid fa-circle" aria-hidden="true"></i> This recording is still in progress. <a href="${liveChannel}">Watch the live channel</a>.</p>` : ''}
${vod.ai_overview ? html`<section class="ovv-panel" aria-labelledby="h-overview"><h2 id="h-overview">About this stream</h2><p>${vod.ai_overview}</p><p class="ovv-fine">Written by AI from the stream.</p></section>` : ''}
${segs.length ? html`<details class="ovv-panel ovv-transcript"><summary>Transcript <span class="ovv-fine">(AI, ${segs.length} lines)</span></summary><div class="ovv-transcript-body">${segs.map((s) => html`<p><button type="button" class="ovv-seek" data-ovv-seek="${s.start}">${vttTime(s.start).slice(0, 8)}</button> <span>${s.text}</span></p>`)}</div></details>` : ''}
++${commentsOk ? html`<section class="ovv-panel ovv-comments" aria-labelledby="h-comments"><h2 id="h-comments">Comments</h2>${commentRows.length ? commentRows.map(commentItem) : html`<p class="ovv-comments-empty">No comments yet.</p>`}${threadUrl ? html`<p class="ovv-comments-link"><a class="ovv-btn" href="${threadUrl}">Join the discussion on OpenVibe.Community</a></p>` : ''}</section>` : ''}
</div>
<aside class="ovv-side">
${mems.length ? html`<section class="ovv-panel ovv-chapters" aria-labelledby="h-chapters"><h2 id="h-chapters">Chapters</h2><ol>${mems.map((m) => html`<li><a href="/v/${id}?t=${m.offset_seconds}" data-ovv-chapter="${m.offset_seconds}"><span class="ovv-time">${vttTime(m.offset_seconds).slice(0, 8)}</span><span>${m.description}</span></a></li>`)}</ol><p class="ovv-fine">From the AI moments of the stream.</p></section>` : ''}
++${chatRows ? html`<section class="ovv-panel ovv-chat" aria-labelledby="h-chat"><h2 id="h-chat">Chat replay</h2><div class="ovv-chat-body" data-ovv-chat>${chatRows.rows.map((r) => html`<div class="ovv-chat-row" data-ovv-at="${r.offset}"><button type="button" class="ovv-seek" data-ovv-seek="${r.offset}">${streamTime(r.offset)}</button><span class="ovv-chat-msg">${r.name ? html`<b class="ovv-chat-name">${r.name}</b> ` : ''}${r.text}</span></div>`)}</div>${chatRows.leftOut ? html`<p class="ovv-fine">Showing the first 1,000 messages; ${chatRows.leftOut} more not shown.</p>` : ''}</section>` : ''}
${clips.length ? html`<section class="ovv-panel" aria-labelledby="h-clips"><h2 id="h-clips">Clips from this stream</h2><div class="ovv-grid ovv-grid--side">${clips.map(clipCard)}</div></section>` : ''}
<p class="ovv-manage"><a href="${manage}">Manage on OpenVibe.Live</a> <span class="ovv-fine">· owner tools (visibility, clips, delete) stay on Live for now</span></p>
</aside>
ddiff --git a/test/video-pages.test.js b/test/video-pages.test.js
iindex 3e334c2..e1f1ae7 100644
---- a/test/video-pages.test.js
++++ b/test/video-pages.test.js
@@@ -33,7 +33,7 @@ function startLive() {
{ start: 0, end: 2, text: 'Hi & bye <tag> --> here' },
{ start: 2, end: 4, text: 'Second\nline --> done' },
],
-- transcript_status: 'done', stream_category: 'Just Chatting', stream_started_at: '2026-10-12T09:30:00.000Z',
++ transcript_status: 'done', stream_category: 'Just Chatting', stream_id: 1001, stream_started_at: '2026-10-12 09:30:00', stream_ended_at: '2026-10-12 09:30:45',
stream_peak_viewers: 12, comment_count: 3, view_count: 100, is_recording: false,
};
const unlistedVod = {
@@@ -63,7 +63,32 @@ function startLive() {
source_streamer_username: 'streamer', source_streamer_display_name: 'Streamer', view_count: 20,
};
-- const state = { down: false };
++ const comments101 = [
++ { id: 'c1', message: 'Nice <b>stream</b>!', display_name: 'Commenter', username: 'commenter', created_at: '2026-10-12T10:00:00.000Z', reply_count: 3, is_deleted: 0, deleted: false },
++ { id: 'c2', message: 'Agreed', display_name: null, username: 'other_user', created_at: '2026-10-12T10:05:00.000Z', reply_count: 0, is_deleted: 0, deleted: false },
++ { id: 'c3', message: '', display_name: null, username: null, created_at: '2026-10-12T10:10:00.000Z', reply_count: 0, is_deleted: 1, deleted: true },
++ ];
++ const chatMessages101 = [
++ { id: 'm1', timestamp: '2026-10-12 09:29:59', username: 'early', display_name: 'Early', message: 'EARLY_MSG_XYZ', message_type: 'chat', user_id: 900001, anon_id: 'anon-PRIVATE-1', metadata: { note: 'meta-PRIVATE-1' }, profile_color: '#PRIVATE-COLOR', role: 'PRIVATE-ROLE' },
++ { id: 'm2', timestamp: '2026-10-12 09:30:05', username: 'viewer', display_name: 'Viewer', message: 'Hello <b>world</b>', message_type: 'chat', user_id: 900002, anon_id: 'anon-PRIVATE-2', metadata: { note: 'meta-PRIVATE-2' }, profile_color: '#PRIVATE-COLOR', role: 'PRIVATE-ROLE' },
++ { id: 'm3', timestamp: '2026-10-12 09:30:10', username: 'deleted_user', display_name: 'Deleted', message: 'DELETED_MSG_XYZ', message_type: 'chat', is_deleted: true, user_id: 900003, anon_id: 'anon-PRIVATE-3', metadata: { note: 'meta-PRIVATE-3' }, profile_color: '#PRIVATE-COLOR', role: 'PRIVATE-ROLE' },
++ { id: 'm4', timestamp: '2026-10-12 09:30:15', username: 'filtered_user', display_name: 'Filtered', message: 'FILTERED_MSG_XYZ', message_type: 'chat', is_filtered: true, user_id: 900004, anon_id: 'anon-PRIVATE-4', metadata: { note: 'meta-PRIVATE-4' }, profile_color: '#PRIVATE-COLOR', role: 'PRIVATE-ROLE' },
++ { id: 'm5', timestamp: '2026-10-12 09:30:20', username: 'system', display_name: 'System', message: 'SYSTEM_MSG_XYZ', message_type: 'system', user_id: 900005, anon_id: 'anon-PRIVATE-5', metadata: { note: 'meta-PRIVATE-5' }, profile_color: '#PRIVATE-COLOR', role: 'PRIVATE-ROLE' },
++ { id: 'm6', timestamp: '2026-10-12 09:30:25', username: 'viewer2', display_name: 'Viewer Two', message: 'Second message', message_type: 'chat', user_id: 900006, anon_id: 'anon-PRIVATE-6', metadata: { note: 'meta-PRIVATE-6' }, profile_color: '#PRIVATE-COLOR', role: 'PRIVATE-ROLE' },
++ { id: 'm7', timestamp: '2026-10-12 09:32:00', username: 'late', display_name: 'Late', message: 'LATE_MSG_XYZ', message_type: 'chat', user_id: 900007, anon_id: 'anon-PRIVATE-7', metadata: { note: 'meta-PRIVATE-7' }, profile_color: '#PRIVATE-COLOR', role: 'PRIVATE-ROLE' },
++ ];
++ const manyChatMessages = Array.from({ length: 1005 }, function (_, i) {
++ return { id: 'many_' + i, timestamp: '2026-10-12 09:30:01', username: 'viewer', display_name: 'Viewer', message: 'Many message ' + i, message_type: 'chat' };
++ });
++ const secondVod = {
++ id: 104, title: 'A long recording', description: '', duration_seconds: 300,
++ created_at: '2026-10-12T09:30:00.000Z', thumbnail_url: null,
++ playback_url: 'https://openvibe.media/v/104', visibility: 'public',
++ username: 'streamer', display_name: 'Streamer', view_count: 50, is_recording: false,
++ ai_overview: '', ai_transcript_json: null,
++ stream_id: 1002, stream_started_at: '2026-10-12 09:30:00', stream_ended_at: '2026-10-12 09:35:00',
++ };
++ const state = { down: false, chatDown: false };
const server = http.createServer((req, res) => {
const url = new URL(req.url, 'http://x');
const json = (status, body) => {
@@@ -101,6 +126,7 @@ function startLive() {
if (vodDetail[1] === '103') return json(404, { error: 'not found' });
if (vodDetail[1] === '101') return json(200, { vod: publicVod, clips: [clipListItem] });
if (vodDetail[1] === '102') return json(200, { vod: unlistedVod, clips: [] });
++ if (vodDetail[1] === '104') return json(200, { vod: secondVod, clips: [] });
return json(404, { error: 'not found' });
}
const clipDetail = /^\/api\/clips\/(\d+)$/.exec(p);
@@@ -110,8 +136,18 @@ function startLive() {
}
const comments = /^\/api\/comments\/(vod|clip)\/(\d+)$/.exec(p);
if (comments) {
++ if (comments[1] === 'vod' && comments[2] === '101') {
++ return json(200, { comments: comments101, total: comments101.length, thread: { id: 'thr_vod_101', url: 'https://openvibe.community/t/vod-101' } });
++ }
return json(200, { comments: [], total: 0, thread: { id: `thr_${comments[1]}_${comments[2]}`, url: `https://openvibe.community/t/${comments[1]}-${comments[2]}` } });
}
++ const chatReplay = /^\/api\/chat\/(\d+)\/replay$/.exec(p);
++ if (chatReplay) {
++ if (state.chatDown) return json(503, { error: 'Chat is down' });
++ if (chatReplay[1] === '1001') return json(200, { messages: chatMessages101 });
++ if (chatReplay[1] === '1002') return json(200, { messages: manyChatMessages });
++ return json(200, { messages: [] });
++ }
return json(404, { error: 'not found' });
});
@@@ -119,6 +155,7 @@ function startLive() {
server.listen(0, '127.0.0.1', () => resolve({
url: `http://127.0.0.1:${server.address().port}`,
setDown: (v) => { state.down = v; },
++ setChatDown: (v) => { state.chatDown = v; },
close: () => new Promise((r) => server.close(r)),
}));
});
@@@ -143,6 +180,13 @@ function request(base, path, host) {
});
}
++function countOccurrences(haystack, needle) {
++ let n = 0;
++ let i = 0;
++ while ((i = haystack.indexOf(needle, i)) !== -1) { n++; i += needle.length; }
++ return n;
++}
++
(async () => {
const live = await startLive();
const t = await boot({
@@@ -150,6 +194,7 @@ function request(base, path, host) {
MEDIAHUB_VIDEO_URL: 'https://openvibe.video',
MEDIAHUB_LIVE_URL: live.url,
MEDIAHUB_LIVE_PUBLIC_URL: 'https://openvibe.live',
++ MEDIAHUB_CHAT_URL: live.url,
},
});
const getVideo = (p) => request(t.base, p, 'openvibe.video');
@@@ -163,6 +208,15 @@ function request(base, path, host) {
live.setDown(false);
});
++ await check('Chat being down still renders the VOD page without a chat panel', async () => {
++ live.setChatDown(true);
++ const r = await getVideo('/v/101');
++ assert.strictEqual(r.status, 200);
++ assert.ok(!r.text.includes('Chat replay'), 'no chat panel when Chat is down');
++ assert.ok(!r.text.includes('ovv-chat'), 'no chat markup when Chat is down');
++ live.setChatDown(false);
++ });
++
await check('the video home shows both latest grids and links the watch pages', async () => {
const r = await getVideo('/');
assert.strictEqual(r.status, 200);
@@@ -274,6 +328,59 @@ function request(base, path, host) {
assert.ok(r.text.includes('private drive'));
});
++ await check('the VOD page lists the chat replay with offsets, escaping and no private fields', async () => {
++ const r = await getVideo('/v/101');
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes('Chat replay'));
++ assert.ok(r.text.includes('data-ovv-at="5"'), 'the first message is 5 seconds in');
++ assert.ok(r.text.includes('data-ovv-seek="5"'), 'the seek button carries the offset');
++ assert.ok(r.text.includes('0:00:05'), 'the offset is H:MM:SS');
++ assert.ok(r.text.includes('data-ovv-at="25"'), 'the second message is 25 seconds in');
++ assert.ok(r.text.includes('0:00:25'));
++ assert.ok(r.text.includes('Hello &lt;b&gt;world&lt;/b&gt;'), 'message text is escaped');
++ assert.ok(!r.text.includes('Hello <b>world</b>'), 'raw HTML never appears');
++ assert.ok(!r.text.includes('EARLY_MSG_XYZ'), 'message before the stream start is dropped');
++ assert.ok(!r.text.includes('DELETED_MSG_XYZ'), 'deleted message is dropped');
++ assert.ok(!r.text.includes('FILTERED_MSG_XYZ'), 'filtered message is dropped');
++ assert.ok(!r.text.includes('SYSTEM_MSG_XYZ'), 'non-chat message is dropped');
++ assert.ok(!r.text.includes('LATE_MSG_XYZ'), 'message after the end + 60 s is dropped');
++ assert.ok(!r.text.includes('anon-PRIVATE'), 'anon ids never render');
++ assert.ok(!r.text.includes('meta-PRIVATE'), 'metadata never renders');
++ assert.ok(!r.text.includes('PRIVATE-COLOR'), 'colours never render');
++ assert.ok(!r.text.includes('PRIVATE-ROLE'), 'roles never render');
++ assert.ok(!r.text.includes('900002'), 'user ids never render');
++ assert.ok(!r.text.includes('user_id'), 'user id field never renders');
++ });
++
++ await check('the chat replay caps at 1,000 messages and says how many were left out', async () => {
++ const r = await getVideo('/v/104');
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes('Showing the first 1,000 messages; 5 more not shown.'), 'the cap note names the count');
++ assert.strictEqual(countOccurrences(r.text, 'class="ovv-chat-row"'), 1000, 'exactly 1,000 rows render');
++ });
++
++ await check('the VOD page shows the newest comments and the Community link', async () => {
++ const r = await getVideo('/v/101');
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes('Comments'));
++ assert.ok(r.text.includes('Commenter'), 'author display name');
++ assert.ok(r.text.includes('other_user'), 'username fallback for the author');
++ assert.ok(r.text.includes('Nice &lt;b&gt;stream&lt;/b&gt;!'), 'comment text is escaped');
++ assert.ok(!r.text.includes('Nice <b>stream</b>!'), 'raw comment HTML never appears');
++ assert.ok(r.text.includes('3 replies'), 'reply count when present');
++ assert.ok(r.text.includes('<time class="ovv-comment-date"'), 'a relative date is shown');
++ assert.strictEqual(countOccurrences(r.text, 'class="ovv-comment"'), 2, 'the deleted comment is not rendered');
++ assert.ok(r.text.includes('Join the discussion on OpenVibe.Community'));
++ assert.ok(r.text.includes('href="https://openvibe.community/t/vod-101"'));
++ });
++
++ await check('a VOD with no comments shows the empty line and the link', async () => {
++ const r = await getVideo('/v/102');
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes('No comments yet.'));
++ assert.ok(r.text.includes('href="https://openvibe.community/t/vod-102"'));
++ });
++
await t.close();
await live.close();
done();