openvibers/openvibe.live Commits
Server-rendered channel, VOD and clip pages for every client (roadmap 32.1/32.2): the SEO middleware no longer depends on the Accept header (a crawler or tool sending */* or none got the home page's title and canonical for /@finditfixit), and each page gets its own head and a real body. /@user renders for an account with no channel row too, names the channel's own spelling as canonical, lists its videos with ?page=N pages (rel prev/next, self-canonical; past the end is noindex with the canonical on page 1), the clips people took and then the AI Moments, labelled; it is cached a minute since it says who is live. /vod/:id lists its clips, people's then the AI's, carries them as schema.org Clip parts (hasPart, /vod/<id>?t=<s>) and breadcrumbs through the channel; /clip/:id links the moment in the full stream and the channel; names come from Live's accounts (Media rows carry ids only). Without JavaScript the body is shown with the site's links and the empty app shell hidden; with it the route boots and hydrates as before. The SPA fallback's shell comes from seo.shellHtml: a 404 is 'Page not found', noindex, with no canonical, OG or JSON-LD from the home page, and no other page claims the home canonical (a channel slot names /@user). Item fetches wait at most 2 s and share one upstream budget with page-status (req.ovLookupDeadlineAt), and what a fresh fetch learned primes page-status, so a missing or private item still costs one Media call; lists wait 1.5 s and a page rendered without one is cached 30 s. The home rails load in parallel under the same limit. test/seo-ssr.test.js; test/page-status.test.js uses the same shell as server/index.js and checks the 404 head.
Changes
@@@ -1017,7 +1017,20 @@ 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.--app.get('*', require('./web/page-status').spaFallback((res, urlPath) => sendDocument(res, 'index.html', urlPath)));++// The shell comes from server/seo/seo.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; }++function sendShell(res, urlPath) {++ let html = null;++ try { html = _shellHtml ? _shellHtml(urlPath, res.statusCode) : null; } catch { html = null; }++ if (!html) return sendDocument(res, 'index.html', urlPath);++ if (!res.getHeader('Cache-Control')) assets.setNoCache(res);++ try { res.setHeader('Content-Security-Policy-Report-Only', assets.cspReportOnly(html)); } catch { /* */ }++ res.type('html').send(html);++ return true;++}++app.get('*', require('./web/page-status').spaFallback((res, urlPath) => sendShell(res, urlPath)));// ── Global Error Handler ─────────────────────────────────────app.use((err, req, res, _next) => {@@@ -51,9 +51,76 @@ const _feedList = (feed) => _cached(`feed:${feed}`, async () => {}));});const { isPrivate } = require('../media-proxy/access');--const _vodGet = (id) => _cached(`v:${id}`, () => media.getVod(id));--const _clipGet = (id) => _cached(`c:${id}`, () => media.getClip(id));--const _pasteGet = (slug) => _cached(`p:${slug}`, () => require('../pastes-client').getPaste(slug));++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.@@@ -125,7 +192,7 @@ function jsonLd(obj) {// Returns { title, description, canonicalPath, image, ogType, robots, jsonLd:[...], snapshot }// or null to fall through (unknown / private / not found).--async function _pageMeta(routePath) {++async function _pageMeta(routePath, { page = 1 } = {}) {const p = routePath.replace(/\/+$/, '') || '/';const bu = baseUrl();@@@ -143,7 +210,7 @@ async function _pageMeta(routePath) {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(/^\/@([A-Za-z0-9_.-]+)$/))) return _channelMeta(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();@@@ -151,40 +218,134 @@ async function _pageMeta(routePath) {void bu;}--function _channelMeta(username) {-- let ch; try { ch = db.getChannelByUsername(username); } catch { ch = null; }-- if (!ch) return null;-- const name = ch.display_name || ch.username || username;-- const handle = '@' + (ch.username || username);++// ── 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(ch.user_id) : null; } catch { /* */ }++ try { ov = db.getStreamerOverview ? db.getStreamerOverview(acct.userId) : null; } catch { /* */ }let followers = 0;-- try { followers = db.getFollowerCount ? (db.getFollowerCount(ch.user_id) || 0) : 0; } catch { /* */ }-- const bio = clean(ch.bio || '', 300);++ 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 = ch.avatar_url ? abs(ch.avatar_url) : DEFAULT_OG_IMAGE;-- const canonicalPath = `/${handle}`;++ const image = acct.avatarUrl ? abs(acct.avatarUrl) : DEFAULT_OG_IMAGE;const person = {-- '@type': 'Person', name: clean(name, 80), alternateName: handle, url: abs(canonicalPath),++ '@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 snapshot = _detailSnapshot({-- title: `${name} (${handle})`, byline: followers ? `${followers} follower${followers === 1 ? '' : 's'} on ${SITE_NAME}` : null,-- desc: bio || null, overview: (ov && ov.overview) || null, transcript: null,-- canonicalPath, watchLabel: `Visit ${name}'s channel`,-- });++ 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}) — ${SITE_NAME}`, description: desc,-- canonicalPath, image, ogType: 'profile', robots: 'index,follow',-- jsonLd: [profile, _breadcrumb([{ name: 'Home', url: '/' }, { name: name, url: canonicalPath }])],-- snapshot,++ 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 = [];@@@ -267,17 +428,24 @@ async function _homeMeta() {const description = 'Free, open-source live streaming with sub-second WebRTC, OBS/RTMP & CLI ingest, auto VODs & AI clips, restreaming to Twitch/YouTube/Kick, global chat, viewer-controlled robots, and the whole OpenVibe network behind it.';// Pull the actual live content so the source has real, crawlable text.-- let live = [], vods = [], clips = [], pastes = [], stats = null;++ let live = [];try { live = (db.getLiveStreams() || []).slice(0, 12); } catch { /* */ }-- try { vods = (await _vodList(12)) || []; } catch { /* */ }-- try { clips = (await _clipList(12)) || []; } catch { /* */ }-- try { pastes = (await _pasteList(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.-- try { stats = await require('../media-proxy/lookups').withArchiveStats({ ...db.getHomeStats() }); } catch { /* */ }++ 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: v.display_name || v.username, 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: c.display_name || c.username || c.streamer_username, meta: _fmtDur(c.duration_seconds || c.duration) }));++ 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>` : '';@@@ -313,7 +481,7 @@ async function _homeMeta() {},{ '@context': 'https://schema.org', '@type': 'ItemList', name: 'Live & recent on OpenVibe.Live', itemListElement: listEls },];-- return { title, description, canonicalPath: '/', image: DEFAULT_OG_IMAGE, ogType: 'website', robots: 'index,follow', jsonLd, snapshot };++ return { title, description, canonicalPath: '/', image: DEFAULT_OG_IMAGE, ogType: 'website', robots: 'index,follow', jsonLd, snapshot, cacheTtlMs: track.partial ? SHORT_CACHE_MS : undefined };}async function _listMeta(slug, label, description, itemsFn) {@@@ -371,26 +539,40 @@ const _nameOf = (u) => (u ? (u.display_name || u.username) : null);*/async function _sourceMoment(vodId, seconds) {if (vodId == null || !/^\d+$/.test(String(vodId))) return null;-- let v = null;-- try { v = await _vodGet(Number(vodId)); } catch { v = null; }-- if (!v || isPrivate(v) || Number(v.is_public) === 0) 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) {-- let v; try { v = await _vodGet(id); } catch { v = null; }++ 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 author = v.display_name || v.username;++ 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],@@@ -399,22 +581,32 @@ async function _vodMeta(id) {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,++ 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: '/' }, { name: 'VODs', url: '/vods' }, { name: title, url: canonicalPath }])],++ 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) {-- let c; try { c = await _clipGet(id); } catch { c = null; }++ 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');@@@ -435,15 +627,19 @@ async function _clipMeta(id) {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}` : null, desc,++ 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: '/' }, { name: 'Clips', url: '/clips' }, { name: title, url: canonicalPath }])],++ jsonLd: [vo, _breadcrumb([{ name: 'Home', url: '/' }, ...(channel ? [{ name: _nameOf(channel), url: `/@${channel.username}` }] : [{ name: 'Clips', url: '/clips' }]), { name: title, url: canonicalPath }])],snapshot,};}@@@ -487,7 +683,7 @@ async function _aiClipMeta(c, id) {}async function _pasteMeta(slug) {-- let p; try { p = await _pasteGet(slug); } catch { p = null; }++ const p = await _pasteGet(slug);if (!p) return null;if (isAiPaste(p)) return _aiPasteMeta(p, slug);const isScreenshot = p.type === 'screenshot';@@@ -646,8 +842,19 @@ function _headBlock(meta) {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;@@@ -683,24 +890,70 @@ function render(meta, urlPath) {// 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) {-- const style = 'position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:normal;border:0;';-- html = html.replace(/(<body[^>]*>)/i, `$1\n<div id="seo-prerender" style="${style}">${meta.snapshot}</div>`);-- }++ 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);++ 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 -> { html, at }++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) < CACHE_TTL_MS ? e.html : null; }--function _cacheSet(key, html) {++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() });++ _cache.set(key, { html, at: Date.now(), ttl });}--const SEO_ROUTE_RE = /^\/(?:$|content$|moments$|vods$|clips$|pastes$|arena$|vod\/\d+$|clip\/\d+$|recap\/\d+$|p\/[A-Za-z0-9_-]+$|@[A-Za-z0-9_.-]+$)/;++const SEO_ROUTE_RE = /^\/(?:$|content$|moments$|vods$|clips$|pastes$|arena$|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) {++ if (!CHANNEL_PATH_RE.test(p)) return 1;++ const raw = query && typeof query.page === 'string' ? query.page : '';++ return /^\d{1,4}$/.test(raw) ? Math.min(CHANNEL_MAX_PAGE, Math.max(1, parseInt(raw, 10))) : 1;++}async function middleware(req, res, next) {if (req.method !== 'GET') return next();@@@ -709,23 +962,33 @@ async function middleware(req, res, 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 (PASTES_BASE && p.startsWith('/p/')) return next();-- // Only rewrite HTML navigations, not fetch()/XHR/asset probes.-- if (req.headers.accept && req.headers.accept.indexOf('text/html') === -1) 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(p);-- if (cached) { res.set('Cache-Control', 'public, max-age=300'); res.set('Content-Security-Policy-Report-Only', assets.cspReportOnly(cached)); res.type('html'); return res.send(cached); }-- const meta = await _pageMeta(p);-- if (!meta) return next(); // unknown / not found → let the SPA 404 client-side++ 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();-- _cacheSet(p, html);-- res.set('Cache-Control', 'public, max-age=300');-- res.set('Content-Security-Policy-Report-Only', assets.cspReportOnly(html));-- res.type('html');-- return res.send(html);++ 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();@@@ -798,4 +1061,4 @@ function register(app) {console.log('[SEO] per-route meta + dynamic sitemap registered');}--module.exports = { register, middleware, sitemapHandler, buildSitemap, _pageMeta, render };++module.exports = { register, middleware, sitemapHandler, buildSitemap, shellHtml, _pageMeta, render };@@@ -77,9 +77,11 @@ const isMissingError = (err) => !!err && (err.status === 404 || err.status === 4/*** The cached answer for `key`, loading it at most once at a time. Resolves within-- * LOOKUP_DEADLINE_MS; a slower load keeps running and fills the cache for the next request.++ * LOOKUP_DEADLINE_MS, or by `deadlineAt` when the request has already spent part of that budget++ * (the SEO renderer runs first and sets req.ovLookupDeadlineAt); a slower load keeps running and++ * fills the cache for the next request.*/--function lookup(key, load) {++function lookup(key, load, deadlineAt = null) {const now = Date.now();let entry = _cache.get(key);if (!entry || (!entry.pending && now - entry.at >= entry.ttl)) {@@@ -99,8 +101,9 @@ function lookup(key, load) {if (_cache.size > CACHE_MAX) _cache.delete(_cache.keys().next().value);}if (!entry.pending) return Promise.resolve(entry.value);++ const wait = deadlineAt ? Math.max(0, Math.min(LOOKUP_DEADLINE_MS, deadlineAt - Date.now())) : LOOKUP_DEADLINE_MS;let timer;-- const deadline = new Promise((resolve) => { timer = setTimeout(resolve, LOOKUP_DEADLINE_MS, UNKNOWN); });++ const deadline = new Promise((resolve) => { timer = setTimeout(resolve, wait, UNKNOWN); });return Promise.race([entry.pending, deadline]).finally(() => clearTimeout(timer));}@@@ -115,18 +118,18 @@ async function mediaItemStatus(req, kind, id) {if (!ID_RE.test(id)) return NOT_FOUND;const row = await lookup(`${kind}:${id}`, async () => slimMediaRow(kind === 'vod'? await media.getVod(id, { timeoutMs: 10_000 })-- : await media.getClip(id, { timeoutMs: 10_000 })));++ : await media.getClip(id, { timeoutMs: 10_000 })), req.ovLookupDeadlineAt || null);if (row === UNKNOWN) return OK;if (row === MISSING) return NOT_FOUND;return access.canView(visitor(req), row) ? OK : NOT_FOUND;}--async function pasteStatus(slug) {++async function pasteStatus(req, slug) {if (!SLUG_RE.test(slug)) return NOT_FOUND;const found = await lookup(`paste:${slug}`, async () => {const p = await require('../pastes-client').getPaste(slug);return p && typeof p === 'object' ? true : MISSING;-- });++ }, req.ovLookupDeadlineAt || null);return found === MISSING ? NOT_FOUND : OK;}@@@ -159,7 +162,7 @@ async function statusFor(req) {switch (first) {case 'vod': return await mediaItemStatus(req, 'vod', second);case 'clip': return await mediaItemStatus(req, 'clip', second);-- case 'p': return await pasteStatus(second);++ case 'p': return await pasteStatus(req, second);case 'recap':case 'stream': return streamStatus(second);default: return NOT_FOUND;@@@ -190,7 +193,30 @@ 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++ * 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.++ */++function prime(key, value) {++ const e = _cache.get(key);++ if (e && (e.pending || Date.now() - e.at < e.ttl)) return;++ _cache.delete(key);++ _cache.set(key, { at: Date.now(), ttl: value === MISSING ? MISSING_TTL_MS : FOUND_TTL_MS, value, pending: null });++ if (_cache.size > CACHE_MAX) _cache.delete(_cache.keys().next().value);++}++/** A VOD or clip row (`row` null = Media said it does not exist). Only the visibility fields are kept. */++function primeMediaItem(kind, id, row) {++ if ((kind !== 'vod' && kind !== 'clip') || !ID_RE.test(String(id))) return;++ prime(`${kind}:${id}`, row ? slimMediaRow(row) : MISSING);++}++function primePaste(slug, found) {++ if (!SLUG_RE.test(String(slug))) return;++ prime(`paste:${slug}`, found ? true : MISSING);++}++/** Forget cached lookups (tests; or after a change that must show at once). */function clearCache() { _cache.clear(); }--module.exports = { statusFor, spaFallback, clearCache, EXACT, PREFIX, LOOKUP_DEADLINE_MS };++module.exports = { statusFor, spaFallback, clearCache, primeMediaItem, primePaste, EXACT, PREFIX, LOOKUP_DEADLINE_MS };@@@ -87,13 +87,16 @@ pastesClient.listPastes = async () => ({ pastes: [] });const assets = require('../server/web/assets');const pageStatus = require('../server/web/page-status');const app = express();--require('../server/seo/seo').register(app);++const seo = require('../server/seo/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) => {-- const doc = assets.document('index.html');-- if (!doc) return false;-- res.type('html').send(assets.renderRoute(doc.html, urlPath));++ const html = seo.shellHtml(urlPath, res.statusCode);++ if (!html) return false;++ res.type('html').send(html);return true;}));++void assets;let base;function get(p, { user, html = false, method = 'GET' } = {}) {@@@ -178,6 +181,11 @@ server.listen(0, '127.0.0.1', async () => {const unknown = await get('/no-such-page', { html: true });assert.strictEqual(unknown.status, 404);assert.ok(unknown.body.includes('/js/app.js'));++ // …and tells search engines so: noindex, and not the home page's canonical.++ for (const r of [shell, unknown]) {++ assert.ok(r.body.includes('<meta name="robots" content="noindex">'), 'noindex');++ assert.ok(!/<link rel="canonical"/.test(r.body), 'no canonical');++ }});await check('HEAD matches GET, and API paths keep their JSON 404', async () => {nnew file mode 100644@@@ -0,0 +1,297 @@++/**++ * Server-rendered channel, VOD and clip pages (server/seo/seo.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.++ *++ * It used to render only when Accept named text/html, so a crawler or tool sending *\/* got the home++ * page's title and canonical for /@finditfixit. Checked here, with the SEO middleware and the real++ * SPA fallback mounted as server/index.js mounts them (Media stubbed in-process, temp database):++ * - /@user (a channel row or just an account), /vod/:id and /clip/:id answer the same page for++ * every Accept header and user agent, with their own head and body;++ * - a channel's videos page with ?page=N links (rel prev/next), each page self-canonical, and a++ * page past the end noindex with the canonical on page 1;++ * - a VOD lists its clips, people's then the AI's, and carries them as schema.org Clip parts;++ * - a 404 is noindex with no canonical and no home metadata; no other shell page claims the home++ * page as its canonical;++ * - without JavaScript the body is shown (and the empty app shell hidden); with it, the route++ * still boots its feature scripts and the client hydrates as before.++ *++ * node test/seo-ssr.test.js++ */++'use strict';++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++++const tmp = path.join(os.tmpdir(), `ov-seo-ssr-${process.pid}.db`);++process.env.DB_PATH = tmp;++process.env.NODE_ENV = 'test';++delete process.env.PASTES_ON_COMMUNITY;++const quiet = console.log;++console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };++console.warn = () => {};++console.error = () => {};++++const db = require('../server/db/database');++db.initDb();++const raw = db.getDb();++const auth = require('../server/auth/auth');++auth.optionalAuth = (req, res, next) => next();++++const addUser = (id, username, display, extra = '') => raw.prepare(++ `INSERT INTO users (id, username, display_name, email, password_hash, role, bio, created_at)++ VALUES (?, ?, ?, ?, 'x', 'streamer', ?, '2025-01-01 00:00:00')`).run(id, username, display, `${username}@x`, extra);++addUser(3, 'alice', 'Alice', 'I stream woodworking.');++addUser(4, 'bob', 'Bob');++addUser(5, 'finditfixit', 'finditfixit', 'likes vintage video games'); // an account with no channel row++db.ensureChannel(3);++++// ── OpenVibe.Media stand-in ──++const media = require('../server/media-client');++const missing = () => new media.MediaApiError('not found', 404, { error: 'not found' });++let slow = 0;++const day = (n) => new Date(Date.UTC(2026, 8, 1 + n)).toISOString().replace('T', ' ').slice(0, 19);++// Alice has 30 public VODs (100–129, newest first) and one private one.++const VODS = [];++for (let i = 0; i < 30; i++) VODS.push({ id: 100 + i, user_id: 3, title: `Build night ${100 + i}`, visibility: 'public', is_public: true, status: 'ready', duration_seconds: 3600 + i, created_at: day(30 - i) });++VODS.push({ id: 199, user_id: 3, title: 'SECRET VOD', visibility: 'private', is_public: false, status: 'ready', duration_seconds: 60, created_at: day(40) });++const CLIPS = [++ { id: 200, user_id: 4, channel_user_id: 3, vod_id: 100, title: 'Bob clipped the saw', visibility: 'public', is_public: true, status: 'ready', auto_generated: false, start_time: 61.2, end_time: 81.2, duration_seconds: 20 },++ { id: 201, user_id: 3, channel_user_id: 3, vod_id: 100, title: 'Chat lost it', visibility: 'public', is_public: true, status: 'ready', auto_generated: true, start_time: 125, end_time: 150, duration_seconds: 25 },++];++const listed = (rows, q) => {++ const off = Number(q.offset) || 0, lim = Number(q.limit) || 50;++ return rows.slice(off, off + lim);++};++media.listVods = async (q = {}) => {++ if (slow) await new Promise((r) => setTimeout(r, slow));++ const rows = VODS.filter((v) => v.is_public && (q.user_id == null || String(v.user_id) === String(q.user_id)));++ return { vods: listed(rows, q).map((v) => ({ ...v })), total: rows.length };++};++media.listClips = async (q = {}) => {++ if (slow) await new Promise((r) => setTimeout(r, slow));++ const rows = CLIPS.filter((c) => c.is_public++ && (q.channel_user_id == null || String(c.channel_user_id) === String(q.channel_user_id))++ && (q.vod_id == null || String(c.vod_id) === String(q.vod_id))++ && (q.auto_generated == null || Number(c.auto_generated) === Number(q.auto_generated)));++ return { clips: listed(rows, q).map((c) => ({ ...c })), total: rows.length };++};++media.getVod = async (id) => { const v = VODS.find((x) => x.id === Number(id)); if (!v) throw missing(); return { ...v }; };++media.getClip = async (id) => { const c = CLIPS.find((x) => x.id === Number(id)); if (!c) throw missing(); return { ...c }; };++media.request = async () => { throw new media.MediaApiError('stubbed', 0, null); };++const pastesClient = require('../server/pastes-client');++pastesClient.listPastes = async () => ({ pastes: [] });++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 pageStatus = require('../server/web/page-status');++const app = express();++seo.register(app);++app.get('*', pageStatus.spaFallback((res, urlPath) => {++ const html = seo.shellHtml(urlPath, res.statusCode);++ if (!html) return false;++ res.type('html').send(html);++ return true;++}));++++let base;++function get(p, headers = {}) {++ return new Promise((resolve, reject) => {++ const req = http.request(base + p, { method: 'GET', headers }, (res) => {++ let body = '';++ res.on('data', (c) => { body += c; });++ res.on('end', () => resolve({ status: res.statusCode, body, cache: res.headers['cache-control'] || '' }));++ });++ req.on('error', reject);++ req.end();++ });++}++const head = (html) => html.slice(0, html.search(/<\/head>/i));++const titleOf = (html) => ((head(html).match(/<title>([^<]*)<\/title>/) || [])[1] || '').replace(/'/g, "'");++const canonicalsOf = (html) => [...head(html).matchAll(/<link rel="canonical" href="([^"]+)">/g)].map((m) => m[1]);++const robotsOf = (html) => [...head(html).matchAll(/<meta name="robots" content="([^"]+)">/g)].map((m) => m[1]);++const ldOf = (html) => [...head(html).matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => { try { return JSON.parse(m[1]); } catch { return null; } }).filter(Boolean);++const bodyOf = (html) => ((html.match(/<div id="seo-prerender"[^>]*>([\s\S]*?)<\/div>\n/) || [])[1] || '');++const CLIENTS = [++ { name: 'no Accept (curl, monitors)', headers: {} },++ { name: 'Accept */*', headers: { accept: '*/*' } },++ { name: 'Googlebot', headers: { accept: 'text/html,application/xhtml+xml,application/signed-exchange;v=b3,*/*;q=0.8', 'user-agent': 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)' } },++ { name: 'a browser', headers: { accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' } },++];++++let failures = 0;++async function check(name, fn) {++ try { await fn(); quiet(' ✓', name); }++ catch (e) { failures++; quiet(' ✗', name, '\n ', e.message); }++}++++const server = http.createServer(app).listen(0, '127.0.0.1', async () => {++ base = `http://127.0.0.1:${server.address().port}`;++ quiet('Server-rendered pages');++++ await check('/@alice: its own title, canonical, OG and JSON-LD for every client', async () => {++ for (const c of CLIENTS) {++ const r = await get('/@alice', c.headers);++ assert.strictEqual(r.status, 200, c.name);++ assert.strictEqual(titleOf(r.body), 'Alice (@alice) — OpenVibe.Live', `${c.name}: ${titleOf(r.body)}`);++ assert.deepStrictEqual(canonicalsOf(r.body), ['https://openvibe.live/@alice'], c.name);++ assert.ok(r.body.includes('<meta property="og:url" content="https://openvibe.live/@alice">'), c.name);++ assert.ok(r.body.includes('<meta property="og:type" content="profile">'), c.name);++ assert.deepStrictEqual(robotsOf(r.body), ['index,follow'], c.name);++ assert.ok(ldOf(r.body).some((n) => n['@type'] === 'ProfilePage' && n.mainEntity.name === 'Alice'), c.name);++ }++ });++++ 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));++ assert.ok(b.includes('I stream woodworking.'));++ for (let id = 100; id < 112; id++) assert.ok(b.includes(`https://openvibe.live/vod/${id}"`), `VOD ${id} listed`);++ assert.ok(!b.includes('/vod/112"'), 'page 1 holds 12 videos');++ assert.ok(!b.includes('SECRET VOD'), 'no private VOD');++ assert.ok(b.includes('<a rel="next" href="https://openvibe.live/@alice?page=2">Older videos</a>'), 'next page link');++ assert.ok(b.includes('Page 1 of 3'));++ const clipsAt = b.indexOf('<h2>Clips</h2>'), aiAt = b.indexOf('<h2>AI Moments</h2>');++ assert.ok(clipsAt > 0 && aiAt > clipsAt, 'people\'s clips, then the AI\'s');++ assert.ok(b.slice(clipsAt, aiAt).includes('clipped by Bob'));++ assert.ok(b.slice(aiAt).includes('Chat lost it') && b.slice(aiAt).includes('AI clip'));++ const list = ldOf((await get('/@alice')).body).find((n) => n['@type'] === 'ItemList');++ assert.strictEqual(list.itemListElement.length, 12);++ });++++ await check('/@alice?page=2 and ?page=3: self-canonical pages with prev/next; past the end is noindex', async () => {++ const p2 = (await get('/@alice?page=2')).body;++ assert.deepStrictEqual(canonicalsOf(p2), ['https://openvibe.live/@alice?page=2']);++ assert.ok(/page 2/.test(titleOf(p2)), titleOf(p2));++ const b2 = bodyOf(p2);++ assert.ok(b2.includes('/vod/112"') && b2.includes('/vod/123"') && !b2.includes('/vod/111"'));++ assert.ok(b2.includes('<a rel="prev" href="https://openvibe.live/@alice">Newer videos</a>'));++ assert.ok(b2.includes('<a rel="next" href="https://openvibe.live/@alice?page=3">'));++ assert.ok(!b2.includes('<h2>AI Moments</h2>'), 'clips are on page 1 only');++ const b3 = bodyOf((await get('/@alice?page=3')).body);++ assert.ok(b3.includes('/vod/129"') && !b3.includes('rel="next"'), 'the last page has no next link');++ const past = (await get('/@alice?page=9')).body;++ assert.deepStrictEqual(robotsOf(past), ['noindex,follow']);++ assert.deepStrictEqual(canonicalsOf(past), ['https://openvibe.live/@alice']);++ const junk = (await get('/@alice?page=abc')).body;++ assert.deepStrictEqual(canonicalsOf(junk), ['https://openvibe.live/@alice'], 'a non-number is page 1');++ });++++ await check('/@finditfixit (an account with no channel row) gets its own page, not the home page', async () => {++ const r = await get('/@finditfixit', { 'user-agent': 'Googlebot/2.1' });++ assert.strictEqual(r.status, 200);++ assert.strictEqual(titleOf(r.body), 'finditfixit (@finditfixit) — OpenVibe.Live');++ assert.deepStrictEqual(canonicalsOf(r.body), ['https://openvibe.live/@finditfixit']);++ assert.ok(bodyOf(r.body).includes('likes vintage video games'));++ });++++ await check('/@Alice names the channel\'s own spelling as canonical', async () => {++ assert.deepStrictEqual(canonicalsOf((await get('/@Alice')).body), ['https://openvibe.live/@alice']);++ });++++ await check('a 404 is noindex, with no canonical and none of the home page\'s metadata', async () => {++ for (const p of ['/@nobody', '/vod/999', '/clip/999', '/no-such-page', `/vod/199`]) {++ for (const c of CLIENTS.slice(0, 3)) {++ const r = await get(p, c.headers);++ assert.strictEqual(r.status, 404, `${p} ${c.name}`);++ assert.deepStrictEqual(canonicalsOf(r.body), [], `${p}: no canonical`);++ assert.deepStrictEqual(robotsOf(r.body), ['noindex'], `${p}: noindex`);++ assert.strictEqual(titleOf(r.body), 'Page not found — OpenVibe.Live', p);++ assert.ok(!/og:url|og:title|application\/ld\+json/.test(head(r.body)), `${p}: home OG/JSON-LD left in`);++ assert.ok(bodyOf(r.body).includes('<h1>Page not found</h1>'), `${p}: a not-found body`);++ assert.ok(!r.body.includes('SECRET VOD'));++ }++ }++ });++++ await check('no other shell page claims the home page as canonical; a slot names its channel', async () => {++ const dash = (await get('/dashboard')).body;++ assert.deepStrictEqual(canonicalsOf(dash), []);++ const slot = (await get('/@alice/main')).body;++ assert.deepStrictEqual(canonicalsOf(slot), ['https://openvibe.live/@alice']);++ const home = (await get('/')).body;++ assert.deepStrictEqual(canonicalsOf(home), ['https://openvibe.live/']);++ });++++ await check('/vod/100: its own head and a body with people\'s clips, then the AI\'s, as Clip parts', async () => {++ for (const c of CLIENTS) {++ const r = await get('/vod/100', c.headers);++ assert.strictEqual(r.status, 200, 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));++ }++ const r = await get('/vod/100?t=125');++ assert.deepStrictEqual(canonicalsOf(r.body), ['https://openvibe.live/vod/100'], 'a timestamp link is the same page');++ const vo = ldOf(r.body).find((n) => n['@type'] === 'VideoObject');++ assert.deepStrictEqual(vo.author, { '@type': 'Person', name: 'Alice' });++ assert.deepStrictEqual(vo.hasPart.map((x) => [x['@type'], x.startOffset, x.url]), [++ ['Clip', 61, 'https://openvibe.live/vod/100?t=61'],++ ['Clip', 125, 'https://openvibe.live/vod/100?t=125'],++ ]);++ const crumbs = ldOf(r.body).find((n) => n['@type'] === 'BreadcrumbList').itemListElement.map((i) => i.name);++ assert.deepStrictEqual(crumbs, ['Home', 'Alice', 'Build night 100']);++ const b = bodyOf(r.body);++ assert.ok(b.includes('Streamed by Alice') && b.includes('https://openvibe.live/@alice'));++ const people = b.indexOf('<h2>Clips from this stream</h2>'), ai = b.indexOf('<h2>AI Moments from this stream</h2>');++ assert.ok(people > 0 && ai > people);++ assert.ok(b.slice(people, ai).includes('clipped by Bob'));++ assert.ok(b.slice(ai).includes('AI clip · at 2:05'));++ });++++ await check('/clip/200: its own head and a body linking the moment in the full stream', async () => {++ for (const c of CLIENTS) {++ const r = await get('/clip/200', c.headers);++ assert.strictEqual(r.status, 200, c.name);++ assert.deepStrictEqual(canonicalsOf(r.body), ['https://openvibe.live/clip/200'], c.name);++ assert.deepStrictEqual(robotsOf(r.body), ['index,follow'], c.name);++ }++ const b = bodyOf((await get('/clip/200')).body);++ assert.ok(b.includes("Clipped by Bob from Alice's stream"), b.slice(0, 300));++ assert.ok(b.includes('https://openvibe.live/vod/100?t=61'));++ });++++ await check('without JavaScript the body shows (with the site links) and the empty app shell is hidden', async () => {++ const html = (await get('/@alice')).body;++ assert.ok(head(html).includes('<noscript><style>#seo-prerender{position:static!important'), 'no-JS rule in the head');++ assert.ok(head(html).includes('#app{display:none!important}'));++ assert.ok(bodyOf(html).startsWith('<nav aria-label="OpenVibe.Live"><a href="/">OpenVibe.Live</a>'));++ });++++ await check('the client still hydrates: the route boots its feature and the fragment is inlined', async () => {++ const html = (await get('/@alice')).body;++ const boot = html.match(/<script type="application\/json" id="ov-route-features">([^<]*)<\/script>/);++ assert.ok(boot && JSON.parse(boot[1]).includes('channel'), 'the channel feature boots');++ assert.ok(html.includes('/js/app.js'), 'the client router is loaded');++ assert.ok(html.includes('data-fragment="channel" data-fragment-loaded="1"'), 'the channel markup is inlined');++ const src = fs.readFileSync(path.join(__dirname, '../public/js/app.js'), 'utf8');++ assert.ok(/getElementById\('seo-prerender'\)\?\.remove\(\)/.test(src), 'the SPA removes the server body on boot');++ });++++ await check('a slow Media never holds a channel page: rendered without its lists, cached briefly', async () => {++ slow = 4000;++ const t0 = Date.now();++ const r = await get('/@bob');++ const took = Date.now() - t0;++ slow = 0;++ assert.strictEqual(r.status, 200);++ assert.ok(took < 2500, `took ${took}ms`);++ assert.strictEqual(titleOf(r.body), 'Bob (@bob) — OpenVibe.Live');++ assert.ok(/max-age=30\b/.test(r.cache), r.cache);++ });++++ 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("app.get('*', require('./web/page-status').spaFallback((res, urlPath) => sendShell(res, urlPath)));"));++ });++++ server.close();++ try { fs.unlinkSync(tmp); } catch { /* */ }++ for (const ext of ['-wal', '-shm']) { try { fs.unlinkSync(tmp + ext); } catch { /* */ } }++ quiet(failures ? `\n${failures} check(s) failed` : '\nseo ssr: all checks passed');++ process.exit(failures ? 1 : 0);++});
@@ -1017,7 +1017,20 @@ app.get('/banned', (req, res) => {
@@ -1017,7 +1017,20 @@ 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.
-app.get('*', require('./web/page-status').spaFallback((res, urlPath) => sendDocument(res, 'index.html', urlPath)));
+// The shell comes from server/seo/seo.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; }
+function sendShell(res, urlPath) {
+ let html = null;
+ try { html = _shellHtml ? _shellHtml(urlPath, res.statusCode) : null; } catch { html = null; }
+ if (!html) return sendDocument(res, 'index.html', urlPath);
+ if (!res.getHeader('Cache-Control')) assets.setNoCache(res);
+ try { res.setHeader('Content-Security-Policy-Report-Only', assets.cspReportOnly(html)); } catch { /* */ }
+ res.type('html').send(html);
+ return true;
+}
+app.get('*', require('./web/page-status').spaFallback((res, urlPath) => sendShell(res, urlPath)));
// ── Global Error Handler ─────────────────────────────────────
// ── Global Error Handler ─────────────────────────────────────
app.use((err, req, res, _next) => {
app.use((err, req, res, _next) => {
@@ -51,9 +51,76 @@ const _feedList = (feed) => _cached(`feed:${feed}`, async () => {
@@ -51,9 +51,76 @@ const _feedList = (feed) => _cached(`feed:${feed}`, async () => {
}));
}));
});
});
const { isPrivate } = require('../media-proxy/access');
const { isPrivate } = require('../media-proxy/access');
-const _vodGet = (id) => _cached(`v:${id}`, () => media.getVod(id));
-const _clipGet = (id) => _cached(`c:${id}`, () => media.getClip(id));
-const _pasteGet = (slug) => _cached(`p:${slug}`, () => require('../pastes-client').getPaste(slug));
+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
// Overlay the Live-owned AI state (vod_ai_state/clip_ai_state) onto a Media row so
// descriptions/transcripts keep enriching the crawlable snapshot.
// descriptions/transcripts keep enriching the crawlable snapshot.
@@ -125,7 +192,7 @@ function jsonLd(obj) {
@@ -125,7 +192,7 @@ function jsonLd(obj) {
// Returns { title, description, canonicalPath, image, ogType, robots, jsonLd:[...], snapshot }
// Returns { title, description, canonicalPath, image, ogType, robots, jsonLd:[...], snapshot }
// or null to fall through (unknown / private / not found).
// or null to fall through (unknown / private / not found).
-async function _pageMeta(routePath) {
+async function _pageMeta(routePath, { page = 1 } = {}) {
const p = routePath.replace(/\/+$/, '') || '/';
const p = routePath.replace(/\/+$/, '') || '/';
const bu = baseUrl();
const bu = baseUrl();
@@ -143,7 +210,7 @@ async function _pageMeta(routePath) {
@@ -143,7 +210,7 @@ async function _pageMeta(routePath) {
if ((m = p.match(/^\/vod\/(\d+)$/))) return _vodMeta(parseInt(m[1], 10));
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(/^\/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(/^\/p\/([A-Za-z0-9_-]+)$/))) return _pasteMeta(m[1]);
- if ((m = p.match(/^\/@([A-Za-z0-9_.-]+)$/))) return _channelMeta(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 ((m = p.match(/^\/recap\/(\d+)$/))) return _recapMeta(parseInt(m[1], 10));
if (p === '/arena') return _arenaMeta();
if (p === '/arena') return _arenaMeta();
@@ -151,40 +218,134 @@ async function _pageMeta(routePath) {
@@ -151,40 +218,134 @@ async function _pageMeta(routePath) {
void bu;
void bu;
}
}
-function _channelMeta(username) {
- let ch; try { ch = db.getChannelByUsername(username); } catch { ch = null; }
- if (!ch) return null;
- const name = ch.display_name || ch.username || username;
- const handle = '@' + (ch.username || username);
+// ── 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;
let ov = null;
- try { ov = db.getStreamerOverview ? db.getStreamerOverview(ch.user_id) : null; } catch { /* */ }
+ try { ov = db.getStreamerOverview ? db.getStreamerOverview(acct.userId) : null; } catch { /* */ }
let followers = 0;
let followers = 0;
- try { followers = db.getFollowerCount ? (db.getFollowerCount(ch.user_id) || 0) : 0; } catch { /* */ }
- const bio = clean(ch.bio || '', 300);
+ 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);
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 desc = clean(bio || aiShort || `${name} (${handle}) streams live on ${SITE_NAME}. Watch their live streams, VODs and clips.`, 200);
- const image = ch.avatar_url ? abs(ch.avatar_url) : DEFAULT_OG_IMAGE;
- const canonicalPath = `/${handle}`;
+ const image = acct.avatarUrl ? abs(acct.avatarUrl) : DEFAULT_OG_IMAGE;
const person = {
const person = {
- '@type': 'Person', name: clean(name, 80), alternateName: handle, url: abs(canonicalPath),
+ '@type': 'Person', name: clean(name, 80), alternateName: handle, url: abs(basePath),
image: image !== DEFAULT_OG_IMAGE ? image : undefined,
image: image !== DEFAULT_OG_IMAGE ? image : undefined,
description: desc,
description: desc,
interactionStatistic: followers ? { '@type': 'InteractionCounter', interactionType: 'https://schema.org/FollowAction', userInteractionCount: followers } : undefined,
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 profile = { '@context': 'https://schema.org', '@type': 'ProfilePage', name: `${name} (${handle})`, url: abs(canonicalPath), mainEntity: person };
- const snapshot = _detailSnapshot({
- title: `${name} (${handle})`, byline: followers ? `${followers} follower${followers === 1 ? '' : 's'} on ${SITE_NAME}` : null,
- desc: bio || null, overview: (ov && ov.overview) || null, transcript: null,
- canonicalPath, watchLabel: `Visit ${name}'s channel`,
- });
+ 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 {
return {
- title: `${name} (${handle}) — ${SITE_NAME}`, description: desc,
- canonicalPath, image, ogType: 'profile', robots: 'index,follow',
- jsonLd: [profile, _breadcrumb([{ name: 'Home', url: '/' }, { name: name, url: canonicalPath }])],
- snapshot,
+ 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.
// The Arena: a real content page (mic-judged trash talk), so give it real metadata.
function _arenaMeta() {
function _arenaMeta() {
let fighters = [], moments = [];
let fighters = [], moments = [];
@@ -267,17 +428,24 @@ async function _homeMeta() {
@@ -267,17 +428,24 @@ async function _homeMeta() {
const description = 'Free, open-source live streaming with sub-second WebRTC, OBS/RTMP & CLI ingest, auto VODs & AI clips, restreaming to Twitch/YouTube/Kick, global chat, viewer-controlled robots, and the whole OpenVibe network behind it.';
const description = 'Free, open-source live streaming with sub-second WebRTC, OBS/RTMP & CLI ingest, auto VODs & AI clips, restreaming to Twitch/YouTube/Kick, global chat, viewer-controlled robots, and the whole OpenVibe network behind it.';
// Pull the actual live content so the source has real, crawlable text.
// Pull the actual live content so the source has real, crawlable text.
- let live = [], vods = [], clips = [], pastes = [], stats = null;
+ let live = [];
try { live = (db.getLiveStreams() || []).slice(0, 12); } catch { /* */ }
try { live = (db.getLiveStreams() || []).slice(0, 12); } catch { /* */ }
- try { vods = (await _vodList(12)) || []; } catch { /* */ }
- try { clips = (await _clipList(12)) || []; } catch { /* */ }
- try { pastes = (await _pasteList(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.
// VODs, clips and pastes are counted by the services that hold them (Media, Community), not Live's frozen tables.
- try { stats = await require('../media-proxy/lookups').withArchiveStats({ ...db.getHomeStats() }); } catch { /* */ }
+ 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 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: v.display_name || v.username, 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: c.display_name || c.username || c.streamer_username, meta: _fmtDur(c.duration_seconds || c.duration) }));
+ 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 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 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>` : '';
@@ -313,7 +481,7 @@ async function _homeMeta() {
@@ -313,7 +481,7 @@ async function _homeMeta() {
},
},
{ '@context': 'https://schema.org', '@type': 'ItemList', name: 'Live & recent on OpenVibe.Live', itemListElement: listEls },
{ '@context': 'https://schema.org', '@type': 'ItemList', name: 'Live & recent on OpenVibe.Live', itemListElement: listEls },
];
];
- return { title, description, canonicalPath: '/', image: DEFAULT_OG_IMAGE, ogType: 'website', robots: 'index,follow', jsonLd, snapshot };
+ return { title, description, canonicalPath: '/', image: DEFAULT_OG_IMAGE, ogType: 'website', robots: 'index,follow', jsonLd, snapshot, cacheTtlMs: track.partial ? SHORT_CACHE_MS : undefined };
}
}
async function _listMeta(slug, label, description, itemsFn) {
async function _listMeta(slug, label, description, itemsFn) {
@@ -371,26 +539,40 @@ const _nameOf = (u) => (u ? (u.display_name || u.username) : null);
@@ -371,26 +539,40 @@ const _nameOf = (u) => (u ? (u.display_name || u.username) : null);
*/
*/
async function _sourceMoment(vodId, seconds) {
async function _sourceMoment(vodId, seconds) {
if (vodId == null || !/^\d+$/.test(String(vodId))) return null;
if (vodId == null || !/^\d+$/.test(String(vodId))) return null;
- let v = null;
- try { v = await _vodGet(Number(vodId)); } catch { v = null; }
- if (!v || isPrivate(v) || Number(v.is_public) === 0) return null;
+ const v = await _vodGet(Number(vodId));
+ if (!v || !_pub(v)) return null;
const t = Math.max(0, Math.floor(Number(seconds) || 0));
const t = Math.max(0, Math.floor(Number(seconds) || 0));
return { path: `/vod/${Number(vodId)}${t ? `?t=${t}` : ''}`, vod: v };
return { path: `/vod/${Number(vodId)}${t ? `?t=${t}` : ''}`, vod: v };
}
}
async function _vodMeta(id) {
async function _vodMeta(id) {
- let v; try { v = await _vodGet(id); } catch { v = null; }
+ const v = await _vodGet(id);
// Private: fall through exactly like an unknown id. This HTML is cached per id and shared by
// 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.
// every visitor, so it never carries a private VOD's title, overview or transcript.
if (!v || isPrivate(v)) return null;
if (!v || isPrivate(v)) return null;
if (v.duration_seconds == null && v.duration != null) v.duration_seconds = v.duration;
if (v.duration_seconds == null && v.duration != null) v.duration_seconds = v.duration;
_overlayAiState(v, 'vod');
_overlayAiState(v, 'vod');
const indexable = Number(v.is_public) === 1 && (!v.visibility || v.visibility === 'public');
const indexable = Number(v.is_public) === 1 && (!v.visibility || v.visibility === 'public');
- const author = v.display_name || v.username;
+ 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 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 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 image = v.thumbnail_url ? media.publicUrl(v.thumbnail_url) : media.thumbUrl(`vod-${id}`);
const canonicalPath = `/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 = {
const vo = {
'@context': 'https://schema.org', '@type': 'VideoObject',
'@context': 'https://schema.org', '@type': 'VideoObject',
name: title, description: desc, thumbnailUrl: [image],
name: title, description: desc, thumbnailUrl: [image],
@@ -399,22 +581,32 @@ async function _vodMeta(id) {
@@ -399,22 +581,32 @@ async function _vodMeta(id) {
contentUrl: abs(canonicalPath), embedUrl: abs(canonicalPath),
contentUrl: abs(canonicalPath), embedUrl: abs(canonicalPath),
interactionStatistic: { '@type': 'InteractionCounter', interactionType: 'https://schema.org/WatchAction', userInteractionCount: Number(v.view_count) || 0 },
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() },
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({
const snapshot = _detailSnapshot({
- title, byline: author ? `Streamed by ${author}` : null, desc,
+ title, byline: author ? `Streamed by ${author}` : null, desc, extraHtml,
overview: v.ai_overview, transcript: v.ai_transcript, canonicalPath, watchLabel: 'Watch this VOD',
overview: v.ai_overview, transcript: v.ai_transcript, canonicalPath, watchLabel: 'Watch this VOD',
});
});
return {
return {
title: `${title}${author ? ' — ' + author : ''} | ${SITE_NAME}`, description: desc,
title: `${title}${author ? ' — ' + author : ''} | ${SITE_NAME}`, description: desc,
canonicalPath, image, ogType: 'video.other', robots: indexable ? 'index,follow' : 'noindex,follow',
canonicalPath, image, ogType: 'video.other', robots: indexable ? 'index,follow' : 'noindex,follow',
video: { duration: Math.floor(Number(v.duration_seconds) || 0) },
video: { duration: Math.floor(Number(v.duration_seconds) || 0) },
- jsonLd: [vo, _breadcrumb([{ name: 'Home', url: '/' }, { name: 'VODs', url: '/vods' }, { name: title, url: canonicalPath }])],
+ jsonLd: [vo, _breadcrumb([{ name: 'Home', url: '/' }, ...(owner ? [{ name: author || owner.username, url: `/@${owner.username}` }] : [{ name: 'VODs', url: '/vods' }]), { name: title, url: canonicalPath }])],
snapshot,
snapshot,
+ cacheTtlMs: track.partial ? SHORT_CACHE_MS : undefined,
};
};
}
}
async function _clipMeta(id) {
async function _clipMeta(id) {
- let c; try { c = await _clipGet(id); } catch { c = null; }
+ const c = await _clipGet(id);
if (!c || isPrivate(c)) return null; // private: same as unknown (see _vodMeta)
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;
if (c.duration_seconds == null && c.duration != null) c.duration_seconds = c.duration;
_overlayAiState(c, 'clip');
_overlayAiState(c, 'clip');
@@ -435,15 +627,19 @@ async function _clipMeta(id) {
@@ -435,15 +627,19 @@ async function _clipMeta(id) {
interactionStatistic: { '@type': 'InteractionCounter', interactionType: 'https://schema.org/WatchAction', userInteractionCount: Number(c.view_count) || 0 },
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() },
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({
const snapshot = _detailSnapshot({
- title, byline: creator ? `Clipped by ${creator}` : null, desc,
+ 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',
overview: c.ai_overview, transcript: c.ai_transcript, canonicalPath, watchLabel: 'Watch this clip',
});
});
return {
return {
title: `${title} — clip | ${SITE_NAME}`, description: desc,
title: `${title} — clip | ${SITE_NAME}`, description: desc,
canonicalPath, image, ogType: 'video.other', robots: indexable ? 'index,follow' : 'noindex,follow',
canonicalPath, image, ogType: 'video.other', robots: indexable ? 'index,follow' : 'noindex,follow',
video: { duration: Math.floor(Number(c.duration_seconds) || 0) },
video: { duration: Math.floor(Number(c.duration_seconds) || 0) },
- jsonLd: [vo, _breadcrumb([{ name: 'Home', url: '/' }, { name: 'Clips', url: '/clips' }, { name: title, url: canonicalPath }])],
+ jsonLd: [vo, _breadcrumb([{ name: 'Home', url: '/' }, ...(channel ? [{ name: _nameOf(channel), url: `/@${channel.username}` }] : [{ name: 'Clips', url: '/clips' }]), { name: title, url: canonicalPath }])],
snapshot,
snapshot,
};
};
}
}
@@ -487,7 +683,7 @@ async function _aiClipMeta(c, id) {
@@ -487,7 +683,7 @@ async function _aiClipMeta(c, id) {
}
}
async function _pasteMeta(slug) {
async function _pasteMeta(slug) {
- let p; try { p = await _pasteGet(slug); } catch { p = null; }
+ const p = await _pasteGet(slug);
if (!p) return null;
if (!p) return null;
if (isAiPaste(p)) return _aiPasteMeta(p, slug);
if (isAiPaste(p)) return _aiPasteMeta(p, slug);
const isScreenshot = p.type === 'screenshot';
const isScreenshot = p.type === 'screenshot';
@@ -646,8 +842,19 @@ function _headBlock(meta) {
@@ -646,8 +842,19 @@ function _headBlock(meta) {
parts.push(`<meta property="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));
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';
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) {
function render(meta, urlPath) {
let html = _base();
let html = _base();
if (!html) return null;
if (!html) return null;
@@ -683,24 +890,70 @@ function render(meta, urlPath) {
@@ -683,24 +890,70 @@ function render(meta, urlPath) {
// extractors read it — unlike <noscript>, which many strip), but visually-hidden so users
// 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:
// 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.
// it summarises the same content the SPA renders.
- if (meta.snapshot) {
- const style = 'position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:normal;border:0;';
- html = html.replace(/(<body[^>]*>)/i, `$1\n<div id="seo-prerender" style="${style}">${meta.snapshot}</div>`);
- }
+ if (meta.snapshot) html = _prerender(html, meta.snapshot);
return html;
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);
+ 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) ───────────────────────────────────────────────
// ── Cache (rendered HTML per path, short TTL) ───────────────────────────────────────────────
-const _cache = new Map(); // path -> { html, at }
+const _cache = new Map(); // path (with ?page=N) -> { html, at, ttl }
const CACHE_TTL_MS = 5 * 60 * 1000;
const CACHE_TTL_MS = 5 * 60 * 1000;
const CACHE_MAX = 500;
const CACHE_MAX = 500;
-function _cacheGet(key) { const e = _cache.get(key); return e && (Date.now() - e.at) < CACHE_TTL_MS ? e.html : null; }
-function _cacheSet(key, html) {
+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); }
if (_cache.size >= CACHE_MAX) { const first = _cache.keys().next().value; _cache.delete(first); }
- _cache.set(key, { html, at: Date.now() });
+ _cache.set(key, { html, at: Date.now(), ttl });
}
}
-const SEO_ROUTE_RE = /^\/(?:$|content$|moments$|vods$|clips$|pastes$|arena$|vod\/\d+$|clip\/\d+$|recap\/\d+$|p\/[A-Za-z0-9_-]+$|@[A-Za-z0-9_.-]+$)/;
+const SEO_ROUTE_RE = /^\/(?:$|content$|moments$|vods$|clips$|pastes$|arena$|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) {
+ if (!CHANNEL_PATH_RE.test(p)) return 1;
+ const raw = query && typeof query.page === 'string' ? query.page : '';
+ return /^\d{1,4}$/.test(raw) ? Math.min(CHANNEL_MAX_PAGE, Math.max(1, parseInt(raw, 10))) : 1;
+}
async function middleware(req, res, next) {
async function middleware(req, res, next) {
if (req.method !== 'GET') return next();
if (req.method !== 'GET') return next();
@@ -709,23 +962,33 @@ async function middleware(req, res, next) {
@@ -709,23 +962,33 @@ async function middleware(req, res, next) {
// A paste Community owns is never rendered here (the handover answers it first; this keeps it
// A paste Community owns is never rendered here (the handover answers it first; this keeps it
// so if the mount order ever changes).
// so if the mount order ever changes).
if (PASTES_BASE && p.startsWith('/p/')) return next();
if (PASTES_BASE && p.startsWith('/p/')) return next();
- // Only rewrite HTML navigations, not fetch()/XHR/asset probes.
- if (req.headers.accept && req.headers.accept.indexOf('text/html') === -1) 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 {
try {
// Check for a changed index.html before trusting the page cache: a cache hit would otherwise
// 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.
// skip the check and keep serving pages that point at the previous asset versions.
_base();
_base();
- const cached = _cacheGet(p);
- if (cached) { res.set('Cache-Control', 'public, max-age=300'); res.set('Content-Security-Policy-Report-Only', assets.cspReportOnly(cached)); res.type('html'); return res.send(cached); }
- const meta = await _pageMeta(p);
- if (!meta) return next(); // unknown / not found → let the SPA 404 client-side
+ 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);
const html = render(meta, p);
if (!html) return next();
if (!html) return next();
- _cacheSet(p, html);
- res.set('Cache-Control', 'public, max-age=300');
- res.set('Content-Security-Policy-Report-Only', assets.cspReportOnly(html));
- res.type('html');
- return res.send(html);
+ const ttl = meta.cacheTtlMs || CACHE_TTL_MS;
+ _cacheSet(key, html, ttl);
+ return send(html, ttl);
} catch (e) {
} catch (e) {
console.warn('[SEO] render failed for', p, '-', e.message);
console.warn('[SEO] render failed for', p, '-', e.message);
return next();
return next();
@@ -798,4 +1061,4 @@ function register(app) {
@@ -798,4 +1061,4 @@ function register(app) {
console.log('[SEO] per-route meta + dynamic sitemap registered');
console.log('[SEO] per-route meta + dynamic sitemap registered');
}
}
-module.exports = { register, middleware, sitemapHandler, buildSitemap, _pageMeta, render };
+module.exports = { register, middleware, sitemapHandler, buildSitemap, shellHtml, _pageMeta, render };
@@ -77,9 +77,11 @@ const isMissingError = (err) => !!err && (err.status === 404 || err.status === 4
@@ -77,9 +77,11 @@ const isMissingError = (err) => !!err && (err.status === 404 || err.status === 4
/**
/**
* The cached answer for `key`, loading it at most once at a time. Resolves within
* The cached answer for `key`, loading it at most once at a time. Resolves within
- * LOOKUP_DEADLINE_MS; a slower load keeps running and fills the cache for the next request.
+ * LOOKUP_DEADLINE_MS, or by `deadlineAt` when the request has already spent part of that budget
+ * (the SEO renderer runs first and sets req.ovLookupDeadlineAt); a slower load keeps running and
+ * fills the cache for the next request.
*/
*/
-function lookup(key, load) {
+function lookup(key, load, deadlineAt = null) {
const now = Date.now();
const now = Date.now();
let entry = _cache.get(key);
let entry = _cache.get(key);
if (!entry || (!entry.pending && now - entry.at >= entry.ttl)) {
if (!entry || (!entry.pending && now - entry.at >= entry.ttl)) {
@@ -99,8 +101,9 @@ function lookup(key, load) {
@@ -99,8 +101,9 @@ function lookup(key, load) {
if (_cache.size > CACHE_MAX) _cache.delete(_cache.keys().next().value);
if (_cache.size > CACHE_MAX) _cache.delete(_cache.keys().next().value);
}
}
if (!entry.pending) return Promise.resolve(entry.value);
if (!entry.pending) return Promise.resolve(entry.value);
+ const wait = deadlineAt ? Math.max(0, Math.min(LOOKUP_DEADLINE_MS, deadlineAt - Date.now())) : LOOKUP_DEADLINE_MS;
let timer;
let timer;
- const deadline = new Promise((resolve) => { timer = setTimeout(resolve, LOOKUP_DEADLINE_MS, UNKNOWN); });
+ const deadline = new Promise((resolve) => { timer = setTimeout(resolve, wait, UNKNOWN); });
return Promise.race([entry.pending, deadline]).finally(() => clearTimeout(timer));
return Promise.race([entry.pending, deadline]).finally(() => clearTimeout(timer));
}
}
@@ -115,18 +118,18 @@ async function mediaItemStatus(req, kind, id) {
@@ -115,18 +118,18 @@ async function mediaItemStatus(req, kind, id) {
if (!ID_RE.test(id)) return NOT_FOUND;
if (!ID_RE.test(id)) return NOT_FOUND;
const row = await lookup(`${kind}:${id}`, async () => slimMediaRow(kind === 'vod'
const row = await lookup(`${kind}:${id}`, async () => slimMediaRow(kind === 'vod'
? await media.getVod(id, { timeoutMs: 10_000 })
? await media.getVod(id, { timeoutMs: 10_000 })
- : await media.getClip(id, { timeoutMs: 10_000 })));
+ : await media.getClip(id, { timeoutMs: 10_000 })), req.ovLookupDeadlineAt || null);
if (row === UNKNOWN) return OK;
if (row === UNKNOWN) return OK;
if (row === MISSING) return NOT_FOUND;
if (row === MISSING) return NOT_FOUND;
return access.canView(visitor(req), row) ? OK : NOT_FOUND;
return access.canView(visitor(req), row) ? OK : NOT_FOUND;
}
}
-async function pasteStatus(slug) {
+async function pasteStatus(req, slug) {
if (!SLUG_RE.test(slug)) return NOT_FOUND;
if (!SLUG_RE.test(slug)) return NOT_FOUND;
const found = await lookup(`paste:${slug}`, async () => {
const found = await lookup(`paste:${slug}`, async () => {
const p = await require('../pastes-client').getPaste(slug);
const p = await require('../pastes-client').getPaste(slug);
return p && typeof p === 'object' ? true : MISSING;
return p && typeof p === 'object' ? true : MISSING;
- });
+ }, req.ovLookupDeadlineAt || null);
return found === MISSING ? NOT_FOUND : OK;
return found === MISSING ? NOT_FOUND : OK;
}
}
@@ -159,7 +162,7 @@ async function statusFor(req) {
@@ -159,7 +162,7 @@ async function statusFor(req) {
switch (first) {
switch (first) {
case 'vod': return await mediaItemStatus(req, 'vod', second);
case 'vod': return await mediaItemStatus(req, 'vod', second);
case 'clip': return await mediaItemStatus(req, 'clip', second);
case 'clip': return await mediaItemStatus(req, 'clip', second);
- case 'p': return await pasteStatus(second);
+ case 'p': return await pasteStatus(req, second);
case 'recap':
case 'recap':
case 'stream': return streamStatus(second);
case 'stream': return streamStatus(second);
default: return NOT_FOUND;
default: return NOT_FOUND;
@@ -190,7 +193,30 @@ function spaFallback(sendShell) {
@@ -190,7 +193,30 @@ 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
+ * 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.
+ */
+function prime(key, value) {
+ const e = _cache.get(key);
+ if (e && (e.pending || Date.now() - e.at < e.ttl)) return;
+ _cache.delete(key);
+ _cache.set(key, { at: Date.now(), ttl: value === MISSING ? MISSING_TTL_MS : FOUND_TTL_MS, value, pending: null });
+ if (_cache.size > CACHE_MAX) _cache.delete(_cache.keys().next().value);
+}
+/** A VOD or clip row (`row` null = Media said it does not exist). Only the visibility fields are kept. */
+function primeMediaItem(kind, id, row) {
+ if ((kind !== 'vod' && kind !== 'clip') || !ID_RE.test(String(id))) return;
+ prime(`${kind}:${id}`, row ? slimMediaRow(row) : MISSING);
+}
+function primePaste(slug, found) {
+ if (!SLUG_RE.test(String(slug))) return;
+ prime(`paste:${slug}`, found ? true : MISSING);
+}
+
/** Forget cached lookups (tests; or after a change that must show at once). */
/** Forget cached lookups (tests; or after a change that must show at once). */
function clearCache() { _cache.clear(); }
function clearCache() { _cache.clear(); }
-module.exports = { statusFor, spaFallback, clearCache, EXACT, PREFIX, LOOKUP_DEADLINE_MS };
+module.exports = { statusFor, spaFallback, clearCache, primeMediaItem, primePaste, EXACT, PREFIX, LOOKUP_DEADLINE_MS };
@@ -87,13 +87,16 @@ pastesClient.listPastes = async () => ({ pastes: [] });
@@ -87,13 +87,16 @@ 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();
-require('../server/seo/seo').register(app);
+const seo = require('../server/seo/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) => {
app.get('*', pageStatus.spaFallback((res, urlPath) => {
- const doc = assets.document('index.html');
- if (!doc) return false;
- res.type('html').send(assets.renderRoute(doc.html, urlPath));
+ const html = seo.shellHtml(urlPath, res.statusCode);
+ if (!html) return false;
+ res.type('html').send(html);
return true;
return true;
}));
}));
+void assets;
let base;
let base;
function get(p, { user, html = false, method = 'GET' } = {}) {
function get(p, { user, html = false, method = 'GET' } = {}) {
@@ -178,6 +181,11 @@ server.listen(0, '127.0.0.1', async () => {
@@ -178,6 +181,11 @@ server.listen(0, '127.0.0.1', async () => {
const unknown = await get('/no-such-page', { html: true });
const unknown = await get('/no-such-page', { html: true });
assert.strictEqual(unknown.status, 404);
assert.strictEqual(unknown.status, 404);
assert.ok(unknown.body.includes('/js/app.js'));
assert.ok(unknown.body.includes('/js/app.js'));
+ // …and tells search engines so: noindex, and not the home page's canonical.
+ for (const r of [shell, unknown]) {
+ assert.ok(r.body.includes('<meta name="robots" content="noindex">'), 'noindex');
+ assert.ok(!/<link rel="canonical"/.test(r.body), 'no canonical');
+ }
});
});
await check('HEAD matches GET, and API paths keep their JSON 404', async () => {
await check('HEAD matches GET, and API paths keep their JSON 404', async () => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,297 @@
@@ -0,0 +1,297 @@
+/**
+ * Server-rendered channel, VOD and clip pages (server/seo/seo.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.
+ *
+ * It used to render only when Accept named text/html, so a crawler or tool sending *\/* got the home
+ * page's title and canonical for /@finditfixit. Checked here, with the SEO middleware and the real
+ * SPA fallback mounted as server/index.js mounts them (Media stubbed in-process, temp database):
+ * - /@user (a channel row or just an account), /vod/:id and /clip/:id answer the same page for
+ * every Accept header and user agent, with their own head and body;
+ * - a channel's videos page with ?page=N links (rel prev/next), each page self-canonical, and a
+ * page past the end noindex with the canonical on page 1;
+ * - a VOD lists its clips, people's then the AI's, and carries them as schema.org Clip parts;
+ * - a 404 is noindex with no canonical and no home metadata; no other shell page claims the home
+ * page as its canonical;
+ * - without JavaScript the body is shown (and the empty app shell hidden); with it, the route
+ * still boots its feature scripts and the client hydrates as before.
+ *
+ * node test/seo-ssr.test.js
+ */
+'use strict';
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+
+const tmp = path.join(os.tmpdir(), `ov-seo-ssr-${process.pid}.db`);
+process.env.DB_PATH = tmp;
+process.env.NODE_ENV = 'test';
+delete process.env.PASTES_ON_COMMUNITY;
+const quiet = console.log;
+console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
+console.warn = () => {};
+console.error = () => {};
+
+const db = require('../server/db/database');
+db.initDb();
+const raw = db.getDb();
+const auth = require('../server/auth/auth');
+auth.optionalAuth = (req, res, next) => next();
+
+const addUser = (id, username, display, extra = '') => raw.prepare(
+ `INSERT INTO users (id, username, display_name, email, password_hash, role, bio, created_at)
+ VALUES (?, ?, ?, ?, 'x', 'streamer', ?, '2025-01-01 00:00:00')`).run(id, username, display, `${username}@x`, extra);
+addUser(3, 'alice', 'Alice', 'I stream woodworking.');
+addUser(4, 'bob', 'Bob');
+addUser(5, 'finditfixit', 'finditfixit', 'likes vintage video games'); // an account with no channel row
+db.ensureChannel(3);
+
+// ── OpenVibe.Media stand-in ──
+const media = require('../server/media-client');
+const missing = () => new media.MediaApiError('not found', 404, { error: 'not found' });
+let slow = 0;
+const day = (n) => new Date(Date.UTC(2026, 8, 1 + n)).toISOString().replace('T', ' ').slice(0, 19);
+// Alice has 30 public VODs (100–129, newest first) and one private one.
+const VODS = [];
+for (let i = 0; i < 30; i++) VODS.push({ id: 100 + i, user_id: 3, title: `Build night ${100 + i}`, visibility: 'public', is_public: true, status: 'ready', duration_seconds: 3600 + i, created_at: day(30 - i) });
+VODS.push({ id: 199, user_id: 3, title: 'SECRET VOD', visibility: 'private', is_public: false, status: 'ready', duration_seconds: 60, created_at: day(40) });
+const CLIPS = [
+ { id: 200, user_id: 4, channel_user_id: 3, vod_id: 100, title: 'Bob clipped the saw', visibility: 'public', is_public: true, status: 'ready', auto_generated: false, start_time: 61.2, end_time: 81.2, duration_seconds: 20 },
+ { id: 201, user_id: 3, channel_user_id: 3, vod_id: 100, title: 'Chat lost it', visibility: 'public', is_public: true, status: 'ready', auto_generated: true, start_time: 125, end_time: 150, duration_seconds: 25 },
+];
+const listed = (rows, q) => {
+ const off = Number(q.offset) || 0, lim = Number(q.limit) || 50;
+ return rows.slice(off, off + lim);
+};
+media.listVods = async (q = {}) => {
+ if (slow) await new Promise((r) => setTimeout(r, slow));
+ const rows = VODS.filter((v) => v.is_public && (q.user_id == null || String(v.user_id) === String(q.user_id)));
+ return { vods: listed(rows, q).map((v) => ({ ...v })), total: rows.length };
+};
+media.listClips = async (q = {}) => {
+ if (slow) await new Promise((r) => setTimeout(r, slow));
+ const rows = CLIPS.filter((c) => c.is_public
+ && (q.channel_user_id == null || String(c.channel_user_id) === String(q.channel_user_id))
+ && (q.vod_id == null || String(c.vod_id) === String(q.vod_id))
+ && (q.auto_generated == null || Number(c.auto_generated) === Number(q.auto_generated)));
+ return { clips: listed(rows, q).map((c) => ({ ...c })), total: rows.length };
+};
+media.getVod = async (id) => { const v = VODS.find((x) => x.id === Number(id)); if (!v) throw missing(); return { ...v }; };
+media.getClip = async (id) => { const c = CLIPS.find((x) => x.id === Number(id)); if (!c) throw missing(); return { ...c }; };
+media.request = async () => { throw new media.MediaApiError('stubbed', 0, null); };
+const pastesClient = require('../server/pastes-client');
+pastesClient.listPastes = async () => ({ pastes: [] });
+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 pageStatus = require('../server/web/page-status');
+const app = express();
+seo.register(app);
+app.get('*', pageStatus.spaFallback((res, urlPath) => {
+ const html = seo.shellHtml(urlPath, res.statusCode);
+ if (!html) return false;
+ res.type('html').send(html);
+ return true;
+}));
+
+let base;
+function get(p, headers = {}) {
+ return new Promise((resolve, reject) => {
+ const req = http.request(base + p, { method: 'GET', headers }, (res) => {
+ let body = '';
+ res.on('data', (c) => { body += c; });
+ res.on('end', () => resolve({ status: res.statusCode, body, cache: res.headers['cache-control'] || '' }));
+ });
+ req.on('error', reject);
+ req.end();
+ });
+}
+const head = (html) => html.slice(0, html.search(/<\/head>/i));
+const titleOf = (html) => ((head(html).match(/<title>([^<]*)<\/title>/) || [])[1] || '').replace(/'/g, "'");
+const canonicalsOf = (html) => [...head(html).matchAll(/<link rel="canonical" href="([^"]+)">/g)].map((m) => m[1]);
+const robotsOf = (html) => [...head(html).matchAll(/<meta name="robots" content="([^"]+)">/g)].map((m) => m[1]);
+const ldOf = (html) => [...head(html).matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => { try { return JSON.parse(m[1]); } catch { return null; } }).filter(Boolean);
+const bodyOf = (html) => ((html.match(/<div id="seo-prerender"[^>]*>([\s\S]*?)<\/div>\n/) || [])[1] || '');
+const CLIENTS = [
+ { name: 'no Accept (curl, monitors)', headers: {} },
+ { name: 'Accept */*', headers: { accept: '*/*' } },
+ { name: 'Googlebot', headers: { accept: 'text/html,application/xhtml+xml,application/signed-exchange;v=b3,*/*;q=0.8', 'user-agent': 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)' } },
+ { name: 'a browser', headers: { accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' } },
+];
+
+let failures = 0;
+async function check(name, fn) {
+ try { await fn(); quiet(' ✓', name); }
+ catch (e) { failures++; quiet(' ✗', name, '\n ', e.message); }
+}
+
+const server = http.createServer(app).listen(0, '127.0.0.1', async () => {
+ base = `http://127.0.0.1:${server.address().port}`;
+ quiet('Server-rendered pages');
+
+ await check('/@alice: its own title, canonical, OG and JSON-LD for every client', async () => {
+ for (const c of CLIENTS) {
+ const r = await get('/@alice', c.headers);
+ assert.strictEqual(r.status, 200, c.name);
+ assert.strictEqual(titleOf(r.body), 'Alice (@alice) — OpenVibe.Live', `${c.name}: ${titleOf(r.body)}`);
+ assert.deepStrictEqual(canonicalsOf(r.body), ['https://openvibe.live/@alice'], c.name);
+ assert.ok(r.body.includes('<meta property="og:url" content="https://openvibe.live/@alice">'), c.name);
+ assert.ok(r.body.includes('<meta property="og:type" content="profile">'), c.name);
+ assert.deepStrictEqual(robotsOf(r.body), ['index,follow'], c.name);
+ assert.ok(ldOf(r.body).some((n) => n['@type'] === 'ProfilePage' && n.mainEntity.name === 'Alice'), c.name);
+ }
+ });
+
+ 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));
+ assert.ok(b.includes('I stream woodworking.'));
+ for (let id = 100; id < 112; id++) assert.ok(b.includes(`https://openvibe.live/vod/${id}"`), `VOD ${id} listed`);
+ assert.ok(!b.includes('/vod/112"'), 'page 1 holds 12 videos');
+ assert.ok(!b.includes('SECRET VOD'), 'no private VOD');
+ assert.ok(b.includes('<a rel="next" href="https://openvibe.live/@alice?page=2">Older videos</a>'), 'next page link');
+ assert.ok(b.includes('Page 1 of 3'));
+ const clipsAt = b.indexOf('<h2>Clips</h2>'), aiAt = b.indexOf('<h2>AI Moments</h2>');
+ assert.ok(clipsAt > 0 && aiAt > clipsAt, 'people\'s clips, then the AI\'s');
+ assert.ok(b.slice(clipsAt, aiAt).includes('clipped by Bob'));
+ assert.ok(b.slice(aiAt).includes('Chat lost it') && b.slice(aiAt).includes('AI clip'));
+ const list = ldOf((await get('/@alice')).body).find((n) => n['@type'] === 'ItemList');
+ assert.strictEqual(list.itemListElement.length, 12);
+ });
+
+ await check('/@alice?page=2 and ?page=3: self-canonical pages with prev/next; past the end is noindex', async () => {
+ const p2 = (await get('/@alice?page=2')).body;
+ assert.deepStrictEqual(canonicalsOf(p2), ['https://openvibe.live/@alice?page=2']);
+ assert.ok(/page 2/.test(titleOf(p2)), titleOf(p2));
+ const b2 = bodyOf(p2);
+ assert.ok(b2.includes('/vod/112"') && b2.includes('/vod/123"') && !b2.includes('/vod/111"'));
+ assert.ok(b2.includes('<a rel="prev" href="https://openvibe.live/@alice">Newer videos</a>'));
+ assert.ok(b2.includes('<a rel="next" href="https://openvibe.live/@alice?page=3">'));
+ assert.ok(!b2.includes('<h2>AI Moments</h2>'), 'clips are on page 1 only');
+ const b3 = bodyOf((await get('/@alice?page=3')).body);
+ assert.ok(b3.includes('/vod/129"') && !b3.includes('rel="next"'), 'the last page has no next link');
+ const past = (await get('/@alice?page=9')).body;
+ assert.deepStrictEqual(robotsOf(past), ['noindex,follow']);
+ assert.deepStrictEqual(canonicalsOf(past), ['https://openvibe.live/@alice']);
+ const junk = (await get('/@alice?page=abc')).body;
+ assert.deepStrictEqual(canonicalsOf(junk), ['https://openvibe.live/@alice'], 'a non-number is page 1');
+ });
+
+ await check('/@finditfixit (an account with no channel row) gets its own page, not the home page', async () => {
+ const r = await get('/@finditfixit', { 'user-agent': 'Googlebot/2.1' });
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(titleOf(r.body), 'finditfixit (@finditfixit) — OpenVibe.Live');
+ assert.deepStrictEqual(canonicalsOf(r.body), ['https://openvibe.live/@finditfixit']);
+ assert.ok(bodyOf(r.body).includes('likes vintage video games'));
+ });
+
+ await check('/@Alice names the channel\'s own spelling as canonical', async () => {
+ assert.deepStrictEqual(canonicalsOf((await get('/@Alice')).body), ['https://openvibe.live/@alice']);
+ });
+
+ await check('a 404 is noindex, with no canonical and none of the home page\'s metadata', async () => {
+ for (const p of ['/@nobody', '/vod/999', '/clip/999', '/no-such-page', `/vod/199`]) {
+ for (const c of CLIENTS.slice(0, 3)) {
+ const r = await get(p, c.headers);
+ assert.strictEqual(r.status, 404, `${p} ${c.name}`);
+ assert.deepStrictEqual(canonicalsOf(r.body), [], `${p}: no canonical`);
+ assert.deepStrictEqual(robotsOf(r.body), ['noindex'], `${p}: noindex`);
+ assert.strictEqual(titleOf(r.body), 'Page not found — OpenVibe.Live', p);
+ assert.ok(!/og:url|og:title|application\/ld\+json/.test(head(r.body)), `${p}: home OG/JSON-LD left in`);
+ assert.ok(bodyOf(r.body).includes('<h1>Page not found</h1>'), `${p}: a not-found body`);
+ assert.ok(!r.body.includes('SECRET VOD'));
+ }
+ }
+ });
+
+ await check('no other shell page claims the home page as canonical; a slot names its channel', async () => {
+ const dash = (await get('/dashboard')).body;
+ assert.deepStrictEqual(canonicalsOf(dash), []);
+ const slot = (await get('/@alice/main')).body;
+ assert.deepStrictEqual(canonicalsOf(slot), ['https://openvibe.live/@alice']);
+ const home = (await get('/')).body;
+ assert.deepStrictEqual(canonicalsOf(home), ['https://openvibe.live/']);
+ });
+
+ await check('/vod/100: its own head and a body with people\'s clips, then the AI\'s, as Clip parts', async () => {
+ for (const c of CLIENTS) {
+ const r = await get('/vod/100', c.headers);
+ assert.strictEqual(r.status, 200, 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));
+ }
+ const r = await get('/vod/100?t=125');
+ assert.deepStrictEqual(canonicalsOf(r.body), ['https://openvibe.live/vod/100'], 'a timestamp link is the same page');
+ const vo = ldOf(r.body).find((n) => n['@type'] === 'VideoObject');
+ assert.deepStrictEqual(vo.author, { '@type': 'Person', name: 'Alice' });
+ assert.deepStrictEqual(vo.hasPart.map((x) => [x['@type'], x.startOffset, x.url]), [
+ ['Clip', 61, 'https://openvibe.live/vod/100?t=61'],
+ ['Clip', 125, 'https://openvibe.live/vod/100?t=125'],
+ ]);
+ const crumbs = ldOf(r.body).find((n) => n['@type'] === 'BreadcrumbList').itemListElement.map((i) => i.name);
+ assert.deepStrictEqual(crumbs, ['Home', 'Alice', 'Build night 100']);
+ const b = bodyOf(r.body);
+ assert.ok(b.includes('Streamed by Alice') && b.includes('https://openvibe.live/@alice'));
+ const people = b.indexOf('<h2>Clips from this stream</h2>'), ai = b.indexOf('<h2>AI Moments from this stream</h2>');
+ assert.ok(people > 0 && ai > people);
+ assert.ok(b.slice(people, ai).includes('clipped by Bob'));
+ assert.ok(b.slice(ai).includes('AI clip · at 2:05'));
+ });
+
+ await check('/clip/200: its own head and a body linking the moment in the full stream', async () => {
+ for (const c of CLIENTS) {
+ const r = await get('/clip/200', c.headers);
+ assert.strictEqual(r.status, 200, c.name);
+ assert.deepStrictEqual(canonicalsOf(r.body), ['https://openvibe.live/clip/200'], c.name);
+ assert.deepStrictEqual(robotsOf(r.body), ['index,follow'], c.name);
+ }
+ const b = bodyOf((await get('/clip/200')).body);
+ assert.ok(b.includes("Clipped by Bob from Alice's stream"), b.slice(0, 300));
+ assert.ok(b.includes('https://openvibe.live/vod/100?t=61'));
+ });
+
+ await check('without JavaScript the body shows (with the site links) and the empty app shell is hidden', async () => {
+ const html = (await get('/@alice')).body;
+ assert.ok(head(html).includes('<noscript><style>#seo-prerender{position:static!important'), 'no-JS rule in the head');
+ assert.ok(head(html).includes('#app{display:none!important}'));
+ assert.ok(bodyOf(html).startsWith('<nav aria-label="OpenVibe.Live"><a href="/">OpenVibe.Live</a>'));
+ });
+
+ await check('the client still hydrates: the route boots its feature and the fragment is inlined', async () => {
+ const html = (await get('/@alice')).body;
+ const boot = html.match(/<script type="application\/json" id="ov-route-features">([^<]*)<\/script>/);
+ assert.ok(boot && JSON.parse(boot[1]).includes('channel'), 'the channel feature boots');
+ assert.ok(html.includes('/js/app.js'), 'the client router is loaded');
+ assert.ok(html.includes('data-fragment="channel" data-fragment-loaded="1"'), 'the channel markup is inlined');
+ const src = fs.readFileSync(path.join(__dirname, '../public/js/app.js'), 'utf8');
+ assert.ok(/getElementById\('seo-prerender'\)\?\.remove\(\)/.test(src), 'the SPA removes the server body on boot');
+ });
+
+ await check('a slow Media never holds a channel page: rendered without its lists, cached briefly', async () => {
+ slow = 4000;
+ const t0 = Date.now();
+ const r = await get('/@bob');
+ const took = Date.now() - t0;
+ slow = 0;
+ assert.strictEqual(r.status, 200);
+ assert.ok(took < 2500, `took ${took}ms`);
+ assert.strictEqual(titleOf(r.body), 'Bob (@bob) — OpenVibe.Live');
+ assert.ok(/max-age=30\b/.test(r.cache), r.cache);
+ });
+
+ 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("app.get('*', require('./web/page-status').spaFallback((res, urlPath) => sendShell(res, urlPath)));"));
+ });
+
+ server.close();
+ try { fs.unlinkSync(tmp); } catch { /* */ }
+ for (const ext of ['-wal', '-shm']) { try { fs.unlinkSync(tmp + ext); } catch { /* */ } }
+ quiet(failures ? `\n${failures} check(s) failed` : '\nseo ssr: all checks passed');
+ process.exit(failures ? 1 : 0);
+});