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)
Changes
@@@ -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 */@@@ -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@@@ -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()}`);@@@ -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 minutesr.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 downconst 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] } : {}) };nnew file mode 100644@@@ -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 };@@@ -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 & setup\n\n00:00:30.000 --> 00:00:45.000\nThe <main> 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(/'/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\}\]/);
@@ -27,8 +27,45 @@
@@ -27,8 +27,45 @@
.ovv-lede { margin: 0; max-width: 62ch; font-size: 1.05rem; color: var(--ovv-dim); }
.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; }
.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 */
/* Sections and card grids */
.ovv-section { margin-top: 2.2rem; }
.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-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 { 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); }
.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; }
@@ -128,6 +165,11 @@ a.ovv-channel-name:hover { text-decoration: underline; }
.ovv-card-title { font-size: .9rem; }
.ovv-card-title { font-size: .9rem; }
.ovv-player-wrap { border-radius: 10px; margin: 0 -4px; }
.ovv-player-wrap { border-radius: 10px; margin: 0 -4px; }
.ovv-btn { white-space: normal; }
.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 */
/* Playlists */
@@ -120,7 +120,8 @@ async function createApp(opts = {}) {
@@ -120,7 +120,8 @@ async function createApp(opts = {}) {
scriptSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://static.cloudflareinsights.com'],
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'],
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'],
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
// 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
// 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
// as on OpenVibe.Live. Over HLS it plays through Media Source Extensions (blob:) and fetches the
@@ -173,12 +173,30 @@ function createLiveClient({ config, fetchImpl = globalThis.fetch, log = console
@@ -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). */
/** 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 }),
createClip: (token, fields, viewerIp) => asPerson(token, 'POST', '/api/vods/clips', fields, { viewerIp }),
forget,
forget,
- feed({ type, limit, cursor = null }) {
+ feed({ type, limit, cursor = null, sort = null, window = null }) {
const qs = new URLSearchParams({ type, limit: String(limit) });
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.
// 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));
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()}`);
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 }) {
vodsByUser(username, { limit, offset = 0 }) {
const qs = new URLSearchParams({ username, limit: String(limit), offset: String(offset) });
const qs = new URLSearchParams({ username, limit: String(limit), offset: String(offset) });
return getJson(`/api/vods?${qs.toString()}`);
return getJson(`/api/vods?${qs.toString()}`);
@@ -5,7 +5,9 @@
@@ -5,7 +5,9 @@
* (VODs) and clips. Everything here is server-rendered and complete without JavaScript; the one
* (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.
* 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)
* /v/:id a VOD watch page (player, AI chapters and captions, its clips)
* /c/:id a clip watch page
* /c/:id a clip watch page
* /@:username a channel's recordings (and clips), newest first
* /@:username a channel's recordings (and clips), newest first
@@ -28,6 +30,10 @@ const frame = require('openvibe-shared/frame');
@@ -28,6 +30,10 @@ const frame = require('openvibe-shared/frame');
const { send, asset, assetVersion, SITE_NAME } = require('./layout');
const { send, asset, assetVersion, SITE_NAME } = require('./layout');
const { createLiveClient, LiveRefusedError } = require('./live-client');
const { createLiveClient, LiveRefusedError } = require('./live-client');
const { createChatClient } = require('./chat-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 { 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');
@@ -393,18 +399,26 @@ function meta(parts) {
@@ -393,18 +399,26 @@ function meta(parts) {
return html`<p class="ovv-card-meta">${present.map((p, i) => (i === 0 ? p : [html` · `, p]))}</p>`;
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) {
function card(o) {
const channel = o.channel ? html`<a href="${o.channelHref}">@${o.channel}</a>` : null;
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">
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">
<div class="ovv-card-body">
<h3 class="ovv-card-title"><a href="${o.href}">${o.title}</a></h3>
<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>
</div>
</article>`;
</article>`;
}
}
-function feedCard(item) {
+function feedCard(item, { views = false } = {}) {
const href = item.kind === 'clip' ? `/c/${item.id}` : `/v/${item.id}`;
const href = item.kind === 'clip' ? `/c/${item.id}` : `/v/${item.id}`;
return card({
return card({
href,
href,
@@ -415,6 +429,7 @@ function feedCard(item) {
@@ -415,6 +429,7 @@ function feedCard(item) {
channel: item.channel && item.channel.username ? item.channel.username : null,
channel: item.channel && item.channel.username ? item.channel.username : null,
channelHref: 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,
duration: item.duration_seconds != null ? durationLabel(item.duration_seconds) : null,
+ views: views ? item.views : null,
date: dateLabel(item.created_at) || null,
date: dateLabel(item.created_at) || null,
iso: item.created_at || null,
iso: item.created_at || null,
});
});
@@ -469,6 +484,7 @@ function createVideoRoutes(ctx) {
@@ -469,6 +484,7 @@ function createVideoRoutes(ctx) {
async function subjectOf(username) { return (await identityOf(username)).subject; }
async function subjectOf(username) { return (await identityOf(username)).subject; }
const live = createLiveClient({ config, fetchImpl: ctx.fetchImpl || globalThis.fetch, log });
const live = createLiveClient({ config, fetchImpl: ctx.fetchImpl || globalThis.fetch, log });
const chat = createChatClient({ config, fetchImpl: ctx.fetchImpl || globalThis.fetch, log });
const chat = createChatClient({ config, fetchImpl: ctx.fetchImpl || globalThis.fetch, log });
+ const shelves = createSearchReads({ config, fetchImpl: ctx.fetchImpl || globalThis.fetch, log });
const r = asyncRouter();
const r = asyncRouter();
const base = String(config.video.url).replace(/\/+$/, '');
const base = String(config.video.url).replace(/\/+$/, '');
const livePublic = String(config.video.livePublicUrl).replace(/\/+$/, '');
const livePublic = String(config.video.livePublicUrl).replace(/\/+$/, '');
@@ -735,32 +751,157 @@ ${kind === 'vod' ? html`<label class="ovv-field"><span>Description</span><textar
@@ -735,32 +751,157 @@ ${kind === 'vod' ? html`<label class="ovv-field"><span>Description</span><textar
}
}
// ── Home ────────────────────────────────────────────────
// ── 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) => {
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: 'vods', limit: HOME_LIMIT + 6 }),
live.feed({ type: 'clips', limit: HOME_LIMIT }),
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
// 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.
// 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 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, {
return page(req, res, {
index: true,
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: [
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>
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>` : ''}
${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
@@ -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
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) => {
r.get('/sitemap.xml', async (req, res) => {
if (sitemapCache && Date.now() - sitemapCache.at < SITEMAP_TTL_MS) {
if (sitemapCache && Date.now() - sitemapCache.at < SITEMAP_TTL_MS) {
return res.type('application/xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(sitemapCache.xml);
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 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;
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.
// 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
@@ -1312,6 +1483,8 @@ ${isOwner ? html`<p class="ovv-fine">You can reorder items with the up and down
const urls = [
const urls = [
{ loc: `${base}/`, lastmod: newest(items.map((it) => it.created_at)) },
{ loc: `${base}/`, lastmod: newest(items.map((it) => it.created_at)) },
{ loc: `${base}/updates` },
{ 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) => {
...items.map((it) => {
const video = sitemapVideo(it);
const video = sitemapVideo(it);
return { loc: `${base}${it.kind === 'clip' ? '/c/' : '/v/'}${Number(it.id)}`, lastmod: day(it.created_at), ...(video ? { videos: [video] } : {}) };
return { loc: `${base}${it.kind === 'clip' ? '/c/' : '/v/'}${Number(it.id)}`, lastmod: day(it.created_at), ...(video ? { videos: [video] } : {}) };
new file mode 100644
new file mode 100644
@@ -0,0 +1,98 @@
@@ -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 };
@@ -104,19 +104,45 @@ function startLive() {
@@ -104,19 +104,45 @@ function startLive() {
if (state.down) return json(503, { error: 'Live is down' });
if (state.down) return json(503, { error: 'Live is down' });
const p = url.pathname;
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') {
if (p === '/api/content/feed') {
const type = url.searchParams.get('type');
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 === '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 });
if (type === 'clips') return json(200, { items: [clipFeedItem], next: null });
return json(200, { items: [], next: null });
return json(200, { items: [], next: null });
}
}
if (p === '/api/vods') {
if (p === '/api/vods') {
const username = url.searchParams.get('username');
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 });
if (username === 'streamer') return json(200, { vods: [vodListItem], total: 1, hasMore: false });
return json(200, { vods: [], total: 0, hasMore: false });
return json(200, { vods: [], total: 0, hasMore: false });
}
}
if (p === '/api/clips') {
if (p === '/api/clips') {
const username = url.searchParams.get('username');
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 });
if (username === 'streamer') return json(200, { clips: [clipListItem], total: 1, hasMore: false });
return json(200, { clips: [], total: 0, hasMore: false });
return json(200, { clips: [], total: 0, hasMore: false });
}
}
@@ -202,6 +228,8 @@ function countOccurrences(haystack, needle) {
@@ -202,6 +228,8 @@ function countOccurrences(haystack, needle) {
MEDIAHUB_LIVE_URL: live.url,
MEDIAHUB_LIVE_URL: live.url,
MEDIAHUB_LIVE_PUBLIC_URL: 'https://openvibe.live',
MEDIAHUB_LIVE_PUBLIC_URL: 'https://openvibe.live',
MEDIAHUB_CHAT_URL: live.url,
MEDIAHUB_CHAT_URL: live.url,
+ MEDIAHUB_SEARCH_URL: live.url,
+ MEDIAHUB_VIDEO_SEARCH: 'off',
},
},
});
});
const getVideo = (p) => request(t.base, p, 'openvibe.video');
const getVideo = (p) => request(t.base, p, 'openvibe.video');
@@ -242,7 +270,7 @@ function countOccurrences(haystack, needle) {
@@ -242,7 +270,7 @@ function countOccurrences(haystack, needle) {
const r = await getVideo('/');
const r = await getVideo('/');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
assert.ok(r.text.includes('OpenVibe.Video'));
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('Latest clips'));
assert.ok(r.text.includes('/v/101'));
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])));
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) {
@@ -361,6 +389,51 @@ function countOccurrences(haystack, needle) {
assert.strictEqual(r.text, 'WEBVTT\n\n00:00:10.000 --> 00:00:30.000\nIntro & setup\n\n00:00:30.000 --> 00:00:45.000\nThe <main> part\n');
assert.strictEqual(r.text, 'WEBVTT\n\n00:00:10.000 --> 00:00:30.000\nIntro & setup\n\n00:00:30.000 --> 00:00:45.000\nThe <main> 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(/'/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 () => {
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;
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\}\]/);
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\}\]/);