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.

OP OpenVibers committed 24 Sept 2026 · 517fbcc219 · 5 files, +685 −78


Changes

ddiff --git a/server/index.js b/server/index.js
iindex 76f2d355..48cc161b 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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) => {
ddiff --git a/server/seo/seo.js b/server/seo/seo.js
iindex c271508e..9698c462 100644
---- a/server/seo/seo.js
++++ b/server/seo/seo.js
@@@ -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 };
ddiff --git a/server/web/page-status.js b/server/web/page-status.js
iindex c1f0c6c3..4869f06d 100644
---- a/server/web/page-status.js
++++ b/server/web/page-status.js
@@@ -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 };
ddiff --git a/test/page-status.test.js b/test/page-status.test.js
iindex 04d6afbb..abe867a5 100644
---- a/test/page-status.test.js
++++ b/test/page-status.test.js
@@@ -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 () => {
ddiff --git a/test/seo-ssr.test.js b/test/seo-ssr.test.js
nnew file mode 100644
iindex 00000000..cd3e21b7
---- /dev/null
++++ b/test/seo-ssr.test.js
@@@ -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(/&#39;/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&#39;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);
++});