openvibers/openvibe.mediahub Commits

Video: VideoObject structured data with chapters as key moments, clip isPartOf, playlist ItemList; AI clips noindex (#37)

OP OpenVibers committed 12 Oct 2026 · 5da2ccdd08 · 4 files, +83 −3

openvibe.video's watch pages carried no structured data at all, so search engines could not list them as videos or show their chapters. This adds it:

- **Recordings:** a schema.org `VideoObject` with name, description, thumbnail, upload time, ISO 8601 duration, the file (`contentUrl`), the page (`embedUrl`), the view count (`WatchAction`), the channel as `Person` author (linked to its `/@user` page) and OpenVibe as publisher. Its AI chapters become `hasPart` `Clip`s with `startOffset`, `endOffset` and a `?t=` URL, which is what Google shows as key moments.
- **Clips:** their own `VideoObject`, with `isPartOf` their recording.
- **Public playlists:** an `ItemList` of their items.
- **Share images:** the thumbnail becomes the page's `og:image`, with `og:type` `video.other`. The layout passes `jsonLd`, `image` and `ogType` through to `shell.page()`.
- **Kept out of search, as on Live:** AI clips (noindex now, no structured data), a recording still in progress, and an owner's private view.

Checked against Live's real API with a local boot. VOD 5717 gives `PT2H23M58S`, its thumbnail, 12 key moments (first at 16 s, ending at 856 s) and 4 views. Clip 441 gives `PT28S` and `isPartOf` `/v/5716`.

Tests: `video-pages.test.js` asserts the recording's `VideoObject` (url, name, upload time, duration, thumbnail, file, author, chapters as `Clip`s), the clip's `isPartOf` and duration, and `og:image`. The full suite passes (27 of 27).

Changes

ddiff --git a/README.md b/README.md
iindex 128f2b2..a92494e 100644
---- a/README.md
++++ b/README.md
@@@ -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
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,
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
ddiff --git a/server/video/layout.js b/server/video/layout.js
iindex b4e6b71..f27c23a 100644
---- a/server/video/layout.js
++++ b/server/video/layout.js
@@@ -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: {
ddiff --git a/server/video/pages.js b/server/video/pages.js
iindex 3c359a8..27e6593 100644
---- a/server/video/pages.js
++++ b/server/video/pages.js
@@@ -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 i
const 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 }],
ddiff --git a/test/video-pages.test.js b/test/video-pages.test.js
iindex 3a11e34..d0a0d1c 100644
---- a/test/video-pages.test.js
++++ b/test/video-pages.test.js
@@@ -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'));