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)
Changes
@@@ -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).++## ViewsA view is one `content_views` row per item and address: Live's detail read (`GET /api/vods/:id`, `GET /api/clips/:id`)@@@ -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),@@@ -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' },@@@ -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 || [],});nnew file mode 100644@@@ -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 };@@@ -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}`);}@@@ -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' });@@@ -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'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 "Night stream" 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');@@@ -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'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');});
@@ -58,6 +58,15 @@ From the dashboard:
@@ -58,6 +58,15 @@ From the dashboard:
(`vod_ai_state`/`clip_ai_state`, `content_views`) and its Search document is re-read at once
(`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
- **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
## Views
A view is one `content_views` row per item and address: Live's detail read (`GET /api/vods/:id`, `GET /api/clips/:id`)
A view is one `content_views` row per item and address: Live's detail read (`GET /api/vods/:id`, `GET /api/clips/:id`)
@@ -1,8 +1,8 @@
@@ -1,8 +1,8 @@
'use strict';
'use strict';
/**
/**
* Live's VOD and clip pages in OpenVibe.Search (roadmap WS-O task 10; Contracts 0.45.0
* 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
* 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
* 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
* from the frozen local vods/clips tables; the AI overview and transcript are Live's vod_ai_state and
@@ -21,8 +21,8 @@
@@ -21,8 +21,8 @@
const crypto = require('crypto');
const crypto = require('crypto');
const db = require('../db/database');
const db = require('../db/database');
const streamEvents = require('./stream-events');
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 DAY_MS = 86400000;
const PAGE = 200;
const PAGE = 200;
const TOUCH_DELAY_MS = Number(process.env.LIVE_SEARCH_TOUCH_DELAY_MS) || 1500;
const TOUCH_DELAY_MS = Number(process.env.LIVE_SEARCH_TOUCH_DELAY_MS) || 1500;
@@ -79,7 +79,8 @@ async function documentFor(kind, row) {
@@ -79,7 +79,8 @@ async function documentFor(kind, row) {
const reasons = [...(aiClip ? ['ai_unreviewed'] : []), ...(stream && stream.is_nsfw ? ['sensitive'] : [])];
const reasons = [...(aiClip ? ['ai_unreviewed'] : []), ...(stream && stream.is_nsfw ? ['sensitive'] : [])];
const doc = {
const doc = {
owner: 'live', type: kind, id: String(row.id), deleted: false, visibility: 'public',
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),
title: clean(row.title || fallbackTitle, 500),
summary,
summary,
body: [clean(row.description, 4000), clean(ai.overview, 8000), ai.transcript].filter(Boolean).join('\n').slice(0, 40000),
body: [clean(row.description, 4000), clean(ai.overview, 8000), ai.transcript].filter(Boolean).join('\n').slice(0, 40000),
@@ -9,7 +9,7 @@ const seo = require('openvibe-shared/seo');
@@ -9,7 +9,7 @@ const seo = require('openvibe-shared/seo');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
const db = require('../db/database');
const db = require('../db/database');
const media = require('../media-client');
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 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
const DISCOVERY_MAX_AGE = 3600; // seconds, for every file here
@@ -55,8 +55,9 @@ async function buildSitemap() {
@@ -55,8 +55,9 @@ async function buildSitemap() {
off += per;
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) => {
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);
const ch = await channelOf(v, v.user_id);
if (ch) seenChannels.add(ch);
if (ch) seenChannels.add(ch);
});
});
@@ -65,7 +66,6 @@ async function buildSitemap() {
@@ -65,7 +66,6 @@ async function buildSitemap() {
// upstream that ignores it still cannot put an AI item here.
// 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) => {
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;
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);
const ch = await channelOf({}, c.channel_user_id != null ? c.channel_user_id : c.user_id);
if (ch) seenChannels.add(ch);
if (ch) seenChannels.add(ch);
});
});
@@ -108,7 +108,7 @@ function llmsTxt() {
@@ -108,7 +108,7 @@ function llmsTxt() {
return seo.llmsTxt({
return seo.llmsTxt({
name: SITE_NAME,
name: SITE_NAME,
summary: LLMS_SUMMARY,
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: [
sections: [
{ title: 'Going live from a browser (no OBS)', links: [
{ title: 'Going live from a browser (no OBS)', links: [
{ title: 'How to go live in your browser', url: `${b}/docs/go-live-in-your-browser`, note: `open ${b}, sign in, press Go Live, allow camera and microphone, start. No software, no follower minimum.` },
{ title: '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() {
@@ -119,8 +119,8 @@ function llmsTxt() {
{ title: 'Home', url: `${b}/`, note: 'who is live now, recent VODs and clips' },
{ 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: '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: '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: '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: 'Chat', url: `${b}/chat` },
{ title: 'The Arena', url: `${b}/arena`, note: 'mic-judged streamer callouts' },
{ title: 'The Arena', url: `${b}/arena`, note: 'mic-judged streamer callouts' },
@@ -68,6 +68,7 @@ const _feedPage = async (feed, page = 1) => await _cached(`feed:${feed}:${page}`
@@ -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 _feedList = async (feed) => (await _feedPage(feed, 1)).items;
const { isPrivate } = require('../media-proxy/access');
const { isPrivate } = require('../media-proxy/access');
const pageStatus = require('../web/page-status');
const pageStatus = require('../web/page-status');
+const { videoUrl } = require('../web/video-url');
// ── One item (VOD, clip, paste) ──
// ── 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
// 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) {
@@ -585,7 +586,8 @@ async function _sourceMoment(vodId, seconds) {
const v = await _vodGet(Number(vodId));
const v = await _vodGet(Number(vodId));
if (!v || !_pub(v)) return null;
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 };
+ // 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) {
async function _vodMeta(id) {
@@ -640,7 +642,9 @@ async function _vodMeta(id) {
@@ -640,7 +642,9 @@ async function _vodMeta(id) {
});
});
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',
+ // 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) },
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 }])],
jsonLd: [vo, _breadcrumb([{ name: 'Home', url: '/' }, ...(owner ? [{ name: author || owner.username, url: `/@${owner.username}` }] : [{ name: 'VODs', url: '/vods' }]), { name: title, url: canonicalPath }])],
snapshot,
snapshot,
@@ -680,7 +684,8 @@ async function _clipMeta(id) {
@@ -680,7 +684,8 @@ async function _clipMeta(id) {
});
});
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',
+ ...(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) },
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 }])],
jsonLd: [vo, _breadcrumb([{ name: 'Home', url: '/' }, ...(channel ? [{ name: _nameOf(channel), url: `/@${channel.username}` }] : [{ name: 'Clips', url: '/clips' }]), { name: title, url: canonicalPath }])],
snapshot,
snapshot,
@@ -858,7 +863,8 @@ function _headBlock(meta) {
@@ -858,7 +863,8 @@ function _headBlock(meta) {
let tags = seo.headTags({
let tags = seo.headTags({
title: meta.title, description: meta.description, canonical,
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.
// 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,
image: abs(meta.image || DEFAULT_OG_IMAGE), type: meta.ogType || 'website', siteName: SITE_NAME,
robots: meta.robots || 'index,follow', jsonLd: meta.jsonLd || [],
robots: meta.robots || 'index,follow', jsonLd: meta.jsonLd || [],
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,23 @@
@@ -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 };
@@ -50,7 +50,7 @@ async function main() {
@@ -50,7 +50,7 @@ async function main() {
await check('names the public pages and the JSON behind them', () => {
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>',
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']) {
'https://openvibe.live/api/content/moments', 'https://openvibe.community/p/<slug>', 'https://openvibe.live/sitemap.xml']) {
assert.ok(text.includes(u), `missing ${u}`);
assert.ok(text.includes(u), `missing ${u}`);
}
}
@@ -75,7 +75,7 @@ const vod = (id, extra = {}) => ({ id, app_id: 'live', user_id: 1, stream_id: 1,
@@ -75,7 +75,7 @@ const vod = (id, extra = {}) => ({ id, app_id: 'live', user_id: 1, stream_id: 1,
let ev = index()[0];
let ev = index()[0];
assert.strictEqual(ev.event_type, 'live.index_document.upserted'); valid(ev);
assert.strictEqual(ev.event_type, 'live.index_document.upserted'); valid(ev);
assert.deepStrictEqual(ev.subject, { type: 'vod', id: '912', revision: 1 });
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.summary.startsWith('Alex builds the rating menu'));
assert.ok(ev.payload.body.includes('we are building the forum'), 'the transcript is searchable');
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' });
assert.deepStrictEqual(ev.payload.facets, { duration_seconds: 5400, channel: 'alex', category: 'tech' });
@@ -105,17 +105,17 @@ console.log('AI Moments pages');
@@ -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 () => {
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');
const m = await seo._pageMeta('/clip/20');
assert.strictEqual(m.robots, 'noindex,follow');
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.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 clip from Alice's stream/.test(m.title), m.title);
assert.ok(/^AI-generated clip/.test(m.description), m.description);
assert.ok(/^AI-generated clip/.test(m.description), m.description);
assert.ok(m.snapshot.includes("AI clip · from Alice's stream"), 'the byline says what made it');
assert.ok(m.snapshot.includes("AI clip · from Alice'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');
assert.ok(!/Clipped by/.test(m.snapshot), 'never "Clipped by" the streamer');
assertNoPerson(m, 'auto-clip');
assertNoPerson(m, 'auto-clip');
const vo = mainLd(m);
const vo = mainLd(m);
assert.strictEqual(vo.keywords, 'AI-generated');
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);
const crumbs = m.jsonLd.find((n) => n['@type'] === 'BreadcrumbList').itemListElement.map((i) => i.name);
assert.deepStrictEqual(crumbs.slice(0, 2), ['Home', 'AI Moments']);
assert.deepStrictEqual(crumbs.slice(0, 2), ['Home', 'AI Moments']);
});
});
@@ -123,7 +123,7 @@ console.log('AI Moments pages');
@@ -123,7 +123,7 @@ console.log('AI Moments pages');
await check('the rendered page carries it: robots, canonical and og:url', async () => {
await check('the rendered page carries it: robots, canonical and og:url', async () => {
const html = seo.render(await seo._pageMeta('/clip/20'), '/clip/20');
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('<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.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(/<link rel="canonical"/g) || []).length, 1, 'one canonical');
assert.strictEqual((html.match(/<meta name="robots"/g) || []).length, 1, 'one robots tag');
assert.strictEqual((html.match(/<meta name="robots"/g) || []).length, 1, 'one robots tag');
@@ -139,7 +139,8 @@ console.log('AI Moments pages');
@@ -139,7 +139,8 @@ console.log('AI Moments pages');
await check("a person's clip stays indexable, self-canonical, credited to the clipper", async () => {
await check("a person's clip stays indexable, self-canonical, credited to the clipper", async () => {
const m = await seo._pageMeta('/clip/21');
const m = await seo._pageMeta('/clip/21');
assert.strictEqual(m.robots, 'index,follow');
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.deepStrictEqual(mainLd(m).author, { '@type': 'Person', name: 'Bob' });
assert.ok(m.snapshot.includes('Clipped by Bob'));
assert.ok(m.snapshot.includes('Clipped by Bob'));
});
});
@@ -147,7 +148,7 @@ console.log('AI Moments pages');
@@ -147,7 +148,7 @@ console.log('AI Moments pages');
await check('an AI moment paste points at the VOD second it came from', async () => {
await check('an AI moment paste points at the VOD second it came from', async () => {
const m = await seo._pageMeta('/p/ai-shot');
const m = await seo._pageMeta('/p/ai-shot');
assert.strictEqual(m.robots, 'noindex,follow');
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(/AI note from "Night stream"/.test(m.title), m.title);
assert.ok(m.snapshot.includes('AI note · from "Night stream" at 5:00'), m.snapshot.slice(0, 300));
assert.ok(m.snapshot.includes('AI note · from "Night stream" at 5:00'), m.snapshot.slice(0, 300));
assert.ok(!m.snapshot.includes('Shared by'), 'never "Shared by OpenVibe AI"');
assert.ok(!m.snapshot.includes('Shared by'), 'never "Shared by OpenVibe AI"');
@@ -158,7 +159,7 @@ console.log('AI Moments pages');
@@ -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 () => {
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');
const m = await seo._pageMeta('/p/ai-live');
assert.strictEqual(m.robots, 'noindex,follow');
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'));
assert.ok(m.snapshot.includes('while the stream was live'));
assertNoPerson(m, 'live AI paste');
assertNoPerson(m, 'live AI paste');
});
});
@@ -173,7 +174,7 @@ console.log('AI Moments pages');
@@ -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 () => {
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}`);
const m = await seo._pageMeta(`/recap/${streamId}`);
assert.strictEqual(m.robots, 'noindex,follow');
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(/AI recap of Alice's stream/.test(m.title), m.title);
assert.ok(m.snapshot.includes('The streamer did not write it.'));
assert.ok(m.snapshot.includes('The streamer did not write it.'));
assertNoPerson(m, 'AI recap');
assertNoPerson(m, 'AI recap');
@@ -188,8 +189,7 @@ console.log('AI Moments pages');
@@ -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 () => {
await check("the sitemap lists people's clips only, even when Media ignores the filter", async () => {
const xml = await seo.buildSitemap();
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(!/<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(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');
assert.ok(clipQueries.some((q) => q.auto_generated === 0), 'asked Media for people\'s clips');
@@ -246,11 +246,11 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {
@@ -246,11 +246,11 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {
const r = await get('/vod/100', c.headers);
const r = await get('/vod/100', c.headers);
assert.strictEqual(r.status, 200, c.name);
assert.strictEqual(r.status, 200, c.name);
assert.match(r.cache, /^public, max-age=\d+, must-revalidate$/, c.name);
assert.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));
assert.ok(/^Build night 100 — Alice \| OpenVibe\.Live$/.test(titleOf(r.body)), titleOf(r.body));
}
}
const r = await get('/vod/100?t=125');
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');
const vo = ldOf(r.body).find((n) => n['@type'] === 'VideoObject');
assert.deepStrictEqual(vo.author, { '@type': 'Person', name: 'Alice' });
assert.deepStrictEqual(vo.author, { '@type': 'Person', name: 'Alice' });
assert.deepStrictEqual(vo.hasPart.map((x) => [x['@type'], x.startOffset, x.url]), [
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 () => {
@@ -272,12 +272,12 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {
const r = await get('/clip/200', c.headers);
const r = await get('/clip/200', c.headers);
assert.strictEqual(r.status, 200, c.name);
assert.strictEqual(r.status, 200, c.name);
assert.match(r.cache, /^public, max-age=\d+, must-revalidate$/, c.name);
assert.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);
assert.deepStrictEqual(robotsOf(r.body), ['index,follow'], c.name);
}
}
const b = bodyOf((await get('/clip/200')).body);
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("Clipped by Bob from Alice'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 () => {
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 () => {
@@ -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 () => {
await check('the sitemap lists the channel pages, named from Live\'s accounts (Media rows carry ids only)', async () => {
const xml = await seo.buildSitemap();
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/@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');
assert.ok(!xml.includes('/vod/199<'), 'no private VOD');
});
});