openvibers/openvibe.live Commits
Build Live SEO, discovery files and cache headers with openvibe-shared (#10)
Changes
@@@ -11,7 +11,7 @@ Every stream that ran at least 8 minutes gets a report a couple of minutes after- **The room, minute by minute** — viewer curve with chat bars, peak marker.- Announced in the channel's chat room when it lands. Stored in `stream_recaps` (one JSON row perstream). Owner/admin can rebuild it (`POST /api/recap/:id/regenerate`).--- Crawlable: `/recap/:id` gets OG/Twitter meta + Article JSON-LD (`server/seo/seo.js`), so a++- Crawlable: `/recap/:id` gets OG/Twitter meta + Article JSON-LD (`server/seo/pages.js`), so ashared link previews with the headline and the VOD thumbnail.API: `GET /api/recap/:streamId` (builds on first view if missing), `GET /api/recap/channel/:username`.@@@ -27,7 +27,7 @@"onnxruntime-node": "^1.20.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.83.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.16.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.4.0","pg": "^8.23.1","sanitize-html": "^2.17.7","sdp-transform": "^3.0.0",@@@ -2860,9 +2860,9 @@}},"node_modules/openvibe-shared": {-- "version": "2.3.1",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1",-- "integrity": "sha512-K+MXnB4O+8xIFi+NQGC2SDTvDq3RkzfxQ3bTVx76Y/Rbuq7kC/L5kL3c/06xr9sPYcjyzF3Bhpv5yH2YNfR4Zw==",++ "version": "2.4.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.4.0",++ "integrity": "sha512-DmFlDcVlT/WRza0M4h0xf+WiwM4FsiJ0WarIesOYByE0v/JYZcoIdjYUvVB5GzshJ7QmFHJLnJsNmdJqj7L1kg==","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0"@@@ -48,7 +48,7 @@"onnxruntime-node": "^1.20.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.83.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.16.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.4.0","pg": "^8.23.1","sanitize-html": "^2.17.7","sdp-transform": "^3.0.0",@@@ -13,21 +13,26 @@const fs = require('fs');const path = require('path');const zlib = require('zlib');++const { check, format, assetsOf } = require('openvibe-shared/perf-budget');const ROOT = path.join(__dirname, '..', '..');++// openvibe-shared/perf-budget names; "js" is the eager scripts (incl. route scripts for "/"),++// "css" the render-blocking stylesheets.const BUDGETS = {htmlRawKB: 120, // measured 94.9htmlBrotliKB: 30,-- eagerJsFiles: 26, // measured 23 (incl. route scripts for "/")-- eagerJsRawKB: 1150, // measured ~1000-- eagerJsBrotliKB: 260,-- blockingCssRawKB: 760, // measured ~717 (style.css + home + icons + i18n)-- blockingCssBrotliKB: 130,++ jsFiles: 26, // measured 23 (incl. route scripts for "/")++ jsRawKB: 1150, // measured ~1000++ jsBrotliKB: 260,++ cssRawKB: 760, // measured ~717 (style.css + home + icons + i18n)++ cssBrotliKB: 130,};// Code that must never be part of the home page's first load.const FORBIDDEN_ON_HOME = ['/js/broadcast.js', '/js/broadcast-workspace.js', '/js/dashboard.js', '/js/call.js', '/js/voice-channels.js','/js/stream-player.js', '/js/app-channel.js', '/js/arena.js', '/js/pastes.js', '/css/features/broadcast.css', '/css/features/channel.css'];++// perf-budget's measure() fetches from a running server; this script stays offline, so it reads++// what the server would render for "/" and hands check() the same measurement shape.const assets = require(path.join(ROOT, 'server/web/assets'));const SHARED_DIR = require('openvibe-shared/files').dir;try { assets.setSharedDir(SHARED_DIR); } catch { /* */ }@@@ -36,33 +41,29 @@ const fileFor = (url) => {const p = url.split('?')[0];return p.startsWith('/shared/') ? path.join(SHARED_DIR, path.basename(p)) : path.join(ROOT, 'public', p);};--const kb = (n) => +(n / 1024).toFixed(1);++const kb = (n) => Math.round((n / 1024) * 10) / 10;const br = (buf) => zlib.brotliCompressSync(buf, { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 11 } }).length;--const scripts = [...html.matchAll(/<script src="([^"]+)"/g)].map((m) => m[1]).filter((s) => s.startsWith('/'));// <noscript> fallbacks are not fetched by browsers with JavaScript on.--const blockingCss = [...html.replace(/<noscript>[\s\S]*?<\/noscript>/gi, '').matchAll(/<link rel="stylesheet" href="([^"]+)"(?![^>]*media="print")[^>]*>/g)].map((m) => m[1]).filter((s) => s.startsWith('/'));++const named = assetsOf(html.replace(/<noscript>[\s\S]*?<\/noscript>/gi, ''));++const local = (u) => u.startsWith('/') && !u.startsWith('//');++const scripts = named.js.filter(local);++const blockingCss = named.css.filter(local);const read = (list) => list.map((u) => { try { return fs.readFileSync(fileFor(u)); } catch { return Buffer.alloc(0); } });--const jsBufs = read(scripts), cssBufs = read(blockingCss);--const sum = (arr, f) => arr.reduce((n, b) => n + f(b), 0);--++const total = (list) => {++ const bufs = read(list);++ return { files: list.length, rawKB: kb(bufs.reduce((n, b) => n + b.length, 0)), brotliKB: kb(bufs.reduce((n, b) => n + br(b), 0)) };++};++const htmlBuf = Buffer.from(html);const measured = {-- htmlRawKB: kb(Buffer.byteLength(html)),-- htmlBrotliKB: kb(br(Buffer.from(html))),-- eagerJsFiles: scripts.length,-- eagerJsRawKB: kb(sum(jsBufs, (b) => b.length)),-- eagerJsBrotliKB: kb(sum(jsBufs, br)),-- blockingCssRawKB: kb(sum(cssBufs, (b) => b.length)),-- blockingCssBrotliKB: kb(sum(cssBufs, br)),++ url: '/', html: { rawKB: kb(htmlBuf.length), brotliKB: kb(br(htmlBuf)) },++ js: total(scripts), css: total(blockingCss),++ external: [...named.js, ...named.css].filter((u) => !local(u)), urls: { js: scripts, css: blockingCss },};--let failed = 0;--for (const [k, limit] of Object.entries(BUDGETS)) {-- const v = measured[k];-- const ok = v <= limit;-- if (!ok) failed++;-- console.log(`${ok ? '✓' : '✗'} ${k.padEnd(20)} ${String(v).padStart(8)} / ${limit}`);--}++const over = check(measured, BUDGETS);++console.log(format(measured, over));++let failed = over.length;for (const f of FORBIDDEN_ON_HOME) {if (scripts.some((s) => s.startsWith(f + '?') || s === f) || blockingCss.some((s) => s.startsWith(f + '?') || s === f)) {failed++;@@@ -15,6 +15,7 @@const express = require('express');const fs = require('fs');const path = require('path');++const cache = require('openvibe-shared/cache-policy');const router = express.Router();// Through OV_APP_ROOT (the `current` release symlink) when set, so doc edits go live without a restart.@@@ -216,7 +217,7 @@ function renderMarkdown(markdown) {// ── Page shell ───────────────────────────────────────────────--// The public origin for canonical URLs: the configured base URL, never a local one (as server/seo/seo.js).++// The public origin for canonical URLs: the configured base URL, never a local one (as server/seo/pages.js).let config = null; try { config = require('../config'); } catch { /* */ }function publicBase() {let b = (config && config.baseUrl) || 'https://openvibe.live';@@@ -318,7 +319,7 @@ function sendDoc(req, res, name) {const markdown = loadDoc(name);if (markdown === null) return res.status(404).type('text/plain').send('No such doc');const rendered = renderMarkdown(markdown);-- res.set('Cache-Control', 'public, max-age=300');++ res.set('Cache-Control', cache.htmlHeaders({ maxAge: 300 }));res.type('html').send(renderPage({ name, ...rendered }));}@@@ -69,6 +69,7 @@ const cookieParser = require('cookie-parser');const rateLimit = drill.enabled ? () => (req, res, next) => next() : require('express-rate-limit');const config = require('./config');const assets = require('./web/assets');++const cache = require('openvibe-shared/cache-policy');// Starts the event-loop delay histogram at boot so the first diagnostics window is complete.require('./diagnostics');@@@ -506,8 +507,9 @@ console.log(`[Server] /shared: serving ${SHARED_BROWSER_FILES.length} browser fires.setHeader('Access-Control-Allow-Origin', '*');res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');// Same deal as /js and /css: only a ?v= that matches the file's content hash is immutable.-- if (req.query.v && req.query.v === assets.hashOf('/shared/' + fileName)) assets.setImmutable(res);-- else res.setHeader('Cache-Control', 'public, max-age=300');++ const hashed = Boolean(req.query.v) && req.query.v === assets.hashOf('/shared/' + fileName);++ res.setHeader('Cache-Control', cache.assetHeaders(req.originalUrl, { hashed }));++ if (hashed) res.setHeader('CDN-Cache-Control', cache.IMMUTABLE);res.setHeader('Content-Type', 'application/javascript; charset=utf-8');res.sendFile(filePath, (err) => {if (err && !res.headersSent) {@@@ -544,7 +546,7 @@ app.use('/fragments', assets.versionedStatic('/fragments'), express.static(path.// Pastes live on openvibe.community: /p/<slug> 301s there, and it is mounted// before SEO so crawlers and people get the same redirect and Community is the one canonical page.require('./web/paste-handover').register(app);--try { require('./seo/seo').register(app); } catch (e) { console.warn('[SEO] not registered:', e.message); }++try { require('./seo').register(app); } catch (e) { console.warn('[SEO] not registered:', e.message); }// Standalone HTML pages (popout chat, kiosk, legal, OBS overlays…) go through the same asset rewrite// as the SPA shell, so their script and stylesheet URLs can never drift out of date again.app.use((req, res, next) => {@@@ -1032,10 +1034,10 @@ app.get('/banned', (req, res) => {// paste or stream, or a private item this visitor may not see) gets it with a 404 status, so// search engines and monitors see a real 404; the client renders its not-found view either way.// API paths still get the JSON 404. See server/web/page-status.js for the page list.--// The shell comes from server/seo/seo.js shellHtml: a 404 is noindex with no canonical (the shell's++// The shell comes from server/seo/pages.js shellHtml: a 404 is noindex with no canonical (the shell's// own head describes the home page), and no other page claims the home page as its canonical.let _shellHtml = null;--try { _shellHtml = require('./seo/seo').shellHtml; } catch { _shellHtml = null; }++try { _shellHtml = require('./seo').shellHtml; } catch { _shellHtml = null; }function sendShell(res, urlPath) {let html = null;try { html = _shellHtml ? _shellHtml(urlPath, res.statusCode) : null; } catch { html = null; }@@@ -14,6 +14,7 @@ const access = require('./access');const purge = require('./purge');const { commentCount } = require('./comments');const lineage = require('../lineage/resolver');++const cache = require('openvibe-shared/cache-policy');const router = express.Router();@@@ -221,7 +222,7 @@ router.get('/:id', optionalAuth, async (req, res) => {return mediaErr(res, err, 'Failed to get clip');}if (!clip || !access.canView(req.user, clip)) return clipNotFound(res);-- if (access.isPrivate(clip)) res.set('Cache-Control', 'private, no-store');++ if (access.isPrivate(clip)) res.set('Cache-Control', cache.htmlHeaders({ private: true }));withUserFields(clip);// Unique-view tracking (content_views stays in live.db).@@@ -411,7 +412,7 @@ router.get('/:id/job', requireAuth, async (req, res) => {try { out = await media.getJob(jobId); } catch (err) { return mediaErr(res, err, 'Job not found'); }const job = out && out.job;if (!job || job.type !== 'clip.cut' || Number(job.params && job.params.clip_id) !== Number(clip.id)) return res.status(404).json({ error: 'Job not found' });-- res.set('Cache-Control', 'private, no-store').json({++ res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({job: { id: job.id, status: job.status, attempts: job.attempts, max_attempts: job.max_attempts, error: job.status === 'failed' ? job.error || null : null, run_after: job.run_after || null },clip: { id: clip.id, status: clip.status, title: clip.title, duration_seconds: clip.duration_seconds },});@@@ -18,6 +18,7 @@ const permissions = require('../auth/permissions');const access = require('./access');const purge = require('./purge');const { commentCount } = require('./comments');++const cache = require('openvibe-shared/cache-policy');const router = express.Router();@@@ -417,7 +418,7 @@ router.get('/:id/context', optionalAuth, async (req, res) => {const isPrivate = access.isPrivate(vod);if (!access.canView(req.user, vod)) return vodNotFound(res);// A private VOD's context must not sit in a shared cache anywhere on the way back either.-- const cacheHeader = isPrivate ? 'private, no-store' : 'public, max-age=60';++ const cacheHeader = isPrivate ? cache.htmlHeaders({ private: true }) : 'public, max-age=60';const hit = _ctxCache.get(id);if (hit && Date.now() - hit.at < 120000) { res.set('Cache-Control', cacheHeader); return res.json(hit.data); }@@@ -469,7 +470,7 @@ router.get('/:id', optionalAuth, async (req, res) => {// carrying the title and username, which is what anonymous callers used to get.const vod = await loadVisibleVod(req, res, req.params.id);if (!vod) return;-- if (access.isPrivate(vod)) res.set('Cache-Control', 'private, no-store');++ if (access.isPrivate(vod)) res.set('Cache-Control', cache.htmlHeaders({ private: true }));withUserFields(vod);// Enrich with local stream details for chat replay.nnew file mode 100644@@@ -0,0 +1,178 @@++/**++ * Discovery files: /sitemap.xml, /robots.txt, /llms.txt and /llms-full.txt, built with++ * openvibe-shared/seo from what Live knows (the VODs and clips Media holds, the docs folder).++ */++'use strict';++const fs = require('node:fs');++const path = require('node:path');++const seo = require('openvibe-shared/seo');++const cache = require('openvibe-shared/cache-policy');++const db = require('../db/database');++const media = require('../media-client');++const { SITE_NAME, baseUrl, isAiClip, isoDate } = require('./pages');++++const DOCS_DIR = process.env.OV_APP_ROOT ? path.join(process.env.OV_APP_ROOT, 'docs') : path.join(__dirname, '../../docs');++const DISCOVERY_MAX_AGE = 3600; // seconds, for every file here++++// ── Dynamic sitemap.xml (cached ~1h) ────────────────────────────────────────────────────────++let _sitemap = null, _sitemapAt = 0;++const SITEMAP_TTL_MS = 60 * 60 * 1000;++const SITEMAP_CAP = 5000; // per content type++async function buildSitemap() {++ const b = baseUrl();++ const urls = [];++ const entry = (loc, lastmod, changefreq, priority) => urls.push({ loc: b + loc, lastmod: lastmod || undefined, changefreq, priority });++ const seenChannels = new Set();++ // Media rows carry Live user ids, not names: a channel is named from Live's accounts (banned++ // accounts are left out).++ const names = new Map();++ const channelOf = (row, id) => {++ if (row.username) return row.username;++ if (id == null) return null;++ if (!names.has(id)) {++ let u = null;++ try { u = db.getUserById(Number(id)); } catch { u = null; }++ names.set(id, u && !(u.is_banned === 1 || u.is_banned === true) ? u.username : null);++ }++ return names.get(id);++ };++ const statics = [['/', 'daily', 1.0], ['/content', 'hourly', 0.9], ['/moments', 'hourly', 0.7], ['/vods', 'hourly', 0.8], ['/clips', 'hourly', 0.8], ['/pastes', 'hourly', 0.7], ['/chat', 'daily', 0.5], ['/arena', 'hourly', 0.6]];++ // Rendered docs (server/docs/routes.js): /docs is the index (README.md), the rest by file name.++ try {++ for (const f of fs.readdirSync(DOCS_DIR)) {++ if (!f.endsWith('.md')) continue;++ statics.push([f === 'README.md' ? '/docs' : `/docs/${f.slice(0, -3)}`, 'weekly', 0.5]);++ }++ } catch { /* docs folder absent in some deploys */ }++ for (const [u, cf, pr] of statics) entry(u, null, cf, pr);++ const page = async (fetch, per, cap, emit) => {++ let off = 0;++ while (off < cap) {++ let rows = [];++ try { rows = (await fetch(per, off)) || []; } catch { break; }++ for (const r of rows) emit(r);++ if (rows.length < per) break;++ off += per;++ }++ };++ await page((l, o) => media.listVods({ limit: l, offset: o }).then(r => r?.vods || []), 200, SITEMAP_CAP, (v) => {++ entry(`/vod/${v.id}`, isoDate(v.created_at), 'weekly', 0.6);++ const ch = channelOf(v, v.user_id);++ if (ch) seenChannels.add(ch);++ });++ // People's clips only: AI Moments are noindex and never listed (the /moments++ // collection above is their indexable form). The filter is checked again per row, so an++ // upstream that ignores it still cannot put an AI item here.++ await page((l, o) => media.listClips({ limit: l, offset: o, auto_generated: 0 }).then(r => r?.clips || []), 200, SITEMAP_CAP, (c) => {++ if (isAiClip(c)) return;++ entry(`/clip/${c.id}`, isoDate(c.created_at), 'weekly', 0.6);++ const ch = channelOf({}, c.channel_user_id != null ? c.channel_user_id : c.user_id);++ if (ch) seenChannels.add(ch);++ });++ // No /p/ here: pastes are OpenVibe.Community's, and its sitemap lists them under their canonical URL.++ for (const u of seenChannels) if (u) entry(`/@${u}`, null, 'daily', 0.6);++ return seo.sitemapXml(urls);++}++async function sitemapHandler(req, res) {++ if (!_sitemap || (Date.now() - _sitemapAt) > SITEMAP_TTL_MS) {++ try { _sitemap = await buildSitemap(); _sitemapAt = Date.now(); }++ catch (e) { console.warn('[SEO] sitemap build failed:', e.message); if (!_sitemap) return res.status(500).end(); }++ }++ res.set('Content-Type', 'application/xml; charset=utf-8');++ res.set('Cache-Control', cache.htmlHeaders({ maxAge: DISCOVERY_MAX_AGE }));++ res.send(_sitemap);++}++++// ── /robots.txt: every crawler welcome, search and AI alike, pointed at the sitemap ────────++function robotsTxt() {++ return seo.robotsTxt({ sitemaps: [`${baseUrl()}/sitemap.xml`] });++}++++// ── /llms.txt: what this site is, for language models and other automated readers ─────────++// (llmstxt.org). It names the public pages, the JSON behind them, the API docs, and how people's++// work is kept apart from what the AI derived from it (roadmap 32.4, 33.8).++const LLMS_DOCS = [++ ['go-live-in-your-browser', 'Go live from a browser: no OBS, no downloads, no follower minimum (how, devices, questions)'],++ ['whip', 'WHIP ingest API: publish to a channel from a browser or any WHIP client'],++ ['broadcasting', 'Going live: WebRTC, WHIP, RTMP (OBS) and the JSMPEG/CLI path'],++ ['api-tokens', 'Bot and integration tokens (hbt_...) for the API'],++ ['chat-system', 'Chat features and moderation'],++ ['vods-and-clips', 'How VODs and clips are recorded and cut'],++ ['architecture', 'System design and how Live fits the OpenVibe network'],++];++const LLMS_SUMMARY = 'Open-source, community-run live streaming. Anyone can go live straight from a web browser, with no OBS, no downloads and no follower, subscriber or equipment requirement: press Go Live, allow camera and microphone, and the stream is live (WebRTC, under a second of delay; Chromebooks, laptops and phones). OBS (RTMP), WHIP and command-line ingest are there for produced shows. Every stream can be recorded as a VOD, clipped and discussed. Part of the OpenVibe network: one account across openvibe.network, openvibe.media, openvibe.community, openvibe.tools and the other OpenVibe sites.';++const _docsPresent = () => LLMS_DOCS.filter(([name]) => { try { return fs.existsSync(path.join(DOCS_DIR, `${name}.md`)); } catch { return false; } });++++function llmsTxt() {++ const b = baseUrl();++ return seo.llmsTxt({++ name: SITE_NAME,++ summary: LLMS_SUMMARY,++ details: 'People\'s work and AI-made material are kept apart everywhere on this site. The Content pages list what people made; AI Moments list what the platform\'s AI derived from streams. Every AI item is labelled AI-generated, credited to no person, marked noindex,follow and made canonical to the source VOD at the moment it came from (/vod/<id>?t=<seconds>). In the JSON feeds, AI items carry "ai": true and an "ai_label".',++ sections: [++ { title: 'Going live from a browser (no OBS)', links: [++ { title: 'How to go live in your browser', url: `${b}/docs/go-live-in-your-browser`, note: `open ${b}, sign in, press Go Live, allow camera and microphone, start. No software, no follower minimum.` },++ { title: 'The broadcaster', url: `${b}/broadcast`, note: 'camera switching, screen/window/tab sharing with a camera picture-in-picture, live stats, chat beside the stream.' },++ { title: 'Browser publishing for other sites', url: `${b}/docs/whip#publishing-from-a-browser`, note: 'any web page can publish to a channel over WHIP.' },++ ] },++ { title: 'What people made', links: [++ { title: 'Home', url: `${b}/`, note: 'who is live now, recent VODs and clips' },++ { title: 'Content', url: `${b}/content`, note: `VODs, the clips people took, and pastes people wrote. JSON: ${b}/api/content/feed (cursor paging; ?type=vods|clips|pastes, ?sort=new|top)` },++ { title: 'Channel pages', url: `${b}/@<username>`, note: `the channel's videos in pages (${b}/@<username>?page=2)` },++ { title: 'VOD pages', url: `${b}/vod/<id>`, note: `a moment in a VOD: ${b}/vod/<id>?t=<seconds>` },++ { title: 'Clip pages', url: `${b}/clip/<id>` },++ { title: 'Pastes', url: 'https://openvibe.community/p/<slug>', note: 'pastes live on OpenVibe.Community (Live\'s /p/<slug> redirects there)' },++ { title: 'Chat', url: `${b}/chat` },++ { title: 'The Arena', url: `${b}/arena`, note: 'mic-judged streamer callouts' },++ ] },++ { title: 'What the AI made (AI Moments)', links: [++ { title: 'AI Moments', url: `${b}/moments`, note: `auto-clips cut when chat reacted, frames the AI picked from streams, and AI-written after-show recaps. JSON: ${b}/api/content/moments. AI Moments pages are noindex; the /moments collection is their indexable form. They are never listed in the sitemap. An AI clip says "AI clip · from <streamer>'s stream"; it is never presented as something the streamer or a viewer clipped. A streamer can turn AI Moments off for their channel; the AI then makes no new ones from their streams.` },++ ] },++ { title: 'API and docs', links: [++ { title: 'API docs', url: `${b}/documentation`, note: 'every feature has an open API (chat, streams, clips, overlays, robots, sound commands)' },++ { title: 'Docs index', url: `${b}/docs` },++ ..._docsPresent().map(([name, what]) => ({ title: name, url: `${b}/docs/${name}`, note: what })),++ { title: 'Source code', url: 'https://github.com/OpenVibers/OpenVibe.Live' },++ ] },++ { title: 'Discovery', links: [++ { title: 'Sitemap', url: `${b}/sitemap.xml`, note: 'people\'s work only' },++ { title: 'robots.txt', url: `${b}/robots.txt` },++ { title: 'llms-full.txt', url: `${b}/llms-full.txt`, note: 'the docs above in full, as plain text' },++ { title: 'OpenVibe platform descriptor', url: 'https://openvibe.network/.well-known/openvibe', note: 'the OpenVibe network\'s descriptor' },++ ] },++ ],++ });++}++++// ── /llms-full.txt: the same header, then the docs themselves ──────────────────────────────++const LLMS_FULL_DOC_CHARS = 40_000;++const LLMS_FULL_TOTAL_CHARS = 400_000;++let _llmsFull = null, _llmsFullAt = 0;++function llmsFullTxt() {++ if (_llmsFull && Date.now() - _llmsFullAt < DISCOVERY_MAX_AGE * 1000) return _llmsFull;++ const sections = [];++ for (const [name, what] of _docsPresent()) {++ let body = '';++ try { body = fs.readFileSync(path.join(DOCS_DIR, `${name}.md`), 'utf8'); } catch { continue; }++ sections.push({ title: what, url: `/docs/${name}`, body });++ }++ _llmsFull = seo.llmsFull({ site: { name: SITE_NAME, url: baseUrl() }, summary: LLMS_SUMMARY, sections, maxChars: LLMS_FULL_DOC_CHARS, maxTotal: LLMS_FULL_TOTAL_CHARS });++ _llmsFullAt = Date.now();++ return _llmsFull;++}++++const textHandler = (build) => (req, res) => {++ res.set('Content-Type', 'text/plain; charset=utf-8');++ res.set('Cache-Control', cache.htmlHeaders({ maxAge: DISCOVERY_MAX_AGE }));++ res.send(build());++};++++/** Mounted before express.static, so /robots.txt here wins over any file in public/. */++function register(app) {++ app.get('/sitemap.xml', sitemapHandler);++ app.get('/robots.txt', textHandler(robotsTxt));++ app.get('/llms.txt', textHandler(llmsTxt));++ app.get('/llms-full.txt', textHandler(llmsFullTxt));++}++++module.exports = { register, sitemapHandler, buildSitemap, robotsTxt, llmsTxt, llmsFullTxt };nnew file mode 100644@@@ -0,0 +1,22 @@++/**++ * Live's SEO: per-route head tags and no-JS snapshots (./pages.js) and the discovery files++ * (./discovery.js), both built with openvibe-shared/seo.++ */++'use strict';++const pages = require('./pages');++const discovery = require('./discovery');++++// Discovery files first, then the meta middleware. MUST be mounted BEFORE express.static (so it++// can intercept "/" and /robots.txt) and before the SPA catch-all.++function register(app) {++ discovery.register(app);++ app.use(pages.middleware);++ console.log('[SEO] per-route meta + sitemap.xml + robots.txt + llms.txt + llms-full.txt registered');++}++++module.exports = {++ register,++ middleware: pages.middleware, shellHtml: pages.shellHtml, render: pages.render, _pageMeta: pages._pageMeta,++ sitemapHandler: discovery.sitemapHandler, buildSitemap: discovery.buildSitemap,++ robotsTxt: discovery.robotsTxt, llmsTxt: discovery.llmsTxt, llmsFullTxt: discovery.llmsFullTxt,++};nnew file mode 100644@@@ -0,0 +1,1033 @@++/**++ * SEO for the SPA — per-route server-side meta / Open Graph / Twitter / JSON-LD injection (tags++ * built with openvibe-shared/seo) and a no-JS crawlable content snapshot. Cached throughout.++ * The sitemap, robots.txt and llms files are in ./discovery.js.++ *++ * The app is a client-rendered SPA (server/index.js serves public/index.html for every route),++ * so crawlers/AI scrapers see an empty shell. This middleware intercepts the HTML routes we++ * care about (home, vods/clips/pastes lists, and vod/clip/paste detail pages), and rewrites the++ * <head> with route-specific machine-readable metadata + drops a <noscript> content snapshot so++ * no-JS scrapers get real content. Everything is cached so it adds ~no per-request DB cost.++ *++ * AI Moments (auto-clips, AI moment pastes, AI recaps) are labelled AI-made, credited to no++ * person, noindex,follow, canonical to their source VOD and left out of the sitemap (see++ * "AI Moments" below). People's work keeps normal, indexable pages.++ */++'use strict';++const path = require('node:path');++const seo = require('openvibe-shared/seo');++const { esc } = seo;++const db = require('../db/database');++const media = require('../media-client');++let config = null; try { config = require('../config'); } catch { /* */ }++++// ── Short-cached Media content (VODs/clips/pastes live in OpenVibe.Media now) ──++const _mc = new Map(); // key → { v, at }++const MC_TTL_MS = 60_000;++async function _cached(key, fn) {++ const e = _mc.get(key);++ if (e && Date.now() - e.at < MC_TTL_MS) return e.v;++ let v;++ try { v = await fn(); } catch (err) {++ // A missing id is remembered too: otherwise every request for a dead link (the SPA fallback++ // then answers it 404, see server/web/page-status.js) is another round trip to Media.++ if (!err || (err.status !== 404 && err.status !== 410)) return e ? e.v : null;++ v = null;++ }++ _mc.set(key, { v, at: Date.now() });++ if (_mc.size > 300) _mc.delete(_mc.keys().next().value);++ return v;++}++const _vodList = (limit, offset = 0) => _cached(`vl:${limit}:${offset}`, async () => (await media.listVods({ limit, offset }))?.vods || []);++// People's clips and pastes: the AI's are listed on /moments (server/content/feed.js).++const _clipList = (limit, offset = 0) => _cached(`cl:${limit}:${offset}`, async () => (await media.listClips({ limit, offset, auto_generated: 0 }))?.clips || []);++const _pasteList = (limit, offset = 0) => _cached(`pl:${limit}:${offset}`, async () => (await require('../pastes-client').listPastes({ limit, offset, visibility: 'public', origin: 'user' }))?.pastes || []);++/**++ * Page `page` of the Content or Moments feed, as list-page items: { items, more }. The feed pages by++ * cursor, so page N follows N-1 `next` cursors (each page cached; at most FEED_MAX_PAGE deep), which++ * gives crawlers plain ?page=N links (roadmap D44) while the SPA keeps its infinite scroll.++ */++const FEED_MAX_PAGE = 20;++const FEED_PAGE_SIZE = 24;++const _feedPage = (feed, page = 1) => _cached(`feed:${feed}:${page}`, async () => {++ const f = require('../content/feed');++ let cursor = null, out = null;++ for (let n = 1; n <= page; n++) {++ out = await f.page(feed, { limit: FEED_PAGE_SIZE, ...(cursor ? { cursor } : {}) });++ if (n < page) { if (!out.next) return { items: [], more: false }; cursor = out.next; }++ }++ const kindLabel = { vod: 'VOD', clip: 'clip', paste: 'paste', recap: 'recap' };++ return {++ items: (out.items || []).map((it) => ({++ url: it.kind === 'paste' ? pasteHref(it.id) : it.href, name: it.title, by: it.channel ? it.channel.display_name : null,++ meta: [kindLabel[it.kind], it.duration_seconds ? _fmtDur(it.duration_seconds) : null].filter(Boolean).join(' · '),++ desc: it.excerpt,++ })),++ more: Boolean(out.next) && page < FEED_MAX_PAGE,++ };++});++const _feedList = async (feed) => (await _feedPage(feed, 1)).items;++const { isPrivate } = require('../media-proxy/access');++const pageStatus = require('../web/page-status');++++// ── One item (VOD, clip, paste) ──++// Fetched at most once at a time per id and kept a minute (a missing id 30 s, a failure not at++// all). A page waits at most DETAIL_WAIT_MS for it, so a slow Media or Community never holds a++// page, or a crawler, for its whole timeout; the fetch keeps running and fills the cache. What a++// fresh fetch learned is handed to the SPA fallback's status check (server/web/page-status.js):++// a page this renderer passes on (missing, or private to this visitor) costs one upstream call.++const DETAIL_WAIT_MS = Math.max(0, pageStatus.LOOKUP_DEADLINE_MS - 500);++const DETAIL_FOUND_TTL_MS = 60_000;++const DETAIL_MISSING_TTL_MS = 30_000;++const _items = new Map(); // key → { at, ttl, state: 'found'|'missing'|null, row, pending }++function _detail(key, load) {++ let e = _items.get(key);++ if (e && !e.pending && e.state && Date.now() - e.at < e.ttl) return Promise.resolve({ state: e.state, row: e.row, fresh: false });++ if (!e || !e.pending) {++ const entry = { at: 0, ttl: 0, state: null, row: null, pending: null };++ entry.pending = Promise.resolve().then(load).then(++ (row) => { if (row && typeof row === 'object') { entry.state = 'found'; entry.row = row; entry.ttl = DETAIL_FOUND_TTL_MS; } else { entry.state = 'missing'; entry.ttl = DETAIL_MISSING_TTL_MS; } },++ (err) => { if (err && (err.status === 404 || err.status === 410)) { entry.state = 'missing'; entry.ttl = DETAIL_MISSING_TTL_MS; } },++ ).then(() => {++ entry.at = Date.now();++ entry.pending = null;++ if (!entry.state && _items.get(key) === entry) _items.delete(key); // a failure is not remembered++ return { state: entry.state || 'unknown', row: entry.row, fresh: true };++ });++ _items.delete(key);++ _items.set(key, entry);++ if (_items.size > 1000) _items.delete(_items.keys().next().value);++ e = entry;++ }++ let timer;++ const late = new Promise((resolve) => { timer = setTimeout(resolve, DETAIL_WAIT_MS, { state: 'unknown', row: null, fresh: false }); });++ return Promise.race([e.pending, late]).finally(() => clearTimeout(timer));++}++// Callers may annotate the row they get (AI state overlay), so each gets its own copy.++const _copy = (r) => (r.row ? { ...r.row } : null);++async function _vodGet(id) {++ const r = await _detail(`v:${id}`, () => media.getVod(id));++ if (r.fresh && r.state !== 'unknown') pageStatus.primeMediaItem('vod', String(id), r.row);++ return _copy(r);++}++async function _clipGet(id) {++ const r = await _detail(`c:${id}`, () => media.getClip(id));++ if (r.fresh && r.state !== 'unknown') pageStatus.primeMediaItem('clip', String(id), r.row);++ return _copy(r);++}++async function _pasteGet(slug) {++ const r = await _detail(`p:${slug}`, () => require('../pastes-client').getPaste(slug));++ if (r.fresh && r.state !== 'unknown') pageStatus.primePaste(slug, r.state === 'found');++ return _copy(r);++}++++// Lists (a channel's VODs, a VOD's clips, the home rails) are extras on a page: each waits at++// most LIST_WAIT_MS, and a page rendered without one is cached briefly so it fills in soon.++const LIST_WAIT_MS = 1500;++const SHORT_CACHE_MS = 30_000;++const _TIMED_OUT = Symbol('timed out');++function _within(promise, ms) {++ let timer;++ const late = new Promise((resolve) => { timer = setTimeout(resolve, ms, _TIMED_OUT); });++ return Promise.race([Promise.resolve(promise).catch(() => null), late]).finally(() => clearTimeout(timer));++}++/** A cached list call, or null (and `partial` set on the tracker) when it did not answer in time. */++async function _listWithin(key, fn, track) {++ const r = await _within(_cached(key, fn), LIST_WAIT_MS);++ if (r === _TIMED_OUT || r == null) { if (track) track.partial = true; return null; }++ return r;++}++const _pub = (row) => row && !isPrivate(row) && Number(row.is_public) !== 0 && row.is_public !== false;++++// Overlay the Live-owned AI state (vod_ai_state/clip_ai_state) onto a Media row so++// descriptions/transcripts keep enriching the crawlable snapshot.++function _overlayAiState(row, kind) {++ try {++ const st = kind === 'clip' ? db.getClipAiState(row.id) : db.getVodAiState(row.id);++ if (!st) return;++ if (st.ai_overview_short) {++ row.ai_overview_short = row.ai_overview_short || st.ai_overview_short;++ row.ai_overview = row.ai_overview || st.ai_overview || st.ai_overview_short;++ }++ if (st.ai_transcript_json && !row.ai_transcript) {++ try { row.ai_transcript = JSON.parse(st.ai_transcript_json).map(s => s.text).join(' ').trim(); } catch { /* */ }++ }++ } catch { /* */ }++}++++// Pastes live on openvibe.community: /p/<slug> is a redirect there (server/web/paste-handover.js,++// mounted before this middleware), and every paste link Live renders, here and in the SPA (which++// reads the base from the ov-pastes-base meta on every shell), points straight at Community, the++// paste's one canonical page. Live's sitemap never lists /p/ (roadmap 32.2).++const PASTES_BASE = (process.env.OV_COMMUNITY_URL || 'https://openvibe.community').replace(/\/$/, '');++const PASTES_META = `<meta name="ov-pastes-base" content="${esc(PASTES_BASE)}">`;++const pasteHref = (slug) => `${PASTES_BASE}/p/${encodeURIComponent(slug)}`;++++const SITE_NAME = 'OpenVibe.Live';++const DEFAULT_OG_IMAGE = '/og-image.png';++++function baseUrl() {++ let b = (config && config.baseUrl) || 'https://openvibe.live';++ if (/localhost|127\.0\.0\.1/.test(b)) b = 'https://openvibe.live'; // never emit localhost in public meta++ return b.replace(/\/+$/, '');++}++function abs(url) {++ if (!url) return '';++ if (/^https?:\/\//i.test(url)) return url;++ return baseUrl() + (url.startsWith('/') ? url : '/' + url);++}++// Collapse to a clean single-line meta-description-safe string.++function clean(s, max) {++ let t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim();++ if (max && t.length > max) t = t.slice(0, max - 1).replace(/\s+\S*$/, '') + '…';++ return t;++}++function isoDate(dt) {++ try {++ if (!dt) return null;++ const d = new Date(String(dt).includes('T') ? dt : String(dt).replace(' ', 'T') + 'Z');++ return isNaN(d.getTime()) ? null : d.toISOString();++ } catch { return null; }++}++// Seconds → ISO-8601 duration (PT#H#M#S) for VideoObject.++function iso8601Duration(sec) {++ sec = Math.max(0, Math.floor(Number(sec) || 0));++ const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;++ return 'PT' + (h ? h + 'H' : '') + (m ? m + 'M' : '') + (s || (!h && !m) ? s + 'S' : '');++}++++// ── Build the per-route metadata object ────────────────────────────────────────────────────++// Returns { title, description, canonicalPath, image, ogType, robots, jsonLd:[...], snapshot }++// or null to fall through (unknown / private / not found).++++async function _pageMeta(routePath, { page = 1 } = {}) {++ const p = routePath.replace(/\/+$/, '') || '/';++ const bu = baseUrl();++++ // Home++ if (p === '/') return _homeMeta();++ // List pages (content sourced from OpenVibe.Media, canonical URLs stay on openvibe.live)++ if (p === '/vods') return _listMeta('vods', 'VODs', 'Browse recorded live streams (VODs) on OpenVibe.Live — auto-recorded broadcasts with AI overviews and searchable transcripts.', async () => (await _vodList(30) || []).map(v => ({ url: `/vod/${v.id}`, name: v.title || 'VOD', by: v.display_name || v.username, meta: _fmtDur(v.duration_seconds || v.duration), desc: v.ai_overview_short })));++ if (p === '/clips') return _listMeta('clips', 'Clips', 'Watch the best clips from OpenVibe.Live live streams — the moments viewers clipped.', async () => (await _clipList(30) || []).map(c => ({ url: `/clip/${c.id}`, name: c.title || 'Clip', by: c.display_name || c.username || c.streamer_username, meta: _fmtDur(c.duration_seconds || c.duration), desc: c.ai_overview_short })));++ if (p === '/content') return _listMeta('content', 'Content', 'VODs, clips and pastes made by the people of OpenVibe.Live — recorded streams, the moments viewers clipped, and the code, notes and screenshots they shared.', () => _feedPage('content', page), { page });++ if (p === '/moments') return _listMeta('moments', 'AI Moments', 'AI-made highlights from OpenVibe.Live streams: auto-clips of the moments chat erupted, standout frames the AI picked, and AI-written after-show recaps. Everything listed here is AI-generated.', () => _feedPage('moments', page), { page });++ if (p === '/pastes') return _listMeta('pastes', 'Pastes', 'Code, text and screenshot pastes shared on OpenVibe.Live — a Pastebin built into the streaming network, with AI summaries.', async () => (await _pasteList(30) || []).map(x => ({ url: pasteHref(x.slug), name: x.title || 'Paste', by: x.username || 'anon', meta: x.type === 'screenshot' ? 'image' : (x.language || 'text'), desc: x.ai_summary })));++++ // Detail pages++ let m;++ if ((m = p.match(/^\/vod\/(\d+)$/))) return _vodMeta(parseInt(m[1], 10));++ if ((m = p.match(/^\/clip\/(\d+)$/))) return _clipMeta(parseInt(m[1], 10));++ if ((m = p.match(/^\/p\/([A-Za-z0-9_-]+)$/))) return _pasteMeta(m[1]);++ if ((m = p.match(CHANNEL_PATH_RE))) return _channelMeta(m[1], page);++ if ((m = p.match(/^\/recap\/(\d+)$/))) return _recapMeta(parseInt(m[1], 10));++ if (p === '/arena') return _arenaMeta();++ // Search results (public/js/app-search.js) are never indexed; the page itself is findable.++ if (p === '/search') return { title: `Search | ${SITE_NAME}`, description: 'Search channels, VODs and clips on OpenVibe.Live.', canonicalPath: '/search', ogType: 'website', robots: 'noindex,follow', jsonLd: [] };++ // The global chat room: its own title and canonical (the shell left it with neither; browser check, WS-Q task 3).++ if (p === '/chat') return { title: `Chat | ${SITE_NAME}`, description: 'The global chat room of OpenVibe.Live, open to everyone on the network.', canonicalPath: '/chat', ogType: 'website', robots: 'index,follow', jsonLd: [] };++++ return null;++ void bu;++}++++// ── Channel page: /@<username>, with ?page=N over the channel's videos ─────────────────────++// The client's channel-name rule (CHANNEL_USERNAME_RE in public/js/app.js, CHANNEL_RE in++// server/web/page-status.js): anything else is never a channel.++const CHANNEL_PATH_RE = /^\/@([A-Za-z0-9_]{3,24})$/;++const CHANNEL_PAGE_SIZE = 12;++const CHANNEL_MAX_PAGE = 500;++const CHANNEL_CACHE_MS = 60_000; // the page says who is live, so it is kept a minute, not five++++/** The account behind /@<name>: its channel row, or just the account when it has none yet. */++function _channelAccount(username) {++ let ch = null;++ try { ch = db.getChannelByUsername(username); } catch { ch = null; }++ let user = null;++ try { user = db.getUserById(ch ? ch.user_id : (db.getUserByUsername(username) || {}).id); } catch { user = null; }++ if (!ch && !user) return null;++ return {++ userId: ch ? ch.user_id : user.id,++ username: (ch && ch.username) || user.username,++ displayName: (ch && ch.display_name) || (user && user.display_name) || (ch && ch.username) || user.username,++ avatarUrl: (ch && ch.avatar_url) || (user && user.avatar_url) || null,++ bio: (ch && ch.bio) || (user && user.bio) || '',++ banned: !!(user && (user.is_banned === 1 || user.is_banned === true)),++ };++}++++async function _channelMeta(username, page = 1) {++ const acct = _channelAccount(username);++ if (!acct) return null;++ page = Math.min(CHANNEL_MAX_PAGE, Math.max(1, Math.floor(Number(page) || 1)));++ const name = acct.displayName;++ const handle = '@' + acct.username;++ const basePath = `/${handle}`;++ let ov = null;++ try { ov = db.getStreamerOverview ? db.getStreamerOverview(acct.userId) : null; } catch { /* */ }++ let followers = 0;++ try { followers = db.getFollowerCount ? (db.getFollowerCount(acct.userId) || 0) : 0; } catch { /* */ }++ let live = [];++ try { live = acct.banned ? [] : (db.getLiveStreamsByUserId(acct.userId) || []); } catch { live = []; }++ const bio = clean(acct.bio || '', 300);++ const aiShort = clean((ov && (ov.overview_short || ov.overview)) || '', 220);++++ // What the channel has made: its videos (this page of them), the clips people took of its++ // streams, then what the AI cut from them, labelled (roadmap 33.6: made, then derived).++ const track = { partial: false };++ const offset = (page - 1) * CHANNEL_PAGE_SIZE;++ const [vr, cr, ar] = acct.banned ? [null, null, null] : await Promise.all([++ _listWithin(`chv:${acct.userId}:${offset}`, () => media.listVods({ user_id: acct.userId, order: 'newest', limit: CHANNEL_PAGE_SIZE, offset }, { timeoutMs: 5000 }), track),++ page === 1 ? _listWithin(`chc:${acct.userId}`, () => media.listClips({ channel_user_id: acct.userId, auto_generated: 0, limit: 8 }, { timeoutMs: 5000 }), track) : null,++ page === 1 ? _listWithin(`cha:${acct.userId}`, () => media.listClips({ channel_user_id: acct.userId, auto_generated: 1, limit: 6 }, { timeoutMs: 5000 }), track) : null,++ ]);++ const vods = ((vr && vr.vods) || []).filter((v) => _pub(v) && !v.is_recording && v.status !== 'failed');++ const vodTotal = vr && Number.isFinite(Number(vr.total)) ? Number(vr.total) : null;++ const pages = vodTotal != null ? Math.max(1, Math.ceil(vodTotal / CHANNEL_PAGE_SIZE)) : null;++ const clips = ((cr && cr.clips) || []).filter((c) => _pub(c) && !isAiClip(c));++ const aiClips = ((ar && ar.clips) || []).filter((c) => _pub(c) && isAiClip(c));++ // A page past the end has nothing on it: not a page to index, and its canonical is page 1.++ const pastEnd = page > 1 && !track.partial && (!vods.length || (pages != null && page > pages));++ const canonicalPath = page > 1 && !pastEnd ? `${basePath}?page=${page}` : basePath;++ const pageLabel = page > 1 ? ` — videos, page ${page}` : '';++++ const desc = clean(bio || aiShort || `${name} (${handle}) streams live on ${SITE_NAME}. Watch their live streams, VODs and clips.`, 200);++ const image = acct.avatarUrl ? abs(acct.avatarUrl) : DEFAULT_OG_IMAGE;++ const person = {++ '@type': 'Person', name: clean(name, 80), alternateName: handle, url: abs(basePath),++ image: image !== DEFAULT_OG_IMAGE ? image : undefined,++ description: desc,++ interactionStatistic: followers ? { '@type': 'InteractionCounter', interactionType: 'https://schema.org/FollowAction', userInteractionCount: followers } : undefined,++ };++ const profile = { ...seo.jsonLd.webPage({ type: 'ProfilePage', name: `${name} (${handle})`, url: abs(canonicalPath) }), mainEntity: person };++ const ld = [profile];++ if (vods.length) {++ ld.push({++ '@context': 'https://schema.org', '@type': 'ItemList', name: `${name}'s videos${page > 1 ? `, page ${page}` : ''}`,++ itemListElement: vods.map((v, i) => ({ '@type': 'ListItem', position: offset + i + 1, url: abs(`/vod/${Number(v.id)}`), name: clean(v.title || 'Stream recording', 110) })),++ });++ }++ ld.push(_breadcrumb([{ name: 'Home', url: '/' }, { name, url: basePath }, ...(page > 1 ? [{ name: `Videos, page ${page}`, url: canonicalPath }] : [])]));++++ // The page body, for crawlers and for anyone reading without JavaScript.++ let body = `<article><h1>${esc(`${name} (${handle})`)}</h1>`;++ const facts = [followers ? `${followers} follower${followers === 1 ? '' : 's'} on ${SITE_NAME}` : null];++ if (live.length) facts.push(`Live now: ${clean(live[0].title || 'streaming', 100)}`);++ if (facts.filter(Boolean).length) body += `<p class="byline">${esc(facts.filter(Boolean).join(' · '))}</p>`;++ if (acct.banned) body += '<p>This account is suspended.</p>';++ if (bio) body += `<p>${esc(bio)}</p>`;++ if (page === 1 && ov && ov.overview && String(ov.overview).trim()) {++ body += `<section><h2>AI overview</h2><p class="ai-disclosure">Written by OpenVibe's AI from ${esc(name)}'s past streams.</p><p>${esc(clean(ov.overview, 1200))}</p></section>`;++ }++ body += _itemSection(page > 1 ? `Videos, page ${page}` : 'Videos', vods.map((v) => ({++ url: `/vod/${Number(v.id)}`, name: v.title || 'Stream recording',++ meta: [_fmtDur(v.duration_seconds ?? v.duration), _day(v.created_at)].filter(Boolean).join(' · '),++ })), vods.length || page > 1 ? null : (track.partial ? null : 'No videos yet.'));++ if (vods.length || page > 1) body += _pager(basePath, page, pages, vods.length === CHANNEL_PAGE_SIZE, 'videos');++ if (clips.length) body += _itemSection('Clips', clips.map((c) => ({ url: `/clip/${Number(c.id)}`, name: c.title || 'Clip', meta: [_clipperOf(c) ? `clipped by ${_clipperOf(c)}` : null, _fmtDur(c.duration_seconds ?? c.duration)].filter(Boolean).join(' · ') })));++ if (aiClips.length) {++ body += _itemSection('AI Moments', aiClips.map((c) => ({ url: `/clip/${Number(c.id)}`, name: c.title || 'AI clip', meta: ['AI clip', _fmtDur(c.duration_seconds ?? c.duration)].filter(Boolean).join(' · ') })),++ null, `<p class="ai-disclosure">Clips OpenVibe's AI cut from ${esc(name)}'s streams when chat reacted. No one clipped these.</p>`);++ }++ body += `<p><a href="${esc(abs(basePath))}">Visit ${esc(name)}'s channel</a> on ${SITE_NAME}.</p></article>`;++++ return {++ title: `${name} (${handle})${pageLabel} — ${SITE_NAME}`, description: desc,++ canonicalPath, image, ogType: 'profile',++ robots: acct.banned || pastEnd ? 'noindex,follow' : 'index,follow',++ jsonLd: ld, snapshot: body,++ cacheTtlMs: track.partial ? SHORT_CACHE_MS : CHANNEL_CACHE_MS,++ };++}++++/** "Page 2 of 5" with newer/older links (?page=N), the crawlable form of the channel's video list. */++function _pager(basePath, page, pages, maybeMore, noun) {++ const href = (n) => esc(abs(n > 1 ? `${basePath}?page=${n}` : basePath));++ const parts = [];++ if (page > 1) parts.push(`<a rel="prev" href="${href(page - 1)}">Newer ${noun}</a>`);++ parts.push(`Page ${page}${pages ? ` of ${pages}` : ''}`);++ if (pages ? page < pages : maybeMore) parts.push(`<a rel="next" href="${href(page + 1)}">Older ${noun}</a>`);++ return `<nav class="pagination" aria-label="Pages">${parts.join(' · ')}</nav>`;++}++/** A heading and a list of links with their details; `empty` when there is nothing, `intro` above. */++function _itemSection(heading, items, empty = null, intro = '') {++ if (!items.length && !empty) return '';++ const li = items.map((it) => `<li><a href="${esc(abs(it.url))}">${esc(clean(it.name, 120))}</a>${it.meta ? ` — ${esc(it.meta)}` : ''}</li>`).join('');++ return `<section><h2>${esc(heading)}</h2>${intro}${items.length ? `<ul>${li}</ul>` : `<p>${esc(empty)}</p>`}</section>`;++}++const _day = (dt) => { const d = isoDate(dt); return d ? d.slice(0, 10) : null; };++/** A person's clip's clipper, from Live's accounts (Media rows carry ids only). */++function _clipperOf(c) { return c.display_name || c.username || _nameOf(_channelOwner(c.user_id)); }++++// The Arena: a real content page (mic-judged trash talk), so give it real metadata.++function _arenaMeta() {++ let fighters = [], moments = [];++ try { fighters = (require('../arena/arena-service').loadRoster() || {}).order || []; } catch { fighters = []; }++ try { moments = require('../arena/mic').feed({ limit: 10 }) || []; } catch { moments = []; }++ const nameOf = (id) => { try { return require('../arena/mic').nameOf(id); } catch { return null; } };++ const names = fighters.slice(0, 8).map(nameOf).filter(Boolean);++ const title = 'The Arena — Mic-Judged Trash Talk Between Streamers';++ const desc = clean(`Every callout streamers make on mic, judged and ranked. ${names.length ? 'Fighters right now: ' + names.slice(0, 5).join(', ') + '. ' : ''}Chat can hype a beef but never write one.`, 200);++ const items = moments.slice(0, 10).map((mo) => ({ name: clean(mo.text || 'Mic moment', 110) }));++ const snapshot = _detailSnapshot({++ title: 'The Arena', byline: names.length ? `Fighters: ${names.join(', ')}` : null,++ desc, overview: moments.slice(0, 8).map(mo => mo.text).filter(Boolean).join(' · ') || null,++ transcript: null, canonicalPath: '/arena', watchLabel: 'Open the Arena',++ });++ return {++ title: `${title} | ${SITE_NAME}`, description: desc, canonicalPath: '/arena',++ image: DEFAULT_OG_IMAGE, ogType: 'website', robots: 'index,follow',++ jsonLd: [++ seo.jsonLd.webPage({ type: 'CollectionPage', name: title, url: abs('/arena'), description: desc }),++ ...(items.length ? [seo.jsonLd.itemList('Latest mic moments', items)] : []),++ _breadcrumb([{ name: 'Home', url: '/' }, { name: 'The Arena', url: '/arena' }]),++ ],++ snapshot,++ };++}++++// After-show report: /recap/:streamId++// The AI-written report is an AI Moment: labelled, attributed to no person (the streamer did not++// write it), noindex,follow and canonical to the stream's VOD when there is one. A template report++// (stats only, no model) is not AI writing and keeps its own indexable page; it has no Person++// author either, since the streamer did not write that one.++async function _recapMeta(streamId) {++ let r; try { r = require('../recap/recap').getRecap(streamId); } catch { r = null; }++ if (!r) return null;++ const ai = r.ai === true || Number(r.ai) === 1;++ const name = r.streamer.display_name || r.streamer.username;++ const title = clean(`${r.write.headline || r.stream.title}`, 90);++ const desc = clean(ai++ ? `AI-written after-show report on ${name}'s stream "${r.stream.title}". ${r.write.summary || ''}`++ : (r.write.summary || `After-show report for ${name}'s stream "${r.stream.title}" on ${SITE_NAME}.`), 200);++ const image = r.vod && r.vod.thumbnail_url ? media.publicUrl(r.vod.thumbnail_url) : (r.streamer.avatar_url ? abs(r.streamer.avatar_url) : DEFAULT_OG_IMAGE);++ const selfPath = `/recap/${streamId}`;++ const source = ai && r.vod ? await _sourceMoment(r.vod.id, 0) : null;++ const article = {++ ...seo.jsonLd.article({ headline: title, url: abs(selfPath), description: desc, image: [image], datePublished: isoDate(r.stream.ended_at) || undefined }),++ keywords: ai ? AI_KEYWORDS : undefined,++ isBasedOn: source ? abs(source.path) : undefined,++ publisher: { '@type': 'Organization', name: SITE_NAME, url: baseUrl() },++ };++ const byline = `${ai ? 'AI recap · from' : 'Report on'} ${name}'s stream · ${Math.round((r.stream.duration_seconds || 0) / 60)} min · grade ${r.write.grade}`;++ const extraHtml = (ai ? '<p class="ai-disclosure">Written automatically by OpenVibe\'s after-show report workflow from the stream\'s stats, chat and transcript. The streamer did not write it.</p>' : '')++ + `<p class="source">${source ? `<a href="${esc(abs(source.path))}">Watch the stream</a> · ` : ''}<a href="${esc(abs(`/@${r.streamer.username}`))}">${esc(name)}'s channel</a></p>`;++ const snapshot = _detailSnapshot({ title: `After-show report: ${r.stream.title}`, byline, desc, extraHtml, overview: r.write.summary, transcript: null, canonicalPath: selfPath, watchLabel: 'Read the report' });++ return {++ title: `${title} — ${ai ? 'AI recap of ' : ''}${name}'s stream${ai ? '' : ' report'} | ${SITE_NAME}`, description: desc,++ canonicalPath: source ? source.path : selfPath, ogUrlPath: selfPath, image, ogType: 'article',++ robots: ai ? AI_ROBOTS : 'index,follow',++ jsonLd: [article, _breadcrumb([{ name: 'Home', url: '/' }, { name, url: `/@${r.streamer.username}` }, { name: ai ? 'AI recap' : 'Report', url: selfPath }])],++ snapshot,++ };++}++++// A crawlable <section> listing media items with real detail (title, streamer, meta).++function _mediaSection(heading, items) {++ if (!items || !items.length) return '';++ const li = items.map(it => {++ const bits = [];++ if (it.by) bits.push('by ' + esc(it.by));++ if (it.meta) bits.push(esc(it.meta));++ return `<li><a href="${esc(abs(it.url))}">${esc(clean(it.name, 120))}</a>${bits.length ? ' — ' + bits.join(' · ') : ''}${it.desc ? `<br><small>${esc(clean(it.desc, 160))}</small>` : ''}</li>`;++ }).join('');++ return `<section><h2>${esc(heading)}</h2><ul>${li}</ul></section>`;++}++// m:ss, or h:mm:ss from an hour up; '' for nothing.++function _fmtDur(sec) {++ sec = Math.floor(Number(sec) || 0);++ if (!sec) return '';++ const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;++ const ss = String(s).padStart(2, '0');++ return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`;++}++++async function _homeMeta() {++ // Going live from the browser (no OBS, no downloads, no follower minimum) leads: it is what people ask++ // search engines and AI assistants for, and what they recommend OpenVibe.Live for (2026-09-28).++ const title = 'OpenVibe.Live — Go Live From Your Browser, No OBS Needed';++ const description = 'Go live instantly from your browser: no OBS, no downloads, no follower minimum. Open-source, community-run streaming with sub-second WebRTC, chat, VODs, clips and restreaming to Twitch, YouTube and Kick.';++++ // Pull the actual live content so the source has real, crawlable text.++ let live = [];++ try { live = (db.getLiveStreams() || []).slice(0, 12); } catch { /* */ }++ // Each rail waits at most LIST_WAIT_MS: a slow upstream leaves its rail out, never the page.++ // VODs, clips and pastes are counted by the services that hold them (Media, Community), not Live's frozen tables.++ let homeStats = null;++ try { homeStats = { ...db.getHomeStats() }; } catch { homeStats = null; }++ const track = { partial: false };++ const settle = async (promise) => { const r = await _within(promise, LIST_WAIT_MS); if (r === _TIMED_OUT) { track.partial = true; return null; } return r; };++ let [vods, clips, pastes, stats] = await Promise.all([++ settle(_vodList(12)), settle(_clipList(12)), settle(_pasteList(12)),++ homeStats ? settle(require('../media-proxy/lookups').withArchiveStats(homeStats)) : null,++ ]);++ vods = vods || []; clips = clips || []; pastes = pastes || [];++ const ownerName = (row, id) => row.display_name || row.username || _nameOf(_channelOwner(id));++++ const liveItems = live.map(s => ({ url: `/@${s.username}`, name: s.title || `${s.display_name || s.username} live`, by: s.display_name || s.username, meta: s.category || 'live' }));++ const vodItems = vods.map(v => ({ url: `/vod/${v.id}`, name: v.title || 'VOD', by: ownerName(v, v.user_id), meta: _fmtDur(v.duration_seconds || v.duration), desc: v.ai_overview_short }));++ const clipItems = clips.map(c => ({ url: `/clip/${c.id}`, name: c.title || 'Clip', by: ownerName(c, c.user_id), meta: _fmtDur(c.duration_seconds || c.duration) }));++ const pasteItems = pastes.map(x => ({ url: pasteHref(x.slug), name: x.title || 'Paste', by: x.username || 'anon', meta: x.type === 'screenshot' ? 'image' : (x.language || 'text') }));++++ const statLine = stats ? `<p>${SITE_NAME} hosts ${stats.streamers || 0} streamers, ${stats.vods || 0} VODs, ${stats.clips || 0} clips, ${stats.pastes || 0} pastes and ${stats.chatMessages || 0} chat messages.</p>` : '';++ const snapshot =++ `<h1>${SITE_NAME} — go live from your browser, no OBS needed</h1>` +++ `<p>${esc(description)}</p>` +++ '<section><h2>Go live in your browser</h2><ol>' +++ '<li>Open openvibe.live in Chrome, Edge, Firefox or Safari — on a Chromebook, laptop or phone.</li>' +++ '<li>Sign in or make an account; your channel exists right away.</li>' +++ '<li>Press Go Live and allow your camera and microphone.</li>' +++ '<li>Add a title and start: you are live with your own channel and chat.</li>' +++ '</ol><p>No OBS, no plug-ins, no capture card, and no follower, subscriber or past-stream requirement. ' +++ `<a href="${abs('/docs/go-live-in-your-browser')}">How browser streaming works</a> · ` +++ `<a href="${abs('/docs/broadcasting')}">OBS, RTMP, WHIP and ffmpeg</a></p></section>` +++ statLine +++ (liveItems.length ? _mediaSection('Live now', liveItems) : '<section><h2>Live now</h2><p>No one is streaming right now — be the first to go live.</p></section>') +++ _mediaSection('Recent VODs', vodItems) +++ _mediaSection('Recent clips', clipItems) +++ _mediaSection('Recent pastes', pasteItems);++++ // Rich JSON-LD: WebSite + one ItemList per section of the snapshot, named by that section's visible++ // heading (structured data names what a visitor sees; the browser check compares them).++ const itemList = (name, items) => seo.jsonLd.itemList(name, items.slice(0, 10).map((it) => ({ url: abs(it.url), name: clean(it.name, 110) })));++ const lists = [['Live now', liveItems], ['Recent VODs', vodItems], ['Recent clips', clipItems]].filter(([, items]) => items.length).map(([n, items]) => itemList(n, items));++ const jsonLd = [++ seo.jsonLd.website({ name: SITE_NAME, url: baseUrl(), description: clean(description, 300) }),++ {++ ...seo.jsonLd.softwareApp({ name: SITE_NAME, url: baseUrl(), category: 'MultimediaApplication',++ description: 'Live streaming you start from a web browser: no OBS, no downloads and no follower minimum.' }),++ browserRequirements: 'A current Chrome, Edge, Firefox or Safari with camera and microphone access',++ featureList: [++ 'Go live from the browser with no OBS or other software (WebRTC, under one second of delay)',++ 'No follower, subscriber or past-stream requirement to go live',++ 'Works on Chromebooks, laptops and phones',++ 'Screen, window and tab sharing with a camera picture-in-picture',++ 'OBS/RTMP, WHIP and ffmpeg ingest for produced shows',++ 'Live chat, VODs, clips and restreaming to Twitch, YouTube and Kick',++ 'Open source and community run',++ ],++ },++ // The OpenVibe organisation every site names (openvibe-shared/seo); sameAs is how a search++ // engine learns these properties are one brand rather than unrelated sites sharing a name.++ seo.jsonLd.organization({++ description: 'An open-source network of live streaming, media and developer tools, built and run by the people who use it.',++ sameAs: ['https://github.com/OpenVibers', 'https://discord.gg/M6MuRUaeJj', 'https://openvibe.tools', 'https://openvibe.network', baseUrl()],++ }),++ ...lists,++ ];++ return { title, description, canonicalPath: '/', image: DEFAULT_OG_IMAGE, ogType: 'website', robots: 'index,follow', jsonLd, snapshot, cacheTtlMs: track.partial ? SHORT_CACHE_MS : undefined };++}++++/**++ * A list page. `itemsFn` gives an array, or { items, more } for a paged list (page N of it: ?page=N,++ * self-canonical, with newer/older links; a page past the end is not rendered here).++ */++async function _listMeta(slug, label, description, itemsFn, { page = 1 } = {}) {++ let items = [], more = null;++ try {++ const got = await itemsFn();++ if (Array.isArray(got)) items = got.slice(0, 24);++ else if (got) { items = (got.items || []).slice(0, 24); more = Boolean(got.more); }++ } catch { /* */ }++ if (page > 1 && !items.length) return null;++ const basePath = '/' + slug;++ const selfPath = page > 1 ? `${basePath}?page=${page}` : basePath;++ const pageLabel = page > 1 ? `, page ${page}` : '';++ const list = {++ ...seo.jsonLd.webPage({ type: 'CollectionPage', name: `${label}${pageLabel} — ${SITE_NAME}`, url: abs(selfPath), description }),++ mainEntity: {++ '@type': 'ItemList',++ itemListElement: items.map((it, i) => ({ '@type': 'ListItem', position: (page - 1) * 24 + i + 1, url: abs(it.url), name: clean(it.name, 110) })),++ },++ };++ let snapshot = `<h1>${esc(label)} on ${SITE_NAME}${page > 1 ? ` — page ${page}` : ''}</h1><p>${esc(description)}</p>` +++ _mediaSection(page > 1 ? `${label}, page ${page}` : `Latest ${label.toLowerCase()}`, items);++ if (more !== null && (page > 1 || more)) snapshot += _pager(basePath, page, null, more, label.toLowerCase());++ return {++ title: `${label}${pageLabel} — ${SITE_NAME}`, description, canonicalPath: selfPath,++ image: DEFAULT_OG_IMAGE, ogType: 'website', robots: 'index,follow',++ jsonLd: [list, _breadcrumb([{ name: 'Home', url: '/' }, { name: label, url: basePath }, ...(page > 1 ? [{ name: `Page ${page}`, url: selfPath }] : [])])],++ snapshot,++ };++}++++function _authorLd(name) { return name ? { '@type': 'Person', name: clean(name, 80) } : undefined; }++++// ── AI Moments (roadmap §33.4, §33.8) ───────────────────────────────────────────────────────++// What the AI made from a stream (auto-clips, AI moment pastes, AI after-show recaps) is labelled++// as AI-made, attributed to no person, kept out of search (noindex,follow: the page still passes++// its links on) and made canonical to its source: the VOD at the moment it came from, when that++// VOD is known and public. The store decides what is AI (Media's auto_generated, Community's++// origin, the recap's own ai flag); nothing is inferred here. People's clips and pastes keep their++// normal, indexable pages.++const AI_ROBOTS = 'noindex,follow';++const AI_KEYWORDS = 'AI-generated';++const VOD_MOMENT_RE = /^\/vod\/(\d+)(?:\?t=(\d+(?:\.\d+)?))?$/;++++const isAiClip = (c) => !!c && (c.auto_generated === true || Number(c.auto_generated) === 1);++const isAiPaste = (p) => !!p && p.origin === 'ai';++function _json(v) {++ if (!v) return null;++ if (typeof v === 'object') return v;++ try { const o = JSON.parse(v); return o && typeof o === 'object' ? o : null; } catch { return null; }++}++/** The Live account a stream-derived item belongs to (the channel, never a viewer). */++function _channelOwner(userId) {++ if (userId == null || userId === '') return null;++ try { return db.getUserById(Number(userId)) || null; } catch { return null; }++}++const _nameOf = (u) => (u ? (u.display_name || u.username) : null);++++/**++ * `/vod/<id>?t=<s>` for the moment an item came from, or null when the VOD is unknown, gone or++ * not public (a canonical must never point at a page that answers 404 to the crawler).++ */++async function _sourceMoment(vodId, seconds) {++ if (vodId == null || !/^\d+$/.test(String(vodId))) return null;++ const v = await _vodGet(Number(vodId));++ if (!v || !_pub(v)) return null;++ const t = Math.max(0, Math.floor(Number(seconds) || 0));++ return { path: `/vod/${Number(vodId)}${t ? `?t=${t}` : ''}`, vod: v };++}++++async function _vodMeta(id) {++ const v = await _vodGet(id);++ // Private: fall through exactly like an unknown id. This HTML is cached per id and shared by++ // every visitor, so it never carries a private VOD's title, overview or transcript.++ if (!v || isPrivate(v)) return null;++ if (v.duration_seconds == null && v.duration != null) v.duration_seconds = v.duration;++ _overlayAiState(v, 'vod');++ const indexable = Number(v.is_public) === 1 && (!v.visibility || v.visibility === 'public');++ const owner = _channelOwner(v.user_id);++ const author = v.display_name || v.username || _nameOf(owner);++ const title = clean(v.title || `${author ? author + "'s " : ''}stream VOD`, 80);++ const desc = clean(v.ai_overview_short || v.ai_overview || v.description || `Recorded live stream${author ? ' by ' + author : ''} on ${SITE_NAME}.`, 200);++ const image = v.thumbnail_url ? media.publicUrl(v.thumbnail_url) : media.thumbUrl(`vod-${id}`);++ const canonicalPath = `/vod/${id}`;++++ // The clips of this VOD: people's, then the AI's. Every one is also a moment of this video++ // (schema.org Clip in hasPart), so the source carries its moments (roadmap 33.8).++ const track = { partial: false };++ const cr = await _listWithin(`vc:${id}`, () => media.listClips({ vod_id: id, limit: 30 }, { timeoutMs: 5000 }), track);++ const all = ((cr && cr.clips) || []).filter((c) => _pub(c) && (!c.status || c.status === 'ready'));++ const people = all.filter((c) => !isAiClip(c));++ const ai = all.filter(isAiClip);++ const at = (c) => Math.max(0, Math.floor(Number(c.start_time) || 0));++ const parts = all.filter((c) => Number.isFinite(Number(c.start_time))).slice(0, 20).map((c) => ({++ '@type': 'Clip', name: clean(c.title || (isAiClip(c) ? 'AI clip' : 'Clip'), 110),++ startOffset: at(c), endOffset: Number.isFinite(Number(c.end_time)) ? Math.max(at(c) + 1, Math.ceil(Number(c.end_time))) : undefined,++ url: abs(`/vod/${id}?t=${at(c)}`),++ }));++ const vo = {++ ...seo.jsonLd.video({++ name: title, url: abs(canonicalPath), description: desc, thumbnailUrl: [image],++ uploadDate: isoDate(v.created_at) || undefined, duration: iso8601Duration(v.duration_seconds),++ contentUrl: abs(canonicalPath), embedUrl: abs(canonicalPath),++ }),++ interactionStatistic: { '@type': 'InteractionCounter', interactionType: 'https://schema.org/WatchAction', userInteractionCount: Number(v.view_count) || 0 },++ author: _authorLd(author), publisher: { '@type': 'Organization', name: SITE_NAME, url: baseUrl() },++ hasPart: parts.length ? parts : undefined,++ };++ const moment = (c) => `/vod/${id}?t=${at(c)}`;++ let extraHtml = owner ? `<p class="source"><a href="${esc(abs(`/@${owner.username}`))}">${esc(author || owner.username)}'s channel</a></p>` : '';++ extraHtml += _itemSection('Clips from this stream', people.map((c) => ({ url: `/clip/${Number(c.id)}`, name: c.title || 'Clip', meta: [_clipperOf(c) ? `clipped by ${_clipperOf(c)}` : null, `at ${_fmtDur(at(c)) || '0:00'}`].filter(Boolean).join(' · ') })));++ if (ai.length) {++ extraHtml += _itemSection('AI Moments from this stream', ai.map((c) => ({ url: `/clip/${Number(c.id)}`, name: c.title || 'AI clip', meta: `AI clip · at ${_fmtDur(at(c)) || '0:00'}` })),++ null, '<p class="ai-disclosure">Cut automatically by OpenVibe\'s auto-clip workflow. No one clipped these.</p>');++ extraHtml += `<p>${ai.slice(0, 6).map((c) => `<a href="${esc(abs(moment(c)))}">Jump to ${esc(_fmtDur(at(c)) || '0:00')}</a>`).join(' · ')}</p>`;++ }++ const snapshot = _detailSnapshot({++ title, byline: author ? `Streamed by ${author}` : null, desc, extraHtml,++ overview: v.ai_overview, transcript: v.ai_transcript, canonicalPath, watchLabel: 'Watch this VOD',++ });++ return {++ title: `${title}${author ? ' — ' + author : ''} | ${SITE_NAME}`, description: desc,++ canonicalPath, image, ogType: 'video.other', robots: indexable ? 'index,follow' : 'noindex,follow',++ video: { duration: Math.floor(Number(v.duration_seconds) || 0) },++ jsonLd: [vo, _breadcrumb([{ name: 'Home', url: '/' }, ...(owner ? [{ name: author || owner.username, url: `/@${owner.username}` }] : [{ name: 'VODs', url: '/vods' }]), { name: title, url: canonicalPath }])],++ snapshot,++ cacheTtlMs: track.partial ? SHORT_CACHE_MS : undefined,++ };++}++++async function _clipMeta(id) {++ const c = await _clipGet(id);++ if (!c || isPrivate(c)) return null; // private: same as unknown (see _vodMeta)++ if (c.duration_seconds == null && c.duration != null) c.duration_seconds = c.duration;++ _overlayAiState(c, 'clip');++ if (isAiClip(c)) return _aiClipMeta(c, id);++ const indexable = Number(c.is_public) === 1 && (!c.visibility || c.visibility === 'public');++ // Media stores ids only: the clipper's name comes from Live's accounts.++ const creator = c.display_name || c.username || _nameOf(_channelOwner(c.user_id));++ const title = clean(c.title || 'Clip', 80);++ const desc = clean(c.ai_overview_short || c.ai_overview || c.description || `A clip from a live stream on ${SITE_NAME}${creator ? ', clipped by ' + creator : ''}.`, 200);++ const image = c.thumbnail_url ? media.publicUrl(c.thumbnail_url) : media.thumbUrl(`clip-${id}`);++ const canonicalPath = `/clip/${id}`;++ const vo = {++ ...seo.jsonLd.video({++ name: title, url: abs(canonicalPath), description: desc, thumbnailUrl: [image],++ uploadDate: isoDate(c.created_at) || undefined, duration: iso8601Duration(c.duration_seconds),++ contentUrl: abs(canonicalPath), embedUrl: abs(canonicalPath),++ }),++ interactionStatistic: { '@type': 'InteractionCounter', interactionType: 'https://schema.org/WatchAction', userInteractionCount: Number(c.view_count) || 0 },++ author: _authorLd(creator), publisher: { '@type': 'Organization', name: SITE_NAME, url: baseUrl() },++ };++ const channel = _channelOwner(c.channel_user_id);++ const source = await _sourceMoment(c.vod_id, c.start_time);++ const sourceHtml = (source || channel) ? `<p class="source">${source ? `<a href="${esc(abs(source.path))}">Watch it in the full stream</a>` : ''}${source && channel ? ' · ' : ''}${channel ? `<a href="${esc(abs(`/@${channel.username}`))}">${esc(_nameOf(channel))}'s channel</a>` : ''}</p>` : '';++ const snapshot = _detailSnapshot({++ title, byline: creator ? `Clipped by ${creator}${channel && channel.id !== Number(c.user_id) ? ` from ${_nameOf(channel)}'s stream` : ''}` : null, desc,++ extraHtml: sourceHtml,++ overview: c.ai_overview, transcript: c.ai_transcript, canonicalPath, watchLabel: 'Watch this clip',++ });++ return {++ title: `${title} — clip | ${SITE_NAME}`, description: desc,++ canonicalPath, image, ogType: 'video.other', robots: indexable ? 'index,follow' : 'noindex,follow',++ video: { duration: Math.floor(Number(c.duration_seconds) || 0) },++ jsonLd: [vo, _breadcrumb([{ name: 'Home', url: '/' }, ...(channel ? [{ name: _nameOf(channel), url: `/@${channel.username}` }] : [{ name: 'Clips', url: '/clips' }]), { name: title, url: canonicalPath }])],++ snapshot,++ };++}++++/** An auto-clip: "AI clip · from <streamer>'s stream", canonical to the VOD at the moment. */++async function _aiClipMeta(c, id) {++ const owner = _channelOwner(c.channel_user_id != null ? c.channel_user_id : c.user_id);++ const streamer = _nameOf(owner);++ const from = streamer ? `${streamer}'s stream` : 'a live stream';++ const title = clean(c.title || 'AI clip', 80);++ const source = await _sourceMoment(c.vod_id, c.start_time);++ const desc = clean(`AI-generated clip from ${from} on ${SITE_NAME}. ${c.ai_overview_short || c.ai_overview || c.description || ''}`, 200);++ const image = c.thumbnail_url ? media.publicUrl(c.thumbnail_url) : media.thumbUrl(`clip-${id}`);++ const selfPath = `/clip/${id}`;++ const vo = {++ ...seo.jsonLd.video({++ name: title, url: abs(selfPath), description: desc, thumbnailUrl: [image],++ uploadDate: isoDate(c.created_at) || undefined, duration: iso8601Duration(c.duration_seconds),++ contentUrl: abs(selfPath), embedUrl: abs(selfPath),++ }),++ // No author or creator: a workflow cut this, not a person.++ keywords: AI_KEYWORDS,++ isBasedOn: source ? abs(source.path) : undefined,++ publisher: { '@type': 'Organization', name: SITE_NAME, url: baseUrl() },++ };++ const sourceLink = source++ ? `<p class="source"><a href="${esc(abs(source.path))}">Watch this moment in the full stream</a>${owner ? ` · <a href="${esc(abs(`/@${owner.username}`))}">${esc(streamer)}'s channel</a>` : ''}</p>`++ : (owner ? `<p class="source"><a href="${esc(abs(`/@${owner.username}`))}">${esc(streamer)}'s channel</a></p>` : '');++ const snapshot = _detailSnapshot({++ title, byline: `AI clip · from ${from}`, desc,++ extraHtml: `<p class="ai-disclosure">Cut automatically by OpenVibe's auto-clip workflow when chat reacted. No person clipped it.</p>${sourceLink}`,++ overview: c.ai_overview, transcript: c.ai_transcript, canonicalPath: selfPath, watchLabel: 'Watch this AI clip',++ });++ return {++ title: `${title} — AI clip from ${from} | ${SITE_NAME}`, description: desc,++ canonicalPath: source ? source.path : selfPath, ogUrlPath: selfPath, image, ogType: 'video.other', robots: AI_ROBOTS,++ video: { duration: Math.floor(Number(c.duration_seconds) || 0) },++ jsonLd: [vo, _breadcrumb([{ name: 'Home', url: '/' }, { name: 'AI Moments', url: '/moments' }, { name: title, url: selfPath }])],++ snapshot,++ };++}++++async function _pasteMeta(slug) {++ const p = await _pasteGet(slug);++ if (!p) return null;++ if (isAiPaste(p)) return _aiPasteMeta(p, slug);++ const isScreenshot = p.type === 'screenshot';++ // Only public, non-NSFW, non-burn pastes are indexable.++ const indexable = (p.visibility === 'public' || p.visibility == null) && !Number(p.is_nsfw) && !Number(p.burn_after_read);++ const author = p.display_name || p.username;++ const title = clean(p.title || (isScreenshot ? 'Screenshot' : 'Paste'), 80);++ const desc = clean(p.ai_summary || (isScreenshot ? `A screenshot shared on ${SITE_NAME}.` : String(p.content || '').slice(0, 220)) || `A paste on ${SITE_NAME}.`, 200);++ let image = DEFAULT_OG_IMAGE;++ if (isScreenshot && p.screenshot_url) image = media.publicUrl(p.screenshot_url);++ else if (isScreenshot && p.screenshot_path) image = media.screenshotUrl(path.basename(p.screenshot_path));++ const canonicalPath = `/p/${slug}`;++ let tags = [];++ try { tags = p.ai_tags ? JSON.parse(p.ai_tags) : []; } catch { tags = []; }++ const ld = isScreenshot++ ? { '@context': 'https://schema.org', '@type': 'ImageObject', name: title, description: desc, contentUrl: image, uploadDate: isoDate(p.created_at) || undefined, author: _authorLd(author) }++ : { '@context': 'https://schema.org', '@type': 'SoftwareSourceCode', name: title, description: desc, programmingLanguage: p.language && p.language !== 'text' ? p.language : undefined, dateCreated: isoDate(p.created_at) || undefined, author: _authorLd(author), keywords: (Array.isArray(tags) && tags.length) ? tags.join(', ') : undefined };++ const snapshot = _detailSnapshot({++ title, byline: author ? `Shared by ${author}` : null, desc,++ overview: p.ai_summary, transcript: (!isScreenshot ? String(p.content || '').slice(0, 1200) : null),++ canonicalPath, watchLabel: 'View this paste',++ });++ return {++ title: `${title} — paste | ${SITE_NAME}`, description: desc,++ canonicalPath, image, ogType: isScreenshot ? 'article' : 'article', robots: indexable ? 'index,follow' : 'noindex,follow',++ jsonLd: [ld, _breadcrumb([{ name: 'Home', url: '/' }, { name: 'Pastes', url: '/pastes' }, { name: title, url: canonicalPath }])],++ snapshot,++ };++}++++/** The VOD recorded from a stream (the longest public one), for items that know only their stream. */++const _vodOfStream = (streamId) => _cached(`vs:${streamId}`, async () => {++ const r = await media.listVods({ stream_id: streamId, limit: 3 });++ const rows = (r && r.vods) || [];++ return rows.filter((v) => v && !isPrivate(v) && Number(v.is_public) !== 0)++ .sort((a, b) => (Number(b.duration_seconds ?? b.duration) || 0) - (Number(a.duration_seconds ?? a.duration) || 0))[0] || null;++});++++/**++ * An AI moment paste (a frame the AI picked from a VOD, or one it "caught live"):++ * "AI note · from <stream>", canonical to the VOD at that second when the VOD is known.++ */++async function _aiPasteMeta(p, slug) {++ const meta = _json(p.metadata) || {};++ const streamId = p.stream_id || meta.stream_id || null;++ let stream = null;++ try { stream = streamId ? db.getStreamById(Number(streamId)) : null; } catch { stream = null; }++ const owner = stream ? _channelOwner(stream.user_id) : (meta.username ? (() => { try { return db.getUserByUsername(String(meta.username)); } catch { return null; } })() : null);++ const streamer = _nameOf(owner);++ const streamTitle = stream && stream.title ? clean(stream.title, 90) : null;++ const from = streamTitle ? `"${streamTitle}"` : (streamer ? `${streamer}'s stream` : 'a live stream');++ // The source: the VOD link the job recorded, else the VOD of the stream it was caught on.++ let source = null;++ const link = typeof meta.vod_link === 'string' ? meta.vod_link.match(VOD_MOMENT_RE) : null;++ if (link) source = await _sourceMoment(link[1], link[2]);++ else if (streamId) {++ let v = null;++ try { v = await _vodOfStream(Number(streamId)); } catch { v = null; }++ if (v) source = await _sourceMoment(v.id, meta.offset);++ }++ const isScreenshot = p.type === 'screenshot';++ const title = clean(p.title || 'AI moment', 80);++ const desc = clean(`AI-generated note from ${from} on ${SITE_NAME}. ${p.ai_summary || ''}`, 200);++ let image = DEFAULT_OG_IMAGE;++ if (isScreenshot && p.screenshot_url) image = media.publicUrl(p.screenshot_url);++ const selfPath = `/p/${slug}`;++ const ld = {++ '@context': 'https://schema.org', '@type': isScreenshot ? 'ImageObject' : 'CreativeWork',++ name: title, description: desc, contentUrl: isScreenshot ? image : undefined,++ dateCreated: isoDate(p.created_at) || undefined,++ // No author: the AI wrote it, and it is not a person.++ keywords: AI_KEYWORDS,++ isBasedOn: source ? abs(source.path) : undefined,++ publisher: { '@type': 'Organization', name: SITE_NAME, url: baseUrl() },++ };++ const at = meta.offset != null && Number.isFinite(Number(meta.offset)) ? ` at ${_fmtDur(Number(meta.offset)) || '0:00'}` : '';++ const sourceLink = source++ ? `<p class="source"><a href="${esc(abs(source.path))}">Watch this moment in the full stream${esc(at)}</a>${owner ? ` · <a href="${esc(abs(`/@${owner.username}`))}">${esc(streamer)}'s channel</a>` : ''}</p>`++ : (owner ? `<p class="source"><a href="${esc(abs(`/@${owner.username}`))}">${esc(streamer)}'s channel</a></p>` : '');++ const snapshot = _detailSnapshot({++ title, byline: `AI note · from ${from}${at}`, desc,++ extraHtml: `<p class="ai-disclosure">Written automatically by OpenVibe's AI moments workflow${meta.live ? ' while the stream was live' : ' from the recording'}. No person wrote it.</p>${sourceLink}`,++ overview: null, transcript: null, canonicalPath: selfPath, watchLabel: 'View this AI note',++ });++ return {++ title: `${title} — AI note from ${from} | ${SITE_NAME}`, description: desc,++ canonicalPath: source ? source.path : selfPath, ogUrlPath: selfPath, image, ogType: 'article', robots: AI_ROBOTS,++ jsonLd: [ld, _breadcrumb([{ name: 'Home', url: '/' }, { name: 'AI Moments', url: '/moments' }, { name: title, url: selfPath }])],++ snapshot,++ };++}++++function _breadcrumb(items) {++ return seo.jsonLd.breadcrumbs(items.map((it) => ({ name: clean(it.name, 90), url: abs(it.url) })));++}++// `extraHtml` is markup the caller built (and escaped) itself.++function _detailSnapshot({ title, byline, desc, extraHtml, overview, transcript, canonicalPath, watchLabel }) {++ let html = `<article><h1>${esc(title)}</h1>`;++ if (byline) html += `<p class="byline">${esc(byline)}</p>`;++ if (desc) html += `<p>${esc(desc)}</p>`;++ if (extraHtml) html += extraHtml;++ if (overview && String(overview).trim()) html += `<section><h2>AI overview</h2><p>${esc(clean(overview, 1200))}</p></section>`;++ if (transcript && String(transcript).trim()) html += `<section><h2>Transcript</h2><p>${esc(clean(transcript, 1500))}</p></section>`;++ html += `<p><a href="${esc(abs(canonicalPath))}">${esc(watchLabel || 'Open')}</a> on ${SITE_NAME}.</p></article>`;++ return html;++}++++// ── Inject metadata into the base index.html ────────────────────────────────────────────────++// The base document comes from server/web/assets.js: index.html with its asset URLs rewritten to++// content hashes, re-read when index.html or any asset it references changes (checked at most++// every 2s). A change that only touches public/ therefore goes live with a git pull and no++// restart — and a restart drops every live RTMP stream, because RTMP ingest is owned by this++// process (socket activation only covers HTTP).++const assets = require('../web/assets');++let _baseVersion = null;++function _base() {++ let doc = null;++ try { doc = assets.document('index.html'); } catch { doc = null; }++ if (!doc) return '';++ if (doc.version !== _baseVersion) {++ _baseVersion = doc.version;++ // Pages rendered from the old document reference old asset versions.++ if (typeof _cache !== 'undefined') _cache.clear();++ }++ return doc.html;++}++++function _headBlock(meta) {++ const canonical = abs(meta.canonicalPath || '/');++ let tags = seo.headTags({++ title: meta.title, description: meta.description, canonical,++ // One English page per URL (the SPA translates in place): the page is its own en and x-default.++ alternates: [{ hreflang: 'en', href: canonical }, { hreflang: 'x-default', href: canonical }],++ image: abs(meta.image || DEFAULT_OG_IMAGE), type: meta.ogType || 'website', siteName: SITE_NAME,++ robots: meta.robots || 'index,follow', jsonLd: meta.jsonLd || [],++ });++ // og:url is the page itself: a shared AI clip previews as that clip, while its canonical names the source.++ if (meta.ogUrlPath) tags = tags.replace(`<meta property="og:url" content="${esc(canonical)}">`, `<meta property="og:url" content="${esc(abs(meta.ogUrlPath))}">`);++ const parts = [PASTES_META, ...tags.split('\n')];++ if (meta.video && meta.video.duration) {++ parts.push(`<meta property="og:video:duration" content="${meta.video.duration}">`);++ parts.push(`<meta property="video:duration" content="${meta.video.duration}">`);++ }++ if (meta.snapshot) parts.push(NOJS_STYLE);++ return '\n' + parts.map(x => ' ' + x).join('\n') + '\n';++}++++// The page body below is visually hidden while the SPA boots (it removes it, public/js/app.js).++// Without JavaScript nothing boots, so the body is shown instead of the empty app shell.++const PRERENDER_STYLE = 'position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:normal;border:0;';++const NOJS_STYLE = '<noscript><style>#seo-prerender{position:static!important;width:auto!important;height:auto!important;margin:0 auto!important;padding:16px!important;overflow:visible!important;clip:auto!important;max-width:960px;line-height:1.5}#seo-prerender nav{margin:8px 0 16px}#app{display:none!important}</style></noscript>';++// The site's own links, for a reader without JavaScript (the navbar is built by a script).++const NOJS_NAV = `<nav aria-label="${SITE_NAME}"><a href="/">${SITE_NAME}</a> · <a href="/content">Content</a> · <a href="/moments">AI Moments</a> · <a href="/chat">Chat</a> · <a href="/documentation">API docs</a></nav>`;++function _prerender(html, snapshot) {++ return html.replace(/(<body[^>]*>)/i, `$1\n<div id="seo-prerender" style="${PRERENDER_STYLE}">${NOJS_NAV}${snapshot}</div>`);++}++// The page's one-paragraph summary (openvibe-shared/seo pageSummary): a hidden, machine-readable++// <section data-ov-summary> after the snapshot, the same on every OpenVibe site.++function _summary(meta) {++ return seo.pageSummary({ title: meta.title, summary: meta.description, url: abs(meta.ogUrlPath || meta.canonicalPath || '/') }).html;++}++function render(meta, urlPath) {++ let html = _base();++ if (!html) return null;++ html = assets.renderRoute(html, urlPath || meta.canonicalPath || '/');++++ // Only the <head> is rewritten, so only the <head> is scanned.++ //++ // These six patterns used to run over the whole document. index.html is ~378KB and the head++ // is the first ~8.5KB of it, so five /ig regexes were scanning 370KB of page markup that++ // cannot contain a <title> or an og: tag — measured at 60ms of blocking CPU per uncached++ // render, against 0.73ms for the head alone. That cost lands on every cache miss, and the++ // sitemap advertises far more URLs than the 500-entry cache holds, so a crawler walking it++ // misses every time.++ const headEnd = html.search(/<\/head>/i);++ let head = headEnd === -1 ? html : html.slice(0, headEnd);++ const rest = headEnd === -1 ? '' : html.slice(headEnd);++++ // Strip the hardcoded homepage tags we're replacing (title, description, canonical, all++ // og:/twitter:, robots) so there are no duplicates.++ head = head++ .replace(/\s*<title>[\s\S]*?<\/title>/i, '')++ .replace(/\s*<meta\s+name=["']description["'][^>]*>/ig, '')++ .replace(/\s*<meta\s+name=["']robots["'][^>]*>/ig, '')++ .replace(/\s*<link\s+rel=["']canonical["'][^>]*>/ig, '')++ .replace(/\s*<meta\s+property=["']og:[^"']*["'][^>]*>/ig, '')++ .replace(/\s*<meta\s+name=["']twitter:[^"']*["'][^>]*>/ig, '');++++ // Insert the fresh head block just before </head>.++ html = headEnd === -1++ ? head.replace(/<\/head>/i, _headBlock(meta) + '</head>')++ : head + _headBlock(meta) + rest;++ // Server-rendered crawlable content, right after <body>. It's REAL content (so AI text++ // extractors read it — unlike <noscript>, which many strip), but visually-hidden so users++ // never see a flash, and the SPA removes #seo-prerender on boot (see app.js). Not cloaking:++ // it summarises the same content the SPA renders.++ if (meta.snapshot) html = _prerender(html, meta.snapshot + _summary(meta));++ return html;++}++++/**++ * The SPA shell the fallback sends (server/index.js, server/web/page-status.js) for a page this++ * renderer did not answer. The shell's own head describes the home page, and a page that is not++ * the home page must not claim to be it:++ * 404 → "Page not found", robots noindex, no canonical, no og:url, no structured data, and a++ * not-found body for readers without JavaScript;++ * 200 → the home canonical and og:url dropped (a channel slot, /@user/<slot>, names /@user).++ */++function shellHtml(urlPath, status) {++ let html = _base();++ if (!html) return null;++ const p = String(urlPath || '/');++ html = assets.renderRoute(html, p).replace(/<\/head>/i, `${PASTES_META}</head>`);++ if (p === '/' && status !== 404) return html;++ const headEnd = html.search(/<\/head>/i);++ if (headEnd === -1) return html;++ let head = html.slice(0, headEnd);++ const rest = html.slice(headEnd);++ head = head++ .replace(/\s*<link\s+rel=["']canonical["'][^>]*>/ig, '')++ .replace(/\s*<meta\s+property=["']og:url["'][^>]*>/ig, '')++ .replace(/\s*<meta\s+name=["']twitter:url["'][^>]*>/ig, '');++ if (status === 404) {++ head = head++ .replace(/\s*<title>[\s\S]*?<\/title>/i, '')++ .replace(/\s*<meta\s+name=["']description["'][^>]*>/ig, '')++ .replace(/\s*<meta\s+name=["']robots["'][^>]*>/ig, '')++ .replace(/\s*<meta\s+property=["']og:[^"']*["'][^>]*>/ig, '')++ .replace(/\s*<meta\s+name=["']twitter:[^"']*["'][^>]*>/ig, '')++ .replace(/\s*<script type="application\/ld\+json">[\s\S]*?<\/script>/ig, '');++ head += `\n <title>Page not found — ${SITE_NAME}</title>\n <meta name="description" content="There is nothing at this address on ${SITE_NAME}.">\n <meta name="robots" content="noindex">\n ${NOJS_STYLE}\n`;++ return _prerender(head + rest, `<h1>Page not found</h1><p>There is nothing at this address on ${SITE_NAME}. It may have been deleted or made private, or the link may be mistyped.</p><p><a href="/">Go to the home page</a></p>`);++ }++ const slot = p.match(/^\/@([A-Za-z0-9_]{3,24})\/[^/]+\/?$/);++ if (slot) {++ let user = null;++ try { user = db.getUserByUsername(slot[1]); } catch { user = null; }++ if (user) head += `\n <link rel="canonical" href="${esc(abs(`/@${user.username}`))}">\n`;++ }++ return head + rest;++}++++// ── Cache (rendered HTML per path, short TTL) ───────────────────────────────────────────────++const _cache = new Map(); // path (with ?page=N) -> { html, at, ttl }++const CACHE_TTL_MS = 5 * 60 * 1000;++const CACHE_MAX = 500;++function _cacheGet(key) { const e = _cache.get(key); return e && (Date.now() - e.at) < e.ttl ? e : null; }++function _cacheSet(key, html, ttl = CACHE_TTL_MS) {++ if (_cache.size >= CACHE_MAX) { const first = _cache.keys().next().value; _cache.delete(first); }++ _cache.set(key, { html, at: Date.now(), ttl });++}++++const SEO_ROUTE_RE = /^\/(?:$|content$|moments$|vods$|clips$|pastes$|arena$|chat$|search$|vod\/\d+$|clip\/\d+$|recap\/\d+$|p\/[A-Za-z0-9_-]+$|@[A-Za-z0-9_]{3,24}$)/;++++/** ?page=N on a channel page (its video list); 1 everywhere else and for anything that is not a number. */++function _pageParam(p, query) {++ const feed = p === '/content' || p === '/moments';++ if (!feed && !CHANNEL_PATH_RE.test(p)) return 1;++ const raw = query && typeof query.page === 'string' ? query.page : '';++ return /^\d{1,4}$/.test(raw) ? Math.min(feed ? FEED_MAX_PAGE : CHANNEL_MAX_PAGE, Math.max(1, parseInt(raw, 10))) : 1;++}++++async function middleware(req, res, next) {++ if (req.method !== 'GET') return next();++ const p = (req.path || '/').replace(/\/+$/, '') || '/';++ if (!SEO_ROUTE_RE.test(p)) return next();++ // A paste Community owns is never rendered here (the handover answers it first; this keeps it++ // so if the mount order ever changes).++ if (p.startsWith('/p/')) return next();++ // Every client gets the same page, whatever it sends as Accept: browsers, crawlers, curl and++ // monitors. (It used to be skipped unless Accept named text/html, so a crawler or tool that sent++ // */* got the home page's title and canonical for every channel, VOD and clip.) Nothing fetches++ // these paths for data: the API is under /api.++ const page = _pageParam(p, req.query);++ const key = page > 1 ? `${p}?page=${page}` : p;++ // This renderer and the SPA fallback's status check share one wait on upstreams (page-status.js).++ req.ovLookupDeadlineAt = Date.now() + pageStatus.LOOKUP_DEADLINE_MS;++ const send = (html, ttl) => {++ res.set('Cache-Control', `public, max-age=${Math.max(0, Math.round(ttl / 1000))}, must-revalidate`);++ res.set('Content-Security-Policy-Report-Only', assets.cspReportOnly(html));++ res.type('html');++ return res.send(html);++ };++ try {++ // Check for a changed index.html before trusting the page cache: a cache hit would otherwise++ // skip the check and keep serving pages that point at the previous asset versions.++ _base();++ const cached = _cacheGet(key);++ if (cached) return send(cached.html, cached.ttl);++ const meta = await _pageMeta(p, { page });++ if (!meta) return next(); // unknown, private or not found: the SPA fallback answers (404 when it names nothing)++ const html = render(meta, p);++ if (!html) return next();++ const ttl = meta.cacheTtlMs || CACHE_TTL_MS;++ _cacheSet(key, html, ttl);++ return send(html, ttl);++ } catch (e) {++ console.warn('[SEO] render failed for', p, '-', e.message);++ return next();++ }++}++++module.exports = { middleware, shellHtml, _pageMeta, render, SITE_NAME, baseUrl, isAiClip, isoDate };ddeleted file mode 100644@@@ -1,1215 +0,0 @@--/**-- * SEO for the SPA — per-route server-side meta / Open Graph / Twitter / JSON-LD injection,-- * a no-JS crawlable content snapshot, and a dynamic sitemap. Cached throughout.-- *-- * The app is a client-rendered SPA (server/index.js serves public/index.html for every route),-- * so crawlers/AI scrapers see an empty shell. This middleware intercepts the HTML routes we-- * care about (home, vods/clips/pastes lists, and vod/clip/paste detail pages), and rewrites the-- * <head> with route-specific machine-readable metadata + drops a <noscript> content snapshot so-- * no-JS scrapers get real content. Everything is cached so it adds ~no per-request DB cost.-- *-- * AI Moments (auto-clips, AI moment pastes, AI recaps) are labelled AI-made, credited to no-- * person, noindex,follow, canonical to their source VOD and left out of the sitemap (see-- * "AI Moments" below). People's work keeps normal, indexable pages.-- */--'use strict';--const fs = require('node:fs');--const path = require('node:path');--const db = require('../db/database');--const media = require('../media-client');--let config = null; try { config = require('../config'); } catch { /* */ }----// ── Short-cached Media content (VODs/clips/pastes live in OpenVibe.Media now) ──--const _mc = new Map(); // key → { v, at }--const MC_TTL_MS = 60_000;--async function _cached(key, fn) {-- const e = _mc.get(key);-- if (e && Date.now() - e.at < MC_TTL_MS) return e.v;-- let v;-- try { v = await fn(); } catch (err) {-- // A missing id is remembered too: otherwise every request for a dead link (the SPA fallback-- // then answers it 404, see server/web/page-status.js) is another round trip to Media.-- if (!err || (err.status !== 404 && err.status !== 410)) return e ? e.v : null;-- v = null;-- }-- _mc.set(key, { v, at: Date.now() });-- if (_mc.size > 300) _mc.delete(_mc.keys().next().value);-- return v;--}--const _vodList = (limit, offset = 0) => _cached(`vl:${limit}:${offset}`, async () => (await media.listVods({ limit, offset }))?.vods || []);--// People's clips and pastes: the AI's are listed on /moments (server/content/feed.js).--const _clipList = (limit, offset = 0) => _cached(`cl:${limit}:${offset}`, async () => (await media.listClips({ limit, offset, auto_generated: 0 }))?.clips || []);--const _pasteList = (limit, offset = 0) => _cached(`pl:${limit}:${offset}`, async () => (await require('../pastes-client').listPastes({ limit, offset, visibility: 'public', origin: 'user' }))?.pastes || []);--/**-- * Page `page` of the Content or Moments feed, as list-page items: { items, more }. The feed pages by-- * cursor, so page N follows N-1 `next` cursors (each page cached; at most FEED_MAX_PAGE deep), which-- * gives crawlers plain ?page=N links (roadmap D44) while the SPA keeps its infinite scroll.-- */--const FEED_MAX_PAGE = 20;--const FEED_PAGE_SIZE = 24;--const _feedPage = (feed, page = 1) => _cached(`feed:${feed}:${page}`, async () => {-- const f = require('../content/feed');-- let cursor = null, out = null;-- for (let n = 1; n <= page; n++) {-- out = await f.page(feed, { limit: FEED_PAGE_SIZE, ...(cursor ? { cursor } : {}) });-- if (n < page) { if (!out.next) return { items: [], more: false }; cursor = out.next; }-- }-- const kindLabel = { vod: 'VOD', clip: 'clip', paste: 'paste', recap: 'recap' };-- return {-- items: (out.items || []).map((it) => ({-- url: it.kind === 'paste' ? pasteHref(it.id) : it.href, name: it.title, by: it.channel ? it.channel.display_name : null,-- meta: [kindLabel[it.kind], it.duration_seconds ? _fmtDur(it.duration_seconds) : null].filter(Boolean).join(' · '),-- desc: it.excerpt,-- })),-- more: Boolean(out.next) && page < FEED_MAX_PAGE,-- };--});--const _feedList = async (feed) => (await _feedPage(feed, 1)).items;--const { isPrivate } = require('../media-proxy/access');--const pageStatus = require('../web/page-status');----// ── One item (VOD, clip, paste) ──--// Fetched at most once at a time per id and kept a minute (a missing id 30 s, a failure not at--// all). A page waits at most DETAIL_WAIT_MS for it, so a slow Media or Community never holds a--// page, or a crawler, for its whole timeout; the fetch keeps running and fills the cache. What a--// fresh fetch learned is handed to the SPA fallback's status check (server/web/page-status.js):--// a page this renderer passes on (missing, or private to this visitor) costs one upstream call.--const DETAIL_WAIT_MS = Math.max(0, pageStatus.LOOKUP_DEADLINE_MS - 500);--const DETAIL_FOUND_TTL_MS = 60_000;--const DETAIL_MISSING_TTL_MS = 30_000;--const _items = new Map(); // key → { at, ttl, state: 'found'|'missing'|null, row, pending }--function _detail(key, load) {-- let e = _items.get(key);-- if (e && !e.pending && e.state && Date.now() - e.at < e.ttl) return Promise.resolve({ state: e.state, row: e.row, fresh: false });-- if (!e || !e.pending) {-- const entry = { at: 0, ttl: 0, state: null, row: null, pending: null };-- entry.pending = Promise.resolve().then(load).then(-- (row) => { if (row && typeof row === 'object') { entry.state = 'found'; entry.row = row; entry.ttl = DETAIL_FOUND_TTL_MS; } else { entry.state = 'missing'; entry.ttl = DETAIL_MISSING_TTL_MS; } },-- (err) => { if (err && (err.status === 404 || err.status === 410)) { entry.state = 'missing'; entry.ttl = DETAIL_MISSING_TTL_MS; } },-- ).then(() => {-- entry.at = Date.now();-- entry.pending = null;-- if (!entry.state && _items.get(key) === entry) _items.delete(key); // a failure is not remembered-- return { state: entry.state || 'unknown', row: entry.row, fresh: true };-- });-- _items.delete(key);-- _items.set(key, entry);-- if (_items.size > 1000) _items.delete(_items.keys().next().value);-- e = entry;-- }-- let timer;-- const late = new Promise((resolve) => { timer = setTimeout(resolve, DETAIL_WAIT_MS, { state: 'unknown', row: null, fresh: false }); });-- return Promise.race([e.pending, late]).finally(() => clearTimeout(timer));--}--// Callers may annotate the row they get (AI state overlay), so each gets its own copy.--const _copy = (r) => (r.row ? { ...r.row } : null);--async function _vodGet(id) {-- const r = await _detail(`v:${id}`, () => media.getVod(id));-- if (r.fresh && r.state !== 'unknown') pageStatus.primeMediaItem('vod', String(id), r.row);-- return _copy(r);--}--async function _clipGet(id) {-- const r = await _detail(`c:${id}`, () => media.getClip(id));-- if (r.fresh && r.state !== 'unknown') pageStatus.primeMediaItem('clip', String(id), r.row);-- return _copy(r);--}--async function _pasteGet(slug) {-- const r = await _detail(`p:${slug}`, () => require('../pastes-client').getPaste(slug));-- if (r.fresh && r.state !== 'unknown') pageStatus.primePaste(slug, r.state === 'found');-- return _copy(r);--}----// Lists (a channel's VODs, a VOD's clips, the home rails) are extras on a page: each waits at--// most LIST_WAIT_MS, and a page rendered without one is cached briefly so it fills in soon.--const LIST_WAIT_MS = 1500;--const SHORT_CACHE_MS = 30_000;--const _TIMED_OUT = Symbol('timed out');--function _within(promise, ms) {-- let timer;-- const late = new Promise((resolve) => { timer = setTimeout(resolve, ms, _TIMED_OUT); });-- return Promise.race([Promise.resolve(promise).catch(() => null), late]).finally(() => clearTimeout(timer));--}--/** A cached list call, or null (and `partial` set on the tracker) when it did not answer in time. */--async function _listWithin(key, fn, track) {-- const r = await _within(_cached(key, fn), LIST_WAIT_MS);-- if (r === _TIMED_OUT || r == null) { if (track) track.partial = true; return null; }-- return r;--}--const _pub = (row) => row && !isPrivate(row) && Number(row.is_public) !== 0 && row.is_public !== false;----// Overlay the Live-owned AI state (vod_ai_state/clip_ai_state) onto a Media row so--// descriptions/transcripts keep enriching the crawlable snapshot.--function _overlayAiState(row, kind) {-- try {-- const st = kind === 'clip' ? db.getClipAiState(row.id) : db.getVodAiState(row.id);-- if (!st) return;-- if (st.ai_overview_short) {-- row.ai_overview_short = row.ai_overview_short || st.ai_overview_short;-- row.ai_overview = row.ai_overview || st.ai_overview || st.ai_overview_short;-- }-- if (st.ai_transcript_json && !row.ai_transcript) {-- try { row.ai_transcript = JSON.parse(st.ai_transcript_json).map(s => s.text).join(' ').trim(); } catch { /* */ }-- }-- } catch { /* */ }--}----// Pastes live on openvibe.community: /p/<slug> is a redirect there (server/web/paste-handover.js,--// mounted before this middleware), and every paste link Live renders, here and in the SPA (which--// reads the base from the ov-pastes-base meta on every shell), points straight at Community, the--// paste's one canonical page. Live's sitemap never lists /p/ (roadmap 32.2).--const PASTES_BASE = (process.env.OV_COMMUNITY_URL || 'https://openvibe.community').replace(/\/$/, '');--const PASTES_META = `<meta name="ov-pastes-base" content="${esc(PASTES_BASE)}">`;--const pasteHref = (slug) => `${PASTES_BASE}/p/${encodeURIComponent(slug)}`;----const SITE_NAME = 'OpenVibe.Live';--const DEFAULT_OG_IMAGE = '/og-image.png';----function baseUrl() {-- let b = (config && config.baseUrl) || 'https://openvibe.live';-- if (/localhost|127\.0\.0\.1/.test(b)) b = 'https://openvibe.live'; // never emit localhost in public meta-- return b.replace(/\/+$/, '');--}--function abs(url) {-- if (!url) return '';-- if (/^https?:\/\//i.test(url)) return url;-- return baseUrl() + (url.startsWith('/') ? url : '/' + url);--}--// HTML-escape for text nodes / attribute values.--function esc(s) {-- return String(s == null ? '' : s)-- .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')-- .replace(/"/g, '"').replace(/'/g, ''');--}--// Collapse to a clean single-line meta-description-safe string.--function clean(s, max) {-- let t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim();-- if (max && t.length > max) t = t.slice(0, max - 1).replace(/\s+\S*$/, '') + '…';-- return t;--}--function isoDate(dt) {-- try {-- if (!dt) return null;-- const d = new Date(String(dt).includes('T') ? dt : String(dt).replace(' ', 'T') + 'Z');-- return isNaN(d.getTime()) ? null : d.toISOString();-- } catch { return null; }--}--// Seconds → ISO-8601 duration (PT#H#M#S) for VideoObject.--function iso8601Duration(sec) {-- sec = Math.max(0, Math.floor(Number(sec) || 0));-- const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;-- return 'PT' + (h ? h + 'H' : '') + (m ? m + 'M' : '') + (s || (!h && !m) ? s + 'S' : '');--}--function jsonLd(obj) {-- // Escape "<" so a value can never break out of the <script> block.-- return `<script type="application/ld+json">${JSON.stringify(obj).replace(/</g, '\\u003c')}</script>`;--}----// ── Build the per-route metadata object ────────────────────────────────────────────────────--// Returns { title, description, canonicalPath, image, ogType, robots, jsonLd:[...], snapshot }--// or null to fall through (unknown / private / not found).----async function _pageMeta(routePath, { page = 1 } = {}) {-- const p = routePath.replace(/\/+$/, '') || '/';-- const bu = baseUrl();---- // Home-- if (p === '/') return _homeMeta();-- // List pages (content sourced from OpenVibe.Media, canonical URLs stay on openvibe.live)-- if (p === '/vods') return _listMeta('vods', 'VODs', 'Browse recorded live streams (VODs) on OpenVibe.Live — auto-recorded broadcasts with AI overviews and searchable transcripts.', async () => (await _vodList(30) || []).map(v => ({ url: `/vod/${v.id}`, name: v.title || 'VOD', by: v.display_name || v.username, meta: _fmtDur(v.duration_seconds || v.duration), desc: v.ai_overview_short })));-- if (p === '/clips') return _listMeta('clips', 'Clips', 'Watch the best clips from OpenVibe.Live live streams — the moments viewers clipped.', async () => (await _clipList(30) || []).map(c => ({ url: `/clip/${c.id}`, name: c.title || 'Clip', by: c.display_name || c.username || c.streamer_username, meta: _fmtDur(c.duration_seconds || c.duration), desc: c.ai_overview_short })));-- if (p === '/content') return _listMeta('content', 'Content', 'VODs, clips and pastes made by the people of OpenVibe.Live — recorded streams, the moments viewers clipped, and the code, notes and screenshots they shared.', () => _feedPage('content', page), { page });-- if (p === '/moments') return _listMeta('moments', 'AI Moments', 'AI-made highlights from OpenVibe.Live streams: auto-clips of the moments chat erupted, standout frames the AI picked, and AI-written after-show recaps. Everything listed here is AI-generated.', () => _feedPage('moments', page), { page });-- if (p === '/pastes') return _listMeta('pastes', 'Pastes', 'Code, text and screenshot pastes shared on OpenVibe.Live — a Pastebin built into the streaming network, with AI summaries.', async () => (await _pasteList(30) || []).map(x => ({ url: pasteHref(x.slug), name: x.title || 'Paste', by: x.username || 'anon', meta: x.type === 'screenshot' ? 'image' : (x.language || 'text'), desc: x.ai_summary })));---- // Detail pages-- let m;-- if ((m = p.match(/^\/vod\/(\d+)$/))) return _vodMeta(parseInt(m[1], 10));-- if ((m = p.match(/^\/clip\/(\d+)$/))) return _clipMeta(parseInt(m[1], 10));-- if ((m = p.match(/^\/p\/([A-Za-z0-9_-]+)$/))) return _pasteMeta(m[1]);-- if ((m = p.match(CHANNEL_PATH_RE))) return _channelMeta(m[1], page);-- if ((m = p.match(/^\/recap\/(\d+)$/))) return _recapMeta(parseInt(m[1], 10));-- if (p === '/arena') return _arenaMeta();-- // Search results (public/js/app-search.js) are never indexed; the page itself is findable.-- if (p === '/search') return { title: `Search | ${SITE_NAME}`, description: 'Search channels, VODs and clips on OpenVibe.Live.', canonicalPath: '/search', ogType: 'website', robots: 'noindex,follow', jsonLd: [] };-- // The global chat room: its own title and canonical (the shell left it with neither; browser check, WS-Q task 3).-- if (p === '/chat') return { title: `Chat | ${SITE_NAME}`, description: 'The global chat room of OpenVibe.Live, open to everyone on the network.', canonicalPath: '/chat', ogType: 'website', robots: 'index,follow', jsonLd: [] };---- return null;-- void bu;--}----// ── Channel page: /@<username>, with ?page=N over the channel's videos ─────────────────────--// The client's channel-name rule (CHANNEL_USERNAME_RE in public/js/app.js, CHANNEL_RE in--// server/web/page-status.js): anything else is never a channel.--const CHANNEL_PATH_RE = /^\/@([A-Za-z0-9_]{3,24})$/;--const CHANNEL_PAGE_SIZE = 12;--const CHANNEL_MAX_PAGE = 500;--const CHANNEL_CACHE_MS = 60_000; // the page says who is live, so it is kept a minute, not five----/** The account behind /@<name>: its channel row, or just the account when it has none yet. */--function _channelAccount(username) {-- let ch = null;-- try { ch = db.getChannelByUsername(username); } catch { ch = null; }-- let user = null;-- try { user = db.getUserById(ch ? ch.user_id : (db.getUserByUsername(username) || {}).id); } catch { user = null; }-- if (!ch && !user) return null;-- return {-- userId: ch ? ch.user_id : user.id,-- username: (ch && ch.username) || user.username,-- displayName: (ch && ch.display_name) || (user && user.display_name) || (ch && ch.username) || user.username,-- avatarUrl: (ch && ch.avatar_url) || (user && user.avatar_url) || null,-- bio: (ch && ch.bio) || (user && user.bio) || '',-- banned: !!(user && (user.is_banned === 1 || user.is_banned === true)),-- };--}----async function _channelMeta(username, page = 1) {-- const acct = _channelAccount(username);-- if (!acct) return null;-- page = Math.min(CHANNEL_MAX_PAGE, Math.max(1, Math.floor(Number(page) || 1)));-- const name = acct.displayName;-- const handle = '@' + acct.username;-- const basePath = `/${handle}`;-- let ov = null;-- try { ov = db.getStreamerOverview ? db.getStreamerOverview(acct.userId) : null; } catch { /* */ }-- let followers = 0;-- try { followers = db.getFollowerCount ? (db.getFollowerCount(acct.userId) || 0) : 0; } catch { /* */ }-- let live = [];-- try { live = acct.banned ? [] : (db.getLiveStreamsByUserId(acct.userId) || []); } catch { live = []; }-- const bio = clean(acct.bio || '', 300);-- const aiShort = clean((ov && (ov.overview_short || ov.overview)) || '', 220);---- // What the channel has made: its videos (this page of them), the clips people took of its-- // streams, then what the AI cut from them, labelled (roadmap 33.6: made, then derived).-- const track = { partial: false };-- const offset = (page - 1) * CHANNEL_PAGE_SIZE;-- const [vr, cr, ar] = acct.banned ? [null, null, null] : await Promise.all([-- _listWithin(`chv:${acct.userId}:${offset}`, () => media.listVods({ user_id: acct.userId, order: 'newest', limit: CHANNEL_PAGE_SIZE, offset }, { timeoutMs: 5000 }), track),-- page === 1 ? _listWithin(`chc:${acct.userId}`, () => media.listClips({ channel_user_id: acct.userId, auto_generated: 0, limit: 8 }, { timeoutMs: 5000 }), track) : null,-- page === 1 ? _listWithin(`cha:${acct.userId}`, () => media.listClips({ channel_user_id: acct.userId, auto_generated: 1, limit: 6 }, { timeoutMs: 5000 }), track) : null,-- ]);-- const vods = ((vr && vr.vods) || []).filter((v) => _pub(v) && !v.is_recording && v.status !== 'failed');-- const vodTotal = vr && Number.isFinite(Number(vr.total)) ? Number(vr.total) : null;-- const pages = vodTotal != null ? Math.max(1, Math.ceil(vodTotal / CHANNEL_PAGE_SIZE)) : null;-- const clips = ((cr && cr.clips) || []).filter((c) => _pub(c) && !isAiClip(c));-- const aiClips = ((ar && ar.clips) || []).filter((c) => _pub(c) && isAiClip(c));-- // A page past the end has nothing on it: not a page to index, and its canonical is page 1.-- const pastEnd = page > 1 && !track.partial && (!vods.length || (pages != null && page > pages));-- const canonicalPath = page > 1 && !pastEnd ? `${basePath}?page=${page}` : basePath;-- const pageLabel = page > 1 ? ` — videos, page ${page}` : '';---- const desc = clean(bio || aiShort || `${name} (${handle}) streams live on ${SITE_NAME}. Watch their live streams, VODs and clips.`, 200);-- const image = acct.avatarUrl ? abs(acct.avatarUrl) : DEFAULT_OG_IMAGE;-- const person = {-- '@type': 'Person', name: clean(name, 80), alternateName: handle, url: abs(basePath),-- image: image !== DEFAULT_OG_IMAGE ? image : undefined,-- description: desc,-- interactionStatistic: followers ? { '@type': 'InteractionCounter', interactionType: 'https://schema.org/FollowAction', userInteractionCount: followers } : undefined,-- };-- const profile = { '@context': 'https://schema.org', '@type': 'ProfilePage', name: `${name} (${handle})`, url: abs(canonicalPath), mainEntity: person };-- const ld = [profile];-- if (vods.length) {-- ld.push({-- '@context': 'https://schema.org', '@type': 'ItemList', name: `${name}'s videos${page > 1 ? `, page ${page}` : ''}`,-- itemListElement: vods.map((v, i) => ({ '@type': 'ListItem', position: offset + i + 1, url: abs(`/vod/${Number(v.id)}`), name: clean(v.title || 'Stream recording', 110) })),-- });-- }-- ld.push(_breadcrumb([{ name: 'Home', url: '/' }, { name, url: basePath }, ...(page > 1 ? [{ name: `Videos, page ${page}`, url: canonicalPath }] : [])]));---- // The page body, for crawlers and for anyone reading without JavaScript.-- let body = `<article><h1>${esc(`${name} (${handle})`)}</h1>`;-- const facts = [followers ? `${followers} follower${followers === 1 ? '' : 's'} on ${SITE_NAME}` : null];-- if (live.length) facts.push(`Live now: ${clean(live[0].title || 'streaming', 100)}`);-- if (facts.filter(Boolean).length) body += `<p class="byline">${esc(facts.filter(Boolean).join(' · '))}</p>`;-- if (acct.banned) body += '<p>This account is suspended.</p>';-- if (bio) body += `<p>${esc(bio)}</p>`;-- if (page === 1 && ov && ov.overview && String(ov.overview).trim()) {-- body += `<section><h2>AI overview</h2><p class="ai-disclosure">Written by OpenVibe's AI from ${esc(name)}'s past streams.</p><p>${esc(clean(ov.overview, 1200))}</p></section>`;-- }-- body += _itemSection(page > 1 ? `Videos, page ${page}` : 'Videos', vods.map((v) => ({-- url: `/vod/${Number(v.id)}`, name: v.title || 'Stream recording',-- meta: [_fmtDur(v.duration_seconds ?? v.duration), _day(v.created_at)].filter(Boolean).join(' · '),-- })), vods.length || page > 1 ? null : (track.partial ? null : 'No videos yet.'));-- if (vods.length || page > 1) body += _pager(basePath, page, pages, vods.length === CHANNEL_PAGE_SIZE, 'videos');-- if (clips.length) body += _itemSection('Clips', clips.map((c) => ({ url: `/clip/${Number(c.id)}`, name: c.title || 'Clip', meta: [_clipperOf(c) ? `clipped by ${_clipperOf(c)}` : null, _fmtDur(c.duration_seconds ?? c.duration)].filter(Boolean).join(' · ') })));-- if (aiClips.length) {-- body += _itemSection('AI Moments', aiClips.map((c) => ({ url: `/clip/${Number(c.id)}`, name: c.title || 'AI clip', meta: ['AI clip', _fmtDur(c.duration_seconds ?? c.duration)].filter(Boolean).join(' · ') })),-- null, `<p class="ai-disclosure">Clips OpenVibe's AI cut from ${esc(name)}'s streams when chat reacted. No one clipped these.</p>`);-- }-- body += `<p><a href="${esc(abs(basePath))}">Visit ${esc(name)}'s channel</a> on ${SITE_NAME}.</p></article>`;---- return {-- title: `${name} (${handle})${pageLabel} — ${SITE_NAME}`, description: desc,-- canonicalPath, image, ogType: 'profile',-- robots: acct.banned || pastEnd ? 'noindex,follow' : 'index,follow',-- jsonLd: ld, snapshot: body,-- cacheTtlMs: track.partial ? SHORT_CACHE_MS : CHANNEL_CACHE_MS,-- };--}----/** "Page 2 of 5" with newer/older links (?page=N), the crawlable form of the channel's video list. */--function _pager(basePath, page, pages, maybeMore, noun) {-- const href = (n) => esc(abs(n > 1 ? `${basePath}?page=${n}` : basePath));-- const parts = [];-- if (page > 1) parts.push(`<a rel="prev" href="${href(page - 1)}">Newer ${noun}</a>`);-- parts.push(`Page ${page}${pages ? ` of ${pages}` : ''}`);-- if (pages ? page < pages : maybeMore) parts.push(`<a rel="next" href="${href(page + 1)}">Older ${noun}</a>`);-- return `<nav class="pagination" aria-label="Pages">${parts.join(' · ')}</nav>`;--}--/** A heading and a list of links with their details; `empty` when there is nothing, `intro` above. */--function _itemSection(heading, items, empty = null, intro = '') {-- if (!items.length && !empty) return '';-- const li = items.map((it) => `<li><a href="${esc(abs(it.url))}">${esc(clean(it.name, 120))}</a>${it.meta ? ` — ${esc(it.meta)}` : ''}</li>`).join('');-- return `<section><h2>${esc(heading)}</h2>${intro}${items.length ? `<ul>${li}</ul>` : `<p>${esc(empty)}</p>`}</section>`;--}--const _day = (dt) => { const d = isoDate(dt); return d ? d.slice(0, 10) : null; };--/** A person's clip's clipper, from Live's accounts (Media rows carry ids only). */--function _clipperOf(c) { return c.display_name || c.username || _nameOf(_channelOwner(c.user_id)); }----// The Arena: a real content page (mic-judged trash talk), so give it real metadata.--function _arenaMeta() {-- let fighters = [], moments = [];-- try { fighters = (require('../arena/arena-service').loadRoster() || {}).order || []; } catch { fighters = []; }-- try { moments = require('../arena/mic').feed({ limit: 10 }) || []; } catch { moments = []; }-- const nameOf = (id) => { try { return require('../arena/mic').nameOf(id); } catch { return null; } };-- const names = fighters.slice(0, 8).map(nameOf).filter(Boolean);-- const title = 'The Arena — Mic-Judged Trash Talk Between Streamers';-- const desc = clean(`Every callout streamers make on mic, judged and ranked. ${names.length ? 'Fighters right now: ' + names.slice(0, 5).join(', ') + '. ' : ''}Chat can hype a beef but never write one.`, 200);-- const items = moments.slice(0, 10).map((mo, i) => ({ '@type': 'ListItem', position: i + 1, name: clean(mo.text || 'Mic moment', 110) }));-- const snapshot = _detailSnapshot({-- title: 'The Arena', byline: names.length ? `Fighters: ${names.join(', ')}` : null,-- desc, overview: moments.slice(0, 8).map(mo => mo.text).filter(Boolean).join(' · ') || null,-- transcript: null, canonicalPath: '/arena', watchLabel: 'Open the Arena',-- });-- return {-- title: `${title} | ${SITE_NAME}`, description: desc, canonicalPath: '/arena',-- image: DEFAULT_OG_IMAGE, ogType: 'website', robots: 'index,follow',-- jsonLd: [-- { '@context': 'https://schema.org', '@type': 'CollectionPage', name: title, url: abs('/arena'), description: desc },-- ...(items.length ? [{ '@context': 'https://schema.org', '@type': 'ItemList', name: 'Latest mic moments', itemListElement: items }] : []),-- _breadcrumb([{ name: 'Home', url: '/' }, { name: 'The Arena', url: '/arena' }]),-- ],-- snapshot,-- };--}----// After-show report: /recap/:streamId--// The AI-written report is an AI Moment: labelled, attributed to no person (the streamer did not--// write it), noindex,follow and canonical to the stream's VOD when there is one. A template report--// (stats only, no model) is not AI writing and keeps its own indexable page; it has no Person--// author either, since the streamer did not write that one.--async function _recapMeta(streamId) {-- let r; try { r = require('../recap/recap').getRecap(streamId); } catch { r = null; }-- if (!r) return null;-- const ai = r.ai === true || Number(r.ai) === 1;-- const name = r.streamer.display_name || r.streamer.username;-- const title = clean(`${r.write.headline || r.stream.title}`, 90);-- const desc = clean(ai-- ? `AI-written after-show report on ${name}'s stream "${r.stream.title}". ${r.write.summary || ''}`-- : (r.write.summary || `After-show report for ${name}'s stream "${r.stream.title}" on ${SITE_NAME}.`), 200);-- const image = r.vod && r.vod.thumbnail_url ? media.publicUrl(r.vod.thumbnail_url) : (r.streamer.avatar_url ? abs(r.streamer.avatar_url) : DEFAULT_OG_IMAGE);-- const selfPath = `/recap/${streamId}`;-- const source = ai && r.vod ? await _sourceMoment(r.vod.id, 0) : null;-- const article = {-- '@context': 'https://schema.org', '@type': 'Article', headline: title, description: desc, image: [image],-- datePublished: isoDate(r.stream.ended_at) || undefined,-- keywords: ai ? AI_KEYWORDS : undefined,-- isBasedOn: source ? abs(source.path) : undefined,-- publisher: { '@type': 'Organization', name: SITE_NAME, url: baseUrl() },-- };-- const byline = `${ai ? 'AI recap · from' : 'Report on'} ${name}'s stream · ${Math.round((r.stream.duration_seconds || 0) / 60)} min · grade ${r.write.grade}`;-- const extraHtml = (ai ? '<p class="ai-disclosure">Written automatically by OpenVibe\'s after-show report workflow from the stream\'s stats, chat and transcript. The streamer did not write it.</p>' : '')-- + `<p class="source">${source ? `<a href="${esc(abs(source.path))}">Watch the stream</a> · ` : ''}<a href="${esc(abs(`/@${r.streamer.username}`))}">${esc(name)}'s channel</a></p>`;-- const snapshot = _detailSnapshot({ title: `After-show report: ${r.stream.title}`, byline, desc, extraHtml, overview: r.write.summary, transcript: null, canonicalPath: selfPath, watchLabel: 'Read the report' });-- return {-- title: `${title} — ${ai ? 'AI recap of ' : ''}${name}'s stream${ai ? '' : ' report'} | ${SITE_NAME}`, description: desc,-- canonicalPath: source ? source.path : selfPath, ogUrlPath: selfPath, image, ogType: 'article',-- robots: ai ? AI_ROBOTS : 'index,follow',-- jsonLd: [article, _breadcrumb([{ name: 'Home', url: '/' }, { name, url: `/@${r.streamer.username}` }, { name: ai ? 'AI recap' : 'Report', url: selfPath }])],-- snapshot,-- };--}----// A crawlable <section> listing media items with real detail (title, streamer, meta).--function _mediaSection(heading, items) {-- if (!items || !items.length) return '';-- const li = items.map(it => {-- const bits = [];-- if (it.by) bits.push('by ' + esc(it.by));-- if (it.meta) bits.push(esc(it.meta));-- return `<li><a href="${esc(abs(it.url))}">${esc(clean(it.name, 120))}</a>${bits.length ? ' — ' + bits.join(' · ') : ''}${it.desc ? `<br><small>${esc(clean(it.desc, 160))}</small>` : ''}</li>`;-- }).join('');-- return `<section><h2>${esc(heading)}</h2><ul>${li}</ul></section>`;--}--// m:ss, or h:mm:ss from an hour up; '' for nothing.--function _fmtDur(sec) {-- sec = Math.floor(Number(sec) || 0);-- if (!sec) return '';-- const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;-- const ss = String(s).padStart(2, '0');-- return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`;--}----async function _homeMeta() {-- // Going live from the browser (no OBS, no downloads, no follower minimum) leads: it is what people ask-- // search engines and AI assistants for, and what they recommend OpenVibe.Live for (2026-09-28).-- const title = 'OpenVibe.Live — Go Live From Your Browser, No OBS Needed';-- const description = 'Go live instantly from your browser: no OBS, no downloads, no follower minimum. Open-source, community-run streaming with sub-second WebRTC, chat, VODs, clips and restreaming to Twitch, YouTube and Kick.';---- // Pull the actual live content so the source has real, crawlable text.-- let live = [];-- try { live = (db.getLiveStreams() || []).slice(0, 12); } catch { /* */ }-- // Each rail waits at most LIST_WAIT_MS: a slow upstream leaves its rail out, never the page.-- // VODs, clips and pastes are counted by the services that hold them (Media, Community), not Live's frozen tables.-- let homeStats = null;-- try { homeStats = { ...db.getHomeStats() }; } catch { homeStats = null; }-- const track = { partial: false };-- const settle = async (promise) => { const r = await _within(promise, LIST_WAIT_MS); if (r === _TIMED_OUT) { track.partial = true; return null; } return r; };-- let [vods, clips, pastes, stats] = await Promise.all([-- settle(_vodList(12)), settle(_clipList(12)), settle(_pasteList(12)),-- homeStats ? settle(require('../media-proxy/lookups').withArchiveStats(homeStats)) : null,-- ]);-- vods = vods || []; clips = clips || []; pastes = pastes || [];-- const ownerName = (row, id) => row.display_name || row.username || _nameOf(_channelOwner(id));---- const liveItems = live.map(s => ({ url: `/@${s.username}`, name: s.title || `${s.display_name || s.username} live`, by: s.display_name || s.username, meta: s.category || 'live' }));-- const vodItems = vods.map(v => ({ url: `/vod/${v.id}`, name: v.title || 'VOD', by: ownerName(v, v.user_id), meta: _fmtDur(v.duration_seconds || v.duration), desc: v.ai_overview_short }));-- const clipItems = clips.map(c => ({ url: `/clip/${c.id}`, name: c.title || 'Clip', by: ownerName(c, c.user_id), meta: _fmtDur(c.duration_seconds || c.duration) }));-- const pasteItems = pastes.map(x => ({ url: pasteHref(x.slug), name: x.title || 'Paste', by: x.username || 'anon', meta: x.type === 'screenshot' ? 'image' : (x.language || 'text') }));---- const statLine = stats ? `<p>${SITE_NAME} hosts ${stats.streamers || 0} streamers, ${stats.vods || 0} VODs, ${stats.clips || 0} clips, ${stats.pastes || 0} pastes and ${stats.chatMessages || 0} chat messages.</p>` : '';-- const snapshot =-- `<h1>${SITE_NAME} — go live from your browser, no OBS needed</h1>` +-- `<p>${esc(description)}</p>` +-- '<section><h2>Go live in your browser</h2><ol>' +-- '<li>Open openvibe.live in Chrome, Edge, Firefox or Safari — on a Chromebook, laptop or phone.</li>' +-- '<li>Sign in or make an account; your channel exists right away.</li>' +-- '<li>Press Go Live and allow your camera and microphone.</li>' +-- '<li>Add a title and start: you are live with your own channel and chat.</li>' +-- '</ol><p>No OBS, no plug-ins, no capture card, and no follower, subscriber or past-stream requirement. ' +-- `<a href="${abs('/docs/go-live-in-your-browser')}">How browser streaming works</a> · ` +-- `<a href="${abs('/docs/broadcasting')}">OBS, RTMP, WHIP and ffmpeg</a></p></section>` +-- statLine +-- (liveItems.length ? _mediaSection('Live now', liveItems) : '<section><h2>Live now</h2><p>No one is streaming right now — be the first to go live.</p></section>') +-- _mediaSection('Recent VODs', vodItems) +-- _mediaSection('Recent clips', clipItems) +-- _mediaSection('Recent pastes', pasteItems);---- // Rich JSON-LD: WebSite + one ItemList per section of the snapshot, named by that section's visible-- // heading (structured data names what a visitor sees; the browser check compares them).-- const itemList = (name, items) => ({ '@context': 'https://schema.org', '@type': 'ItemList', name,-- itemListElement: items.slice(0, 10).map((it, i) => ({ '@type': 'ListItem', position: i + 1, url: abs(it.url), name: clean(it.name, 110) })) });-- const lists = [['Live now', liveItems], ['Recent VODs', vodItems], ['Recent clips', clipItems]].filter(([, items]) => items.length).map(([n, items]) => itemList(n, items));-- const jsonLd = [-- {-- '@context': 'https://schema.org', '@type': 'WebSite',-- name: SITE_NAME, url: baseUrl(), description: clean(description, 300),-- inLanguage: 'en', publisher: { '@id': `${baseUrl()}/#org` },-- },-- {-- '@context': 'https://schema.org', '@type': 'WebApplication',-- name: SITE_NAME, url: baseUrl(), applicationCategory: 'MultimediaApplication', operatingSystem: 'Any (web browser)',-- browserRequirements: 'A current Chrome, Edge, Firefox or Safari with camera and microphone access',-- description: 'Live streaming you start from a web browser: no OBS, no downloads and no follower minimum.',-- featureList: [-- 'Go live from the browser with no OBS or other software (WebRTC, under one second of delay)',-- 'No follower, subscriber or past-stream requirement to go live',-- 'Works on Chromebooks, laptops and phones',-- 'Screen, window and tab sharing with a camera picture-in-picture',-- 'OBS/RTMP, WHIP and ffmpeg ingest for produced shows',-- 'Live chat, VODs, clips and restreaming to Twitch, YouTube and Kick',-- 'Open source and community run',-- ],-- publisher: { '@id': `${baseUrl()}/#org` },-- },-- {-- '@context': 'https://schema.org', '@type': 'Organization', '@id': `${baseUrl()}/#org`,-- name: 'OpenVibe', url: baseUrl(), logo: DEFAULT_OG_IMAGE,-- description: 'An open-source network of live streaming, media and developer tools, built and run by the people who use it.',-- // sameAs is how a search engine learns these properties are one brand rather than-- // unrelated sites that happen to share a name.-- sameAs: [-- 'https://github.com/OpenVibers',-- 'https://discord.gg/M6MuRUaeJj',-- 'https://openvibe.tools',-- 'https://openvibe.network',-- ],-- },-- ...lists,-- ];-- return { title, description, canonicalPath: '/', image: DEFAULT_OG_IMAGE, ogType: 'website', robots: 'index,follow', jsonLd, snapshot, cacheTtlMs: track.partial ? SHORT_CACHE_MS : undefined };--}----/**-- * A list page. `itemsFn` gives an array, or { items, more } for a paged list (page N of it: ?page=N,-- * self-canonical, with newer/older links; a page past the end is not rendered here).-- */--async function _listMeta(slug, label, description, itemsFn, { page = 1 } = {}) {-- let items = [], more = null;-- try {-- const got = await itemsFn();-- if (Array.isArray(got)) items = got.slice(0, 24);-- else if (got) { items = (got.items || []).slice(0, 24); more = Boolean(got.more); }-- } catch { /* */ }-- if (page > 1 && !items.length) return null;-- const basePath = '/' + slug;-- const selfPath = page > 1 ? `${basePath}?page=${page}` : basePath;-- const pageLabel = page > 1 ? `, page ${page}` : '';-- const list = {-- '@context': 'https://schema.org', '@type': 'CollectionPage',-- name: `${label}${pageLabel} — ${SITE_NAME}`, url: abs(selfPath), description,-- mainEntity: {-- '@type': 'ItemList',-- itemListElement: items.map((it, i) => ({ '@type': 'ListItem', position: (page - 1) * 24 + i + 1, url: abs(it.url), name: clean(it.name, 110) })),-- },-- };-- let snapshot = `<h1>${esc(label)} on ${SITE_NAME}${page > 1 ? ` — page ${page}` : ''}</h1><p>${esc(description)}</p>` +-- _mediaSection(page > 1 ? `${label}, page ${page}` : `Latest ${label.toLowerCase()}`, items);-- if (more !== null && (page > 1 || more)) snapshot += _pager(basePath, page, null, more, label.toLowerCase());-- return {-- title: `${label}${pageLabel} — ${SITE_NAME}`, description, canonicalPath: selfPath,-- image: DEFAULT_OG_IMAGE, ogType: 'website', robots: 'index,follow',-- jsonLd: [list, _breadcrumb([{ name: 'Home', url: '/' }, { name: label, url: basePath }, ...(page > 1 ? [{ name: `Page ${page}`, url: selfPath }] : [])])],-- snapshot,-- };--}----function _authorLd(name) { return name ? { '@type': 'Person', name: clean(name, 80) } : undefined; }----// ── AI Moments (roadmap §33.4, §33.8) ───────────────────────────────────────────────────────--// What the AI made from a stream (auto-clips, AI moment pastes, AI after-show recaps) is labelled--// as AI-made, attributed to no person, kept out of search (noindex,follow: the page still passes--// its links on) and made canonical to its source: the VOD at the moment it came from, when that--// VOD is known and public. The store decides what is AI (Media's auto_generated, Community's--// origin, the recap's own ai flag); nothing is inferred here. People's clips and pastes keep their--// normal, indexable pages.--const AI_ROBOTS = 'noindex,follow';--const AI_KEYWORDS = 'AI-generated';--const VOD_MOMENT_RE = /^\/vod\/(\d+)(?:\?t=(\d+(?:\.\d+)?))?$/;----const isAiClip = (c) => !!c && (c.auto_generated === true || Number(c.auto_generated) === 1);--const isAiPaste = (p) => !!p && p.origin === 'ai';--function _json(v) {-- if (!v) return null;-- if (typeof v === 'object') return v;-- try { const o = JSON.parse(v); return o && typeof o === 'object' ? o : null; } catch { return null; }--}--/** The Live account a stream-derived item belongs to (the channel, never a viewer). */--function _channelOwner(userId) {-- if (userId == null || userId === '') return null;-- try { return db.getUserById(Number(userId)) || null; } catch { return null; }--}--const _nameOf = (u) => (u ? (u.display_name || u.username) : null);----/**-- * `/vod/<id>?t=<s>` for the moment an item came from, or null when the VOD is unknown, gone or-- * not public (a canonical must never point at a page that answers 404 to the crawler).-- */--async function _sourceMoment(vodId, seconds) {-- if (vodId == null || !/^\d+$/.test(String(vodId))) return null;-- const v = await _vodGet(Number(vodId));-- if (!v || !_pub(v)) return null;-- const t = Math.max(0, Math.floor(Number(seconds) || 0));-- return { path: `/vod/${Number(vodId)}${t ? `?t=${t}` : ''}`, vod: v };--}----async function _vodMeta(id) {-- const v = await _vodGet(id);-- // Private: fall through exactly like an unknown id. This HTML is cached per id and shared by-- // every visitor, so it never carries a private VOD's title, overview or transcript.-- if (!v || isPrivate(v)) return null;-- if (v.duration_seconds == null && v.duration != null) v.duration_seconds = v.duration;-- _overlayAiState(v, 'vod');-- const indexable = Number(v.is_public) === 1 && (!v.visibility || v.visibility === 'public');-- const owner = _channelOwner(v.user_id);-- const author = v.display_name || v.username || _nameOf(owner);-- const title = clean(v.title || `${author ? author + "'s " : ''}stream VOD`, 80);-- const desc = clean(v.ai_overview_short || v.ai_overview || v.description || `Recorded live stream${author ? ' by ' + author : ''} on ${SITE_NAME}.`, 200);-- const image = v.thumbnail_url ? media.publicUrl(v.thumbnail_url) : media.thumbUrl(`vod-${id}`);-- const canonicalPath = `/vod/${id}`;---- // The clips of this VOD: people's, then the AI's. Every one is also a moment of this video-- // (schema.org Clip in hasPart), so the source carries its moments (roadmap 33.8).-- const track = { partial: false };-- const cr = await _listWithin(`vc:${id}`, () => media.listClips({ vod_id: id, limit: 30 }, { timeoutMs: 5000 }), track);-- const all = ((cr && cr.clips) || []).filter((c) => _pub(c) && (!c.status || c.status === 'ready'));-- const people = all.filter((c) => !isAiClip(c));-- const ai = all.filter(isAiClip);-- const at = (c) => Math.max(0, Math.floor(Number(c.start_time) || 0));-- const parts = all.filter((c) => Number.isFinite(Number(c.start_time))).slice(0, 20).map((c) => ({-- '@type': 'Clip', name: clean(c.title || (isAiClip(c) ? 'AI clip' : 'Clip'), 110),-- startOffset: at(c), endOffset: Number.isFinite(Number(c.end_time)) ? Math.max(at(c) + 1, Math.ceil(Number(c.end_time))) : undefined,-- url: abs(`/vod/${id}?t=${at(c)}`),-- }));-- const vo = {-- '@context': 'https://schema.org', '@type': 'VideoObject',-- name: title, description: desc, thumbnailUrl: [image],-- uploadDate: isoDate(v.created_at) || undefined,-- duration: iso8601Duration(v.duration_seconds),-- contentUrl: abs(canonicalPath), embedUrl: abs(canonicalPath),-- interactionStatistic: { '@type': 'InteractionCounter', interactionType: 'https://schema.org/WatchAction', userInteractionCount: Number(v.view_count) || 0 },-- author: _authorLd(author), publisher: { '@type': 'Organization', name: SITE_NAME, url: baseUrl() },-- hasPart: parts.length ? parts : undefined,-- };-- const moment = (c) => `/vod/${id}?t=${at(c)}`;-- let extraHtml = owner ? `<p class="source"><a href="${esc(abs(`/@${owner.username}`))}">${esc(author || owner.username)}'s channel</a></p>` : '';-- extraHtml += _itemSection('Clips from this stream', people.map((c) => ({ url: `/clip/${Number(c.id)}`, name: c.title || 'Clip', meta: [_clipperOf(c) ? `clipped by ${_clipperOf(c)}` : null, `at ${_fmtDur(at(c)) || '0:00'}`].filter(Boolean).join(' · ') })));-- if (ai.length) {-- extraHtml += _itemSection('AI Moments from this stream', ai.map((c) => ({ url: `/clip/${Number(c.id)}`, name: c.title || 'AI clip', meta: `AI clip · at ${_fmtDur(at(c)) || '0:00'}` })),-- null, '<p class="ai-disclosure">Cut automatically by OpenVibe\'s auto-clip workflow. No one clipped these.</p>');-- extraHtml += `<p>${ai.slice(0, 6).map((c) => `<a href="${esc(abs(moment(c)))}">Jump to ${esc(_fmtDur(at(c)) || '0:00')}</a>`).join(' · ')}</p>`;-- }-- const snapshot = _detailSnapshot({-- title, byline: author ? `Streamed by ${author}` : null, desc, extraHtml,-- overview: v.ai_overview, transcript: v.ai_transcript, canonicalPath, watchLabel: 'Watch this VOD',-- });-- return {-- title: `${title}${author ? ' — ' + author : ''} | ${SITE_NAME}`, description: desc,-- canonicalPath, image, ogType: 'video.other', robots: indexable ? 'index,follow' : 'noindex,follow',-- video: { duration: Math.floor(Number(v.duration_seconds) || 0) },-- jsonLd: [vo, _breadcrumb([{ name: 'Home', url: '/' }, ...(owner ? [{ name: author || owner.username, url: `/@${owner.username}` }] : [{ name: 'VODs', url: '/vods' }]), { name: title, url: canonicalPath }])],-- snapshot,-- cacheTtlMs: track.partial ? SHORT_CACHE_MS : undefined,-- };--}----async function _clipMeta(id) {-- const c = await _clipGet(id);-- if (!c || isPrivate(c)) return null; // private: same as unknown (see _vodMeta)-- if (c.duration_seconds == null && c.duration != null) c.duration_seconds = c.duration;-- _overlayAiState(c, 'clip');-- if (isAiClip(c)) return _aiClipMeta(c, id);-- const indexable = Number(c.is_public) === 1 && (!c.visibility || c.visibility === 'public');-- // Media stores ids only: the clipper's name comes from Live's accounts.-- const creator = c.display_name || c.username || _nameOf(_channelOwner(c.user_id));-- const title = clean(c.title || 'Clip', 80);-- const desc = clean(c.ai_overview_short || c.ai_overview || c.description || `A clip from a live stream on ${SITE_NAME}${creator ? ', clipped by ' + creator : ''}.`, 200);-- const image = c.thumbnail_url ? media.publicUrl(c.thumbnail_url) : media.thumbUrl(`clip-${id}`);-- const canonicalPath = `/clip/${id}`;-- const vo = {-- '@context': 'https://schema.org', '@type': 'VideoObject',-- name: title, description: desc, thumbnailUrl: [image],-- uploadDate: isoDate(c.created_at) || undefined,-- duration: iso8601Duration(c.duration_seconds),-- contentUrl: abs(canonicalPath), embedUrl: abs(canonicalPath),-- interactionStatistic: { '@type': 'InteractionCounter', interactionType: 'https://schema.org/WatchAction', userInteractionCount: Number(c.view_count) || 0 },-- author: _authorLd(creator), publisher: { '@type': 'Organization', name: SITE_NAME, url: baseUrl() },-- };-- const channel = _channelOwner(c.channel_user_id);-- const source = await _sourceMoment(c.vod_id, c.start_time);-- const sourceHtml = (source || channel) ? `<p class="source">${source ? `<a href="${esc(abs(source.path))}">Watch it in the full stream</a>` : ''}${source && channel ? ' · ' : ''}${channel ? `<a href="${esc(abs(`/@${channel.username}`))}">${esc(_nameOf(channel))}'s channel</a>` : ''}</p>` : '';-- const snapshot = _detailSnapshot({-- title, byline: creator ? `Clipped by ${creator}${channel && channel.id !== Number(c.user_id) ? ` from ${_nameOf(channel)}'s stream` : ''}` : null, desc,-- extraHtml: sourceHtml,-- overview: c.ai_overview, transcript: c.ai_transcript, canonicalPath, watchLabel: 'Watch this clip',-- });-- return {-- title: `${title} — clip | ${SITE_NAME}`, description: desc,-- canonicalPath, image, ogType: 'video.other', robots: indexable ? 'index,follow' : 'noindex,follow',-- video: { duration: Math.floor(Number(c.duration_seconds) || 0) },-- jsonLd: [vo, _breadcrumb([{ name: 'Home', url: '/' }, ...(channel ? [{ name: _nameOf(channel), url: `/@${channel.username}` }] : [{ name: 'Clips', url: '/clips' }]), { name: title, url: canonicalPath }])],-- snapshot,-- };--}----/** An auto-clip: "AI clip · from <streamer>'s stream", canonical to the VOD at the moment. */--async function _aiClipMeta(c, id) {-- const owner = _channelOwner(c.channel_user_id != null ? c.channel_user_id : c.user_id);-- const streamer = _nameOf(owner);-- const from = streamer ? `${streamer}'s stream` : 'a live stream';-- const title = clean(c.title || 'AI clip', 80);-- const source = await _sourceMoment(c.vod_id, c.start_time);-- const desc = clean(`AI-generated clip from ${from} on ${SITE_NAME}. ${c.ai_overview_short || c.ai_overview || c.description || ''}`, 200);-- const image = c.thumbnail_url ? media.publicUrl(c.thumbnail_url) : media.thumbUrl(`clip-${id}`);-- const selfPath = `/clip/${id}`;-- const vo = {-- '@context': 'https://schema.org', '@type': 'VideoObject',-- name: title, description: desc, thumbnailUrl: [image],-- uploadDate: isoDate(c.created_at) || undefined,-- duration: iso8601Duration(c.duration_seconds),-- contentUrl: abs(selfPath), embedUrl: abs(selfPath),-- // No author or creator: a workflow cut this, not a person.-- keywords: AI_KEYWORDS,-- isBasedOn: source ? abs(source.path) : undefined,-- publisher: { '@type': 'Organization', name: SITE_NAME, url: baseUrl() },-- };-- const sourceLink = source-- ? `<p class="source"><a href="${esc(abs(source.path))}">Watch this moment in the full stream</a>${owner ? ` · <a href="${esc(abs(`/@${owner.username}`))}">${esc(streamer)}'s channel</a>` : ''}</p>`-- : (owner ? `<p class="source"><a href="${esc(abs(`/@${owner.username}`))}">${esc(streamer)}'s channel</a></p>` : '');-- const snapshot = _detailSnapshot({-- title, byline: `AI clip · from ${from}`, desc,-- extraHtml: `<p class="ai-disclosure">Cut automatically by OpenVibe's auto-clip workflow when chat reacted. No person clipped it.</p>${sourceLink}`,-- overview: c.ai_overview, transcript: c.ai_transcript, canonicalPath: selfPath, watchLabel: 'Watch this AI clip',-- });-- return {-- title: `${title} — AI clip from ${from} | ${SITE_NAME}`, description: desc,-- canonicalPath: source ? source.path : selfPath, ogUrlPath: selfPath, image, ogType: 'video.other', robots: AI_ROBOTS,-- video: { duration: Math.floor(Number(c.duration_seconds) || 0) },-- jsonLd: [vo, _breadcrumb([{ name: 'Home', url: '/' }, { name: 'AI Moments', url: '/moments' }, { name: title, url: selfPath }])],-- snapshot,-- };--}----async function _pasteMeta(slug) {-- const p = await _pasteGet(slug);-- if (!p) return null;-- if (isAiPaste(p)) return _aiPasteMeta(p, slug);-- const isScreenshot = p.type === 'screenshot';-- // Only public, non-NSFW, non-burn pastes are indexable.-- const indexable = (p.visibility === 'public' || p.visibility == null) && !Number(p.is_nsfw) && !Number(p.burn_after_read);-- const author = p.display_name || p.username;-- const title = clean(p.title || (isScreenshot ? 'Screenshot' : 'Paste'), 80);-- const desc = clean(p.ai_summary || (isScreenshot ? `A screenshot shared on ${SITE_NAME}.` : String(p.content || '').slice(0, 220)) || `A paste on ${SITE_NAME}.`, 200);-- let image = DEFAULT_OG_IMAGE;-- if (isScreenshot && p.screenshot_url) image = media.publicUrl(p.screenshot_url);-- else if (isScreenshot && p.screenshot_path) image = media.screenshotUrl(path.basename(p.screenshot_path));-- const canonicalPath = `/p/${slug}`;-- let tags = [];-- try { tags = p.ai_tags ? JSON.parse(p.ai_tags) : []; } catch { tags = []; }-- const ld = isScreenshot-- ? { '@context': 'https://schema.org', '@type': 'ImageObject', name: title, description: desc, contentUrl: image, uploadDate: isoDate(p.created_at) || undefined, author: _authorLd(author) }-- : { '@context': 'https://schema.org', '@type': 'SoftwareSourceCode', name: title, description: desc, programmingLanguage: p.language && p.language !== 'text' ? p.language : undefined, dateCreated: isoDate(p.created_at) || undefined, author: _authorLd(author), keywords: (Array.isArray(tags) && tags.length) ? tags.join(', ') : undefined };-- const snapshot = _detailSnapshot({-- title, byline: author ? `Shared by ${author}` : null, desc,-- overview: p.ai_summary, transcript: (!isScreenshot ? String(p.content || '').slice(0, 1200) : null),-- canonicalPath, watchLabel: 'View this paste',-- });-- return {-- title: `${title} — paste | ${SITE_NAME}`, description: desc,-- canonicalPath, image, ogType: isScreenshot ? 'article' : 'article', robots: indexable ? 'index,follow' : 'noindex,follow',-- jsonLd: [ld, _breadcrumb([{ name: 'Home', url: '/' }, { name: 'Pastes', url: '/pastes' }, { name: title, url: canonicalPath }])],-- snapshot,-- };--}----/** The VOD recorded from a stream (the longest public one), for items that know only their stream. */--const _vodOfStream = (streamId) => _cached(`vs:${streamId}`, async () => {-- const r = await media.listVods({ stream_id: streamId, limit: 3 });-- const rows = (r && r.vods) || [];-- return rows.filter((v) => v && !isPrivate(v) && Number(v.is_public) !== 0)-- .sort((a, b) => (Number(b.duration_seconds ?? b.duration) || 0) - (Number(a.duration_seconds ?? a.duration) || 0))[0] || null;--});----/**-- * An AI moment paste (a frame the AI picked from a VOD, or one it "caught live"):-- * "AI note · from <stream>", canonical to the VOD at that second when the VOD is known.-- */--async function _aiPasteMeta(p, slug) {-- const meta = _json(p.metadata) || {};-- const streamId = p.stream_id || meta.stream_id || null;-- let stream = null;-- try { stream = streamId ? db.getStreamById(Number(streamId)) : null; } catch { stream = null; }-- const owner = stream ? _channelOwner(stream.user_id) : (meta.username ? (() => { try { return db.getUserByUsername(String(meta.username)); } catch { return null; } })() : null);-- const streamer = _nameOf(owner);-- const streamTitle = stream && stream.title ? clean(stream.title, 90) : null;-- const from = streamTitle ? `"${streamTitle}"` : (streamer ? `${streamer}'s stream` : 'a live stream');-- // The source: the VOD link the job recorded, else the VOD of the stream it was caught on.-- let source = null;-- const link = typeof meta.vod_link === 'string' ? meta.vod_link.match(VOD_MOMENT_RE) : null;-- if (link) source = await _sourceMoment(link[1], link[2]);-- else if (streamId) {-- let v = null;-- try { v = await _vodOfStream(Number(streamId)); } catch { v = null; }-- if (v) source = await _sourceMoment(v.id, meta.offset);-- }-- const isScreenshot = p.type === 'screenshot';-- const title = clean(p.title || 'AI moment', 80);-- const desc = clean(`AI-generated note from ${from} on ${SITE_NAME}. ${p.ai_summary || ''}`, 200);-- let image = DEFAULT_OG_IMAGE;-- if (isScreenshot && p.screenshot_url) image = media.publicUrl(p.screenshot_url);-- const selfPath = `/p/${slug}`;-- const ld = {-- '@context': 'https://schema.org', '@type': isScreenshot ? 'ImageObject' : 'CreativeWork',-- name: title, description: desc, contentUrl: isScreenshot ? image : undefined,-- dateCreated: isoDate(p.created_at) || undefined,-- // No author: the AI wrote it, and it is not a person.-- keywords: AI_KEYWORDS,-- isBasedOn: source ? abs(source.path) : undefined,-- publisher: { '@type': 'Organization', name: SITE_NAME, url: baseUrl() },-- };-- const at = meta.offset != null && Number.isFinite(Number(meta.offset)) ? ` at ${_fmtDur(Number(meta.offset)) || '0:00'}` : '';-- const sourceLink = source-- ? `<p class="source"><a href="${esc(abs(source.path))}">Watch this moment in the full stream${esc(at)}</a>${owner ? ` · <a href="${esc(abs(`/@${owner.username}`))}">${esc(streamer)}'s channel</a>` : ''}</p>`-- : (owner ? `<p class="source"><a href="${esc(abs(`/@${owner.username}`))}">${esc(streamer)}'s channel</a></p>` : '');-- const snapshot = _detailSnapshot({-- title, byline: `AI note · from ${from}${at}`, desc,-- extraHtml: `<p class="ai-disclosure">Written automatically by OpenVibe's AI moments workflow${meta.live ? ' while the stream was live' : ' from the recording'}. No person wrote it.</p>${sourceLink}`,-- overview: null, transcript: null, canonicalPath: selfPath, watchLabel: 'View this AI note',-- });-- return {-- title: `${title} — AI note from ${from} | ${SITE_NAME}`, description: desc,-- canonicalPath: source ? source.path : selfPath, ogUrlPath: selfPath, image, ogType: 'article', robots: AI_ROBOTS,-- jsonLd: [ld, _breadcrumb([{ name: 'Home', url: '/' }, { name: 'AI Moments', url: '/moments' }, { name: title, url: selfPath }])],-- snapshot,-- };--}----function _breadcrumb(items) {-- return {-- '@context': 'https://schema.org', '@type': 'BreadcrumbList',-- itemListElement: items.map((it, i) => ({ '@type': 'ListItem', position: i + 1, name: clean(it.name, 90), item: abs(it.url) })),-- };--}--// `extraHtml` is markup the caller built (and escaped) itself.--function _detailSnapshot({ title, byline, desc, extraHtml, overview, transcript, canonicalPath, watchLabel }) {-- let html = `<article><h1>${esc(title)}</h1>`;-- if (byline) html += `<p class="byline">${esc(byline)}</p>`;-- if (desc) html += `<p>${esc(desc)}</p>`;-- if (extraHtml) html += extraHtml;-- if (overview && String(overview).trim()) html += `<section><h2>AI overview</h2><p>${esc(clean(overview, 1200))}</p></section>`;-- if (transcript && String(transcript).trim()) html += `<section><h2>Transcript</h2><p>${esc(clean(transcript, 1500))}</p></section>`;-- html += `<p><a href="${esc(abs(canonicalPath))}">${esc(watchLabel || 'Open')}</a> on ${SITE_NAME}.</p></article>`;-- return html;--}----// ── Inject metadata into the base index.html ────────────────────────────────────────────────--// The base document comes from server/web/assets.js: index.html with its asset URLs rewritten to--// content hashes, re-read when index.html or any asset it references changes (checked at most--// every 2s). A change that only touches public/ therefore goes live with a git pull and no--// restart — and a restart drops every live RTMP stream, because RTMP ingest is owned by this--// process (socket activation only covers HTTP).--const assets = require('../web/assets');--let _baseVersion = null;--function _base() {-- let doc = null;-- try { doc = assets.document('index.html'); } catch { doc = null; }-- if (!doc) return '';-- if (doc.version !== _baseVersion) {-- _baseVersion = doc.version;-- // Pages rendered from the old document reference old asset versions.-- if (typeof _cache !== 'undefined') _cache.clear();-- }-- return doc.html;--}----function _headBlock(meta) {-- const canonical = abs(meta.canonicalPath || '/');-- // og:url is the page itself: a shared AI clip previews as that clip, while its canonical names the source.-- const ogUrl = meta.ogUrlPath ? abs(meta.ogUrlPath) : canonical;-- const img = abs(meta.image || DEFAULT_OG_IMAGE);-- const parts = [-- PASTES_META,-- `<title>${esc(meta.title)}</title>`,-- `<meta name="description" content="${esc(meta.description)}">`,-- `<meta name="robots" content="${esc(meta.robots || 'index,follow')}">`,-- `<link rel="canonical" href="${esc(canonical)}">`,-- `<meta property="og:type" content="${esc(meta.ogType || 'website')}">`,-- `<meta property="og:site_name" content="${SITE_NAME}">`,-- `<meta property="og:title" content="${esc(meta.title)}">`,-- `<meta property="og:description" content="${esc(meta.description)}">`,-- `<meta property="og:url" content="${esc(ogUrl)}">`,-- `<meta property="og:image" content="${esc(img)}">`,-- `<meta property="og:locale" content="en_US">`,-- `<meta name="twitter:card" content="${meta.video ? 'player' : 'summary_large_image'}">`,-- `<meta name="twitter:title" content="${esc(meta.title)}">`,-- `<meta name="twitter:description" content="${esc(meta.description)}">`,-- `<meta name="twitter:image" content="${esc(img)}">`,-- ];-- if (meta.video && meta.video.duration) {-- parts.push(`<meta property="og:video:duration" content="${meta.video.duration}">`);-- parts.push(`<meta property="video:duration" content="${meta.video.duration}">`);-- }-- for (const l of (meta.jsonLd || [])) parts.push(jsonLd(l));-- if (meta.snapshot) parts.push(NOJS_STYLE);-- return '\n' + parts.map(x => ' ' + x).join('\n') + '\n';--}----// The page body below is visually hidden while the SPA boots (it removes it, public/js/app.js).--// Without JavaScript nothing boots, so the body is shown instead of the empty app shell.--const PRERENDER_STYLE = 'position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:normal;border:0;';--const NOJS_STYLE = '<noscript><style>#seo-prerender{position:static!important;width:auto!important;height:auto!important;margin:0 auto!important;padding:16px!important;overflow:visible!important;clip:auto!important;max-width:960px;line-height:1.5}#seo-prerender nav{margin:8px 0 16px}#app{display:none!important}</style></noscript>';--// The site's own links, for a reader without JavaScript (the navbar is built by a script).--const NOJS_NAV = `<nav aria-label="${SITE_NAME}"><a href="/">${SITE_NAME}</a> · <a href="/content">Content</a> · <a href="/moments">AI Moments</a> · <a href="/chat">Chat</a> · <a href="/documentation">API docs</a></nav>`;--function _prerender(html, snapshot) {-- return html.replace(/(<body[^>]*>)/i, `$1\n<div id="seo-prerender" style="${PRERENDER_STYLE}">${NOJS_NAV}${snapshot}</div>`);--}--function render(meta, urlPath) {-- let html = _base();-- if (!html) return null;-- html = assets.renderRoute(html, urlPath || meta.canonicalPath || '/');---- // Only the <head> is rewritten, so only the <head> is scanned.-- //-- // These six patterns used to run over the whole document. index.html is ~378KB and the head-- // is the first ~8.5KB of it, so five /ig regexes were scanning 370KB of page markup that-- // cannot contain a <title> or an og: tag — measured at 60ms of blocking CPU per uncached-- // render, against 0.73ms for the head alone. That cost lands on every cache miss, and the-- // sitemap advertises far more URLs than the 500-entry cache holds, so a crawler walking it-- // misses every time.-- const headEnd = html.search(/<\/head>/i);-- let head = headEnd === -1 ? html : html.slice(0, headEnd);-- const rest = headEnd === -1 ? '' : html.slice(headEnd);---- // Strip the hardcoded homepage tags we're replacing (title, description, canonical, all-- // og:/twitter:, robots) so there are no duplicates.-- head = head-- .replace(/\s*<title>[\s\S]*?<\/title>/i, '')-- .replace(/\s*<meta\s+name=["']description["'][^>]*>/ig, '')-- .replace(/\s*<meta\s+name=["']robots["'][^>]*>/ig, '')-- .replace(/\s*<link\s+rel=["']canonical["'][^>]*>/ig, '')-- .replace(/\s*<meta\s+property=["']og:[^"']*["'][^>]*>/ig, '')-- .replace(/\s*<meta\s+name=["']twitter:[^"']*["'][^>]*>/ig, '');---- // Insert the fresh head block just before </head>.-- html = headEnd === -1-- ? head.replace(/<\/head>/i, _headBlock(meta) + '</head>')-- : head + _headBlock(meta) + rest;-- // Server-rendered crawlable content, right after <body>. It's REAL content (so AI text-- // extractors read it — unlike <noscript>, which many strip), but visually-hidden so users-- // never see a flash, and the SPA removes #seo-prerender on boot (see app.js). Not cloaking:-- // it summarises the same content the SPA renders.-- if (meta.snapshot) html = _prerender(html, meta.snapshot);-- return html;--}----/**-- * The SPA shell the fallback sends (server/index.js, server/web/page-status.js) for a page this-- * renderer did not answer. The shell's own head describes the home page, and a page that is not-- * the home page must not claim to be it:-- * 404 → "Page not found", robots noindex, no canonical, no og:url, no structured data, and a-- * not-found body for readers without JavaScript;-- * 200 → the home canonical and og:url dropped (a channel slot, /@user/<slot>, names /@user).-- */--function shellHtml(urlPath, status) {-- let html = _base();-- if (!html) return null;-- const p = String(urlPath || '/');-- html = assets.renderRoute(html, p).replace(/<\/head>/i, `${PASTES_META}</head>`);-- if (p === '/' && status !== 404) return html;-- const headEnd = html.search(/<\/head>/i);-- if (headEnd === -1) return html;-- let head = html.slice(0, headEnd);-- const rest = html.slice(headEnd);-- head = head-- .replace(/\s*<link\s+rel=["']canonical["'][^>]*>/ig, '')-- .replace(/\s*<meta\s+property=["']og:url["'][^>]*>/ig, '')-- .replace(/\s*<meta\s+name=["']twitter:url["'][^>]*>/ig, '');-- if (status === 404) {-- head = head-- .replace(/\s*<title>[\s\S]*?<\/title>/i, '')-- .replace(/\s*<meta\s+name=["']description["'][^>]*>/ig, '')-- .replace(/\s*<meta\s+name=["']robots["'][^>]*>/ig, '')-- .replace(/\s*<meta\s+property=["']og:[^"']*["'][^>]*>/ig, '')-- .replace(/\s*<meta\s+name=["']twitter:[^"']*["'][^>]*>/ig, '')-- .replace(/\s*<script type="application\/ld\+json">[\s\S]*?<\/script>/ig, '');-- head += `\n <title>Page not found — ${SITE_NAME}</title>\n <meta name="description" content="There is nothing at this address on ${SITE_NAME}.">\n <meta name="robots" content="noindex">\n ${NOJS_STYLE}\n`;-- return _prerender(head + rest, `<h1>Page not found</h1><p>There is nothing at this address on ${SITE_NAME}. It may have been deleted or made private, or the link may be mistyped.</p><p><a href="/">Go to the home page</a></p>`);-- }-- const slot = p.match(/^\/@([A-Za-z0-9_]{3,24})\/[^/]+\/?$/);-- if (slot) {-- let user = null;-- try { user = db.getUserByUsername(slot[1]); } catch { user = null; }-- if (user) head += `\n <link rel="canonical" href="${esc(abs(`/@${user.username}`))}">\n`;-- }-- return head + rest;--}----// ── Cache (rendered HTML per path, short TTL) ───────────────────────────────────────────────--const _cache = new Map(); // path (with ?page=N) -> { html, at, ttl }--const CACHE_TTL_MS = 5 * 60 * 1000;--const CACHE_MAX = 500;--function _cacheGet(key) { const e = _cache.get(key); return e && (Date.now() - e.at) < e.ttl ? e : null; }--function _cacheSet(key, html, ttl = CACHE_TTL_MS) {-- if (_cache.size >= CACHE_MAX) { const first = _cache.keys().next().value; _cache.delete(first); }-- _cache.set(key, { html, at: Date.now(), ttl });--}----const SEO_ROUTE_RE = /^\/(?:$|content$|moments$|vods$|clips$|pastes$|arena$|chat$|search$|vod\/\d+$|clip\/\d+$|recap\/\d+$|p\/[A-Za-z0-9_-]+$|@[A-Za-z0-9_]{3,24}$)/;----/** ?page=N on a channel page (its video list); 1 everywhere else and for anything that is not a number. */--function _pageParam(p, query) {-- const feed = p === '/content' || p === '/moments';-- if (!feed && !CHANNEL_PATH_RE.test(p)) return 1;-- const raw = query && typeof query.page === 'string' ? query.page : '';-- return /^\d{1,4}$/.test(raw) ? Math.min(feed ? FEED_MAX_PAGE : CHANNEL_MAX_PAGE, Math.max(1, parseInt(raw, 10))) : 1;--}----async function middleware(req, res, next) {-- if (req.method !== 'GET') return next();-- const p = (req.path || '/').replace(/\/+$/, '') || '/';-- if (!SEO_ROUTE_RE.test(p)) return next();-- // A paste Community owns is never rendered here (the handover answers it first; this keeps it-- // so if the mount order ever changes).-- if (p.startsWith('/p/')) return next();-- // Every client gets the same page, whatever it sends as Accept: browsers, crawlers, curl and-- // monitors. (It used to be skipped unless Accept named text/html, so a crawler or tool that sent-- // */* got the home page's title and canonical for every channel, VOD and clip.) Nothing fetches-- // these paths for data: the API is under /api.-- const page = _pageParam(p, req.query);-- const key = page > 1 ? `${p}?page=${page}` : p;-- // This renderer and the SPA fallback's status check share one wait on upstreams (page-status.js).-- req.ovLookupDeadlineAt = Date.now() + pageStatus.LOOKUP_DEADLINE_MS;-- const send = (html, ttl) => {-- res.set('Cache-Control', `public, max-age=${Math.max(0, Math.round(ttl / 1000))}`);-- res.set('Content-Security-Policy-Report-Only', assets.cspReportOnly(html));-- res.type('html');-- return res.send(html);-- };-- try {-- // Check for a changed index.html before trusting the page cache: a cache hit would otherwise-- // skip the check and keep serving pages that point at the previous asset versions.-- _base();-- const cached = _cacheGet(key);-- if (cached) return send(cached.html, cached.ttl);-- const meta = await _pageMeta(p, { page });-- if (!meta) return next(); // unknown, private or not found: the SPA fallback answers (404 when it names nothing)-- const html = render(meta, p);-- if (!html) return next();-- const ttl = meta.cacheTtlMs || CACHE_TTL_MS;-- _cacheSet(key, html, ttl);-- return send(html, ttl);-- } catch (e) {-- console.warn('[SEO] render failed for', p, '-', e.message);-- return next();-- }--}----// ── Dynamic sitemap.xml (cached ~1h) ────────────────────────────────────────────────────────--let _sitemap = null, _sitemapAt = 0;--const SITEMAP_TTL_MS = 60 * 60 * 1000;--const SITEMAP_CAP = 5000; // per content type--function _urlTag(loc, lastmod, changefreq, priority) {-- return `<url><loc>${esc(abs(loc))}</loc>` +-- (lastmod ? `<lastmod>${esc(lastmod)}</lastmod>` : '') +-- (changefreq ? `<changefreq>${changefreq}</changefreq>` : '') +-- (priority ? `<priority>${priority}</priority>` : '') + `</url>`;--}--async function buildSitemap() {-- const urls = [];-- const seenChannels = new Set();-- // Media rows carry Live user ids, not names: a channel is named from Live's accounts (banned-- // accounts are left out).-- const names = new Map();-- const channelOf = (row, id) => {-- if (row.username) return row.username;-- if (id == null) return null;-- if (!names.has(id)) {-- let u = null;-- try { u = db.getUserById(Number(id)); } catch { u = null; }-- names.set(id, u && !(u.is_banned === 1 || u.is_banned === true) ? u.username : null);-- }-- return names.get(id);-- };-- const statics = [['/', 'daily', '1.0'], ['/content', 'hourly', '0.9'], ['/moments', 'hourly', '0.7'], ['/vods', 'hourly', '0.8'], ['/clips', 'hourly', '0.8'], ['/pastes', 'hourly', '0.7'], ['/chat', 'daily', '0.5'], ['/arena', 'hourly', '0.6']];-- // Rendered docs (server/docs/routes.js): /docs is the index (README.md), the rest by file name.-- try {-- for (const f of require('fs').readdirSync(require('path').join(__dirname, '../../docs'))) {-- if (!f.endsWith('.md')) continue;-- statics.push([f === 'README.md' ? '/docs' : `/docs/${f.slice(0, -3)}`, 'weekly', '0.5']);-- }-- } catch { /* docs folder absent in some deploys */ }-- for (const [u, cf, pr] of statics) urls.push(_urlTag(u, null, cf, pr));-- const page = async (fetch, per, cap, emit) => {-- let off = 0;-- while (off < cap) {-- let rows = [];-- try { rows = (await fetch(per, off)) || []; } catch { break; }-- for (const r of rows) emit(r);-- if (rows.length < per) break;-- off += per;-- }-- };-- await page((l, o) => media.listVods({ limit: l, offset: o }).then(r => r?.vods || []), 200, SITEMAP_CAP, (v) => {-- urls.push(_urlTag(`/vod/${v.id}`, isoDate(v.created_at), 'weekly', '0.6'));-- const ch = channelOf(v, v.user_id);-- if (ch) seenChannels.add(ch);-- });-- // People's clips only: AI Moments are noindex and never listed (the /moments-- // collection above is their indexable form). The filter is checked again per row, so an-- // upstream that ignores it still cannot put an AI item here.-- await page((l, o) => media.listClips({ limit: l, offset: o, auto_generated: 0 }).then(r => r?.clips || []), 200, SITEMAP_CAP, (c) => {-- if (isAiClip(c)) return;-- urls.push(_urlTag(`/clip/${c.id}`, isoDate(c.created_at), 'weekly', '0.6'));-- const ch = channelOf({}, c.channel_user_id != null ? c.channel_user_id : c.user_id);-- if (ch) seenChannels.add(ch);-- });-- // No /p/ here: pastes are OpenVibe.Community's, and its sitemap lists them under their canonical URL.-- for (const u of seenChannels) if (u) urls.push(_urlTag(`/@${u}`, null, 'daily', '0.6'));-- return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.join('\n')}\n</urlset>`;--}--async function sitemapHandler(req, res) {-- if (!_sitemap || (Date.now() - _sitemapAt) > SITEMAP_TTL_MS) {-- try { _sitemap = await buildSitemap(); _sitemapAt = Date.now(); }-- catch (e) { console.warn('[SEO] sitemap build failed:', e.message); if (!_sitemap) return res.status(500).end(); }-- }-- res.set('Content-Type', 'application/xml; charset=utf-8');-- res.set('Cache-Control', 'public, max-age=3600');-- res.send(_sitemap);--}----// ── /llms.txt: what this site is, for language models and other automated readers ─────────--// (llmstxt.org). It names the public pages, the JSON behind them, the API docs, and how people's--// work is kept apart from what the AI derived from it (roadmap 32.4, 33.8).--const LLMS_DOCS = [-- ['go-live-in-your-browser', 'Go live from a browser: no OBS, no downloads, no follower minimum (how, devices, questions)'],-- ['whip', 'WHIP ingest API: publish to a channel from a browser or any WHIP client'],-- ['broadcasting', 'Going live: WebRTC, WHIP, RTMP (OBS) and the JSMPEG/CLI path'],-- ['api-tokens', 'Bot and integration tokens (hbt_...) for the API'],-- ['chat-system', 'Chat features and moderation'],-- ['vods-and-clips', 'How VODs and clips are recorded and cut'],-- ['architecture', 'System design and how Live fits the OpenVibe network'],--];--function llmsTxt() {-- const b = baseUrl();-- const docs = LLMS_DOCS.filter(([name]) => { try { return fs.existsSync(path.join(__dirname, '../../docs', `${name}.md`)); } catch { return false; } });-- return [-- `# ${SITE_NAME}`,-- '',-- '> Open-source, community-run live streaming. Anyone can go live straight from a web browser, with no OBS, no downloads and no follower, subscriber or equipment requirement: press Go Live, allow camera and microphone, and the stream is live (WebRTC, under a second of delay; Chromebooks, laptops and phones). OBS (RTMP), WHIP and command-line ingest are there for produced shows. Every stream can be recorded as a VOD, clipped and discussed. Part of the OpenVibe network: one account across openvibe.network, openvibe.media, openvibe.community, openvibe.tools and the other OpenVibe sites.',-- '',-- '## Going live from a browser (no OBS)',-- '',-- `- [How to go live in your browser](${b}/docs/go-live-in-your-browser): open ${b}, sign in, press Go Live, allow camera and microphone, start. No software, no follower minimum.`,-- `- [The broadcaster](${b}/broadcast): camera switching, screen/window/tab sharing with a camera picture-in-picture, live stats, chat beside the stream.`,-- `- [Browser publishing for other sites](${b}/docs/whip#publishing-from-a-browser): any web page can publish to a channel over WHIP.`,-- '',-- 'People\'s work and AI-made material are kept apart everywhere on this site. The Content pages list what people made; AI Moments list what the platform\'s AI derived from streams. Every AI item is labelled AI-generated, credited to no person, marked noindex,follow and made canonical to the source VOD at the moment it came from (/vod/<id>?t=<seconds>). In the JSON feeds, AI items carry "ai": true and an "ai_label".',-- '',-- '## What people made',-- '',-- `- [Home](${b}/): who is live now, recent VODs and clips`,-- `- [Content](${b}/content): VODs, the clips people took, and pastes people wrote. JSON: ${b}/api/content/feed (cursor paging; ?type=vods|clips|pastes, ?sort=new|top)`,-- `- Channel pages: ${b}/@<username>, with the channel's videos in pages (${b}/@<username>?page=2)`,-- `- VOD pages: ${b}/vod/<id>; a moment in a VOD: ${b}/vod/<id>?t=<seconds>`,-- `- Clip pages: ${b}/clip/<id>`,-- '- Pastes live on OpenVibe.Community: https://openvibe.community/p/<slug> (Live\'s /p/<slug> redirects there)',-- `- [Chat](${b}/chat) and [the Arena](${b}/arena): mic-judged streamer callouts`,-- '',-- '## What the AI made (AI Moments)',-- '',-- `- [AI Moments](${b}/moments): auto-clips cut when chat reacted, frames the AI picked from streams, and AI-written after-show recaps. JSON: ${b}/api/content/moments`,-- '- AI Moments pages are noindex; the /moments collection is their indexable form. They are never listed in the sitemap.',-- '- An AI clip says "AI clip · from <streamer>\'s stream"; it is never presented as something the streamer or a viewer clipped.',-- '- A streamer can turn AI Moments off for their channel; the AI then makes no new ones from their streams.',-- '',-- '## API and docs',-- '',-- `- [API docs](${b}/documentation): every feature has an open API (chat, streams, clips, overlays, robots, sound commands)`,-- `- [Docs index](${b}/docs)`,-- ...docs.map(([name, what]) => `- [${name}](${b}/docs/${name}): ${what}`),-- '- Source code: https://github.com/OpenVibers/OpenVibe.Live',-- '',-- '## Discovery',-- '',-- `- Sitemap (people's work only): ${b}/sitemap.xml`,-- `- robots.txt: ${b}/robots.txt`,-- '- The OpenVibe network\'s platform descriptor: https://openvibe.network/.well-known/openvibe',-- '',-- ].join('\n');--}--function llmsHandler(req, res) {-- res.set('Content-Type', 'text/plain; charset=utf-8');-- res.set('Cache-Control', 'public, max-age=3600');-- res.send(llmsTxt());--}----// Register: dynamic sitemap and llms.txt first, then the meta middleware. MUST be mounted BEFORE--// express.static (so it can intercept "/") and before the SPA catch-all.--function register(app) {-- app.get('/sitemap.xml', sitemapHandler);-- app.get('/llms.txt', llmsHandler);-- app.use(middleware);-- console.log('[SEO] per-route meta + dynamic sitemap + llms.txt registered');--}----module.exports = { register, middleware, sitemapHandler, buildSitemap, shellHtml, llmsTxt, _pageMeta, render };@@@ -13,6 +13,7 @@ const express = require('express');const db = require('../db/database');const { channelSocialLinks, catalog } = require('./links');const { preview } = require('./preview');++const cache = require('openvibe-shared/cache-policy');const router = express.Router();@@@ -47,7 +48,7 @@ function xTimelinePage(req, res) {const h = String(req.query.h || '');if (!/^[A-Za-z0-9_]{1,15}$/.test(h)) return res.status(400).type('text/plain').send('bad handle');res.set('Content-Security-Policy', "default-src 'none'; script-src https://platform.twitter.com; frame-src https://platform.twitter.com https://syndication.twitter.com; img-src https: data:; style-src 'unsafe-inline' https://platform.twitter.com; frame-ancestors 'self'");-- res.set('Cache-Control', 'public, max-age=3600');++ res.set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }));res.type('html').send(`<!doctype html><html><head><meta charset="utf-8"><meta name="robots" content="noindex"><meta name="viewport" content="width=device-width,initial-scale=1"><style>html,body{margin:0;background:transparent;color:#e7e9ea;font:14px system-ui,sans-serif}a{color:#1d9bf0}</style></head><body><a class="twitter-timeline" data-theme="dark" data-chrome="noheader nofooter noborders transparent" data-height="520" href="https://twitter.com/${h}">Posts by @${h}</a>@@@ -124,6 +124,7 @@ const { notifyDiscordGoLive } = require('../integrations/discord-webhook');// Go-live fan-out lives in ./golive-notify (shared with the RTMP and WHIP ingest paths,// which never notified anyone before), with follower-id translation + 60-min dedupe.const { notifyFollowersGoLive } = require('./golive-notify');++const cache = require('openvibe-shared/cache-policy');function hasOwn(obj, key) {return Object.prototype.hasOwnProperty.call(obj || {}, key);@@@ -1667,7 +1668,7 @@ router.get('/setup-progress', requireAuth, async (req, res) => {];const done = tasks.filter(t => t.done).length;const next = tasks.find(t => !t.done) || null;-- res.set('Cache-Control', 'private, no-store');++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));res.json({ tasks, done, total: tasks.length, next, username: user.username });});@@@ -21,6 +21,7 @@const fs = require('fs');const path = require('path');const crypto = require('crypto');++const cache = require('openvibe-shared/cache-policy');// Where static files are read from on every request. By default the public/ next to this file. In the// release layout (deploy/scripts/deploy.sh) the unit sets OV_APP_ROOT=/opt/openvibe.live/current: the@@@ -349,7 +350,7 @@ function cspReportOnly(html) {return policy;}--const IMMUTABLE = 'public, max-age=31536000, immutable';++const IMMUTABLE = cache.IMMUTABLE;function setImmutable(res) { res.setHeader('Cache-Control', IMMUTABLE); res.setHeader('CDN-Cache-Control', IMMUTABLE); }function setNoCache(res) { res.setHeader('Cache-Control', 'no-cache'); res.setHeader('CDN-Cache-Control', 'no-store'); }@@@ -9,7 +9,7 @@* not-found view (showNotFound in public/js/app.js).** The pages, mirroring routeFromURL() in public/js/app.js and the routes in public/features.json-- * (server/seo/seo.js renders the crawlable ones first and hands anything it cannot find to the++ * (server/seo/pages.js renders the crawlable ones first and hands anything it cannot find to the* fallback):** / home@@@ -211,7 +211,7 @@ function spaFallback(sendShell) {/*** Record what another part of the same request just fetched fresh from upstream, so the status-- * check does not ask again: the SEO renderer (server/seo/seo.js) runs first, and when it passes a++ * check does not ask again: the SEO renderer (server/seo/pages.js) runs first, and when it passes a* page on (missing, or private to this visitor) the fallback would otherwise repeat its fetch. An* entry that is still fresh, or a lookup in flight, is left alone.*/@@@ -4,7 +4,7 @@* so links in chat, search results and clipboards survive. Every paste link Live renders already* points at Community, so this only answers old links.*-- * Mounted BEFORE the SEO middleware (server/seo/seo.js). It used to be mounted after it, so every++ * Mounted BEFORE the SEO middleware (server/seo/pages.js). It used to be mounted after it, so every* HTML navigation, crawlers included, got Live's own server-rendered copy of the paste, with a* canonical on openvibe.live, while only non-HTML clients got the redirect: two indexable pages,* each naming itself canonical, and a different answer for people and machines. Now every client@@@ -17,7 +17,7 @@ feed.page = async (name, q) => {const items = n <= 3 ? Array.from({ length: 24 }, (_, i) => ({ kind: 'clip', id: n * 100 + i, href: `/clip/${n * 100 + i}`, title: `Clip ${n}-${i}`, channel: { display_name: 'Goosely' }, excerpt: 'x' })) : [];return { items, next: n < 3 ? `c${n + 1}` : null };};--const seo = require('../server/seo/seo');++const seo = require('../server/seo');(async () => {const p1 = await seo._pageMeta('/content', { page: 1 });@@@ -1,5 +1,5 @@/**-- * /llms.txt (server/seo/seo.js, roadmap 32.4/33.8): plain text that tells language models and other++ * /llms.txt (server/seo/discovery.js, roadmap 32.4/33.8): plain text that tells language models and other* automated readers what OpenVibe.Live is, where its public pages and their JSON are, where the API* docs are, and how people's work is kept apart from what the AI derived. Every docs page it links* must exist, and it must answer before the SPA fallback (never the HTML shell).@@@ -23,7 +23,7 @@ console.error = () => {};require('../server/db/database').initDb();const express = require('express');--const seo = require('../server/seo/seo');++const seo = require('../server/seo');const app = express();seo.register(app);app.get('*', (req, res) => res.status(404).type('html').send('<html>shell</html>'));@@@ -247,7 +247,7 @@ async function check(name, fn) {});await check('server-rendered /vod/:id and /clip/:id: private is the unknown-id fall-through', async () => {-- const seo = require('../server/seo/seo');++ const seo = require('../server/seo');assert.strictEqual(await seo._pageMeta('/vod/101'), null);assert.strictEqual(await seo._pageMeta('/vod/102'), null);assert.strictEqual(await seo._pageMeta('/clip/201'), null);@@@ -85,7 +85,7 @@ pastesClient.listPastes = async () => ({ pastes: [] });const assets = require('../server/web/assets');const pageStatus = require('../server/web/page-status');const app = express();--const seo = require('../server/seo/seo');++const seo = require('../server/seo');seo.register(app);// As server/index.js sendShell: the shell from seo.shellHtml (a 404 is noindex, no canonical).app.get('*', pageStatus.spaFallback((res, urlPath) => {@@@ -5,7 +5,7 @@* The handover used to be mounted after the SEO middleware, so an HTML navigation (every crawler)* got Live's own rendered copy with a canonical on openvibe.live, and only a non-HTML client got* the 301: two self-canonical pages and a different answer for people and machines. Checked here:-- * - server/index.js mounts server/web/paste-handover.js before server/seo/seo.js;++ * - server/index.js mounts server/web/paste-handover.js before server/seo/pages.js;* - /p/<slug> answers 301 to Community whatever the Accept header or cookie, and /p/<slug>/raw* 302s to Community's raw text (not Media's);* - Live's sitemap lists no /p/ URL, and the paste links Live renders, server-side and in the@@@ -44,7 +44,7 @@ pastesClient.listPastes = async (q = {}) => ({ pastes: (Number(q.offset) || 0) ?const express = require('express');const app = express();require('../server/web/paste-handover').register(app);--const seo = require('../server/seo/seo');++const seo = require('../server/seo');seo.register(app);app.get('*', (req, res) => res.status(200).type('html').send('<html>shell</html>'));@@@ -74,7 +74,7 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {await check('server/index.js mounts the paste handover before the SEO middleware', () => {const src = fs.readFileSync(path.join(__dirname, '../server/index.js'), 'utf8');const handover = src.indexOf("require('./web/paste-handover').register(app)");-- const seoAt = src.indexOf("require('./seo/seo').register(app)");++ const seoAt = src.indexOf("require('./seo').register(app)");assert.ok(handover > 0, 'the handover is mounted');assert.ok(seoAt > 0, 'SEO is mounted');assert.ok(handover < seoAt, 'handover first');@@@ -255,7 +255,7 @@ const ok = (name) => { pass++; console.log(' ok -', name); };const cacheAt = h[0].indexOf('_ctxCache.get(id)');assert(checkAt > 0 && cacheAt > 0, 'both the visibility check and the cache read should be present');assert(checkAt < cacheAt, 'the visibility check must run before the cache is read');-- assert(/private, no-store/.test(h[0]), 'a private VOD context must not be sent with a shareable Cache-Control');++ assert(/cache\.htmlHeaders\(\{ private: true \}\)/.test(h[0]) && require('openvibe-shared/cache-policy').htmlHeaders({ private: true }) === 'private, no-store', 'a private VOD context must not be sent with a shareable Cache-Control');ok('private VOD context is authorised before the cache, and never shareable');}@@@ -368,7 +368,7 @@ async function check(name, fn) {'server/chat/soundboard-service.js': '101soundboards (host allowlist; the audio download through safeLookup)','server/chat/tts-engine.js': 'Google / AWS TTS (fixed hosts)','server/meta/routes.js': 'GitHub API (fixed host)',-- 'server/seo/seo.js': 'a local function named fetch (no request)',++ 'server/seo/discovery.js': 'a local function named fetch (no request)','server/news/sources/newsapi-source.js': 'NewsAPI (fixed host)','server/news/sources/reddit-source.js': 'Reddit (fixed host)','server/news/sources/rss-source.js': 'feeds the site owner configures',nnew file mode 100644@@@ -0,0 +1,95 @@++/**++ * Discovery files and asset caching on the real server (server/seo/discovery.js, openvibe-shared/seo++ * and openvibe-shared/cache-policy): /robots.txt, /sitemap.xml, /llms.txt and /llms-full.txt answer++ * from the shared builders, and a /shared/ file is immutable for a year only under its current ?v=.++ */++'use strict';++const assert = require('assert');++const crawl = require('./security-crawl');++++const quiet = console.log;++console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };++console.warn = () => {};++console.error = () => {};++++let failures = 0;++async function check(name, fn) {++ try { await fn(); quiet(' ✓', name); }++ catch (e) { failures++; quiet(' ✗', name, '\n ', e.message); }++}++++(async () => {++ const tmp = crawl.tempEnv('seo-discovery');++ let code = 1;++ try {++ const srv = await crawl.boot(tmp);++ const get = (p) => srv.request('GET', p, { timeoutMs: 8000 });++ quiet('seo discovery');++++ await check('/robots.txt welcomes crawlers, names the AI bots and the sitemap', async () => {++ const r = await get('/robots.txt');++ assert.strictEqual(r.status, 200);++ assert.match(r.headers['content-type'], /^text\/plain/);++ assert.ok(r.text.startsWith('User-agent: *\nAllow: /\n'), r.text.slice(0, 80));++ assert.ok(/^User-agent: GPTBot$/m.test(r.text) && /^User-agent: ClaudeBot$/m.test(r.text));++ assert.ok(r.text.includes('Sitemap: https://openvibe.live/sitemap.xml'));++ assert.strictEqual(r.headers['cache-control'], 'public, max-age=3600, stale-while-revalidate=3600');++ });++++ await check('/sitemap.xml is a urlset of absolute openvibe.live URLs, with the docs and no /p/', async () => {++ const r = await get('/sitemap.xml');++ assert.strictEqual(r.status, 200);++ assert.match(r.headers['content-type'], /^application\/xml/);++ assert.ok(r.text.includes('<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"'));++ const locs = [...r.text.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]);++ for (const u of ['https://openvibe.live/', 'https://openvibe.live/content', 'https://openvibe.live/moments', 'https://openvibe.live/docs/whip']) {++ assert.ok(locs.includes(u), `missing ${u}`);++ }++ assert.ok(locs.every((u) => u.startsWith('https://openvibe.live/')), locs.join(' '));++ assert.ok(!locs.some((u) => u.includes('/p/')), 'pastes are Community\'s');++ assert.ok(r.text.includes('<loc>https://openvibe.live/</loc><changefreq>daily</changefreq><priority>1.0</priority>'));++ assert.strictEqual(r.headers['cache-control'], 'public, max-age=3600, stale-while-revalidate=3600');++ });++++ await check('/llms.txt is an llmstxt.org map that links /llms-full.txt', async () => {++ const r = await get('/llms.txt');++ assert.strictEqual(r.status, 200);++ assert.match(r.headers['content-type'], /^text\/plain/);++ assert.ok(r.text.startsWith('# OpenVibe.Live\n\n> '));++ assert.ok(r.text.includes('[llms-full.txt](https://openvibe.live/llms-full.txt)'));++ assert.ok(r.text.includes('[robots.txt](https://openvibe.live/robots.txt)'));++ });++++ await check('/llms-full.txt carries the docs themselves, under their own URLs', async () => {++ const r = await get('/llms-full.txt');++ assert.strictEqual(r.status, 200);++ assert.match(r.headers['content-type'], /^text\/plain/);++ assert.ok(r.text.startsWith('# OpenVibe.Live\n\n> '));++ assert.ok(r.text.includes('\nhttps://openvibe.live/docs/whip\n'), 'the WHIP doc section');++ assert.ok(r.text.includes('\nhttps://openvibe.live/docs/go-live-in-your-browser\n'));++ assert.ok(r.text.length > 5000, `only ${r.text.length} characters`);++ assert.ok(!/<html/i.test(r.text));++ });++++ await check('a /shared/ file is immutable under its current ?v=, short-cached under any other', async () => {++ const assets = require('../server/web/assets');++ const v = assets.hashOf('/shared/shipped.js');++ assert.ok(v, 'shipped.js has a content hash');++ const current = await get(`/shared/shipped.js?v=${v}`);++ assert.strictEqual(current.status, 200);++ assert.strictEqual(current.headers['cache-control'], 'public, max-age=31536000, immutable');++ assert.strictEqual(current.headers['cdn-cache-control'], 'public, max-age=31536000, immutable');++ const wrong = await get(`/shared/shipped.js?v=${v === '0123456789ab' ? 'ba9876543210' : '0123456789ab'}`);++ assert.strictEqual(wrong.status, 200);++ assert.strictEqual(wrong.headers['cache-control'], 'public, max-age=300, stale-while-revalidate=86400');++ assert.strictEqual(wrong.headers['cdn-cache-control'], undefined);++ });++ code = failures ? 1 : 0;++ } catch (e) {++ quiet(' ✗ boot', e && e.stack);++ } finally {++ tmp.cleanup();++ quiet(code ? `\n${failures} check(s) failed` : '\nseo discovery: all checks passed');++ process.exit(code);++ }++})();@@@ -1,5 +1,5 @@/**-- * AI Moments pages (server/seo/seo.js, roadmap §33.4 and §33.8): what the AI made from a stream is++ * AI Moments pages (server/seo/pages.js, roadmap §33.4 and §33.8): what the AI made from a stream is* labelled as AI-made, credited to no person, kept out of search and pointed at its source.** - An auto-clip (Media auto_generated), an AI moment paste (Community origin 'ai') and an@@@ -81,7 +81,7 @@ const recapJson = (sid, ai, vod) => JSON.stringify({raw.prepare('INSERT INTO stream_recaps (stream_id, user_id, json, ai) VALUES (?, 3, ?, 1)').run(streamId, recapJson(streamId, true, { id: 10, thumbnail_url: null }));raw.prepare('INSERT INTO stream_recaps (stream_id, user_id, json, ai) VALUES (?, 3, ?, 0)').run(quietStream, recapJson(quietStream, false, null));--const seo = require('../server/seo/seo');++const seo = require('../server/seo');/** Every node of a JSON-LD graph, depth first. */function nodes(value, out = []) {@@@ -1,5 +1,5 @@/**-- * Server-rendered channel, VOD and clip pages (server/seo/seo.js, roadmap 32.1/32.2): each has its++ * Server-rendered channel, VOD and clip pages (server/seo/pages.js, roadmap 32.1/32.2): each has its* own title, description, canonical, Open Graph and JSON-LD, and a real HTML body that crawlers and* readers without JavaScript can use, whatever Accept header the client sends.*@@@ -86,7 +86,7 @@ pastesClient.getPaste = async () => { throw missing(); };// ── The app, mounted as server/index.js mounts it ──const express = require('express');--const seo = require('../server/seo/seo');++const seo = require('../server/seo');const pageStatus = require('../server/web/page-status');const app = express();seo.register(app);@@@ -145,6 +145,14 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {}});++ await check('/@alice: openvibe-shared/seo head (hreflang alternates, cache policy) and page summary', async () => {++ const r = await get('/@alice');++ assert.ok(head(r.body).includes('<link rel="alternate" hreflang="en" href="https://openvibe.live/@alice">'));++ assert.ok(head(r.body).includes('<link rel="alternate" hreflang="x-default" href="https://openvibe.live/@alice">'));++ assert.ok(/<section data-ov-summary hidden><h2>Alice \(@alice\) — OpenVibe\.Live<\/h2>/.test(r.body), 'shared page summary in the snapshot');++ assert.match(r.cache, /^public, max-age=\d+, must-revalidate$/);++ });++await check('/@alice: a real body with its videos, ?page=N links, clips and AI Moments apart', async () => {const b = bodyOf((await get('/@alice')).body);assert.ok(b.includes('<h1>Alice (@alice)</h1>'), b.slice(0, 300));@@@ -241,6 +249,7 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {for (const c of CLIENTS) {const r = await get('/vod/100', c.headers);assert.strictEqual(r.status, 200, c.name);++ assert.match(r.cache, /^public, max-age=\d+, must-revalidate$/, c.name);assert.deepStrictEqual(canonicalsOf(r.body), ['https://openvibe.live/vod/100'], c.name);assert.ok(/^Build night 100 — Alice \| OpenVibe\.Live$/.test(titleOf(r.body)), titleOf(r.body));}@@@ -266,6 +275,7 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {for (const c of CLIENTS) {const r = await get('/clip/200', c.headers);assert.strictEqual(r.status, 200, c.name);++ assert.match(r.cache, /^public, max-age=\d+, must-revalidate$/, c.name);assert.deepStrictEqual(canonicalsOf(r.body), ['https://openvibe.live/clip/200'], c.name);assert.deepStrictEqual(robotsOf(r.body), ['index,follow'], c.name);}@@@ -312,7 +322,7 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {await check('server/index.js sends the fallback shell through seo.shellHtml', () => {const src = fs.readFileSync(path.join(__dirname, '../server/index.js'), 'utf8');-- assert.ok(src.includes("require('./seo/seo').shellHtml"));++ assert.ok(src.includes("require('./seo').shellHtml"));assert.ok(src.includes("app.get('*', require('./web/page-status').spaFallback((res, urlPath) => sendShell(res, urlPath)));"));});
@@ -11,7 +11,7 @@ Every stream that ran at least 8 minutes gets a report a couple of minutes after
@@ -11,7 +11,7 @@ Every stream that ran at least 8 minutes gets a report a couple of minutes after
- **The room, minute by minute** — viewer curve with chat bars, peak marker.
- **The room, minute by minute** — viewer curve with chat bars, peak marker.
- Announced in the channel's chat room when it lands. Stored in `stream_recaps` (one JSON row per
- Announced in the channel's chat room when it lands. Stored in `stream_recaps` (one JSON row per
stream). Owner/admin can rebuild it (`POST /api/recap/:id/regenerate`).
stream). Owner/admin can rebuild it (`POST /api/recap/:id/regenerate`).
-- Crawlable: `/recap/:id` gets OG/Twitter meta + Article JSON-LD (`server/seo/seo.js`), so a
+- Crawlable: `/recap/:id` gets OG/Twitter meta + Article JSON-LD (`server/seo/pages.js`), so a
shared link previews with the headline and the VOD thumbnail.
shared link previews with the headline and the VOD thumbnail.
API: `GET /api/recap/:streamId` (builds on first view if missing), `GET /api/recap/channel/:username`.
API: `GET /api/recap/:streamId` (builds on first view if missing), `GET /api/recap/channel/:username`.
@@ -27,7 +27,7 @@
@@ -27,7 +27,7 @@
"onnxruntime-node": "^1.20.0",
"onnxruntime-node": "^1.20.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.83.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.83.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.16.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.16.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.4.0",
"pg": "^8.23.1",
"pg": "^8.23.1",
"sanitize-html": "^2.17.7",
"sanitize-html": "^2.17.7",
"sdp-transform": "^3.0.0",
"sdp-transform": "^3.0.0",
@@ -2860,9 +2860,9 @@
@@ -2860,9 +2860,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "2.3.1",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1",
- "integrity": "sha512-K+MXnB4O+8xIFi+NQGC2SDTvDq3RkzfxQ3bTVx76Y/Rbuq7kC/L5kL3c/06xr9sPYcjyzF3Bhpv5yH2YNfR4Zw==",
+ "version": "2.4.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.4.0",
+ "integrity": "sha512-DmFlDcVlT/WRza0M4h0xf+WiwM4FsiJ0WarIesOYByE0v/JYZcoIdjYUvVB5GzshJ7QmFHJLnJsNmdJqj7L1kg==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -48,7 +48,7 @@
@@ -48,7 +48,7 @@
"onnxruntime-node": "^1.20.0",
"onnxruntime-node": "^1.20.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.83.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.83.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.16.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.16.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.4.0",
"pg": "^8.23.1",
"pg": "^8.23.1",
"sanitize-html": "^2.17.7",
"sanitize-html": "^2.17.7",
"sdp-transform": "^3.0.0",
"sdp-transform": "^3.0.0",
@@ -13,21 +13,26 @@
@@ -13,21 +13,26 @@
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
const zlib = require('zlib');
const zlib = require('zlib');
+const { check, format, assetsOf } = require('openvibe-shared/perf-budget');
const ROOT = path.join(__dirname, '..', '..');
const ROOT = path.join(__dirname, '..', '..');
+// openvibe-shared/perf-budget names; "js" is the eager scripts (incl. route scripts for "/"),
+// "css" the render-blocking stylesheets.
const BUDGETS = {
const BUDGETS = {
htmlRawKB: 120, // measured 94.9
htmlRawKB: 120, // measured 94.9
htmlBrotliKB: 30,
htmlBrotliKB: 30,
- eagerJsFiles: 26, // measured 23 (incl. route scripts for "/")
- eagerJsRawKB: 1150, // measured ~1000
- eagerJsBrotliKB: 260,
- blockingCssRawKB: 760, // measured ~717 (style.css + home + icons + i18n)
- blockingCssBrotliKB: 130,
+ jsFiles: 26, // measured 23 (incl. route scripts for "/")
+ jsRawKB: 1150, // measured ~1000
+ jsBrotliKB: 260,
+ cssRawKB: 760, // measured ~717 (style.css + home + icons + i18n)
+ cssBrotliKB: 130,
};
};
// Code that must never be part of the home page's first load.
// Code that must never be part of the home page's first load.
const FORBIDDEN_ON_HOME = ['/js/broadcast.js', '/js/broadcast-workspace.js', '/js/dashboard.js', '/js/call.js', '/js/voice-channels.js',
const FORBIDDEN_ON_HOME = ['/js/broadcast.js', '/js/broadcast-workspace.js', '/js/dashboard.js', '/js/call.js', '/js/voice-channels.js',
'/js/stream-player.js', '/js/app-channel.js', '/js/arena.js', '/js/pastes.js', '/css/features/broadcast.css', '/css/features/channel.css'];
'/js/stream-player.js', '/js/app-channel.js', '/js/arena.js', '/js/pastes.js', '/css/features/broadcast.css', '/css/features/channel.css'];
+// perf-budget's measure() fetches from a running server; this script stays offline, so it reads
+// what the server would render for "/" and hands check() the same measurement shape.
const assets = require(path.join(ROOT, 'server/web/assets'));
const assets = require(path.join(ROOT, 'server/web/assets'));
const SHARED_DIR = require('openvibe-shared/files').dir;
const SHARED_DIR = require('openvibe-shared/files').dir;
try { assets.setSharedDir(SHARED_DIR); } catch { /* */ }
try { assets.setSharedDir(SHARED_DIR); } catch { /* */ }
@@ -36,33 +41,29 @@ const fileFor = (url) => {
@@ -36,33 +41,29 @@ const fileFor = (url) => {
const p = url.split('?')[0];
const p = url.split('?')[0];
return p.startsWith('/shared/') ? path.join(SHARED_DIR, path.basename(p)) : path.join(ROOT, 'public', p);
return p.startsWith('/shared/') ? path.join(SHARED_DIR, path.basename(p)) : path.join(ROOT, 'public', p);
};
};
-const kb = (n) => +(n / 1024).toFixed(1);
+const kb = (n) => Math.round((n / 1024) * 10) / 10;
const br = (buf) => zlib.brotliCompressSync(buf, { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 11 } }).length;
const br = (buf) => zlib.brotliCompressSync(buf, { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 11 } }).length;
-const scripts = [...html.matchAll(/<script src="([^"]+)"/g)].map((m) => m[1]).filter((s) => s.startsWith('/'));
// <noscript> fallbacks are not fetched by browsers with JavaScript on.
// <noscript> fallbacks are not fetched by browsers with JavaScript on.
-const blockingCss = [...html.replace(/<noscript>[\s\S]*?<\/noscript>/gi, '').matchAll(/<link rel="stylesheet" href="([^"]+)"(?![^>]*media="print")[^>]*>/g)].map((m) => m[1]).filter((s) => s.startsWith('/'));
+const named = assetsOf(html.replace(/<noscript>[\s\S]*?<\/noscript>/gi, ''));
+const local = (u) => u.startsWith('/') && !u.startsWith('//');
+const scripts = named.js.filter(local);
+const blockingCss = named.css.filter(local);
const read = (list) => list.map((u) => { try { return fs.readFileSync(fileFor(u)); } catch { return Buffer.alloc(0); } });
const read = (list) => list.map((u) => { try { return fs.readFileSync(fileFor(u)); } catch { return Buffer.alloc(0); } });
-const jsBufs = read(scripts), cssBufs = read(blockingCss);
-const sum = (arr, f) => arr.reduce((n, b) => n + f(b), 0);
-
+const total = (list) => {
+ const bufs = read(list);
+ return { files: list.length, rawKB: kb(bufs.reduce((n, b) => n + b.length, 0)), brotliKB: kb(bufs.reduce((n, b) => n + br(b), 0)) };
+};
+const htmlBuf = Buffer.from(html);
const measured = {
const measured = {
- htmlRawKB: kb(Buffer.byteLength(html)),
- htmlBrotliKB: kb(br(Buffer.from(html))),
- eagerJsFiles: scripts.length,
- eagerJsRawKB: kb(sum(jsBufs, (b) => b.length)),
- eagerJsBrotliKB: kb(sum(jsBufs, br)),
- blockingCssRawKB: kb(sum(cssBufs, (b) => b.length)),
- blockingCssBrotliKB: kb(sum(cssBufs, br)),
+ url: '/', html: { rawKB: kb(htmlBuf.length), brotliKB: kb(br(htmlBuf)) },
+ js: total(scripts), css: total(blockingCss),
+ external: [...named.js, ...named.css].filter((u) => !local(u)), urls: { js: scripts, css: blockingCss },
};
};
-let failed = 0;
-for (const [k, limit] of Object.entries(BUDGETS)) {
- const v = measured[k];
- const ok = v <= limit;
- if (!ok) failed++;
- console.log(`${ok ? '✓' : '✗'} ${k.padEnd(20)} ${String(v).padStart(8)} / ${limit}`);
-}
+const over = check(measured, BUDGETS);
+console.log(format(measured, over));
+let failed = over.length;
for (const f of FORBIDDEN_ON_HOME) {
for (const f of FORBIDDEN_ON_HOME) {
if (scripts.some((s) => s.startsWith(f + '?') || s === f) || blockingCss.some((s) => s.startsWith(f + '?') || s === f)) {
if (scripts.some((s) => s.startsWith(f + '?') || s === f) || blockingCss.some((s) => s.startsWith(f + '?') || s === f)) {
failed++;
failed++;
@@ -15,6 +15,7 @@
@@ -15,6 +15,7 @@
const express = require('express');
const express = require('express');
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
+const cache = require('openvibe-shared/cache-policy');
const router = express.Router();
const router = express.Router();
// Through OV_APP_ROOT (the `current` release symlink) when set, so doc edits go live without a restart.
// Through OV_APP_ROOT (the `current` release symlink) when set, so doc edits go live without a restart.
@@ -216,7 +217,7 @@ function renderMarkdown(markdown) {
@@ -216,7 +217,7 @@ function renderMarkdown(markdown) {
// ── Page shell ───────────────────────────────────────────────
// ── Page shell ───────────────────────────────────────────────
-// The public origin for canonical URLs: the configured base URL, never a local one (as server/seo/seo.js).
+// The public origin for canonical URLs: the configured base URL, never a local one (as server/seo/pages.js).
let config = null; try { config = require('../config'); } catch { /* */ }
let config = null; try { config = require('../config'); } catch { /* */ }
function publicBase() {
function publicBase() {
let b = (config && config.baseUrl) || 'https://openvibe.live';
let b = (config && config.baseUrl) || 'https://openvibe.live';
@@ -318,7 +319,7 @@ function sendDoc(req, res, name) {
@@ -318,7 +319,7 @@ function sendDoc(req, res, name) {
const markdown = loadDoc(name);
const markdown = loadDoc(name);
if (markdown === null) return res.status(404).type('text/plain').send('No such doc');
if (markdown === null) return res.status(404).type('text/plain').send('No such doc');
const rendered = renderMarkdown(markdown);
const rendered = renderMarkdown(markdown);
- res.set('Cache-Control', 'public, max-age=300');
+ res.set('Cache-Control', cache.htmlHeaders({ maxAge: 300 }));
res.type('html').send(renderPage({ name, ...rendered }));
res.type('html').send(renderPage({ name, ...rendered }));
}
}
@@ -69,6 +69,7 @@ const cookieParser = require('cookie-parser');
@@ -69,6 +69,7 @@ const cookieParser = require('cookie-parser');
const rateLimit = drill.enabled ? () => (req, res, next) => next() : require('express-rate-limit');
const rateLimit = drill.enabled ? () => (req, res, next) => next() : require('express-rate-limit');
const config = require('./config');
const config = require('./config');
const assets = require('./web/assets');
const assets = require('./web/assets');
+const cache = require('openvibe-shared/cache-policy');
// Starts the event-loop delay histogram at boot so the first diagnostics window is complete.
// Starts the event-loop delay histogram at boot so the first diagnostics window is complete.
require('./diagnostics');
require('./diagnostics');
@@ -506,8 +507,9 @@ console.log(`[Server] /shared: serving ${SHARED_BROWSER_FILES.length} browser fi
@@ -506,8 +507,9 @@ console.log(`[Server] /shared: serving ${SHARED_BROWSER_FILES.length} browser fi
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
// Same deal as /js and /css: only a ?v= that matches the file's content hash is immutable.
// Same deal as /js and /css: only a ?v= that matches the file's content hash is immutable.
- if (req.query.v && req.query.v === assets.hashOf('/shared/' + fileName)) assets.setImmutable(res);
- else res.setHeader('Cache-Control', 'public, max-age=300');
+ const hashed = Boolean(req.query.v) && req.query.v === assets.hashOf('/shared/' + fileName);
+ res.setHeader('Cache-Control', cache.assetHeaders(req.originalUrl, { hashed }));
+ if (hashed) res.setHeader('CDN-Cache-Control', cache.IMMUTABLE);
res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
res.sendFile(filePath, (err) => {
res.sendFile(filePath, (err) => {
if (err && !res.headersSent) {
if (err && !res.headersSent) {
@@ -544,7 +546,7 @@ app.use('/fragments', assets.versionedStatic('/fragments'), express.static(path.
@@ -544,7 +546,7 @@ app.use('/fragments', assets.versionedStatic('/fragments'), express.static(path.
// Pastes live on openvibe.community: /p/<slug> 301s there, and it is mounted
// Pastes live on openvibe.community: /p/<slug> 301s there, and it is mounted
// before SEO so crawlers and people get the same redirect and Community is the one canonical page.
// before SEO so crawlers and people get the same redirect and Community is the one canonical page.
require('./web/paste-handover').register(app);
require('./web/paste-handover').register(app);
-try { require('./seo/seo').register(app); } catch (e) { console.warn('[SEO] not registered:', e.message); }
+try { require('./seo').register(app); } catch (e) { console.warn('[SEO] not registered:', e.message); }
// Standalone HTML pages (popout chat, kiosk, legal, OBS overlays…) go through the same asset rewrite
// Standalone HTML pages (popout chat, kiosk, legal, OBS overlays…) go through the same asset rewrite
// as the SPA shell, so their script and stylesheet URLs can never drift out of date again.
// as the SPA shell, so their script and stylesheet URLs can never drift out of date again.
app.use((req, res, next) => {
app.use((req, res, next) => {
@@ -1032,10 +1034,10 @@ app.get('/banned', (req, res) => {
@@ -1032,10 +1034,10 @@ app.get('/banned', (req, res) => {
// paste or stream, or a private item this visitor may not see) gets it with a 404 status, so
// paste or stream, or a private item this visitor may not see) gets it with a 404 status, so
// search engines and monitors see a real 404; the client renders its not-found view either way.
// search engines and monitors see a real 404; the client renders its not-found view either way.
// API paths still get the JSON 404. See server/web/page-status.js for the page list.
// API paths still get the JSON 404. See server/web/page-status.js for the page list.
-// The shell comes from server/seo/seo.js shellHtml: a 404 is noindex with no canonical (the shell's
+// The shell comes from server/seo/pages.js shellHtml: a 404 is noindex with no canonical (the shell's
// own head describes the home page), and no other page claims the home page as its canonical.
// own head describes the home page), and no other page claims the home page as its canonical.
let _shellHtml = null;
let _shellHtml = null;
-try { _shellHtml = require('./seo/seo').shellHtml; } catch { _shellHtml = null; }
+try { _shellHtml = require('./seo').shellHtml; } catch { _shellHtml = null; }
function sendShell(res, urlPath) {
function sendShell(res, urlPath) {
let html = null;
let html = null;
try { html = _shellHtml ? _shellHtml(urlPath, res.statusCode) : null; } catch { html = null; }
try { html = _shellHtml ? _shellHtml(urlPath, res.statusCode) : null; } catch { html = null; }
@@ -14,6 +14,7 @@ const access = require('./access');
@@ -14,6 +14,7 @@ const access = require('./access');
const purge = require('./purge');
const purge = require('./purge');
const { commentCount } = require('./comments');
const { commentCount } = require('./comments');
const lineage = require('../lineage/resolver');
const lineage = require('../lineage/resolver');
+const cache = require('openvibe-shared/cache-policy');
const router = express.Router();
const router = express.Router();
@@ -221,7 +222,7 @@ router.get('/:id', optionalAuth, async (req, res) => {
@@ -221,7 +222,7 @@ router.get('/:id', optionalAuth, async (req, res) => {
return mediaErr(res, err, 'Failed to get clip');
return mediaErr(res, err, 'Failed to get clip');
}
}
if (!clip || !access.canView(req.user, clip)) return clipNotFound(res);
if (!clip || !access.canView(req.user, clip)) return clipNotFound(res);
- if (access.isPrivate(clip)) res.set('Cache-Control', 'private, no-store');
+ if (access.isPrivate(clip)) res.set('Cache-Control', cache.htmlHeaders({ private: true }));
withUserFields(clip);
withUserFields(clip);
// Unique-view tracking (content_views stays in live.db).
// Unique-view tracking (content_views stays in live.db).
@@ -411,7 +412,7 @@ router.get('/:id/job', requireAuth, async (req, res) => {
@@ -411,7 +412,7 @@ router.get('/:id/job', requireAuth, async (req, res) => {
try { out = await media.getJob(jobId); } catch (err) { return mediaErr(res, err, 'Job not found'); }
try { out = await media.getJob(jobId); } catch (err) { return mediaErr(res, err, 'Job not found'); }
const job = out && out.job;
const job = out && out.job;
if (!job || job.type !== 'clip.cut' || Number(job.params && job.params.clip_id) !== Number(clip.id)) return res.status(404).json({ error: 'Job not found' });
if (!job || job.type !== 'clip.cut' || Number(job.params && job.params.clip_id) !== Number(clip.id)) return res.status(404).json({ error: 'Job not found' });
- res.set('Cache-Control', 'private, no-store').json({
+ res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({
job: { id: job.id, status: job.status, attempts: job.attempts, max_attempts: job.max_attempts, error: job.status === 'failed' ? job.error || null : null, run_after: job.run_after || null },
job: { id: job.id, status: job.status, attempts: job.attempts, max_attempts: job.max_attempts, error: job.status === 'failed' ? job.error || null : null, run_after: job.run_after || null },
clip: { id: clip.id, status: clip.status, title: clip.title, duration_seconds: clip.duration_seconds },
clip: { id: clip.id, status: clip.status, title: clip.title, duration_seconds: clip.duration_seconds },
});
});
@@ -18,6 +18,7 @@ const permissions = require('../auth/permissions');
@@ -18,6 +18,7 @@ const permissions = require('../auth/permissions');
const access = require('./access');
const access = require('./access');
const purge = require('./purge');
const purge = require('./purge');
const { commentCount } = require('./comments');
const { commentCount } = require('./comments');
+const cache = require('openvibe-shared/cache-policy');
const router = express.Router();
const router = express.Router();
@@ -417,7 +418,7 @@ router.get('/:id/context', optionalAuth, async (req, res) => {
@@ -417,7 +418,7 @@ router.get('/:id/context', optionalAuth, async (req, res) => {
const isPrivate = access.isPrivate(vod);
const isPrivate = access.isPrivate(vod);
if (!access.canView(req.user, vod)) return vodNotFound(res);
if (!access.canView(req.user, vod)) return vodNotFound(res);
// A private VOD's context must not sit in a shared cache anywhere on the way back either.
// A private VOD's context must not sit in a shared cache anywhere on the way back either.
- const cacheHeader = isPrivate ? 'private, no-store' : 'public, max-age=60';
+ const cacheHeader = isPrivate ? cache.htmlHeaders({ private: true }) : 'public, max-age=60';
const hit = _ctxCache.get(id);
const hit = _ctxCache.get(id);
if (hit && Date.now() - hit.at < 120000) { res.set('Cache-Control', cacheHeader); return res.json(hit.data); }
if (hit && Date.now() - hit.at < 120000) { res.set('Cache-Control', cacheHeader); return res.json(hit.data); }
@@ -469,7 +470,7 @@ router.get('/:id', optionalAuth, async (req, res) => {
@@ -469,7 +470,7 @@ router.get('/:id', optionalAuth, async (req, res) => {
// carrying the title and username, which is what anonymous callers used to get.
// carrying the title and username, which is what anonymous callers used to get.
const vod = await loadVisibleVod(req, res, req.params.id);
const vod = await loadVisibleVod(req, res, req.params.id);
if (!vod) return;
if (!vod) return;
- if (access.isPrivate(vod)) res.set('Cache-Control', 'private, no-store');
+ if (access.isPrivate(vod)) res.set('Cache-Control', cache.htmlHeaders({ private: true }));
withUserFields(vod);
withUserFields(vod);
// Enrich with local stream details for chat replay.
// Enrich with local stream details for chat replay.
new file mode 100644
new file mode 100644
@@ -0,0 +1,178 @@
@@ -0,0 +1,178 @@
+/**
+ * Discovery files: /sitemap.xml, /robots.txt, /llms.txt and /llms-full.txt, built with
+ * openvibe-shared/seo from what Live knows (the VODs and clips Media holds, the docs folder).
+ */
+'use strict';
+const fs = require('node:fs');
+const path = require('node:path');
+const seo = require('openvibe-shared/seo');
+const cache = require('openvibe-shared/cache-policy');
+const db = require('../db/database');
+const media = require('../media-client');
+const { SITE_NAME, baseUrl, isAiClip, isoDate } = require('./pages');
+
+const DOCS_DIR = process.env.OV_APP_ROOT ? path.join(process.env.OV_APP_ROOT, 'docs') : path.join(__dirname, '../../docs');
+const DISCOVERY_MAX_AGE = 3600; // seconds, for every file here
+
+// ── Dynamic sitemap.xml (cached ~1h) ────────────────────────────────────────────────────────
+let _sitemap = null, _sitemapAt = 0;
+const SITEMAP_TTL_MS = 60 * 60 * 1000;
+const SITEMAP_CAP = 5000; // per content type
+async function buildSitemap() {
+ const b = baseUrl();
+ const urls = [];
+ const entry = (loc, lastmod, changefreq, priority) => urls.push({ loc: b + loc, lastmod: lastmod || undefined, changefreq, priority });
+ const seenChannels = new Set();
+ // Media rows carry Live user ids, not names: a channel is named from Live's accounts (banned
+ // accounts are left out).
+ const names = new Map();
+ const channelOf = (row, id) => {
+ if (row.username) return row.username;
+ if (id == null) return null;
+ if (!names.has(id)) {
+ let u = null;
+ try { u = db.getUserById(Number(id)); } catch { u = null; }
+ names.set(id, u && !(u.is_banned === 1 || u.is_banned === true) ? u.username : null);
+ }
+ return names.get(id);
+ };
+ const statics = [['/', 'daily', 1.0], ['/content', 'hourly', 0.9], ['/moments', 'hourly', 0.7], ['/vods', 'hourly', 0.8], ['/clips', 'hourly', 0.8], ['/pastes', 'hourly', 0.7], ['/chat', 'daily', 0.5], ['/arena', 'hourly', 0.6]];
+ // Rendered docs (server/docs/routes.js): /docs is the index (README.md), the rest by file name.
+ try {
+ for (const f of fs.readdirSync(DOCS_DIR)) {
+ if (!f.endsWith('.md')) continue;
+ statics.push([f === 'README.md' ? '/docs' : `/docs/${f.slice(0, -3)}`, 'weekly', 0.5]);
+ }
+ } catch { /* docs folder absent in some deploys */ }
+ for (const [u, cf, pr] of statics) entry(u, null, cf, pr);
+ const page = async (fetch, per, cap, emit) => {
+ let off = 0;
+ while (off < cap) {
+ let rows = [];
+ try { rows = (await fetch(per, off)) || []; } catch { break; }
+ for (const r of rows) emit(r);
+ if (rows.length < per) break;
+ off += per;
+ }
+ };
+ await page((l, o) => media.listVods({ limit: l, offset: o }).then(r => r?.vods || []), 200, SITEMAP_CAP, (v) => {
+ entry(`/vod/${v.id}`, isoDate(v.created_at), 'weekly', 0.6);
+ const ch = channelOf(v, v.user_id);
+ if (ch) seenChannels.add(ch);
+ });
+ // People's clips only: AI Moments are noindex and never listed (the /moments
+ // collection above is their indexable form). The filter is checked again per row, so an
+ // upstream that ignores it still cannot put an AI item here.
+ await page((l, o) => media.listClips({ limit: l, offset: o, auto_generated: 0 }).then(r => r?.clips || []), 200, SITEMAP_CAP, (c) => {
+ if (isAiClip(c)) return;
+ entry(`/clip/${c.id}`, isoDate(c.created_at), 'weekly', 0.6);
+ const ch = channelOf({}, c.channel_user_id != null ? c.channel_user_id : c.user_id);
+ if (ch) seenChannels.add(ch);
+ });
+ // No /p/ here: pastes are OpenVibe.Community's, and its sitemap lists them under their canonical URL.
+ for (const u of seenChannels) if (u) entry(`/@${u}`, null, 'daily', 0.6);
+ return seo.sitemapXml(urls);
+}
+async function sitemapHandler(req, res) {
+ if (!_sitemap || (Date.now() - _sitemapAt) > SITEMAP_TTL_MS) {
+ try { _sitemap = await buildSitemap(); _sitemapAt = Date.now(); }
+ catch (e) { console.warn('[SEO] sitemap build failed:', e.message); if (!_sitemap) return res.status(500).end(); }
+ }
+ res.set('Content-Type', 'application/xml; charset=utf-8');
+ res.set('Cache-Control', cache.htmlHeaders({ maxAge: DISCOVERY_MAX_AGE }));
+ res.send(_sitemap);
+}
+
+// ── /robots.txt: every crawler welcome, search and AI alike, pointed at the sitemap ────────
+function robotsTxt() {
+ return seo.robotsTxt({ sitemaps: [`${baseUrl()}/sitemap.xml`] });
+}
+
+// ── /llms.txt: what this site is, for language models and other automated readers ─────────
+// (llmstxt.org). It names the public pages, the JSON behind them, the API docs, and how people's
+// work is kept apart from what the AI derived from it (roadmap 32.4, 33.8).
+const LLMS_DOCS = [
+ ['go-live-in-your-browser', 'Go live from a browser: no OBS, no downloads, no follower minimum (how, devices, questions)'],
+ ['whip', 'WHIP ingest API: publish to a channel from a browser or any WHIP client'],
+ ['broadcasting', 'Going live: WebRTC, WHIP, RTMP (OBS) and the JSMPEG/CLI path'],
+ ['api-tokens', 'Bot and integration tokens (hbt_...) for the API'],
+ ['chat-system', 'Chat features and moderation'],
+ ['vods-and-clips', 'How VODs and clips are recorded and cut'],
+ ['architecture', 'System design and how Live fits the OpenVibe network'],
+];
+const LLMS_SUMMARY = 'Open-source, community-run live streaming. Anyone can go live straight from a web browser, with no OBS, no downloads and no follower, subscriber or equipment requirement: press Go Live, allow camera and microphone, and the stream is live (WebRTC, under a second of delay; Chromebooks, laptops and phones). OBS (RTMP), WHIP and command-line ingest are there for produced shows. Every stream can be recorded as a VOD, clipped and discussed. Part of the OpenVibe network: one account across openvibe.network, openvibe.media, openvibe.community, openvibe.tools and the other OpenVibe sites.';
+const _docsPresent = () => LLMS_DOCS.filter(([name]) => { try { return fs.existsSync(path.join(DOCS_DIR, `${name}.md`)); } catch { return false; } });
+
+function llmsTxt() {
+ const b = baseUrl();
+ return seo.llmsTxt({
+ name: SITE_NAME,
+ summary: LLMS_SUMMARY,
+ details: 'People\'s work and AI-made material are kept apart everywhere on this site. The Content pages list what people made; AI Moments list what the platform\'s AI derived from streams. Every AI item is labelled AI-generated, credited to no person, marked noindex,follow and made canonical to the source VOD at the moment it came from (/vod/<id>?t=<seconds>). In the JSON feeds, AI items carry "ai": true and an "ai_label".',
+ sections: [
+ { title: 'Going live from a browser (no OBS)', links: [
+ { title: 'How to go live in your browser', url: `${b}/docs/go-live-in-your-browser`, note: `open ${b}, sign in, press Go Live, allow camera and microphone, start. No software, no follower minimum.` },
+ { title: 'The broadcaster', url: `${b}/broadcast`, note: 'camera switching, screen/window/tab sharing with a camera picture-in-picture, live stats, chat beside the stream.' },
+ { title: 'Browser publishing for other sites', url: `${b}/docs/whip#publishing-from-a-browser`, note: 'any web page can publish to a channel over WHIP.' },
+ ] },
+ { title: 'What people made', links: [
+ { title: 'Home', url: `${b}/`, note: 'who is live now, recent VODs and clips' },
+ { title: 'Content', url: `${b}/content`, note: `VODs, the clips people took, and pastes people wrote. JSON: ${b}/api/content/feed (cursor paging; ?type=vods|clips|pastes, ?sort=new|top)` },
+ { title: 'Channel pages', url: `${b}/@<username>`, note: `the channel's videos in pages (${b}/@<username>?page=2)` },
+ { title: 'VOD pages', url: `${b}/vod/<id>`, note: `a moment in a VOD: ${b}/vod/<id>?t=<seconds>` },
+ { title: 'Clip pages', url: `${b}/clip/<id>` },
+ { title: 'Pastes', url: 'https://openvibe.community/p/<slug>', note: 'pastes live on OpenVibe.Community (Live\'s /p/<slug> redirects there)' },
+ { title: 'Chat', url: `${b}/chat` },
+ { title: 'The Arena', url: `${b}/arena`, note: 'mic-judged streamer callouts' },
+ ] },
+ { title: 'What the AI made (AI Moments)', links: [
+ { title: 'AI Moments', url: `${b}/moments`, note: `auto-clips cut when chat reacted, frames the AI picked from streams, and AI-written after-show recaps. JSON: ${b}/api/content/moments. AI Moments pages are noindex; the /moments collection is their indexable form. They are never listed in the sitemap. An AI clip says "AI clip · from <streamer>'s stream"; it is never presented as something the streamer or a viewer clipped. A streamer can turn AI Moments off for their channel; the AI then makes no new ones from their streams.` },
+ ] },
+ { title: 'API and docs', links: [
+ { title: 'API docs', url: `${b}/documentation`, note: 'every feature has an open API (chat, streams, clips, overlays, robots, sound commands)' },
+ { title: 'Docs index', url: `${b}/docs` },
+ ..._docsPresent().map(([name, what]) => ({ title: name, url: `${b}/docs/${name}`, note: what })),
+ { title: 'Source code', url: 'https://github.com/OpenVibers/OpenVibe.Live' },
+ ] },
+ { title: 'Discovery', links: [
+ { title: 'Sitemap', url: `${b}/sitemap.xml`, note: 'people\'s work only' },
+ { title: 'robots.txt', url: `${b}/robots.txt` },
+ { title: 'llms-full.txt', url: `${b}/llms-full.txt`, note: 'the docs above in full, as plain text' },
+ { title: 'OpenVibe platform descriptor', url: 'https://openvibe.network/.well-known/openvibe', note: 'the OpenVibe network\'s descriptor' },
+ ] },
+ ],
+ });
+}
+
+// ── /llms-full.txt: the same header, then the docs themselves ──────────────────────────────
+const LLMS_FULL_DOC_CHARS = 40_000;
+const LLMS_FULL_TOTAL_CHARS = 400_000;
+let _llmsFull = null, _llmsFullAt = 0;
+function llmsFullTxt() {
+ if (_llmsFull && Date.now() - _llmsFullAt < DISCOVERY_MAX_AGE * 1000) return _llmsFull;
+ const sections = [];
+ for (const [name, what] of _docsPresent()) {
+ let body = '';
+ try { body = fs.readFileSync(path.join(DOCS_DIR, `${name}.md`), 'utf8'); } catch { continue; }
+ sections.push({ title: what, url: `/docs/${name}`, body });
+ }
+ _llmsFull = seo.llmsFull({ site: { name: SITE_NAME, url: baseUrl() }, summary: LLMS_SUMMARY, sections, maxChars: LLMS_FULL_DOC_CHARS, maxTotal: LLMS_FULL_TOTAL_CHARS });
+ _llmsFullAt = Date.now();
+ return _llmsFull;
+}
+
+const textHandler = (build) => (req, res) => {
+ res.set('Content-Type', 'text/plain; charset=utf-8');
+ res.set('Cache-Control', cache.htmlHeaders({ maxAge: DISCOVERY_MAX_AGE }));
+ res.send(build());
+};
+
+/** Mounted before express.static, so /robots.txt here wins over any file in public/. */
+function register(app) {
+ app.get('/sitemap.xml', sitemapHandler);
+ app.get('/robots.txt', textHandler(robotsTxt));
+ app.get('/llms.txt', textHandler(llmsTxt));
+ app.get('/llms-full.txt', textHandler(llmsFullTxt));
+}
+
+module.exports = { register, sitemapHandler, buildSitemap, robotsTxt, llmsTxt, llmsFullTxt };
new file mode 100644
new file mode 100644
@@ -0,0 +1,22 @@
@@ -0,0 +1,22 @@
+/**
+ * Live's SEO: per-route head tags and no-JS snapshots (./pages.js) and the discovery files
+ * (./discovery.js), both built with openvibe-shared/seo.
+ */
+'use strict';
+const pages = require('./pages');
+const discovery = require('./discovery');
+
+// Discovery files first, then the meta middleware. MUST be mounted BEFORE express.static (so it
+// can intercept "/" and /robots.txt) and before the SPA catch-all.
+function register(app) {
+ discovery.register(app);
+ app.use(pages.middleware);
+ console.log('[SEO] per-route meta + sitemap.xml + robots.txt + llms.txt + llms-full.txt registered');
+}
+
+module.exports = {
+ register,
+ middleware: pages.middleware, shellHtml: pages.shellHtml, render: pages.render, _pageMeta: pages._pageMeta,
+ sitemapHandler: discovery.sitemapHandler, buildSitemap: discovery.buildSitemap,
+ robotsTxt: discovery.robotsTxt, llmsTxt: discovery.llmsTxt, llmsFullTxt: discovery.llmsFullTxt,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,1033 @@
@@ -0,0 +1,1033 @@
+/**
+ * SEO for the SPA — per-route server-side meta / Open Graph / Twitter / JSON-LD injection (tags
+ * built with openvibe-shared/seo) and a no-JS crawlable content snapshot. Cached throughout.
+ * The sitemap, robots.txt and llms files are in ./discovery.js.
+ *
+ * The app is a client-rendered SPA (server/index.js serves public/index.html for every route),
+ * so crawlers/AI scrapers see an empty shell. This middleware intercepts the HTML routes we
+ * care about (home, vods/clips/pastes lists, and vod/clip/paste detail pages), and rewrites the
+ * <head> with route-specific machine-readable metadata + drops a <noscript> content snapshot so
+ * no-JS scrapers get real content. Everything is cached so it adds ~no per-request DB cost.
+ *
+ * AI Moments (auto-clips, AI moment pastes, AI recaps) are labelled AI-made, credited to no
+ * person, noindex,follow, canonical to their source VOD and left out of the sitemap (see
+ * "AI Moments" below). People's work keeps normal, indexable pages.
+ */
+'use strict';
+const path = require('node:path');
+const seo = require('openvibe-shared/seo');
+const { esc } = seo;
+const db = require('../db/database');
+const media = require('../media-client');
+let config = null; try { config = require('../config'); } catch { /* */ }
+
+// ── Short-cached Media content (VODs/clips/pastes live in OpenVibe.Media now) ──
+const _mc = new Map(); // key → { v, at }
+const MC_TTL_MS = 60_000;
+async function _cached(key, fn) {
+ const e = _mc.get(key);
+ if (e && Date.now() - e.at < MC_TTL_MS) return e.v;
+ let v;
+ try { v = await fn(); } catch (err) {
+ // A missing id is remembered too: otherwise every request for a dead link (the SPA fallback
+ // then answers it 404, see server/web/page-status.js) is another round trip to Media.
+ if (!err || (err.status !== 404 && err.status !== 410)) return e ? e.v : null;
+ v = null;
+ }
+ _mc.set(key, { v, at: Date.now() });
+ if (_mc.size > 300) _mc.delete(_mc.keys().next().value);
+ return v;
+}
+const _vodList = (limit, offset = 0) => _cached(`vl:${limit}:${offset}`, async () => (await media.listVods({ limit, offset }))?.vods || []);
+// People's clips and pastes: the AI's are listed on /moments (server/content/feed.js).
+const _clipList = (limit, offset = 0) => _cached(`cl:${limit}:${offset}`, async () => (await media.listClips({ limit, offset, auto_generated: 0 }))?.clips || []);
+const _pasteList = (limit, offset = 0) => _cached(`pl:${limit}:${offset}`, async () => (await require('../pastes-client').listPastes({ limit, offset, visibility: 'public', origin: 'user' }))?.pastes || []);
+/**
+ * Page `page` of the Content or Moments feed, as list-page items: { items, more }. The feed pages by
+ * cursor, so page N follows N-1 `next` cursors (each page cached; at most FEED_MAX_PAGE deep), which
+ * gives crawlers plain ?page=N links (roadmap D44) while the SPA keeps its infinite scroll.
+ */
+const FEED_MAX_PAGE = 20;
+const FEED_PAGE_SIZE = 24;
+const _feedPage = (feed, page = 1) => _cached(`feed:${feed}:${page}`, async () => {
+ const f = require('../content/feed');
+ let cursor = null, out = null;
+ for (let n = 1; n <= page; n++) {
+ out = await f.page(feed, { limit: FEED_PAGE_SIZE, ...(cursor ? { cursor } : {}) });
+ if (n < page) { if (!out.next) return { items: [], more: false }; cursor = out.next; }
+ }
+ const kindLabel = { vod: 'VOD', clip: 'clip', paste: 'paste', recap: 'recap' };
+ return {
+ items: (out.items || []).map((it) => ({
+ url: it.kind === 'paste' ? pasteHref(it.id) : it.href, name: it.title, by: it.channel ? it.channel.display_name : null,
+ meta: [kindLabel[it.kind], it.duration_seconds ? _fmtDur(it.duration_seconds) : null].filter(Boolean).join(' · '),
+ desc: it.excerpt,
+ })),
+ more: Boolean(out.next) && page < FEED_MAX_PAGE,
+ };
+});
+const _feedList = async (feed) => (await _feedPage(feed, 1)).items;
+const { isPrivate } = require('../media-proxy/access');
+const pageStatus = require('../web/page-status');
+
+// ── One item (VOD, clip, paste) ──
+// Fetched at most once at a time per id and kept a minute (a missing id 30 s, a failure not at
+// all). A page waits at most DETAIL_WAIT_MS for it, so a slow Media or Community never holds a
+// page, or a crawler, for its whole timeout; the fetch keeps running and fills the cache. What a
+// fresh fetch learned is handed to the SPA fallback's status check (server/web/page-status.js):
+// a page this renderer passes on (missing, or private to this visitor) costs one upstream call.
+const DETAIL_WAIT_MS = Math.max(0, pageStatus.LOOKUP_DEADLINE_MS - 500);
+const DETAIL_FOUND_TTL_MS = 60_000;
+const DETAIL_MISSING_TTL_MS = 30_000;
+const _items = new Map(); // key → { at, ttl, state: 'found'|'missing'|null, row, pending }
+function _detail(key, load) {
+ let e = _items.get(key);
+ if (e && !e.pending && e.state && Date.now() - e.at < e.ttl) return Promise.resolve({ state: e.state, row: e.row, fresh: false });
+ if (!e || !e.pending) {
+ const entry = { at: 0, ttl: 0, state: null, row: null, pending: null };
+ entry.pending = Promise.resolve().then(load).then(
+ (row) => { if (row && typeof row === 'object') { entry.state = 'found'; entry.row = row; entry.ttl = DETAIL_FOUND_TTL_MS; } else { entry.state = 'missing'; entry.ttl = DETAIL_MISSING_TTL_MS; } },
+ (err) => { if (err && (err.status === 404 || err.status === 410)) { entry.state = 'missing'; entry.ttl = DETAIL_MISSING_TTL_MS; } },
+ ).then(() => {
+ entry.at = Date.now();
+ entry.pending = null;
+ if (!entry.state && _items.get(key) === entry) _items.delete(key); // a failure is not remembered
+ return { state: entry.state || 'unknown', row: entry.row, fresh: true };
+ });
+ _items.delete(key);
+ _items.set(key, entry);
+ if (_items.size > 1000) _items.delete(_items.keys().next().value);
+ e = entry;
+ }
+ let timer;
+ const late = new Promise((resolve) => { timer = setTimeout(resolve, DETAIL_WAIT_MS, { state: 'unknown', row: null, fresh: false }); });
+ return Promise.race([e.pending, late]).finally(() => clearTimeout(timer));
+}
+// Callers may annotate the row they get (AI state overlay), so each gets its own copy.
+const _copy = (r) => (r.row ? { ...r.row } : null);
+async function _vodGet(id) {
+ const r = await _detail(`v:${id}`, () => media.getVod(id));
+ if (r.fresh && r.state !== 'unknown') pageStatus.primeMediaItem('vod', String(id), r.row);
+ return _copy(r);
+}
+async function _clipGet(id) {
+ const r = await _detail(`c:${id}`, () => media.getClip(id));
+ if (r.fresh && r.state !== 'unknown') pageStatus.primeMediaItem('clip', String(id), r.row);
+ return _copy(r);
+}
+async function _pasteGet(slug) {
+ const r = await _detail(`p:${slug}`, () => require('../pastes-client').getPaste(slug));
+ if (r.fresh && r.state !== 'unknown') pageStatus.primePaste(slug, r.state === 'found');
+ return _copy(r);
+}
+
+// Lists (a channel's VODs, a VOD's clips, the home rails) are extras on a page: each waits at
+// most LIST_WAIT_MS, and a page rendered without one is cached briefly so it fills in soon.
+const LIST_WAIT_MS = 1500;
+const SHORT_CACHE_MS = 30_000;
+const _TIMED_OUT = Symbol('timed out');
+function _within(promise, ms) {
+ let timer;
+ const late = new Promise((resolve) => { timer = setTimeout(resolve, ms, _TIMED_OUT); });
+ return Promise.race([Promise.resolve(promise).catch(() => null), late]).finally(() => clearTimeout(timer));
+}
+/** A cached list call, or null (and `partial` set on the tracker) when it did not answer in time. */
+async function _listWithin(key, fn, track) {
+ const r = await _within(_cached(key, fn), LIST_WAIT_MS);
+ if (r === _TIMED_OUT || r == null) { if (track) track.partial = true; return null; }
+ return r;
+}
+const _pub = (row) => row && !isPrivate(row) && Number(row.is_public) !== 0 && row.is_public !== false;
+
+// Overlay the Live-owned AI state (vod_ai_state/clip_ai_state) onto a Media row so
+// descriptions/transcripts keep enriching the crawlable snapshot.
+function _overlayAiState(row, kind) {
+ try {
+ const st = kind === 'clip' ? db.getClipAiState(row.id) : db.getVodAiState(row.id);
+ if (!st) return;
+ if (st.ai_overview_short) {
+ row.ai_overview_short = row.ai_overview_short || st.ai_overview_short;
+ row.ai_overview = row.ai_overview || st.ai_overview || st.ai_overview_short;
+ }
+ if (st.ai_transcript_json && !row.ai_transcript) {
+ try { row.ai_transcript = JSON.parse(st.ai_transcript_json).map(s => s.text).join(' ').trim(); } catch { /* */ }
+ }
+ } catch { /* */ }
+}
+
+// Pastes live on openvibe.community: /p/<slug> is a redirect there (server/web/paste-handover.js,
+// mounted before this middleware), and every paste link Live renders, here and in the SPA (which
+// reads the base from the ov-pastes-base meta on every shell), points straight at Community, the
+// paste's one canonical page. Live's sitemap never lists /p/ (roadmap 32.2).
+const PASTES_BASE = (process.env.OV_COMMUNITY_URL || 'https://openvibe.community').replace(/\/$/, '');
+const PASTES_META = `<meta name="ov-pastes-base" content="${esc(PASTES_BASE)}">`;
+const pasteHref = (slug) => `${PASTES_BASE}/p/${encodeURIComponent(slug)}`;
+
+const SITE_NAME = 'OpenVibe.Live';
+const DEFAULT_OG_IMAGE = '/og-image.png';
+
+function baseUrl() {
+ let b = (config && config.baseUrl) || 'https://openvibe.live';
+ if (/localhost|127\.0\.0\.1/.test(b)) b = 'https://openvibe.live'; // never emit localhost in public meta
+ return b.replace(/\/+$/, '');
+}
+function abs(url) {
+ if (!url) return '';
+ if (/^https?:\/\//i.test(url)) return url;
+ return baseUrl() + (url.startsWith('/') ? url : '/' + url);
+}
+// Collapse to a clean single-line meta-description-safe string.
+function clean(s, max) {
+ let t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim();
+ if (max && t.length > max) t = t.slice(0, max - 1).replace(/\s+\S*$/, '') + '…';
+ return t;
+}
+function isoDate(dt) {
+ try {
+ if (!dt) return null;
+ const d = new Date(String(dt).includes('T') ? dt : String(dt).replace(' ', 'T') + 'Z');
+ return isNaN(d.getTime()) ? null : d.toISOString();
+ } catch { return null; }
+}
+// Seconds → ISO-8601 duration (PT#H#M#S) for VideoObject.
+function iso8601Duration(sec) {
+ sec = Math.max(0, Math.floor(Number(sec) || 0));
+ const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;
+ return 'PT' + (h ? h + 'H' : '') + (m ? m + 'M' : '') + (s || (!h && !m) ? s + 'S' : '');
+}
+
+// ── Build the per-route metadata object ────────────────────────────────────────────────────
+// Returns { title, description, canonicalPath, image, ogType, robots, jsonLd:[...], snapshot }
+// or null to fall through (unknown / private / not found).
+
+async function _pageMeta(routePath, { page = 1 } = {}) {
+ const p = routePath.replace(/\/+$/, '') || '/';
+ const bu = baseUrl();
+
+ // Home
+ if (p === '/') return _homeMeta();
+ // List pages (content sourced from OpenVibe.Media, canonical URLs stay on openvibe.live)
+ if (p === '/vods') return _listMeta('vods', 'VODs', 'Browse recorded live streams (VODs) on OpenVibe.Live — auto-recorded broadcasts with AI overviews and searchable transcripts.', async () => (await _vodList(30) || []).map(v => ({ url: `/vod/${v.id}`, name: v.title || 'VOD', by: v.display_name || v.username, meta: _fmtDur(v.duration_seconds || v.duration), desc: v.ai_overview_short })));
+ if (p === '/clips') return _listMeta('clips', 'Clips', 'Watch the best clips from OpenVibe.Live live streams — the moments viewers clipped.', async () => (await _clipList(30) || []).map(c => ({ url: `/clip/${c.id}`, name: c.title || 'Clip', by: c.display_name || c.username || c.streamer_username, meta: _fmtDur(c.duration_seconds || c.duration), desc: c.ai_overview_short })));
+ if (p === '/content') return _listMeta('content', 'Content', 'VODs, clips and pastes made by the people of OpenVibe.Live — recorded streams, the moments viewers clipped, and the code, notes and screenshots they shared.', () => _feedPage('content', page), { page });
+ if (p === '/moments') return _listMeta('moments', 'AI Moments', 'AI-made highlights from OpenVibe.Live streams: auto-clips of the moments chat erupted, standout frames the AI picked, and AI-written after-show recaps. Everything listed here is AI-generated.', () => _feedPage('moments', page), { page });
+ if (p === '/pastes') return _listMeta('pastes', 'Pastes', 'Code, text and screenshot pastes shared on OpenVibe.Live — a Pastebin built into the streaming network, with AI summaries.', async () => (await _pasteList(30) || []).map(x => ({ url: pasteHref(x.slug), name: x.title || 'Paste', by: x.username || 'anon', meta: x.type === 'screenshot' ? 'image' : (x.language || 'text'), desc: x.ai_summary })));
+
+ // Detail pages
+ let m;
+ if ((m = p.match(/^\/vod\/(\d+)$/))) return _vodMeta(parseInt(m[1], 10));
+ if ((m = p.match(/^\/clip\/(\d+)$/))) return _clipMeta(parseInt(m[1], 10));
+ if ((m = p.match(/^\/p\/([A-Za-z0-9_-]+)$/))) return _pasteMeta(m[1]);
+ if ((m = p.match(CHANNEL_PATH_RE))) return _channelMeta(m[1], page);
+ if ((m = p.match(/^\/recap\/(\d+)$/))) return _recapMeta(parseInt(m[1], 10));
+ if (p === '/arena') return _arenaMeta();
+ // Search results (public/js/app-search.js) are never indexed; the page itself is findable.
+ if (p === '/search') return { title: `Search | ${SITE_NAME}`, description: 'Search channels, VODs and clips on OpenVibe.Live.', canonicalPath: '/search', ogType: 'website', robots: 'noindex,follow', jsonLd: [] };
+ // The global chat room: its own title and canonical (the shell left it with neither; browser check, WS-Q task 3).
+ if (p === '/chat') return { title: `Chat | ${SITE_NAME}`, description: 'The global chat room of OpenVibe.Live, open to everyone on the network.', canonicalPath: '/chat', ogType: 'website', robots: 'index,follow', jsonLd: [] };
+
+ return null;
+ void bu;
+}
+
+// ── Channel page: /@<username>, with ?page=N over the channel's videos ─────────────────────
+// The client's channel-name rule (CHANNEL_USERNAME_RE in public/js/app.js, CHANNEL_RE in
+// server/web/page-status.js): anything else is never a channel.
+const CHANNEL_PATH_RE = /^\/@([A-Za-z0-9_]{3,24})$/;
+const CHANNEL_PAGE_SIZE = 12;
+const CHANNEL_MAX_PAGE = 500;
+const CHANNEL_CACHE_MS = 60_000; // the page says who is live, so it is kept a minute, not five
+
+/** The account behind /@<name>: its channel row, or just the account when it has none yet. */
+function _channelAccount(username) {
+ let ch = null;
+ try { ch = db.getChannelByUsername(username); } catch { ch = null; }
+ let user = null;
+ try { user = db.getUserById(ch ? ch.user_id : (db.getUserByUsername(username) || {}).id); } catch { user = null; }
+ if (!ch && !user) return null;
+ return {
+ userId: ch ? ch.user_id : user.id,
+ username: (ch && ch.username) || user.username,
+ displayName: (ch && ch.display_name) || (user && user.display_name) || (ch && ch.username) || user.username,
+ avatarUrl: (ch && ch.avatar_url) || (user && user.avatar_url) || null,
+ bio: (ch && ch.bio) || (user && user.bio) || '',
+ banned: !!(user && (user.is_banned === 1 || user.is_banned === true)),
+ };
+}
+
+async function _channelMeta(username, page = 1) {
+ const acct = _channelAccount(username);
+ if (!acct) return null;
+ page = Math.min(CHANNEL_MAX_PAGE, Math.max(1, Math.floor(Number(page) || 1)));
+ const name = acct.displayName;
+ const handle = '@' + acct.username;
+ const basePath = `/${handle}`;
+ let ov = null;
+ try { ov = db.getStreamerOverview ? db.getStreamerOverview(acct.userId) : null; } catch { /* */ }
+ let followers = 0;
+ try { followers = db.getFollowerCount ? (db.getFollowerCount(acct.userId) || 0) : 0; } catch { /* */ }
+ let live = [];
+ try { live = acct.banned ? [] : (db.getLiveStreamsByUserId(acct.userId) || []); } catch { live = []; }
+ const bio = clean(acct.bio || '', 300);
+ const aiShort = clean((ov && (ov.overview_short || ov.overview)) || '', 220);
+
+ // What the channel has made: its videos (this page of them), the clips people took of its
+ // streams, then what the AI cut from them, labelled (roadmap 33.6: made, then derived).
+ const track = { partial: false };
+ const offset = (page - 1) * CHANNEL_PAGE_SIZE;
+ const [vr, cr, ar] = acct.banned ? [null, null, null] : await Promise.all([
+ _listWithin(`chv:${acct.userId}:${offset}`, () => media.listVods({ user_id: acct.userId, order: 'newest', limit: CHANNEL_PAGE_SIZE, offset }, { timeoutMs: 5000 }), track),
+ page === 1 ? _listWithin(`chc:${acct.userId}`, () => media.listClips({ channel_user_id: acct.userId, auto_generated: 0, limit: 8 }, { timeoutMs: 5000 }), track) : null,
+ page === 1 ? _listWithin(`cha:${acct.userId}`, () => media.listClips({ channel_user_id: acct.userId, auto_generated: 1, limit: 6 }, { timeoutMs: 5000 }), track) : null,
+ ]);
+ const vods = ((vr && vr.vods) || []).filter((v) => _pub(v) && !v.is_recording && v.status !== 'failed');
+ const vodTotal = vr && Number.isFinite(Number(vr.total)) ? Number(vr.total) : null;
+ const pages = vodTotal != null ? Math.max(1, Math.ceil(vodTotal / CHANNEL_PAGE_SIZE)) : null;
+ const clips = ((cr && cr.clips) || []).filter((c) => _pub(c) && !isAiClip(c));
+ const aiClips = ((ar && ar.clips) || []).filter((c) => _pub(c) && isAiClip(c));
+ // A page past the end has nothing on it: not a page to index, and its canonical is page 1.
+ const pastEnd = page > 1 && !track.partial && (!vods.length || (pages != null && page > pages));
+ const canonicalPath = page > 1 && !pastEnd ? `${basePath}?page=${page}` : basePath;
+ const pageLabel = page > 1 ? ` — videos, page ${page}` : '';
+
+ const desc = clean(bio || aiShort || `${name} (${handle}) streams live on ${SITE_NAME}. Watch their live streams, VODs and clips.`, 200);
+ const image = acct.avatarUrl ? abs(acct.avatarUrl) : DEFAULT_OG_IMAGE;
+ const person = {
+ '@type': 'Person', name: clean(name, 80), alternateName: handle, url: abs(basePath),
+ image: image !== DEFAULT_OG_IMAGE ? image : undefined,
+ description: desc,
+ interactionStatistic: followers ? { '@type': 'InteractionCounter', interactionType: 'https://schema.org/FollowAction', userInteractionCount: followers } : undefined,
+ };
+ const profile = { ...seo.jsonLd.webPage({ type: 'ProfilePage', name: `${name} (${handle})`, url: abs(canonicalPath) }), mainEntity: person };
+ const ld = [profile];
+ if (vods.length) {
+ ld.push({
+ '@context': 'https://schema.org', '@type': 'ItemList', name: `${name}'s videos${page > 1 ? `, page ${page}` : ''}`,
+ itemListElement: vods.map((v, i) => ({ '@type': 'ListItem', position: offset + i + 1, url: abs(`/vod/${Number(v.id)}`), name: clean(v.title || 'Stream recording', 110) })),
+ });
+ }
+ ld.push(_breadcrumb([{ name: 'Home', url: '/' }, { name, url: basePath }, ...(page > 1 ? [{ name: `Videos, page ${page}`, url: canonicalPath }] : [])]));
+
+ // The page body, for crawlers and for anyone reading without JavaScript.
+ let body = `<article><h1>${esc(`${name} (${handle})`)}</h1>`;
+ const facts = [followers ? `${followers} follower${followers === 1 ? '' : 's'} on ${SITE_NAME}` : null];
+ if (live.length) facts.push(`Live now: ${clean(live[0].title || 'streaming', 100)}`);
+ if (facts.filter(Boolean).length) body += `<p class="byline">${esc(facts.filter(Boolean).join(' · '))}</p>`;
+ if (acct.banned) body += '<p>This account is suspended.</p>';
+ if (bio) body += `<p>${esc(bio)}</p>`;
+ if (page === 1 && ov && ov.overview && String(ov.overview).trim()) {
+ body += `<section><h2>AI overview</h2><p class="ai-disclosure">Written by OpenVibe's AI from ${esc(name)}'s past streams.</p><p>${esc(clean(ov.overview, 1200))}</p></section>`;
+ }
+ body += _itemSection(page > 1 ? `Videos, page ${page}` : 'Videos', vods.map((v) => ({
+ url: `/vod/${Number(v.id)}`, name: v.title || 'Stream recording',
+ meta: [_fmtDur(v.duration_seconds ?? v.duration), _day(v.created_at)].filter(Boolean).join(' · '),
+ })), vods.length || page > 1 ? null : (track.partial ? null : 'No videos yet.'));
+ if (vods.length || page > 1) body += _pager(basePath, page, pages, vods.length === CHANNEL_PAGE_SIZE, 'videos');
+ if (clips.length) body += _itemSection('Clips', clips.map((c) => ({ url: `/clip/${Number(c.id)}`, name: c.title || 'Clip', meta: [_clipperOf(c) ? `clipped by ${_clipperOf(c)}` : null, _fmtDur(c.duration_seconds ?? c.duration)].filter(Boolean).join(' · ') })));
+ if (aiClips.length) {
+ body += _itemSection('AI Moments', aiClips.map((c) => ({ url: `/clip/${Number(c.id)}`, name: c.title || 'AI clip', meta: ['AI clip', _fmtDur(c.duration_seconds ?? c.duration)].filter(Boolean).join(' · ') })),
+ null, `<p class="ai-disclosure">Clips OpenVibe's AI cut from ${esc(name)}'s streams when chat reacted. No one clipped these.</p>`);
+ }
+ body += `<p><a href="${esc(abs(basePath))}">Visit ${esc(name)}'s channel</a> on ${SITE_NAME}.</p></article>`;
+
+ return {
+ title: `${name} (${handle})${pageLabel} — ${SITE_NAME}`, description: desc,
+ canonicalPath, image, ogType: 'profile',
+ robots: acct.banned || pastEnd ? 'noindex,follow' : 'index,follow',
+ jsonLd: ld, snapshot: body,
+ cacheTtlMs: track.partial ? SHORT_CACHE_MS : CHANNEL_CACHE_MS,
+ };
+}
+
+/** "Page 2 of 5" with newer/older links (?page=N), the crawlable form of the channel's video list. */
+function _pager(basePath, page, pages, maybeMore, noun) {
+ const href = (n) => esc(abs(n > 1 ? `${basePath}?page=${n}` : basePath));
+ const parts = [];
+ if (page > 1) parts.push(`<a rel="prev" href="${href(page - 1)}">Newer ${noun}</a>`);
+ parts.push(`Page ${page}${pages ? ` of ${pages}` : ''}`);
+ if (pages ? page < pages : maybeMore) parts.push(`<a rel="next" href="${href(page + 1)}">Older ${noun}</a>`);
+ return `<nav class="pagination" aria-label="Pages">${parts.join(' · ')}</nav>`;
+}
+/** A heading and a list of links with their details; `empty` when there is nothing, `intro` above. */
+function _itemSection(heading, items, empty = null, intro = '') {
+ if (!items.length && !empty) return '';
+ const li = items.map((it) => `<li><a href="${esc(abs(it.url))}">${esc(clean(it.name, 120))}</a>${it.meta ? ` — ${esc(it.meta)}` : ''}</li>`).join('');
+ return `<section><h2>${esc(heading)}</h2>${intro}${items.length ? `<ul>${li}</ul>` : `<p>${esc(empty)}</p>`}</section>`;
+}
+const _day = (dt) => { const d = isoDate(dt); return d ? d.slice(0, 10) : null; };
+/** A person's clip's clipper, from Live's accounts (Media rows carry ids only). */
+function _clipperOf(c) { return c.display_name || c.username || _nameOf(_channelOwner(c.user_id)); }
+
+// The Arena: a real content page (mic-judged trash talk), so give it real metadata.
+function _arenaMeta() {
+ let fighters = [], moments = [];
+ try { fighters = (require('../arena/arena-service').loadRoster() || {}).order || []; } catch { fighters = []; }
+ try { moments = require('../arena/mic').feed({ limit: 10 }) || []; } catch { moments = []; }
+ const nameOf = (id) => { try { return require('../arena/mic').nameOf(id); } catch { return null; } };
+ const names = fighters.slice(0, 8).map(nameOf).filter(Boolean);
+ const title = 'The Arena — Mic-Judged Trash Talk Between Streamers';
+ const desc = clean(`Every callout streamers make on mic, judged and ranked. ${names.length ? 'Fighters right now: ' + names.slice(0, 5).join(', ') + '. ' : ''}Chat can hype a beef but never write one.`, 200);
+ const items = moments.slice(0, 10).map((mo) => ({ name: clean(mo.text || 'Mic moment', 110) }));
+ const snapshot = _detailSnapshot({
+ title: 'The Arena', byline: names.length ? `Fighters: ${names.join(', ')}` : null,
+ desc, overview: moments.slice(0, 8).map(mo => mo.text).filter(Boolean).join(' · ') || null,
+ transcript: null, canonicalPath: '/arena', watchLabel: 'Open the Arena',
+ });
+ return {
+ title: `${title} | ${SITE_NAME}`, description: desc, canonicalPath: '/arena',
+ image: DEFAULT_OG_IMAGE, ogType: 'website', robots: 'index,follow',
+ jsonLd: [
+ seo.jsonLd.webPage({ type: 'CollectionPage', name: title, url: abs('/arena'), description: desc }),
+ ...(items.length ? [seo.jsonLd.itemList('Latest mic moments', items)] : []),
+ _breadcrumb([{ name: 'Home', url: '/' }, { name: 'The Arena', url: '/arena' }]),
+ ],
+ snapshot,
+ };
+}
+
+// After-show report: /recap/:streamId
+// The AI-written report is an AI Moment: labelled, attributed to no person (the streamer did not
+// write it), noindex,follow and canonical to the stream's VOD when there is one. A template report
+// (stats only, no model) is not AI writing and keeps its own indexable page; it has no Person
+// author either, since the streamer did not write that one.
+async function _recapMeta(streamId) {
+ let r; try { r = require('../recap/recap').getRecap(streamId); } catch { r = null; }
+ if (!r) return null;
+ const ai = r.ai === true || Number(r.ai) === 1;
+ const name = r.streamer.display_name || r.streamer.username;
+ const title = clean(`${r.write.headline || r.stream.title}`, 90);
+ const desc = clean(ai
+ ? `AI-written after-show report on ${name}'s stream "${r.stream.title}". ${r.write.summary || ''}`
+ : (r.write.summary || `After-show report for ${name}'s stream "${r.stream.title}" on ${SITE_NAME}.`), 200);
+ const image = r.vod && r.vod.thumbnail_url ? media.publicUrl(r.vod.thumbnail_url) : (r.streamer.avatar_url ? abs(r.streamer.avatar_url) : DEFAULT_OG_IMAGE);
+ const selfPath = `/recap/${streamId}`;
+ const source = ai && r.vod ? await _sourceMoment(r.vod.id, 0) : null;
+ const article = {
+ ...seo.jsonLd.article({ headline: title, url: abs(selfPath), description: desc, image: [image], datePublished: isoDate(r.stream.ended_at) || undefined }),
+ keywords: ai ? AI_KEYWORDS : undefined,
+ isBasedOn: source ? abs(source.path) : undefined,
+ publisher: { '@type': 'Organization', name: SITE_NAME, url: baseUrl() },
+ };
+ const byline = `${ai ? 'AI recap · from' : 'Report on'} ${name}'s stream · ${Math.round((r.stream.duration_seconds || 0) / 60)} min · grade ${r.write.grade}`;
+ const extraHtml = (ai ? '<p class="ai-disclosure">Written automatically by OpenVibe\'s after-show report workflow from the stream\'s stats, chat and transcript. The streamer did not write it.</p>' : '')
+ + `<p class="source">${source ? `<a href="${esc(abs(source.path))}">Watch the stream</a> · ` : ''}<a href="${esc(abs(`/@${r.streamer.username}`))}">${esc(name)}'s channel</a></p>`;
+ const snapshot = _detailSnapshot({ title: `After-show report: ${r.stream.title}`, byline, desc, extraHtml, overview: r.write.summary, transcript: null, canonicalPath: selfPath, watchLabel: 'Read the report' });
+ return {
+ title: `${title} — ${ai ? 'AI recap of ' : ''}${name}'s stream${ai ? '' : ' report'} | ${SITE_NAME}`, description: desc,
+ canonicalPath: source ? source.path : selfPath, ogUrlPath: selfPath, image, ogType: 'article',
+ robots: ai ? AI_ROBOTS : 'index,follow',
+ jsonLd: [article, _breadcrumb([{ name: 'Home', url: '/' }, { name, url: `/@${r.streamer.username}` }, { name: ai ? 'AI recap' : 'Report', url: selfPath }])],
+ snapshot,
+ };
+}
+
+// A crawlable <section> listing media items with real detail (title, streamer, meta).
+function _mediaSection(heading, items) {
+ if (!items || !items.length) return '';
+ const li = items.map(it => {
+ const bits = [];
+ if (it.by) bits.push('by ' + esc(it.by));
+ if (it.meta) bits.push(esc(it.meta));
+ return `<li><a href="${esc(abs(it.url))}">${esc(clean(it.name, 120))}</a>${bits.length ? ' — ' + bits.join(' · ') : ''}${it.desc ? `<br><small>${esc(clean(it.desc, 160))}</small>` : ''}</li>`;
+ }).join('');
+ return `<section><h2>${esc(heading)}</h2><ul>${li}</ul></section>`;
+}
+// m:ss, or h:mm:ss from an hour up; '' for nothing.
+function _fmtDur(sec) {
+ sec = Math.floor(Number(sec) || 0);
+ if (!sec) return '';
+ const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;
+ const ss = String(s).padStart(2, '0');
+ return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`;
+}
+
+async function _homeMeta() {
+ // Going live from the browser (no OBS, no downloads, no follower minimum) leads: it is what people ask
+ // search engines and AI assistants for, and what they recommend OpenVibe.Live for (2026-09-28).
+ const title = 'OpenVibe.Live — Go Live From Your Browser, No OBS Needed';
+ const description = 'Go live instantly from your browser: no OBS, no downloads, no follower minimum. Open-source, community-run streaming with sub-second WebRTC, chat, VODs, clips and restreaming to Twitch, YouTube and Kick.';
+
+ // Pull the actual live content so the source has real, crawlable text.
+ let live = [];
+ try { live = (db.getLiveStreams() || []).slice(0, 12); } catch { /* */ }
+ // Each rail waits at most LIST_WAIT_MS: a slow upstream leaves its rail out, never the page.
+ // VODs, clips and pastes are counted by the services that hold them (Media, Community), not Live's frozen tables.
+ let homeStats = null;
+ try { homeStats = { ...db.getHomeStats() }; } catch { homeStats = null; }
+ const track = { partial: false };
+ const settle = async (promise) => { const r = await _within(promise, LIST_WAIT_MS); if (r === _TIMED_OUT) { track.partial = true; return null; } return r; };
+ let [vods, clips, pastes, stats] = await Promise.all([
+ settle(_vodList(12)), settle(_clipList(12)), settle(_pasteList(12)),
+ homeStats ? settle(require('../media-proxy/lookups').withArchiveStats(homeStats)) : null,
+ ]);
+ vods = vods || []; clips = clips || []; pastes = pastes || [];
+ const ownerName = (row, id) => row.display_name || row.username || _nameOf(_channelOwner(id));
+
+ const liveItems = live.map(s => ({ url: `/@${s.username}`, name: s.title || `${s.display_name || s.username} live`, by: s.display_name || s.username, meta: s.category || 'live' }));
+ const vodItems = vods.map(v => ({ url: `/vod/${v.id}`, name: v.title || 'VOD', by: ownerName(v, v.user_id), meta: _fmtDur(v.duration_seconds || v.duration), desc: v.ai_overview_short }));
+ const clipItems = clips.map(c => ({ url: `/clip/${c.id}`, name: c.title || 'Clip', by: ownerName(c, c.user_id), meta: _fmtDur(c.duration_seconds || c.duration) }));
+ const pasteItems = pastes.map(x => ({ url: pasteHref(x.slug), name: x.title || 'Paste', by: x.username || 'anon', meta: x.type === 'screenshot' ? 'image' : (x.language || 'text') }));
+
+ const statLine = stats ? `<p>${SITE_NAME} hosts ${stats.streamers || 0} streamers, ${stats.vods || 0} VODs, ${stats.clips || 0} clips, ${stats.pastes || 0} pastes and ${stats.chatMessages || 0} chat messages.</p>` : '';
+ const snapshot =
+ `<h1>${SITE_NAME} — go live from your browser, no OBS needed</h1>` +
+ `<p>${esc(description)}</p>` +
+ '<section><h2>Go live in your browser</h2><ol>' +
+ '<li>Open openvibe.live in Chrome, Edge, Firefox or Safari — on a Chromebook, laptop or phone.</li>' +
+ '<li>Sign in or make an account; your channel exists right away.</li>' +
+ '<li>Press Go Live and allow your camera and microphone.</li>' +
+ '<li>Add a title and start: you are live with your own channel and chat.</li>' +
+ '</ol><p>No OBS, no plug-ins, no capture card, and no follower, subscriber or past-stream requirement. ' +
+ `<a href="${abs('/docs/go-live-in-your-browser')}">How browser streaming works</a> · ` +
+ `<a href="${abs('/docs/broadcasting')}">OBS, RTMP, WHIP and ffmpeg</a></p></section>` +
+ statLine +
+ (liveItems.length ? _mediaSection('Live now', liveItems) : '<section><h2>Live now</h2><p>No one is streaming right now — be the first to go live.</p></section>') +
+ _mediaSection('Recent VODs', vodItems) +
+ _mediaSection('Recent clips', clipItems) +
+ _mediaSection('Recent pastes', pasteItems);
+
+ // Rich JSON-LD: WebSite + one ItemList per section of the snapshot, named by that section's visible
+ // heading (structured data names what a visitor sees; the browser check compares them).
+ const itemList = (name, items) => seo.jsonLd.itemList(name, items.slice(0, 10).map((it) => ({ url: abs(it.url), name: clean(it.name, 110) })));
+ const lists = [['Live now', liveItems], ['Recent VODs', vodItems], ['Recent clips', clipItems]].filter(([, items]) => items.length).map(([n, items]) => itemList(n, items));
+ const jsonLd = [
+ seo.jsonLd.website({ name: SITE_NAME, url: baseUrl(), description: clean(description, 300) }),
+ {
+ ...seo.jsonLd.softwareApp({ name: SITE_NAME, url: baseUrl(), category: 'MultimediaApplication',
+ description: 'Live streaming you start from a web browser: no OBS, no downloads and no follower minimum.' }),
+ browserRequirements: 'A current Chrome, Edge, Firefox or Safari with camera and microphone access',
+ featureList: [
+ 'Go live from the browser with no OBS or other software (WebRTC, under one second of delay)',
+ 'No follower, subscriber or past-stream requirement to go live',
+ 'Works on Chromebooks, laptops and phones',
+ 'Screen, window and tab sharing with a camera picture-in-picture',
+ 'OBS/RTMP, WHIP and ffmpeg ingest for produced shows',
+ 'Live chat, VODs, clips and restreaming to Twitch, YouTube and Kick',
+ 'Open source and community run',
+ ],
+ },
+ // The OpenVibe organisation every site names (openvibe-shared/seo); sameAs is how a search
+ // engine learns these properties are one brand rather than unrelated sites sharing a name.
+ seo.jsonLd.organization({
+ description: 'An open-source network of live streaming, media and developer tools, built and run by the people who use it.',
+ sameAs: ['https://github.com/OpenVibers', 'https://discord.gg/M6MuRUaeJj', 'https://openvibe.tools', 'https://openvibe.network', baseUrl()],
+ }),
+ ...lists,
+ ];
+ return { title, description, canonicalPath: '/', image: DEFAULT_OG_IMAGE, ogType: 'website', robots: 'index,follow', jsonLd, snapshot, cacheTtlMs: track.partial ? SHORT_CACHE_MS : undefined };
+}
+
+/**
+ * A list page. `itemsFn` gives an array, or { items, more } for a paged list (page N of it: ?page=N,
+ * self-canonical, with newer/older links; a page past the end is not rendered here).
+ */
+async function _listMeta(slug, label, description, itemsFn, { page = 1 } = {}) {
+ let items = [], more = null;
+ try {
+ const got = await itemsFn();
+ if (Array.isArray(got)) items = got.slice(0, 24);
+ else if (got) { items = (got.items || []).slice(0, 24); more = Boolean(got.more); }
+ } catch { /* */ }
+ if (page > 1 && !items.length) return null;
+ const basePath = '/' + slug;
+ const selfPath = page > 1 ? `${basePath}?page=${page}` : basePath;
+ const pageLabel = page > 1 ? `, page ${page}` : '';
+ const list = {
+ ...seo.jsonLd.webPage({ type: 'CollectionPage', name: `${label}${pageLabel} — ${SITE_NAME}`, url: abs(selfPath), description }),
+ mainEntity: {
+ '@type': 'ItemList',
+ itemListElement: items.map((it, i) => ({ '@type': 'ListItem', position: (page - 1) * 24 + i + 1, url: abs(it.url), name: clean(it.name, 110) })),
+ },
+ };
+ let snapshot = `<h1>${esc(label)} on ${SITE_NAME}${page > 1 ? ` — page ${page}` : ''}</h1><p>${esc(description)}</p>` +
+ _mediaSection(page > 1 ? `${label}, page ${page}` : `Latest ${label.toLowerCase()}`, items);
+ if (more !== null && (page > 1 || more)) snapshot += _pager(basePath, page, null, more, label.toLowerCase());
+ return {
+ title: `${label}${pageLabel} — ${SITE_NAME}`, description, canonicalPath: selfPath,
+ image: DEFAULT_OG_IMAGE, ogType: 'website', robots: 'index,follow',
+ jsonLd: [list, _breadcrumb([{ name: 'Home', url: '/' }, { name: label, url: basePath }, ...(page > 1 ? [{ name: `Page ${page}`, url: selfPath }] : [])])],
+ snapshot,
+ };
+}
+
+function _authorLd(name) { return name ? { '@type': 'Person', name: clean(name, 80) } : undefined; }
+
+// ── AI Moments (roadmap §33.4, §33.8) ───────────────────────────────────────────────────────
+// What the AI made from a stream (auto-clips, AI moment pastes, AI after-show recaps) is labelled
+// as AI-made, attributed to no person, kept out of search (noindex,follow: the page still passes
+// its links on) and made canonical to its source: the VOD at the moment it came from, when that
+// VOD is known and public. The store decides what is AI (Media's auto_generated, Community's
+// origin, the recap's own ai flag); nothing is inferred here. People's clips and pastes keep their
+// normal, indexable pages.
+const AI_ROBOTS = 'noindex,follow';
+const AI_KEYWORDS = 'AI-generated';
+const VOD_MOMENT_RE = /^\/vod\/(\d+)(?:\?t=(\d+(?:\.\d+)?))?$/;
+
+const isAiClip = (c) => !!c && (c.auto_generated === true || Number(c.auto_generated) === 1);
+const isAiPaste = (p) => !!p && p.origin === 'ai';
+function _json(v) {
+ if (!v) return null;
+ if (typeof v === 'object') return v;
+ try { const o = JSON.parse(v); return o && typeof o === 'object' ? o : null; } catch { return null; }
+}
+/** The Live account a stream-derived item belongs to (the channel, never a viewer). */
+function _channelOwner(userId) {
+ if (userId == null || userId === '') return null;
+ try { return db.getUserById(Number(userId)) || null; } catch { return null; }
+}
+const _nameOf = (u) => (u ? (u.display_name || u.username) : null);
+
+/**
+ * `/vod/<id>?t=<s>` for the moment an item came from, or null when the VOD is unknown, gone or
+ * not public (a canonical must never point at a page that answers 404 to the crawler).
+ */
+async function _sourceMoment(vodId, seconds) {
+ if (vodId == null || !/^\d+$/.test(String(vodId))) return null;
+ const v = await _vodGet(Number(vodId));
+ if (!v || !_pub(v)) return null;
+ const t = Math.max(0, Math.floor(Number(seconds) || 0));
+ return { path: `/vod/${Number(vodId)}${t ? `?t=${t}` : ''}`, vod: v };
+}
+
+async function _vodMeta(id) {
+ const v = await _vodGet(id);
+ // Private: fall through exactly like an unknown id. This HTML is cached per id and shared by
+ // every visitor, so it never carries a private VOD's title, overview or transcript.
+ if (!v || isPrivate(v)) return null;
+ if (v.duration_seconds == null && v.duration != null) v.duration_seconds = v.duration;
+ _overlayAiState(v, 'vod');
+ const indexable = Number(v.is_public) === 1 && (!v.visibility || v.visibility === 'public');
+ const owner = _channelOwner(v.user_id);
+ const author = v.display_name || v.username || _nameOf(owner);
+ const title = clean(v.title || `${author ? author + "'s " : ''}stream VOD`, 80);
+ const desc = clean(v.ai_overview_short || v.ai_overview || v.description || `Recorded live stream${author ? ' by ' + author : ''} on ${SITE_NAME}.`, 200);
+ const image = v.thumbnail_url ? media.publicUrl(v.thumbnail_url) : media.thumbUrl(`vod-${id}`);
+ const canonicalPath = `/vod/${id}`;
+
+ // The clips of this VOD: people's, then the AI's. Every one is also a moment of this video
+ // (schema.org Clip in hasPart), so the source carries its moments (roadmap 33.8).
+ const track = { partial: false };
+ const cr = await _listWithin(`vc:${id}`, () => media.listClips({ vod_id: id, limit: 30 }, { timeoutMs: 5000 }), track);
+ const all = ((cr && cr.clips) || []).filter((c) => _pub(c) && (!c.status || c.status === 'ready'));
+ const people = all.filter((c) => !isAiClip(c));
+ const ai = all.filter(isAiClip);
+ const at = (c) => Math.max(0, Math.floor(Number(c.start_time) || 0));
+ const parts = all.filter((c) => Number.isFinite(Number(c.start_time))).slice(0, 20).map((c) => ({
+ '@type': 'Clip', name: clean(c.title || (isAiClip(c) ? 'AI clip' : 'Clip'), 110),
+ startOffset: at(c), endOffset: Number.isFinite(Number(c.end_time)) ? Math.max(at(c) + 1, Math.ceil(Number(c.end_time))) : undefined,
+ url: abs(`/vod/${id}?t=${at(c)}`),
+ }));
+ const vo = {
+ ...seo.jsonLd.video({
+ name: title, url: abs(canonicalPath), description: desc, thumbnailUrl: [image],
+ uploadDate: isoDate(v.created_at) || undefined, duration: iso8601Duration(v.duration_seconds),
+ contentUrl: abs(canonicalPath), embedUrl: abs(canonicalPath),
+ }),
+ interactionStatistic: { '@type': 'InteractionCounter', interactionType: 'https://schema.org/WatchAction', userInteractionCount: Number(v.view_count) || 0 },
+ author: _authorLd(author), publisher: { '@type': 'Organization', name: SITE_NAME, url: baseUrl() },
+ hasPart: parts.length ? parts : undefined,
+ };
+ const moment = (c) => `/vod/${id}?t=${at(c)}`;
+ let extraHtml = owner ? `<p class="source"><a href="${esc(abs(`/@${owner.username}`))}">${esc(author || owner.username)}'s channel</a></p>` : '';
+ extraHtml += _itemSection('Clips from this stream', people.map((c) => ({ url: `/clip/${Number(c.id)}`, name: c.title || 'Clip', meta: [_clipperOf(c) ? `clipped by ${_clipperOf(c)}` : null, `at ${_fmtDur(at(c)) || '0:00'}`].filter(Boolean).join(' · ') })));
+ if (ai.length) {
+ extraHtml += _itemSection('AI Moments from this stream', ai.map((c) => ({ url: `/clip/${Number(c.id)}`, name: c.title || 'AI clip', meta: `AI clip · at ${_fmtDur(at(c)) || '0:00'}` })),
+ null, '<p class="ai-disclosure">Cut automatically by OpenVibe\'s auto-clip workflow. No one clipped these.</p>');
+ extraHtml += `<p>${ai.slice(0, 6).map((c) => `<a href="${esc(abs(moment(c)))}">Jump to ${esc(_fmtDur(at(c)) || '0:00')}</a>`).join(' · ')}</p>`;
+ }
+ const snapshot = _detailSnapshot({
+ title, byline: author ? `Streamed by ${author}` : null, desc, extraHtml,
+ overview: v.ai_overview, transcript: v.ai_transcript, canonicalPath, watchLabel: 'Watch this VOD',
+ });
+ return {
+ title: `${title}${author ? ' — ' + author : ''} | ${SITE_NAME}`, description: desc,
+ canonicalPath, image, ogType: 'video.other', robots: indexable ? 'index,follow' : 'noindex,follow',
+ video: { duration: Math.floor(Number(v.duration_seconds) || 0) },
+ jsonLd: [vo, _breadcrumb([{ name: 'Home', url: '/' }, ...(owner ? [{ name: author || owner.username, url: `/@${owner.username}` }] : [{ name: 'VODs', url: '/vods' }]), { name: title, url: canonicalPath }])],
+ snapshot,
+ cacheTtlMs: track.partial ? SHORT_CACHE_MS : undefined,
+ };
+}
+
+async function _clipMeta(id) {
+ const c = await _clipGet(id);
+ if (!c || isPrivate(c)) return null; // private: same as unknown (see _vodMeta)
+ if (c.duration_seconds == null && c.duration != null) c.duration_seconds = c.duration;
+ _overlayAiState(c, 'clip');
+ if (isAiClip(c)) return _aiClipMeta(c, id);
+ const indexable = Number(c.is_public) === 1 && (!c.visibility || c.visibility === 'public');
+ // Media stores ids only: the clipper's name comes from Live's accounts.
+ const creator = c.display_name || c.username || _nameOf(_channelOwner(c.user_id));
+ const title = clean(c.title || 'Clip', 80);
+ const desc = clean(c.ai_overview_short || c.ai_overview || c.description || `A clip from a live stream on ${SITE_NAME}${creator ? ', clipped by ' + creator : ''}.`, 200);
+ const image = c.thumbnail_url ? media.publicUrl(c.thumbnail_url) : media.thumbUrl(`clip-${id}`);
+ const canonicalPath = `/clip/${id}`;
+ const vo = {
+ ...seo.jsonLd.video({
+ name: title, url: abs(canonicalPath), description: desc, thumbnailUrl: [image],
+ uploadDate: isoDate(c.created_at) || undefined, duration: iso8601Duration(c.duration_seconds),
+ contentUrl: abs(canonicalPath), embedUrl: abs(canonicalPath),
+ }),
+ interactionStatistic: { '@type': 'InteractionCounter', interactionType: 'https://schema.org/WatchAction', userInteractionCount: Number(c.view_count) || 0 },
+ author: _authorLd(creator), publisher: { '@type': 'Organization', name: SITE_NAME, url: baseUrl() },
+ };
+ const channel = _channelOwner(c.channel_user_id);
+ const source = await _sourceMoment(c.vod_id, c.start_time);
+ const sourceHtml = (source || channel) ? `<p class="source">${source ? `<a href="${esc(abs(source.path))}">Watch it in the full stream</a>` : ''}${source && channel ? ' · ' : ''}${channel ? `<a href="${esc(abs(`/@${channel.username}`))}">${esc(_nameOf(channel))}'s channel</a>` : ''}</p>` : '';
+ const snapshot = _detailSnapshot({
+ title, byline: creator ? `Clipped by ${creator}${channel && channel.id !== Number(c.user_id) ? ` from ${_nameOf(channel)}'s stream` : ''}` : null, desc,
+ extraHtml: sourceHtml,
+ overview: c.ai_overview, transcript: c.ai_transcript, canonicalPath, watchLabel: 'Watch this clip',
+ });
+ return {
+ title: `${title} — clip | ${SITE_NAME}`, description: desc,
+ canonicalPath, image, ogType: 'video.other', robots: indexable ? 'index,follow' : 'noindex,follow',
+ video: { duration: Math.floor(Number(c.duration_seconds) || 0) },
+ jsonLd: [vo, _breadcrumb([{ name: 'Home', url: '/' }, ...(channel ? [{ name: _nameOf(channel), url: `/@${channel.username}` }] : [{ name: 'Clips', url: '/clips' }]), { name: title, url: canonicalPath }])],
+ snapshot,
+ };
+}
+
+/** An auto-clip: "AI clip · from <streamer>'s stream", canonical to the VOD at the moment. */
+async function _aiClipMeta(c, id) {
+ const owner = _channelOwner(c.channel_user_id != null ? c.channel_user_id : c.user_id);
+ const streamer = _nameOf(owner);
+ const from = streamer ? `${streamer}'s stream` : 'a live stream';
+ const title = clean(c.title || 'AI clip', 80);
+ const source = await _sourceMoment(c.vod_id, c.start_time);
+ const desc = clean(`AI-generated clip from ${from} on ${SITE_NAME}. ${c.ai_overview_short || c.ai_overview || c.description || ''}`, 200);
+ const image = c.thumbnail_url ? media.publicUrl(c.thumbnail_url) : media.thumbUrl(`clip-${id}`);
+ const selfPath = `/clip/${id}`;
+ const vo = {
+ ...seo.jsonLd.video({
+ name: title, url: abs(selfPath), description: desc, thumbnailUrl: [image],
+ uploadDate: isoDate(c.created_at) || undefined, duration: iso8601Duration(c.duration_seconds),
+ contentUrl: abs(selfPath), embedUrl: abs(selfPath),
+ }),
+ // No author or creator: a workflow cut this, not a person.
+ keywords: AI_KEYWORDS,
+ isBasedOn: source ? abs(source.path) : undefined,
+ publisher: { '@type': 'Organization', name: SITE_NAME, url: baseUrl() },
+ };
+ const sourceLink = source
+ ? `<p class="source"><a href="${esc(abs(source.path))}">Watch this moment in the full stream</a>${owner ? ` · <a href="${esc(abs(`/@${owner.username}`))}">${esc(streamer)}'s channel</a>` : ''}</p>`
+ : (owner ? `<p class="source"><a href="${esc(abs(`/@${owner.username}`))}">${esc(streamer)}'s channel</a></p>` : '');
+ const snapshot = _detailSnapshot({
+ title, byline: `AI clip · from ${from}`, desc,
+ extraHtml: `<p class="ai-disclosure">Cut automatically by OpenVibe's auto-clip workflow when chat reacted. No person clipped it.</p>${sourceLink}`,
+ overview: c.ai_overview, transcript: c.ai_transcript, canonicalPath: selfPath, watchLabel: 'Watch this AI clip',
+ });
+ return {
+ title: `${title} — AI clip from ${from} | ${SITE_NAME}`, description: desc,
+ canonicalPath: source ? source.path : selfPath, ogUrlPath: selfPath, image, ogType: 'video.other', robots: AI_ROBOTS,
+ video: { duration: Math.floor(Number(c.duration_seconds) || 0) },
+ jsonLd: [vo, _breadcrumb([{ name: 'Home', url: '/' }, { name: 'AI Moments', url: '/moments' }, { name: title, url: selfPath }])],
+ snapshot,
+ };
+}
+
+async function _pasteMeta(slug) {
+ const p = await _pasteGet(slug);
+ if (!p) return null;
+ if (isAiPaste(p)) return _aiPasteMeta(p, slug);
+ const isScreenshot = p.type === 'screenshot';
+ // Only public, non-NSFW, non-burn pastes are indexable.
+ const indexable = (p.visibility === 'public' || p.visibility == null) && !Number(p.is_nsfw) && !Number(p.burn_after_read);
+ const author = p.display_name || p.username;
+ const title = clean(p.title || (isScreenshot ? 'Screenshot' : 'Paste'), 80);
+ const desc = clean(p.ai_summary || (isScreenshot ? `A screenshot shared on ${SITE_NAME}.` : String(p.content || '').slice(0, 220)) || `A paste on ${SITE_NAME}.`, 200);
+ let image = DEFAULT_OG_IMAGE;
+ if (isScreenshot && p.screenshot_url) image = media.publicUrl(p.screenshot_url);
+ else if (isScreenshot && p.screenshot_path) image = media.screenshotUrl(path.basename(p.screenshot_path));
+ const canonicalPath = `/p/${slug}`;
+ let tags = [];
+ try { tags = p.ai_tags ? JSON.parse(p.ai_tags) : []; } catch { tags = []; }
+ const ld = isScreenshot
+ ? { '@context': 'https://schema.org', '@type': 'ImageObject', name: title, description: desc, contentUrl: image, uploadDate: isoDate(p.created_at) || undefined, author: _authorLd(author) }
+ : { '@context': 'https://schema.org', '@type': 'SoftwareSourceCode', name: title, description: desc, programmingLanguage: p.language && p.language !== 'text' ? p.language : undefined, dateCreated: isoDate(p.created_at) || undefined, author: _authorLd(author), keywords: (Array.isArray(tags) && tags.length) ? tags.join(', ') : undefined };
+ const snapshot = _detailSnapshot({
+ title, byline: author ? `Shared by ${author}` : null, desc,
+ overview: p.ai_summary, transcript: (!isScreenshot ? String(p.content || '').slice(0, 1200) : null),
+ canonicalPath, watchLabel: 'View this paste',
+ });
+ return {
+ title: `${title} — paste | ${SITE_NAME}`, description: desc,
+ canonicalPath, image, ogType: isScreenshot ? 'article' : 'article', robots: indexable ? 'index,follow' : 'noindex,follow',
+ jsonLd: [ld, _breadcrumb([{ name: 'Home', url: '/' }, { name: 'Pastes', url: '/pastes' }, { name: title, url: canonicalPath }])],
+ snapshot,
+ };
+}
+
+/** The VOD recorded from a stream (the longest public one), for items that know only their stream. */
+const _vodOfStream = (streamId) => _cached(`vs:${streamId}`, async () => {
+ const r = await media.listVods({ stream_id: streamId, limit: 3 });
+ const rows = (r && r.vods) || [];
+ return rows.filter((v) => v && !isPrivate(v) && Number(v.is_public) !== 0)
+ .sort((a, b) => (Number(b.duration_seconds ?? b.duration) || 0) - (Number(a.duration_seconds ?? a.duration) || 0))[0] || null;
+});
+
+/**
+ * An AI moment paste (a frame the AI picked from a VOD, or one it "caught live"):
+ * "AI note · from <stream>", canonical to the VOD at that second when the VOD is known.
+ */
+async function _aiPasteMeta(p, slug) {
+ const meta = _json(p.metadata) || {};
+ const streamId = p.stream_id || meta.stream_id || null;
+ let stream = null;
+ try { stream = streamId ? db.getStreamById(Number(streamId)) : null; } catch { stream = null; }
+ const owner = stream ? _channelOwner(stream.user_id) : (meta.username ? (() => { try { return db.getUserByUsername(String(meta.username)); } catch { return null; } })() : null);
+ const streamer = _nameOf(owner);
+ const streamTitle = stream && stream.title ? clean(stream.title, 90) : null;
+ const from = streamTitle ? `"${streamTitle}"` : (streamer ? `${streamer}'s stream` : 'a live stream');
+ // The source: the VOD link the job recorded, else the VOD of the stream it was caught on.
+ let source = null;
+ const link = typeof meta.vod_link === 'string' ? meta.vod_link.match(VOD_MOMENT_RE) : null;
+ if (link) source = await _sourceMoment(link[1], link[2]);
+ else if (streamId) {
+ let v = null;
+ try { v = await _vodOfStream(Number(streamId)); } catch { v = null; }
+ if (v) source = await _sourceMoment(v.id, meta.offset);
+ }
+ const isScreenshot = p.type === 'screenshot';
+ const title = clean(p.title || 'AI moment', 80);
+ const desc = clean(`AI-generated note from ${from} on ${SITE_NAME}. ${p.ai_summary || ''}`, 200);
+ let image = DEFAULT_OG_IMAGE;
+ if (isScreenshot && p.screenshot_url) image = media.publicUrl(p.screenshot_url);
+ const selfPath = `/p/${slug}`;
+ const ld = {
+ '@context': 'https://schema.org', '@type': isScreenshot ? 'ImageObject' : 'CreativeWork',
+ name: title, description: desc, contentUrl: isScreenshot ? image : undefined,
+ dateCreated: isoDate(p.created_at) || undefined,
+ // No author: the AI wrote it, and it is not a person.
+ keywords: AI_KEYWORDS,
+ isBasedOn: source ? abs(source.path) : undefined,
+ publisher: { '@type': 'Organization', name: SITE_NAME, url: baseUrl() },
+ };
+ const at = meta.offset != null && Number.isFinite(Number(meta.offset)) ? ` at ${_fmtDur(Number(meta.offset)) || '0:00'}` : '';
+ const sourceLink = source
+ ? `<p class="source"><a href="${esc(abs(source.path))}">Watch this moment in the full stream${esc(at)}</a>${owner ? ` · <a href="${esc(abs(`/@${owner.username}`))}">${esc(streamer)}'s channel</a>` : ''}</p>`
+ : (owner ? `<p class="source"><a href="${esc(abs(`/@${owner.username}`))}">${esc(streamer)}'s channel</a></p>` : '');
+ const snapshot = _detailSnapshot({
+ title, byline: `AI note · from ${from}${at}`, desc,
+ extraHtml: `<p class="ai-disclosure">Written automatically by OpenVibe's AI moments workflow${meta.live ? ' while the stream was live' : ' from the recording'}. No person wrote it.</p>${sourceLink}`,
+ overview: null, transcript: null, canonicalPath: selfPath, watchLabel: 'View this AI note',
+ });
+ return {
+ title: `${title} — AI note from ${from} | ${SITE_NAME}`, description: desc,
+ canonicalPath: source ? source.path : selfPath, ogUrlPath: selfPath, image, ogType: 'article', robots: AI_ROBOTS,
+ jsonLd: [ld, _breadcrumb([{ name: 'Home', url: '/' }, { name: 'AI Moments', url: '/moments' }, { name: title, url: selfPath }])],
+ snapshot,
+ };
+}
+
+function _breadcrumb(items) {
+ return seo.jsonLd.breadcrumbs(items.map((it) => ({ name: clean(it.name, 90), url: abs(it.url) })));
+}
+// `extraHtml` is markup the caller built (and escaped) itself.
+function _detailSnapshot({ title, byline, desc, extraHtml, overview, transcript, canonicalPath, watchLabel }) {
+ let html = `<article><h1>${esc(title)}</h1>`;
+ if (byline) html += `<p class="byline">${esc(byline)}</p>`;
+ if (desc) html += `<p>${esc(desc)}</p>`;
+ if (extraHtml) html += extraHtml;
+ if (overview && String(overview).trim()) html += `<section><h2>AI overview</h2><p>${esc(clean(overview, 1200))}</p></section>`;
+ if (transcript && String(transcript).trim()) html += `<section><h2>Transcript</h2><p>${esc(clean(transcript, 1500))}</p></section>`;
+ html += `<p><a href="${esc(abs(canonicalPath))}">${esc(watchLabel || 'Open')}</a> on ${SITE_NAME}.</p></article>`;
+ return html;
+}
+
+// ── Inject metadata into the base index.html ────────────────────────────────────────────────
+// The base document comes from server/web/assets.js: index.html with its asset URLs rewritten to
+// content hashes, re-read when index.html or any asset it references changes (checked at most
+// every 2s). A change that only touches public/ therefore goes live with a git pull and no
+// restart — and a restart drops every live RTMP stream, because RTMP ingest is owned by this
+// process (socket activation only covers HTTP).
+const assets = require('../web/assets');
+let _baseVersion = null;
+function _base() {
+ let doc = null;
+ try { doc = assets.document('index.html'); } catch { doc = null; }
+ if (!doc) return '';
+ if (doc.version !== _baseVersion) {
+ _baseVersion = doc.version;
+ // Pages rendered from the old document reference old asset versions.
+ if (typeof _cache !== 'undefined') _cache.clear();
+ }
+ return doc.html;
+}
+
+function _headBlock(meta) {
+ const canonical = abs(meta.canonicalPath || '/');
+ let tags = seo.headTags({
+ title: meta.title, description: meta.description, canonical,
+ // One English page per URL (the SPA translates in place): the page is its own en and x-default.
+ alternates: [{ hreflang: 'en', href: canonical }, { hreflang: 'x-default', href: canonical }],
+ image: abs(meta.image || DEFAULT_OG_IMAGE), type: meta.ogType || 'website', siteName: SITE_NAME,
+ robots: meta.robots || 'index,follow', jsonLd: meta.jsonLd || [],
+ });
+ // og:url is the page itself: a shared AI clip previews as that clip, while its canonical names the source.
+ if (meta.ogUrlPath) tags = tags.replace(`<meta property="og:url" content="${esc(canonical)}">`, `<meta property="og:url" content="${esc(abs(meta.ogUrlPath))}">`);
+ const parts = [PASTES_META, ...tags.split('\n')];
+ if (meta.video && meta.video.duration) {
+ parts.push(`<meta property="og:video:duration" content="${meta.video.duration}">`);
+ parts.push(`<meta property="video:duration" content="${meta.video.duration}">`);
+ }
+ if (meta.snapshot) parts.push(NOJS_STYLE);
+ return '\n' + parts.map(x => ' ' + x).join('\n') + '\n';
+}
+
+// The page body below is visually hidden while the SPA boots (it removes it, public/js/app.js).
+// Without JavaScript nothing boots, so the body is shown instead of the empty app shell.
+const PRERENDER_STYLE = 'position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:normal;border:0;';
+const NOJS_STYLE = '<noscript><style>#seo-prerender{position:static!important;width:auto!important;height:auto!important;margin:0 auto!important;padding:16px!important;overflow:visible!important;clip:auto!important;max-width:960px;line-height:1.5}#seo-prerender nav{margin:8px 0 16px}#app{display:none!important}</style></noscript>';
+// The site's own links, for a reader without JavaScript (the navbar is built by a script).
+const NOJS_NAV = `<nav aria-label="${SITE_NAME}"><a href="/">${SITE_NAME}</a> · <a href="/content">Content</a> · <a href="/moments">AI Moments</a> · <a href="/chat">Chat</a> · <a href="/documentation">API docs</a></nav>`;
+function _prerender(html, snapshot) {
+ return html.replace(/(<body[^>]*>)/i, `$1\n<div id="seo-prerender" style="${PRERENDER_STYLE}">${NOJS_NAV}${snapshot}</div>`);
+}
+// The page's one-paragraph summary (openvibe-shared/seo pageSummary): a hidden, machine-readable
+// <section data-ov-summary> after the snapshot, the same on every OpenVibe site.
+function _summary(meta) {
+ return seo.pageSummary({ title: meta.title, summary: meta.description, url: abs(meta.ogUrlPath || meta.canonicalPath || '/') }).html;
+}
+function render(meta, urlPath) {
+ let html = _base();
+ if (!html) return null;
+ html = assets.renderRoute(html, urlPath || meta.canonicalPath || '/');
+
+ // Only the <head> is rewritten, so only the <head> is scanned.
+ //
+ // These six patterns used to run over the whole document. index.html is ~378KB and the head
+ // is the first ~8.5KB of it, so five /ig regexes were scanning 370KB of page markup that
+ // cannot contain a <title> or an og: tag — measured at 60ms of blocking CPU per uncached
+ // render, against 0.73ms for the head alone. That cost lands on every cache miss, and the
+ // sitemap advertises far more URLs than the 500-entry cache holds, so a crawler walking it
+ // misses every time.
+ const headEnd = html.search(/<\/head>/i);
+ let head = headEnd === -1 ? html : html.slice(0, headEnd);
+ const rest = headEnd === -1 ? '' : html.slice(headEnd);
+
+ // Strip the hardcoded homepage tags we're replacing (title, description, canonical, all
+ // og:/twitter:, robots) so there are no duplicates.
+ head = head
+ .replace(/\s*<title>[\s\S]*?<\/title>/i, '')
+ .replace(/\s*<meta\s+name=["']description["'][^>]*>/ig, '')
+ .replace(/\s*<meta\s+name=["']robots["'][^>]*>/ig, '')
+ .replace(/\s*<link\s+rel=["']canonical["'][^>]*>/ig, '')
+ .replace(/\s*<meta\s+property=["']og:[^"']*["'][^>]*>/ig, '')
+ .replace(/\s*<meta\s+name=["']twitter:[^"']*["'][^>]*>/ig, '');
+
+ // Insert the fresh head block just before </head>.
+ html = headEnd === -1
+ ? head.replace(/<\/head>/i, _headBlock(meta) + '</head>')
+ : head + _headBlock(meta) + rest;
+ // Server-rendered crawlable content, right after <body>. It's REAL content (so AI text
+ // extractors read it — unlike <noscript>, which many strip), but visually-hidden so users
+ // never see a flash, and the SPA removes #seo-prerender on boot (see app.js). Not cloaking:
+ // it summarises the same content the SPA renders.
+ if (meta.snapshot) html = _prerender(html, meta.snapshot + _summary(meta));
+ return html;
+}
+
+/**
+ * The SPA shell the fallback sends (server/index.js, server/web/page-status.js) for a page this
+ * renderer did not answer. The shell's own head describes the home page, and a page that is not
+ * the home page must not claim to be it:
+ * 404 → "Page not found", robots noindex, no canonical, no og:url, no structured data, and a
+ * not-found body for readers without JavaScript;
+ * 200 → the home canonical and og:url dropped (a channel slot, /@user/<slot>, names /@user).
+ */
+function shellHtml(urlPath, status) {
+ let html = _base();
+ if (!html) return null;
+ const p = String(urlPath || '/');
+ html = assets.renderRoute(html, p).replace(/<\/head>/i, `${PASTES_META}</head>`);
+ if (p === '/' && status !== 404) return html;
+ const headEnd = html.search(/<\/head>/i);
+ if (headEnd === -1) return html;
+ let head = html.slice(0, headEnd);
+ const rest = html.slice(headEnd);
+ head = head
+ .replace(/\s*<link\s+rel=["']canonical["'][^>]*>/ig, '')
+ .replace(/\s*<meta\s+property=["']og:url["'][^>]*>/ig, '')
+ .replace(/\s*<meta\s+name=["']twitter:url["'][^>]*>/ig, '');
+ if (status === 404) {
+ head = head
+ .replace(/\s*<title>[\s\S]*?<\/title>/i, '')
+ .replace(/\s*<meta\s+name=["']description["'][^>]*>/ig, '')
+ .replace(/\s*<meta\s+name=["']robots["'][^>]*>/ig, '')
+ .replace(/\s*<meta\s+property=["']og:[^"']*["'][^>]*>/ig, '')
+ .replace(/\s*<meta\s+name=["']twitter:[^"']*["'][^>]*>/ig, '')
+ .replace(/\s*<script type="application\/ld\+json">[\s\S]*?<\/script>/ig, '');
+ head += `\n <title>Page not found — ${SITE_NAME}</title>\n <meta name="description" content="There is nothing at this address on ${SITE_NAME}.">\n <meta name="robots" content="noindex">\n ${NOJS_STYLE}\n`;
+ return _prerender(head + rest, `<h1>Page not found</h1><p>There is nothing at this address on ${SITE_NAME}. It may have been deleted or made private, or the link may be mistyped.</p><p><a href="/">Go to the home page</a></p>`);
+ }
+ const slot = p.match(/^\/@([A-Za-z0-9_]{3,24})\/[^/]+\/?$/);
+ if (slot) {
+ let user = null;
+ try { user = db.getUserByUsername(slot[1]); } catch { user = null; }
+ if (user) head += `\n <link rel="canonical" href="${esc(abs(`/@${user.username}`))}">\n`;
+ }
+ return head + rest;
+}
+
+// ── Cache (rendered HTML per path, short TTL) ───────────────────────────────────────────────
+const _cache = new Map(); // path (with ?page=N) -> { html, at, ttl }
+const CACHE_TTL_MS = 5 * 60 * 1000;
+const CACHE_MAX = 500;
+function _cacheGet(key) { const e = _cache.get(key); return e && (Date.now() - e.at) < e.ttl ? e : null; }
+function _cacheSet(key, html, ttl = CACHE_TTL_MS) {
+ if (_cache.size >= CACHE_MAX) { const first = _cache.keys().next().value; _cache.delete(first); }
+ _cache.set(key, { html, at: Date.now(), ttl });
+}
+
+const SEO_ROUTE_RE = /^\/(?:$|content$|moments$|vods$|clips$|pastes$|arena$|chat$|search$|vod\/\d+$|clip\/\d+$|recap\/\d+$|p\/[A-Za-z0-9_-]+$|@[A-Za-z0-9_]{3,24}$)/;
+
+/** ?page=N on a channel page (its video list); 1 everywhere else and for anything that is not a number. */
+function _pageParam(p, query) {
+ const feed = p === '/content' || p === '/moments';
+ if (!feed && !CHANNEL_PATH_RE.test(p)) return 1;
+ const raw = query && typeof query.page === 'string' ? query.page : '';
+ return /^\d{1,4}$/.test(raw) ? Math.min(feed ? FEED_MAX_PAGE : CHANNEL_MAX_PAGE, Math.max(1, parseInt(raw, 10))) : 1;
+}
+
+async function middleware(req, res, next) {
+ if (req.method !== 'GET') return next();
+ const p = (req.path || '/').replace(/\/+$/, '') || '/';
+ if (!SEO_ROUTE_RE.test(p)) return next();
+ // A paste Community owns is never rendered here (the handover answers it first; this keeps it
+ // so if the mount order ever changes).
+ if (p.startsWith('/p/')) return next();
+ // Every client gets the same page, whatever it sends as Accept: browsers, crawlers, curl and
+ // monitors. (It used to be skipped unless Accept named text/html, so a crawler or tool that sent
+ // */* got the home page's title and canonical for every channel, VOD and clip.) Nothing fetches
+ // these paths for data: the API is under /api.
+ const page = _pageParam(p, req.query);
+ const key = page > 1 ? `${p}?page=${page}` : p;
+ // This renderer and the SPA fallback's status check share one wait on upstreams (page-status.js).
+ req.ovLookupDeadlineAt = Date.now() + pageStatus.LOOKUP_DEADLINE_MS;
+ const send = (html, ttl) => {
+ res.set('Cache-Control', `public, max-age=${Math.max(0, Math.round(ttl / 1000))}, must-revalidate`);
+ res.set('Content-Security-Policy-Report-Only', assets.cspReportOnly(html));
+ res.type('html');
+ return res.send(html);
+ };
+ try {
+ // Check for a changed index.html before trusting the page cache: a cache hit would otherwise
+ // skip the check and keep serving pages that point at the previous asset versions.
+ _base();
+ const cached = _cacheGet(key);
+ if (cached) return send(cached.html, cached.ttl);
+ const meta = await _pageMeta(p, { page });
+ if (!meta) return next(); // unknown, private or not found: the SPA fallback answers (404 when it names nothing)
+ const html = render(meta, p);
+ if (!html) return next();
+ const ttl = meta.cacheTtlMs || CACHE_TTL_MS;
+ _cacheSet(key, html, ttl);
+ return send(html, ttl);
+ } catch (e) {
+ console.warn('[SEO] render failed for', p, '-', e.message);
+ return next();
+ }
+}
+
+module.exports = { middleware, shellHtml, _pageMeta, render, SITE_NAME, baseUrl, isAiClip, isoDate };
deleted file mode 100644
deleted file mode 100644
@@ -1,1215 +0,0 @@
@@ -1,1215 +0,0 @@
-/**
- * SEO for the SPA — per-route server-side meta / Open Graph / Twitter / JSON-LD injection,
- * a no-JS crawlable content snapshot, and a dynamic sitemap. Cached throughout.
- *
- * The app is a client-rendered SPA (server/index.js serves public/index.html for every route),
- * so crawlers/AI scrapers see an empty shell. This middleware intercepts the HTML routes we
- * care about (home, vods/clips/pastes lists, and vod/clip/paste detail pages), and rewrites the
- * <head> with route-specific machine-readable metadata + drops a <noscript> content snapshot so
- * no-JS scrapers get real content. Everything is cached so it adds ~no per-request DB cost.
- *
- * AI Moments (auto-clips, AI moment pastes, AI recaps) are labelled AI-made, credited to no
- * person, noindex,follow, canonical to their source VOD and left out of the sitemap (see
- * "AI Moments" below). People's work keeps normal, indexable pages.
- */
-'use strict';
-const fs = require('node:fs');
-const path = require('node:path');
-const db = require('../db/database');
-const media = require('../media-client');
-let config = null; try { config = require('../config'); } catch { /* */ }
-
-// ── Short-cached Media content (VODs/clips/pastes live in OpenVibe.Media now) ──
-const _mc = new Map(); // key → { v, at }
-const MC_TTL_MS = 60_000;
-async function _cached(key, fn) {
- const e = _mc.get(key);
- if (e && Date.now() - e.at < MC_TTL_MS) return e.v;
- let v;
- try { v = await fn(); } catch (err) {
- // A missing id is remembered too: otherwise every request for a dead link (the SPA fallback
- // then answers it 404, see server/web/page-status.js) is another round trip to Media.
- if (!err || (err.status !== 404 && err.status !== 410)) return e ? e.v : null;
- v = null;
- }
- _mc.set(key, { v, at: Date.now() });
- if (_mc.size > 300) _mc.delete(_mc.keys().next().value);
- return v;
-}
-const _vodList = (limit, offset = 0) => _cached(`vl:${limit}:${offset}`, async () => (await media.listVods({ limit, offset }))?.vods || []);
-// People's clips and pastes: the AI's are listed on /moments (server/content/feed.js).
-const _clipList = (limit, offset = 0) => _cached(`cl:${limit}:${offset}`, async () => (await media.listClips({ limit, offset, auto_generated: 0 }))?.clips || []);
-const _pasteList = (limit, offset = 0) => _cached(`pl:${limit}:${offset}`, async () => (await require('../pastes-client').listPastes({ limit, offset, visibility: 'public', origin: 'user' }))?.pastes || []);
-/**
- * Page `page` of the Content or Moments feed, as list-page items: { items, more }. The feed pages by
- * cursor, so page N follows N-1 `next` cursors (each page cached; at most FEED_MAX_PAGE deep), which
- * gives crawlers plain ?page=N links (roadmap D44) while the SPA keeps its infinite scroll.
- */
-const FEED_MAX_PAGE = 20;
-const FEED_PAGE_SIZE = 24;
-const _feedPage = (feed, page = 1) => _cached(`feed:${feed}:${page}`, async () => {
- const f = require('../content/feed');
- let cursor = null, out = null;
- for (let n = 1; n <= page; n++) {
- out = await f.page(feed, { limit: FEED_PAGE_SIZE, ...(cursor ? { cursor } : {}) });
- if (n < page) { if (!out.next) return { items: [], more: false }; cursor = out.next; }
- }
- const kindLabel = { vod: 'VOD', clip: 'clip', paste: 'paste', recap: 'recap' };
- return {
- items: (out.items || []).map((it) => ({
- url: it.kind === 'paste' ? pasteHref(it.id) : it.href, name: it.title, by: it.channel ? it.channel.display_name : null,
- meta: [kindLabel[it.kind], it.duration_seconds ? _fmtDur(it.duration_seconds) : null].filter(Boolean).join(' · '),
- desc: it.excerpt,
- })),
- more: Boolean(out.next) && page < FEED_MAX_PAGE,
- };
-});
-const _feedList = async (feed) => (await _feedPage(feed, 1)).items;
-const { isPrivate } = require('../media-proxy/access');
-const pageStatus = require('../web/page-status');
-
-// ── One item (VOD, clip, paste) ──
-// Fetched at most once at a time per id and kept a minute (a missing id 30 s, a failure not at
-// all). A page waits at most DETAIL_WAIT_MS for it, so a slow Media or Community never holds a
-// page, or a crawler, for its whole timeout; the fetch keeps running and fills the cache. What a
-// fresh fetch learned is handed to the SPA fallback's status check (server/web/page-status.js):
-// a page this renderer passes on (missing, or private to this visitor) costs one upstream call.
-const DETAIL_WAIT_MS = Math.max(0, pageStatus.LOOKUP_DEADLINE_MS - 500);
-const DETAIL_FOUND_TTL_MS = 60_000;
-const DETAIL_MISSING_TTL_MS = 30_000;
-const _items = new Map(); // key → { at, ttl, state: 'found'|'missing'|null, row, pending }
-function _detail(key, load) {
- let e = _items.get(key);
- if (e && !e.pending && e.state && Date.now() - e.at < e.ttl) return Promise.resolve({ state: e.state, row: e.row, fresh: false });
- if (!e || !e.pending) {
- const entry = { at: 0, ttl: 0, state: null, row: null, pending: null };
- entry.pending = Promise.resolve().then(load).then(
- (row) => { if (row && typeof row === 'object') { entry.state = 'found'; entry.row = row; entry.ttl = DETAIL_FOUND_TTL_MS; } else { entry.state = 'missing'; entry.ttl = DETAIL_MISSING_TTL_MS; } },
- (err) => { if (err && (err.status === 404 || err.status === 410)) { entry.state = 'missing'; entry.ttl = DETAIL_MISSING_TTL_MS; } },
- ).then(() => {
- entry.at = Date.now();
- entry.pending = null;
- if (!entry.state && _items.get(key) === entry) _items.delete(key); // a failure is not remembered
- return { state: entry.state || 'unknown', row: entry.row, fresh: true };
- });
- _items.delete(key);
- _items.set(key, entry);
- if (_items.size > 1000) _items.delete(_items.keys().next().value);
- e = entry;
- }
- let timer;
- const late = new Promise((resolve) => { timer = setTimeout(resolve, DETAIL_WAIT_MS, { state: 'unknown', row: null, fresh: false }); });
- return Promise.race([e.pending, late]).finally(() => clearTimeout(timer));
-}
-// Callers may annotate the row they get (AI state overlay), so each gets its own copy.
-const _copy = (r) => (r.row ? { ...r.row } : null);
-async function _vodGet(id) {
- const r = await _detail(`v:${id}`, () => media.getVod(id));
- if (r.fresh && r.state !== 'unknown') pageStatus.primeMediaItem('vod', String(id), r.row);
- return _copy(r);
-}
-async function _clipGet(id) {
- const r = await _detail(`c:${id}`, () => media.getClip(id));
- if (r.fresh && r.state !== 'unknown') pageStatus.primeMediaItem('clip', String(id), r.row);
- return _copy(r);
-}
-async function _pasteGet(slug) {
- const r = await _detail(`p:${slug}`, () => require('../pastes-client').getPaste(slug));
- if (r.fresh && r.state !== 'unknown') pageStatus.primePaste(slug, r.state === 'found');
- return _copy(r);
-}
-
-// Lists (a channel's VODs, a VOD's clips, the home rails) are extras on a page: each waits at
-// most LIST_WAIT_MS, and a page rendered without one is cached briefly so it fills in soon.
-const LIST_WAIT_MS = 1500;
-const SHORT_CACHE_MS = 30_000;
-const _TIMED_OUT = Symbol('timed out');
-function _within(promise, ms) {
- let timer;
- const late = new Promise((resolve) => { timer = setTimeout(resolve, ms, _TIMED_OUT); });
- return Promise.race([Promise.resolve(promise).catch(() => null), late]).finally(() => clearTimeout(timer));
-}
-/** A cached list call, or null (and `partial` set on the tracker) when it did not answer in time. */
-async function _listWithin(key, fn, track) {
- const r = await _within(_cached(key, fn), LIST_WAIT_MS);
- if (r === _TIMED_OUT || r == null) { if (track) track.partial = true; return null; }
- return r;
-}
-const _pub = (row) => row && !isPrivate(row) && Number(row.is_public) !== 0 && row.is_public !== false;
-
-// Overlay the Live-owned AI state (vod_ai_state/clip_ai_state) onto a Media row so
-// descriptions/transcripts keep enriching the crawlable snapshot.
-function _overlayAiState(row, kind) {
- try {
- const st = kind === 'clip' ? db.getClipAiState(row.id) : db.getVodAiState(row.id);
- if (!st) return;
- if (st.ai_overview_short) {
- row.ai_overview_short = row.ai_overview_short || st.ai_overview_short;
- row.ai_overview = row.ai_overview || st.ai_overview || st.ai_overview_short;
- }
- if (st.ai_transcript_json && !row.ai_transcript) {
- try { row.ai_transcript = JSON.parse(st.ai_transcript_json).map(s => s.text).join(' ').trim(); } catch { /* */ }
- }
- } catch { /* */ }
-}
-
-// Pastes live on openvibe.community: /p/<slug> is a redirect there (server/web/paste-handover.js,
-// mounted before this middleware), and every paste link Live renders, here and in the SPA (which
-// reads the base from the ov-pastes-base meta on every shell), points straight at Community, the
-// paste's one canonical page. Live's sitemap never lists /p/ (roadmap 32.2).
-const PASTES_BASE = (process.env.OV_COMMUNITY_URL || 'https://openvibe.community').replace(/\/$/, '');
-const PASTES_META = `<meta name="ov-pastes-base" content="${esc(PASTES_BASE)}">`;
-const pasteHref = (slug) => `${PASTES_BASE}/p/${encodeURIComponent(slug)}`;
-
-const SITE_NAME = 'OpenVibe.Live';
-const DEFAULT_OG_IMAGE = '/og-image.png';
-
-function baseUrl() {
- let b = (config && config.baseUrl) || 'https://openvibe.live';
- if (/localhost|127\.0\.0\.1/.test(b)) b = 'https://openvibe.live'; // never emit localhost in public meta
- return b.replace(/\/+$/, '');
-}
-function abs(url) {
- if (!url) return '';
- if (/^https?:\/\//i.test(url)) return url;
- return baseUrl() + (url.startsWith('/') ? url : '/' + url);
-}
-// HTML-escape for text nodes / attribute values.
-function esc(s) {
- return String(s == null ? '' : s)
- .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
- .replace(/"/g, '"').replace(/'/g, ''');
-}
-// Collapse to a clean single-line meta-description-safe string.
-function clean(s, max) {
- let t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim();
- if (max && t.length > max) t = t.slice(0, max - 1).replace(/\s+\S*$/, '') + '…';
- return t;
-}
-function isoDate(dt) {
- try {
- if (!dt) return null;
- const d = new Date(String(dt).includes('T') ? dt : String(dt).replace(' ', 'T') + 'Z');
- return isNaN(d.getTime()) ? null : d.toISOString();
- } catch { return null; }
-}
-// Seconds → ISO-8601 duration (PT#H#M#S) for VideoObject.
-function iso8601Duration(sec) {
- sec = Math.max(0, Math.floor(Number(sec) || 0));
- const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;
- return 'PT' + (h ? h + 'H' : '') + (m ? m + 'M' : '') + (s || (!h && !m) ? s + 'S' : '');
-}
-function jsonLd(obj) {
- // Escape "<" so a value can never break out of the <script> block.
- return `<script type="application/ld+json">${JSON.stringify(obj).replace(/</g, '\\u003c')}</script>`;
-}
-
-// ── Build the per-route metadata object ────────────────────────────────────────────────────
-// Returns { title, description, canonicalPath, image, ogType, robots, jsonLd:[...], snapshot }
-// or null to fall through (unknown / private / not found).
-
-async function _pageMeta(routePath, { page = 1 } = {}) {
- const p = routePath.replace(/\/+$/, '') || '/';
- const bu = baseUrl();
-
- // Home
- if (p === '/') return _homeMeta();
- // List pages (content sourced from OpenVibe.Media, canonical URLs stay on openvibe.live)
- if (p === '/vods') return _listMeta('vods', 'VODs', 'Browse recorded live streams (VODs) on OpenVibe.Live — auto-recorded broadcasts with AI overviews and searchable transcripts.', async () => (await _vodList(30) || []).map(v => ({ url: `/vod/${v.id}`, name: v.title || 'VOD', by: v.display_name || v.username, meta: _fmtDur(v.duration_seconds || v.duration), desc: v.ai_overview_short })));
- if (p === '/clips') return _listMeta('clips', 'Clips', 'Watch the best clips from OpenVibe.Live live streams — the moments viewers clipped.', async () => (await _clipList(30) || []).map(c => ({ url: `/clip/${c.id}`, name: c.title || 'Clip', by: c.display_name || c.username || c.streamer_username, meta: _fmtDur(c.duration_seconds || c.duration), desc: c.ai_overview_short })));
- if (p === '/content') return _listMeta('content', 'Content', 'VODs, clips and pastes made by the people of OpenVibe.Live — recorded streams, the moments viewers clipped, and the code, notes and screenshots they shared.', () => _feedPage('content', page), { page });
- if (p === '/moments') return _listMeta('moments', 'AI Moments', 'AI-made highlights from OpenVibe.Live streams: auto-clips of the moments chat erupted, standout frames the AI picked, and AI-written after-show recaps. Everything listed here is AI-generated.', () => _feedPage('moments', page), { page });
- if (p === '/pastes') return _listMeta('pastes', 'Pastes', 'Code, text and screenshot pastes shared on OpenVibe.Live — a Pastebin built into the streaming network, with AI summaries.', async () => (await _pasteList(30) || []).map(x => ({ url: pasteHref(x.slug), name: x.title || 'Paste', by: x.username || 'anon', meta: x.type === 'screenshot' ? 'image' : (x.language || 'text'), desc: x.ai_summary })));
-
- // Detail pages
- let m;
- if ((m = p.match(/^\/vod\/(\d+)$/))) return _vodMeta(parseInt(m[1], 10));
- if ((m = p.match(/^\/clip\/(\d+)$/))) return _clipMeta(parseInt(m[1], 10));
- if ((m = p.match(/^\/p\/([A-Za-z0-9_-]+)$/))) return _pasteMeta(m[1]);
- if ((m = p.match(CHANNEL_PATH_RE))) return _channelMeta(m[1], page);
- if ((m = p.match(/^\/recap\/(\d+)$/))) return _recapMeta(parseInt(m[1], 10));
- if (p === '/arena') return _arenaMeta();
- // Search results (public/js/app-search.js) are never indexed; the page itself is findable.
- if (p === '/search') return { title: `Search | ${SITE_NAME}`, description: 'Search channels, VODs and clips on OpenVibe.Live.', canonicalPath: '/search', ogType: 'website', robots: 'noindex,follow', jsonLd: [] };
- // The global chat room: its own title and canonical (the shell left it with neither; browser check, WS-Q task 3).
- if (p === '/chat') return { title: `Chat | ${SITE_NAME}`, description: 'The global chat room of OpenVibe.Live, open to everyone on the network.', canonicalPath: '/chat', ogType: 'website', robots: 'index,follow', jsonLd: [] };
-
- return null;
- void bu;
-}
-
-// ── Channel page: /@<username>, with ?page=N over the channel's videos ─────────────────────
-// The client's channel-name rule (CHANNEL_USERNAME_RE in public/js/app.js, CHANNEL_RE in
-// server/web/page-status.js): anything else is never a channel.
-const CHANNEL_PATH_RE = /^\/@([A-Za-z0-9_]{3,24})$/;
-const CHANNEL_PAGE_SIZE = 12;
-const CHANNEL_MAX_PAGE = 500;
-const CHANNEL_CACHE_MS = 60_000; // the page says who is live, so it is kept a minute, not five
-
-/** The account behind /@<name>: its channel row, or just the account when it has none yet. */
-function _channelAccount(username) {
- let ch = null;
- try { ch = db.getChannelByUsername(username); } catch { ch = null; }
- let user = null;
- try { user = db.getUserById(ch ? ch.user_id : (db.getUserByUsername(username) || {}).id); } catch { user = null; }
- if (!ch && !user) return null;
- return {
- userId: ch ? ch.user_id : user.id,
- username: (ch && ch.username) || user.username,
- displayName: (ch && ch.display_name) || (user && user.display_name) || (ch && ch.username) || user.username,
- avatarUrl: (ch && ch.avatar_url) || (user && user.avatar_url) || null,
- bio: (ch && ch.bio) || (user && user.bio) || '',
- banned: !!(user && (user.is_banned === 1 || user.is_banned === true)),
- };
-}
-
-async function _channelMeta(username, page = 1) {
- const acct = _channelAccount(username);
- if (!acct) return null;
- page = Math.min(CHANNEL_MAX_PAGE, Math.max(1, Math.floor(Number(page) || 1)));
- const name = acct.displayName;
- const handle = '@' + acct.username;
- const basePath = `/${handle}`;
- let ov = null;
- try { ov = db.getStreamerOverview ? db.getStreamerOverview(acct.userId) : null; } catch { /* */ }
- let followers = 0;
- try { followers = db.getFollowerCount ? (db.getFollowerCount(acct.userId) || 0) : 0; } catch { /* */ }
- let live = [];
- try { live = acct.banned ? [] : (db.getLiveStreamsByUserId(acct.userId) || []); } catch { live = []; }
- const bio = clean(acct.bio || '', 300);
- const aiShort = clean((ov && (ov.overview_short || ov.overview)) || '', 220);
-
- // What the channel has made: its videos (this page of them), the clips people took of its
- // streams, then what the AI cut from them, labelled (roadmap 33.6: made, then derived).
- const track = { partial: false };
- const offset = (page - 1) * CHANNEL_PAGE_SIZE;
- const [vr, cr, ar] = acct.banned ? [null, null, null] : await Promise.all([
- _listWithin(`chv:${acct.userId}:${offset}`, () => media.listVods({ user_id: acct.userId, order: 'newest', limit: CHANNEL_PAGE_SIZE, offset }, { timeoutMs: 5000 }), track),
- page === 1 ? _listWithin(`chc:${acct.userId}`, () => media.listClips({ channel_user_id: acct.userId, auto_generated: 0, limit: 8 }, { timeoutMs: 5000 }), track) : null,
- page === 1 ? _listWithin(`cha:${acct.userId}`, () => media.listClips({ channel_user_id: acct.userId, auto_generated: 1, limit: 6 }, { timeoutMs: 5000 }), track) : null,
- ]);
- const vods = ((vr && vr.vods) || []).filter((v) => _pub(v) && !v.is_recording && v.status !== 'failed');
- const vodTotal = vr && Number.isFinite(Number(vr.total)) ? Number(vr.total) : null;
- const pages = vodTotal != null ? Math.max(1, Math.ceil(vodTotal / CHANNEL_PAGE_SIZE)) : null;
- const clips = ((cr && cr.clips) || []).filter((c) => _pub(c) && !isAiClip(c));
- const aiClips = ((ar && ar.clips) || []).filter((c) => _pub(c) && isAiClip(c));
- // A page past the end has nothing on it: not a page to index, and its canonical is page 1.
- const pastEnd = page > 1 && !track.partial && (!vods.length || (pages != null && page > pages));
- const canonicalPath = page > 1 && !pastEnd ? `${basePath}?page=${page}` : basePath;
- const pageLabel = page > 1 ? ` — videos, page ${page}` : '';
-
- const desc = clean(bio || aiShort || `${name} (${handle}) streams live on ${SITE_NAME}. Watch their live streams, VODs and clips.`, 200);
- const image = acct.avatarUrl ? abs(acct.avatarUrl) : DEFAULT_OG_IMAGE;
- const person = {
- '@type': 'Person', name: clean(name, 80), alternateName: handle, url: abs(basePath),
- image: image !== DEFAULT_OG_IMAGE ? image : undefined,
- description: desc,
- interactionStatistic: followers ? { '@type': 'InteractionCounter', interactionType: 'https://schema.org/FollowAction', userInteractionCount: followers } : undefined,
- };
- const profile = { '@context': 'https://schema.org', '@type': 'ProfilePage', name: `${name} (${handle})`, url: abs(canonicalPath), mainEntity: person };
- const ld = [profile];
- if (vods.length) {
- ld.push({
- '@context': 'https://schema.org', '@type': 'ItemList', name: `${name}'s videos${page > 1 ? `, page ${page}` : ''}`,
- itemListElement: vods.map((v, i) => ({ '@type': 'ListItem', position: offset + i + 1, url: abs(`/vod/${Number(v.id)}`), name: clean(v.title || 'Stream recording', 110) })),
- });
- }
- ld.push(_breadcrumb([{ name: 'Home', url: '/' }, { name, url: basePath }, ...(page > 1 ? [{ name: `Videos, page ${page}`, url: canonicalPath }] : [])]));
-
- // The page body, for crawlers and for anyone reading without JavaScript.
- let body = `<article><h1>${esc(`${name} (${handle})`)}</h1>`;
- const facts = [followers ? `${followers} follower${followers === 1 ? '' : 's'} on ${SITE_NAME}` : null];
- if (live.length) facts.push(`Live now: ${clean(live[0].title || 'streaming', 100)}`);
- if (facts.filter(Boolean).length) body += `<p class="byline">${esc(facts.filter(Boolean).join(' · '))}</p>`;
- if (acct.banned) body += '<p>This account is suspended.</p>';
- if (bio) body += `<p>${esc(bio)}</p>`;
- if (page === 1 && ov && ov.overview && String(ov.overview).trim()) {
- body += `<section><h2>AI overview</h2><p class="ai-disclosure">Written by OpenVibe's AI from ${esc(name)}'s past streams.</p><p>${esc(clean(ov.overview, 1200))}</p></section>`;
- }
- body += _itemSection(page > 1 ? `Videos, page ${page}` : 'Videos', vods.map((v) => ({
- url: `/vod/${Number(v.id)}`, name: v.title || 'Stream recording',
- meta: [_fmtDur(v.duration_seconds ?? v.duration), _day(v.created_at)].filter(Boolean).join(' · '),
- })), vods.length || page > 1 ? null : (track.partial ? null : 'No videos yet.'));
- if (vods.length || page > 1) body += _pager(basePath, page, pages, vods.length === CHANNEL_PAGE_SIZE, 'videos');
- if (clips.length) body += _itemSection('Clips', clips.map((c) => ({ url: `/clip/${Number(c.id)}`, name: c.title || 'Clip', meta: [_clipperOf(c) ? `clipped by ${_clipperOf(c)}` : null, _fmtDur(c.duration_seconds ?? c.duration)].filter(Boolean).join(' · ') })));
- if (aiClips.length) {
- body += _itemSection('AI Moments', aiClips.map((c) => ({ url: `/clip/${Number(c.id)}`, name: c.title || 'AI clip', meta: ['AI clip', _fmtDur(c.duration_seconds ?? c.duration)].filter(Boolean).join(' · ') })),
- null, `<p class="ai-disclosure">Clips OpenVibe's AI cut from ${esc(name)}'s streams when chat reacted. No one clipped these.</p>`);
- }
- body += `<p><a href="${esc(abs(basePath))}">Visit ${esc(name)}'s channel</a> on ${SITE_NAME}.</p></article>`;
-
- return {
- title: `${name} (${handle})${pageLabel} — ${SITE_NAME}`, description: desc,
- canonicalPath, image, ogType: 'profile',
- robots: acct.banned || pastEnd ? 'noindex,follow' : 'index,follow',
- jsonLd: ld, snapshot: body,
- cacheTtlMs: track.partial ? SHORT_CACHE_MS : CHANNEL_CACHE_MS,
- };
-}
-
-/** "Page 2 of 5" with newer/older links (?page=N), the crawlable form of the channel's video list. */
-function _pager(basePath, page, pages, maybeMore, noun) {
- const href = (n) => esc(abs(n > 1 ? `${basePath}?page=${n}` : basePath));
- const parts = [];
- if (page > 1) parts.push(`<a rel="prev" href="${href(page - 1)}">Newer ${noun}</a>`);
- parts.push(`Page ${page}${pages ? ` of ${pages}` : ''}`);
- if (pages ? page < pages : maybeMore) parts.push(`<a rel="next" href="${href(page + 1)}">Older ${noun}</a>`);
- return `<nav class="pagination" aria-label="Pages">${parts.join(' · ')}</nav>`;
-}
-/** A heading and a list of links with their details; `empty` when there is nothing, `intro` above. */
-function _itemSection(heading, items, empty = null, intro = '') {
- if (!items.length && !empty) return '';
- const li = items.map((it) => `<li><a href="${esc(abs(it.url))}">${esc(clean(it.name, 120))}</a>${it.meta ? ` — ${esc(it.meta)}` : ''}</li>`).join('');
- return `<section><h2>${esc(heading)}</h2>${intro}${items.length ? `<ul>${li}</ul>` : `<p>${esc(empty)}</p>`}</section>`;
-}
-const _day = (dt) => { const d = isoDate(dt); return d ? d.slice(0, 10) : null; };
-/** A person's clip's clipper, from Live's accounts (Media rows carry ids only). */
-function _clipperOf(c) { return c.display_name || c.username || _nameOf(_channelOwner(c.user_id)); }
-
-// The Arena: a real content page (mic-judged trash talk), so give it real metadata.
-function _arenaMeta() {
- let fighters = [], moments = [];
- try { fighters = (require('../arena/arena-service').loadRoster() || {}).order || []; } catch { fighters = []; }
- try { moments = require('../arena/mic').feed({ limit: 10 }) || []; } catch { moments = []; }
- const nameOf = (id) => { try { return require('../arena/mic').nameOf(id); } catch { return null; } };
- const names = fighters.slice(0, 8).map(nameOf).filter(Boolean);
- const title = 'The Arena — Mic-Judged Trash Talk Between Streamers';
- const desc = clean(`Every callout streamers make on mic, judged and ranked. ${names.length ? 'Fighters right now: ' + names.slice(0, 5).join(', ') + '. ' : ''}Chat can hype a beef but never write one.`, 200);
- const items = moments.slice(0, 10).map((mo, i) => ({ '@type': 'ListItem', position: i + 1, name: clean(mo.text || 'Mic moment', 110) }));
- const snapshot = _detailSnapshot({
- title: 'The Arena', byline: names.length ? `Fighters: ${names.join(', ')}` : null,
- desc, overview: moments.slice(0, 8).map(mo => mo.text).filter(Boolean).join(' · ') || null,
- transcript: null, canonicalPath: '/arena', watchLabel: 'Open the Arena',
- });
- return {
- title: `${title} | ${SITE_NAME}`, description: desc, canonicalPath: '/arena',
- image: DEFAULT_OG_IMAGE, ogType: 'website', robots: 'index,follow',
- jsonLd: [
- { '@context': 'https://schema.org', '@type': 'CollectionPage', name: title, url: abs('/arena'), description: desc },
- ...(items.length ? [{ '@context': 'https://schema.org', '@type': 'ItemList', name: 'Latest mic moments', itemListElement: items }] : []),
- _breadcrumb([{ name: 'Home', url: '/' }, { name: 'The Arena', url: '/arena' }]),
- ],
- snapshot,
- };
-}
-
-// After-show report: /recap/:streamId
-// The AI-written report is an AI Moment: labelled, attributed to no person (the streamer did not
-// write it), noindex,follow and canonical to the stream's VOD when there is one. A template report
-// (stats only, no model) is not AI writing and keeps its own indexable page; it has no Person
-// author either, since the streamer did not write that one.
-async function _recapMeta(streamId) {
- let r; try { r = require('../recap/recap').getRecap(streamId); } catch { r = null; }
- if (!r) return null;
- const ai = r.ai === true || Number(r.ai) === 1;
- const name = r.streamer.display_name || r.streamer.username;
- const title = clean(`${r.write.headline || r.stream.title}`, 90);
- const desc = clean(ai
- ? `AI-written after-show report on ${name}'s stream "${r.stream.title}". ${r.write.summary || ''}`
- : (r.write.summary || `After-show report for ${name}'s stream "${r.stream.title}" on ${SITE_NAME}.`), 200);
- const image = r.vod && r.vod.thumbnail_url ? media.publicUrl(r.vod.thumbnail_url) : (r.streamer.avatar_url ? abs(r.streamer.avatar_url) : DEFAULT_OG_IMAGE);
- const selfPath = `/recap/${streamId}`;
- const source = ai && r.vod ? await _sourceMoment(r.vod.id, 0) : null;
- const article = {
- '@context': 'https://schema.org', '@type': 'Article', headline: title, description: desc, image: [image],
- datePublished: isoDate(r.stream.ended_at) || undefined,
- keywords: ai ? AI_KEYWORDS : undefined,
- isBasedOn: source ? abs(source.path) : undefined,
- publisher: { '@type': 'Organization', name: SITE_NAME, url: baseUrl() },
- };
- const byline = `${ai ? 'AI recap · from' : 'Report on'} ${name}'s stream · ${Math.round((r.stream.duration_seconds || 0) / 60)} min · grade ${r.write.grade}`;
- const extraHtml = (ai ? '<p class="ai-disclosure">Written automatically by OpenVibe\'s after-show report workflow from the stream\'s stats, chat and transcript. The streamer did not write it.</p>' : '')
- + `<p class="source">${source ? `<a href="${esc(abs(source.path))}">Watch the stream</a> · ` : ''}<a href="${esc(abs(`/@${r.streamer.username}`))}">${esc(name)}'s channel</a></p>`;
- const snapshot = _detailSnapshot({ title: `After-show report: ${r.stream.title}`, byline, desc, extraHtml, overview: r.write.summary, transcript: null, canonicalPath: selfPath, watchLabel: 'Read the report' });
- return {
- title: `${title} — ${ai ? 'AI recap of ' : ''}${name}'s stream${ai ? '' : ' report'} | ${SITE_NAME}`, description: desc,
- canonicalPath: source ? source.path : selfPath, ogUrlPath: selfPath, image, ogType: 'article',
- robots: ai ? AI_ROBOTS : 'index,follow',
- jsonLd: [article, _breadcrumb([{ name: 'Home', url: '/' }, { name, url: `/@${r.streamer.username}` }, { name: ai ? 'AI recap' : 'Report', url: selfPath }])],
- snapshot,
- };
-}
-
-// A crawlable <section> listing media items with real detail (title, streamer, meta).
-function _mediaSection(heading, items) {
- if (!items || !items.length) return '';
- const li = items.map(it => {
- const bits = [];
- if (it.by) bits.push('by ' + esc(it.by));
- if (it.meta) bits.push(esc(it.meta));
- return `<li><a href="${esc(abs(it.url))}">${esc(clean(it.name, 120))}</a>${bits.length ? ' — ' + bits.join(' · ') : ''}${it.desc ? `<br><small>${esc(clean(it.desc, 160))}</small>` : ''}</li>`;
- }).join('');
- return `<section><h2>${esc(heading)}</h2><ul>${li}</ul></section>`;
-}
-// m:ss, or h:mm:ss from an hour up; '' for nothing.
-function _fmtDur(sec) {
- sec = Math.floor(Number(sec) || 0);
- if (!sec) return '';
- const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;
- const ss = String(s).padStart(2, '0');
- return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`;
-}
-
-async function _homeMeta() {
- // Going live from the browser (no OBS, no downloads, no follower minimum) leads: it is what people ask
- // search engines and AI assistants for, and what they recommend OpenVibe.Live for (2026-09-28).
- const title = 'OpenVibe.Live — Go Live From Your Browser, No OBS Needed';
- const description = 'Go live instantly from your browser: no OBS, no downloads, no follower minimum. Open-source, community-run streaming with sub-second WebRTC, chat, VODs, clips and restreaming to Twitch, YouTube and Kick.';
-
- // Pull the actual live content so the source has real, crawlable text.
- let live = [];
- try { live = (db.getLiveStreams() || []).slice(0, 12); } catch { /* */ }
- // Each rail waits at most LIST_WAIT_MS: a slow upstream leaves its rail out, never the page.
- // VODs, clips and pastes are counted by the services that hold them (Media, Community), not Live's frozen tables.
- let homeStats = null;
- try { homeStats = { ...db.getHomeStats() }; } catch { homeStats = null; }
- const track = { partial: false };
- const settle = async (promise) => { const r = await _within(promise, LIST_WAIT_MS); if (r === _TIMED_OUT) { track.partial = true; return null; } return r; };
- let [vods, clips, pastes, stats] = await Promise.all([
- settle(_vodList(12)), settle(_clipList(12)), settle(_pasteList(12)),
- homeStats ? settle(require('../media-proxy/lookups').withArchiveStats(homeStats)) : null,
- ]);
- vods = vods || []; clips = clips || []; pastes = pastes || [];
- const ownerName = (row, id) => row.display_name || row.username || _nameOf(_channelOwner(id));
-
- const liveItems = live.map(s => ({ url: `/@${s.username}`, name: s.title || `${s.display_name || s.username} live`, by: s.display_name || s.username, meta: s.category || 'live' }));
- const vodItems = vods.map(v => ({ url: `/vod/${v.id}`, name: v.title || 'VOD', by: ownerName(v, v.user_id), meta: _fmtDur(v.duration_seconds || v.duration), desc: v.ai_overview_short }));
- const clipItems = clips.map(c => ({ url: `/clip/${c.id}`, name: c.title || 'Clip', by: ownerName(c, c.user_id), meta: _fmtDur(c.duration_seconds || c.duration) }));
- const pasteItems = pastes.map(x => ({ url: pasteHref(x.slug), name: x.title || 'Paste', by: x.username || 'anon', meta: x.type === 'screenshot' ? 'image' : (x.language || 'text') }));
-
- const statLine = stats ? `<p>${SITE_NAME} hosts ${stats.streamers || 0} streamers, ${stats.vods || 0} VODs, ${stats.clips || 0} clips, ${stats.pastes || 0} pastes and ${stats.chatMessages || 0} chat messages.</p>` : '';
- const snapshot =
- `<h1>${SITE_NAME} — go live from your browser, no OBS needed</h1>` +
- `<p>${esc(description)}</p>` +
- '<section><h2>Go live in your browser</h2><ol>' +
- '<li>Open openvibe.live in Chrome, Edge, Firefox or Safari — on a Chromebook, laptop or phone.</li>' +
- '<li>Sign in or make an account; your channel exists right away.</li>' +
- '<li>Press Go Live and allow your camera and microphone.</li>' +
- '<li>Add a title and start: you are live with your own channel and chat.</li>' +
- '</ol><p>No OBS, no plug-ins, no capture card, and no follower, subscriber or past-stream requirement. ' +
- `<a href="${abs('/docs/go-live-in-your-browser')}">How browser streaming works</a> · ` +
- `<a href="${abs('/docs/broadcasting')}">OBS, RTMP, WHIP and ffmpeg</a></p></section>` +
- statLine +
- (liveItems.length ? _mediaSection('Live now', liveItems) : '<section><h2>Live now</h2><p>No one is streaming right now — be the first to go live.</p></section>') +
- _mediaSection('Recent VODs', vodItems) +
- _mediaSection('Recent clips', clipItems) +
- _mediaSection('Recent pastes', pasteItems);
-
- // Rich JSON-LD: WebSite + one ItemList per section of the snapshot, named by that section's visible
- // heading (structured data names what a visitor sees; the browser check compares them).
- const itemList = (name, items) => ({ '@context': 'https://schema.org', '@type': 'ItemList', name,
- itemListElement: items.slice(0, 10).map((it, i) => ({ '@type': 'ListItem', position: i + 1, url: abs(it.url), name: clean(it.name, 110) })) });
- const lists = [['Live now', liveItems], ['Recent VODs', vodItems], ['Recent clips', clipItems]].filter(([, items]) => items.length).map(([n, items]) => itemList(n, items));
- const jsonLd = [
- {
- '@context': 'https://schema.org', '@type': 'WebSite',
- name: SITE_NAME, url: baseUrl(), description: clean(description, 300),
- inLanguage: 'en', publisher: { '@id': `${baseUrl()}/#org` },
- },
- {
- '@context': 'https://schema.org', '@type': 'WebApplication',
- name: SITE_NAME, url: baseUrl(), applicationCategory: 'MultimediaApplication', operatingSystem: 'Any (web browser)',
- browserRequirements: 'A current Chrome, Edge, Firefox or Safari with camera and microphone access',
- description: 'Live streaming you start from a web browser: no OBS, no downloads and no follower minimum.',
- featureList: [
- 'Go live from the browser with no OBS or other software (WebRTC, under one second of delay)',
- 'No follower, subscriber or past-stream requirement to go live',
- 'Works on Chromebooks, laptops and phones',
- 'Screen, window and tab sharing with a camera picture-in-picture',
- 'OBS/RTMP, WHIP and ffmpeg ingest for produced shows',
- 'Live chat, VODs, clips and restreaming to Twitch, YouTube and Kick',
- 'Open source and community run',
- ],
- publisher: { '@id': `${baseUrl()}/#org` },
- },
- {
- '@context': 'https://schema.org', '@type': 'Organization', '@id': `${baseUrl()}/#org`,
- name: 'OpenVibe', url: baseUrl(), logo: DEFAULT_OG_IMAGE,
- description: 'An open-source network of live streaming, media and developer tools, built and run by the people who use it.',
- // sameAs is how a search engine learns these properties are one brand rather than
- // unrelated sites that happen to share a name.
- sameAs: [
- 'https://github.com/OpenVibers',
- 'https://discord.gg/M6MuRUaeJj',
- 'https://openvibe.tools',
- 'https://openvibe.network',
- ],
- },
- ...lists,
- ];
- return { title, description, canonicalPath: '/', image: DEFAULT_OG_IMAGE, ogType: 'website', robots: 'index,follow', jsonLd, snapshot, cacheTtlMs: track.partial ? SHORT_CACHE_MS : undefined };
-}
-
-/**
- * A list page. `itemsFn` gives an array, or { items, more } for a paged list (page N of it: ?page=N,
- * self-canonical, with newer/older links; a page past the end is not rendered here).
- */
-async function _listMeta(slug, label, description, itemsFn, { page = 1 } = {}) {
- let items = [], more = null;
- try {
- const got = await itemsFn();
- if (Array.isArray(got)) items = got.slice(0, 24);
- else if (got) { items = (got.items || []).slice(0, 24); more = Boolean(got.more); }
- } catch { /* */ }
- if (page > 1 && !items.length) return null;
- const basePath = '/' + slug;
- const selfPath = page > 1 ? `${basePath}?page=${page}` : basePath;
- const pageLabel = page > 1 ? `, page ${page}` : '';
- const list = {
- '@context': 'https://schema.org', '@type': 'CollectionPage',
- name: `${label}${pageLabel} — ${SITE_NAME}`, url: abs(selfPath), description,
- mainEntity: {
- '@type': 'ItemList',
- itemListElement: items.map((it, i) => ({ '@type': 'ListItem', position: (page - 1) * 24 + i + 1, url: abs(it.url), name: clean(it.name, 110) })),
- },
- };
- let snapshot = `<h1>${esc(label)} on ${SITE_NAME}${page > 1 ? ` — page ${page}` : ''}</h1><p>${esc(description)}</p>` +
- _mediaSection(page > 1 ? `${label}, page ${page}` : `Latest ${label.toLowerCase()}`, items);
- if (more !== null && (page > 1 || more)) snapshot += _pager(basePath, page, null, more, label.toLowerCase());
- return {
- title: `${label}${pageLabel} — ${SITE_NAME}`, description, canonicalPath: selfPath,
- image: DEFAULT_OG_IMAGE, ogType: 'website', robots: 'index,follow',
- jsonLd: [list, _breadcrumb([{ name: 'Home', url: '/' }, { name: label, url: basePath }, ...(page > 1 ? [{ name: `Page ${page}`, url: selfPath }] : [])])],
- snapshot,
- };
-}
-
-function _authorLd(name) { return name ? { '@type': 'Person', name: clean(name, 80) } : undefined; }
-
-// ── AI Moments (roadmap §33.4, §33.8) ───────────────────────────────────────────────────────
-// What the AI made from a stream (auto-clips, AI moment pastes, AI after-show recaps) is labelled
-// as AI-made, attributed to no person, kept out of search (noindex,follow: the page still passes
-// its links on) and made canonical to its source: the VOD at the moment it came from, when that
-// VOD is known and public. The store decides what is AI (Media's auto_generated, Community's
-// origin, the recap's own ai flag); nothing is inferred here. People's clips and pastes keep their
-// normal, indexable pages.
-const AI_ROBOTS = 'noindex,follow';
-const AI_KEYWORDS = 'AI-generated';
-const VOD_MOMENT_RE = /^\/vod\/(\d+)(?:\?t=(\d+(?:\.\d+)?))?$/;
-
-const isAiClip = (c) => !!c && (c.auto_generated === true || Number(c.auto_generated) === 1);
-const isAiPaste = (p) => !!p && p.origin === 'ai';
-function _json(v) {
- if (!v) return null;
- if (typeof v === 'object') return v;
- try { const o = JSON.parse(v); return o && typeof o === 'object' ? o : null; } catch { return null; }
-}
-/** The Live account a stream-derived item belongs to (the channel, never a viewer). */
-function _channelOwner(userId) {
- if (userId == null || userId === '') return null;
- try { return db.getUserById(Number(userId)) || null; } catch { return null; }
-}
-const _nameOf = (u) => (u ? (u.display_name || u.username) : null);
-
-/**
- * `/vod/<id>?t=<s>` for the moment an item came from, or null when the VOD is unknown, gone or
- * not public (a canonical must never point at a page that answers 404 to the crawler).
- */
-async function _sourceMoment(vodId, seconds) {
- if (vodId == null || !/^\d+$/.test(String(vodId))) return null;
- const v = await _vodGet(Number(vodId));
- if (!v || !_pub(v)) return null;
- const t = Math.max(0, Math.floor(Number(seconds) || 0));
- return { path: `/vod/${Number(vodId)}${t ? `?t=${t}` : ''}`, vod: v };
-}
-
-async function _vodMeta(id) {
- const v = await _vodGet(id);
- // Private: fall through exactly like an unknown id. This HTML is cached per id and shared by
- // every visitor, so it never carries a private VOD's title, overview or transcript.
- if (!v || isPrivate(v)) return null;
- if (v.duration_seconds == null && v.duration != null) v.duration_seconds = v.duration;
- _overlayAiState(v, 'vod');
- const indexable = Number(v.is_public) === 1 && (!v.visibility || v.visibility === 'public');
- const owner = _channelOwner(v.user_id);
- const author = v.display_name || v.username || _nameOf(owner);
- const title = clean(v.title || `${author ? author + "'s " : ''}stream VOD`, 80);
- const desc = clean(v.ai_overview_short || v.ai_overview || v.description || `Recorded live stream${author ? ' by ' + author : ''} on ${SITE_NAME}.`, 200);
- const image = v.thumbnail_url ? media.publicUrl(v.thumbnail_url) : media.thumbUrl(`vod-${id}`);
- const canonicalPath = `/vod/${id}`;
-
- // The clips of this VOD: people's, then the AI's. Every one is also a moment of this video
- // (schema.org Clip in hasPart), so the source carries its moments (roadmap 33.8).
- const track = { partial: false };
- const cr = await _listWithin(`vc:${id}`, () => media.listClips({ vod_id: id, limit: 30 }, { timeoutMs: 5000 }), track);
- const all = ((cr && cr.clips) || []).filter((c) => _pub(c) && (!c.status || c.status === 'ready'));
- const people = all.filter((c) => !isAiClip(c));
- const ai = all.filter(isAiClip);
- const at = (c) => Math.max(0, Math.floor(Number(c.start_time) || 0));
- const parts = all.filter((c) => Number.isFinite(Number(c.start_time))).slice(0, 20).map((c) => ({
- '@type': 'Clip', name: clean(c.title || (isAiClip(c) ? 'AI clip' : 'Clip'), 110),
- startOffset: at(c), endOffset: Number.isFinite(Number(c.end_time)) ? Math.max(at(c) + 1, Math.ceil(Number(c.end_time))) : undefined,
- url: abs(`/vod/${id}?t=${at(c)}`),
- }));
- const vo = {
- '@context': 'https://schema.org', '@type': 'VideoObject',
- name: title, description: desc, thumbnailUrl: [image],
- uploadDate: isoDate(v.created_at) || undefined,
- duration: iso8601Duration(v.duration_seconds),
- contentUrl: abs(canonicalPath), embedUrl: abs(canonicalPath),
- interactionStatistic: { '@type': 'InteractionCounter', interactionType: 'https://schema.org/WatchAction', userInteractionCount: Number(v.view_count) || 0 },
- author: _authorLd(author), publisher: { '@type': 'Organization', name: SITE_NAME, url: baseUrl() },
- hasPart: parts.length ? parts : undefined,
- };
- const moment = (c) => `/vod/${id}?t=${at(c)}`;
- let extraHtml = owner ? `<p class="source"><a href="${esc(abs(`/@${owner.username}`))}">${esc(author || owner.username)}'s channel</a></p>` : '';
- extraHtml += _itemSection('Clips from this stream', people.map((c) => ({ url: `/clip/${Number(c.id)}`, name: c.title || 'Clip', meta: [_clipperOf(c) ? `clipped by ${_clipperOf(c)}` : null, `at ${_fmtDur(at(c)) || '0:00'}`].filter(Boolean).join(' · ') })));
- if (ai.length) {
- extraHtml += _itemSection('AI Moments from this stream', ai.map((c) => ({ url: `/clip/${Number(c.id)}`, name: c.title || 'AI clip', meta: `AI clip · at ${_fmtDur(at(c)) || '0:00'}` })),
- null, '<p class="ai-disclosure">Cut automatically by OpenVibe\'s auto-clip workflow. No one clipped these.</p>');
- extraHtml += `<p>${ai.slice(0, 6).map((c) => `<a href="${esc(abs(moment(c)))}">Jump to ${esc(_fmtDur(at(c)) || '0:00')}</a>`).join(' · ')}</p>`;
- }
- const snapshot = _detailSnapshot({
- title, byline: author ? `Streamed by ${author}` : null, desc, extraHtml,
- overview: v.ai_overview, transcript: v.ai_transcript, canonicalPath, watchLabel: 'Watch this VOD',
- });
- return {
- title: `${title}${author ? ' — ' + author : ''} | ${SITE_NAME}`, description: desc,
- canonicalPath, image, ogType: 'video.other', robots: indexable ? 'index,follow' : 'noindex,follow',
- video: { duration: Math.floor(Number(v.duration_seconds) || 0) },
- jsonLd: [vo, _breadcrumb([{ name: 'Home', url: '/' }, ...(owner ? [{ name: author || owner.username, url: `/@${owner.username}` }] : [{ name: 'VODs', url: '/vods' }]), { name: title, url: canonicalPath }])],
- snapshot,
- cacheTtlMs: track.partial ? SHORT_CACHE_MS : undefined,
- };
-}
-
-async function _clipMeta(id) {
- const c = await _clipGet(id);
- if (!c || isPrivate(c)) return null; // private: same as unknown (see _vodMeta)
- if (c.duration_seconds == null && c.duration != null) c.duration_seconds = c.duration;
- _overlayAiState(c, 'clip');
- if (isAiClip(c)) return _aiClipMeta(c, id);
- const indexable = Number(c.is_public) === 1 && (!c.visibility || c.visibility === 'public');
- // Media stores ids only: the clipper's name comes from Live's accounts.
- const creator = c.display_name || c.username || _nameOf(_channelOwner(c.user_id));
- const title = clean(c.title || 'Clip', 80);
- const desc = clean(c.ai_overview_short || c.ai_overview || c.description || `A clip from a live stream on ${SITE_NAME}${creator ? ', clipped by ' + creator : ''}.`, 200);
- const image = c.thumbnail_url ? media.publicUrl(c.thumbnail_url) : media.thumbUrl(`clip-${id}`);
- const canonicalPath = `/clip/${id}`;
- const vo = {
- '@context': 'https://schema.org', '@type': 'VideoObject',
- name: title, description: desc, thumbnailUrl: [image],
- uploadDate: isoDate(c.created_at) || undefined,
- duration: iso8601Duration(c.duration_seconds),
- contentUrl: abs(canonicalPath), embedUrl: abs(canonicalPath),
- interactionStatistic: { '@type': 'InteractionCounter', interactionType: 'https://schema.org/WatchAction', userInteractionCount: Number(c.view_count) || 0 },
- author: _authorLd(creator), publisher: { '@type': 'Organization', name: SITE_NAME, url: baseUrl() },
- };
- const channel = _channelOwner(c.channel_user_id);
- const source = await _sourceMoment(c.vod_id, c.start_time);
- const sourceHtml = (source || channel) ? `<p class="source">${source ? `<a href="${esc(abs(source.path))}">Watch it in the full stream</a>` : ''}${source && channel ? ' · ' : ''}${channel ? `<a href="${esc(abs(`/@${channel.username}`))}">${esc(_nameOf(channel))}'s channel</a>` : ''}</p>` : '';
- const snapshot = _detailSnapshot({
- title, byline: creator ? `Clipped by ${creator}${channel && channel.id !== Number(c.user_id) ? ` from ${_nameOf(channel)}'s stream` : ''}` : null, desc,
- extraHtml: sourceHtml,
- overview: c.ai_overview, transcript: c.ai_transcript, canonicalPath, watchLabel: 'Watch this clip',
- });
- return {
- title: `${title} — clip | ${SITE_NAME}`, description: desc,
- canonicalPath, image, ogType: 'video.other', robots: indexable ? 'index,follow' : 'noindex,follow',
- video: { duration: Math.floor(Number(c.duration_seconds) || 0) },
- jsonLd: [vo, _breadcrumb([{ name: 'Home', url: '/' }, ...(channel ? [{ name: _nameOf(channel), url: `/@${channel.username}` }] : [{ name: 'Clips', url: '/clips' }]), { name: title, url: canonicalPath }])],
- snapshot,
- };
-}
-
-/** An auto-clip: "AI clip · from <streamer>'s stream", canonical to the VOD at the moment. */
-async function _aiClipMeta(c, id) {
- const owner = _channelOwner(c.channel_user_id != null ? c.channel_user_id : c.user_id);
- const streamer = _nameOf(owner);
- const from = streamer ? `${streamer}'s stream` : 'a live stream';
- const title = clean(c.title || 'AI clip', 80);
- const source = await _sourceMoment(c.vod_id, c.start_time);
- const desc = clean(`AI-generated clip from ${from} on ${SITE_NAME}. ${c.ai_overview_short || c.ai_overview || c.description || ''}`, 200);
- const image = c.thumbnail_url ? media.publicUrl(c.thumbnail_url) : media.thumbUrl(`clip-${id}`);
- const selfPath = `/clip/${id}`;
- const vo = {
- '@context': 'https://schema.org', '@type': 'VideoObject',
- name: title, description: desc, thumbnailUrl: [image],
- uploadDate: isoDate(c.created_at) || undefined,
- duration: iso8601Duration(c.duration_seconds),
- contentUrl: abs(selfPath), embedUrl: abs(selfPath),
- // No author or creator: a workflow cut this, not a person.
- keywords: AI_KEYWORDS,
- isBasedOn: source ? abs(source.path) : undefined,
- publisher: { '@type': 'Organization', name: SITE_NAME, url: baseUrl() },
- };
- const sourceLink = source
- ? `<p class="source"><a href="${esc(abs(source.path))}">Watch this moment in the full stream</a>${owner ? ` · <a href="${esc(abs(`/@${owner.username}`))}">${esc(streamer)}'s channel</a>` : ''}</p>`
- : (owner ? `<p class="source"><a href="${esc(abs(`/@${owner.username}`))}">${esc(streamer)}'s channel</a></p>` : '');
- const snapshot = _detailSnapshot({
- title, byline: `AI clip · from ${from}`, desc,
- extraHtml: `<p class="ai-disclosure">Cut automatically by OpenVibe's auto-clip workflow when chat reacted. No person clipped it.</p>${sourceLink}`,
- overview: c.ai_overview, transcript: c.ai_transcript, canonicalPath: selfPath, watchLabel: 'Watch this AI clip',
- });
- return {
- title: `${title} — AI clip from ${from} | ${SITE_NAME}`, description: desc,
- canonicalPath: source ? source.path : selfPath, ogUrlPath: selfPath, image, ogType: 'video.other', robots: AI_ROBOTS,
- video: { duration: Math.floor(Number(c.duration_seconds) || 0) },
- jsonLd: [vo, _breadcrumb([{ name: 'Home', url: '/' }, { name: 'AI Moments', url: '/moments' }, { name: title, url: selfPath }])],
- snapshot,
- };
-}
-
-async function _pasteMeta(slug) {
- const p = await _pasteGet(slug);
- if (!p) return null;
- if (isAiPaste(p)) return _aiPasteMeta(p, slug);
- const isScreenshot = p.type === 'screenshot';
- // Only public, non-NSFW, non-burn pastes are indexable.
- const indexable = (p.visibility === 'public' || p.visibility == null) && !Number(p.is_nsfw) && !Number(p.burn_after_read);
- const author = p.display_name || p.username;
- const title = clean(p.title || (isScreenshot ? 'Screenshot' : 'Paste'), 80);
- const desc = clean(p.ai_summary || (isScreenshot ? `A screenshot shared on ${SITE_NAME}.` : String(p.content || '').slice(0, 220)) || `A paste on ${SITE_NAME}.`, 200);
- let image = DEFAULT_OG_IMAGE;
- if (isScreenshot && p.screenshot_url) image = media.publicUrl(p.screenshot_url);
- else if (isScreenshot && p.screenshot_path) image = media.screenshotUrl(path.basename(p.screenshot_path));
- const canonicalPath = `/p/${slug}`;
- let tags = [];
- try { tags = p.ai_tags ? JSON.parse(p.ai_tags) : []; } catch { tags = []; }
- const ld = isScreenshot
- ? { '@context': 'https://schema.org', '@type': 'ImageObject', name: title, description: desc, contentUrl: image, uploadDate: isoDate(p.created_at) || undefined, author: _authorLd(author) }
- : { '@context': 'https://schema.org', '@type': 'SoftwareSourceCode', name: title, description: desc, programmingLanguage: p.language && p.language !== 'text' ? p.language : undefined, dateCreated: isoDate(p.created_at) || undefined, author: _authorLd(author), keywords: (Array.isArray(tags) && tags.length) ? tags.join(', ') : undefined };
- const snapshot = _detailSnapshot({
- title, byline: author ? `Shared by ${author}` : null, desc,
- overview: p.ai_summary, transcript: (!isScreenshot ? String(p.content || '').slice(0, 1200) : null),
- canonicalPath, watchLabel: 'View this paste',
- });
- return {
- title: `${title} — paste | ${SITE_NAME}`, description: desc,
- canonicalPath, image, ogType: isScreenshot ? 'article' : 'article', robots: indexable ? 'index,follow' : 'noindex,follow',
- jsonLd: [ld, _breadcrumb([{ name: 'Home', url: '/' }, { name: 'Pastes', url: '/pastes' }, { name: title, url: canonicalPath }])],
- snapshot,
- };
-}
-
-/** The VOD recorded from a stream (the longest public one), for items that know only their stream. */
-const _vodOfStream = (streamId) => _cached(`vs:${streamId}`, async () => {
- const r = await media.listVods({ stream_id: streamId, limit: 3 });
- const rows = (r && r.vods) || [];
- return rows.filter((v) => v && !isPrivate(v) && Number(v.is_public) !== 0)
- .sort((a, b) => (Number(b.duration_seconds ?? b.duration) || 0) - (Number(a.duration_seconds ?? a.duration) || 0))[0] || null;
-});
-
-/**
- * An AI moment paste (a frame the AI picked from a VOD, or one it "caught live"):
- * "AI note · from <stream>", canonical to the VOD at that second when the VOD is known.
- */
-async function _aiPasteMeta(p, slug) {
- const meta = _json(p.metadata) || {};
- const streamId = p.stream_id || meta.stream_id || null;
- let stream = null;
- try { stream = streamId ? db.getStreamById(Number(streamId)) : null; } catch { stream = null; }
- const owner = stream ? _channelOwner(stream.user_id) : (meta.username ? (() => { try { return db.getUserByUsername(String(meta.username)); } catch { return null; } })() : null);
- const streamer = _nameOf(owner);
- const streamTitle = stream && stream.title ? clean(stream.title, 90) : null;
- const from = streamTitle ? `"${streamTitle}"` : (streamer ? `${streamer}'s stream` : 'a live stream');
- // The source: the VOD link the job recorded, else the VOD of the stream it was caught on.
- let source = null;
- const link = typeof meta.vod_link === 'string' ? meta.vod_link.match(VOD_MOMENT_RE) : null;
- if (link) source = await _sourceMoment(link[1], link[2]);
- else if (streamId) {
- let v = null;
- try { v = await _vodOfStream(Number(streamId)); } catch { v = null; }
- if (v) source = await _sourceMoment(v.id, meta.offset);
- }
- const isScreenshot = p.type === 'screenshot';
- const title = clean(p.title || 'AI moment', 80);
- const desc = clean(`AI-generated note from ${from} on ${SITE_NAME}. ${p.ai_summary || ''}`, 200);
- let image = DEFAULT_OG_IMAGE;
- if (isScreenshot && p.screenshot_url) image = media.publicUrl(p.screenshot_url);
- const selfPath = `/p/${slug}`;
- const ld = {
- '@context': 'https://schema.org', '@type': isScreenshot ? 'ImageObject' : 'CreativeWork',
- name: title, description: desc, contentUrl: isScreenshot ? image : undefined,
- dateCreated: isoDate(p.created_at) || undefined,
- // No author: the AI wrote it, and it is not a person.
- keywords: AI_KEYWORDS,
- isBasedOn: source ? abs(source.path) : undefined,
- publisher: { '@type': 'Organization', name: SITE_NAME, url: baseUrl() },
- };
- const at = meta.offset != null && Number.isFinite(Number(meta.offset)) ? ` at ${_fmtDur(Number(meta.offset)) || '0:00'}` : '';
- const sourceLink = source
- ? `<p class="source"><a href="${esc(abs(source.path))}">Watch this moment in the full stream${esc(at)}</a>${owner ? ` · <a href="${esc(abs(`/@${owner.username}`))}">${esc(streamer)}'s channel</a>` : ''}</p>`
- : (owner ? `<p class="source"><a href="${esc(abs(`/@${owner.username}`))}">${esc(streamer)}'s channel</a></p>` : '');
- const snapshot = _detailSnapshot({
- title, byline: `AI note · from ${from}${at}`, desc,
- extraHtml: `<p class="ai-disclosure">Written automatically by OpenVibe's AI moments workflow${meta.live ? ' while the stream was live' : ' from the recording'}. No person wrote it.</p>${sourceLink}`,
- overview: null, transcript: null, canonicalPath: selfPath, watchLabel: 'View this AI note',
- });
- return {
- title: `${title} — AI note from ${from} | ${SITE_NAME}`, description: desc,
- canonicalPath: source ? source.path : selfPath, ogUrlPath: selfPath, image, ogType: 'article', robots: AI_ROBOTS,
- jsonLd: [ld, _breadcrumb([{ name: 'Home', url: '/' }, { name: 'AI Moments', url: '/moments' }, { name: title, url: selfPath }])],
- snapshot,
- };
-}
-
-function _breadcrumb(items) {
- return {
- '@context': 'https://schema.org', '@type': 'BreadcrumbList',
- itemListElement: items.map((it, i) => ({ '@type': 'ListItem', position: i + 1, name: clean(it.name, 90), item: abs(it.url) })),
- };
-}
-// `extraHtml` is markup the caller built (and escaped) itself.
-function _detailSnapshot({ title, byline, desc, extraHtml, overview, transcript, canonicalPath, watchLabel }) {
- let html = `<article><h1>${esc(title)}</h1>`;
- if (byline) html += `<p class="byline">${esc(byline)}</p>`;
- if (desc) html += `<p>${esc(desc)}</p>`;
- if (extraHtml) html += extraHtml;
- if (overview && String(overview).trim()) html += `<section><h2>AI overview</h2><p>${esc(clean(overview, 1200))}</p></section>`;
- if (transcript && String(transcript).trim()) html += `<section><h2>Transcript</h2><p>${esc(clean(transcript, 1500))}</p></section>`;
- html += `<p><a href="${esc(abs(canonicalPath))}">${esc(watchLabel || 'Open')}</a> on ${SITE_NAME}.</p></article>`;
- return html;
-}
-
-// ── Inject metadata into the base index.html ────────────────────────────────────────────────
-// The base document comes from server/web/assets.js: index.html with its asset URLs rewritten to
-// content hashes, re-read when index.html or any asset it references changes (checked at most
-// every 2s). A change that only touches public/ therefore goes live with a git pull and no
-// restart — and a restart drops every live RTMP stream, because RTMP ingest is owned by this
-// process (socket activation only covers HTTP).
-const assets = require('../web/assets');
-let _baseVersion = null;
-function _base() {
- let doc = null;
- try { doc = assets.document('index.html'); } catch { doc = null; }
- if (!doc) return '';
- if (doc.version !== _baseVersion) {
- _baseVersion = doc.version;
- // Pages rendered from the old document reference old asset versions.
- if (typeof _cache !== 'undefined') _cache.clear();
- }
- return doc.html;
-}
-
-function _headBlock(meta) {
- const canonical = abs(meta.canonicalPath || '/');
- // og:url is the page itself: a shared AI clip previews as that clip, while its canonical names the source.
- const ogUrl = meta.ogUrlPath ? abs(meta.ogUrlPath) : canonical;
- const img = abs(meta.image || DEFAULT_OG_IMAGE);
- const parts = [
- PASTES_META,
- `<title>${esc(meta.title)}</title>`,
- `<meta name="description" content="${esc(meta.description)}">`,
- `<meta name="robots" content="${esc(meta.robots || 'index,follow')}">`,
- `<link rel="canonical" href="${esc(canonical)}">`,
- `<meta property="og:type" content="${esc(meta.ogType || 'website')}">`,
- `<meta property="og:site_name" content="${SITE_NAME}">`,
- `<meta property="og:title" content="${esc(meta.title)}">`,
- `<meta property="og:description" content="${esc(meta.description)}">`,
- `<meta property="og:url" content="${esc(ogUrl)}">`,
- `<meta property="og:image" content="${esc(img)}">`,
- `<meta property="og:locale" content="en_US">`,
- `<meta name="twitter:card" content="${meta.video ? 'player' : 'summary_large_image'}">`,
- `<meta name="twitter:title" content="${esc(meta.title)}">`,
- `<meta name="twitter:description" content="${esc(meta.description)}">`,
- `<meta name="twitter:image" content="${esc(img)}">`,
- ];
- if (meta.video && meta.video.duration) {
- parts.push(`<meta property="og:video:duration" content="${meta.video.duration}">`);
- parts.push(`<meta property="video:duration" content="${meta.video.duration}">`);
- }
- for (const l of (meta.jsonLd || [])) parts.push(jsonLd(l));
- if (meta.snapshot) parts.push(NOJS_STYLE);
- return '\n' + parts.map(x => ' ' + x).join('\n') + '\n';
-}
-
-// The page body below is visually hidden while the SPA boots (it removes it, public/js/app.js).
-// Without JavaScript nothing boots, so the body is shown instead of the empty app shell.
-const PRERENDER_STYLE = 'position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:normal;border:0;';
-const NOJS_STYLE = '<noscript><style>#seo-prerender{position:static!important;width:auto!important;height:auto!important;margin:0 auto!important;padding:16px!important;overflow:visible!important;clip:auto!important;max-width:960px;line-height:1.5}#seo-prerender nav{margin:8px 0 16px}#app{display:none!important}</style></noscript>';
-// The site's own links, for a reader without JavaScript (the navbar is built by a script).
-const NOJS_NAV = `<nav aria-label="${SITE_NAME}"><a href="/">${SITE_NAME}</a> · <a href="/content">Content</a> · <a href="/moments">AI Moments</a> · <a href="/chat">Chat</a> · <a href="/documentation">API docs</a></nav>`;
-function _prerender(html, snapshot) {
- return html.replace(/(<body[^>]*>)/i, `$1\n<div id="seo-prerender" style="${PRERENDER_STYLE}">${NOJS_NAV}${snapshot}</div>`);
-}
-function render(meta, urlPath) {
- let html = _base();
- if (!html) return null;
- html = assets.renderRoute(html, urlPath || meta.canonicalPath || '/');
-
- // Only the <head> is rewritten, so only the <head> is scanned.
- //
- // These six patterns used to run over the whole document. index.html is ~378KB and the head
- // is the first ~8.5KB of it, so five /ig regexes were scanning 370KB of page markup that
- // cannot contain a <title> or an og: tag — measured at 60ms of blocking CPU per uncached
- // render, against 0.73ms for the head alone. That cost lands on every cache miss, and the
- // sitemap advertises far more URLs than the 500-entry cache holds, so a crawler walking it
- // misses every time.
- const headEnd = html.search(/<\/head>/i);
- let head = headEnd === -1 ? html : html.slice(0, headEnd);
- const rest = headEnd === -1 ? '' : html.slice(headEnd);
-
- // Strip the hardcoded homepage tags we're replacing (title, description, canonical, all
- // og:/twitter:, robots) so there are no duplicates.
- head = head
- .replace(/\s*<title>[\s\S]*?<\/title>/i, '')
- .replace(/\s*<meta\s+name=["']description["'][^>]*>/ig, '')
- .replace(/\s*<meta\s+name=["']robots["'][^>]*>/ig, '')
- .replace(/\s*<link\s+rel=["']canonical["'][^>]*>/ig, '')
- .replace(/\s*<meta\s+property=["']og:[^"']*["'][^>]*>/ig, '')
- .replace(/\s*<meta\s+name=["']twitter:[^"']*["'][^>]*>/ig, '');
-
- // Insert the fresh head block just before </head>.
- html = headEnd === -1
- ? head.replace(/<\/head>/i, _headBlock(meta) + '</head>')
- : head + _headBlock(meta) + rest;
- // Server-rendered crawlable content, right after <body>. It's REAL content (so AI text
- // extractors read it — unlike <noscript>, which many strip), but visually-hidden so users
- // never see a flash, and the SPA removes #seo-prerender on boot (see app.js). Not cloaking:
- // it summarises the same content the SPA renders.
- if (meta.snapshot) html = _prerender(html, meta.snapshot);
- return html;
-}
-
-/**
- * The SPA shell the fallback sends (server/index.js, server/web/page-status.js) for a page this
- * renderer did not answer. The shell's own head describes the home page, and a page that is not
- * the home page must not claim to be it:
- * 404 → "Page not found", robots noindex, no canonical, no og:url, no structured data, and a
- * not-found body for readers without JavaScript;
- * 200 → the home canonical and og:url dropped (a channel slot, /@user/<slot>, names /@user).
- */
-function shellHtml(urlPath, status) {
- let html = _base();
- if (!html) return null;
- const p = String(urlPath || '/');
- html = assets.renderRoute(html, p).replace(/<\/head>/i, `${PASTES_META}</head>`);
- if (p === '/' && status !== 404) return html;
- const headEnd = html.search(/<\/head>/i);
- if (headEnd === -1) return html;
- let head = html.slice(0, headEnd);
- const rest = html.slice(headEnd);
- head = head
- .replace(/\s*<link\s+rel=["']canonical["'][^>]*>/ig, '')
- .replace(/\s*<meta\s+property=["']og:url["'][^>]*>/ig, '')
- .replace(/\s*<meta\s+name=["']twitter:url["'][^>]*>/ig, '');
- if (status === 404) {
- head = head
- .replace(/\s*<title>[\s\S]*?<\/title>/i, '')
- .replace(/\s*<meta\s+name=["']description["'][^>]*>/ig, '')
- .replace(/\s*<meta\s+name=["']robots["'][^>]*>/ig, '')
- .replace(/\s*<meta\s+property=["']og:[^"']*["'][^>]*>/ig, '')
- .replace(/\s*<meta\s+name=["']twitter:[^"']*["'][^>]*>/ig, '')
- .replace(/\s*<script type="application\/ld\+json">[\s\S]*?<\/script>/ig, '');
- head += `\n <title>Page not found — ${SITE_NAME}</title>\n <meta name="description" content="There is nothing at this address on ${SITE_NAME}.">\n <meta name="robots" content="noindex">\n ${NOJS_STYLE}\n`;
- return _prerender(head + rest, `<h1>Page not found</h1><p>There is nothing at this address on ${SITE_NAME}. It may have been deleted or made private, or the link may be mistyped.</p><p><a href="/">Go to the home page</a></p>`);
- }
- const slot = p.match(/^\/@([A-Za-z0-9_]{3,24})\/[^/]+\/?$/);
- if (slot) {
- let user = null;
- try { user = db.getUserByUsername(slot[1]); } catch { user = null; }
- if (user) head += `\n <link rel="canonical" href="${esc(abs(`/@${user.username}`))}">\n`;
- }
- return head + rest;
-}
-
-// ── Cache (rendered HTML per path, short TTL) ───────────────────────────────────────────────
-const _cache = new Map(); // path (with ?page=N) -> { html, at, ttl }
-const CACHE_TTL_MS = 5 * 60 * 1000;
-const CACHE_MAX = 500;
-function _cacheGet(key) { const e = _cache.get(key); return e && (Date.now() - e.at) < e.ttl ? e : null; }
-function _cacheSet(key, html, ttl = CACHE_TTL_MS) {
- if (_cache.size >= CACHE_MAX) { const first = _cache.keys().next().value; _cache.delete(first); }
- _cache.set(key, { html, at: Date.now(), ttl });
-}
-
-const SEO_ROUTE_RE = /^\/(?:$|content$|moments$|vods$|clips$|pastes$|arena$|chat$|search$|vod\/\d+$|clip\/\d+$|recap\/\d+$|p\/[A-Za-z0-9_-]+$|@[A-Za-z0-9_]{3,24}$)/;
-
-/** ?page=N on a channel page (its video list); 1 everywhere else and for anything that is not a number. */
-function _pageParam(p, query) {
- const feed = p === '/content' || p === '/moments';
- if (!feed && !CHANNEL_PATH_RE.test(p)) return 1;
- const raw = query && typeof query.page === 'string' ? query.page : '';
- return /^\d{1,4}$/.test(raw) ? Math.min(feed ? FEED_MAX_PAGE : CHANNEL_MAX_PAGE, Math.max(1, parseInt(raw, 10))) : 1;
-}
-
-async function middleware(req, res, next) {
- if (req.method !== 'GET') return next();
- const p = (req.path || '/').replace(/\/+$/, '') || '/';
- if (!SEO_ROUTE_RE.test(p)) return next();
- // A paste Community owns is never rendered here (the handover answers it first; this keeps it
- // so if the mount order ever changes).
- if (p.startsWith('/p/')) return next();
- // Every client gets the same page, whatever it sends as Accept: browsers, crawlers, curl and
- // monitors. (It used to be skipped unless Accept named text/html, so a crawler or tool that sent
- // */* got the home page's title and canonical for every channel, VOD and clip.) Nothing fetches
- // these paths for data: the API is under /api.
- const page = _pageParam(p, req.query);
- const key = page > 1 ? `${p}?page=${page}` : p;
- // This renderer and the SPA fallback's status check share one wait on upstreams (page-status.js).
- req.ovLookupDeadlineAt = Date.now() + pageStatus.LOOKUP_DEADLINE_MS;
- const send = (html, ttl) => {
- res.set('Cache-Control', `public, max-age=${Math.max(0, Math.round(ttl / 1000))}`);
- res.set('Content-Security-Policy-Report-Only', assets.cspReportOnly(html));
- res.type('html');
- return res.send(html);
- };
- try {
- // Check for a changed index.html before trusting the page cache: a cache hit would otherwise
- // skip the check and keep serving pages that point at the previous asset versions.
- _base();
- const cached = _cacheGet(key);
- if (cached) return send(cached.html, cached.ttl);
- const meta = await _pageMeta(p, { page });
- if (!meta) return next(); // unknown, private or not found: the SPA fallback answers (404 when it names nothing)
- const html = render(meta, p);
- if (!html) return next();
- const ttl = meta.cacheTtlMs || CACHE_TTL_MS;
- _cacheSet(key, html, ttl);
- return send(html, ttl);
- } catch (e) {
- console.warn('[SEO] render failed for', p, '-', e.message);
- return next();
- }
-}
-
-// ── Dynamic sitemap.xml (cached ~1h) ────────────────────────────────────────────────────────
-let _sitemap = null, _sitemapAt = 0;
-const SITEMAP_TTL_MS = 60 * 60 * 1000;
-const SITEMAP_CAP = 5000; // per content type
-function _urlTag(loc, lastmod, changefreq, priority) {
- return `<url><loc>${esc(abs(loc))}</loc>` +
- (lastmod ? `<lastmod>${esc(lastmod)}</lastmod>` : '') +
- (changefreq ? `<changefreq>${changefreq}</changefreq>` : '') +
- (priority ? `<priority>${priority}</priority>` : '') + `</url>`;
-}
-async function buildSitemap() {
- const urls = [];
- const seenChannels = new Set();
- // Media rows carry Live user ids, not names: a channel is named from Live's accounts (banned
- // accounts are left out).
- const names = new Map();
- const channelOf = (row, id) => {
- if (row.username) return row.username;
- if (id == null) return null;
- if (!names.has(id)) {
- let u = null;
- try { u = db.getUserById(Number(id)); } catch { u = null; }
- names.set(id, u && !(u.is_banned === 1 || u.is_banned === true) ? u.username : null);
- }
- return names.get(id);
- };
- const statics = [['/', 'daily', '1.0'], ['/content', 'hourly', '0.9'], ['/moments', 'hourly', '0.7'], ['/vods', 'hourly', '0.8'], ['/clips', 'hourly', '0.8'], ['/pastes', 'hourly', '0.7'], ['/chat', 'daily', '0.5'], ['/arena', 'hourly', '0.6']];
- // Rendered docs (server/docs/routes.js): /docs is the index (README.md), the rest by file name.
- try {
- for (const f of require('fs').readdirSync(require('path').join(__dirname, '../../docs'))) {
- if (!f.endsWith('.md')) continue;
- statics.push([f === 'README.md' ? '/docs' : `/docs/${f.slice(0, -3)}`, 'weekly', '0.5']);
- }
- } catch { /* docs folder absent in some deploys */ }
- for (const [u, cf, pr] of statics) urls.push(_urlTag(u, null, cf, pr));
- const page = async (fetch, per, cap, emit) => {
- let off = 0;
- while (off < cap) {
- let rows = [];
- try { rows = (await fetch(per, off)) || []; } catch { break; }
- for (const r of rows) emit(r);
- if (rows.length < per) break;
- off += per;
- }
- };
- await page((l, o) => media.listVods({ limit: l, offset: o }).then(r => r?.vods || []), 200, SITEMAP_CAP, (v) => {
- urls.push(_urlTag(`/vod/${v.id}`, isoDate(v.created_at), 'weekly', '0.6'));
- const ch = channelOf(v, v.user_id);
- if (ch) seenChannels.add(ch);
- });
- // People's clips only: AI Moments are noindex and never listed (the /moments
- // collection above is their indexable form). The filter is checked again per row, so an
- // upstream that ignores it still cannot put an AI item here.
- await page((l, o) => media.listClips({ limit: l, offset: o, auto_generated: 0 }).then(r => r?.clips || []), 200, SITEMAP_CAP, (c) => {
- if (isAiClip(c)) return;
- urls.push(_urlTag(`/clip/${c.id}`, isoDate(c.created_at), 'weekly', '0.6'));
- const ch = channelOf({}, c.channel_user_id != null ? c.channel_user_id : c.user_id);
- if (ch) seenChannels.add(ch);
- });
- // No /p/ here: pastes are OpenVibe.Community's, and its sitemap lists them under their canonical URL.
- for (const u of seenChannels) if (u) urls.push(_urlTag(`/@${u}`, null, 'daily', '0.6'));
- return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.join('\n')}\n</urlset>`;
-}
-async function sitemapHandler(req, res) {
- if (!_sitemap || (Date.now() - _sitemapAt) > SITEMAP_TTL_MS) {
- try { _sitemap = await buildSitemap(); _sitemapAt = Date.now(); }
- catch (e) { console.warn('[SEO] sitemap build failed:', e.message); if (!_sitemap) return res.status(500).end(); }
- }
- res.set('Content-Type', 'application/xml; charset=utf-8');
- res.set('Cache-Control', 'public, max-age=3600');
- res.send(_sitemap);
-}
-
-// ── /llms.txt: what this site is, for language models and other automated readers ─────────
-// (llmstxt.org). It names the public pages, the JSON behind them, the API docs, and how people's
-// work is kept apart from what the AI derived from it (roadmap 32.4, 33.8).
-const LLMS_DOCS = [
- ['go-live-in-your-browser', 'Go live from a browser: no OBS, no downloads, no follower minimum (how, devices, questions)'],
- ['whip', 'WHIP ingest API: publish to a channel from a browser or any WHIP client'],
- ['broadcasting', 'Going live: WebRTC, WHIP, RTMP (OBS) and the JSMPEG/CLI path'],
- ['api-tokens', 'Bot and integration tokens (hbt_...) for the API'],
- ['chat-system', 'Chat features and moderation'],
- ['vods-and-clips', 'How VODs and clips are recorded and cut'],
- ['architecture', 'System design and how Live fits the OpenVibe network'],
-];
-function llmsTxt() {
- const b = baseUrl();
- const docs = LLMS_DOCS.filter(([name]) => { try { return fs.existsSync(path.join(__dirname, '../../docs', `${name}.md`)); } catch { return false; } });
- return [
- `# ${SITE_NAME}`,
- '',
- '> Open-source, community-run live streaming. Anyone can go live straight from a web browser, with no OBS, no downloads and no follower, subscriber or equipment requirement: press Go Live, allow camera and microphone, and the stream is live (WebRTC, under a second of delay; Chromebooks, laptops and phones). OBS (RTMP), WHIP and command-line ingest are there for produced shows. Every stream can be recorded as a VOD, clipped and discussed. Part of the OpenVibe network: one account across openvibe.network, openvibe.media, openvibe.community, openvibe.tools and the other OpenVibe sites.',
- '',
- '## Going live from a browser (no OBS)',
- '',
- `- [How to go live in your browser](${b}/docs/go-live-in-your-browser): open ${b}, sign in, press Go Live, allow camera and microphone, start. No software, no follower minimum.`,
- `- [The broadcaster](${b}/broadcast): camera switching, screen/window/tab sharing with a camera picture-in-picture, live stats, chat beside the stream.`,
- `- [Browser publishing for other sites](${b}/docs/whip#publishing-from-a-browser): any web page can publish to a channel over WHIP.`,
- '',
- 'People\'s work and AI-made material are kept apart everywhere on this site. The Content pages list what people made; AI Moments list what the platform\'s AI derived from streams. Every AI item is labelled AI-generated, credited to no person, marked noindex,follow and made canonical to the source VOD at the moment it came from (/vod/<id>?t=<seconds>). In the JSON feeds, AI items carry "ai": true and an "ai_label".',
- '',
- '## What people made',
- '',
- `- [Home](${b}/): who is live now, recent VODs and clips`,
- `- [Content](${b}/content): VODs, the clips people took, and pastes people wrote. JSON: ${b}/api/content/feed (cursor paging; ?type=vods|clips|pastes, ?sort=new|top)`,
- `- Channel pages: ${b}/@<username>, with the channel's videos in pages (${b}/@<username>?page=2)`,
- `- VOD pages: ${b}/vod/<id>; a moment in a VOD: ${b}/vod/<id>?t=<seconds>`,
- `- Clip pages: ${b}/clip/<id>`,
- '- Pastes live on OpenVibe.Community: https://openvibe.community/p/<slug> (Live\'s /p/<slug> redirects there)',
- `- [Chat](${b}/chat) and [the Arena](${b}/arena): mic-judged streamer callouts`,
- '',
- '## What the AI made (AI Moments)',
- '',
- `- [AI Moments](${b}/moments): auto-clips cut when chat reacted, frames the AI picked from streams, and AI-written after-show recaps. JSON: ${b}/api/content/moments`,
- '- AI Moments pages are noindex; the /moments collection is their indexable form. They are never listed in the sitemap.',
- '- An AI clip says "AI clip · from <streamer>\'s stream"; it is never presented as something the streamer or a viewer clipped.',
- '- A streamer can turn AI Moments off for their channel; the AI then makes no new ones from their streams.',
- '',
- '## API and docs',
- '',
- `- [API docs](${b}/documentation): every feature has an open API (chat, streams, clips, overlays, robots, sound commands)`,
- `- [Docs index](${b}/docs)`,
- ...docs.map(([name, what]) => `- [${name}](${b}/docs/${name}): ${what}`),
- '- Source code: https://github.com/OpenVibers/OpenVibe.Live',
- '',
- '## Discovery',
- '',
- `- Sitemap (people's work only): ${b}/sitemap.xml`,
- `- robots.txt: ${b}/robots.txt`,
- '- The OpenVibe network\'s platform descriptor: https://openvibe.network/.well-known/openvibe',
- '',
- ].join('\n');
-}
-function llmsHandler(req, res) {
- res.set('Content-Type', 'text/plain; charset=utf-8');
- res.set('Cache-Control', 'public, max-age=3600');
- res.send(llmsTxt());
-}
-
-// Register: dynamic sitemap and llms.txt first, then the meta middleware. MUST be mounted BEFORE
-// express.static (so it can intercept "/") and before the SPA catch-all.
-function register(app) {
- app.get('/sitemap.xml', sitemapHandler);
- app.get('/llms.txt', llmsHandler);
- app.use(middleware);
- console.log('[SEO] per-route meta + dynamic sitemap + llms.txt registered');
-}
-
-module.exports = { register, middleware, sitemapHandler, buildSitemap, shellHtml, llmsTxt, _pageMeta, render };
@@ -13,6 +13,7 @@ const express = require('express');
@@ -13,6 +13,7 @@ const express = require('express');
const db = require('../db/database');
const db = require('../db/database');
const { channelSocialLinks, catalog } = require('./links');
const { channelSocialLinks, catalog } = require('./links');
const { preview } = require('./preview');
const { preview } = require('./preview');
+const cache = require('openvibe-shared/cache-policy');
const router = express.Router();
const router = express.Router();
@@ -47,7 +48,7 @@ function xTimelinePage(req, res) {
@@ -47,7 +48,7 @@ function xTimelinePage(req, res) {
const h = String(req.query.h || '');
const h = String(req.query.h || '');
if (!/^[A-Za-z0-9_]{1,15}$/.test(h)) return res.status(400).type('text/plain').send('bad handle');
if (!/^[A-Za-z0-9_]{1,15}$/.test(h)) return res.status(400).type('text/plain').send('bad handle');
res.set('Content-Security-Policy', "default-src 'none'; script-src https://platform.twitter.com; frame-src https://platform.twitter.com https://syndication.twitter.com; img-src https: data:; style-src 'unsafe-inline' https://platform.twitter.com; frame-ancestors 'self'");
res.set('Content-Security-Policy', "default-src 'none'; script-src https://platform.twitter.com; frame-src https://platform.twitter.com https://syndication.twitter.com; img-src https: data:; style-src 'unsafe-inline' https://platform.twitter.com; frame-ancestors 'self'");
- res.set('Cache-Control', 'public, max-age=3600');
+ res.set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }));
res.type('html').send(`<!doctype html><html><head><meta charset="utf-8"><meta name="robots" content="noindex"><meta name="viewport" content="width=device-width,initial-scale=1">
res.type('html').send(`<!doctype html><html><head><meta charset="utf-8"><meta name="robots" content="noindex"><meta name="viewport" content="width=device-width,initial-scale=1">
<style>html,body{margin:0;background:transparent;color:#e7e9ea;font:14px system-ui,sans-serif}a{color:#1d9bf0}</style></head>
<style>html,body{margin:0;background:transparent;color:#e7e9ea;font:14px system-ui,sans-serif}a{color:#1d9bf0}</style></head>
<body><a class="twitter-timeline" data-theme="dark" data-chrome="noheader nofooter noborders transparent" data-height="520" href="https://twitter.com/${h}">Posts by @${h}</a>
<body><a class="twitter-timeline" data-theme="dark" data-chrome="noheader nofooter noborders transparent" data-height="520" href="https://twitter.com/${h}">Posts by @${h}</a>
@@ -124,6 +124,7 @@ const { notifyDiscordGoLive } = require('../integrations/discord-webhook');
@@ -124,6 +124,7 @@ const { notifyDiscordGoLive } = require('../integrations/discord-webhook');
// Go-live fan-out lives in ./golive-notify (shared with the RTMP and WHIP ingest paths,
// Go-live fan-out lives in ./golive-notify (shared with the RTMP and WHIP ingest paths,
// which never notified anyone before), with follower-id translation + 60-min dedupe.
// which never notified anyone before), with follower-id translation + 60-min dedupe.
const { notifyFollowersGoLive } = require('./golive-notify');
const { notifyFollowersGoLive } = require('./golive-notify');
+const cache = require('openvibe-shared/cache-policy');
function hasOwn(obj, key) {
function hasOwn(obj, key) {
return Object.prototype.hasOwnProperty.call(obj || {}, key);
return Object.prototype.hasOwnProperty.call(obj || {}, key);
@@ -1667,7 +1668,7 @@ router.get('/setup-progress', requireAuth, async (req, res) => {
@@ -1667,7 +1668,7 @@ router.get('/setup-progress', requireAuth, async (req, res) => {
];
];
const done = tasks.filter(t => t.done).length;
const done = tasks.filter(t => t.done).length;
const next = tasks.find(t => !t.done) || null;
const next = tasks.find(t => !t.done) || null;
- res.set('Cache-Control', 'private, no-store');
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
res.json({ tasks, done, total: tasks.length, next, username: user.username });
res.json({ tasks, done, total: tasks.length, next, username: user.username });
});
});
@@ -21,6 +21,7 @@
@@ -21,6 +21,7 @@
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
const crypto = require('crypto');
const crypto = require('crypto');
+const cache = require('openvibe-shared/cache-policy');
// Where static files are read from on every request. By default the public/ next to this file. In the
// Where static files are read from on every request. By default the public/ next to this file. In the
// release layout (deploy/scripts/deploy.sh) the unit sets OV_APP_ROOT=/opt/openvibe.live/current: the
// release layout (deploy/scripts/deploy.sh) the unit sets OV_APP_ROOT=/opt/openvibe.live/current: the
@@ -349,7 +350,7 @@ function cspReportOnly(html) {
@@ -349,7 +350,7 @@ function cspReportOnly(html) {
return policy;
return policy;
}
}
-const IMMUTABLE = 'public, max-age=31536000, immutable';
+const IMMUTABLE = cache.IMMUTABLE;
function setImmutable(res) { res.setHeader('Cache-Control', IMMUTABLE); res.setHeader('CDN-Cache-Control', IMMUTABLE); }
function setImmutable(res) { res.setHeader('Cache-Control', IMMUTABLE); res.setHeader('CDN-Cache-Control', IMMUTABLE); }
function setNoCache(res) { res.setHeader('Cache-Control', 'no-cache'); res.setHeader('CDN-Cache-Control', 'no-store'); }
function setNoCache(res) { res.setHeader('Cache-Control', 'no-cache'); res.setHeader('CDN-Cache-Control', 'no-store'); }
@@ -9,7 +9,7 @@
@@ -9,7 +9,7 @@
* not-found view (showNotFound in public/js/app.js).
* not-found view (showNotFound in public/js/app.js).
*
*
* The pages, mirroring routeFromURL() in public/js/app.js and the routes in public/features.json
* The pages, mirroring routeFromURL() in public/js/app.js and the routes in public/features.json
- * (server/seo/seo.js renders the crawlable ones first and hands anything it cannot find to the
+ * (server/seo/pages.js renders the crawlable ones first and hands anything it cannot find to the
* fallback):
* fallback):
*
*
* / home
* / home
@@ -211,7 +211,7 @@ function spaFallback(sendShell) {
@@ -211,7 +211,7 @@ function spaFallback(sendShell) {
/**
/**
* Record what another part of the same request just fetched fresh from upstream, so the status
* Record what another part of the same request just fetched fresh from upstream, so the status
- * check does not ask again: the SEO renderer (server/seo/seo.js) runs first, and when it passes a
+ * check does not ask again: the SEO renderer (server/seo/pages.js) runs first, and when it passes a
* page on (missing, or private to this visitor) the fallback would otherwise repeat its fetch. An
* page on (missing, or private to this visitor) the fallback would otherwise repeat its fetch. An
* entry that is still fresh, or a lookup in flight, is left alone.
* entry that is still fresh, or a lookup in flight, is left alone.
*/
*/
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
* so links in chat, search results and clipboards survive. Every paste link Live renders already
* so links in chat, search results and clipboards survive. Every paste link Live renders already
* points at Community, so this only answers old links.
* points at Community, so this only answers old links.
*
*
- * Mounted BEFORE the SEO middleware (server/seo/seo.js). It used to be mounted after it, so every
+ * Mounted BEFORE the SEO middleware (server/seo/pages.js). It used to be mounted after it, so every
* HTML navigation, crawlers included, got Live's own server-rendered copy of the paste, with a
* HTML navigation, crawlers included, got Live's own server-rendered copy of the paste, with a
* canonical on openvibe.live, while only non-HTML clients got the redirect: two indexable pages,
* canonical on openvibe.live, while only non-HTML clients got the redirect: two indexable pages,
* each naming itself canonical, and a different answer for people and machines. Now every client
* each naming itself canonical, and a different answer for people and machines. Now every client
@@ -17,7 +17,7 @@ feed.page = async (name, q) => {
@@ -17,7 +17,7 @@ feed.page = async (name, q) => {
const items = n <= 3 ? Array.from({ length: 24 }, (_, i) => ({ kind: 'clip', id: n * 100 + i, href: `/clip/${n * 100 + i}`, title: `Clip ${n}-${i}`, channel: { display_name: 'Goosely' }, excerpt: 'x' })) : [];
const items = n <= 3 ? Array.from({ length: 24 }, (_, i) => ({ kind: 'clip', id: n * 100 + i, href: `/clip/${n * 100 + i}`, title: `Clip ${n}-${i}`, channel: { display_name: 'Goosely' }, excerpt: 'x' })) : [];
return { items, next: n < 3 ? `c${n + 1}` : null };
return { items, next: n < 3 ? `c${n + 1}` : null };
};
};
-const seo = require('../server/seo/seo');
+const seo = require('../server/seo');
(async () => {
(async () => {
const p1 = await seo._pageMeta('/content', { page: 1 });
const p1 = await seo._pageMeta('/content', { page: 1 });
@@ -1,5 +1,5 @@
@@ -1,5 +1,5 @@
/**
/**
- * /llms.txt (server/seo/seo.js, roadmap 32.4/33.8): plain text that tells language models and other
+ * /llms.txt (server/seo/discovery.js, roadmap 32.4/33.8): plain text that tells language models and other
* automated readers what OpenVibe.Live is, where its public pages and their JSON are, where the API
* automated readers what OpenVibe.Live is, where its public pages and their JSON are, where the API
* docs are, and how people's work is kept apart from what the AI derived. Every docs page it links
* docs are, and how people's work is kept apart from what the AI derived. Every docs page it links
* must exist, and it must answer before the SPA fallback (never the HTML shell).
* must exist, and it must answer before the SPA fallback (never the HTML shell).
@@ -23,7 +23,7 @@ console.error = () => {};
@@ -23,7 +23,7 @@ console.error = () => {};
require('../server/db/database').initDb();
require('../server/db/database').initDb();
const express = require('express');
const express = require('express');
-const seo = require('../server/seo/seo');
+const seo = require('../server/seo');
const app = express();
const app = express();
seo.register(app);
seo.register(app);
app.get('*', (req, res) => res.status(404).type('html').send('<html>shell</html>'));
app.get('*', (req, res) => res.status(404).type('html').send('<html>shell</html>'));
@@ -247,7 +247,7 @@ async function check(name, fn) {
@@ -247,7 +247,7 @@ async function check(name, fn) {
});
});
await check('server-rendered /vod/:id and /clip/:id: private is the unknown-id fall-through', async () => {
await check('server-rendered /vod/:id and /clip/:id: private is the unknown-id fall-through', async () => {
- const seo = require('../server/seo/seo');
+ const seo = require('../server/seo');
assert.strictEqual(await seo._pageMeta('/vod/101'), null);
assert.strictEqual(await seo._pageMeta('/vod/101'), null);
assert.strictEqual(await seo._pageMeta('/vod/102'), null);
assert.strictEqual(await seo._pageMeta('/vod/102'), null);
assert.strictEqual(await seo._pageMeta('/clip/201'), null);
assert.strictEqual(await seo._pageMeta('/clip/201'), null);
@@ -85,7 +85,7 @@ pastesClient.listPastes = async () => ({ pastes: [] });
@@ -85,7 +85,7 @@ pastesClient.listPastes = async () => ({ pastes: [] });
const assets = require('../server/web/assets');
const assets = require('../server/web/assets');
const pageStatus = require('../server/web/page-status');
const pageStatus = require('../server/web/page-status');
const app = express();
const app = express();
-const seo = require('../server/seo/seo');
+const seo = require('../server/seo');
seo.register(app);
seo.register(app);
// As server/index.js sendShell: the shell from seo.shellHtml (a 404 is noindex, no canonical).
// As server/index.js sendShell: the shell from seo.shellHtml (a 404 is noindex, no canonical).
app.get('*', pageStatus.spaFallback((res, urlPath) => {
app.get('*', pageStatus.spaFallback((res, urlPath) => {
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
* The handover used to be mounted after the SEO middleware, so an HTML navigation (every crawler)
* The handover used to be mounted after the SEO middleware, so an HTML navigation (every crawler)
* got Live's own rendered copy with a canonical on openvibe.live, and only a non-HTML client got
* got Live's own rendered copy with a canonical on openvibe.live, and only a non-HTML client got
* the 301: two self-canonical pages and a different answer for people and machines. Checked here:
* the 301: two self-canonical pages and a different answer for people and machines. Checked here:
- * - server/index.js mounts server/web/paste-handover.js before server/seo/seo.js;
+ * - server/index.js mounts server/web/paste-handover.js before server/seo/pages.js;
* - /p/<slug> answers 301 to Community whatever the Accept header or cookie, and /p/<slug>/raw
* - /p/<slug> answers 301 to Community whatever the Accept header or cookie, and /p/<slug>/raw
* 302s to Community's raw text (not Media's);
* 302s to Community's raw text (not Media's);
* - Live's sitemap lists no /p/ URL, and the paste links Live renders, server-side and in the
* - Live's sitemap lists no /p/ URL, and the paste links Live renders, server-side and in the
@@ -44,7 +44,7 @@ pastesClient.listPastes = async (q = {}) => ({ pastes: (Number(q.offset) || 0) ?
@@ -44,7 +44,7 @@ pastesClient.listPastes = async (q = {}) => ({ pastes: (Number(q.offset) || 0) ?
const express = require('express');
const express = require('express');
const app = express();
const app = express();
require('../server/web/paste-handover').register(app);
require('../server/web/paste-handover').register(app);
-const seo = require('../server/seo/seo');
+const seo = require('../server/seo');
seo.register(app);
seo.register(app);
app.get('*', (req, res) => res.status(200).type('html').send('<html>shell</html>'));
app.get('*', (req, res) => res.status(200).type('html').send('<html>shell</html>'));
@@ -74,7 +74,7 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {
@@ -74,7 +74,7 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {
await check('server/index.js mounts the paste handover before the SEO middleware', () => {
await check('server/index.js mounts the paste handover before the SEO middleware', () => {
const src = fs.readFileSync(path.join(__dirname, '../server/index.js'), 'utf8');
const src = fs.readFileSync(path.join(__dirname, '../server/index.js'), 'utf8');
const handover = src.indexOf("require('./web/paste-handover').register(app)");
const handover = src.indexOf("require('./web/paste-handover').register(app)");
- const seoAt = src.indexOf("require('./seo/seo').register(app)");
+ const seoAt = src.indexOf("require('./seo').register(app)");
assert.ok(handover > 0, 'the handover is mounted');
assert.ok(handover > 0, 'the handover is mounted');
assert.ok(seoAt > 0, 'SEO is mounted');
assert.ok(seoAt > 0, 'SEO is mounted');
assert.ok(handover < seoAt, 'handover first');
assert.ok(handover < seoAt, 'handover first');
@@ -255,7 +255,7 @@ const ok = (name) => { pass++; console.log(' ok -', name); };
@@ -255,7 +255,7 @@ const ok = (name) => { pass++; console.log(' ok -', name); };
const cacheAt = h[0].indexOf('_ctxCache.get(id)');
const cacheAt = h[0].indexOf('_ctxCache.get(id)');
assert(checkAt > 0 && cacheAt > 0, 'both the visibility check and the cache read should be present');
assert(checkAt > 0 && cacheAt > 0, 'both the visibility check and the cache read should be present');
assert(checkAt < cacheAt, 'the visibility check must run before the cache is read');
assert(checkAt < cacheAt, 'the visibility check must run before the cache is read');
- assert(/private, no-store/.test(h[0]), 'a private VOD context must not be sent with a shareable Cache-Control');
+ assert(/cache\.htmlHeaders\(\{ private: true \}\)/.test(h[0]) && require('openvibe-shared/cache-policy').htmlHeaders({ private: true }) === 'private, no-store', 'a private VOD context must not be sent with a shareable Cache-Control');
ok('private VOD context is authorised before the cache, and never shareable');
ok('private VOD context is authorised before the cache, and never shareable');
}
}
@@ -368,7 +368,7 @@ async function check(name, fn) {
@@ -368,7 +368,7 @@ async function check(name, fn) {
'server/chat/soundboard-service.js': '101soundboards (host allowlist; the audio download through safeLookup)',
'server/chat/soundboard-service.js': '101soundboards (host allowlist; the audio download through safeLookup)',
'server/chat/tts-engine.js': 'Google / AWS TTS (fixed hosts)',
'server/chat/tts-engine.js': 'Google / AWS TTS (fixed hosts)',
'server/meta/routes.js': 'GitHub API (fixed host)',
'server/meta/routes.js': 'GitHub API (fixed host)',
- 'server/seo/seo.js': 'a local function named fetch (no request)',
+ 'server/seo/discovery.js': 'a local function named fetch (no request)',
'server/news/sources/newsapi-source.js': 'NewsAPI (fixed host)',
'server/news/sources/newsapi-source.js': 'NewsAPI (fixed host)',
'server/news/sources/reddit-source.js': 'Reddit (fixed host)',
'server/news/sources/reddit-source.js': 'Reddit (fixed host)',
'server/news/sources/rss-source.js': 'feeds the site owner configures',
'server/news/sources/rss-source.js': 'feeds the site owner configures',
new file mode 100644
new file mode 100644
@@ -0,0 +1,95 @@
@@ -0,0 +1,95 @@
+/**
+ * Discovery files and asset caching on the real server (server/seo/discovery.js, openvibe-shared/seo
+ * and openvibe-shared/cache-policy): /robots.txt, /sitemap.xml, /llms.txt and /llms-full.txt answer
+ * from the shared builders, and a /shared/ file is immutable for a year only under its current ?v=.
+ */
+'use strict';
+const assert = require('assert');
+const crawl = require('./security-crawl');
+
+const quiet = console.log;
+console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
+console.warn = () => {};
+console.error = () => {};
+
+let failures = 0;
+async function check(name, fn) {
+ try { await fn(); quiet(' ✓', name); }
+ catch (e) { failures++; quiet(' ✗', name, '\n ', e.message); }
+}
+
+(async () => {
+ const tmp = crawl.tempEnv('seo-discovery');
+ let code = 1;
+ try {
+ const srv = await crawl.boot(tmp);
+ const get = (p) => srv.request('GET', p, { timeoutMs: 8000 });
+ quiet('seo discovery');
+
+ await check('/robots.txt welcomes crawlers, names the AI bots and the sitemap', async () => {
+ const r = await get('/robots.txt');
+ assert.strictEqual(r.status, 200);
+ assert.match(r.headers['content-type'], /^text\/plain/);
+ assert.ok(r.text.startsWith('User-agent: *\nAllow: /\n'), r.text.slice(0, 80));
+ assert.ok(/^User-agent: GPTBot$/m.test(r.text) && /^User-agent: ClaudeBot$/m.test(r.text));
+ assert.ok(r.text.includes('Sitemap: https://openvibe.live/sitemap.xml'));
+ assert.strictEqual(r.headers['cache-control'], 'public, max-age=3600, stale-while-revalidate=3600');
+ });
+
+ await check('/sitemap.xml is a urlset of absolute openvibe.live URLs, with the docs and no /p/', async () => {
+ const r = await get('/sitemap.xml');
+ assert.strictEqual(r.status, 200);
+ assert.match(r.headers['content-type'], /^application\/xml/);
+ assert.ok(r.text.includes('<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"'));
+ const locs = [...r.text.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]);
+ for (const u of ['https://openvibe.live/', 'https://openvibe.live/content', 'https://openvibe.live/moments', 'https://openvibe.live/docs/whip']) {
+ assert.ok(locs.includes(u), `missing ${u}`);
+ }
+ assert.ok(locs.every((u) => u.startsWith('https://openvibe.live/')), locs.join(' '));
+ assert.ok(!locs.some((u) => u.includes('/p/')), 'pastes are Community\'s');
+ assert.ok(r.text.includes('<loc>https://openvibe.live/</loc><changefreq>daily</changefreq><priority>1.0</priority>'));
+ assert.strictEqual(r.headers['cache-control'], 'public, max-age=3600, stale-while-revalidate=3600');
+ });
+
+ await check('/llms.txt is an llmstxt.org map that links /llms-full.txt', async () => {
+ const r = await get('/llms.txt');
+ assert.strictEqual(r.status, 200);
+ assert.match(r.headers['content-type'], /^text\/plain/);
+ assert.ok(r.text.startsWith('# OpenVibe.Live\n\n> '));
+ assert.ok(r.text.includes('[llms-full.txt](https://openvibe.live/llms-full.txt)'));
+ assert.ok(r.text.includes('[robots.txt](https://openvibe.live/robots.txt)'));
+ });
+
+ await check('/llms-full.txt carries the docs themselves, under their own URLs', async () => {
+ const r = await get('/llms-full.txt');
+ assert.strictEqual(r.status, 200);
+ assert.match(r.headers['content-type'], /^text\/plain/);
+ assert.ok(r.text.startsWith('# OpenVibe.Live\n\n> '));
+ assert.ok(r.text.includes('\nhttps://openvibe.live/docs/whip\n'), 'the WHIP doc section');
+ assert.ok(r.text.includes('\nhttps://openvibe.live/docs/go-live-in-your-browser\n'));
+ assert.ok(r.text.length > 5000, `only ${r.text.length} characters`);
+ assert.ok(!/<html/i.test(r.text));
+ });
+
+ await check('a /shared/ file is immutable under its current ?v=, short-cached under any other', async () => {
+ const assets = require('../server/web/assets');
+ const v = assets.hashOf('/shared/shipped.js');
+ assert.ok(v, 'shipped.js has a content hash');
+ const current = await get(`/shared/shipped.js?v=${v}`);
+ assert.strictEqual(current.status, 200);
+ assert.strictEqual(current.headers['cache-control'], 'public, max-age=31536000, immutable');
+ assert.strictEqual(current.headers['cdn-cache-control'], 'public, max-age=31536000, immutable');
+ const wrong = await get(`/shared/shipped.js?v=${v === '0123456789ab' ? 'ba9876543210' : '0123456789ab'}`);
+ assert.strictEqual(wrong.status, 200);
+ assert.strictEqual(wrong.headers['cache-control'], 'public, max-age=300, stale-while-revalidate=86400');
+ assert.strictEqual(wrong.headers['cdn-cache-control'], undefined);
+ });
+ code = failures ? 1 : 0;
+ } catch (e) {
+ quiet(' ✗ boot', e && e.stack);
+ } finally {
+ tmp.cleanup();
+ quiet(code ? `\n${failures} check(s) failed` : '\nseo discovery: all checks passed');
+ process.exit(code);
+ }
+})();
@@ -1,5 +1,5 @@
@@ -1,5 +1,5 @@
/**
/**
- * AI Moments pages (server/seo/seo.js, roadmap §33.4 and §33.8): what the AI made from a stream is
+ * AI Moments pages (server/seo/pages.js, roadmap §33.4 and §33.8): what the AI made from a stream is
* labelled as AI-made, credited to no person, kept out of search and pointed at its source.
* labelled as AI-made, credited to no person, kept out of search and pointed at its source.
*
*
* - An auto-clip (Media auto_generated), an AI moment paste (Community origin 'ai') and an
* - An auto-clip (Media auto_generated), an AI moment paste (Community origin 'ai') and an
@@ -81,7 +81,7 @@ const recapJson = (sid, ai, vod) => JSON.stringify({
@@ -81,7 +81,7 @@ const recapJson = (sid, ai, vod) => JSON.stringify({
raw.prepare('INSERT INTO stream_recaps (stream_id, user_id, json, ai) VALUES (?, 3, ?, 1)').run(streamId, recapJson(streamId, true, { id: 10, thumbnail_url: null }));
raw.prepare('INSERT INTO stream_recaps (stream_id, user_id, json, ai) VALUES (?, 3, ?, 1)').run(streamId, recapJson(streamId, true, { id: 10, thumbnail_url: null }));
raw.prepare('INSERT INTO stream_recaps (stream_id, user_id, json, ai) VALUES (?, 3, ?, 0)').run(quietStream, recapJson(quietStream, false, null));
raw.prepare('INSERT INTO stream_recaps (stream_id, user_id, json, ai) VALUES (?, 3, ?, 0)').run(quietStream, recapJson(quietStream, false, null));
-const seo = require('../server/seo/seo');
+const seo = require('../server/seo');
/** Every node of a JSON-LD graph, depth first. */
/** Every node of a JSON-LD graph, depth first. */
function nodes(value, out = []) {
function nodes(value, out = []) {
@@ -1,5 +1,5 @@
@@ -1,5 +1,5 @@
/**
/**
- * Server-rendered channel, VOD and clip pages (server/seo/seo.js, roadmap 32.1/32.2): each has its
+ * Server-rendered channel, VOD and clip pages (server/seo/pages.js, roadmap 32.1/32.2): each has its
* own title, description, canonical, Open Graph and JSON-LD, and a real HTML body that crawlers and
* own title, description, canonical, Open Graph and JSON-LD, and a real HTML body that crawlers and
* readers without JavaScript can use, whatever Accept header the client sends.
* readers without JavaScript can use, whatever Accept header the client sends.
*
*
@@ -86,7 +86,7 @@ pastesClient.getPaste = async () => { throw missing(); };
@@ -86,7 +86,7 @@ pastesClient.getPaste = async () => { throw missing(); };
// ── The app, mounted as server/index.js mounts it ──
// ── The app, mounted as server/index.js mounts it ──
const express = require('express');
const express = require('express');
-const seo = require('../server/seo/seo');
+const seo = require('../server/seo');
const pageStatus = require('../server/web/page-status');
const pageStatus = require('../server/web/page-status');
const app = express();
const app = express();
seo.register(app);
seo.register(app);
@@ -145,6 +145,14 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {
@@ -145,6 +145,14 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {
}
}
});
});
+ await check('/@alice: openvibe-shared/seo head (hreflang alternates, cache policy) and page summary', async () => {
+ const r = await get('/@alice');
+ assert.ok(head(r.body).includes('<link rel="alternate" hreflang="en" href="https://openvibe.live/@alice">'));
+ assert.ok(head(r.body).includes('<link rel="alternate" hreflang="x-default" href="https://openvibe.live/@alice">'));
+ assert.ok(/<section data-ov-summary hidden><h2>Alice \(@alice\) — OpenVibe\.Live<\/h2>/.test(r.body), 'shared page summary in the snapshot');
+ assert.match(r.cache, /^public, max-age=\d+, must-revalidate$/);
+ });
+
await check('/@alice: a real body with its videos, ?page=N links, clips and AI Moments apart', async () => {
await check('/@alice: a real body with its videos, ?page=N links, clips and AI Moments apart', async () => {
const b = bodyOf((await get('/@alice')).body);
const b = bodyOf((await get('/@alice')).body);
assert.ok(b.includes('<h1>Alice (@alice)</h1>'), b.slice(0, 300));
assert.ok(b.includes('<h1>Alice (@alice)</h1>'), b.slice(0, 300));
@@ -241,6 +249,7 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {
@@ -241,6 +249,7 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {
for (const c of CLIENTS) {
for (const c of CLIENTS) {
const r = await get('/vod/100', c.headers);
const r = await get('/vod/100', c.headers);
assert.strictEqual(r.status, 200, c.name);
assert.strictEqual(r.status, 200, c.name);
+ assert.match(r.cache, /^public, max-age=\d+, must-revalidate$/, c.name);
assert.deepStrictEqual(canonicalsOf(r.body), ['https://openvibe.live/vod/100'], c.name);
assert.deepStrictEqual(canonicalsOf(r.body), ['https://openvibe.live/vod/100'], c.name);
assert.ok(/^Build night 100 — Alice \| OpenVibe\.Live$/.test(titleOf(r.body)), titleOf(r.body));
assert.ok(/^Build night 100 — Alice \| OpenVibe\.Live$/.test(titleOf(r.body)), titleOf(r.body));
}
}
@@ -266,6 +275,7 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {
@@ -266,6 +275,7 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {
for (const c of CLIENTS) {
for (const c of CLIENTS) {
const r = await get('/clip/200', c.headers);
const r = await get('/clip/200', c.headers);
assert.strictEqual(r.status, 200, c.name);
assert.strictEqual(r.status, 200, c.name);
+ assert.match(r.cache, /^public, max-age=\d+, must-revalidate$/, c.name);
assert.deepStrictEqual(canonicalsOf(r.body), ['https://openvibe.live/clip/200'], c.name);
assert.deepStrictEqual(canonicalsOf(r.body), ['https://openvibe.live/clip/200'], c.name);
assert.deepStrictEqual(robotsOf(r.body), ['index,follow'], c.name);
assert.deepStrictEqual(robotsOf(r.body), ['index,follow'], c.name);
}
}
@@ -312,7 +322,7 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {
@@ -312,7 +322,7 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {
await check('server/index.js sends the fallback shell through seo.shellHtml', () => {
await check('server/index.js sends the fallback shell through seo.shellHtml', () => {
const src = fs.readFileSync(path.join(__dirname, '../server/index.js'), 'utf8');
const src = fs.readFileSync(path.join(__dirname, '../server/index.js'), 'utf8');
- assert.ok(src.includes("require('./seo/seo').shellHtml"));
+ assert.ok(src.includes("require('./seo').shellHtml"));
assert.ok(src.includes("app.get('*', require('./web/page-status').spaFallback((res, urlPath) => sendShell(res, urlPath)));"));
assert.ok(src.includes("app.get('*', require('./web/page-status').spaFallback((res, urlPath) => sendShell(res, urlPath)));"));
});
});