openvibers/openvibe.mediahub Commits
MediaHub: OpenVibe.Video watch pages show the stream's chat replay and its comments (#29)
Changes
@@@ -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.videoMEDIAHUB_LIVE_URL=https://openvibe.liveMEDIAHUB_LIVE_PUBLIC_URL=https://openvibe.live++MEDIAHUB_CHAT_URL=https://openvibe.live@@@ -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 streamrecordings and clips (`/v/:id`, `/c/:id`, `/@:username`, WebVTT captions and chapters), reading Live's publicJSON 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 appno longer has "coming" pages or host-based brand handling.@@@ -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 appliesvisibility: 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@@@ -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; }@@@ -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;++ });++ }});})();@@@ -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 onennew file mode 100644@@@ -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 };@@@ -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 ? htfunction 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>@@@ -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 <b>world</b>'), '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 <b>stream</b>!'), '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();
@@ -57,9 +57,10 @@ MEDIAHUB_MEDIA_APP=media-hub
@@ -57,9 +57,10 @@ MEDIAHUB_MEDIA_APP=media-hub
# COOKIE_SECURE=true
# COOKIE_SECURE=true
# ── OpenVibe.Video: public watch pages for OpenVibe.Live's recordings and clips ──
# ── 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_VIDEO_URL=https://openvibe.video
MEDIAHUB_LIVE_URL=https://openvibe.live
MEDIAHUB_LIVE_URL=https://openvibe.live
MEDIAHUB_LIVE_PUBLIC_URL=https://openvibe.live
MEDIAHUB_LIVE_PUBLIC_URL=https://openvibe.live
+MEDIAHUB_CHAT_URL=https://openvibe.live
@@ -10,6 +10,9 @@ What changed in OpenVibe.MediaHub, newest first. Each site also publishes its pa
@@ -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
- 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
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.
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
- 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.
no longer has "coming" pages or host-based brand handling.
@@ -115,10 +115,13 @@ public on OpenVibe.Live. There are no uploads and no new storage — the media f
@@ -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
(`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).
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 |
| Path | What |
|---|---|
|---|---|
| `/` | what OpenVibe.Video is, then the latest recordings and clips |
| `/` | 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 |
| `/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 |
| `/@:username` | a channel's recordings (and clips), newest first, 24 per page |
| `/v/:id/captions.vtt` | the AI transcript as WebVTT captions |
| `/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
@@ -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 |
| `/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),
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
## Pages
@@ -95,6 +95,25 @@ a.ovv-channel-name:hover { text-decoration: underline; }
@@ -95,6 +95,25 @@ a.ovv-channel-name:hover { text-decoration: underline; }
.ovv-channel-head .ovv-meta { margin: 0; }
.ovv-channel-head .ovv-meta { margin: 0; }
.ovv-pagination { display: flex; gap: 10px; margin-top: 1.2rem; }
.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) {
@media (max-width: 1000px) {
.ovv-watch { grid-template-columns: minmax(0, 1fr); }
.ovv-watch { grid-template-columns: minmax(0, 1fr); }
.ovv-chapters ol { max-height: none; }
.ovv-chapters ol { max-height: none; }
@@ -60,5 +60,39 @@
@@ -60,5 +60,39 @@
current = at;
current = at;
if (current) { current.classList.add('is-current'); current.setAttribute('aria-current', 'true'); }
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;
+ });
+ }
});
});
})();
})();
@@ -56,11 +56,13 @@ function load(env = process.env) {
@@ -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
// 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.
// 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
// `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: {
video: {
url: trim(env.MEDIAHUB_VIDEO_URL || 'https://openvibe.video'),
url: trim(env.MEDIAHUB_VIDEO_URL || 'https://openvibe.video'),
liveUrl: trim(env.MEDIAHUB_LIVE_URL || 'https://openvibe.live'),
liveUrl: trim(env.MEDIAHUB_LIVE_URL || 'https://openvibe.live'),
livePublicUrl: trim(env.MEDIAHUB_LIVE_PUBLIC_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
// What one person may keep and do here. Media's own quotas are per tenant (this whole service is one
new file mode 100644
new file mode 100644
@@ -0,0 +1,73 @@
@@ -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 };
@@ -25,6 +25,7 @@ const { asyncRouter } = require('../http/router');
@@ -25,6 +25,7 @@ const { asyncRouter } = require('../http/router');
const { html } = require('../render/html');
const { html } = require('../render/html');
const { send, assetVersion, SITE_NAME } = require('./layout');
const { send, assetVersion, SITE_NAME } = require('./layout');
const { createLiveClient } = require('./live-client');
const { createLiveClient } = require('./live-client');
+const { createChatClient } = require('./chat-client');
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
const HOME_LIMIT = 10;
const HOME_LIMIT = 10;
@@ -56,6 +57,77 @@ function dateLabel(iso) {
@@ -56,6 +57,77 @@ function dateLabel(iso) {
if (Number.isNaN(d.getTime())) return String(iso);
if (Number.isNaN(d.getTime())) return String(iso);
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
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. */
/** WebVTT timestamp: HH:MM:SS.mmm. */
function vttTime(s) {
function vttTime(s) {
@@ -216,6 +288,7 @@ const grid = (cards, empty = 'Nothing here yet.') => (cards && cards.length ? ht
@@ -216,6 +288,7 @@ const grid = (cards, empty = 'Nothing here yet.') => (cards && cards.length ? ht
function createVideoRoutes(ctx) {
function createVideoRoutes(ctx) {
const { config, log } = ctx;
const { config, log } = ctx;
const live = createLiveClient({ config, fetchImpl: ctx.fetchImpl || globalThis.fetch, log });
const live = createLiveClient({ config, fetchImpl: ctx.fetchImpl || globalThis.fetch, log });
+ const chat = createChatClient({ config, fetchImpl: ctx.fetchImpl || globalThis.fetch, log });
const r = asyncRouter();
const r = asyncRouter();
const base = String(config.video.url).replace(/\/+$/, '');
const base = String(config.video.url).replace(/\/+$/, '');
const livePublic = String(config.video.livePublicUrl).replace(/\/+$/, '');
const livePublic = String(config.video.livePublicUrl).replace(/\/+$/, '');
@@ -304,11 +377,22 @@ function createVideoRoutes(ctx) {
@@ -304,11 +377,22 @@ function createVideoRoutes(ctx) {
mems = chapters(moments(m && m.memories), vod.duration_seconds);
mems = chapters(moments(m && m.memories), vod.duration_seconds);
} catch { mems = []; }
} catch { mems = []; }
let threadUrl = null;
let threadUrl = null;
+ let commentRows = [];
+ let commentsOk = false;
try {
try {
const cm = await live.comments('vod', id);
const cm = await live.comments('vod', id);
threadUrl = httpsUrl(cm && cm.thread && cm.thread.url);
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; }
} 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 unlisted = vod.visibility === 'unlisted';
const channelName = vod.display_name || vod.username || null;
const channelName = vod.display_name || vod.username || null;
const localChannel = vod.username ? `/@${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 ==
@@ -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.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>` : ''}
${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>` : ''}
${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>
</div>
<aside class="ovv-side">
<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>` : ''}
${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>` : ''}
${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>
<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>
</aside>
@@ -33,7 +33,7 @@ function startLive() {
@@ -33,7 +33,7 @@ function startLive() {
{ start: 0, end: 2, text: 'Hi & bye <tag> --> here' },
{ start: 0, end: 2, text: 'Hi & bye <tag> --> here' },
{ start: 2, end: 4, text: 'Second\nline --> done' },
{ 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,
stream_peak_viewers: 12, comment_count: 3, view_count: 100, is_recording: false,
};
};
const unlistedVod = {
const unlistedVod = {
@@ -63,7 +63,32 @@ function startLive() {
@@ -63,7 +63,32 @@ function startLive() {
source_streamer_username: 'streamer', source_streamer_display_name: 'Streamer', view_count: 20,
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 server = http.createServer((req, res) => {
const url = new URL(req.url, 'http://x');
const url = new URL(req.url, 'http://x');
const json = (status, body) => {
const json = (status, body) => {
@@ -101,6 +126,7 @@ function startLive() {
@@ -101,6 +126,7 @@ function startLive() {
if (vodDetail[1] === '103') return json(404, { error: 'not found' });
if (vodDetail[1] === '103') return json(404, { error: 'not found' });
if (vodDetail[1] === '101') return json(200, { vod: publicVod, clips: [clipListItem] });
if (vodDetail[1] === '101') return json(200, { vod: publicVod, clips: [clipListItem] });
if (vodDetail[1] === '102') return json(200, { vod: unlistedVod, clips: [] });
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' });
return json(404, { error: 'not found' });
}
}
const clipDetail = /^\/api\/clips\/(\d+)$/.exec(p);
const clipDetail = /^\/api\/clips\/(\d+)$/.exec(p);
@@ -110,8 +136,18 @@ function startLive() {
@@ -110,8 +136,18 @@ function startLive() {
}
}
const comments = /^\/api\/comments\/(vod|clip)\/(\d+)$/.exec(p);
const comments = /^\/api\/comments\/(vod|clip)\/(\d+)$/.exec(p);
if (comments) {
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]}` } });
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' });
return json(404, { error: 'not found' });
});
});
@@ -119,6 +155,7 @@ function startLive() {
@@ -119,6 +155,7 @@ function startLive() {
server.listen(0, '127.0.0.1', () => resolve({
server.listen(0, '127.0.0.1', () => resolve({
url: `http://127.0.0.1:${server.address().port}`,
url: `http://127.0.0.1:${server.address().port}`,
setDown: (v) => { state.down = v; },
setDown: (v) => { state.down = v; },
+ setChatDown: (v) => { state.chatDown = v; },
close: () => new Promise((r) => server.close(r)),
close: () => new Promise((r) => server.close(r)),
}));
}));
});
});
@@ -143,6 +180,13 @@ function request(base, path, host) {
@@ -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 () => {
(async () => {
const live = await startLive();
const live = await startLive();
const t = await boot({
const t = await boot({
@@ -150,6 +194,7 @@ function request(base, path, host) {
@@ -150,6 +194,7 @@ function request(base, path, host) {
MEDIAHUB_VIDEO_URL: 'https://openvibe.video',
MEDIAHUB_VIDEO_URL: 'https://openvibe.video',
MEDIAHUB_LIVE_URL: live.url,
MEDIAHUB_LIVE_URL: live.url,
MEDIAHUB_LIVE_PUBLIC_URL: 'https://openvibe.live',
MEDIAHUB_LIVE_PUBLIC_URL: 'https://openvibe.live',
+ MEDIAHUB_CHAT_URL: live.url,
},
},
});
});
const getVideo = (p) => request(t.base, p, 'openvibe.video');
const getVideo = (p) => request(t.base, p, 'openvibe.video');
@@ -163,6 +208,15 @@ function request(base, path, host) {
@@ -163,6 +208,15 @@ function request(base, path, host) {
live.setDown(false);
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 () => {
await check('the video home shows both latest grids and links the watch pages', async () => {
const r = await getVideo('/');
const r = await getVideo('/');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
@@ -274,6 +328,59 @@ function request(base, path, host) {
@@ -274,6 +328,59 @@ function request(base, path, host) {
assert.ok(r.text.includes('private drive'));
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 <b>world</b>'), '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 <b>stream</b>!'), '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 t.close();
await live.close();
await live.close();
done();
done();