openvibers/openvibe.mediahub Commits

The watch pages say what the video is: its kind, the day it was streamed, a clip's source, a plain link to a moment, and exports that say what they are for (#210)

OP OpenVibers committed 13 days ago · cc236fdcb9 · 9 files, +466 −51

The market research (2026-10-30, outcomes 1, 2, 3 and 7) asked four things of a watch page: what the video is,
where a clip came from, a plain timestamp link, and exports that say what they are for.

- Under the title, one line says what the video is: a stream recording, a clip or an upload, the day it was
  streamed (Live's own stream time), how long it is and how many people watched it. Every word comes from the
  item's own fields: a recording from before Live kept the stream times says when it was published instead, and
  a count Live does not give is left out rather than guessed.
- A clip credits the recording it was cut from and links back to it, at the moment the clip starts. A recording
  that is not public any more is named as such; when Live cannot be asked the clip says so instead of guessing.
- The share box writes the page's own link out (it works without JavaScript) and says how to make a link to a
  moment: ?t=125 opens the video at 2:05. A moment, not a clip: nothing is stored and nothing is cut.
- Every export says what it is for under its name (TXT to read, SRT for editors, VTT for web players, JSON for
  programs, CSV for spreadsheets) — a phone has no hover — and the transcript says it was written by AI, and can
  be wrong.
- The menus on a phone open above the app bar, so their own buttons are not behind it.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 3ea1bd3..42f843a 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,15 @@ What changed in OpenVibe.MediaHub, newest first. Each site also publishes its pa
## Unreleased
++- The watch pages say what the video is (2026-10-30, the market research): under the title, the kind — a stream
++ recording, a clip or an upload — with the day it was streamed (Live's own stream time), its length and its views.
++ Every word comes from the item's own fields; a fact Live does not give is left out, not guessed. A clip credits the
++ recording it was cut from and links back to it, at the moment the clip starts; a recording that is not public any
++ more is named as such. The share box writes the page's own link out (a plain link, and `?t=125` to open the video at
++ 2:05 — a moment, not a clip: nothing is stored). Every export says what it is for (TXT to read, SRT for editors, VTT
++ for web players, JSON for programs, CSV for spreadsheets) and the transcript says it was written by AI, and can be
++ wrong. The menus on a phone open above the app bar, so their buttons are not behind it.
++
- openvibe.video's search basics (2026-10-20). A watch page nobody wrote about gets a description of its own (who,
how long, when, its first words) instead of the site tagline. A recording under two minutes with nothing written
about it is noindex and out of the sitemap: 118 of 785 were under a minute, mostly test streams. The sitemap lists
ddiff --git a/public/css/video.css b/public/css/video.css
iindex 2680046..2cb6a74 100644
---- a/public/css/video.css
++++ b/public/css/video.css
@@@ -225,7 +225,9 @@ a.ovv-channel-name:hover { text-decoration: underline; }
.ovv-dl-label { font-size: .92rem; font-weight: 700; }
.ovv-dl-about { font-size: .78rem; color: var(--ovv-dim); overflow-wrap: anywhere; }
.ovv-dl-formats { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 4px; }
--.ovv-dl-format { display: inline-flex; align-items: center; min-height: 30px; padding: 2px 10px; border-radius: 8px; border: 1px solid var(--ovv-line-strong); color: inherit !important; font-size: .78rem; font-weight: 700; letter-spacing: .3px; text-decoration: none; }
++.ovv-dl-format { display: inline-flex; flex-direction: column; align-items: center; justify-content: center; gap: 1px; min-height: 30px; padding: 2px 10px; border-radius: 8px; border: 1px solid var(--ovv-line-strong); color: inherit !important; font-size: .78rem; font-weight: 700; letter-spacing: .3px; text-decoration: none; }
++/* Each format says what it is for, under its name: the menu reads without hovering (a phone has no hover). */
++.ovv-dl-format small { font-size: .66rem; font-weight: 500; letter-spacing: 0; opacity: .85; }
.ovv-dl-format:hover, .ovv-dl-format:focus-visible { border-color: var(--ovv-accent); background: var(--bg-hover, #1c2a44); }
.ovv-dl-format--main { border-color: var(--accent-strong, #2563eb); background: var(--accent-strong, #2563eb); color: var(--on-accent-strong, #fff) !important; }
.ovv-dl-format--main:hover, .ovv-dl-format--main:focus-visible { background: var(--accent-dark, #1d4ed8); border-color: var(--accent-light, #60a5fa); }
@@@ -338,6 +340,18 @@ a.ovv-channel-name:hover { text-decoration: underline; }
.ovv-btn--icon { width: 38px; padding: 0; justify-content: center; }
.ovv-btn[hidden] { display: none; }
++/* What the watch page is showing (the market outcomes, 2026-10-30): the kind of video under its title, the day it was
++ streamed or published, its length and its views. Every word comes from the item's own fields. */
++.ovv-what { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; margin: 0 0 10px; font-size: .88rem; color: var(--ovv-dim); }
++.ovv-what-kind { display: inline-flex; align-items: center; gap: 6px; padding: 2px 10px; border: 1px solid var(--ovv-line-strong); border-radius: 999px; color: var(--ovv-faint); font-size: .78rem; font-weight: 700; white-space: nowrap; }
++.ovv-what-facts { overflow-wrap: anywhere; }
++/* A clip's credit: the recording it was cut from, and a link back to it. */
++.ovv-source { margin: 0 0 10px; font-size: .88rem; color: var(--ovv-dim); overflow-wrap: anywhere; }
++.ovv-source i { color: var(--ovv-faint); }
++/* The share box: the page's own link, and a link to the moment it is playing (a moment, not a clip). */
++.ovv-share-url { width: 100%; box-sizing: border-box; padding: 8px 10px; border: 1px solid var(--ovv-line-strong); border-radius: 8px; background: var(--bg-tertiary, #16223a); color: inherit; font-size: .82rem; }
++.ovv-share-buttons { display: flex; flex-wrap: wrap; gap: 8px; margin: 10px 0 0; }
++
/* A button that opens a small menu or form (Save, Clip, More): a <details>; video.js closes it on a click elsewhere. */
.ovv-pop { position: relative; }
.ovv-pop > summary { list-style: none; cursor: pointer; }
@@@ -458,6 +472,9 @@ a.ovv-channel-name:hover { text-decoration: underline; }
/* Less space before the footer: the page's own bottom and the footer's top margin made a band of nothing. */
.ovv-page { padding-bottom: 20px; }
.ovv-pop-panel, .ovv-save[open] > .ovv-save-form, .ovv-more-menu .ovv-pop-panel { position: fixed; top: auto; left: 8px; right: 8px; bottom: 8px; width: auto; max-height: 82vh; max-height: 82dvh; overflow-y: auto; border-radius: 16px; padding-bottom: calc(14px + env(safe-area-inset-bottom, 0px)); }
++ /* The sheet sits above the app bar (the shared navbar sets --ovnav-appbar-h to its height), so the sheet's own
++ buttons are not behind the bar at the bottom of the screen. */
++ .ovv-pop-panel, .ovv-save[open] > .ovv-save-form, .ovv-more-menu .ovv-pop-panel { bottom: calc(8px + var(--ovnav-appbar-h, 0px)); }
.ovv-pop--js[open]::before { content: ''; position: fixed; inset: 0; z-index: 39; background: rgba(0, 0, 0, .55); }
.ovv-head .ovv-title { font-size: 1.2rem; }
}
ddiff --git a/public/js/video.js b/public/js/video.js
iindex ccbef9d..cfc4140 100644
---- a/public/js/video.js
++++ b/public/js/video.js
@@@ -242,28 +242,56 @@
if (window.location.hash.length > 1) open(window.location.hash.slice(1));
}
-- // Share: the page's link, at the current time once it plays; the phone's share sheet where there is one.
++ // Share: the page's own link, and the link to the moment it is playing. The link is written in a field on the page,
++ // so it can be copied without JavaScript; these buttons (copy, copy at this moment) appear when JavaScript is there.
function share(video) {
++ const title = (document.querySelector('.ovv-title') || {}).textContent || document.title;
++ const fieldOf = function (b) {
++ const id = b.getAttribute('data-ovv-share-field');
++ return id ? document.getElementById(id) : null;
++ };
++ const momentUrl = function (url) {
++ try {
++ const u = new URL(url);
++ if (video && video.currentTime > 1 && !video.ended) u.searchParams.set('t', String(Math.floor(video.currentTime)));
++ return u.toString();
++ } catch (e) { return url; }
++ };
++ const copied = function (b, url) {
++ const label = b.innerHTML;
++ const field = fieldOf(b);
++ if (field) field.value = url;
++ const said = function (text) {
++ b.innerHTML = text;
++ setTimeout(function () { b.innerHTML = label; }, 2000);
++ };
++ if (!navigator.clipboard || !navigator.clipboard.writeText) {
++ // No clipboard to write to: the field is selected, so the reader can copy it by hand.
++ if (field) { field.focus(); field.select(); }
++ said('<i class="fa-solid fa-check" aria-hidden="true"></i> Link ready to copy');
++ return;
++ }
++ navigator.clipboard.writeText(url).then(function () {
++ said('<i class="fa-solid fa-check" aria-hidden="true"></i> Link copied');
++ }, function () { window.prompt('Copy this link', url); });
++ };
document.querySelectorAll('[data-ovv-share]').forEach(function (b) {
-- if (!navigator.share && !navigator.clipboard) return;
b.hidden = false;
-- const label = b.innerHTML;
b.addEventListener('click', function () {
-- let url = b.getAttribute('data-ovv-share');
-- try {
-- const u = new URL(url);
-- if (video && video.currentTime > 3 && !video.ended) u.searchParams.set('t', String(Math.floor(video.currentTime)));
-- url = u.toString();
-- } catch (e) { /* the page's own link */ }
-- const title = (document.querySelector('.ovv-title') || {}).textContent || document.title;
++ const url = b.getAttribute('data-ovv-share');
++ const field = fieldOf(b);
++ if (field) field.value = url;
if (navigator.share && window.matchMedia('(hover: none)').matches) {
navigator.share({ title: title, url: url }).catch(function () { /* closed */ });
return;
}
-- navigator.clipboard.writeText(url).then(function () {
-- b.innerHTML = '<i class="fa-solid fa-check" aria-hidden="true"></i> Link copied';
-- setTimeout(function () { b.innerHTML = label; }, 2000);
-- }, function () { window.prompt('Copy this link', url); });
++ copied(b, url);
++ });
++ });
++ document.querySelectorAll('[data-ovv-share-at]').forEach(function (b) {
++ b.hidden = false;
++ b.addEventListener('click', function () {
++ copied(b, momentUrl(b.getAttribute('data-ovv-share-at') || ''));
});
});
}
ddiff --git a/server/video/downloads.js b/server/video/downloads.js
iindex cf4cdfe..703c5d0 100644
---- a/server/video/downloads.js
++++ b/server/video/downloads.js
@@@ -191,9 +191,10 @@ const FORMAT_HELP = {
};
const CHAPTER_TXT_HELP = 'Times and titles, ready to paste into a YouTube description';
--/** One row of the menu: an icon, what it is (and a line about it), and a link per format. */
++/** One row of the menu: an icon, what it is (and a line about it), and a link per format. Each format says what it is
++ * for under its name, so the menu reads without hovering (a phone has no hover). */
function row({ icon, label, about, links }) {
-- return html`<li class="ovv-dl-row"><i class="${icon}" aria-hidden="true"></i><span class="ovv-dl-what"><span class="ovv-dl-label">${label}</span>${about ? html`<span class="ovv-dl-about">${about}</span>` : ''}</span><span class="ovv-dl-formats">${links.map((l) => html`<a class="ovv-dl-format${l.main ? ' ovv-dl-format--main' : ''}" href="${l.href}" download rel="nofollow" title="${l.help || FORMAT_HELP[l.ext] || ''}" aria-label="${label}: ${l.text || l.ext.toUpperCase()} (${l.help || FORMAT_HELP[l.ext] || ''})">${l.text || l.ext.toUpperCase()}</a>`)}</span></li>`;
++ return html`<li class="ovv-dl-row"><i class="${icon}" aria-hidden="true"></i><span class="ovv-dl-what"><span class="ovv-dl-label">${label}</span>${about ? html`<span class="ovv-dl-about">${about}</span>` : ''}</span><span class="ovv-dl-formats">${links.map((l) => html`<a class="ovv-dl-format${l.main ? ' ovv-dl-format--main' : ''}" href="${l.href}" download rel="nofollow" title="${l.help || FORMAT_HELP[l.ext] || ''}" aria-label="${label}: ${l.text || l.ext.toUpperCase()} (${l.help || FORMAT_HELP[l.ext] || ''})"><span>${l.text || l.ext.toUpperCase()}</span>${l.purpose ? html`<small>${l.purpose}</small>` : ''}</a>`)}</span></li>`;
}
/** "570.7 MB" for a byte count (null when unknown). */
@@@ -239,9 +240,19 @@ function downloadMenu({ kind, id, item, has }) {
const ext = videoExt(item);
rows.push(row({ icon: 'fa-solid fa-film', label: 'Video', about: [kind === 'clip' ? 'The clip' : 'The whole recording', size].filter(Boolean).join(' · '), links: [{ href: `${at}/video`, ext, main: true }] }));
}
-- if (has.transcript) rows.push(row({ icon: 'fa-solid fa-closed-captioning', label: 'Transcript', about: `${has.transcript} lines, by AI`, links: ['txt', 'srt', 'vtt', 'json'].map((ext) => ({ href: `${at}/transcript.${ext}`, ext })) }));
-- if (has.chat) rows.push(row({ icon: 'fa-solid fa-comments', label: 'Chat replay', about: `${has.chat} messages`, links: ['txt', 'csv', 'json'].map((ext) => ({ href: `${at}/chat.${ext}`, ext })) }));
-- if (has.chapters) rows.push(row({ icon: 'fa-solid fa-list-ol', label: 'Chapters', about: `${has.chapters} chapters`, links: [{ href: `${at}/chapters.txt`, ext: 'txt', help: CHAPTER_TXT_HELP }, { href: `${at}/chapters.vtt`, ext: 'vtt' }, { href: `${at}/chapters.json`, ext: 'json' }] }));
++ if (has.transcript) rows.push(row({ icon: 'fa-solid fa-closed-captioning', label: 'Transcript', about: `${has.transcript} lines, written by AI`, links: [
++ { href: `${at}/transcript.txt`, ext: 'txt', purpose: 'to read' },
++ { href: `${at}/transcript.srt`, ext: 'srt', purpose: 'for editors' },
++ { href: `${at}/transcript.vtt`, ext: 'vtt', purpose: 'for web players' },
++ { href: `${at}/transcript.json`, ext: 'json', purpose: 'for programs' }] }));
++ if (has.chat) rows.push(row({ icon: 'fa-solid fa-comments', label: 'Chat replay', about: `${has.chat} messages`, links: [
++ { href: `${at}/chat.txt`, ext: 'txt', purpose: 'to read' },
++ { href: `${at}/chat.csv`, ext: 'csv', purpose: 'for spreadsheets' },
++ { href: `${at}/chat.json`, ext: 'json', purpose: 'for programs' }] }));
++ if (has.chapters) rows.push(row({ icon: 'fa-solid fa-list-ol', label: 'Chapters', about: `${has.chapters} chapters, made by AI`, links: [
++ { href: `${at}/chapters.txt`, ext: 'txt', purpose: 'to paste', help: CHAPTER_TXT_HELP },
++ { href: `${at}/chapters.vtt`, ext: 'vtt', purpose: 'for players' },
++ { href: `${at}/chapters.json`, ext: 'json', purpose: 'for programs' }] }));
if (item.thumbnail_url) rows.push(row({ icon: 'fa-regular fa-image', label: 'Cover picture', about: null, links: [{ href: `${at}/thumbnail`, ext: 'jpg', text: 'Image' }] }));
rows.push(row({ icon: 'fa-solid fa-circle-info', label: 'Details', about: 'Title, channel, dates, summary', links: [{ href: `${at}/info.json`, ext: 'json' }] }));
const everything = html`<li class="ovv-dl-row ovv-dl-row--all"><i class="fa-solid fa-file-zipper" aria-hidden="true"></i><span class="ovv-dl-what"><span class="ovv-dl-label">Everything</span><span class="ovv-dl-about">All of the above but the video, in one .zip</span></span><span class="ovv-dl-formats"><a class="ovv-dl-format ovv-dl-format--main" href="${at}/all.zip" download rel="nofollow" title="${FORMAT_HELP.zip}" aria-label="Everything: ZIP (${FORMAT_HELP.zip})">ZIP</a></span></li>`;
@@@ -249,7 +260,7 @@ function downloadMenu({ kind, id, item, has }) {
<div class="ovv-dl-menu" role="group" aria-label="Download this ${kind === 'clip' ? 'clip' : 'recording'}">
<div class="ovv-dl-head"><span>Download this ${kind === 'clip' ? 'clip' : 'recording'}</span><button type="button" class="ovv-dl-close" data-ovv-dl-close aria-label="Close"><i class="fa-solid fa-xmark" aria-hidden="true"></i></button></div>
<ul class="ovv-dl-list">${rows}${everything}</ul>
--<p class="ovv-dl-hint">Hover or long-press a format to see what it is for.</p>
++<p class="ovv-dl-hint">Every format says what it is for.</p>
</div></details>`;
}
ddiff --git a/server/video/pages.js b/server/video/pages.js
iindex cb7a10e..3d87d4f 100644
---- a/server/video/pages.js
++++ b/server/video/pages.js
@@@ -43,7 +43,7 @@ const { createVideoApiRoutes } = require('./api');
const { createUploadApi, mountUploadPages } = require('./upload-routes');
const { ACCESS_COOKIE, REFRESH_COOKIE } = require('../auth/sso');
const { mountDownloads, downloadMenu } = require('./downloads');
--const { actionMenu, menuLinks, companion } = require('./watch-ui');
++const { actionMenu, menuLinks, companion, whatItIs, sharePanel, lengthWords, dayLabel, streamDate } = require('./watch-ui');
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
const HOME_LIMIT = 10;
@@@ -129,15 +129,7 @@ function thinRecording(vod, transcriptLines = 0) {
}
/** 31 → "31 seconds", 754 → "13 minutes", 7500 → "2 hours 5 minutes". */
--function durationWords(s) {
-- const n = Math.max(0, Math.round(Number(s) || 0));
-- const plural = (v, unit) => `${v} ${unit}${v === 1 ? '' : 's'}`;
-- if (n < 60) return plural(n, 'second');
-- if (n < 3600) return plural(Math.round(n / 60), 'minute');
-- const h = Math.floor(n / 3600);
-- const m = Math.round((n % 3600) / 60);
-- return m ? `${plural(h, 'hour')} ${plural(m, 'minute')}` : plural(h, 'hour');
--}
++const durationWords = lengthWords;
/**
* A watch page's meta description when nobody wrote one, so no two pages share the site's tagline: what it is, whose,
@@@ -301,9 +293,7 @@ function watchTitle(title, who, iso) {
function dateLabel(iso) {
if (!iso) return '';
-- const d = new Date(iso);
-- if (Number.isNaN(d.getTime())) return String(iso);
-- return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
++ return dayLabel(iso) || String(iso);
}
/** 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) {
@@@ -746,7 +736,7 @@ ${visibilityChoices(item.visibility || 'public')}
r.use(express.urlencoded({ extended: false, limit: '64kb' }));
// ── Uploads: /upload, /u/:id, /me/videos (server/video/upload-routes.js) ──
-- const uploadCard = mountUploadPages(r, { ctx, page, notFound, signedIn, ownerOf, mustSignIn, sameSite, isStaff, visibilityOptions, visibilityLabel, durationLabel, dateLabel, httpsUrl, fileOf, hlsAttrs, grid, card, renderCaptionsVtt, renderChaptersVtt, vttTime, sendVtt, notFoundVtt, playlistForm, upNext });
++ const uploadCard = mountUploadPages(r, { ctx, page, notFound, signedIn, ownerOf, mustSignIn, sameSite, isStaff, visibilityOptions, visibilityLabel, durationLabel, dateLabel, httpsUrl, fileOf, hlsAttrs, grid, card, renderCaptionsVtt, renderChaptersVtt, vttTime, sendVtt, notFoundVtt, playlistForm, upNext, whatItIs, sharePanel });
// ── Downloads: /v/:id/download/:file and /c/:id/download/:file (server/video/downloads.js) ──
mountDownloads(r, { live, chat, ownView, asPerson, notFound, base, httpsUrl, log, fetchImpl: ctx.fetchImpl || globalThis.fetch,
@@@ -1200,10 +1190,15 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
const localChannel = vod.username ? `/@${vod.username}` : null;
const liveChannel = `${livePublic}/@${vod.username}`;
const metaParts = [];
-- if (vod.created_at) metaParts.push(html`<time datetime="${vod.created_at}">${dateLabel(vod.created_at)}</time>`);
-- if (vod.duration_seconds != null) metaParts.push(durationLabel(vod.duration_seconds));
-- if (vod.stream_category) metaParts.push(String(vod.stream_category).charAt(0).toUpperCase() + String(vod.stream_category).slice(1));
-- if (viewsLabel(vod.view_count)) metaParts.push(viewsLabel(vod.view_count));
++ // What this video is: a stream recording, the day it was streamed, how long it is, how many people watched it.
++ // The channel is in the byline under the title and the category in the About section, so nothing is said twice.
++ const what = whatItIs({
++ kind: 'recording',
++ streamedAt: streamDate(vod.stream_started_at),
++ publishedAt: vod.created_at,
++ durationSeconds: vod.duration_seconds,
++ views: Number(vod.view_count) || 0,
++ });
if (manageable) metaParts.push(visibilityBadge(vod.visibility));
const nextAttr = up.nextUrl ? html` data-ovv-next="${up.nextUrl}"` : '';
@@@ -1247,13 +1242,14 @@ ${savedNotice(req)}${ownOnly ? notice('Private: only you (and OpenVibe staff) ca
<div class="ovv-player-wrap"><video class="ovv-player" data-ov-player controls preload="metadata" playsinline${posterAttr(httpsUrl(vod.thumbnail_url))}${shapeAttr} src="${fileOf(vod) || ''}" data-ovv-video${nextAttr}${viewAttr}${hlsAttr}>${segs.length ? html`<track kind="captions" label="Transcript (AI)" src="/v/${id}/captions.vtt" default>` : ''}${mems.length ? html`<track kind="chapters" src="/v/${id}/chapters.vtt">` : ''}</video>${pipsTag(pips)}</div>
<div class="ovv-head">
<h1 class="ovv-title">${vod.title || 'Stream recording'}</h1>
++${what}
<div class="ovv-head-row">
<div class="ovv-byline">
${channelName ? html`<a class="ovv-avatar" href="${localChannel || liveChannel}" aria-hidden="true" tabindex="-1">${String(channelName).slice(0, 1).toUpperCase()}${live.isUsername(String(vod.username || '')) ? html`<img src="${config.networkUrl}/avatar/${vod.username}" alt="" loading="lazy">` : ''}</a>` : ''}
<div class="ovv-byline-text">${channelName ? (localChannel ? html`<a class="ovv-channel-name" href="${localChannel}">@${channelName}</a>` : html`<span class="ovv-channel-name">@${channelName}</span>`) : ''}
${metaParts.length ? html`<span class="ovv-meta">${metaParts.map((p, i) => (i === 0 ? p : [html` · `, p]))}</span>` : ''}</div>
</div>
--<div class="ovv-actions">${manageable ? editButton('recording') : ''}${saveForm}${ownOnly ? '' : clipAction(req, id, vod)}${downloadMenu({ kind: 'vod', id, item: vod, has: { transcript: segs.length, chat: chatRows ? chatRows.rows.length + chatRows.leftOut : 0, chapters: mems.length } })}<button type="button" class="ovv-btn" hidden data-ovv-share="${base}/v/${id}" title="Share a link (at the current time once it plays)"><i class="fa-solid fa-share-nodes" aria-hidden="true"></i> Share</button>${moreItems.length ? actionMenu({ icon: 'fa-solid fa-ellipsis', label: 'More', iconOnly: true, cls: 'ovv-more-menu', body: menuLinks(moreItems) }) : ''}</div>
++<div class="ovv-actions">${manageable ? editButton('recording') : ''}${saveForm}${ownOnly ? '' : clipAction(req, id, vod)}${downloadMenu({ kind: 'vod', id, item: vod, has: { transcript: segs.length, chat: chatRows ? chatRows.rows.length + chatRows.leftOut : 0, chapters: mems.length } })}${sharePanel({ url: `${base}/v/${id}`, id, at: Number(req.query.t) || null })}${moreItems.length ? actionMenu({ icon: 'fa-solid fa-ellipsis', label: 'More', iconOnly: true, cls: 'ovv-more-menu', body: menuLinks(moreItems) }) : ''}</div>
</div>
${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>` : ''}
</div>
@@@ -1293,11 +1289,18 @@ ${clips.length ? html`<section class="ovv-panel ovv-clips" aria-labelledby="h-cl
if (clip && clip.ai_overview) clip.ai_overview = noLooks(clip.ai_overview).trim() || null;
const manageable = mayManage(req, 'clip', clip);
-- // The comment thread, the playlist form and up next are asked together.
-- const [cm, saveForm, up] = await Promise.all([
++ // The comment thread, the playlist form, up next and the recording this clip was cut from are asked together.
++ // The source recording (the market outcomes: a clip credits its source video and links back to it): Live's own
++ // answer gives its title, its channel and the day it was streamed. 'down' when Live could not be asked, null
++ // when it says there is no such public recording.
++ const sourceAnswer = clip.vod_id && live.isId(String(clip.vod_id))
++ ? live.vod(String(clip.vod_id)).then((s) => (s && s.vod) || null).catch(() => 'down')
++ : Promise.resolve('none');
++ const [cm, saveForm, up, sourceVod] = await Promise.all([
Promise.resolve().then(() => live.comments('clip', id)).catch(() => null),
playlistForm(req, 'clip', id),
upNext(req, 'clip', id),
++ sourceAnswer,
]);
let threadUrl = null;
try { threadUrl = httpsUrl(cm && cm.thread && cm.thread.url); } catch { threadUrl = null; }
@@@ -1313,6 +1316,25 @@ ${clips.length ? html`<section class="ovv-panel ovv-clips" aria-labelledby="h-cl
const madeBy = clip.display_name || clip.username || 'an OpenVibe member';
const attribution = clip.auto_generated ? `AI clip from ${sourceName}'s stream` : `Made by ${madeBy}`;
++ // The recording this clip was cut from: its title, its channel and the moment the clip starts in it.
++ const sourceWho = sourceVod && sourceVod !== 'down' ? (sourceVod.display_name || sourceVod.username) : null;
++ const sourceTitle = sourceVod && sourceVod !== 'down' && sourceVod.title ? String(sourceVod.title) : null;
++ const clipAt = clip.start_time != null ? clock(Number(clip.start_time)) : null;
++ const clipCredit = !clip.vod_id ? ''
++ : sourceVod === 'down'
++ ? html`<p class="ovv-source"><i class="fa-solid fa-film" aria-hidden="true"></i> Cut from a stream recording. OpenVibe.Live did not answer just now, so its title is not shown.</p>`
++ : !sourceVod
++ ? html`<p class="ovv-source"><i class="fa-solid fa-film" aria-hidden="true"></i> Cut from a stream recording that is not public any more.</p>`
++ : html`<p class="ovv-source"><i class="fa-solid fa-film" aria-hidden="true"></i> Cut from <a href="${fullRecording}">${sourceTitle || 'the recording it came from'}</a>${sourceWho ? html`, ${sourceWho}'s stream recording` : ''}${clipAt ? html`. The clip starts at ${clipAt} in it` : ''}.</p>`;
++ const what = whatItIs({
++ kind: 'clip',
++ label: aiClip ? 'AI clip' : 'Clip',
++ streamedAt: sourceVod && sourceVod !== 'down' ? streamDate(sourceVod.stream_started_at) : null,
++ publishedAt: clip.created_at,
++ durationSeconds: clip.duration_seconds != null ? clip.duration_seconds : clip.duration,
++ views: Number(clip.view_count) || 0,
++ });
++
const nextAttr = up.nextUrl ? html` data-ovv-next="${up.nextUrl}"` : '';
const viewAttr = html` data-ovv-view="${livePublic}/api/views/clip/${id}"`;
const hlsAttr = hlsAttrs(clip);
@@@ -1343,10 +1365,12 @@ ${cutting ? html`<div class="ovv-player-wrap ovv-cutting" role="status" data-ovv
: html`<div class="ovv-player-wrap"><video class="ovv-player" data-ov-player controls preload="metadata" playsinline${posterAttr(httpsUrl(clip.thumbnail_url))} src="${fileOf(clip) || ''}" data-ovv-video${nextAttr}${viewAttr}${hlsAttr}></video></div>`}
<div class="ovv-head">
<h1 class="ovv-title">${clip.title || 'Clip'}</h1>
++${what}
++${clipCredit}
<div class="ovv-head-row">
<div class="ovv-byline"><div class="ovv-byline-text"><span class="ovv-channel-name">${clip.auto_generated ? html`<span class="ovv-tag ovv-tag--inline">AI clip</span> ` : ''}${attribution}</span>
--${clip.created_at ? html`<span class="ovv-meta"><time datetime="${clip.created_at}">${dateLabel(clip.created_at)}</time>${clip.duration_seconds != null ? html` · ${durationLabel(clip.duration_seconds)}` : ''}${manageable ? html` · ${visibilityBadge(clip.visibility)}` : ''}</span>` : ''}</div></div>
--<div class="ovv-actions">${manageable ? editButton('clip') : ''}${fullRecording ? html`<a class="ovv-btn ovv-btn--primary" href="${fullRecording}" title="Watch the recording it was cut from, from this moment"><i class="fa-solid fa-film" aria-hidden="true"></i> Full recording</a>` : ''}${saveForm}${cutting ? '' : downloadMenu({ kind: 'clip', id, item: clip, has: { transcript: segs.length } })}<button type="button" class="ovv-btn" hidden data-ovv-share="${base}/c/${id}" title="Share a link"><i class="fa-solid fa-share-nodes" aria-hidden="true"></i> Share</button>${moreItems.length ? actionMenu({ icon: 'fa-solid fa-ellipsis', label: 'More', iconOnly: true, cls: 'ovv-more-menu', body: menuLinks(moreItems) }) : ''}</div>
++${clip.created_at ? html`<span class="ovv-meta"><time datetime="${clip.created_at}">${dateLabel(clip.created_at)}</time>${manageable ? html` · ${visibilityBadge(clip.visibility)}` : ''}</span>` : ''}</div></div>
++<div class="ovv-actions">${manageable ? editButton('clip') : ''}${fullRecording ? html`<a class="ovv-btn ovv-btn--primary" href="${fullRecording}" title="Watch the recording it was cut from, from this moment"><i class="fa-solid fa-film" aria-hidden="true"></i> Full recording</a>` : ''}${saveForm}${cutting ? '' : downloadMenu({ kind: 'clip', id, item: clip, has: { transcript: segs.length } })}${sharePanel({ url: `${base}/c/${id}`, id, at: Number(req.query.t) || null, what: 'clip' })}${moreItems.length ? actionMenu({ icon: 'fa-solid fa-ellipsis', label: 'More', iconOnly: true, cls: 'ovv-more-menu', body: menuLinks(moreItems) }) : ''}</div>
</div>
</div>
</div>
ddiff --git a/server/video/upload-routes.js b/server/video/upload-routes.js
iindex 70a8e15..6f4a700 100644
---- a/server/video/upload-routes.js
++++ b/server/video/upload-routes.js
@@@ -131,7 +131,7 @@ function createUploadApi({ ctx, config }) {
/** The pages; `h` carries the video site's helpers (page, notFound, signedIn, ownerOf, mustSignIn, sameSite, isStaff, cards). */
function mountUploadPages(r, h) {
-- const { ctx, page, notFound, signedIn, ownerOf, mustSignIn, sameSite, isStaff, visibilityOptions, visibilityLabel, durationLabel, dateLabel, httpsUrl, fileOf, hlsAttrs, grid, card, renderCaptionsVtt, renderChaptersVtt, vttTime, sendVtt, notFoundVtt, playlistForm, upNext } = h;
++ const { ctx, page, notFound, signedIn, ownerOf, mustSignIn, sameSite, isStaff, visibilityOptions, visibilityLabel, durationLabel, dateLabel, httpsUrl, fileOf, hlsAttrs, grid, card, renderCaptionsVtt, renderChaptersVtt, vttTime, sendVtt, notFoundVtt, playlistForm, upNext, whatItIs, sharePanel } = h;
const up = ctx.videoUploads;
const cfg = ctx.config.videoUploads;
const whoOf = (req) => ({ requester: signedIn(req) ? ownerOf(req) : null, staff: isStaff(req) });
@@@ -246,7 +246,14 @@ ${fresh.map((x) => html`<tr><td><a href="/u/${x.id}">${x.title}</a></td><td>${st
if (ready && !own) await up.view(row);
const listed = ready && row.state === 'active' && row.visibility === 'public';
const channel = row.owner_username;
-- const metaParts = [row.ready_at ? html`<time datetime="${row.ready_at}">${dateLabel(row.ready_at)}</time>` : null, row.duration_seconds ? durationLabel(row.duration_seconds) : null, `${Number(row.views) || 0} views`].filter(Boolean);
++ // What this video is (the market outcomes): an upload, the day it was uploaded, how long it is, how many people
++ // watched it. The channel is in the byline under the title.
++ const what = whatItIs({
++ kind: 'upload',
++ publishedAt: row.created_at,
++ durationSeconds: row.duration_seconds,
++ views: Number(row.views) || 0,
++ });
const thumb = httpsUrl(row.thumbnail_url);
if (row.status === 'processing') res.set('Refresh', '15'); // the page reloads itself until Media is done
// Playlists (kind upload, by seq): a ready video anyone else may watch can be saved, and ?list= plays the next one.
@@@ -279,11 +286,12 @@ ${ready && vod ? html`<div class="ovv-player-wrap"><video class="ovv-player" dat
: row.status === 'failed' ? html`<p>This upload could not be processed. ${row.error || ''}</p>`
: html`<p>The upload stopped before the end (${row.parts_received} of ${row.parts_expected} parts). Upload the file again from <a href="/upload">Upload</a>.</p>`}</div>`}
<h1 class="ovv-title">${row.title}</h1>
++${what}
<div class="ovv-byline">
${channel ? html`<a class="ovv-avatar" href="/@${channel}" aria-hidden="true" tabindex="-1">${channel.slice(0, 1).toUpperCase()}${/^[A-Za-z0-9_]{1,32}$/.test(channel) ? html`<img src="${ctx.config.networkUrl}/avatar/${channel}" alt="" loading="lazy">` : ''}</a>` : ''}
<div class="ovv-byline-text">${channel ? html`<a class="ovv-channel-name" href="/@${channel}">@${channel}</a>` : ''}
--${metaParts.length ? html`<span class="ovv-meta">${metaParts.map((p, i) => (i === 0 ? p : [html` · `, p]))}</span>` : ''}</div>
--${saveForm ? html`<div class="ovv-byline-actions">${saveForm}</div>` : ''}
++</div>
++${saveForm || sharePanel ? html`<div class="ovv-byline-actions">${saveForm}${sharePanel ? sharePanel({ url: `${ctx.config.video.url}/u/${row.id}`, id: row.id, at: Number(req.query.t) || null }) : ''}</div>` : ''}
</div>
${row.description ? html`<section class="ovv-panel ovv-description"><p>${row.description}</p></section>` : ''}
${aiDone && ai.overview ? html`<section class="ovv-panel" aria-labelledby="h-ai"><h2 id="h-ai">About this video</h2><p>${ai.overview}</p><p class="ovv-fine">Written by AI from the video's sound and pictures (OpenVibe.AI, run on OpenVibe's own servers).</p>
ddiff --git a/server/video/watch-ui.js b/server/video/watch-ui.js
iindex fe3a00a..3acafcb 100644
---- a/server/video/watch-ui.js
++++ b/server/video/watch-ui.js
@@@ -11,6 +11,11 @@
* companion({ … }) chat replay, chapters and the transcript as one panel beside the player, as tabs (video.js
* turns them on; without it the three are listed one after another, each with its heading)
*
++ * whatItIs({ … }) the one line under a watch page's title that says what the video is: a stream recording, a clip
++ * or an upload, when it was streamed or published, how long it is and how many people watched
++ * sharePanel({ … }) the share box: the page's own link, and a link to the moment it is playing (no clip, nothing
++ * stored)
++ *
* Times read the way a player shows them (1:57, or 1:02:05 in a video an hour or longer), not 00:01:57.
*/
const { html } = require('../render/html');
@@@ -24,6 +29,103 @@ function clockOf(seconds, long = false) {
return h || long ? `${h}:${String(m).padStart(2, '0')}:${s}` : `${m}:${s}`;
}
++/** 31 → "31 seconds", 754 → "13 minutes", 7500 → "2 hours 5 minutes". */
++function lengthWords(s) {
++ const n = Math.max(0, Math.round(Number(s) || 0));
++ if (!n) return '';
++ const plural = (v, unit) => `${v} ${unit}${v === 1 ? '' : 's'}`;
++ if (n < 60) return plural(n, 'second');
++ if (n < 3600) return plural(Math.round(n / 60), 'minute');
++ const h = Math.floor(n / 3600);
++ const m = Math.round((n % 3600) / 60);
++ return m ? `${plural(h, 'hour')} ${plural(m, 'minute')}` : plural(h, 'hour');
++}
++
++/** "Oct 12, 2026" for a date (a Date, an ISO string or Live's own time), in the server's timezone. */
++function dayLabel(v) {
++ if (!v) return '';
++ const d = v instanceof Date ? v : new Date(String(v));
++ if (Number.isNaN(d.getTime())) return '';
++ return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
++}
++
++/**
++ * A stream's own time (Live's 'YYYY-MM-DD HH:MM:SS', which is UTC) as a Date. Anything with a timezone marker in it is
++ * read as it is. Null when there is no time.
++ */
++function streamDate(v) {
++ const s = String(v == null ? '' : v).trim();
++ if (!s) return null;
++ const iso = /[TtZz]/.test(s) ? s : `${s.replace(' ', 'T')}Z`;
++ const t = Date.parse(iso);
++ return Number.isFinite(t) ? new Date(t) : null;
++}
++
++/** What a kind of video is called on the page. */
++const KINDS = {
++ recording: { icon: 'fa-solid fa-tower-broadcast', label: 'Stream recording' },
++ clip: { icon: 'fa-solid fa-scissors', label: 'Clip' },
++ upload: { icon: 'fa-solid fa-upload', label: 'Upload' },
++};
++
++/**
++ * The one line under a watch page's title that says what the video is: the kind (a stream recording, a clip or an
++ * upload), when it was streamed or published, how long it is and how many people watched it.
++ *
++ * Every word comes from the item's own fields: the date is the stream's own start (a recording) or the moment the
++ * item was made (a clip, an upload), the length is the file's, the views are the count Live keeps. A fact that is
++ * not there is left out rather than guessed.
++ *
++ * kind: 'recording' | 'clip' | 'upload'
++ * streamedAt: when it was streamed (a recording, or a clip's source recording)
++ * publishedAt: when it was published or cut (the item's own created_at)
++ */
++function whatItIs({ kind, label = null, streamedAt = null, publishedAt = null, durationSeconds = null, views = null }) {
++ const k = KINDS[kind] || KINDS.recording;
++ const facts = [];
++ const streamed = streamedAt ? dayLabel(streamedAt) : '';
++ if (streamed) facts.push(html`Streamed on <time datetime="${isoOf(streamedAt)}">${streamed}</time>`);
++ else if (publishedAt) {
++ const day = dayLabel(publishedAt);
++ if (day) facts.push(html`${kind === 'upload' ? 'Uploaded' : kind === 'clip' ? 'Cut' : 'Published'} on <time datetime="${isoOf(publishedAt)}">${day}</time>`);
++ }
++ const len = lengthWords(durationSeconds);
++ if (len) facts.push(len);
++ const v = Number(views);
++ if (Number.isSafeInteger(v) && v > 0) facts.push(`${v.toLocaleString('en-US')} ${v === 1 ? 'view' : 'views'}`);
++ return html`<p class="ovv-what"><span class="ovv-what-kind"><i class="${k.icon}" aria-hidden="true"></i> ${label || k.label}</span>${facts.length ? html`<span class="ovv-what-facts">${facts.map((f, i) => (i === 0 ? f : [html` · `, f]))}</span>` : ''}</p>`;
++}
++
++/** A date as the value of a <time> element's datetime. */
++function isoOf(v) {
++ if (v instanceof Date) return Number.isNaN(v.getTime()) ? '' : v.toISOString();
++ return String(v == null ? '' : v);
++}
++
++/**
++ * The share box: the page's own link, and a link to the moment the video is playing. The link is written out in a
++ * field, so it can be copied without JavaScript; the buttons (copy, copy at this moment) appear when JavaScript is
++ * there. Nothing is stored and no clip is made: it is the page's address with a time on it.
++ *
++ * url: the page's own address (with ?t= when the page was opened at a moment)
++ * at: the moment in the video the page was opened at (seconds), or null
++ */
++function sharePanel({ url, id, at = null, what = 'video' }) {
++ const field = `ovv-share-${id}`;
++ const withTime = at != null && Number(at) > 0 ? `${url}?t=${Math.floor(Number(at))}` : url;
++ return html`<details class="ovv-pop ovv-share" data-ovv-menu><summary class="ovv-btn" title="Share a link to this ${what}"><i class="fa-solid fa-share-nodes" aria-hidden="true"></i> Share</summary>
++<div class="ovv-pop-panel" role="group" aria-label="Share this ${what}">
++<p class="ovv-pop-title">Share this ${what}</p>
++<label class="sr-only" for="${field}">The link to this video</label>
++<input class="ovv-share-url" id="${field}" type="text" readonly value="${withTime}">
++<p class="ovv-fine">A plain link: it opens this page. Put <code>?t=125</code> on the end and the video opens at 2:05 — a moment, not a clip: nothing is stored and nothing is cut.</p>
++<p class="ovv-share-buttons">
++<button type="button" class="ovv-btn ovv-btn--small" hidden data-ovv-share="${url}" data-ovv-share-field="${field}">Copy the link</button>
++<button type="button" class="ovv-btn ovv-btn--small" hidden data-ovv-share-at="${url}" data-ovv-share-field="${field}">Copy the link at this moment</button>
++</p>
++</div></details>`;
++}
++
/** A button of the action row that opens a menu or a small form. `body` is the panel's content (html). */
function actionMenu({ icon, label, title = null, body, cls = '', iconOnly = false }) {
return html`<details class="ovv-pop ${cls}" data-ovv-menu><summary class="ovv-btn${iconOnly ? ' ovv-btn--icon' : ''}" title="${title || label}"${iconOnly ? html` aria-label="${label}"` : ''}><i class="${icon}" aria-hidden="true"></i>${iconOnly ? '' : html` ${label}`}</summary>
@@@ -98,7 +200,7 @@ function companion({ kind, id, chat, chapters, transcript, long }) {
if (transcript && transcript.length) {
tabs.push({
key: 'transcript', tab: 'Transcript', title: 'Transcript', count: transcript.length,
-- note: 'Written by AI from the audio',
++ note: 'Written by AI from the audio — it can be wrong',
search: true,
body: html`<div class="ovv-tp-body ovv-transcript-body" data-ovv-transcript>${transcript.map((s) => html`<p class="ovv-line" data-ovv-at="${Math.floor(s.start)}"><button type="button" class="ovv-seek" data-ovv-seek="${s.start}" title="Play from here">${clockOf(s.start, long)}</button> <span>${s.text}</span></p>`)}<p class="ovv-tp-empty" hidden data-ovv-search-empty>No line has that.</p></div>`,
});
@@@ -115,4 +217,4 @@ ${t.body}
</section>`;
}
--module.exports = { clockOf, actionMenu, menuLinks, cornerMenu, downloadsFor, companion };
++module.exports = { clockOf, lengthWords, dayLabel, streamDate, whatItIs, sharePanel, actionMenu, menuLinks, cornerMenu, downloadsFor, companion };
ddiff --git a/test/video-downloads.test.js b/test/video-downloads.test.js
iindex df5bd1a..59301ef 100644
---- a/test/video-downloads.test.js
++++ b/test/video-downloads.test.js
@@@ -148,7 +148,7 @@ function unzip(buf) {
'/v/6026/download/chapters.txt', '/v/6026/download/chapters.vtt', '/v/6026/download/chapters.json', '/v/6026/download/thumbnail',
'/v/6026/download/info.json', '/v/6026/download/all.zip']) assert.ok(menu.includes(`href="${href}"`), href);
assert.ok(menu.includes('The whole recording · 571 MB'), 'the video with its size');
-- assert.ok(menu.includes('2 lines, by AI') && menu.includes('2 messages') && menu.includes('2 chapters'), 'what each holds');
++ assert.ok(menu.includes('2 lines, written by AI') && menu.includes('2 messages') && menu.includes('2 chapters'), 'what each holds');
assert.ok(/rel="nofollow"/.test(menu) && /download rel/.test(menu), 'download links, not followed by crawlers');
// Each tab's own download menu sits in its corner (one quiet icon), with Copy for YouTube on the chapters.
const panel = (key) => { const i = r.text.indexOf(`data-ovv-tp="${key}"`); return r.text.slice(i, r.text.indexOf('</section>', i)); };
ddiff --git a/test/video-outcomes.test.js b/test/video-outcomes.test.js
nnew file mode 100644
iindex 0000000..68f637e
---- /dev/null
++++ b/test/video-outcomes.test.js
@@@ -0,0 +1,216 @@
++'use strict';
++/**
++ * OpenVibe.Video, the market outcomes of 2026-10-30 (research/outcomes/video.md), on the watch pages:
++ *
++ * 1 the page says what the video is: a stream recording, a clip or an upload, the day it was streamed, its length
++ * 2 a clip credits the recording it was cut from and links back to it
++ * 3 a plain timestamp link: the page's own address, with a time on it (a moment, not a clip, nothing stored)
++ * 7 the exports say what they are for, and the transcript says it was written by AI
++ *
++ * Every number on the page comes from Live's own answer (or the upload's own row): the page never says a day, a length
++ * or a count it was not given. Live is a stub here, so each fact can be checked against the stub.
++ */
++const assert = require('assert');
++const http = require('http');
++const { boot, check, done } = require('./helpers/boot');
++
++/** A request with Host: openvibe.video (Node's fetch drops a Host header). */
++function videoClient(t) {
++ return (p, o = {}) => new Promise((resolve, reject) => {
++ const headers = { Host: 'openvibe.video', ...(o.headers || {}) };
++ if (o.as) headers.cookie = t.signIn(o.as);
++ const u = new URL(t.base + p);
++ const req = http.request({ hostname: u.hostname, port: u.port, path: u.pathname + u.search, method: o.method || 'GET', headers }, (res) => {
++ const chunks = [];
++ res.on('data', (c) => chunks.push(c));
++ res.on('end', () => { const text = Buffer.concat(chunks).toString('utf8'); resolve({ status: res.statusCode, text, json: () => JSON.parse(text) }); });
++ });
++ req.on('error', reject);
++ req.end();
++ });
++}
++
++/** OpenVibe.Live, as a stub: one recording with a stream time, one without, and two clips. */
++function startLive() {
++ const recording = {
++ id: 501, title: 'A long recording', description: 'A stream', duration_seconds: 754,
++ created_at: '2026-10-12T10:00:00.000Z', thumbnail_url: 'https://openvibe.media/t/501.jpg',
++ file_url: 'https://openvibe.media/v/501', playback_url: 'https://openvibe.media/v/501', visibility: 'public',
++ username: 'streamer', display_name: 'Streamer', view_count: 1204, is_recording: false,
++ ai_overview: 'An overview.', stream_category: 'Gaming', stream_id: 9001,
++ stream_started_at: '2026-10-12 09:30:00', stream_ended_at: '2026-10-12 09:42:34',
++ ai_transcript_json: [{ start: 0, end: 2, text: 'Hello there.' }, { start: 2, end: 4, text: 'And again.' }],
++ };
++ // A recording from before Live kept the stream's own times: it has no day to be streamed on.
++ const oldRecording = {
++ ...recording, id: 502, title: 'An older recording', stream_started_at: null, stream_id: null,
++ duration_seconds: 45, view_count: 0, ai_overview: '', ai_transcript_json: null,
++ };
++ const clip = {
++ id: 601, vod_id: 501, title: 'A clip of it', start_time: 65, end_time: 95, duration_seconds: 30,
++ file_url: 'https://openvibe.media/c/601', playback_url: 'https://openvibe.media/c/601', visibility: 'public',
++ auto_generated: false, username: 'clipper', display_name: 'Clipper', created_at: '2026-10-13T09:00:00.000Z',
++ source_streamer_username: 'streamer', source_streamer_display_name: 'Streamer', ai_overview: 'A clip overview',
++ view_count: 20,
++ };
++ // A clip whose recording is not public any more (Live answers 404 for it).
++ const orphanClip = { ...clip, id: 602, vod_id: 999, title: 'A clip of a private recording' };
++
++ const server = http.createServer((req, res) => {
++ const url = new URL(req.url, 'http://x');
++ const p = url.pathname;
++ const json = (status, body) => {
++ const text = JSON.stringify(body);
++ res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(text) });
++ res.end(text);
++ };
++ const vod = /^\/api\/vods\/(\d+)$/.exec(p);
++ if (vod) {
++ if (vod[1] === '501') return json(200, { vod: recording, clips: [] });
++ if (vod[1] === '502') return json(200, { vod: oldRecording, clips: [] });
++ return json(404, { error: 'not found' });
++ }
++ const cl = /^\/api\/clips\/(\d+)$/.exec(p);
++ if (cl) {
++ if (cl[1] === '601') return json(200, { clip });
++ if (cl[1] === '602') return json(200, { clip: orphanClip });
++ return json(404, { error: 'not found' });
++ }
++ if (/^\/api\/vods\/\d+\/memories$/.test(p)) return json(200, { memories: [{ offset_seconds: 10, description: 'The start' }] });
++ if (/^\/api\/comments\/(vod|clip)\/\d+$/.test(p)) return json(200, { comments: [], total: 0, thread: { id: 'thr', url: 'https://openvibe.community/t/x' } });
++ if (/^\/api\/chat\/\d+\/replay$/.test(p)) return json(200, { messages: [
++ { id: 'm1', timestamp: '2026-10-12 09:31:00', username: 'viewer', display_name: 'Viewer', message: 'Hello', message_type: 'chat' },
++ { id: 'm2', timestamp: '2026-10-12 09:32:00', username: 'viewer2', display_name: 'Viewer Two', message: 'Again', message_type: 'chat' },
++ ] });
++ if (p === '/api/streams') return json(200, { streams: [] });
++ if (p === '/api/content/feed') return json(200, { items: [], next: null });
++ if (p === '/api/vods' || p === '/api/clips') return json(200, { vods: [], clips: [], total: 0, hasMore: false });
++ if (p === '/api/v1/search') return json(200, { results: [] });
++ return json(404, { error: 'not found' });
++ });
++ return new Promise((resolve) => {
++ server.listen(0, '127.0.0.1', () => resolve({
++ url: `http://127.0.0.1:${server.address().port}`,
++ close: () => new Promise((r) => server.close(r)),
++ }));
++ });
++}
++
++(async () => {
++ const live = await startLive();
++ const t = await boot({
++ env: {
++ MEDIAHUB_VIDEO_URL: 'https://openvibe.video',
++ MEDIAHUB_LIVE_URL: live.url,
++ MEDIAHUB_LIVE_PUBLIC_URL: 'https://openvibe.live',
++ MEDIAHUB_CHAT_URL: live.url,
++ MEDIAHUB_SEARCH_URL: live.url,
++ MEDIAHUB_VIDEO_SEARCH: 'off',
++ },
++ });
++ const get = videoClient(t);
++ try {
++ await check('outcome 1: a recording says what it is: a stream recording, the day, the length, the views', async () => {
++ const r = await get('/v/501');
++ assert.strictEqual(r.status, 200);
++ const what = /<p class="ovv-what">([\s\S]*?)<\/p>/.exec(r.text);
++ assert.ok(what, 'the page says what the video is');
++ const line = what[1];
++ assert.ok(line.includes('Stream recording'), line);
++ assert.ok(line.includes('Streamed on') && line.includes('Oct 12, 2026'), 'the day it was streamed: ' + line);
++ assert.ok(line.includes('13 minutes'), 'its length, in words: ' + line);
++ assert.ok(line.includes('1,204 views'), 'its views: ' + line);
++ assert.ok(/<time datetime="2026-10-12T09:30:00.000Z">/.test(line), 'the day in a machine-readable time: ' + line);
++ // The channel is in the byline under it, and the category in About: nothing is said twice.
++ assert.ok(r.text.includes('class="ovv-channel-name"') && r.text.includes('@Streamer'), 'the channel');
++ assert.ok(r.text.includes("Streamed live on") && r.text.includes('in Gaming'), 'the category is in About');
++ });
++
++ await check('outcome 1: a recording with no stream time says when it was published, and guesses nothing', async () => {
++ const r = await get('/v/502');
++ assert.strictEqual(r.status, 200);
++ const line = /<p class="ovv-what">([\s\S]*?)<\/p>/.exec(r.text)[1];
++ assert.ok(line.includes('Published on') && line.includes('Oct 12, 2026'), line);
++ assert.ok(!line.includes('Streamed on'), 'no day it was streamed: Live never said one');
++ assert.ok(!line.includes('views'), 'no views: Live counted none');
++ assert.ok(line.includes('45 seconds'), 'the length it does have: ' + line);
++ });
++
++ await check('outcome 1: a clip says what it is, and an upload says it is an upload', async () => {
++ const clip = await get('/c/601');
++ const line = /<p class="ovv-what">([\s\S]*?)<\/p>/.exec(clip.text)[1];
++ assert.ok(line.includes('Clip</span>') && !line.includes('AI clip'), 'a clip: ' + line);
++ assert.ok(line.includes('30 seconds') && line.includes('20 views'), line);
++ // The upload's own row: the day it was uploaded, its length and its views.
++ const s = t.ctx.s;
++ const id = 'vup_01J0000000000000000000000B';
++ t.media.vods.set(950, { id: 950, title: 'A home video', visibility: 'public', bytes: 1000, types: ['video/mp4'], status: 'ready' });
++ await s.db.exec(`INSERT INTO video_uploads (id, owner, owner_username, title, description, visibility, media_vod_id, content_type, size_bytes, part_size, parts_expected, parts_received, status, state, created_at, updated_at, ready_at, duration_seconds, views)
++ VALUES ($1, 'user:nobody', 'kim', 'A home video', '', 'public', 950, 'video/mp4', 1000, 1000, 1, 1, 'ready', 'active', '2026-10-20T10:00:00.000Z', '2026-10-20T10:00:00.000Z', '2026-10-20T10:00:00.000Z', 125, 0)`,
++ [id]);
++ const up = await get(`/u/${id}`);
++ assert.strictEqual(up.status, 200);
++ const uline = /<p class="ovv-what">([\s\S]*?)<\/p>/.exec(up.text)[1];
++ assert.ok(uline.includes('Upload') && uline.includes('Uploaded on') && uline.includes('Oct 20, 2026'), uline);
++ assert.ok(uline.includes('2 minutes'), 'its length: ' + uline);
++ });
++
++ await check('outcome 2: a clip credits the recording it was cut from and links back to it', async () => {
++ const r = await get('/c/601');
++ const credit = /<p class="ovv-source">([\s\S]*?)<\/p>/.exec(r.text);
++ assert.ok(credit, 'the clip credits its source');
++ const line = credit[1];
++ assert.ok(line.includes('Cut from'), line);
++ assert.ok(line.includes('A long recording'), 'the title of the recording it came from: ' + line);
++ assert.ok(line.includes('href="/v/501?t=65"'), 'a link back to the recording, at the clip\'s moment: ' + line);
++ assert.ok(line.includes("Streamer's stream recording"), 'whose recording it was: ' + line);
++ assert.ok(line.includes('starts at 1:05 in it'), 'where the clip starts in it: ' + line);
++ });
++
++ await check('outcome 2: a clip whose recording is not public says so, and names nothing it cannot know', async () => {
++ const r = await get('/c/602');
++ assert.strictEqual(r.status, 200);
++ const line = /<p class="ovv-source">([\s\S]*?)<\/p>/.exec(r.text)[1];
++ assert.ok(line.includes('not public any more'), line);
++ assert.ok(!line.includes('A long recording'), 'no title from a recording we cannot see');
++ });
++
++ await check('outcome 3: the page carries a plain link, and says how to make a link to a moment', async () => {
++ const r = await get('/v/501');
++ const field = /<input class="ovv-share-url"[^>]*value="([^"]*)"/.exec(r.text);
++ assert.ok(field, 'the link is written out on the page');
++ assert.strictEqual(field[1], 'https://openvibe.video/v/501');
++ const panel = r.text.slice(r.text.indexOf('ovv-share'), r.text.indexOf('</details>', r.text.indexOf('ovv-share')));
++ assert.ok(panel.includes('?t=125'), 'the panel says how to link to a moment: ' + panel);
++ assert.ok(panel.includes('nothing is stored'), 'a moment is not a clip: ' + panel);
++ assert.ok(panel.includes('data-ovv-share-at="https://openvibe.video/v/501"'), 'the button that copies the link at the moment it plays');
++ // The page opened at a moment: the field shows that link.
++ const at = await get('/v/501?t=125');
++ assert.strictEqual(/<input class="ovv-share-url"[^>]*value="([^"]*)"/.exec(at.text)[1], 'https://openvibe.video/v/501?t=125');
++ // An upload's watch page carries the same plain link.
++ const up = await get('/u/vup_01J0000000000000000000000B');
++ assert.strictEqual(up.status, 200);
++ assert.ok(/<input class="ovv-share-url"[^>]*value="https:\/\/openvibe\.video\/u\/vup_01J0000000000000000000000B"/.test(up.text), 'the upload page has the link too');
++ });
++
++ await check('outcome 7: every export says what it is for, and the transcript says it was written by AI', async () => {
++ const r = await get('/v/501');
++ const menu = r.text.slice(r.text.indexOf('<details class="ovv-dl"'), r.text.indexOf('</details>', r.text.indexOf('<details class="ovv-dl"')));
++ assert.ok(menu.includes('Transcript') && menu.includes('2 lines, written by AI'), 'the transcript says it is AI-made');
++ for (const [ext, why] of [['TXT', 'to read'], ['SRT', 'for editors'], ['VTT', 'for web players'], ['JSON', 'for programs']]) {
++ assert.ok(new RegExp(`<span>${ext}</span><small>${why}</small>`).test(menu), `${ext} says what it is for (${why})`);
++ }
++ // The chat replay's own exports: the same, in its own menu.
++ const chat = /<a class="ovv-mini-item"[^>]*href="\/v\/501\/download\/chat\.csv"[\s\S]*?<\/a>/.exec(r.text);
++ assert.ok(chat, 'the chat replay is exported as a spreadsheet');
++ assert.ok(/Opens in Excel or Sheets/.test(chat[0]), 'and says so: ' + chat[0]);
++ // The transcript panel itself.
++ assert.ok(r.text.includes('Written by AI from the audio'), 'the transcript panel says it was written by AI');
++ assert.ok(r.text.includes('data-ovv-tp="transcript"'), 'the transcript panel is there');
++ });
++ } finally {
++ await t.close();
++ await live.close();
++ }
++ done();
++})().catch((err) => { console.error(err); process.exit(1); });