openvibers/openvibe.mediahub Commits
Video: VideoObject structured data with chapters as key moments, clip isPartOf, playlist ItemList; AI clips noindex (#37)
Changes
@@@ -127,6 +127,13 @@ Live's `POST /api/views/:kind/:id`, so Live counts the view for the viewer's owncome from the loopback address). The CSP allows Media's hosts (openvibe.media, its shield edge.openvibe.media andthe presigned R2/B2 hosts it may redirect a file to) and Live's origin for the beacon.++**Structured data.** A public recording's page carries a schema.org `VideoObject` (name, description, thumbnail, upload++time, ISO 8601 duration, the file, the view count, the channel as author) with its chapters as `Clip` parts (Google's++key moments, each a `?t=` link); a clip's page has its own `VideoObject`, `isPartOf` its recording; a public++playlist's page an `ItemList`. The thumbnail is the page's share image (`og:image`, `og:type` `video.other`). AI clips++stay out of search as on Live (noindex, no structured data), and so do a recording in progress and an owner's private++view.++**Owner tools.** A recording's channel, and a clip's maker or channel, see a "Manage" panel on the watch page: title,description (recordings), visibility and delete. Each change is a same-origin web form that openvibe.video sends toOpenVibe.Live's own API as the signed-in person (their Network token as a Bearer, refreshed once if it expired), so@@@ -32,7 +32,8 @@ function assetVersion(rel) {}const asset = (rel) => `/${rel}?v=${assetVersion(rel)}`;--/** o: title, description, body (html), config, path, index, noindex, viewer, private, crumbs [{label, href}], scripts [rel] */++/** o: title, description, body (html), config, path, index, noindex, viewer, private, crumbs [{label, href}], scripts [rel],++ * jsonLd (structured data for the page), image (its share image, an https URL), ogType */function renderPage(o) {const title = o.title || SITE_NAME;const viewer = o.viewer || { kind: 'anonymous' };@@@ -67,6 +68,9 @@ ${render(o.body)}description: o.description || `${SITE_NAME} — ${TAGLINE}`,canonical,robots: o.noindex ? 'noindex' : (o.index ? 'index, follow' : 'noindex, nofollow'),++ ...(o.jsonLd ? { jsonLd: o.jsonLd } : {}),++ ...(o.image ? { image: o.image, imageAlt: title } : {}),++ ...(o.ogType ? { type: o.ogType } : {}),home: '/',navLinks: [],navbar: {@@@ -63,6 +63,47 @@ function durationLabel(s) {return h ? `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}` : `${m}:${String(sec).padStart(2, '0')}`;}++/** Seconds → an ISO 8601 duration (PT1H2M3S), or undefined. */++function isoDuration(seconds) {++ const n = Math.floor(Number(seconds));++ if (!Number.isFinite(n) || n <= 0) return undefined;++ const h = Math.floor(n / 3600);++ const m = Math.floor((n % 3600) / 60);++ const s = n % 60;++ return `PT${h ? `${h}H` : ''}${m ? `${m}M` : ''}${s || (!h && !m) ? `${s}S` : ''}`;++}++++const isoTime = (v) => { const t = Date.parse(String(v || '')); return Number.isFinite(t) ? new Date(t).toISOString() : undefined; };++++/**++ * schema.org VideoObject for a public watch page (Google's video results): the page, the thumbnail, the upload time,++ * the duration, the file, the view count, the channel and, for a recording, its chapters as Clip parts (key moments).++ */++function videoLd({ base, path, item, title, description, chapterList, partOf }) {++ const pageUrl = `${base}${path}`;++ const thumb = httpsUrl(item.thumbnail_url);++ const channel = item.display_name || item.username;++ const parts = (chapterList || []).map((m, i, all) => ({++ '@type': 'Clip',++ name: String(m.description || `Chapter ${i + 1}`).slice(0, 110),++ startOffset: m.offset_seconds,++ ...(all[i + 1] ? { endOffset: all[i + 1].offset_seconds } : (Number(item.duration_seconds) > m.offset_seconds ? { endOffset: Math.floor(Number(item.duration_seconds)) } : {})),++ url: `${pageUrl}?t=${m.offset_seconds}`,++ }));++ return {++ ...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: httpsUrl(item.playback_url) || 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}` } : {}) } } : {}),++ publisher: { '@type': 'Organization', name: 'OpenVibe', url: 'https://openvibe.network' },++ ...(parts.length ? { hasPart: parts } : {}),++ ...(partOf ? { isPartOf: { '@type': 'VideoObject', url: `${base}${partOf}` } } : {}),++ };++}++function dateLabel(iso) {if (!iso) return '';const d = new Date(iso);@@@ -630,6 +671,10 @@ ${kind === 'vod' ? html`<p class="ovv-fine">New clips are still cut on <a href="...(ownOnly ? { cache: cache.htmlHeaders({ private: true }) } : {}),title: vod.title || 'Stream recording',description: vod.description || vod.ai_overview || '',++ ...(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 }),++ image: httpsUrl(vod.thumbnail_url) || undefined, ogType: 'video.other',++ }),scripts: ['js/video.js'],body: html`<div class="ovv-watch"><div class="ovv-main">@@@ -680,6 +725,8 @@ ${clips.length ? html`<section class="ovv-panel" aria-labelledby="h-clips"><h2 i} catch { threadUrl = null; }const unlisted = clip.visibility === 'unlisted';++ // AI clips stay out of search, as on Live: noindex and no structured data.++ const aiClip = Boolean(clip.auto_generated);const fullRecording = clip.vod_id ? `/v/${clip.vod_id}${clip.start_time != null ? `?t=${clip.start_time}` : ''}` : null;const sourceName = clip.source_streamer_display_name || clip.source_streamer_username || 'the streamer';const madeBy = clip.display_name || clip.username || 'an OpenVibe member';@@@ -692,11 +739,15 @@ ${clips.length ? html`<section class="ovv-panel" aria-labelledby="h-clips"><h2 iconst hlsAttr = hlsAttrs(clip);return page(req, res, {-- index: !unlisted && !ownOnly,-- noindex: unlisted || ownOnly,++ index: !unlisted && !ownOnly && !aiClip,++ noindex: unlisted || ownOnly || aiClip,...(ownOnly ? { cache: cache.htmlHeaders({ private: true }) } : {}),title: clip.title || 'Clip',description: clip.ai_overview || '',++ ...(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 }),++ image: httpsUrl(clip.thumbnail_url) || undefined, ogType: 'video.other',++ }),scripts: ['js/video.js'],body: html`<div class="ovv-watch ovv-watch--clip"><div class="ovv-main">@@@ -921,6 +972,7 @@ ${rows.length ? html`<ul class="ovv-playlist-list">${rows.map(playlistRow)}</ul>const items = await playlists.items(pl.id);const rows = [];++ const listLd = [];for (const it of items) {const itemId = Number(it.item_id);let info = null;@@@ -933,6 +985,7 @@ ${rows.length ? html`<ul class="ovv-playlist-list">${rows.map(playlistRow)}</ul>continue;}const href = it.kind === 'vod' ? `/v/${itemId}` : `/c/${itemId}`;++ listLd.push({ '@type': 'ListItem', position: listLd.length + 1, url: `${base}${href}`, name: String(info.title || (it.kind === 'vod' ? 'Stream recording' : 'Clip')).slice(0, 110) });const thumb = httpsUrl(info.thumbnail_url);const title = info.title || (it.kind === 'clip' ? 'Clip' : 'Stream recording');const channel = info.username || info.display_name || null;@@@ -950,6 +1003,7 @@ ${isOwner ? itemControls(pl.id, it) : ''}return page(req, res, {index: pl.visibility === 'public',noindex: pl.visibility === 'unlisted',++ ...(pl.visibility === 'public' && listLd.length ? { jsonLd: { '@context': 'https://schema.org', '@type': 'ItemList', name: pl.title, numberOfItems: listLd.length, itemListElement: listLd } } : {}),title: pl.title,description: pl.description || undefined,crumbs: [{ label: 'Home', href: '/' }, { label: 'Playlists', href: '/playlists' }, { label: pl.title }],@@@ -237,6 +237,17 @@ function countOccurrences(haystack, needle) {assert.ok(r.text.includes('playsinline'));assert.ok(r.text.includes('data-ovv-video'));assert.ok(r.text.includes('data-ovv-view="https://openvibe.live/api/views/vod/101"'), 'playback counts a view on Live');++ const ld = [...r.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1])).find((x) => x['@type'] === 'VideoObject');++ assert.ok(ld, 'a VideoObject');++ assert.strictEqual(ld.url, 'https://openvibe.video/v/101');++ assert.strictEqual(ld.name, 'A public recording');++ 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.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');assert.ok(r.text.includes('data-ovv-hls="https://openvibe.media/o/med_101/master.m3u8"'), 'the timeline playlist');assert.ok(/data-ovv-hls-lib="\/vendor\/hls\.light\.min\.js\?v=[0-9a-f]{10}"/.test(r.text), 'hls.js, self-hosted and content-hashed');assert.ok(/media-src[^;]*blob:/.test(String(r.headers['content-security-policy'])), 'MSE plays from blob:');@@@ -257,6 +268,10 @@ function countOccurrences(haystack, needle) {const r = await getVideo('/c/201');assert.strictEqual(r.status, 200);assert.ok(r.text.includes('data-ovv-view="https://openvibe.live/api/views/clip/201"'), 'playback counts a clip view on Live');++ const cld = [...r.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1])).find((x) => x['@type'] === 'VideoObject');++ assert.ok(cld && cld.url === 'https://openvibe.video/c/201', 'a clip VideoObject');++ assert.deepStrictEqual(cld.isPartOf, { '@type': 'VideoObject', url: 'https://openvibe.video/v/101' }, 'part of its recording');++ assert.strictEqual(cld.duration, 'PT10S');assert.ok(r.text.includes('src="https://openvibe.media/c/201"'), 'the clip playback url');assert.ok(r.text.includes('Made by Clipper'), 'attributed to the person who made it');assert.ok(r.text.includes('Watch in the full recording'));
@@ -127,6 +127,13 @@ Live's `POST /api/views/:kind/:id`, so Live counts the view for the viewer's own
@@ -127,6 +127,13 @@ Live's `POST /api/views/:kind/:id`, so Live counts the view for the viewer's own
come from the loopback address). The CSP allows Media's hosts (openvibe.media, its shield edge.openvibe.media and
come from the loopback address). The CSP allows Media's hosts (openvibe.media, its shield edge.openvibe.media and
the presigned R2/B2 hosts it may redirect a file to) and Live's origin for the beacon.
the presigned R2/B2 hosts it may redirect a file to) and Live's origin for the beacon.
+**Structured data.** A public recording's page carries a schema.org `VideoObject` (name, description, thumbnail, upload
+time, ISO 8601 duration, the file, the view count, the channel as author) with its chapters as `Clip` parts (Google's
+key moments, each a `?t=` link); a clip's page has its own `VideoObject`, `isPartOf` its recording; a public
+playlist's page an `ItemList`. The thumbnail is the page's share image (`og:image`, `og:type` `video.other`). AI clips
+stay out of search as on Live (noindex, no structured data), and so do a recording in progress and an owner's private
+view.
+
**Owner tools.** A recording's channel, and a clip's maker or channel, see a "Manage" panel on the watch page: title,
**Owner tools.** A recording's channel, and a clip's maker or channel, see a "Manage" panel on the watch page: title,
description (recordings), visibility and delete. Each change is a same-origin web form that openvibe.video sends to
description (recordings), visibility and delete. Each change is a same-origin web form that openvibe.video sends to
OpenVibe.Live's own API as the signed-in person (their Network token as a Bearer, refreshed once if it expired), so
OpenVibe.Live's own API as the signed-in person (their Network token as a Bearer, refreshed once if it expired), so
@@ -32,7 +32,8 @@ function assetVersion(rel) {
@@ -32,7 +32,8 @@ function assetVersion(rel) {
}
}
const asset = (rel) => `/${rel}?v=${assetVersion(rel)}`;
const asset = (rel) => `/${rel}?v=${assetVersion(rel)}`;
-/** o: title, description, body (html), config, path, index, noindex, viewer, private, crumbs [{label, href}], scripts [rel] */
+/** o: title, description, body (html), config, path, index, noindex, viewer, private, crumbs [{label, href}], scripts [rel],
+ * jsonLd (structured data for the page), image (its share image, an https URL), ogType */
function renderPage(o) {
function renderPage(o) {
const title = o.title || SITE_NAME;
const title = o.title || SITE_NAME;
const viewer = o.viewer || { kind: 'anonymous' };
const viewer = o.viewer || { kind: 'anonymous' };
@@ -67,6 +68,9 @@ ${render(o.body)}
@@ -67,6 +68,9 @@ ${render(o.body)}
description: o.description || `${SITE_NAME} — ${TAGLINE}`,
description: o.description || `${SITE_NAME} — ${TAGLINE}`,
canonical,
canonical,
robots: o.noindex ? 'noindex' : (o.index ? 'index, follow' : 'noindex, nofollow'),
robots: o.noindex ? 'noindex' : (o.index ? 'index, follow' : 'noindex, nofollow'),
+ ...(o.jsonLd ? { jsonLd: o.jsonLd } : {}),
+ ...(o.image ? { image: o.image, imageAlt: title } : {}),
+ ...(o.ogType ? { type: o.ogType } : {}),
home: '/',
home: '/',
navLinks: [],
navLinks: [],
navbar: {
navbar: {
@@ -63,6 +63,47 @@ function durationLabel(s) {
@@ -63,6 +63,47 @@ function durationLabel(s) {
return h ? `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}` : `${m}:${String(sec).padStart(2, '0')}`;
return h ? `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}` : `${m}:${String(sec).padStart(2, '0')}`;
}
}
+/** Seconds → an ISO 8601 duration (PT1H2M3S), or undefined. */
+function isoDuration(seconds) {
+ const n = Math.floor(Number(seconds));
+ if (!Number.isFinite(n) || n <= 0) return undefined;
+ const h = Math.floor(n / 3600);
+ const m = Math.floor((n % 3600) / 60);
+ const s = n % 60;
+ return `PT${h ? `${h}H` : ''}${m ? `${m}M` : ''}${s || (!h && !m) ? `${s}S` : ''}`;
+}
+
+const isoTime = (v) => { const t = Date.parse(String(v || '')); return Number.isFinite(t) ? new Date(t).toISOString() : undefined; };
+
+/**
+ * schema.org VideoObject for a public watch page (Google's video results): the page, the thumbnail, the upload time,
+ * the duration, the file, the view count, the channel and, for a recording, its chapters as Clip parts (key moments).
+ */
+function videoLd({ base, path, item, title, description, chapterList, partOf }) {
+ const pageUrl = `${base}${path}`;
+ const thumb = httpsUrl(item.thumbnail_url);
+ const channel = item.display_name || item.username;
+ const parts = (chapterList || []).map((m, i, all) => ({
+ '@type': 'Clip',
+ name: String(m.description || `Chapter ${i + 1}`).slice(0, 110),
+ startOffset: m.offset_seconds,
+ ...(all[i + 1] ? { endOffset: all[i + 1].offset_seconds } : (Number(item.duration_seconds) > m.offset_seconds ? { endOffset: Math.floor(Number(item.duration_seconds)) } : {})),
+ url: `${pageUrl}?t=${m.offset_seconds}`,
+ }));
+ return {
+ ...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: httpsUrl(item.playback_url) || 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}` } : {}) } } : {}),
+ publisher: { '@type': 'Organization', name: 'OpenVibe', url: 'https://openvibe.network' },
+ ...(parts.length ? { hasPart: parts } : {}),
+ ...(partOf ? { isPartOf: { '@type': 'VideoObject', url: `${base}${partOf}` } } : {}),
+ };
+}
+
function dateLabel(iso) {
function dateLabel(iso) {
if (!iso) return '';
if (!iso) return '';
const d = new Date(iso);
const d = new Date(iso);
@@ -630,6 +671,10 @@ ${kind === 'vod' ? html`<p class="ovv-fine">New clips are still cut on <a href="
@@ -630,6 +671,10 @@ ${kind === 'vod' ? html`<p class="ovv-fine">New clips are still cut on <a href="
...(ownOnly ? { cache: cache.htmlHeaders({ private: true }) } : {}),
...(ownOnly ? { cache: cache.htmlHeaders({ private: true }) } : {}),
title: vod.title || 'Stream recording',
title: vod.title || 'Stream recording',
description: vod.description || vod.ai_overview || '',
description: vod.description || vod.ai_overview || '',
+ ...(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 }),
+ image: httpsUrl(vod.thumbnail_url) || undefined, ogType: 'video.other',
+ }),
scripts: ['js/video.js'],
scripts: ['js/video.js'],
body: html`<div class="ovv-watch">
body: html`<div class="ovv-watch">
<div class="ovv-main">
<div class="ovv-main">
@@ -680,6 +725,8 @@ ${clips.length ? html`<section class="ovv-panel" aria-labelledby="h-clips"><h2 i
@@ -680,6 +725,8 @@ ${clips.length ? html`<section class="ovv-panel" aria-labelledby="h-clips"><h2 i
} catch { threadUrl = null; }
} catch { threadUrl = null; }
const unlisted = clip.visibility === 'unlisted';
const unlisted = clip.visibility === 'unlisted';
+ // AI clips stay out of search, as on Live: noindex and no structured data.
+ const aiClip = Boolean(clip.auto_generated);
const fullRecording = clip.vod_id ? `/v/${clip.vod_id}${clip.start_time != null ? `?t=${clip.start_time}` : ''}` : null;
const fullRecording = clip.vod_id ? `/v/${clip.vod_id}${clip.start_time != null ? `?t=${clip.start_time}` : ''}` : null;
const sourceName = clip.source_streamer_display_name || clip.source_streamer_username || 'the streamer';
const sourceName = clip.source_streamer_display_name || clip.source_streamer_username || 'the streamer';
const madeBy = clip.display_name || clip.username || 'an OpenVibe member';
const madeBy = clip.display_name || clip.username || 'an OpenVibe member';
@@ -692,11 +739,15 @@ ${clips.length ? html`<section class="ovv-panel" aria-labelledby="h-clips"><h2 i
@@ -692,11 +739,15 @@ ${clips.length ? html`<section class="ovv-panel" aria-labelledby="h-clips"><h2 i
const hlsAttr = hlsAttrs(clip);
const hlsAttr = hlsAttrs(clip);
return page(req, res, {
return page(req, res, {
- index: !unlisted && !ownOnly,
- noindex: unlisted || ownOnly,
+ index: !unlisted && !ownOnly && !aiClip,
+ noindex: unlisted || ownOnly || aiClip,
...(ownOnly ? { cache: cache.htmlHeaders({ private: true }) } : {}),
...(ownOnly ? { cache: cache.htmlHeaders({ private: true }) } : {}),
title: clip.title || 'Clip',
title: clip.title || 'Clip',
description: clip.ai_overview || '',
description: clip.ai_overview || '',
+ ...(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 }),
+ image: httpsUrl(clip.thumbnail_url) || undefined, ogType: 'video.other',
+ }),
scripts: ['js/video.js'],
scripts: ['js/video.js'],
body: html`<div class="ovv-watch ovv-watch--clip">
body: html`<div class="ovv-watch ovv-watch--clip">
<div class="ovv-main">
<div class="ovv-main">
@@ -921,6 +972,7 @@ ${rows.length ? html`<ul class="ovv-playlist-list">${rows.map(playlistRow)}</ul>
@@ -921,6 +972,7 @@ ${rows.length ? html`<ul class="ovv-playlist-list">${rows.map(playlistRow)}</ul>
const items = await playlists.items(pl.id);
const items = await playlists.items(pl.id);
const rows = [];
const rows = [];
+ const listLd = [];
for (const it of items) {
for (const it of items) {
const itemId = Number(it.item_id);
const itemId = Number(it.item_id);
let info = null;
let info = null;
@@ -933,6 +985,7 @@ ${rows.length ? html`<ul class="ovv-playlist-list">${rows.map(playlistRow)}</ul>
@@ -933,6 +985,7 @@ ${rows.length ? html`<ul class="ovv-playlist-list">${rows.map(playlistRow)}</ul>
continue;
continue;
}
}
const href = it.kind === 'vod' ? `/v/${itemId}` : `/c/${itemId}`;
const href = it.kind === 'vod' ? `/v/${itemId}` : `/c/${itemId}`;
+ listLd.push({ '@type': 'ListItem', position: listLd.length + 1, url: `${base}${href}`, name: String(info.title || (it.kind === 'vod' ? 'Stream recording' : 'Clip')).slice(0, 110) });
const thumb = httpsUrl(info.thumbnail_url);
const thumb = httpsUrl(info.thumbnail_url);
const title = info.title || (it.kind === 'clip' ? 'Clip' : 'Stream recording');
const title = info.title || (it.kind === 'clip' ? 'Clip' : 'Stream recording');
const channel = info.username || info.display_name || null;
const channel = info.username || info.display_name || null;
@@ -950,6 +1003,7 @@ ${isOwner ? itemControls(pl.id, it) : ''}
@@ -950,6 +1003,7 @@ ${isOwner ? itemControls(pl.id, it) : ''}
return page(req, res, {
return page(req, res, {
index: pl.visibility === 'public',
index: pl.visibility === 'public',
noindex: pl.visibility === 'unlisted',
noindex: pl.visibility === 'unlisted',
+ ...(pl.visibility === 'public' && listLd.length ? { jsonLd: { '@context': 'https://schema.org', '@type': 'ItemList', name: pl.title, numberOfItems: listLd.length, itemListElement: listLd } } : {}),
title: pl.title,
title: pl.title,
description: pl.description || undefined,
description: pl.description || undefined,
crumbs: [{ label: 'Home', href: '/' }, { label: 'Playlists', href: '/playlists' }, { label: pl.title }],
crumbs: [{ label: 'Home', href: '/' }, { label: 'Playlists', href: '/playlists' }, { label: pl.title }],
@@ -237,6 +237,17 @@ function countOccurrences(haystack, needle) {
@@ -237,6 +237,17 @@ function countOccurrences(haystack, needle) {
assert.ok(r.text.includes('playsinline'));
assert.ok(r.text.includes('playsinline'));
assert.ok(r.text.includes('data-ovv-video'));
assert.ok(r.text.includes('data-ovv-video'));
assert.ok(r.text.includes('data-ovv-view="https://openvibe.live/api/views/vod/101"'), 'playback counts a view on Live');
assert.ok(r.text.includes('data-ovv-view="https://openvibe.live/api/views/vod/101"'), 'playback counts a view on Live');
+ const ld = [...r.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1])).find((x) => x['@type'] === 'VideoObject');
+ assert.ok(ld, 'a VideoObject');
+ assert.strictEqual(ld.url, 'https://openvibe.video/v/101');
+ assert.strictEqual(ld.name, 'A public recording');
+ 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.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');
assert.ok(r.text.includes('data-ovv-hls="https://openvibe.media/o/med_101/master.m3u8"'), 'the timeline playlist');
assert.ok(r.text.includes('data-ovv-hls="https://openvibe.media/o/med_101/master.m3u8"'), 'the timeline playlist');
assert.ok(/data-ovv-hls-lib="\/vendor\/hls\.light\.min\.js\?v=[0-9a-f]{10}"/.test(r.text), 'hls.js, self-hosted and content-hashed');
assert.ok(/data-ovv-hls-lib="\/vendor\/hls\.light\.min\.js\?v=[0-9a-f]{10}"/.test(r.text), 'hls.js, self-hosted and content-hashed');
assert.ok(/media-src[^;]*blob:/.test(String(r.headers['content-security-policy'])), 'MSE plays from blob:');
assert.ok(/media-src[^;]*blob:/.test(String(r.headers['content-security-policy'])), 'MSE plays from blob:');
@@ -257,6 +268,10 @@ function countOccurrences(haystack, needle) {
@@ -257,6 +268,10 @@ function countOccurrences(haystack, needle) {
const r = await getVideo('/c/201');
const r = await getVideo('/c/201');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
assert.ok(r.text.includes('data-ovv-view="https://openvibe.live/api/views/clip/201"'), 'playback counts a clip view on Live');
assert.ok(r.text.includes('data-ovv-view="https://openvibe.live/api/views/clip/201"'), 'playback counts a clip view on Live');
+ const cld = [...r.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1])).find((x) => x['@type'] === 'VideoObject');
+ assert.ok(cld && cld.url === 'https://openvibe.video/c/201', 'a clip VideoObject');
+ assert.deepStrictEqual(cld.isPartOf, { '@type': 'VideoObject', url: 'https://openvibe.video/v/101' }, 'part of its recording');
+ assert.strictEqual(cld.duration, 'PT10S');
assert.ok(r.text.includes('src="https://openvibe.media/c/201"'), 'the clip playback url');
assert.ok(r.text.includes('src="https://openvibe.media/c/201"'), 'the clip playback url');
assert.ok(r.text.includes('Made by Clipper'), 'attributed to the person who made it');
assert.ok(r.text.includes('Made by Clipper'), 'attributed to the person who made it');
assert.ok(r.text.includes('Watch in the full recording'));
assert.ok(r.text.includes('Watch in the full recording'));