openvibers/openvibe.mediahub Commits

openvibe.video's home shows who is live now, the month's most watched, categories and channels with real counts, how it works and questions; each category has its own page (#105)

OP OpenVibers committed 22 days ago · 8ae08244ae · 6 files, +426 −21

Changes

ddiff --git a/public/css/video.css b/public/css/video.css
iindex e6e4531..222bcc8 100644
---- a/public/css/video.css
++++ b/public/css/video.css
@@@ -27,8 +27,45 @@
.ovv-lede { margin: 0; max-width: 62ch; font-size: 1.05rem; color: var(--ovv-dim); }
.ovv-hero-actions { display: flex; flex-wrap: wrap; gap: 10px; margin: 1.1rem 0 0; }
++/* Home: the numbers under the header (real counts from Live and Search; a count that is unknown is left out) */
++.ovv-stats { display: flex; flex-wrap: wrap; gap: 10px; margin: 1.4rem 0 0; padding: 0; }
++.ovv-stats > div { display: flex; flex-direction: column-reverse; min-width: 0; padding: 10px 16px; border: 1px solid var(--ovv-line); border-radius: 12px; background: var(--ovv-card); }
++.ovv-stats dt { margin: 0; font-size: .8rem; color: var(--ovv-dim); }
++.ovv-stats dd { margin: 0; font-size: 1.35rem; font-weight: 800; font-variant-numeric: tabular-nums; line-height: 1.2; }
++
/* Sections and card grids */
.ovv-section { margin-top: 2.2rem; }
++.ovv-section-head { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 4px 16px; margin: 0 0 .75rem; }
++.ovv-section-head h2 { margin: 0; }
++.ovv-section-head a { font-size: .9rem; font-weight: 700; }
++.ovv-section h2 { display: flex; align-items: center; gap: 8px; }
++.ovv-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--live-red, #ef4444); box-shadow: 0 0 0 4px color-mix(in srgb, var(--live-red, #ef4444) 25%, transparent); flex: none; }
++.ovv-tag.ovv-tag--live { background: color-mix(in srgb, var(--live-red, #ef4444) 70%, #000); text-transform: uppercase; letter-spacing: .4px; }
++
++/* Category tiles and channels: a link list that wraps into as many columns as fit */
++.ovv-shelf, .ovv-people, .ovv-more { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(min(200px, 100%), 1fr)); }
++.ovv-shelf-tile { display: grid; grid-template-columns: auto minmax(0, 1fr); grid-template-rows: auto auto; column-gap: 12px; align-items: center; padding: 14px 16px; border: 1px solid var(--ovv-line); border-radius: 12px; background: var(--ovv-card); color: inherit !important; text-decoration: none; }
++.ovv-shelf-tile > i { grid-row: 1 / span 2; display: grid; place-items: center; width: 40px; height: 40px; border-radius: 10px; background: color-mix(in srgb, var(--ovv-accent) 16%, transparent); color: var(--accent-light, var(--ovv-accent)); font-size: 1.1rem; }
++.ovv-shelf-name { font-weight: 700; overflow-wrap: anywhere; }
++.ovv-shelf-count, .ovv-person-count { font-size: .84rem; color: var(--ovv-dim); }
++.ovv-shelf-tile:hover, .ovv-shelf-tile:focus-visible, .ovv-person:hover, .ovv-person:focus-visible, .ovv-more a:hover, .ovv-more a:focus-visible { border-color: var(--ovv-accent); background: var(--bg-hover, #1c2a44); }
++.ovv-person { display: flex; align-items: center; gap: 12px; padding: 10px 14px; border: 1px solid var(--ovv-line); border-radius: 12px; background: var(--ovv-card); color: inherit !important; text-decoration: none; min-width: 0; }
++.ovv-person-text { display: grid; min-width: 0; }
++.ovv-person-name { font-weight: 700; overflow-wrap: anywhere; }
++
++/* How it works, questions, and the rest of the network */
++.ovv-steps { list-style: none; counter-reset: ovv-step; margin: 0; padding: 0; display: grid; gap: 14px; grid-template-columns: repeat(auto-fit, minmax(min(240px, 100%), 1fr)); }
++.ovv-steps li { counter-increment: ovv-step; padding: 16px 18px; border: 1px solid var(--ovv-line); border-radius: 14px; background: var(--ovv-card); }
++.ovv-steps li::before { content: counter(ovv-step); display: inline-grid; place-items: center; width: 28px; height: 28px; margin-bottom: .5rem; border-radius: 50%; background: var(--accent-strong, #2563eb); color: var(--on-accent-strong, #fff); font-weight: 800; font-size: .9rem; }
++.ovv-steps h3 { margin: 0 0 .35rem; font-size: 1rem; }
++.ovv-steps p { margin: 0; color: var(--ovv-dim); line-height: 1.55; }
++.ovv-faq { display: grid; gap: 8px; max-width: 860px; }
++.ovv-faq details { border: 1px solid var(--ovv-line); border-radius: 12px; background: var(--ovv-card); }
++.ovv-faq summary { cursor: pointer; padding: 12px 16px; font-weight: 700; }
++.ovv-faq details[open] summary { border-bottom: 1px solid var(--ovv-line); }
++.ovv-faq p { margin: 0; padding: 12px 16px 14px; color: var(--ovv-dim); line-height: 1.6; }
++.ovv-more a { display: grid; gap: 2px; height: 100%; box-sizing: border-box; padding: 14px 16px; border: 1px solid var(--ovv-line); border-radius: 12px; background: var(--ovv-card); color: inherit !important; text-decoration: none; }
++.ovv-more span { font-size: .88rem; color: var(--ovv-dim); }
.ovv-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 22px 16px; }
.ovv-card { display: flex; flex-direction: column; gap: .55rem; min-width: 0; }
.ovv-card-media { position: relative; display: block; aspect-ratio: 16 / 9; border-radius: 12px; overflow: hidden; background: var(--ovv-card); border: 1px solid var(--ovv-line); }
@@@ -128,6 +165,11 @@ a.ovv-channel-name:hover { text-decoration: underline; }
.ovv-card-title { font-size: .9rem; }
.ovv-player-wrap { border-radius: 10px; margin: 0 -4px; }
.ovv-btn { white-space: normal; }
++ /* Categories and channels two by two, each a small upright card */
++ .ovv-shelf, .ovv-people { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
++ .ovv-shelf-tile { grid-template-columns: minmax(0, 1fr); row-gap: 2px; padding: 12px; }
++ .ovv-shelf-tile > i { grid-row: auto; margin-bottom: 6px; }
++ .ovv-person { flex-direction: column; align-items: flex-start; gap: 8px; padding: 12px; }
}
/* Playlists */
ddiff --git a/server/app.js b/server/app.js
iindex 32bae09..fc12c65 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -120,7 +120,8 @@ async function createApp(opts = {}) {
scriptSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://static.cloudflareinsights.com'],
styleSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://fonts.googleapis.com', 'https://cdnjs.cloudflare.com'],
fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com'],
-- imgSrc: ["'self'", 'data:', 'https://openvibe.network', 'https://openvibe.media'],
++ // The Video home's live-now cards show OpenVibe.Live's own stream thumbnails (its /api/thumbnails).
++ imgSrc: ["'self'", 'data:', 'https://openvibe.network', 'https://openvibe.media', liveOrigin],
// The video player loads its media file from OpenVibe.Media (https://openvibe.media/v|/c), which may
// redirect to its shield host (edge.openvibe.media, for B2 copies) or a presigned R2/B2 URL, exactly
// as on OpenVibe.Live. Over HLS it plays through Media Source Extensions (blob:) and fetches the
ddiff --git a/server/video/live-client.js b/server/video/live-client.js
iindex f00dc31..b9c2943 100644
---- a/server/video/live-client.js
++++ b/server/video/live-client.js
@@@ -173,12 +173,30 @@ function createLiveClient({ config, fetchImpl = globalThis.fetch, log = console
/** A clip of a recording, cut by Media: { vod_id, start_time, end_time, title } → { clip } (Live's rules and limits). */
createClip: (token, fields, viewerIp) => asPerson(token, 'POST', '/api/vods/clips', fields, { viewerIp }),
forget,
-- feed({ type, limit, cursor = null }) {
++ feed({ type, limit, cursor = null, sort = null, window = null }) {
const qs = new URLSearchParams({ type, limit: String(limit) });
// Live's feed pages by an opaque cursor (its `next`); only a plain token is passed on.
if (cursor && /^[A-Za-z0-9_-]{1,200}$/.test(String(cursor))) qs.set('cursor', String(cursor));
++ // Most watched: Live's Top order (views) over a window of a week, a month or all time.
++ if (sort === 'top') {
++ qs.set('sort', 'top');
++ if (['week', 'month', 'all'].includes(window)) qs.set('window', window);
++ }
return getJson(`/api/content/feed?${qs.toString()}`);
},
++ /** Who is live on OpenVibe.Live right now: { streams: [...] } (public fields only), null when Live has none. */
++ liveNow() {
++ return getJson('/api/streams');
++ },
++ /** How many public recordings and clips Live holds: { vods, clips }, each a number or null when unknown. */
++ async totals() {
++ const [v, c] = await Promise.all([
++ getJson('/api/vods?limit=1').catch(() => null),
++ getJson('/api/clips?limit=1').catch(() => null),
++ ]);
++ const n = (x) => (x && Number.isSafeInteger(Number(x.total)) && Number(x.total) > 0 ? Number(x.total) : null);
++ return { vods: n(v), clips: n(c) };
++ },
vodsByUser(username, { limit, offset = 0 }) {
const qs = new URLSearchParams({ username, limit: String(limit), offset: String(offset) });
return getJson(`/api/vods?${qs.toString()}`);
ddiff --git a/server/video/pages.js b/server/video/pages.js
iindex 8e6a0ad..b39a3b9 100644
---- a/server/video/pages.js
++++ b/server/video/pages.js
@@@ -5,7 +5,9 @@
* (VODs) and clips. Everything here is server-rendered and complete without JavaScript; the one
* script (public/js/video.js) only adds seeking and keeps the URL's `?t=` in step.
*
-- * / what OpenVibe.Video is, then the latest recordings and clips
++ * / what OpenVibe.Video is: who is live now, the latest uploads, the month's most watched,
++ * the latest recordings and clips, categories and channels, how it works, questions
++ * /category/:slug a category's recordings (OpenVibe.Search's facets over Live's recordings)
* /v/:id a VOD watch page (player, AI chapters and captions, its clips)
* /c/:id a clip watch page
* /@:username a channel's recordings (and clips), newest first
@@@ -28,6 +30,10 @@ const frame = require('openvibe-shared/frame');
const { send, asset, assetVersion, SITE_NAME } = require('./layout');
const { createLiveClient, LiveRefusedError } = require('./live-client');
const { createChatClient } = require('./chat-client');
++const { createSearchReads, CATEGORIES, isCategory } = require('./search-reads');
++const rules = require('../drive/rules');
++/** 2147483648 → "2 GB" (the drive's formatBytes, without a trailing ".0"). */
++const formatBytes = (b) => rules.formatBytes(b).replace(/\.0 /, ' ');
const { createVideoApiRoutes } = require('./api');
const { createUploadApi, mountUploadPages } = require('./upload-routes');
const { ACCESS_COOKIE, REFRESH_COOKIE } = require('../auth/sso');
@@@ -393,18 +399,26 @@ function meta(parts) {
return html`<p class="ovv-card-meta">${present.map((p, i) => (i === 0 ? p : [html` · `, p]))}</p>`;
}
++/** "1 view", "1,204 views": a real count from Live, or nothing. */
++function viewsLabel(n) {
++ const v = Number(n);
++ if (!Number.isSafeInteger(v) || v <= 0) return null;
++ return `${v.toLocaleString('en-US')} ${v === 1 ? 'view' : 'views'}`;
++}
++
function card(o) {
const channel = o.channel ? html`<a href="${o.channelHref}">@${o.channel}</a>` : null;
++ const icon = o.live ? 'fa-tower-broadcast' : (o.kind === 'clip' ? 'fa-scissors' : 'fa-video');
return html`<article class="ovv-card">
--<a class="ovv-card-media" href="${o.href}" tabindex="-1" aria-hidden="true">${o.thumb ? thumbImg(o.thumb, CARD_SIZES) : html`<span class="ovv-card-placeholder"><i class="fa-solid ${o.kind === 'clip' ? 'fa-scissors' : 'fa-video'}"></i></span>`}${o.duration ? html`<span class="ovv-dur">${o.duration}</span>` : ''}${o.badge ? html`<span class="ovv-tag">${o.badge}</span>` : ''}</a>
++<a class="ovv-card-media" href="${o.href}" tabindex="-1" aria-hidden="true">${o.thumb ? thumbImg(o.thumb, CARD_SIZES) : html`<span class="ovv-card-placeholder"><i class="fa-solid ${icon}"></i></span>`}${o.duration ? html`<span class="ovv-dur">${o.duration}</span>` : ''}${o.badge ? html`<span class="ovv-tag${o.live ? ' ovv-tag--live' : ''}">${o.badge}</span>` : ''}</a>
<div class="ovv-card-body">
<h3 class="ovv-card-title"><a href="${o.href}">${o.title}</a></h3>
--${meta([channel, o.date ? html`<time datetime="${o.iso}">${o.date}</time>` : null])}
++${meta([channel, o.extra || null, o.views ? viewsLabel(o.views) : null, o.date ? html`<time datetime="${o.iso}">${o.date}</time>` : null])}
</div>
</article>`;
}
--function feedCard(item) {
++function feedCard(item, { views = false } = {}) {
const href = item.kind === 'clip' ? `/c/${item.id}` : `/v/${item.id}`;
return card({
href,
@@@ -415,6 +429,7 @@ function feedCard(item) {
channel: item.channel && item.channel.username ? item.channel.username : null,
channelHref: item.channel && item.channel.username ? `/@${item.channel.username}` : null,
duration: item.duration_seconds != null ? durationLabel(item.duration_seconds) : null,
++ views: views ? item.views : null,
date: dateLabel(item.created_at) || null,
iso: item.created_at || null,
});
@@@ -469,6 +484,7 @@ function createVideoRoutes(ctx) {
async function subjectOf(username) { return (await identityOf(username)).subject; }
const live = createLiveClient({ config, fetchImpl: ctx.fetchImpl || globalThis.fetch, log });
const chat = createChatClient({ config, fetchImpl: ctx.fetchImpl || globalThis.fetch, log });
++ const shelves = createSearchReads({ config, fetchImpl: ctx.fetchImpl || globalThis.fetch, log });
const r = asyncRouter();
const base = String(config.video.url).replace(/\/+$/, '');
const livePublic = String(config.video.livePublicUrl).replace(/\/+$/, '');
@@@ -735,32 +751,157 @@ ${kind === 'vod' ? html`<label class="ovv-field"><span>Description</span><textar
}
// ── Home ────────────────────────────────────────────────
++ // Every section is real data and hides itself when it has none: who is live on OpenVibe.Live now, the latest
++ // uploads, the month's most watched, the latest recordings and clips (Live's public feed), the categories and
++ // channels with their counts (OpenVibe.Search's facets over Live's recordings), then how it works and questions.
++ const settle = (p) => Promise.resolve().then(() => p).catch(() => null);
++ const itemsOf = (x) => ((x && Array.isArray(x.items)) ? x.items : []);
++ const LIVE_THUMB = /^\/api\/thumbnails\/[A-Za-z0-9._-]{1,200}$/;
++ /** Live's own streams list → cards for the ones that may be shown to everyone (not marked adult). */
++ function liveCards(answer) {
++ const streams = (answer && Array.isArray(answer.streams)) ? answer.streams : [];
++ return streams.filter((s) => s && !s.is_nsfw && /^[A-Za-z0-9_]{1,32}$/.test(String(s.username || '')))
++ .sort((a, b) => (Number(b.total_viewer_count ?? b.viewer_count) || 0) - (Number(a.total_viewer_count ?? a.viewer_count) || 0))
++ .slice(0, 8)
++ .map((s) => {
++ const watching = Number(s.total_viewer_count ?? s.viewer_count) || 0;
++ const cat = CATEGORIES[String(s.category || s.chosen_category || '')];
++ const started = s.started_at ? new Date(`${String(s.started_at).replace(' ', 'T')}${/[zZ]|[+-]\d\d:?\d\d$/.test(String(s.started_at)) ? '' : 'Z'}`) : null;
++ return card({
++ href: `${livePublic}/@${s.username}`, live: true, badge: 'Live',
++ thumb: LIVE_THUMB.test(String(s.thumbnail_url || '')) ? httpsUrl(`${livePublic}${s.thumbnail_url}`) : null,
++ title: s.title || `${s.display_name || s.username} is live`,
++ channel: s.username, channelHref: `${livePublic}/@${s.username}`,
++ extra: [cat ? cat.label : null, watching > 0 ? `${watching.toLocaleString('en-US')} watching` : null].filter(Boolean).join(' · ') || null,
++ date: started && !Number.isNaN(started.getTime()) ? `started ${relativeDate(started.toISOString())}` : null,
++ iso: started && !Number.isNaN(started.getTime()) ? started.toISOString() : null,
++ });
++ });
++ }
++ const n = (v) => Number(v).toLocaleString('en-US');
++ const HOME_FAQ = (vu) => [
++ { q: 'Is OpenVibe.Video free?', a: `Yes. Watching needs no account, and uploading needs a free OpenVibe account. Each person can upload ${vu.perDay} videos a day and keep up to ${formatBytes(vu.quotaBytes)}. There are no ads.` },
++ { q: 'Which files can I upload?', a: `MP4 (MOV and M4V too), WebM and MKV, up to ${formatBytes(vu.maxBytes)} each. You can also record your camera or screen right in the browser.` },
++ { q: 'How do streams from OpenVibe.Live get here?', a: 'When a stream on OpenVibe.Live is recorded, the recording appears here after the stream ends, on the streamer\'s channel at openvibe.video/@name, together with the clips people cut from it.' },
++ { q: 'Where do the captions and chapters come from?', a: 'OpenVibe.AI turns the speech into captions, splits the video into chapters and writes a short summary. On your own uploads you can use the summary as the description.' },
++ { q: 'Who can see my video?', a: 'You choose for each video: public videos show on your channel and in search, unlisted ones only to people who have the link, and private ones only to you.' },
++ { q: 'Can I make a clip?', a: `Yes. Sign in, open a recording and cut a clip of up to ${CLIP_MAX_SECONDS} seconds. It gets its own watch page and shows on the channel.` },
++ ];
r.get('/', async (req, res) => {
-- const [vods, clips] = await Promise.all([
++ const [vods, clips, topVods, topClips, onAir, totals, shelf] = await Promise.all([
live.feed({ type: 'vods', limit: HOME_LIMIT + 6 }),
live.feed({ type: 'clips', limit: HOME_LIMIT }),
++ settle(live.feed({ type: 'vods', limit: HOME_LIMIT, sort: 'top', window: 'month' })),
++ settle(live.feed({ type: 'clips', limit: HOME_LIMIT, sort: 'top', window: 'month' })),
++ settle(live.liveNow()),
++ settle(live.totals()),
++ settle(shelves.shelves()),
]);
// A stream that ended seconds after it started (a test, a dropped connection) is not worth a tile on the front
// page; it stays on its channel.
-- const vodItems = ((vods && Array.isArray(vods.items)) ? vods.items : [])
-- .filter((x) => !(Number(x.duration_seconds) > 0 && Number(x.duration_seconds) < MIN_HOME_SECONDS)).slice(0, HOME_LIMIT);
-- const clipItems = (clips && Array.isArray(clips.items)) ? clips.items : [];
++ const worthATile = (x) => !(Number(x.duration_seconds) > 0 && Number(x.duration_seconds) < MIN_HOME_SECONDS);
++ const vodItems = itemsOf(vods).filter(worthATile).slice(0, HOME_LIMIT);
++ const clipItems = itemsOf(clips);
++ const topItems = [...itemsOf(topVods).filter(worthATile), ...itemsOf(topClips)]
++ .filter((x) => Number(x.views) > 0)
++ .sort((a, b) => (Number(b.views) || 0) - (Number(a.views) || 0))
++ .slice(0, HOME_LIMIT);
const uploaded = await ctx.videoUploads.latest(HOME_LIMIT);
++ const onAirCards = liveCards(onAir);
++ const categories = (shelf && shelf.categories) || [];
++ const channels = ((shelf && shelf.channels) || []).filter((c) => c.count >= 2).slice(0, 12);
++ const counts = totals || {};
++ const stats = [
++ counts.vods ? { value: n(counts.vods), label: counts.vods === 1 ? 'stream recording' : 'stream recordings' } : null,
++ counts.clips ? { value: n(counts.clips), label: counts.clips === 1 ? 'clip' : 'clips' } : null,
++ shelf && shelf.channels.length ? { value: n(shelf.channels.length), label: shelf.channels.length === 1 ? 'channel' : 'channels' } : null,
++ categories.length ? { value: n(categories.length), label: categories.length === 1 ? 'category' : 'categories' } : null,
++ onAirCards.length ? { value: n(onAirCards.length), label: 'live now' } : null,
++ ].filter(Boolean);
++ const description = counts.vods && counts.clips
++ ? `Watch ${n(counts.vods)} stream recordings and ${n(counts.clips)} clips from OpenVibe.Live, or upload and record your own videos, with AI captions and chapters.`
++ : 'Watch stream recordings and clips from OpenVibe.Live, or upload and record your own videos, with AI captions and chapters.';
++ const faq = HOME_FAQ(config.videoUploads);
++ const listed = vodItems.map((it) => ({ name: it.title || 'Stream recording', url: `${base}/v/${it.id}` }));
return page(req, res, {
index: true,
-- description: 'Videos people upload, and stream recordings and clips from OpenVibe.Live with AI chapters and captions.',
-- // What the site is, for search engines (as the Download home says it).
++ title: 'OpenVibe.Video: watch streams again and share your videos',
++ description,
jsonLd: [
-- seo.jsonLd.website({ name: 'OpenVibe.Video', url: base, description: 'Videos people upload, and stream recordings and clips from OpenVibe.Live with AI chapters and captions.' }),
-- seo.jsonLd.webPage({ name: 'OpenVibe.Video', url: `${base}/`, description: 'Videos people upload, and stream recordings and clips from OpenVibe.Live with AI chapters and captions.', siteUrl: base }),
++ seo.jsonLd.website({ name: 'OpenVibe.Video', url: base, description }),
++ seo.jsonLd.webPage({ name: 'OpenVibe.Video', url: `${base}/`, description, siteUrl: base }),
++ ...(listed.length ? [seo.jsonLd.itemList('Latest stream recordings on OpenVibe.Video', listed, { url: `${base}/` })] : []),
++ seo.jsonLd.faq(faq),
],
body: html`<header class="ovv-hero"><p class="ovv-eyebrow"><i class="fa-solid fa-circle-play" aria-hidden="true"></i> OpenVibe.Video</p>
--<h1>Watch, upload, stream.</h1>
--<p class="ovv-lede">Upload a video or record your camera or screen right here, and OpenVibe.AI adds captions, chapters and a summary you can use as the description. The recordings and clips of streams on OpenVibe.Live are here too. Everything you post plays on your channel at openvibe.video/@you.</p>
--<p class="ovv-hero-actions"><a class="ovv-btn ovv-btn--primary" href="/upload"><i class="fa-solid fa-upload" aria-hidden="true"></i> Upload a video</a> <a class="ovv-btn" href="/record"><i class="fa-solid fa-video" aria-hidden="true"></i> Record in the browser</a> <a class="ovv-btn" href="${livePublic}/">Live now on OpenVibe.Live</a> <a class="ovv-btn" href="${livePublic}/docs/go-live-in-your-browser">Go live from your browser</a></p></header>
++<h1>Watch streams again, and share your own videos</h1>
++<p class="ovv-lede">Recordings and clips of streams on OpenVibe.Live land here when the stream ends. Upload a video or record your camera or screen in the browser, and OpenVibe.AI adds captions, chapters and a summary. Everything you post plays on your channel at openvibe.video/@you.</p>
++<p class="ovv-hero-actions"><a class="ovv-btn ovv-btn--primary" href="/upload"><i class="fa-solid fa-upload" aria-hidden="true"></i> Upload a video</a> <a class="ovv-btn" href="/record"><i class="fa-solid fa-video" aria-hidden="true"></i> Record in the browser</a> <a class="ovv-btn" href="${livePublic}/docs/go-live-in-your-browser"><i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i> Go live from your browser</a></p>
++${stats.length ? html`<dl class="ovv-stats">${stats.map((s) => html`<div><dt>${s.label}</dt><dd>${s.value}</dd></div>`)}</dl>` : ''}</header>
++${onAirCards.length ? html`<section class="ovv-section" aria-labelledby="h-live"><div class="ovv-section-head"><h2 id="h-live"><span class="ovv-dot" aria-hidden="true"></span> Live now on OpenVibe.Live</h2><a href="${livePublic}/">Everyone live</a></div>${grid(onAirCards)}</section>` : ''}
${uploaded.length ? html`<section class="ovv-section" aria-labelledby="h-uploads"><h2 id="h-uploads">Latest uploads</h2>${grid(uploaded.map(uploadCard))}</section>` : ''}
--<section class="ovv-section" aria-labelledby="h-vods"><h2 id="h-vods">Latest recordings</h2>${grid(vodItems.map(feedCard), 'No recordings yet.')}</section>
--<section class="ovv-section" aria-labelledby="h-clips"><h2 id="h-clips">Latest clips</h2>${grid(clipItems.map(feedCard), 'No clips yet.')}</section>`,
++${topItems.length ? html`<section class="ovv-section" aria-labelledby="h-top"><h2 id="h-top">Most watched this month</h2>${grid(topItems.map((it) => feedCard(it, { views: true })))}</section>` : ''}
++<section class="ovv-section" aria-labelledby="h-vods"><h2 id="h-vods">Latest stream recordings</h2>${grid(vodItems.map(feedCard), 'No recordings yet.')}</section>
++${categories.length ? html`<section class="ovv-section" id="categories" aria-labelledby="h-cats"><h2 id="h-cats">Browse by category</h2><ul class="ovv-shelf" role="list">${categories.map((c) => html`<li><a class="ovv-shelf-tile" href="/category/${c.slug}"><i class="fa-solid ${c.icon}" aria-hidden="true"></i><span class="ovv-shelf-name">${c.label}</span><span class="ovv-shelf-count">${n(c.count)} ${c.count === 1 ? 'recording' : 'recordings'}</span></a></li>`)}</ul></section>` : ''}
++<section class="ovv-section" aria-labelledby="h-clips"><h2 id="h-clips">Latest clips</h2>${grid(clipItems.map(feedCard), 'No clips yet.')}</section>
++${channels.length ? html`<section class="ovv-section" aria-labelledby="h-channels"><h2 id="h-channels">Channels</h2><ul class="ovv-people" role="list">${channels.map((c) => html`<li><a class="ovv-person" href="/@${c.username}"><span class="ovv-avatar" aria-hidden="true">${c.username.slice(0, 1).toUpperCase()}<img src="${config.networkUrl}/avatar/${c.username}" alt="" loading="lazy" width="42" height="42"></span><span class="ovv-person-text"><span class="ovv-person-name">@${c.username}</span><span class="ovv-person-count">${n(c.count)} ${c.count === 1 ? 'recording' : 'recordings'}</span></span></a></li>`)}</ul></section>` : ''}
++<section class="ovv-section" aria-labelledby="h-how"><h2 id="h-how">How it works</h2><ol class="ovv-steps">
++<li><h3>Stream or upload</h3><p>Go live on OpenVibe.Live from your browser and the recording comes here, or upload a file: MP4, MOV, WebM or MKV up to ${formatBytes(config.videoUploads.maxBytes)}.</p></li>
++<li><h3>Let AI do the busywork</h3><p>OpenVibe.AI writes captions, chapters and a summary. On an upload, the AI cut can make an edited copy with the pauses taken out.</p></li>
++<li><h3>Share it</h3><p>Every video gets a watch page and a place on your channel. Save videos to playlists, and cut clips of up to ${CLIP_MAX_SECONDS} seconds from any recording.</p></li>
++</ol></section>
++<section class="ovv-section" aria-labelledby="h-faq"><h2 id="h-faq">Questions</h2><div class="ovv-faq">${faq.map((f) => html`<details><summary>${f.q}</summary><p>${f.a}</p></details>`)}</div></section>
++<section class="ovv-section" aria-labelledby="h-more"><h2 id="h-more">More on OpenVibe</h2><ul class="ovv-more" role="list">
++<li><a href="${livePublic}/"><strong>OpenVibe.Live</strong><span>Go live from your browser, no OBS needed.</span></a></li>
++<li><a href="${config.pics.url}/"><strong>OpenVibe.Pics</strong><span>Share pictures with a link, no ads.</span></a></li>
++<li><a href="${config.baseUrl}/"><strong>OpenVibe.Download</strong><span>Your files and backups, shared on your terms.</span></a></li>
++<li><a href="https://openvibe.community/"><strong>OpenVibe.Community</strong><span>Forums and pastes for talking it over.</span></a></li>
++</ul></section>`,
++ });
++ });
++
++ // ── Categories: Live's recordings of one kind, from OpenVibe.Search, newest first ──
++ r.get('/category/:slug', async (req, res) => {
++ const slug = String(req.params.slug || '');
++ if (!isCategory(slug)) return notFound(req, res);
++ const cat = CATEGORIES[slug];
++ const cursor = typeof req.query.after === 'string' ? req.query.after : null;
++ const [pageOf, shelf] = await Promise.all([shelves.category(slug, cursor), settle(shelves.shelves())]);
++ if (!pageOf) {
++ return page(req, res, {
++ title: `${cat.label} videos`, noindex: true, cache: cache.htmlHeaders({ private: true }),
++ crumbs: [{ label: 'Home', href: '/' }, { label: cat.label }],
++ body: html`<h1>${cat.label}</h1><p class="muted">This list cannot be read right now. Try again in a minute, or see the <a href="/">latest recordings</a>.</p>`,
++ }, 503);
++ }
++ if (!pageOf.items.length && cursor) return notFound(req, res);
++ const known = await catalogIndex();
++ // A recording of a few seconds (a test, a dropped connection) is left off the shelf, as on the home page.
++ const items = await Promise.all(pageOf.items.filter((x) => !(Number(x.duration_seconds) > 0 && Number(x.duration_seconds) < MIN_HOME_SECONDS)).map(async (x) => {
++ let it = known.get(`vod:${x.id}`);
++ if (!it) {
++ const d = await settle(live.vod(String(x.id)));
++ const v = d && d.vod;
++ if (v && v.visibility === 'public') it = { id: x.id, kind: 'vod', title: v.title, thumbnail_url: v.thumbnail_url, duration_seconds: v.duration_seconds, created_at: v.created_at, channel: { username: v.username } };
++ }
++ return it || { id: x.id, kind: 'vod', title: x.title, thumbnail_url: null, duration_seconds: x.duration_seconds, created_at: x.published_at, channel: { username: x.channel } };
++ }));
++ const count = ((shelf && shelf.categories) || []).find((c) => c.slug === slug);
++ const others = ((shelf && shelf.categories) || []).filter((c) => c.slug !== slug);
++ const title = `${cat.label} stream recordings`;
++ const description = `${cat.label} stream recordings from OpenVibe.Live, newest first${count ? ` (${n(count.count)} so far)` : ''}. ${cat.blurb}`.slice(0, 160);
++ return page(req, res, {
++ index: !cursor && items.length >= 4, noindex: Boolean(cursor) || items.length < 4,
++ title, description,
++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Categories', href: '/#categories' }, { label: cat.label }],
++ jsonLd: [
++ seo.jsonLd.breadcrumbs([{ name: 'OpenVibe.Video', url: `${base}/` }, { name: cat.label, url: `${base}/category/${slug}` }]),
++ seo.jsonLd.itemList(title, items.map((it) => ({ name: it.title || 'Stream recording', url: `${base}/v/${it.id}` })), { url: `${base}/category/${slug}` }),
++ ],
++ body: html`<header class="ovv-channel-head"><span class="ovv-avatar ovv-avatar--big" aria-hidden="true"><i class="fa-solid ${cat.icon}"></i></span><div><h1>${cat.label}</h1><p class="ovv-meta">${cat.blurb}${count ? ` ${n(count.count)} ${count.count === 1 ? 'recording' : 'recordings'}.` : ''}</p></div></header>
++<section class="ovv-section" aria-label="${title}">${grid(items.map((it) => feedCard(it)), 'Nothing in this category yet.')}</section>
++${pageOf.next ? html`<p class="ovv-pagination"><a class="ovv-btn" href="/category/${slug}?after=${encodeURIComponent(pageOf.next)}" rel="next">Older recordings</a></p>` : ''}
++${others.length ? html`<section class="ovv-section" aria-labelledby="h-other"><h2 id="h-other">Other categories</h2><ul class="ovv-shelf" role="list">${others.map((c) => html`<li><a class="ovv-shelf-tile" href="/category/${c.slug}"><i class="fa-solid ${c.icon}" aria-hidden="true"></i><span class="ovv-shelf-name">${c.label}</span><span class="ovv-shelf-count">${n(c.count)} ${c.count === 1 ? 'recording' : 'recordings'}</span></a></li>`)}</ul></section>` : ''}`,
});
});
@@@ -1285,12 +1426,42 @@ ${isOwner ? html`<p class="ovv-fine">You can reorder items with the up and down
};
}
++ // Live's whole public feed, walked page by page and kept ten minutes: the sitemap's list, and the thumbnails and
++ // durations of the recordings a category page lists (Search's documents carry neither).
++ let catalogCache = null; // { at, vods, clips, index: Map('vod:<id>' | 'clip:<id>' → feed item) }
++ let catalogRunning = null;
++ function refreshCatalog() {
++ if (!catalogRunning) {
++ catalogRunning = Promise.all([feedItems('vods'), feedItems('clips')])
++ .then(([vods, clips]) => {
++ const index = new Map();
++ for (const it of [...vods, ...clips]) {
++ if (it && Number.isSafeInteger(Number(it.id))) index.set(`${it.kind === 'clip' ? 'clip' : 'vod'}:${Number(it.id)}`, it);
++ }
++ catalogCache = { at: Date.now(), vods, clips, index };
++ return catalogCache;
++ })
++ .finally(() => { catalogRunning = null; });
++ }
++ return catalogRunning;
++ }
++ /** The whole catalog, waiting for a walk when there is none yet or it is old (the sitemap). */
++ function catalog() {
++ if (catalogCache && Date.now() - catalogCache.at < SITEMAP_TTL_MS) return Promise.resolve(catalogCache);
++ return refreshCatalog();
++ }
++ /** What is known now, never waiting: a stale or missing catalog starts a walk in the background. */
++ async function catalogIndex() {
++ if (!catalogCache || Date.now() - catalogCache.at >= SITEMAP_TTL_MS) refreshCatalog().catch(() => {});
++ return catalogCache ? catalogCache.index : new Map();
++ }
++
let sitemapCache = null; // { at, xml }: Live's feed is walked page by page, so the answer is kept ten minutes
r.get('/sitemap.xml', async (req, res) => {
if (sitemapCache && Date.now() - sitemapCache.at < SITEMAP_TTL_MS) {
return res.type('application/xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(sitemapCache.xml);
}
-- const [vods, clips, uploaded] = await Promise.all([feedItems('vods'), feedItems('clips'), ctx.videoUploads.latest(500)]);
++ const [{ vods, clips }, uploaded, shelf] = await Promise.all([catalog(), ctx.videoUploads.latest(500), shelves.shelves()]);
const day = (iso) => { const d = new Date(String(iso || '')); return Number.isNaN(d.getTime()) ? null : d.toISOString().slice(0, 10); };
const newest = (list) => list.map(day).filter(Boolean).sort().pop() || null;
// A short recording with nothing written about it is noindex on its page (thinRecording), so it is left out here.
@@@ -1312,6 +1483,8 @@ ${isOwner ? html`<p class="ovv-fine">You can reorder items with the up and down
const urls = [
{ loc: `${base}/`, lastmod: newest(items.map((it) => it.created_at)) },
{ loc: `${base}/updates` },
++ // A category page is indexable from four recordings (as the page itself decides).
++ ...((shelf && shelf.categories) || []).filter((c) => c.count >= 4).map((c) => ({ loc: `${base}/category/${c.slug}` })),
...items.map((it) => {
const video = sitemapVideo(it);
return { loc: `${base}${it.kind === 'clip' ? '/c/' : '/v/'}${Number(it.id)}`, lastmod: day(it.created_at), ...(video ? { videos: [video] } : {}) };
ddiff --git a/server/video/search-reads.js b/server/video/search-reads.js
nnew file mode 100644
iindex 0000000..5db225f
---- /dev/null
++++ b/server/video/search-reads.js
@@@ -0,0 +1,98 @@
++'use strict';
++
++/**
++ * OpenVibe.Video's read-only view of OpenVibe.Live's recordings in OpenVibe.Search: the categories and channels with
++ * their counts (Search's facets), and the recordings of one category, newest first. Live writes one Search document per
++ * public recording (owner live, type vod, facets category and channel), so these are the same public recordings the
++ * watch pages show; Search only returns public, published, indexable documents.
++ *
++ * Signed out, no credentials, JSON only. Answers are cached five minutes (at most 200), and anything wrong (no Search
++ * URL configured, a timeout, a non-2xx, a body that is not JSON) is null: the home hides the sections and a category
++ * page says it cannot list right now. It never throws to the pages.
++ */
++const TIMEOUT_MS = 4000;
++const CACHE_TTL_MS = 5 * 60_000;
++const CACHE_MAX = 200;
++const PAGE = 24;
++
++// Live's stream categories (OpenVibe.Live server/ai/ai-analysis.js CATEGORIES), as people read them, with an icon each.
++const CATEGORIES = {
++ gaming: { label: 'Gaming', icon: 'fa-gamepad', blurb: 'Games played live, from battle royales to sports games.' },
++ irl: { label: 'IRL and chatting', icon: 'fa-comments', blurb: 'Talking, cooking and everyday life, on camera.' },
++ desktop: { label: 'Desktop and tech', icon: 'fa-desktop', blurb: 'Screens shared live: coding, building software and tech.' },
++ robot: { label: 'Robots', icon: 'fa-robot', blurb: 'Robots driven, built and tested live.' },
++ outdoors: { label: 'Outdoors', icon: 'fa-mountain-sun', blurb: 'Streams from outside: walks, hikes and time in the open air.' },
++ travel: { label: 'Travel and driving', icon: 'fa-route', blurb: 'On the road, in the air and out exploring.' },
++ building: { label: 'Making and crafts', icon: 'fa-hammer', blurb: 'Building, fixing and making things by hand.' },
++ music: { label: 'Music', icon: 'fa-music', blurb: 'Playing, mixing and making music live.' },
++ other: { label: 'Everything else', icon: 'fa-shapes', blurb: 'Streams that fit no other shelf.' },
++};
++const isCategory = (v) => Object.prototype.hasOwnProperty.call(CATEGORIES, String(v || ''));
++const USERNAME_RE = /^[A-Za-z0-9_]{1,32}$/;
++
++function createSearchReads({ config, fetchImpl = globalThis.fetch, log = console } = {}) {
++ const base = String((config.search && config.search.url) || '').replace(/\/+$/, '');
++ const cache = new Map(); // url → { at, value }
++
++ async function getJson(qs) {
++ if (!base) return null;
++ const url = `${base}/api/v1/search?${qs.toString()}`;
++ const entry = cache.get(url);
++ if (entry && Date.now() - entry.at < CACHE_TTL_MS) return entry.value;
++ try {
++ const res = await fetchImpl(url, {
++ method: 'GET', headers: { Accept: 'application/json' }, credentials: 'omit', cache: 'no-store',
++ signal: AbortSignal.timeout(TIMEOUT_MS),
++ });
++ if (!res.ok) throw new Error(`HTTP ${res.status}`);
++ const body = await res.json();
++ cache.delete(url);
++ cache.set(url, { at: Date.now(), value: body });
++ if (cache.size > CACHE_MAX) cache.delete(cache.keys().next().value);
++ return body;
++ } catch (err) {
++ log.warn('[OpenVibe.Video] Search did not answer', err && err.message ? err.message.slice(0, 120) : err);
++ // The last answer, however old, beats an empty shelf.
++ return entry ? entry.value : null;
++ }
++ }
++
++ const facetList = (list) => (Array.isArray(list) ? list : [])
++ .map((x) => ({ value: String((x && x.value) || ''), count: Number(x && x.count) || 0 }))
++ .filter((x) => x.value && x.count > 0);
++
++ return {
++ enabled: Boolean(base),
++ /** { categories: [{ slug, label, icon, blurb, count }], channels: [{ username, count }] } or null. */
++ async shelves() {
++ const body = await getJson(new URLSearchParams({ q: '', owner: 'live', type: 'vod', facets: 'category,channel', limit: '1' }));
++ if (!body || !body.facets) return null;
++ const categories = facetList(body.facets.category).filter((x) => isCategory(x.value))
++ .map((x) => ({ slug: x.value, ...CATEGORIES[x.value], count: x.count }));
++ const channels = facetList(body.facets.channel).filter((x) => USERNAME_RE.test(x.value))
++ .map((x) => ({ username: x.value, count: x.count }));
++ return { categories, channels };
++ },
++ /** One page of a category's recordings: { items: [{ id, title, channel, duration_seconds, published_at }], next } or null. */
++ async category(slug, cursor = null) {
++ if (!isCategory(slug)) return null;
++ const qs = new URLSearchParams({ q: '', owner: 'live', type: 'vod', 'facet.category': slug, limit: String(PAGE) });
++ if (cursor && /^[A-Za-z0-9_-]{1,512}$/.test(String(cursor))) qs.set('cursor', String(cursor));
++ const body = await getJson(qs);
++ if (!body || !Array.isArray(body.results)) return null;
++ const items = body.results
++ .filter((x) => x && x.type === 'vod' && /^[1-9]\d{0,15}$/.test(String(x.id)))
++ .map((x) => ({
++ id: Number(x.id),
++ title: String(x.title || '').slice(0, 300),
++ channel: x.facets && USERNAME_RE.test(String(x.facets.channel || '')) ? String(x.facets.channel) : null,
++ duration_seconds: x.facets && Number.isFinite(Number(x.facets.duration_seconds)) ? Number(x.facets.duration_seconds) : null,
++ published_at: x.published_at || null,
++ }));
++ const next = typeof body.next_cursor === 'string' && /^[A-Za-z0-9_-]{1,512}$/.test(body.next_cursor) ? body.next_cursor : null;
++ return { items, next };
++ },
++ };
++}
++
++module.exports = { createSearchReads, CATEGORIES, isCategory };
ddiff --git a/test/video-pages.test.js b/test/video-pages.test.js
iindex b4681c3..7738d58 100644
---- a/test/video-pages.test.js
++++ b/test/video-pages.test.js
@@@ -104,19 +104,45 @@ function startLive() {
if (state.down) return json(503, { error: 'Live is down' });
const p = url.pathname;
++ // Who is live (Live's /api/streams): one stream to show, one marked adult (left out), one with a foreign thumbnail.
++ if (p === '/api/streams') {
++ return json(200, { streams: [
++ { id: 1, username: 'onair', display_name: 'On Air', title: 'Building a robot live', category: 'robot', is_nsfw: 0, viewer_count: 3, total_viewer_count: 7, started_at: '2026-10-22 13:00:00', thumbnail_url: '/api/thumbnails/stream-1-123.jpg' },
++ { id: 2, username: 'adult', display_name: 'Adult', title: 'ADULT_STREAM_XYZ', category: 'irl', is_nsfw: 1, viewer_count: 50, thumbnail_url: null },
++ { id: 3, username: 'elsewhere', display_name: 'Elsewhere', title: 'Foreign thumbnail', category: 'gaming', is_nsfw: 0, viewer_count: 1, thumbnail_url: 'https://evil.example/x.jpg' },
++ ] });
++ }
++ // OpenVibe.Search (the same stub answers for it): the facets, and one category's recordings.
++ if (p === '/api/v1/search') {
++ if (url.searchParams.get('facet.category') === 'gaming') {
++ return json(200, { results: [{ owner: 'live', type: 'vod', id: '101', title: 'A public recording', facets: { channel: 'streamer', category: 'gaming', duration_seconds: 45 }, published_at: '2026-10-12T10:00:00.000Z' }], next_cursor: null });
++ }
++ if (url.searchParams.get('facet.category')) return json(200, { results: [], next_cursor: null });
++ return json(200, { results: [], next_cursor: null, facets: {
++ category: [{ value: 'gaming', count: 5 }, { value: 'not-a-category', count: 3 }, { value: 'music', count: 1 }],
++ channel: [{ value: 'streamer', count: 3 }, { value: 'bad name!', count: 9 }, { value: 'once', count: 1 }],
++ } });
++ }
if (p === '/api/content/feed') {
const type = url.searchParams.get('type');
++ // Live's Top order: the most watched of the month, with their view counts.
++ if (url.searchParams.get('sort') === 'top') {
++ if (type === 'vods') return json(200, { items: [{ ...vodFeedItem, views: 100 }], next: null });
++ if (type === 'clips') return json(200, { items: [{ ...clipFeedItem, views: 250 }], next: null });
++ }
if (type === 'vods') return json(200, { items: [vodFeedItem, { ...vodFeedItem, key: 'vod:109', id: 109, title: 'A two-second blip', duration_seconds: 2 }, { ...vodFeedItem, key: 'vod:110', id: 110, title: "Streamer's Channel", duration_seconds: 10, excerpt: null }], next: null });
if (type === 'clips') return json(200, { items: [clipFeedItem], next: null });
return json(200, { items: [], next: null });
}
if (p === '/api/vods') {
const username = url.searchParams.get('username');
++ if (!username) return json(200, { vods: [vodListItem], total: 812, hasMore: true });
if (username === 'streamer') return json(200, { vods: [vodListItem], total: 1, hasMore: false });
return json(200, { vods: [], total: 0, hasMore: false });
}
if (p === '/api/clips') {
const username = url.searchParams.get('username');
++ if (!username) return json(200, { clips: [clipListItem], total: 399, hasMore: true });
if (username === 'streamer') return json(200, { clips: [clipListItem], total: 1, hasMore: false });
return json(200, { clips: [], total: 0, hasMore: false });
}
@@@ -202,6 +228,8 @@ function countOccurrences(haystack, needle) {
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 getVideo = (p) => request(t.base, p, 'openvibe.video');
@@@ -242,7 +270,7 @@ function countOccurrences(haystack, needle) {
const r = await getVideo('/');
assert.strictEqual(r.status, 200);
assert.ok(r.text.includes('OpenVibe.Video'));
-- assert.ok(r.text.includes('Latest recordings'));
++ assert.ok(r.text.includes('Latest stream recordings'));
assert.ok(r.text.includes('Latest clips'));
assert.ok(r.text.includes('/v/101'));
const ld = [...r.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].flatMap((m) => [].concat(JSON.parse(m[1])));
@@@ -361,6 +389,51 @@ function countOccurrences(haystack, needle) {
assert.strictEqual(r.text, 'WEBVTT\n\n00:00:10.000 --> 00:00:30.000\nIntro &amp; setup\n\n00:00:30.000 --> 00:00:45.000\nThe &lt;main&gt; part\n');
});
++ await check('the video home: live now, most watched, real counts, categories, channels, how it works and questions', async () => {
++ const r = await getVideo('/');
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual((r.text.match(/<h1[\s>]/g) || []).length, 1, 'one h1');
++ // Live now: the stream links its Live channel, with Live's own thumbnail; the adult one and a foreign picture are not shown.
++ assert.ok(r.text.includes('Live now on OpenVibe.Live') && r.text.includes('href="https://openvibe.live/@onair"'), 'the live stream links its channel on Live');
++ assert.ok(r.text.includes('src="https://openvibe.live/api/thumbnails/stream-1-123.jpg"'), "Live's own stream thumbnail");
++ assert.ok(r.text.includes('7 watching') && r.text.includes('Robots'), 'viewers and the category');
++ assert.ok(!r.text.includes('ADULT_STREAM_XYZ'), 'a stream marked adult is left out');
++ assert.ok(!r.text.includes('evil.example'), 'a thumbnail from anywhere else is dropped');
++ // Most watched: clips and recordings together, by views, with the count.
++ const top = r.text.slice(r.text.indexOf('id="h-top"'), r.text.indexOf('id="h-vods"'));
++ assert.ok(top.indexOf('/c/201') !== -1 && top.indexOf('/c/201') < top.indexOf('/v/101'), 'the clip with more views comes first');
++ assert.ok(top.includes('250 views') && top.includes('100 views'), 'view counts');
++ // The numbers are Live's and Search's own.
++ assert.ok(/<dd>812<\/dd>/.test(r.text) && /<dd>399<\/dd>/.test(r.text), 'recordings and clips counted by Live');
++ assert.ok(r.text.includes('Watch 812 stream recordings and 399 clips'), 'the description carries the counts');
++ // Categories and channels from Search's facets: only known categories, only real usernames with two or more.
++ assert.ok(r.text.includes('href="/category/gaming"') && r.text.includes('5 recordings'), 'a category with its count');
++ assert.ok(r.text.includes('href="/category/music"') && r.text.includes('1 recording<'), 'one recording, singular');
++ assert.ok(!r.text.includes('not-a-category'), 'an unknown category is not shown');
++ assert.ok(r.text.includes('href="/@streamer"') && r.text.includes('3 recordings'), 'a channel with its count');
++ assert.ok(!r.text.includes('bad name!') && !r.text.includes('href="/@once"'), 'a bad name and a one-recording channel are left out');
++ const ld = [...r.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].flatMap((m) => [].concat(JSON.parse(m[1])));
++ const faq = ld.find((x) => x['@type'] === 'FAQPage');
++ assert.ok(faq && faq.mainEntity.length >= 5, 'FAQPage structured data');
++ const shown = [...r.text.matchAll(/<summary>([^<]*)<\/summary>/g)].map((m) => m[1].replace(/&#39;/g, "'"));
++ assert.deepStrictEqual(faq.mainEntity.map((q) => q.name), shown, 'every question in the structured data is on the page');
++ assert.ok(ld.some((x) => x['@type'] === 'ItemList' && x.itemListElement.some((it) => it.url === 'https://openvibe.video/v/101')), 'the latest recordings as an ItemList');
++ assert.ok(r.text.includes('up to 2 GB') && !r.text.includes('2.0 GB'), 'the upload limit, as people write it');
++ });
++
++ await check('a category page lists its recordings from Search, with the thumbnails Live has', async () => {
++ const r = await getVideo('/category/gaming');
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes('<h1>Gaming</h1>'));
++ assert.ok(r.text.includes('href="/v/101"') && r.text.includes('https://openvibe.media/t/101.jpg'), 'the recording, with its thumbnail');
++ assert.match(r.text, /<meta name="robots" content="noindex/, 'one recording is too thin to index');
++ assert.ok(r.text.includes('href="/category/music"'), 'the other categories');
++ assert.strictEqual((await getVideo('/category/not-a-category')).status, 404);
++ const map = (await getVideo('/sitemap.xml')).text;
++ assert.match(map, /<loc>https:\/\/openvibe\.video\/category\/gaming<\/loc>/, 'a category with four or more recordings is in the sitemap');
++ assert.ok(!map.includes('/category/music'), 'a category with one is not');
++ });
++
await check('the navbar names the site\'s places: Videos, Upload, Record, Your videos and Playlists, the current one active', async () => {
const home = (await getVideo('/')).text;
assert.match(home, /"links":\[\{"label":"Videos","href":"\/","active":true\},\{"label":"Upload","href":"\/upload","active":false\},\{"label":"Record","href":"\/record","active":false\},\{"label":"Your videos","href":"\/me\/videos","active":false\},\{"label":"Playlists","href":"\/playlists","active":false\}\]/);