openvibers/openvibe.mediahub Commits
openvibe.video for search: a description of its own on every watch page, short recordings with nothing written about them noindex, a sitemap of every item with video entries (openvibe-shared v4.5.0), and contentUrl naming Media's bytes (#99)
Changes
@@@ -4,6 +4,12 @@ What changed in OpenVibe.MediaHub, newest first. Each site also publishes its pa## Unreleased++- openvibe.video's search basics (2026-10-20). A watch page nobody wrote about gets a description of its own (who,++ how long, when, its first words) instead of the site tagline. A recording under two minutes with nothing written++ about it is noindex and out of the sitemap: 118 of 785 were under a minute, mostly test streams. The sitemap lists++ every recording and clip (it stopped at 150 of each) with a video entry each (openvibe-shared 4.5.0) and is kept++ ten minutes. Structured data and the sitemap name Media's file with `?raw=1`, since Media answers crawlers a page++ otherwise.- MediaHub reaches OpenVibe.Media with its Network service token (audience `openvibe.media`, grants`media.object.upload` and `media.object.read` on namespace `media-hub`). `MEDIAHUB_MEDIA_APP_KEY` is gone(compatibility register C-71).@@@ -523,5 +523,5 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [<!-- versions:start -->- openvibe-contracts: v0.200.0- openvibe-sdk: v0.50.1--- openvibe-shared: v4.4.0++- openvibe-shared: v4.5.0<!-- versions:end -->@@@ -10,7 +10,7 @@"packages": ["openvibe-contracts v0.200.0","openvibe-sdk v0.50.1",-- "openvibe-shared v4.4.0"++ "openvibe-shared v4.5.0"],"summary": "OpenVibe.MediaHub: openvibe.download as a private drive. Account-only uploads stream into OpenVibe.Media (a plain form up to 256 MB, resumable parts beyond). Files are private. Share links expire in 1 hour to 7 days, need a signed-in downloader and can be limited to named accounts (matched by subject). Reports suspend a share for staff. Downloads are always sandboxed attachments. Per-person quotas, and account export and deletion (ADR-033). openvibe.video serves public watch pages and playlists for Live's recordings and clips (no uploads); openvibe.pics is parked and redirects to openvibe.network.","works": [@@@ -45,7 +45,7 @@],"contracts": "openvibe-contracts v0.200.0","sdk": "openvibe-sdk v0.50.1",-- "shared": "openvibe-shared v4.4.0",++ "shared": "openvibe-shared v4.5.0","deployment": "openvibe-ovh: unit openvibe-media-hub.service on 127.0.0.1:4990 (git checkout /opt/openvibe.download, env /etc/openvibe/media-hub.env, database ov_media_hub), deployed with `sudo ovhost deploy media-hub`; public at https://openvibe.download since 2026-10-08 and https://openvibe.video since 2026-10-12.","updated": "2026-10-17"}@@@ -17,7 +17,7 @@"iovalkey": "^0.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.200.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.50.1",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.4.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.5.0","pg": "^8.23.0","sharp": "^0.33.5"},@@@ -1379,9 +1379,9 @@}},"node_modules/openvibe-shared": {-- "version": "4.4.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.4.0",-- "integrity": "sha512-zhC3wzRdI3HG+TzLEEXTzZquBoHJHzE/67kGgmrYCXmouqcugKA+RZZ3gFQXMsIx79IQNrbE/tlg0hqiyUCJmw==",++ "version": "4.5.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.5.0",++ "integrity": "sha512-HUEDETiz1l4zbjijr+yov7rQdCU1HV2VLT+JD2UZZeHcR5h9+bQpR7GXUixxeEwY3bznqCmW6l0c61NLEfYVLg==","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0"@@@ -24,7 +24,7 @@"iovalkey": "^0.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.200.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.50.1",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.4.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.5.0","pg": "^8.23.0","sharp": "^0.33.5"},@@@ -36,7 +36,9 @@ const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');const HOME_LIMIT = 10;const MIN_HOME_SECONDS = 15;const SITEMAP_LIMIT = 30; // Live's feed page size (its MAX_LIMIT)--const SITEMAP_PAGES = 5; // per kind: up to 150 recordings and 150 clips++const SITEMAP_PAGES = 50; // per kind: up to 1,500 recordings and 1,500 clips (785 and 156 public on 2026-10-20)++const SITEMAP_TTL_MS = 10 * 60 * 1000;++const THIN_SECONDS = 120;const CHANNEL_CLIPS_LIMIT = 12;const PER_PAGE = 24;@@@ -77,6 +79,58 @@ function fileOf(item) {return httpsUrl(item.file_url) || httpsUrl(item.playback_url);}++/**++ * The file as a crawler should fetch it. openvibe.media's /v/<id> and /c/<id> answer search engines' bots with a page++ * (link previews), Googlebot-Video included, so structured data and the video sitemap ask with ?raw=1: always the bytes.++ */++function crawlFile(url) {++ return /^https:\/\/openvibe\.media\/[vc]\/\d+$/.test(url || '') ? `${url}?raw=1` : url;++}++++/**++ * A recording search engines should leave out: under two minutes with nothing written about it (no description, no AI++ * overview, under three transcript lines). On 2026-10-20, 118 of 785 public recordings were under a minute, mostly test++ * streams titled "<name>'s Channel": hundreds of near-empty pages that only dilute the site. The page still plays.++ */++function thinRecording(vod, transcriptLines = 0) {++ return !vod.description && !vod.ai_overview && transcriptLines < 3 && (Number(vod.duration_seconds) || 0) < THIN_SECONDS;++}++++/** 31 → "31 seconds", 754 → "13 minutes", 7500 → "2 hours 5 minutes". */++function durationWords(s) {++ const n = Math.max(0, Math.round(Number(s) || 0));++ const plural = (v, unit) => `${v} ${unit}${v === 1 ? '' : 's'}`;++ if (n < 60) return plural(n, 'second');++ if (n < 3600) return plural(Math.round(n / 60), 'minute');++ const h = Math.floor(n / 3600);++ const m = Math.round((n % 3600) / 60);++ return m ? `${plural(h, 'hour')} ${plural(m, 'minute')}` : plural(h, 'hour');++}++++/**++ * A watch page's meta description when nobody wrote one, so no two pages share the site's tagline: what it is, whose,++ * how long, when, and (for a recording with a transcript) its first words, at most ~160 characters.++ */++function factualDescription(kind, item, firstWords = '') {++ const at = (u) => (/^[A-Za-z0-9_]{1,32}$/.test(String(u || '')) ? `@${u}` : null);++ const facts = [item.duration_seconds ? durationWords(item.duration_seconds) : null, dateLabel(item.created_at) || null, kind === 'vod' ? item.stream_category || null : null].filter(Boolean).join(', ');++ let lead;++ if (kind === 'vod') lead = `Stream recording${at(item.username) ? ` by ${at(item.username)}` : ''} on OpenVibe.Live`;++ else {++ const streamer = at(item.source_streamer_username);++ const maker = at(item.username);++ lead = `Clip${streamer ? ` of ${streamer}'s stream` : ''} on OpenVibe.Live${maker && maker !== streamer ? `, made by ${maker}` : ''}`;++ }++ const head = `${lead}${facts ? `: ${facts}` : ''}.`;++ const words = String(firstWords || '').replace(/\s+/g, ' ').trim();++ if (words && head.length < 120) {++ const room = 156 - head.length - 12;++ const quote = words.length > room ? `${words.slice(0, room).replace(/\s+\S*$/, '')}…` : words;++ if (quote.length > 12) return `${head} It opens: “${quote}”`;++ }++ return kind === 'vod' ? `${head} Watch with AI captions and chapters on OpenVibe.Video.` : `${head} Watch it on OpenVibe.Video.`;++}++/** The player's HLS source: Media's timeline playlist, for a finished item that has one, on the playback file's origin. */function hlsAttrs(item) {const hls = !item.is_recording && httpsUrl(item.hls_url);@@@ -142,7 +196,7 @@ function videoLd({ base, path, item, title, description, chapterList, partOf })...seo.jsonLd.video({name: title, url: pageUrl, description: String(description || title).slice(0, 500),thumbnailUrl: thumb ? [thumb] : undefined, uploadDate: isoTime(item.created_at),-- duration: isoDuration(item.duration_seconds), contentUrl: fileOf(item) || undefined, embedUrl: pageUrl,++ duration: isoDuration(item.duration_seconds), contentUrl: crawlFile(fileOf(item)) || undefined, embedUrl: pageUrl,}),...(item.view_count != null ? { interactionStatistic: { '@type': 'InteractionCounter', interactionType: 'https://schema.org/WatchAction', userInteractionCount: Number(item.view_count) || 0 } } : {}),...(channel ? { author: { '@type': 'Person', name: channel, ...(item.username ? { url: `${base}/@${item.username}` } : {}) } } : {}),@@@ -803,14 +857,16 @@ ${uploaded.length ? html`<section class="ovv-section" aria-labelledby="h-uploadsconst viewAttr = html` data-ovv-view="${livePublic}/api/views/vod/${id}"`;const hlsAttr = hlsAttrs(vod);++ const thin = thinRecording(vod, segs.length);++ const about = vod.description || vod.ai_overview || factualDescription('vod', vod, segs.slice(0, 6).map((s) => s.text).join(' '));return page(req, res, {-- index: !unlisted && !ownOnly,-- noindex: unlisted || ownOnly,++ index: !unlisted && !ownOnly && !thin,++ noindex: unlisted || ownOnly || thin,...(ownOnly ? { cache: cache.htmlHeaders({ private: true }) } : {}),title: watchTitle(vod.title || 'Stream recording', vod.username, vod.created_at),-- description: vod.description || vod.ai_overview || '',++ description: about,...(ownOnly || vod.is_recording ? {} : {-- jsonLd: videoLd({ base, path: `/v/${id}`, item: vod, title: vod.title || 'Stream recording', description: vod.description || vod.ai_overview, chapterList: mems }),++ jsonLd: videoLd({ base, path: `/v/${id}`, item: vod, title: vod.title || 'Stream recording', description: about, chapterList: mems }),image: httpsUrl(vod.thumbnail_url) || undefined, ogType: 'video.other',}),scripts: ['js/video.js'],@@@ -886,9 +942,9 @@ ${clips.length ? html`<section class="ovv-panel" aria-labelledby="h-clips"><h2 inoindex: unlisted || ownOnly || aiClip,...(ownOnly ? { cache: cache.htmlHeaders({ private: true }) } : {}),title: watchTitle(clip.title || 'Clip', clip.source_streamer_username, clip.created_at),-- description: clip.ai_overview || '',++ description: clip.ai_overview || factualDescription('clip', clip),...(ownOnly || aiClip ? {} : {-- jsonLd: videoLd({ base, path: `/c/${id}`, item: clip, title: clip.title || 'Clip', description: clip.ai_overview || attribution, partOf: clip.vod_id ? `/v/${clip.vod_id}` : null }),++ jsonLd: videoLd({ base, path: `/c/${id}`, item: clip, title: clip.title || 'Clip', description: clip.ai_overview || factualDescription('clip', clip) || attribution, partOf: clip.vod_id ? `/v/${clip.vod_id}` : null }),image: httpsUrl(clip.thumbnail_url) || undefined, ogType: 'video.other',}),scripts: ['js/video.js'],@@@ -1207,11 +1263,37 @@ ${isOwner ? html`<p class="ovv-fine">You can reorder items with the up and downreturn items;}++ /**++ * A feed item's <video:video> (openvibe-shared seo.sitemapXml): the thumbnail, title, a description (the item's own++ * text or the facts), the file (Media's bytes) and who made it. Without a thumbnail there is none.++ */++ function sitemapVideo(it) {++ const thumb = httpsUrl(it.thumbnail_url);++ if (!thumb) return null;++ const kind = it.kind === 'clip' ? 'clip' : 'vod';++ const who = it.channel && it.channel.username;++ const item = { duration_seconds: it.duration_seconds, created_at: it.created_at, username: kind === 'vod' ? who : (it.by && it.by.username) || who, source_streamer_username: who };++ const file = kind === 'clip' ? httpsUrl(it.preview_url) : `https://openvibe.media/v/${Number(it.id)}`;++ return {++ thumbnail_loc: thumb, title: it.title || (kind === 'clip' ? 'Clip' : 'Stream recording'),++ description: it.excerpt || factualDescription(kind, item),++ content_loc: crawlFile(file) || undefined, duration: it.duration_seconds, view_count: it.views,++ publication_date: isoTime(it.created_at),++ ...(who && /^[A-Za-z0-9_]{1,32}$/.test(who) ? { uploader: who, uploader_info: `${base}/@${who}` } : {}),++ };++ }++++ let sitemapCache = null; // { at, xml }: Live's feed is walked page by page, so the answer is kept ten minutesr.get('/sitemap.xml', async (req, res) => {++ if (sitemapCache && Date.now() - sitemapCache.at < SITEMAP_TTL_MS) {++ return res.type('application/xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(sitemapCache.xml);++ }const [vods, clips, uploaded] = await Promise.all([feedItems('vods'), feedItems('clips'), ctx.videoUploads.latest(500)]);const day = (iso) => { const d = new Date(String(iso || '')); return Number.isNaN(d.getTime()) ? null : d.toISOString().slice(0, 10); };const newest = (list) => list.map(day).filter(Boolean).sort().pop() || null;-- const items = [...vods, ...clips].filter((it) => it && Number.isSafeInteger(Number(it.id)));++ // A short recording with nothing written about it is noindex on its page (thinRecording), so it is left out here.++ const listed = vods.filter((it) => it && (it.excerpt || (Number(it.duration_seconds) || 0) >= THIN_SECONDS));++ const items = [...listed, ...clips].filter((it) => it && Number.isSafeInteger(Number(it.id)));const channels = new Map(); // username → its newest item's dayfor (const it of items) {const u = it.channel && it.channel.username;@@@ -1228,11 +1310,15 @@ ${isOwner ? html`<p class="ovv-fine">You can reorder items with the up and downconst urls = [{ loc: `${base}/`, lastmod: newest(items.map((it) => it.created_at)) },{ loc: `${base}/updates` },-- ...items.map((it) => ({ loc: `${base}${it.kind === 'clip' ? '/c/' : '/v/'}${Number(it.id)}`, lastmod: day(it.created_at) })),++ ...items.map((it) => {++ const video = sitemapVideo(it);++ return { loc: `${base}${it.kind === 'clip' ? '/c/' : '/v/'}${Number(it.id)}`, lastmod: day(it.created_at), ...(video ? { videos: [video] } : {}) };++ }),...uploaded.map((x) => ({ loc: `${base}/u/${x.id}`, lastmod: day(x.updated_at) })),...[...channels].map(([u, d]) => ({ loc: `${base}/@${u}`, lastmod: d })),-- ].map((u) => (u.lastmod ? u : { loc: u.loc }));-- return res.type('application/xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(seo.sitemapXml(urls));++ ].map((u) => (u.lastmod ? u : { ...u, lastmod: undefined }));++ sitemapCache = { at: Date.now(), xml: seo.sitemapXml(urls) };++ return res.type('application/xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(sitemapCache.xml);});r.get('/robots.txt', (_req, res) => {@@@ -88,6 +88,8 @@ function startLive() {ai_overview: '', ai_transcript_json: null,stream_id: 1002, stream_started_at: '2026-10-12 09:30:00', stream_ended_at: '2026-10-12 09:35:00',};++ // Twenty seconds with nothing written about it: a test stream, left out of search.++ const thinVod = { ...secondVod, id: 106, title: "Streamer's Channel", duration_seconds: 20, stream_category: null };// Live's answer since T4 step 5: playback_url is the playlist, file_url the file.const hlsFirstVod = { ...secondVod, id: 105, title: 'Playlist first', playback_url: 'https://openvibe.media/o/med_105/master.m3u8',hls_url: 'https://openvibe.media/o/med_105/master.m3u8', file_url: 'https://openvibe.media/v/105' };@@@ -104,7 +106,7 @@ function startLive() {const p = url.pathname;if (p === '/api/content/feed') {const type = url.searchParams.get('type');-- if (type === 'vods') return json(200, { items: [vodFeedItem, { ...vodFeedItem, key: 'vod:109', id: 109, title: 'A two-second blip', duration_seconds: 2 }], next: null });++ if (type === 'vods') return json(200, { items: [vodFeedItem, { ...vodFeedItem, key: 'vod:109', id: 109, title: 'A two-second blip', duration_seconds: 2 }, { ...vodFeedItem, key: 'vod:110', id: 110, title: "Streamer's Channel", duration_seconds: 10, excerpt: null }], next: null });if (type === 'clips') return json(200, { items: [clipFeedItem], next: null });return json(200, { items: [], next: null });}@@@ -130,6 +132,7 @@ function startLive() {if (vodDetail[1] === '101') return json(200, { vod: publicVod, clips: [clipListItem] });if (vodDetail[1] === '102') return json(200, { vod: unlistedVod, clips: [] });if (vodDetail[1] === '104') return json(200, { vod: secondVod, clips: [] });++ if (vodDetail[1] === '106') return json(200, { vod: thinVod, clips: [] });if (vodDetail[1] === '105') return json(200, { vod: hlsFirstVod, clips: [] });return json(404, { error: 'not found' });}@@@ -271,7 +274,7 @@ function countOccurrences(haystack, needle) {assert.strictEqual(ld.uploadDate, '2026-10-12T10:00:00.000Z');assert.strictEqual(ld.duration, 'PT45S');assert.deepStrictEqual(ld.thumbnailUrl, ['https://openvibe.media/t/101.jpg']);-- assert.strictEqual(ld.contentUrl, 'https://openvibe.media/v/101');++ assert.strictEqual(ld.contentUrl, 'https://openvibe.media/v/101?raw=1', 'Media answers bots a page without ?raw=1');assert.deepStrictEqual(ld.author, { '@type': 'Person', name: 'Streamer', url: 'https://openvibe.video/@streamer' });assert.ok(Array.isArray(ld.hasPart) && ld.hasPart.length >= 1 && ld.hasPart.every((c) => c['@type'] === 'Clip' && Number.isInteger(c.startOffset) && c.url.startsWith('https://openvibe.video/v/101?t=')), 'chapters as key moments');assert.match(r.text, /<meta property="og:image" content="https:\/\/openvibe\.media\/t\/101\.jpg"/, 'the thumbnail is the share image');@@@ -381,6 +384,36 @@ function countOccurrences(haystack, needle) {assert.ok(!r.text.includes('/v/102'));});++ await check('a recording nobody wrote about gets a description of its own, not the site tagline', async () => {++ const r = await getVideo('/v/104');++ assert.strictEqual(r.status, 200);++ const want = 'Stream recording by @streamer on OpenVibe.Live: 5 minutes, Oct 12, 2026. Watch with AI captions and chapters on OpenVibe.Video.';++ assert.ok(r.text.includes(`<meta name="description" content="${want}">`), r.text.slice(0, 1500));++ assert.ok(!r.text.includes('content="noindex'), 'five minutes is indexable');++ const clip = await getVideo('/c/201');++ assert.ok(clip.text.includes('<meta name="description" content="A clip overview">'), 'written text still wins');++ });++++ await check('a short recording with nothing written about it is noindex and out of the sitemap', async () => {++ const r = await getVideo('/v/106');++ assert.strictEqual(r.status, 200, 'it still plays');++ assert.ok(r.text.includes('<meta name="robots" content="noindex">'));++ assert.ok(r.text.includes('content="Stream recording by @streamer on OpenVibe.Live: 20 seconds, Oct 12, 2026.'));++ const map = (await getVideo('/sitemap.xml')).text;++ assert.ok(!map.includes('/v/110<'), 'the 10-second feed item with no text is left out');++ assert.ok(map.includes('/v/109<'), 'a short one with a description stays');++ });++++ await check('the sitemap carries a video entry for each listed recording and clip', async () => {++ const map = (await getVideo('/sitemap.xml')).text;++ assert.ok(map.includes('xmlns:video="http://www.google.com/schemas/sitemap-video/1.1"'));++ assert.ok(map.includes('<loc>https://openvibe.video/v/101</loc><lastmod>2026-10-12</lastmod><video:video><video:thumbnail_loc>https://openvibe.media/t/101.jpg</video:thumbnail_loc>'++ + '<video:title>A public recording</video:title><video:description>A public stream</video:description>'++ + '<video:content_loc>https://openvibe.media/v/101?raw=1</video:content_loc><video:duration>45</video:duration><video:view_count>100</video:view_count>'++ + '<video:publication_date>2026-10-12T10:00:00.000Z</video:publication_date><video:uploader info="https://openvibe.video/@streamer">streamer</video:uploader></video:video></url>'), map);++ assert.ok(map.includes('<loc>https://openvibe.video/c/201</loc><lastmod>2026-10-12</lastmod></url>'), 'a clip without a thumbnail has no video entry');++ });++await check('robots.txt and llms.txt answer on the video host', async () => {const robots = await getVideo('/robots.txt');assert.strictEqual(robots.status, 200);@@@ -413,7 +446,7 @@ function countOccurrences(haystack, needle) {assert.strictEqual(r.status, 200);assert.ok(r.text.includes('src="https://openvibe.media/v/105"'), 'the file is the <video> source');assert.ok(r.text.includes('data-ovv-hls="https://openvibe.media/o/med_105/master.m3u8"'));-- assert.ok(r.text.includes('"contentUrl":"https://openvibe.media/v/105"'), 'contentUrl is the file');++ assert.ok(r.text.includes('"contentUrl":"https://openvibe.media/v/105?raw=1"'), 'contentUrl is the file');});await check('the VOD page lists the chat replay with offsets, escaping and no private fields', async () => {
@@ -4,6 +4,12 @@ What changed in OpenVibe.MediaHub, newest first. Each site also publishes its pa
@@ -4,6 +4,12 @@ What changed in OpenVibe.MediaHub, newest first. Each site also publishes its pa
## Unreleased
## Unreleased
+- openvibe.video's search basics (2026-10-20). A watch page nobody wrote about gets a description of its own (who,
+ how long, when, its first words) instead of the site tagline. A recording under two minutes with nothing written
+ about it is noindex and out of the sitemap: 118 of 785 were under a minute, mostly test streams. The sitemap lists
+ every recording and clip (it stopped at 150 of each) with a video entry each (openvibe-shared 4.5.0) and is kept
+ ten minutes. Structured data and the sitemap name Media's file with `?raw=1`, since Media answers crawlers a page
+ otherwise.
- MediaHub reaches OpenVibe.Media with its Network service token (audience `openvibe.media`, grants
- MediaHub reaches OpenVibe.Media with its Network service token (audience `openvibe.media`, grants
`media.object.upload` and `media.object.read` on namespace `media-hub`). `MEDIAHUB_MEDIA_APP_KEY` is gone
`media.object.upload` and `media.object.read` on namespace `media-hub`). `MEDIAHUB_MEDIA_APP_KEY` is gone
(compatibility register C-71).
(compatibility register C-71).
@@ -523,5 +523,5 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
@@ -523,5 +523,5 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
<!-- versions:start -->
<!-- versions:start -->
- openvibe-contracts: v0.200.0
- openvibe-contracts: v0.200.0
- openvibe-sdk: v0.50.1
- openvibe-sdk: v0.50.1
-- openvibe-shared: v4.4.0
+- openvibe-shared: v4.5.0
<!-- versions:end -->
<!-- versions:end -->
@@ -10,7 +10,7 @@
@@ -10,7 +10,7 @@
"packages": [
"packages": [
"openvibe-contracts v0.200.0",
"openvibe-contracts v0.200.0",
"openvibe-sdk v0.50.1",
"openvibe-sdk v0.50.1",
- "openvibe-shared v4.4.0"
+ "openvibe-shared v4.5.0"
],
],
"summary": "OpenVibe.MediaHub: openvibe.download as a private drive. Account-only uploads stream into OpenVibe.Media (a plain form up to 256 MB, resumable parts beyond). Files are private. Share links expire in 1 hour to 7 days, need a signed-in downloader and can be limited to named accounts (matched by subject). Reports suspend a share for staff. Downloads are always sandboxed attachments. Per-person quotas, and account export and deletion (ADR-033). openvibe.video serves public watch pages and playlists for Live's recordings and clips (no uploads); openvibe.pics is parked and redirects to openvibe.network.",
"summary": "OpenVibe.MediaHub: openvibe.download as a private drive. Account-only uploads stream into OpenVibe.Media (a plain form up to 256 MB, resumable parts beyond). Files are private. Share links expire in 1 hour to 7 days, need a signed-in downloader and can be limited to named accounts (matched by subject). Reports suspend a share for staff. Downloads are always sandboxed attachments. Per-person quotas, and account export and deletion (ADR-033). openvibe.video serves public watch pages and playlists for Live's recordings and clips (no uploads); openvibe.pics is parked and redirects to openvibe.network.",
"works": [
"works": [
@@ -45,7 +45,7 @@
@@ -45,7 +45,7 @@
],
],
"contracts": "openvibe-contracts v0.200.0",
"contracts": "openvibe-contracts v0.200.0",
"sdk": "openvibe-sdk v0.50.1",
"sdk": "openvibe-sdk v0.50.1",
- "shared": "openvibe-shared v4.4.0",
+ "shared": "openvibe-shared v4.5.0",
"deployment": "openvibe-ovh: unit openvibe-media-hub.service on 127.0.0.1:4990 (git checkout /opt/openvibe.download, env /etc/openvibe/media-hub.env, database ov_media_hub), deployed with `sudo ovhost deploy media-hub`; public at https://openvibe.download since 2026-10-08 and https://openvibe.video since 2026-10-12.",
"deployment": "openvibe-ovh: unit openvibe-media-hub.service on 127.0.0.1:4990 (git checkout /opt/openvibe.download, env /etc/openvibe/media-hub.env, database ov_media_hub), deployed with `sudo ovhost deploy media-hub`; public at https://openvibe.download since 2026-10-08 and https://openvibe.video since 2026-10-12.",
"updated": "2026-10-17"
"updated": "2026-10-17"
}
}
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.200.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.200.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.50.1",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.50.1",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.4.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.5.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
"sharp": "^0.33.5"
"sharp": "^0.33.5"
},
},
@@ -1379,9 +1379,9 @@
@@ -1379,9 +1379,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "4.4.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.4.0",
- "integrity": "sha512-zhC3wzRdI3HG+TzLEEXTzZquBoHJHzE/67kGgmrYCXmouqcugKA+RZZ3gFQXMsIx79IQNrbE/tlg0hqiyUCJmw==",
+ "version": "4.5.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.5.0",
+ "integrity": "sha512-HUEDETiz1l4zbjijr+yov7rQdCU1HV2VLT+JD2UZZeHcR5h9+bQpR7GXUixxeEwY3bznqCmW6l0c61NLEfYVLg==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -24,7 +24,7 @@
@@ -24,7 +24,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.200.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.200.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.50.1",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.50.1",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.4.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.5.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
"sharp": "^0.33.5"
"sharp": "^0.33.5"
},
},
@@ -36,7 +36,9 @@ const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
@@ -36,7 +36,9 @@ const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
const HOME_LIMIT = 10;
const HOME_LIMIT = 10;
const MIN_HOME_SECONDS = 15;
const MIN_HOME_SECONDS = 15;
const SITEMAP_LIMIT = 30; // Live's feed page size (its MAX_LIMIT)
const SITEMAP_LIMIT = 30; // Live's feed page size (its MAX_LIMIT)
-const SITEMAP_PAGES = 5; // per kind: up to 150 recordings and 150 clips
+const SITEMAP_PAGES = 50; // per kind: up to 1,500 recordings and 1,500 clips (785 and 156 public on 2026-10-20)
+const SITEMAP_TTL_MS = 10 * 60 * 1000;
+const THIN_SECONDS = 120;
const CHANNEL_CLIPS_LIMIT = 12;
const CHANNEL_CLIPS_LIMIT = 12;
const PER_PAGE = 24;
const PER_PAGE = 24;
@@ -77,6 +79,58 @@ function fileOf(item) {
@@ -77,6 +79,58 @@ function fileOf(item) {
return httpsUrl(item.file_url) || httpsUrl(item.playback_url);
return httpsUrl(item.file_url) || httpsUrl(item.playback_url);
}
}
+/**
+ * The file as a crawler should fetch it. openvibe.media's /v/<id> and /c/<id> answer search engines' bots with a page
+ * (link previews), Googlebot-Video included, so structured data and the video sitemap ask with ?raw=1: always the bytes.
+ */
+function crawlFile(url) {
+ return /^https:\/\/openvibe\.media\/[vc]\/\d+$/.test(url || '') ? `${url}?raw=1` : url;
+}
+
+/**
+ * A recording search engines should leave out: under two minutes with nothing written about it (no description, no AI
+ * overview, under three transcript lines). On 2026-10-20, 118 of 785 public recordings were under a minute, mostly test
+ * streams titled "<name>'s Channel": hundreds of near-empty pages that only dilute the site. The page still plays.
+ */
+function thinRecording(vod, transcriptLines = 0) {
+ return !vod.description && !vod.ai_overview && transcriptLines < 3 && (Number(vod.duration_seconds) || 0) < THIN_SECONDS;
+}
+
+/** 31 → "31 seconds", 754 → "13 minutes", 7500 → "2 hours 5 minutes". */
+function durationWords(s) {
+ const n = Math.max(0, Math.round(Number(s) || 0));
+ const plural = (v, unit) => `${v} ${unit}${v === 1 ? '' : 's'}`;
+ if (n < 60) return plural(n, 'second');
+ if (n < 3600) return plural(Math.round(n / 60), 'minute');
+ const h = Math.floor(n / 3600);
+ const m = Math.round((n % 3600) / 60);
+ return m ? `${plural(h, 'hour')} ${plural(m, 'minute')}` : plural(h, 'hour');
+}
+
+/**
+ * A watch page's meta description when nobody wrote one, so no two pages share the site's tagline: what it is, whose,
+ * how long, when, and (for a recording with a transcript) its first words, at most ~160 characters.
+ */
+function factualDescription(kind, item, firstWords = '') {
+ const at = (u) => (/^[A-Za-z0-9_]{1,32}$/.test(String(u || '')) ? `@${u}` : null);
+ const facts = [item.duration_seconds ? durationWords(item.duration_seconds) : null, dateLabel(item.created_at) || null, kind === 'vod' ? item.stream_category || null : null].filter(Boolean).join(', ');
+ let lead;
+ if (kind === 'vod') lead = `Stream recording${at(item.username) ? ` by ${at(item.username)}` : ''} on OpenVibe.Live`;
+ else {
+ const streamer = at(item.source_streamer_username);
+ const maker = at(item.username);
+ lead = `Clip${streamer ? ` of ${streamer}'s stream` : ''} on OpenVibe.Live${maker && maker !== streamer ? `, made by ${maker}` : ''}`;
+ }
+ const head = `${lead}${facts ? `: ${facts}` : ''}.`;
+ const words = String(firstWords || '').replace(/\s+/g, ' ').trim();
+ if (words && head.length < 120) {
+ const room = 156 - head.length - 12;
+ const quote = words.length > room ? `${words.slice(0, room).replace(/\s+\S*$/, '')}…` : words;
+ if (quote.length > 12) return `${head} It opens: “${quote}”`;
+ }
+ return kind === 'vod' ? `${head} Watch with AI captions and chapters on OpenVibe.Video.` : `${head} Watch it on OpenVibe.Video.`;
+}
+
/** The player's HLS source: Media's timeline playlist, for a finished item that has one, on the playback file's origin. */
/** The player's HLS source: Media's timeline playlist, for a finished item that has one, on the playback file's origin. */
function hlsAttrs(item) {
function hlsAttrs(item) {
const hls = !item.is_recording && httpsUrl(item.hls_url);
const hls = !item.is_recording && httpsUrl(item.hls_url);
@@ -142,7 +196,7 @@ function videoLd({ base, path, item, title, description, chapterList, partOf })
@@ -142,7 +196,7 @@ function videoLd({ base, path, item, title, description, chapterList, partOf })
...seo.jsonLd.video({
...seo.jsonLd.video({
name: title, url: pageUrl, description: String(description || title).slice(0, 500),
name: title, url: pageUrl, description: String(description || title).slice(0, 500),
thumbnailUrl: thumb ? [thumb] : undefined, uploadDate: isoTime(item.created_at),
thumbnailUrl: thumb ? [thumb] : undefined, uploadDate: isoTime(item.created_at),
- duration: isoDuration(item.duration_seconds), contentUrl: fileOf(item) || undefined, embedUrl: pageUrl,
+ duration: isoDuration(item.duration_seconds), contentUrl: crawlFile(fileOf(item)) || undefined, embedUrl: pageUrl,
}),
}),
...(item.view_count != null ? { interactionStatistic: { '@type': 'InteractionCounter', interactionType: 'https://schema.org/WatchAction', userInteractionCount: Number(item.view_count) || 0 } } : {}),
...(item.view_count != null ? { interactionStatistic: { '@type': 'InteractionCounter', interactionType: 'https://schema.org/WatchAction', userInteractionCount: Number(item.view_count) || 0 } } : {}),
...(channel ? { author: { '@type': 'Person', name: channel, ...(item.username ? { url: `${base}/@${item.username}` } : {}) } } : {}),
...(channel ? { author: { '@type': 'Person', name: channel, ...(item.username ? { url: `${base}/@${item.username}` } : {}) } } : {}),
@@ -803,14 +857,16 @@ ${uploaded.length ? html`<section class="ovv-section" aria-labelledby="h-uploads
@@ -803,14 +857,16 @@ ${uploaded.length ? html`<section class="ovv-section" aria-labelledby="h-uploads
const viewAttr = html` data-ovv-view="${livePublic}/api/views/vod/${id}"`;
const viewAttr = html` data-ovv-view="${livePublic}/api/views/vod/${id}"`;
const hlsAttr = hlsAttrs(vod);
const hlsAttr = hlsAttrs(vod);
+ const thin = thinRecording(vod, segs.length);
+ const about = vod.description || vod.ai_overview || factualDescription('vod', vod, segs.slice(0, 6).map((s) => s.text).join(' '));
return page(req, res, {
return page(req, res, {
- index: !unlisted && !ownOnly,
- noindex: unlisted || ownOnly,
+ index: !unlisted && !ownOnly && !thin,
+ noindex: unlisted || ownOnly || thin,
...(ownOnly ? { cache: cache.htmlHeaders({ private: true }) } : {}),
...(ownOnly ? { cache: cache.htmlHeaders({ private: true }) } : {}),
title: watchTitle(vod.title || 'Stream recording', vod.username, vod.created_at),
title: watchTitle(vod.title || 'Stream recording', vod.username, vod.created_at),
- description: vod.description || vod.ai_overview || '',
+ description: about,
...(ownOnly || vod.is_recording ? {} : {
...(ownOnly || vod.is_recording ? {} : {
- jsonLd: videoLd({ base, path: `/v/${id}`, item: vod, title: vod.title || 'Stream recording', description: vod.description || vod.ai_overview, chapterList: mems }),
+ jsonLd: videoLd({ base, path: `/v/${id}`, item: vod, title: vod.title || 'Stream recording', description: about, chapterList: mems }),
image: httpsUrl(vod.thumbnail_url) || undefined, ogType: 'video.other',
image: httpsUrl(vod.thumbnail_url) || undefined, ogType: 'video.other',
}),
}),
scripts: ['js/video.js'],
scripts: ['js/video.js'],
@@ -886,9 +942,9 @@ ${clips.length ? html`<section class="ovv-panel" aria-labelledby="h-clips"><h2 i
@@ -886,9 +942,9 @@ ${clips.length ? html`<section class="ovv-panel" aria-labelledby="h-clips"><h2 i
noindex: unlisted || ownOnly || aiClip,
noindex: unlisted || ownOnly || aiClip,
...(ownOnly ? { cache: cache.htmlHeaders({ private: true }) } : {}),
...(ownOnly ? { cache: cache.htmlHeaders({ private: true }) } : {}),
title: watchTitle(clip.title || 'Clip', clip.source_streamer_username, clip.created_at),
title: watchTitle(clip.title || 'Clip', clip.source_streamer_username, clip.created_at),
- description: clip.ai_overview || '',
+ description: clip.ai_overview || factualDescription('clip', clip),
...(ownOnly || aiClip ? {} : {
...(ownOnly || aiClip ? {} : {
- jsonLd: videoLd({ base, path: `/c/${id}`, item: clip, title: clip.title || 'Clip', description: clip.ai_overview || attribution, partOf: clip.vod_id ? `/v/${clip.vod_id}` : null }),
+ jsonLd: videoLd({ base, path: `/c/${id}`, item: clip, title: clip.title || 'Clip', description: clip.ai_overview || factualDescription('clip', clip) || attribution, partOf: clip.vod_id ? `/v/${clip.vod_id}` : null }),
image: httpsUrl(clip.thumbnail_url) || undefined, ogType: 'video.other',
image: httpsUrl(clip.thumbnail_url) || undefined, ogType: 'video.other',
}),
}),
scripts: ['js/video.js'],
scripts: ['js/video.js'],
@@ -1207,11 +1263,37 @@ ${isOwner ? html`<p class="ovv-fine">You can reorder items with the up and down
@@ -1207,11 +1263,37 @@ ${isOwner ? html`<p class="ovv-fine">You can reorder items with the up and down
return items;
return items;
}
}
+ /**
+ * A feed item's <video:video> (openvibe-shared seo.sitemapXml): the thumbnail, title, a description (the item's own
+ * text or the facts), the file (Media's bytes) and who made it. Without a thumbnail there is none.
+ */
+ function sitemapVideo(it) {
+ const thumb = httpsUrl(it.thumbnail_url);
+ if (!thumb) return null;
+ const kind = it.kind === 'clip' ? 'clip' : 'vod';
+ const who = it.channel && it.channel.username;
+ const item = { duration_seconds: it.duration_seconds, created_at: it.created_at, username: kind === 'vod' ? who : (it.by && it.by.username) || who, source_streamer_username: who };
+ const file = kind === 'clip' ? httpsUrl(it.preview_url) : `https://openvibe.media/v/${Number(it.id)}`;
+ return {
+ thumbnail_loc: thumb, title: it.title || (kind === 'clip' ? 'Clip' : 'Stream recording'),
+ description: it.excerpt || factualDescription(kind, item),
+ content_loc: crawlFile(file) || undefined, duration: it.duration_seconds, view_count: it.views,
+ publication_date: isoTime(it.created_at),
+ ...(who && /^[A-Za-z0-9_]{1,32}$/.test(who) ? { uploader: who, uploader_info: `${base}/@${who}` } : {}),
+ };
+ }
+
+ let sitemapCache = null; // { at, xml }: Live's feed is walked page by page, so the answer is kept ten minutes
r.get('/sitemap.xml', async (req, res) => {
r.get('/sitemap.xml', async (req, res) => {
+ if (sitemapCache && Date.now() - sitemapCache.at < SITEMAP_TTL_MS) {
+ return res.type('application/xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(sitemapCache.xml);
+ }
const [vods, clips, uploaded] = await Promise.all([feedItems('vods'), feedItems('clips'), ctx.videoUploads.latest(500)]);
const [vods, clips, uploaded] = await Promise.all([feedItems('vods'), feedItems('clips'), ctx.videoUploads.latest(500)]);
const day = (iso) => { const d = new Date(String(iso || '')); return Number.isNaN(d.getTime()) ? null : d.toISOString().slice(0, 10); };
const day = (iso) => { const d = new Date(String(iso || '')); return Number.isNaN(d.getTime()) ? null : d.toISOString().slice(0, 10); };
const newest = (list) => list.map(day).filter(Boolean).sort().pop() || null;
const newest = (list) => list.map(day).filter(Boolean).sort().pop() || null;
- const items = [...vods, ...clips].filter((it) => it && Number.isSafeInteger(Number(it.id)));
+ // A short recording with nothing written about it is noindex on its page (thinRecording), so it is left out here.
+ const listed = vods.filter((it) => it && (it.excerpt || (Number(it.duration_seconds) || 0) >= THIN_SECONDS));
+ const items = [...listed, ...clips].filter((it) => it && Number.isSafeInteger(Number(it.id)));
const channels = new Map(); // username → its newest item's day
const channels = new Map(); // username → its newest item's day
for (const it of items) {
for (const it of items) {
const u = it.channel && it.channel.username;
const u = it.channel && it.channel.username;
@@ -1228,11 +1310,15 @@ ${isOwner ? html`<p class="ovv-fine">You can reorder items with the up and down
@@ -1228,11 +1310,15 @@ ${isOwner ? html`<p class="ovv-fine">You can reorder items with the up and down
const urls = [
const urls = [
{ loc: `${base}/`, lastmod: newest(items.map((it) => it.created_at)) },
{ loc: `${base}/`, lastmod: newest(items.map((it) => it.created_at)) },
{ loc: `${base}/updates` },
{ loc: `${base}/updates` },
- ...items.map((it) => ({ loc: `${base}${it.kind === 'clip' ? '/c/' : '/v/'}${Number(it.id)}`, lastmod: day(it.created_at) })),
+ ...items.map((it) => {
+ const video = sitemapVideo(it);
+ return { loc: `${base}${it.kind === 'clip' ? '/c/' : '/v/'}${Number(it.id)}`, lastmod: day(it.created_at), ...(video ? { videos: [video] } : {}) };
+ }),
...uploaded.map((x) => ({ loc: `${base}/u/${x.id}`, lastmod: day(x.updated_at) })),
...uploaded.map((x) => ({ loc: `${base}/u/${x.id}`, lastmod: day(x.updated_at) })),
...[...channels].map(([u, d]) => ({ loc: `${base}/@${u}`, lastmod: d })),
...[...channels].map(([u, d]) => ({ loc: `${base}/@${u}`, lastmod: d })),
- ].map((u) => (u.lastmod ? u : { loc: u.loc }));
- return res.type('application/xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(seo.sitemapXml(urls));
+ ].map((u) => (u.lastmod ? u : { ...u, lastmod: undefined }));
+ sitemapCache = { at: Date.now(), xml: seo.sitemapXml(urls) };
+ return res.type('application/xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(sitemapCache.xml);
});
});
r.get('/robots.txt', (_req, res) => {
r.get('/robots.txt', (_req, res) => {
@@ -88,6 +88,8 @@ function startLive() {
@@ -88,6 +88,8 @@ function startLive() {
ai_overview: '', ai_transcript_json: null,
ai_overview: '', ai_transcript_json: null,
stream_id: 1002, stream_started_at: '2026-10-12 09:30:00', stream_ended_at: '2026-10-12 09:35:00',
stream_id: 1002, stream_started_at: '2026-10-12 09:30:00', stream_ended_at: '2026-10-12 09:35:00',
};
};
+ // Twenty seconds with nothing written about it: a test stream, left out of search.
+ const thinVod = { ...secondVod, id: 106, title: "Streamer's Channel", duration_seconds: 20, stream_category: null };
// Live's answer since T4 step 5: playback_url is the playlist, file_url the file.
// Live's answer since T4 step 5: playback_url is the playlist, file_url the file.
const hlsFirstVod = { ...secondVod, id: 105, title: 'Playlist first', playback_url: 'https://openvibe.media/o/med_105/master.m3u8',
const hlsFirstVod = { ...secondVod, id: 105, title: 'Playlist first', playback_url: 'https://openvibe.media/o/med_105/master.m3u8',
hls_url: 'https://openvibe.media/o/med_105/master.m3u8', file_url: 'https://openvibe.media/v/105' };
hls_url: 'https://openvibe.media/o/med_105/master.m3u8', file_url: 'https://openvibe.media/v/105' };
@@ -104,7 +106,7 @@ function startLive() {
@@ -104,7 +106,7 @@ function startLive() {
const p = url.pathname;
const p = url.pathname;
if (p === '/api/content/feed') {
if (p === '/api/content/feed') {
const type = url.searchParams.get('type');
const type = url.searchParams.get('type');
- if (type === 'vods') return json(200, { items: [vodFeedItem, { ...vodFeedItem, key: 'vod:109', id: 109, title: 'A two-second blip', duration_seconds: 2 }], next: null });
+ if (type === 'vods') return json(200, { items: [vodFeedItem, { ...vodFeedItem, key: 'vod:109', id: 109, title: 'A two-second blip', duration_seconds: 2 }, { ...vodFeedItem, key: 'vod:110', id: 110, title: "Streamer's Channel", duration_seconds: 10, excerpt: null }], next: null });
if (type === 'clips') return json(200, { items: [clipFeedItem], next: null });
if (type === 'clips') return json(200, { items: [clipFeedItem], next: null });
return json(200, { items: [], next: null });
return json(200, { items: [], next: null });
}
}
@@ -130,6 +132,7 @@ function startLive() {
@@ -130,6 +132,7 @@ function startLive() {
if (vodDetail[1] === '101') return json(200, { vod: publicVod, clips: [clipListItem] });
if (vodDetail[1] === '101') return json(200, { vod: publicVod, clips: [clipListItem] });
if (vodDetail[1] === '102') return json(200, { vod: unlistedVod, clips: [] });
if (vodDetail[1] === '102') return json(200, { vod: unlistedVod, clips: [] });
if (vodDetail[1] === '104') return json(200, { vod: secondVod, clips: [] });
if (vodDetail[1] === '104') return json(200, { vod: secondVod, clips: [] });
+ if (vodDetail[1] === '106') return json(200, { vod: thinVod, clips: [] });
if (vodDetail[1] === '105') return json(200, { vod: hlsFirstVod, clips: [] });
if (vodDetail[1] === '105') return json(200, { vod: hlsFirstVod, clips: [] });
return json(404, { error: 'not found' });
return json(404, { error: 'not found' });
}
}
@@ -271,7 +274,7 @@ function countOccurrences(haystack, needle) {
@@ -271,7 +274,7 @@ function countOccurrences(haystack, needle) {
assert.strictEqual(ld.uploadDate, '2026-10-12T10:00:00.000Z');
assert.strictEqual(ld.uploadDate, '2026-10-12T10:00:00.000Z');
assert.strictEqual(ld.duration, 'PT45S');
assert.strictEqual(ld.duration, 'PT45S');
assert.deepStrictEqual(ld.thumbnailUrl, ['https://openvibe.media/t/101.jpg']);
assert.deepStrictEqual(ld.thumbnailUrl, ['https://openvibe.media/t/101.jpg']);
- assert.strictEqual(ld.contentUrl, 'https://openvibe.media/v/101');
+ assert.strictEqual(ld.contentUrl, 'https://openvibe.media/v/101?raw=1', 'Media answers bots a page without ?raw=1');
assert.deepStrictEqual(ld.author, { '@type': 'Person', name: 'Streamer', url: 'https://openvibe.video/@streamer' });
assert.deepStrictEqual(ld.author, { '@type': 'Person', name: 'Streamer', url: 'https://openvibe.video/@streamer' });
assert.ok(Array.isArray(ld.hasPart) && ld.hasPart.length >= 1 && ld.hasPart.every((c) => c['@type'] === 'Clip' && Number.isInteger(c.startOffset) && c.url.startsWith('https://openvibe.video/v/101?t=')), 'chapters as key moments');
assert.ok(Array.isArray(ld.hasPart) && ld.hasPart.length >= 1 && ld.hasPart.every((c) => c['@type'] === 'Clip' && Number.isInteger(c.startOffset) && c.url.startsWith('https://openvibe.video/v/101?t=')), 'chapters as key moments');
assert.match(r.text, /<meta property="og:image" content="https:\/\/openvibe\.media\/t\/101\.jpg"/, 'the thumbnail is the share image');
assert.match(r.text, /<meta property="og:image" content="https:\/\/openvibe\.media\/t\/101\.jpg"/, 'the thumbnail is the share image');
@@ -381,6 +384,36 @@ function countOccurrences(haystack, needle) {
@@ -381,6 +384,36 @@ function countOccurrences(haystack, needle) {
assert.ok(!r.text.includes('/v/102'));
assert.ok(!r.text.includes('/v/102'));
});
});
+ await check('a recording nobody wrote about gets a description of its own, not the site tagline', async () => {
+ const r = await getVideo('/v/104');
+ assert.strictEqual(r.status, 200);
+ const want = 'Stream recording by @streamer on OpenVibe.Live: 5 minutes, Oct 12, 2026. Watch with AI captions and chapters on OpenVibe.Video.';
+ assert.ok(r.text.includes(`<meta name="description" content="${want}">`), r.text.slice(0, 1500));
+ assert.ok(!r.text.includes('content="noindex'), 'five minutes is indexable');
+ const clip = await getVideo('/c/201');
+ assert.ok(clip.text.includes('<meta name="description" content="A clip overview">'), 'written text still wins');
+ });
+
+ await check('a short recording with nothing written about it is noindex and out of the sitemap', async () => {
+ const r = await getVideo('/v/106');
+ assert.strictEqual(r.status, 200, 'it still plays');
+ assert.ok(r.text.includes('<meta name="robots" content="noindex">'));
+ assert.ok(r.text.includes('content="Stream recording by @streamer on OpenVibe.Live: 20 seconds, Oct 12, 2026.'));
+ const map = (await getVideo('/sitemap.xml')).text;
+ assert.ok(!map.includes('/v/110<'), 'the 10-second feed item with no text is left out');
+ assert.ok(map.includes('/v/109<'), 'a short one with a description stays');
+ });
+
+ await check('the sitemap carries a video entry for each listed recording and clip', async () => {
+ const map = (await getVideo('/sitemap.xml')).text;
+ assert.ok(map.includes('xmlns:video="http://www.google.com/schemas/sitemap-video/1.1"'));
+ assert.ok(map.includes('<loc>https://openvibe.video/v/101</loc><lastmod>2026-10-12</lastmod><video:video><video:thumbnail_loc>https://openvibe.media/t/101.jpg</video:thumbnail_loc>'
+ + '<video:title>A public recording</video:title><video:description>A public stream</video:description>'
+ + '<video:content_loc>https://openvibe.media/v/101?raw=1</video:content_loc><video:duration>45</video:duration><video:view_count>100</video:view_count>'
+ + '<video:publication_date>2026-10-12T10:00:00.000Z</video:publication_date><video:uploader info="https://openvibe.video/@streamer">streamer</video:uploader></video:video></url>'), map);
+ assert.ok(map.includes('<loc>https://openvibe.video/c/201</loc><lastmod>2026-10-12</lastmod></url>'), 'a clip without a thumbnail has no video entry');
+ });
+
await check('robots.txt and llms.txt answer on the video host', async () => {
await check('robots.txt and llms.txt answer on the video host', async () => {
const robots = await getVideo('/robots.txt');
const robots = await getVideo('/robots.txt');
assert.strictEqual(robots.status, 200);
assert.strictEqual(robots.status, 200);
@@ -413,7 +446,7 @@ function countOccurrences(haystack, needle) {
@@ -413,7 +446,7 @@ function countOccurrences(haystack, needle) {
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
assert.ok(r.text.includes('src="https://openvibe.media/v/105"'), 'the file is the <video> source');
assert.ok(r.text.includes('src="https://openvibe.media/v/105"'), 'the file is the <video> source');
assert.ok(r.text.includes('data-ovv-hls="https://openvibe.media/o/med_105/master.m3u8"'));
assert.ok(r.text.includes('data-ovv-hls="https://openvibe.media/o/med_105/master.m3u8"'));
- assert.ok(r.text.includes('"contentUrl":"https://openvibe.media/v/105"'), 'contentUrl is the file');
+ assert.ok(r.text.includes('"contentUrl":"https://openvibe.media/v/105?raw=1"'), 'contentUrl is the file');
});
});
await check('the VOD page lists the chat replay with offsets, escaping and no private fields', async () => {
await check('the VOD page lists the chat replay with offsets, escaping and no private fields', async () => {