openvibers/openvibe.media Commits

Add creator attribution to media watch pages (#215)

OP OpenVibers committed 15 days ago · d1db39f30f · 4 files, +31 −15

Changes

ddiff --git a/server/public/page-frame.js b/server/public/page-frame.js
iindex 2a25db6..e755ae0 100644
---- a/server/public/page-frame.js
++++ b/server/public/page-frame.js
@@@ -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),
ddiff --git a/server/public/pages.js b/server/public/pages.js
iindex 0be475e..5df686a 100644
---- a/server/public/pages.js
++++ b/server/public/pages.js
@@@ -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,
ddiff --git a/test/crawl.test.js b/test/crawl.test.js
iindex d9be603..97eead2 100644
---- a/test/crawl.test.js
++++ b/test/crawl.test.js
@@@ -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'));
ddiff --git a/test/public-pages.test.js b/test/public-pages.test.js
iindex 16beec1..56c08a9 100644
---- a/test/public-pages.test.js
++++ b/test/public-pages.test.js
@@@ -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 &lt;build&gt; — 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');