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)
Changes
@@@ -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 writtenabout it is noindex and out of the sitemap: 118 of 785 were under a minute, mostly test streams. The sitemap lists@@@ -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; }}@@@ -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') || ''));});});}@@@ -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>`;}@@@ -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="ovvconst 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-clif (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-clconst 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>@@@ -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>${stif (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>@@@ -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 };@@@ -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)); };nnew file mode 100644@@@ -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); });
@@ -4,6 +4,15 @@ What changed in OpenVibe.MediaHub, newest first. Each site also publishes its pa
@@ -4,6 +4,15 @@ What changed in OpenVibe.MediaHub, newest first. Each site also publishes its pa
## Unreleased
## 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,
- 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
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
about it is noindex and out of the sitemap: 118 of 785 were under a minute, mostly test streams. The sitemap lists
@@ -225,7 +225,9 @@ a.ovv-channel-name:hover { text-decoration: underline; }
@@ -225,7 +225,9 @@ a.ovv-channel-name:hover { text-decoration: underline; }
.ovv-dl-label { font-size: .92rem; font-weight: 700; }
.ovv-dl-label { font-size: .92rem; font-weight: 700; }
.ovv-dl-about { font-size: .78rem; color: var(--ovv-dim); overflow-wrap: anywhere; }
.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-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: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 { 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); }
.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; }
@@ -338,6 +340,18 @@ a.ovv-channel-name:hover { text-decoration: underline; }
.ovv-btn--icon { width: 38px; padding: 0; justify-content: center; }
.ovv-btn--icon { width: 38px; padding: 0; justify-content: center; }
.ovv-btn[hidden] { display: none; }
.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. */
/* 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 { position: relative; }
.ovv-pop > summary { list-style: none; cursor: pointer; }
.ovv-pop > summary { list-style: none; cursor: pointer; }
@@ -458,6 +472,9 @@ a.ovv-channel-name:hover { text-decoration: underline; }
@@ -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. */
/* 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-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)); }
.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-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; }
.ovv-head .ovv-title { font-size: 1.2rem; }
}
}
@@ -242,28 +242,56 @@
@@ -242,28 +242,56 @@
if (window.location.hash.length > 1) open(window.location.hash.slice(1));
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) {
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) {
document.querySelectorAll('[data-ovv-share]').forEach(function (b) {
- if (!navigator.share && !navigator.clipboard) return;
b.hidden = false;
b.hidden = false;
- const label = b.innerHTML;
b.addEventListener('click', function () {
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) {
if (navigator.share && window.matchMedia('(hover: none)').matches) {
navigator.share({ title: title, url: url }).catch(function () { /* closed */ });
navigator.share({ title: title, url: url }).catch(function () { /* closed */ });
return;
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') || ''));
});
});
});
});
}
}
@@ -191,9 +191,10 @@ const FORMAT_HELP = {
@@ -191,9 +191,10 @@ const FORMAT_HELP = {
};
};
const CHAPTER_TXT_HELP = 'Times and titles, ready to paste into a YouTube description';
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 }) {
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). */
/** "570.7 MB" for a byte count (null when unknown). */
@@ -239,9 +240,19 @@ function downloadMenu({ kind, id, item, has }) {
@@ -239,9 +240,19 @@ function downloadMenu({ kind, id, item, has }) {
const ext = videoExt(item);
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 }] }));
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' }] }));
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' }] }));
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>`;
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 }) {
@@ -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-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>
<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>
<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>`;
</div></details>`;
}
}
@@ -43,7 +43,7 @@ const { createVideoApiRoutes } = require('./api');
@@ -43,7 +43,7 @@ const { createVideoApiRoutes } = require('./api');
const { createUploadApi, mountUploadPages } = require('./upload-routes');
const { createUploadApi, mountUploadPages } = require('./upload-routes');
const { ACCESS_COOKIE, REFRESH_COOKIE } = require('../auth/sso');
const { ACCESS_COOKIE, REFRESH_COOKIE } = require('../auth/sso');
const { mountDownloads, downloadMenu } = require('./downloads');
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 PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
const HOME_LIMIT = 10;
const HOME_LIMIT = 10;
@@ -129,15 +129,7 @@ function thinRecording(vod, transcriptLines = 0) {
@@ -129,15 +129,7 @@ function thinRecording(vod, transcriptLines = 0) {
}
}
/** 31 → "31 seconds", 754 → "13 minutes", 7500 → "2 hours 5 minutes". */
/** 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,
* 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) {
@@ -301,9 +293,7 @@ function watchTitle(title, who, iso) {
function dateLabel(iso) {
function dateLabel(iso) {
if (!iso) return '';
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. */
/** 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) {
function utcSeconds(v) {
@@ -746,7 +736,7 @@ ${visibilityChoices(item.visibility || 'public')}
@@ -746,7 +736,7 @@ ${visibilityChoices(item.visibility || 'public')}
r.use(express.urlencoded({ extended: false, limit: '64kb' }));
r.use(express.urlencoded({ extended: false, limit: '64kb' }));
// ── Uploads: /upload, /u/:id, /me/videos (server/video/upload-routes.js) ──
// ── 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) ──
// ── 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,
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
@@ -1200,10 +1190,15 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
const localChannel = vod.username ? `/@${vod.username}` : null;
const localChannel = vod.username ? `/@${vod.username}` : null;
const liveChannel = `${livePublic}/@${vod.username}`;
const liveChannel = `${livePublic}/@${vod.username}`;
const metaParts = [];
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));
if (manageable) metaParts.push(visibilityBadge(vod.visibility));
const nextAttr = up.nextUrl ? html` data-ovv-next="${up.nextUrl}"` : '';
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
@@ -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-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">
<div class="ovv-head">
<h1 class="ovv-title">${vod.title || 'Stream recording'}</h1>
<h1 class="ovv-title">${vod.title || 'Stream recording'}</h1>
+${what}
<div class="ovv-head-row">
<div class="ovv-head-row">
<div class="ovv-byline">
<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>` : ''}
${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>`) : ''}
<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>
${metaParts.length ? html`<span class="ovv-meta">${metaParts.map((p, i) => (i === 0 ? p : [html` · `, p]))}</span>` : ''}</div>
</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>
</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>` : ''}
${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>
</div>
@@ -1293,11 +1289,18 @@ ${clips.length ? html`<section class="ovv-panel ovv-clips" aria-labelledby="h-cl
@@ -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;
if (clip && clip.ai_overview) clip.ai_overview = noLooks(clip.ai_overview).trim() || null;
const manageable = mayManage(req, 'clip', clip);
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),
Promise.resolve().then(() => live.comments('clip', id)).catch(() => null),
playlistForm(req, 'clip', id),
playlistForm(req, 'clip', id),
upNext(req, 'clip', id),
upNext(req, 'clip', id),
+ sourceAnswer,
]);
]);
let threadUrl = null;
let threadUrl = null;
try { threadUrl = httpsUrl(cm && cm.thread && cm.thread.url); } catch { 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
@@ -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 madeBy = clip.display_name || clip.username || 'an OpenVibe member';
const attribution = clip.auto_generated ? `AI clip from ${sourceName}'s stream` : `Made by ${madeBy}`;
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 nextAttr = up.nextUrl ? html` data-ovv-next="${up.nextUrl}"` : '';
const viewAttr = html` data-ovv-view="${livePublic}/api/views/clip/${id}"`;
const viewAttr = html` data-ovv-view="${livePublic}/api/views/clip/${id}"`;
const hlsAttr = hlsAttrs(clip);
const hlsAttr = hlsAttrs(clip);
@@ -1343,10 +1365,12 @@ ${cutting ? html`<div class="ovv-player-wrap ovv-cutting" role="status" data-ovv
@@ -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>`}
: 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">
<div class="ovv-head">
<h1 class="ovv-title">${clip.title || 'Clip'}</h1>
<h1 class="ovv-title">${clip.title || 'Clip'}</h1>
+${what}
+${clipCredit}
<div class="ovv-head-row">
<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>
<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>
</div>
</div>
</div>
@@ -131,7 +131,7 @@ function createUploadApi({ ctx, config }) {
@@ -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). */
/** The pages; `h` carries the video site's helpers (page, notFound, signedIn, ownerOf, mustSignIn, sameSite, isStaff, cards). */
function mountUploadPages(r, h) {
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 up = ctx.videoUploads;
const cfg = ctx.config.videoUploads;
const cfg = ctx.config.videoUploads;
const whoOf = (req) => ({ requester: signedIn(req) ? ownerOf(req) : null, staff: isStaff(req) });
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
@@ -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);
if (ready && !own) await up.view(row);
const listed = ready && row.state === 'active' && row.visibility === 'public';
const listed = ready && row.state === 'active' && row.visibility === 'public';
const channel = row.owner_username;
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);
const thumb = httpsUrl(row.thumbnail_url);
if (row.status === 'processing') res.set('Refresh', '15'); // the page reloads itself until Media is done
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.
// 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
@@ -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>`
: 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>`}
: 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>
<h1 class="ovv-title">${row.title}</h1>
+${what}
<div class="ovv-byline">
<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>` : ''}
${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>` : ''}
<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>
</div>
${row.description ? html`<section class="ovv-panel ovv-description"><p>${row.description}</p></section>` : ''}
${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>
${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>
@@ -11,6 +11,11 @@
@@ -11,6 +11,11 @@
* companion({ … }) chat replay, chapters and the transcript as one panel beside the player, as tabs (video.js
* 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)
* 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.
* 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');
const { html } = require('../render/html');
@@ -24,6 +29,103 @@ function clockOf(seconds, long = false) {
@@ -24,6 +29,103 @@ function clockOf(seconds, long = false) {
return h || long ? `${h}:${String(m).padStart(2, '0')}:${s}` : `${m}:${s}`;
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). */
/** 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 }) {
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>
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 }) {
@@ -98,7 +200,7 @@ function companion({ kind, id, chat, chapters, transcript, long }) {
if (transcript && transcript.length) {
if (transcript && transcript.length) {
tabs.push({
tabs.push({
key: 'transcript', tab: 'Transcript', title: 'Transcript', count: transcript.length,
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,
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>`,
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}
@@ -115,4 +217,4 @@ ${t.body}
</section>`;
</section>`;
}
}
-module.exports = { clockOf, actionMenu, menuLinks, cornerMenu, downloadsFor, companion };
+module.exports = { clockOf, lengthWords, dayLabel, streamDate, whatItIs, sharePanel, actionMenu, menuLinks, cornerMenu, downloadsFor, companion };
@@ -148,7 +148,7 @@ function unzip(buf) {
@@ -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/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);
'/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('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');
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.
// 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)); };
const panel = (key) => { const i = r.text.indexOf(`data-ovv-tp="${key}"`); return r.text.slice(i, r.text.indexOf('</section>', i)); };
new file mode 100644
new file mode 100644
@@ -0,0 +1,216 @@
@@ -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); });