openvibers/openvibe.media Commits
Add creator attribution to media watch pages (#215)
Changes
@@@ -14,6 +14,7 @@const config = require('../config');const ovServe = require('openvibe-shared/serve');const shell = require('openvibe-shared/shell');++const seoLib = require('openvibe-shared/seo');const appIcon = require('openvibe-shared/app-icon');const NETWORK_URL = (config.network && config.network.url) || 'https://openvibe.network';@@@ -195,8 +196,14 @@ function defaultFooterLinks() {* through the shell's `head`. `summary` is the page's AI summary (openvibe-shared/seo pageSummary): a* string, or { summary, url, facts: [[label, value]], updated }; the url defaults to the canonical.*/++function oneGraph(nodes) {++ if (!nodes || ![].concat(nodes).length) return undefined;++ return { '@context': 'https://schema.org', '@graph': [].concat(nodes).map(({ '@context': _context, ...node }) => node) };++}++function page({ seo, css = '', body, history, footer, summary }) {const sum = summary == null ? null : typeof summary === 'object' ? summary : { summary };++ const summaryData = sum ? seoLib.pageSummary({ title: seo.title, summary: sum.summary, facts: sum.facts, url: sum.url || seo.canonical, updated: sum.updated }) : null;const html = shell.page({name: SITE_NAME,lang: 'en',@@@ -207,10 +214,9 @@ function page({ seo, css = '', body, history, footer, summary }) {image: seo.image || DEFAULT_OG_IMAGE,type: seo.ogType,robots: seo.robots || 'index, follow',-- jsonLd: seo.jsonLd,-- ...(sum ? { summary: sum.summary, facts: sum.facts, url: sum.url, updated: sum.updated } : {}),-- head: extraHead(seo, css) + footerBootScript(footer),-- body: `<div id="navbar-mount"></div>\n<main id="main">\n${body}\n</main>`,++ jsonLd: oneGraph([...[].concat(seo.jsonLd || []), ...(summaryData ? [summaryData.jsonLd] : [])]),++ head: extraHead(seo, css) + (summaryData && summaryData.meta ? `<meta name="ai-summary" content="${summaryData.meta}">` : '') + footerBootScript(footer),++ body: `${summaryData ? summaryData.body : ''}<div id="navbar-mount"></div>\n<main id="main">\n${body}\n</main>`,navLinks: [{ label: 'Videos', href: '/?tab=videos' }, { label: 'Clips', href: '/?tab=clips' }],navbar: navOptions({ history }),footer: footerOptions(footer),@@@ -102,6 +102,9 @@ function renderWatchPage(kind, record, readiness = null) {const ai = isAiClip(kind, record);// An AI clip is attributed to no person: the automation cut it from someone's stream.const by = ai ? null : creatorLine(record);++ const ownerUrl = by && record.username ? ((record.app_id || 'live') === 'live'++ ? `https://openvibe.video/@${encodeURIComponent(record.username)}`++ : `${appUrl(record.app_id)}/@${encodeURIComponent(record.username)}`) : null;const description = snip(record.description || record.ai_overview || `${isVod ? 'A recorded live stream' : ai ? 'An AI-picked clip from a live stream' : 'A clip'}${by ? ` by ${by}` : ''} on the OpenVibe network.`, 200);const thumb = abs(record.thumbnail_url);const ownedByLive = (record.app_id || 'live') === 'live';@@@ -115,7 +118,6 @@ function renderWatchPage(kind, record, readiness = null) {const duration = isoDuration(record.duration_seconds);const videoObject = {-- '@context': 'https://schema.org','@type': 'VideoObject',name: title,description,@@@ -125,8 +127,8 @@ function renderWatchPage(kind, record, readiness = null) {...(thumb ? { thumbnailUrl: [thumb] } : {}),...(uploadDate ? { uploadDate } : {}),...(duration ? { duration } : {}),-- ...(by ? { author: { '@type': 'Person', name: by } } : {}),-- ...(ai ? { creator: { '@type': 'Organization', name: 'OpenVibe AI', url: NETWORK_URL } } : {}),++ author: ownerUrl ? { '@type': 'Person', name: by, url: ownerUrl } : { '@type': 'Organization', name: 'OpenVibe AI', url: NETWORK_URL },++ creator: ownerUrl ? { '@type': 'Person', name: by, url: ownerUrl } : { '@type': 'Organization', name: 'OpenVibe AI', url: NETWORK_URL },...(sourceVod ? { isBasedOn: sourceVod } : {}),...(record.view_count ? { interactionStatistic: { '@type': 'InteractionCounter', interactionType: 'https://schema.org/WatchAction', userInteractionCount: record.view_count } } : {}),publisher: { '@type': 'Organization', name: 'OpenVibe', url: NETWORK_URL },@@@ -138,7 +140,7 @@ function renderWatchPage(kind, record, readiness = null) {record.duration_seconds ? fmtDuration(record.duration_seconds) : null,record.view_count != null ? `${Number(record.view_count).toLocaleString('en-US')} views` : null,fmtDate(record.created_at) || null,-- by ? `by ${esc(by)}` : null,++ by ? (ownerUrl ? `by <a href="${esc(ownerUrl)}">${esc(by)}</a>` : `by ${esc(by)}`) : null,].filter(Boolean);const [stateTitle, stateText] = playable ? [null, null] : unavailableText(kind, readiness.reason);@@@ -184,7 +186,7 @@ function renderWatchPage(kind, record, readiness = null) {.h2 { font-size: 1rem; margin: 1.2rem 0 .3rem; color: var(--text); }`;// Structured data and the AI summary describe public items only; unlisted and private pages carry none.-- const described = playable && (record.visibility || (record.is_public ? 'public' : 'private')) === 'public';++ const described = playable && (record.visibility || (record.is_public ? 'public' : 'private')) === 'public' && (ai || !!ownerUrl);return pageFrame.page({seo: {title: `${title} — ${SITE_NAME}`, description, canonical, robots,@@@ -142,14 +142,16 @@ const express = require('express');console.log('✅ /llms-full.txt: the full text of the index and every indexable watch page, and only those');// ── Watch pages: VideoObject on public pages; AI clips noindex, no Person, based on the VOD ──-- const ldOf = (html) => { const m = /<script type="application\/ld\+json">([\s\S]*?)<\/script>/.exec(html); return m ? JSON.parse(m[1]) : null; };++ const ldOf = (html) => { const scripts = [...html.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)]; assert.ok(scripts.length <= 1); return scripts.length ? JSON.parse(scripts[0][1])['@graph'][0] : null; };const pub = pages.renderWatchPage('vod', { ...await byId('vods', 1), username: 'gamer' });assert.strictEqual(ldOf(pub)['@type'], 'VideoObject');-- assert.deepStrictEqual(ldOf(pub).author, { '@type': 'Person', name: 'gamer' });++ assert.deepStrictEqual(ldOf(pub).author, { '@type': 'Person', name: 'gamer', url: 'https://openvibe.games/@gamer' });++ assert.deepStrictEqual(ldOf(pub).creator, ldOf(pub).author);++ assert.strictEqual(ldOf(pub).isLiveBroadcast, undefined);const aiHtml = pages.renderWatchPage('clip', { ...await byId('clips', 12), username: 'gamer' });assert.ok(aiHtml.includes('content="noindex, follow"'), 'AI clips never index');const aiLd = ldOf(aiHtml);-- assert.strictEqual(aiLd.author, undefined, 'no Person attribution for an AI clip');++ assert.deepStrictEqual(aiLd.author, aiLd.creator, 'automation is credited to an organization');assert.deepStrictEqual(aiLd.creator, { '@type': 'Organization', name: 'OpenVibe AI', url: 'https://openvibe.network' });assert.strictEqual(aiLd.isBasedOn, 'https://media.test/v/1');assert.ok(aiHtml.includes('<span>AI clip</span>') && !aiHtml.includes('by gamer'));@@@ -30,7 +30,7 @@ const http = require('http');const vod = {id: 42, app_id: 'live', title: 'Late night <build>', description: 'We built a thing & it worked.',file_path: '/x/vod-42.mp4', thumbnail_url: '/t/vod-42-1.jpg', duration_seconds: 3725, view_count: 1234,-- is_public: 1, visibility: 'public', created_at: '2026-09-10 20:15:00', ai_overview: 'AI says: nice.',++ is_public: 1, visibility: 'public', created_at: '2026-09-10 20:15:00', ai_overview: 'AI says: nice.', username: 'maker',};const html = pages.renderWatchPage('vod', vod);assert.ok(html.includes('<title>Late night <build> — OpenVibe.Media</title>'));@@@ -58,12 +58,18 @@ const http = require('http');assert.ok(html.includes('<meta property="og:video" content="https://media.test/v/42?raw=1">'));assert.ok(html.includes('<meta name="twitter:card" content="summary_large_image">'));assert.ok(html.includes('src="https://media.test/v/42?raw=1"'), 'the player fetches the bytes with ?raw=1');-- const ld = JSON.parse(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/.exec(html)[1]);++ const scripts = [...html.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)];++ assert.strictEqual(scripts.length, 1);++ const ld = JSON.parse(scripts[0][1])['@graph'][0];assert.strictEqual(ld['@type'], 'VideoObject');assert.strictEqual(ld.duration, 'PT1H2M5S');assert.strictEqual(ld.uploadDate, '2026-09-10T20:15:00.000Z');assert.deepStrictEqual(ld.thumbnailUrl, ['https://media.test/t/vod-42-1.jpg']);assert.strictEqual(ld.url, 'https://openvibe.video/v/42');++ assert.deepStrictEqual(ld.creator, { '@type': 'Person', name: 'maker', url: 'https://openvibe.video/@maker' });++ assert.deepStrictEqual(ld.author, ld.creator);++ assert.strictEqual(ld.isLiveBroadcast, undefined);++ assert.ok(!pages.renderWatchPage('vod', { ...vod, username: null }).includes('application/ld+json'), 'unknown maker has no invented attribution');assert.ok(!html.includes('</script>"'), 'no script-closing sequence smuggled through JSON');const other = pages.renderWatchPage('clip', { ...vod, id: 7, app_id: 'games', title: 'Frag' });@@@ -145,7 +151,7 @@ const http = require('http');assert.deepStrictEqual([r.status, r.headers.location], [301, 'https://openvibe.community/p/slug1'], 'the paste page is a redirect, no row needed');r = await get('/', nav);assert.strictEqual(r.status, 200);-- const ld = [...r.body.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));++ const ld = [...r.body.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].flatMap((m) => JSON.parse(m[1])['@graph'] || []);assert.ok(ld.some((x) => x['@type'] === 'WebSite'), 'the index names the site');const list = ld.find((x) => x['@type'] === 'ItemList');assert.ok(list && list.itemListElement.length >= 1 && list.itemListElement.every((it) => /^https?:\/\//.test(it.url)), 'the listed cards, with absolute URLs');
@@ -14,6 +14,7 @@
@@ -14,6 +14,7 @@
const config = require('../config');
const config = require('../config');
const ovServe = require('openvibe-shared/serve');
const ovServe = require('openvibe-shared/serve');
const shell = require('openvibe-shared/shell');
const shell = require('openvibe-shared/shell');
+const seoLib = require('openvibe-shared/seo');
const appIcon = require('openvibe-shared/app-icon');
const appIcon = require('openvibe-shared/app-icon');
const NETWORK_URL = (config.network && config.network.url) || 'https://openvibe.network';
const NETWORK_URL = (config.network && config.network.url) || 'https://openvibe.network';
@@ -195,8 +196,14 @@ function defaultFooterLinks() {
@@ -195,8 +196,14 @@ function defaultFooterLinks() {
* through the shell's `head`. `summary` is the page's AI summary (openvibe-shared/seo pageSummary): a
* through the shell's `head`. `summary` is the page's AI summary (openvibe-shared/seo pageSummary): a
* string, or { summary, url, facts: [[label, value]], updated }; the url defaults to the canonical.
* string, or { summary, url, facts: [[label, value]], updated }; the url defaults to the canonical.
*/
*/
+function oneGraph(nodes) {
+ if (!nodes || ![].concat(nodes).length) return undefined;
+ return { '@context': 'https://schema.org', '@graph': [].concat(nodes).map(({ '@context': _context, ...node }) => node) };
+}
+
function page({ seo, css = '', body, history, footer, summary }) {
function page({ seo, css = '', body, history, footer, summary }) {
const sum = summary == null ? null : typeof summary === 'object' ? summary : { summary };
const sum = summary == null ? null : typeof summary === 'object' ? summary : { summary };
+ const summaryData = sum ? seoLib.pageSummary({ title: seo.title, summary: sum.summary, facts: sum.facts, url: sum.url || seo.canonical, updated: sum.updated }) : null;
const html = shell.page({
const html = shell.page({
name: SITE_NAME,
name: SITE_NAME,
lang: 'en',
lang: 'en',
@@ -207,10 +214,9 @@ function page({ seo, css = '', body, history, footer, summary }) {
@@ -207,10 +214,9 @@ function page({ seo, css = '', body, history, footer, summary }) {
image: seo.image || DEFAULT_OG_IMAGE,
image: seo.image || DEFAULT_OG_IMAGE,
type: seo.ogType,
type: seo.ogType,
robots: seo.robots || 'index, follow',
robots: seo.robots || 'index, follow',
- jsonLd: seo.jsonLd,
- ...(sum ? { summary: sum.summary, facts: sum.facts, url: sum.url, updated: sum.updated } : {}),
- head: extraHead(seo, css) + footerBootScript(footer),
- body: `<div id="navbar-mount"></div>\n<main id="main">\n${body}\n</main>`,
+ jsonLd: oneGraph([...[].concat(seo.jsonLd || []), ...(summaryData ? [summaryData.jsonLd] : [])]),
+ head: extraHead(seo, css) + (summaryData && summaryData.meta ? `<meta name="ai-summary" content="${summaryData.meta}">` : '') + footerBootScript(footer),
+ body: `${summaryData ? summaryData.body : ''}<div id="navbar-mount"></div>\n<main id="main">\n${body}\n</main>`,
navLinks: [{ label: 'Videos', href: '/?tab=videos' }, { label: 'Clips', href: '/?tab=clips' }],
navLinks: [{ label: 'Videos', href: '/?tab=videos' }, { label: 'Clips', href: '/?tab=clips' }],
navbar: navOptions({ history }),
navbar: navOptions({ history }),
footer: footerOptions(footer),
footer: footerOptions(footer),
@@ -102,6 +102,9 @@ function renderWatchPage(kind, record, readiness = null) {
@@ -102,6 +102,9 @@ function renderWatchPage(kind, record, readiness = null) {
const ai = isAiClip(kind, record);
const ai = isAiClip(kind, record);
// An AI clip is attributed to no person: the automation cut it from someone's stream.
// An AI clip is attributed to no person: the automation cut it from someone's stream.
const by = ai ? null : creatorLine(record);
const by = ai ? null : creatorLine(record);
+ const ownerUrl = by && record.username ? ((record.app_id || 'live') === 'live'
+ ? `https://openvibe.video/@${encodeURIComponent(record.username)}`
+ : `${appUrl(record.app_id)}/@${encodeURIComponent(record.username)}`) : null;
const description = snip(record.description || record.ai_overview || `${isVod ? 'A recorded live stream' : ai ? 'An AI-picked clip from a live stream' : 'A clip'}${by ? ` by ${by}` : ''} on the OpenVibe network.`, 200);
const description = snip(record.description || record.ai_overview || `${isVod ? 'A recorded live stream' : ai ? 'An AI-picked clip from a live stream' : 'A clip'}${by ? ` by ${by}` : ''} on the OpenVibe network.`, 200);
const thumb = abs(record.thumbnail_url);
const thumb = abs(record.thumbnail_url);
const ownedByLive = (record.app_id || 'live') === 'live';
const ownedByLive = (record.app_id || 'live') === 'live';
@@ -115,7 +118,6 @@ function renderWatchPage(kind, record, readiness = null) {
@@ -115,7 +118,6 @@ function renderWatchPage(kind, record, readiness = null) {
const duration = isoDuration(record.duration_seconds);
const duration = isoDuration(record.duration_seconds);
const videoObject = {
const videoObject = {
- '@context': 'https://schema.org',
'@type': 'VideoObject',
'@type': 'VideoObject',
name: title,
name: title,
description,
description,
@@ -125,8 +127,8 @@ function renderWatchPage(kind, record, readiness = null) {
@@ -125,8 +127,8 @@ function renderWatchPage(kind, record, readiness = null) {
...(thumb ? { thumbnailUrl: [thumb] } : {}),
...(thumb ? { thumbnailUrl: [thumb] } : {}),
...(uploadDate ? { uploadDate } : {}),
...(uploadDate ? { uploadDate } : {}),
...(duration ? { duration } : {}),
...(duration ? { duration } : {}),
- ...(by ? { author: { '@type': 'Person', name: by } } : {}),
- ...(ai ? { creator: { '@type': 'Organization', name: 'OpenVibe AI', url: NETWORK_URL } } : {}),
+ author: ownerUrl ? { '@type': 'Person', name: by, url: ownerUrl } : { '@type': 'Organization', name: 'OpenVibe AI', url: NETWORK_URL },
+ creator: ownerUrl ? { '@type': 'Person', name: by, url: ownerUrl } : { '@type': 'Organization', name: 'OpenVibe AI', url: NETWORK_URL },
...(sourceVod ? { isBasedOn: sourceVod } : {}),
...(sourceVod ? { isBasedOn: sourceVod } : {}),
...(record.view_count ? { interactionStatistic: { '@type': 'InteractionCounter', interactionType: 'https://schema.org/WatchAction', userInteractionCount: record.view_count } } : {}),
...(record.view_count ? { interactionStatistic: { '@type': 'InteractionCounter', interactionType: 'https://schema.org/WatchAction', userInteractionCount: record.view_count } } : {}),
publisher: { '@type': 'Organization', name: 'OpenVibe', url: NETWORK_URL },
publisher: { '@type': 'Organization', name: 'OpenVibe', url: NETWORK_URL },
@@ -138,7 +140,7 @@ function renderWatchPage(kind, record, readiness = null) {
@@ -138,7 +140,7 @@ function renderWatchPage(kind, record, readiness = null) {
record.duration_seconds ? fmtDuration(record.duration_seconds) : null,
record.duration_seconds ? fmtDuration(record.duration_seconds) : null,
record.view_count != null ? `${Number(record.view_count).toLocaleString('en-US')} views` : null,
record.view_count != null ? `${Number(record.view_count).toLocaleString('en-US')} views` : null,
fmtDate(record.created_at) || null,
fmtDate(record.created_at) || null,
- by ? `by ${esc(by)}` : null,
+ by ? (ownerUrl ? `by <a href="${esc(ownerUrl)}">${esc(by)}</a>` : `by ${esc(by)}`) : null,
].filter(Boolean);
].filter(Boolean);
const [stateTitle, stateText] = playable ? [null, null] : unavailableText(kind, readiness.reason);
const [stateTitle, stateText] = playable ? [null, null] : unavailableText(kind, readiness.reason);
@@ -184,7 +186,7 @@ function renderWatchPage(kind, record, readiness = null) {
@@ -184,7 +186,7 @@ function renderWatchPage(kind, record, readiness = null) {
.h2 { font-size: 1rem; margin: 1.2rem 0 .3rem; color: var(--text); }`;
.h2 { font-size: 1rem; margin: 1.2rem 0 .3rem; color: var(--text); }`;
// Structured data and the AI summary describe public items only; unlisted and private pages carry none.
// Structured data and the AI summary describe public items only; unlisted and private pages carry none.
- const described = playable && (record.visibility || (record.is_public ? 'public' : 'private')) === 'public';
+ const described = playable && (record.visibility || (record.is_public ? 'public' : 'private')) === 'public' && (ai || !!ownerUrl);
return pageFrame.page({
return pageFrame.page({
seo: {
seo: {
title: `${title} — ${SITE_NAME}`, description, canonical, robots,
title: `${title} — ${SITE_NAME}`, description, canonical, robots,
@@ -142,14 +142,16 @@ const express = require('express');
@@ -142,14 +142,16 @@ const express = require('express');
console.log('✅ /llms-full.txt: the full text of the index and every indexable watch page, and only those');
console.log('✅ /llms-full.txt: the full text of the index and every indexable watch page, and only those');
// ── Watch pages: VideoObject on public pages; AI clips noindex, no Person, based on the VOD ──
// ── Watch pages: VideoObject on public pages; AI clips noindex, no Person, based on the VOD ──
- const ldOf = (html) => { const m = /<script type="application\/ld\+json">([\s\S]*?)<\/script>/.exec(html); return m ? JSON.parse(m[1]) : null; };
+ const ldOf = (html) => { const scripts = [...html.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)]; assert.ok(scripts.length <= 1); return scripts.length ? JSON.parse(scripts[0][1])['@graph'][0] : null; };
const pub = pages.renderWatchPage('vod', { ...await byId('vods', 1), username: 'gamer' });
const pub = pages.renderWatchPage('vod', { ...await byId('vods', 1), username: 'gamer' });
assert.strictEqual(ldOf(pub)['@type'], 'VideoObject');
assert.strictEqual(ldOf(pub)['@type'], 'VideoObject');
- assert.deepStrictEqual(ldOf(pub).author, { '@type': 'Person', name: 'gamer' });
+ assert.deepStrictEqual(ldOf(pub).author, { '@type': 'Person', name: 'gamer', url: 'https://openvibe.games/@gamer' });
+ assert.deepStrictEqual(ldOf(pub).creator, ldOf(pub).author);
+ assert.strictEqual(ldOf(pub).isLiveBroadcast, undefined);
const aiHtml = pages.renderWatchPage('clip', { ...await byId('clips', 12), username: 'gamer' });
const aiHtml = pages.renderWatchPage('clip', { ...await byId('clips', 12), username: 'gamer' });
assert.ok(aiHtml.includes('content="noindex, follow"'), 'AI clips never index');
assert.ok(aiHtml.includes('content="noindex, follow"'), 'AI clips never index');
const aiLd = ldOf(aiHtml);
const aiLd = ldOf(aiHtml);
- assert.strictEqual(aiLd.author, undefined, 'no Person attribution for an AI clip');
+ assert.deepStrictEqual(aiLd.author, aiLd.creator, 'automation is credited to an organization');
assert.deepStrictEqual(aiLd.creator, { '@type': 'Organization', name: 'OpenVibe AI', url: 'https://openvibe.network' });
assert.deepStrictEqual(aiLd.creator, { '@type': 'Organization', name: 'OpenVibe AI', url: 'https://openvibe.network' });
assert.strictEqual(aiLd.isBasedOn, 'https://media.test/v/1');
assert.strictEqual(aiLd.isBasedOn, 'https://media.test/v/1');
assert.ok(aiHtml.includes('<span>AI clip</span>') && !aiHtml.includes('by gamer'));
assert.ok(aiHtml.includes('<span>AI clip</span>') && !aiHtml.includes('by gamer'));
@@ -30,7 +30,7 @@ const http = require('http');
@@ -30,7 +30,7 @@ const http = require('http');
const vod = {
const vod = {
id: 42, app_id: 'live', title: 'Late night <build>', description: 'We built a thing & it worked.',
id: 42, app_id: 'live', title: 'Late night <build>', description: 'We built a thing & it worked.',
file_path: '/x/vod-42.mp4', thumbnail_url: '/t/vod-42-1.jpg', duration_seconds: 3725, view_count: 1234,
file_path: '/x/vod-42.mp4', thumbnail_url: '/t/vod-42-1.jpg', duration_seconds: 3725, view_count: 1234,
- is_public: 1, visibility: 'public', created_at: '2026-09-10 20:15:00', ai_overview: 'AI says: nice.',
+ is_public: 1, visibility: 'public', created_at: '2026-09-10 20:15:00', ai_overview: 'AI says: nice.', username: 'maker',
};
};
const html = pages.renderWatchPage('vod', vod);
const html = pages.renderWatchPage('vod', vod);
assert.ok(html.includes('<title>Late night <build> — OpenVibe.Media</title>'));
assert.ok(html.includes('<title>Late night <build> — OpenVibe.Media</title>'));
@@ -58,12 +58,18 @@ const http = require('http');
@@ -58,12 +58,18 @@ const http = require('http');
assert.ok(html.includes('<meta property="og:video" content="https://media.test/v/42?raw=1">'));
assert.ok(html.includes('<meta property="og:video" content="https://media.test/v/42?raw=1">'));
assert.ok(html.includes('<meta name="twitter:card" content="summary_large_image">'));
assert.ok(html.includes('<meta name="twitter:card" content="summary_large_image">'));
assert.ok(html.includes('src="https://media.test/v/42?raw=1"'), 'the player fetches the bytes with ?raw=1');
assert.ok(html.includes('src="https://media.test/v/42?raw=1"'), 'the player fetches the bytes with ?raw=1');
- const ld = JSON.parse(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/.exec(html)[1]);
+ const scripts = [...html.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)];
+ assert.strictEqual(scripts.length, 1);
+ const ld = JSON.parse(scripts[0][1])['@graph'][0];
assert.strictEqual(ld['@type'], 'VideoObject');
assert.strictEqual(ld['@type'], 'VideoObject');
assert.strictEqual(ld.duration, 'PT1H2M5S');
assert.strictEqual(ld.duration, 'PT1H2M5S');
assert.strictEqual(ld.uploadDate, '2026-09-10T20:15:00.000Z');
assert.strictEqual(ld.uploadDate, '2026-09-10T20:15:00.000Z');
assert.deepStrictEqual(ld.thumbnailUrl, ['https://media.test/t/vod-42-1.jpg']);
assert.deepStrictEqual(ld.thumbnailUrl, ['https://media.test/t/vod-42-1.jpg']);
assert.strictEqual(ld.url, 'https://openvibe.video/v/42');
assert.strictEqual(ld.url, 'https://openvibe.video/v/42');
+ assert.deepStrictEqual(ld.creator, { '@type': 'Person', name: 'maker', url: 'https://openvibe.video/@maker' });
+ assert.deepStrictEqual(ld.author, ld.creator);
+ assert.strictEqual(ld.isLiveBroadcast, undefined);
+ assert.ok(!pages.renderWatchPage('vod', { ...vod, username: null }).includes('application/ld+json'), 'unknown maker has no invented attribution');
assert.ok(!html.includes('</script>"'), 'no script-closing sequence smuggled through JSON');
assert.ok(!html.includes('</script>"'), 'no script-closing sequence smuggled through JSON');
const other = pages.renderWatchPage('clip', { ...vod, id: 7, app_id: 'games', title: 'Frag' });
const other = pages.renderWatchPage('clip', { ...vod, id: 7, app_id: 'games', title: 'Frag' });
@@ -145,7 +151,7 @@ const http = require('http');
@@ -145,7 +151,7 @@ const http = require('http');
assert.deepStrictEqual([r.status, r.headers.location], [301, 'https://openvibe.community/p/slug1'], 'the paste page is a redirect, no row needed');
assert.deepStrictEqual([r.status, r.headers.location], [301, 'https://openvibe.community/p/slug1'], 'the paste page is a redirect, no row needed');
r = await get('/', nav);
r = await get('/', nav);
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
- const ld = [...r.body.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
+ const ld = [...r.body.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].flatMap((m) => JSON.parse(m[1])['@graph'] || []);
assert.ok(ld.some((x) => x['@type'] === 'WebSite'), 'the index names the site');
assert.ok(ld.some((x) => x['@type'] === 'WebSite'), 'the index names the site');
const list = ld.find((x) => x['@type'] === 'ItemList');
const list = ld.find((x) => x['@type'] === 'ItemList');
assert.ok(list && list.itemListElement.length >= 1 && list.itemListElement.every((it) => /^https?:\/\//.test(it.url)), 'the listed cards, with absolute URLs');
assert.ok(list && list.itemListElement.length >= 1 && list.itemListElement.every((it) => /^https?:\/\//.test(it.url)), 'the listed cards, with absolute URLs');