openvibers/openvibe.live Commits

Live: a public recording's and clip's canonical page is on OpenVibe.Video (sitemap, Search and llms.txt follow) (#108)

OP OpenVibers committed 12 Oct 2026 · b29352fa16 · 9 files, +70 −31

Plan T10, Video: "then Live's pages go". openvibe.video now has everything a public recording page needs: the HLS player, chapters, captions, chat replay, comments, playlists, owner tools and structured data (MediaHub#28–#37). This is the first step: search engines get one copy of each video, and that copy is on Video. Live's pages keep working.

- **Canonical.** A public, finished recording's `/vod/<id>` and a person's public clip's `/clip/<id>` name the Video page (`https://openvibe.video/v/<id>`, `/c/<id>`) as canonical. Their `og:url` stays the Live page, so a shared Live link still previews as itself, and the hreflang self-alternates are left out when the canonical is on another site.
- **What keeps a Live canonical:** unlisted items, recordings in progress and private items. None of them are indexed anywhere.
- **AI clips** (noindex, as before) are canonical to the Video page at their source second, and link that moment there.
- **Sitemap.** Live's sitemap no longer lists recordings or clips; Video's sitemap lists them. The walk still finds the channels that have them.
- **Search.** The documents Live sends to OpenVibe.Search link the Video page. The daily refresh re-sends every changed document, so the index follows within a day.
- **llms.txt** points recording and clip pages at openvibe.video.
- One helper, `server/web/video-url.js` (`VIDEO_PUBLIC_URL` overrides the origin), and a "canonical page" section in `docs/vods-and-clips.md`.

Tests: `seo-ssr`, `seo-moments`, `llms-txt` and `search-media-documents` now expect the Video canonical, the moment links, and the sitemap without recordings or clips. All seven SEO-related test files pass (`ov test`).

Changes

ddiff --git a/docs/vods-and-clips.md b/docs/vods-and-clips.md
iindex 571d84d6..b1f0fc10 100644
---- a/docs/vods-and-clips.md
++++ b/docs/vods-and-clips.md
@@@ -58,6 +58,15 @@ From the dashboard:
(`vod_ai_state`/`clip_ai_state`, `content_views`) and its Search document is re-read at once
- **Thumbnails** are auto-generated; broken thumbnails auto-regenerate on load
++## The canonical page is on OpenVibe.Video
++
++A public, finished recording's and a person's public clip's canonical page is on OpenVibe.Video
++(`https://openvibe.video/v/<id>`, `/c/<id>`; `server/web/video-url.js`, `VIDEO_PUBLIC_URL` overrides the origin). Live's
++own `/vod/<id>` and `/clip/<id>` pages keep working and keep their own `og:url`, but their `<link rel="canonical">`
++names the Video page, Live's sitemap leaves recordings and clips out (Video's sitemap lists them), the documents Live
++sends to OpenVibe.Search link there, and an AI clip's canonical moment is the Video page at that second. Unlisted
++items, recordings in progress and private items keep their Live canonical (they are not indexed anywhere).
++
## Views
A view is one `content_views` row per item and address: Live's detail read (`GET /api/vods/:id`, `GET /api/clips/:id`)
ddiff --git a/server/events/search-media-documents.js b/server/events/search-media-documents.js
iindex 970d19fa..3e210161 100644
---- a/server/events/search-media-documents.js
++++ b/server/events/search-media-documents.js
@@@ -1,8 +1,8 @@
'use strict';
/**
* Live's VOD and clip pages in OpenVibe.Search (roadmap WS-O task 10; Contracts 0.45.0
-- * live.index_document.* types vod and clip): Live owns the canonical /vod/<id> and /clip/<id> pages of
-- * the Media items recorded from its streams (title, channel, AI overview, transcript), so Live sends
++ * live.index_document.* types vod and clip): Live owns the Media items recorded from its streams (title,
++ * channel, AI overview, transcript); their canonical pages are OpenVibe.Video's (../web/video-url.js), so Live sends
* their Search documents through its transactional outbox (./stream-events.js), with a tombstone once
* an item is deleted, made private or unlisted, or failed. Items come from Media (media-client), never
* from the frozen local vods/clips tables; the AI overview and transcript are Live's vod_ai_state and
@@@ -21,8 +21,8 @@
const crypto = require('crypto');
const db = require('../db/database');
const streamEvents = require('./stream-events');
++const { videoUrl } = require('../web/video-url');
--const PUBLIC_BASE = (process.env.LIVE_PUBLIC_ORIGIN || 'https://openvibe.live').replace(/\/+$/, '');
const DAY_MS = 86400000;
const PAGE = 200;
const TOUCH_DELAY_MS = Number(process.env.LIVE_SEARCH_TOUCH_DELAY_MS) || 1500;
@@@ -79,7 +79,8 @@ async function documentFor(kind, row) {
const reasons = [...(aiClip ? ['ai_unreviewed'] : []), ...(stream && stream.is_nsfw ? ['sensitive'] : [])];
const doc = {
owner: 'live', type: kind, id: String(row.id), deleted: false, visibility: 'public',
-- canonical_url: `${PUBLIC_BASE}/${kind}/${row.id}`,
++ // The canonical page is OpenVibe.Video's (web/video-url.js), as Live's own page says.
++ canonical_url: videoUrl(kind, row.id),
title: clean(row.title || fallbackTitle, 500),
summary,
body: [clean(row.description, 4000), clean(ai.overview, 8000), ai.transcript].filter(Boolean).join('\n').slice(0, 40000),
ddiff --git a/server/seo/discovery.js b/server/seo/discovery.js
iindex f94ff69f..2abd5078 100644
---- a/server/seo/discovery.js
++++ b/server/seo/discovery.js
@@@ -9,7 +9,7 @@ const seo = require('openvibe-shared/seo');
const cache = require('openvibe-shared/cache-policy');
const db = require('../db/database');
const media = require('../media-client');
--const { SITE_NAME, baseUrl, isAiClip, isoDate } = require('./pages');
++const { SITE_NAME, baseUrl, isAiClip } = require('./pages');
const DOCS_DIR = process.env.OV_APP_ROOT ? path.join(process.env.OV_APP_ROOT, 'docs') : path.join(__dirname, '../../docs');
const DISCOVERY_MAX_AGE = 3600; // seconds, for every file here
@@@ -55,8 +55,9 @@ async function buildSitemap() {
off += per;
}
};
++ // Recordings and clips are not listed: their canonical pages are on OpenVibe.Video, whose sitemap lists them
++ // (web/video-url.js). The walk still finds the channels that have them.
await page((l, o) => media.listVods({ limit: l, offset: o }).then(r => r?.vods || []), 200, SITEMAP_CAP, async (v) => {
-- entry(`/vod/${v.id}`, isoDate(v.created_at), 'weekly', 0.6);
const ch = await channelOf(v, v.user_id);
if (ch) seenChannels.add(ch);
});
@@@ -65,7 +66,6 @@ async function buildSitemap() {
// upstream that ignores it still cannot put an AI item here.
await page((l, o) => media.listClips({ limit: l, offset: o, auto_generated: 0 }).then(r => r?.clips || []), 200, SITEMAP_CAP, async (c) => {
if (isAiClip(c)) return;
-- entry(`/clip/${c.id}`, isoDate(c.created_at), 'weekly', 0.6);
const ch = await channelOf({}, c.channel_user_id != null ? c.channel_user_id : c.user_id);
if (ch) seenChannels.add(ch);
});
@@@ -108,7 +108,7 @@ function llmsTxt() {
return seo.llmsTxt({
name: SITE_NAME,
summary: LLMS_SUMMARY,
-- details: 'People\'s work and AI-made material are kept apart everywhere on this site. The Content pages list what people made; AI Moments list what the platform\'s AI derived from streams. Every AI item is labelled AI-generated, credited to no person, marked noindex,follow and made canonical to the source VOD at the moment it came from (/vod/<id>?t=<seconds>). In the JSON feeds, AI items carry "ai": true and an "ai_label".',
++ details: 'People\'s work and AI-made material are kept apart everywhere on this site. The Content pages list what people made; AI Moments list what the platform\'s AI derived from streams. Every AI item is labelled AI-generated, credited to no person, marked noindex,follow and made canonical to the source recording at the moment it came from, on OpenVibe.Video (https://openvibe.video/v/<id>?t=<seconds>). In the JSON feeds, AI items carry "ai": true and an "ai_label".',
sections: [
{ title: 'Going live from a browser (no OBS)', links: [
{ title: 'How to go live in your browser', url: `${b}/docs/go-live-in-your-browser`, note: `open ${b}, sign in, press Go Live, allow camera and microphone, start. No software, no follower minimum.` },
@@@ -119,8 +119,8 @@ function llmsTxt() {
{ title: 'Home', url: `${b}/`, note: 'who is live now, recent VODs and clips' },
{ title: 'Content', url: `${b}/content`, note: `VODs, the clips people took, and pastes people wrote. JSON: ${b}/api/content/feed (cursor paging; ?type=vods|clips|pastes, ?sort=new|top)` },
{ title: 'Channel pages', url: `${b}/@<username>`, note: `the channel's videos in pages (${b}/@<username>?page=2)` },
-- { title: 'VOD pages', url: `${b}/vod/<id>`, note: `a moment in a VOD: ${b}/vod/<id>?t=<seconds>` },
-- { title: 'Clip pages', url: `${b}/clip/<id>` },
++ { title: 'Recording pages', url: 'https://openvibe.video/v/<id>', note: 'the canonical watch page on OpenVibe.Video, with chapters, captions and chat replay; a moment: https://openvibe.video/v/<id>?t=<seconds> (Live\'s own /vod/<id> names it as canonical)' },
++ { title: 'Clip pages', url: 'https://openvibe.video/c/<id>', note: 'on OpenVibe.Video (Live\'s /clip/<id> names it as canonical)' },
{ title: 'Pastes', url: 'https://openvibe.community/p/<slug>', note: 'pastes live on OpenVibe.Community (Live\'s /p/<slug> redirects there)' },
{ title: 'Chat', url: `${b}/chat` },
{ title: 'The Arena', url: `${b}/arena`, note: 'mic-judged streamer callouts' },
ddiff --git a/server/seo/pages.js b/server/seo/pages.js
iindex 0d7088bb..55267a7a 100644
---- a/server/seo/pages.js
++++ b/server/seo/pages.js
@@@ -68,6 +68,7 @@ const _feedPage = async (feed, page = 1) => await _cached(`feed:${feed}:${page}`
const _feedList = async (feed) => (await _feedPage(feed, 1)).items;
const { isPrivate } = require('../media-proxy/access');
const pageStatus = require('../web/page-status');
++const { videoUrl } = require('../web/video-url');
// ── 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
@@@ -585,7 +586,8 @@ async function _sourceMoment(vodId, seconds) {
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 };
++ // The moment on the recording's canonical page, OpenVibe.Video (web/video-url.js).
++ return { path: videoUrl('vod', vodId, t), vod: v };
}
async function _vodMeta(id) {
@@@ -640,7 +642,9 @@ async function _vodMeta(id) {
});
return {
title: `${title}${author ? ' — ' + author : ''} | ${SITE_NAME}`, description: desc,
-- canonicalPath, image, ogType: 'video.other', robots: indexable ? 'index,follow' : 'noindex,follow',
++ // A public, finished recording's canonical page is on OpenVibe.Video; this page stays its og:url.
++ ...(indexable && !v.is_recording ? { canonicalPath: videoUrl('vod', id), ogUrlPath: canonicalPath } : { canonicalPath }),
++ image, ogType: 'video.other', robots: indexable ? 'index,follow' : 'noindex,follow',
video: { duration: Math.floor(Number(v.duration_seconds) || 0) },
jsonLd: [vo, _breadcrumb([{ name: 'Home', url: '/' }, ...(owner ? [{ name: author || owner.username, url: `/@${owner.username}` }] : [{ name: 'VODs', url: '/vods' }]), { name: title, url: canonicalPath }])],
snapshot,
@@@ -680,7 +684,8 @@ async function _clipMeta(id) {
});
return {
title: `${title} — clip | ${SITE_NAME}`, description: desc,
-- canonicalPath, image, ogType: 'video.other', robots: indexable ? 'index,follow' : 'noindex,follow',
++ ...(indexable ? { canonicalPath: videoUrl('clip', id), ogUrlPath: canonicalPath } : { canonicalPath }),
++ image, ogType: 'video.other', robots: indexable ? 'index,follow' : 'noindex,follow',
video: { duration: Math.floor(Number(c.duration_seconds) || 0) },
jsonLd: [vo, _breadcrumb([{ name: 'Home', url: '/' }, ...(channel ? [{ name: _nameOf(channel), url: `/@${channel.username}` }] : [{ name: 'Clips', url: '/clips' }]), { name: title, url: canonicalPath }])],
snapshot,
@@@ -858,7 +863,8 @@ function _headBlock(meta) {
let tags = seo.headTags({
title: meta.title, description: meta.description, canonical,
// One English page per URL (the SPA translates in place): the page is its own en and x-default.
-- alternates: [{ hreflang: 'en', href: canonical }, { hreflang: 'x-default', href: canonical }],
++ // (Only when the canonical is on this site: a recording's or clip's canonical is on OpenVibe.Video.)
++ alternates: canonical.startsWith(`${baseUrl()}/`) || canonical === baseUrl() ? [{ hreflang: 'en', href: canonical }, { hreflang: 'x-default', href: canonical }] : [],
image: abs(meta.image || DEFAULT_OG_IMAGE), type: meta.ogType || 'website', siteName: SITE_NAME,
robots: meta.robots || 'index,follow', jsonLd: meta.jsonLd || [],
});
ddiff --git a/server/web/video-url.js b/server/web/video-url.js
nnew file mode 100644
iindex 00000000..5d66d95f
---- /dev/null
++++ b/server/web/video-url.js
@@@ -0,0 +1,23 @@
++'use strict';
++/**
++ * OpenVibe.Video (openvibe.video) is the canonical public home of a recording or clip: its watch page has the player
++ * (HLS), chapters, captions, chat replay, comments, playlists and the owner's tools, and its sitemap lists them.
++ * Live's own /vod/<id> and /clip/<id> pages keep working, but name the Video page as canonical, Live's sitemap leaves
++ * them out, and OpenVibe.Search documents link there.
++ *
++ * videoUrl('vod', 12) → 'https://openvibe.video/v/12'
++ * videoUrl('clip', 7) → 'https://openvibe.video/c/7'
++ * videoUrl('vod', 12, 95) → 'https://openvibe.video/v/12?t=95' (a moment)
++ *
++ * VIDEO_PUBLIC_URL overrides the origin (development, a restore drill).
++ */
++const BASE = String(process.env.VIDEO_PUBLIC_URL || 'https://openvibe.video').replace(/\/+$/, '');
++
++function videoUrl(kind, id, t = null) {
++ const n = Number(id);
++ if (!Number.isSafeInteger(n) || n <= 0) return null;
++ const at = t != null && Number.isFinite(Number(t)) && Number(t) > 0 ? `?t=${Math.floor(Number(t))}` : '';
++ return `${BASE}/${kind === 'clip' ? 'c' : 'v'}/${n}${at}`;
++}
++
++module.exports = { videoUrl, VIDEO_BASE: BASE };
ddiff --git a/test/llms-txt.test.js b/test/llms-txt.test.js
iindex d7734e9c..a1371f72 100644
---- a/test/llms-txt.test.js
++++ b/test/llms-txt.test.js
@@@ -50,7 +50,7 @@ async function main() {
await check('names the public pages and the JSON behind them', () => {
for (const u of ['https://openvibe.live/content', 'https://openvibe.live/moments', 'https://openvibe.live/@<username>',
-- 'https://openvibe.live/vod/<id>?t=<seconds>', 'https://openvibe.live/clip/<id>', 'https://openvibe.live/api/content/feed',
++ 'https://openvibe.video/v/<id>?t=<seconds>', 'https://openvibe.video/c/<id>', 'https://openvibe.live/api/content/feed',
'https://openvibe.live/api/content/moments', 'https://openvibe.community/p/<slug>', 'https://openvibe.live/sitemap.xml']) {
assert.ok(text.includes(u), `missing ${u}`);
}
ddiff --git a/test/search-media-documents.test.js b/test/search-media-documents.test.js
iindex 22f3781a..3f230d83 100644
---- a/test/search-media-documents.test.js
++++ b/test/search-media-documents.test.js
@@@ -75,7 +75,7 @@ const vod = (id, extra = {}) => ({ id, app_id: 'live', user_id: 1, stream_id: 1,
let ev = index()[0];
assert.strictEqual(ev.event_type, 'live.index_document.upserted'); valid(ev);
assert.deepStrictEqual(ev.subject, { type: 'vod', id: '912', revision: 1 });
-- assert.strictEqual(ev.payload.canonical_url, 'https://openvibe.live/vod/912');
++ assert.strictEqual(ev.payload.canonical_url, 'https://openvibe.video/v/912');
assert.ok(ev.payload.summary.startsWith('Alex builds the rating menu'));
assert.ok(ev.payload.body.includes('we are building the forum'), 'the transcript is searchable');
assert.deepStrictEqual(ev.payload.facets, { duration_seconds: 5400, channel: 'alex', category: 'tech' });
ddiff --git a/test/seo-moments.test.js b/test/seo-moments.test.js
iindex 3dbbee98..5b66213f 100644
---- a/test/seo-moments.test.js
++++ b/test/seo-moments.test.js
@@@ -105,17 +105,17 @@ console.log('AI Moments pages');
await check('an auto-clip is noindex,follow, canonical to its VOD at the moment, labelled AI', async () => {
const m = await seo._pageMeta('/clip/20');
assert.strictEqual(m.robots, 'noindex,follow');
-- assert.strictEqual(m.canonicalPath, '/vod/10?t=125');
++ assert.strictEqual(m.canonicalPath, 'https://openvibe.video/v/10?t=125');
assert.strictEqual(m.ogUrlPath, '/clip/20', 'a shared link still previews the clip');
assert.ok(/AI clip from Alice's stream/.test(m.title), m.title);
assert.ok(/^AI-generated clip/.test(m.description), m.description);
assert.ok(m.snapshot.includes("AI clip · from Alice&#39;s stream"), 'the byline says what made it');
-- assert.ok(m.snapshot.includes('https://openvibe.live/vod/10?t=125'), 'the page links its source');
++ assert.ok(m.snapshot.includes('https://openvibe.video/v/10?t=125'), 'the page links its source, on OpenVibe.Video');
assert.ok(!/Clipped by/.test(m.snapshot), 'never "Clipped by" the streamer');
assertNoPerson(m, 'auto-clip');
const vo = mainLd(m);
assert.strictEqual(vo.keywords, 'AI-generated');
-- assert.strictEqual(vo.isBasedOn, 'https://openvibe.live/vod/10?t=125');
++ assert.strictEqual(vo.isBasedOn, 'https://openvibe.video/v/10?t=125');
const crumbs = m.jsonLd.find((n) => n['@type'] === 'BreadcrumbList').itemListElement.map((i) => i.name);
assert.deepStrictEqual(crumbs.slice(0, 2), ['Home', 'AI Moments']);
});
@@@ -123,7 +123,7 @@ console.log('AI Moments pages');
await check('the rendered page carries it: robots, canonical and og:url', async () => {
const html = seo.render(await seo._pageMeta('/clip/20'), '/clip/20');
assert.ok(html.includes('<meta name="robots" content="noindex,follow">'));
-- assert.ok(html.includes('<link rel="canonical" href="https://openvibe.live/vod/10?t=125">'));
++ assert.ok(html.includes('<link rel="canonical" href="https://openvibe.video/v/10?t=125">'));
assert.ok(html.includes('<meta property="og:url" content="https://openvibe.live/clip/20">'));
assert.strictEqual((html.match(/<link rel="canonical"/g) || []).length, 1, 'one canonical');
assert.strictEqual((html.match(/<meta name="robots"/g) || []).length, 1, 'one robots tag');
@@@ -139,7 +139,8 @@ console.log('AI Moments pages');
await check("a person's clip stays indexable, self-canonical, credited to the clipper", async () => {
const m = await seo._pageMeta('/clip/21');
assert.strictEqual(m.robots, 'index,follow');
-- assert.strictEqual(m.canonicalPath, '/clip/21');
++ assert.strictEqual(m.canonicalPath, 'https://openvibe.video/c/21', 'canonical on OpenVibe.Video');
++ assert.strictEqual(m.ogUrlPath, '/clip/21', 'this page stays its og:url');
assert.deepStrictEqual(mainLd(m).author, { '@type': 'Person', name: 'Bob' });
assert.ok(m.snapshot.includes('Clipped by Bob'));
});
@@@ -147,7 +148,7 @@ console.log('AI Moments pages');
await check('an AI moment paste points at the VOD second it came from', async () => {
const m = await seo._pageMeta('/p/ai-shot');
assert.strictEqual(m.robots, 'noindex,follow');
-- assert.strictEqual(m.canonicalPath, '/vod/10?t=300');
++ assert.strictEqual(m.canonicalPath, 'https://openvibe.video/v/10?t=300');
assert.ok(/AI note from "Night stream"/.test(m.title), m.title);
assert.ok(m.snapshot.includes('AI note · from &quot;Night stream&quot; at 5:00'), m.snapshot.slice(0, 300));
assert.ok(!m.snapshot.includes('Shared by'), 'never "Shared by OpenVibe AI"');
@@@ -158,7 +159,7 @@ console.log('AI Moments pages');
await check("a paste caught live is canonical to its stream's VOD at that second", async () => {
const m = await seo._pageMeta('/p/ai-live');
assert.strictEqual(m.robots, 'noindex,follow');
-- assert.strictEqual(m.canonicalPath, '/vod/10?t=42');
++ assert.strictEqual(m.canonicalPath, 'https://openvibe.video/v/10?t=42');
assert.ok(m.snapshot.includes('while the stream was live'));
assertNoPerson(m, 'live AI paste');
});
@@@ -173,7 +174,7 @@ console.log('AI Moments pages');
await check('an AI-written recap is noindex, canonical to the VOD, and not by the streamer', async () => {
const m = await seo._pageMeta(`/recap/${streamId}`);
assert.strictEqual(m.robots, 'noindex,follow');
-- assert.strictEqual(m.canonicalPath, '/vod/10');
++ assert.strictEqual(m.canonicalPath, 'https://openvibe.video/v/10');
assert.ok(/AI recap of Alice's stream/.test(m.title), m.title);
assert.ok(m.snapshot.includes('The streamer did not write it.'));
assertNoPerson(m, 'AI recap');
@@@ -188,8 +189,7 @@ console.log('AI Moments pages');
await check("the sitemap lists people's clips only, even when Media ignores the filter", async () => {
const xml = await seo.buildSitemap();
-- assert.ok(xml.includes('/clip/21<'), "a person's clip is listed");
-- assert.ok(!xml.includes('/clip/20<') && !xml.includes('/clip/22<'), 'no auto-clip');
++ assert.ok(!/<loc>[^<]*\/(clip|vod)\//.test(xml), 'no recording or clip: their canonical pages are on OpenVibe.Video');
assert.ok(!/<loc>[^<]*\/p\//.test(xml), 'no paste at all: Community is their canonical home');
assert.ok(xml.includes('/moments<'), 'the Moments collection is the indexable form');
assert.ok(clipQueries.some((q) => q.auto_generated === 0), 'asked Media for people\'s clips');
ddiff --git a/test/seo-ssr.test.js b/test/seo-ssr.test.js
iindex 9ebac70a..3a5e980e 100644
---- a/test/seo-ssr.test.js
++++ b/test/seo-ssr.test.js
@@@ -246,11 +246,11 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {
const r = await get('/vod/100', c.headers);
assert.strictEqual(r.status, 200, c.name);
assert.match(r.cache, /^public, max-age=\d+, must-revalidate$/, c.name);
-- assert.deepStrictEqual(canonicalsOf(r.body), ['https://openvibe.live/vod/100'], c.name);
++ assert.deepStrictEqual(canonicalsOf(r.body), ['https://openvibe.video/v/100'], `${c.name}: the canonical page is on OpenVibe.Video`);
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');
++ assert.deepStrictEqual(canonicalsOf(r.body), ['https://openvibe.video/v/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]), [
@@@ -272,12 +272,12 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {
const r = await get('/clip/200', c.headers);
assert.strictEqual(r.status, 200, c.name);
assert.match(r.cache, /^public, max-age=\d+, must-revalidate$/, c.name);
-- assert.deepStrictEqual(canonicalsOf(r.body), ['https://openvibe.live/clip/200'], c.name);
++ assert.deepStrictEqual(canonicalsOf(r.body), ['https://openvibe.video/c/200'], `${c.name}: the canonical page is on OpenVibe.Video`);
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'));
++ assert.ok(b.includes('https://openvibe.video/v/100?t=61'), 'the moment, on OpenVibe.Video');
});
await check('without JavaScript the body shows (with the site links) and the empty app shell is hidden', async () => {
@@@ -312,7 +312,7 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {
await check('the sitemap lists the channel pages, named from Live\'s accounts (Media rows carry ids only)', async () => {
const xml = await seo.buildSitemap();
assert.ok(xml.includes('<loc>https://openvibe.live/@alice</loc>'), 'the channel with videos');
-- assert.ok(xml.includes('<loc>https://openvibe.live/vod/100</loc>'));
++ assert.ok(!xml.includes('<loc>https://openvibe.live/vod/100</loc>'), 'recordings are listed by OpenVibe.Video, their canonical home');
assert.ok(!xml.includes('/vod/199<'), 'no private VOD');
});