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)

OP OpenVibers committed 24 days ago · f0afc8344f · 7 files, +148 −23

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 8868f53..3ea1bd3 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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).
ddiff --git a/README.md b/README.md
iindex 6d62a8c..d44805d 100644
---- a/README.md
++++ b/README.md
@@@ -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 -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 102de4b..2c3857a 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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"
}
ddiff --git a/package-lock.json b/package-lock.json
iindex a6bb259..ad0197b 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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"
ddiff --git a/package.json b/package.json
iindex 3053d98..a223b7e 100644
---- a/package.json
++++ b/package.json
@@@ -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"
},
ddiff --git a/server/video/pages.js b/server/video/pages.js
iindex 596969b..2c5e46e 100644
---- a/server/video/pages.js
++++ b/server/video/pages.js
@@@ -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-uploads
const 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 i
noindex: 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 down
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) => {
++ 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 day
for (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 down
const 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) => {
ddiff --git a/test/video-pages.test.js b/test/video-pages.test.js
iindex 345bb32..393c9a8 100644
---- a/test/video-pages.test.js
++++ b/test/video-pages.test.js
@@@ -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 () => {