openvibers/openvibe.live Commits
Describe current live streams in page and sitemap (#437)
Changes
@@@ -15,10 +15,10 @@ const DOCS_DIR = process.env.OV_APP_ROOT ? path.join(process.env.OV_APP_ROOT, 'dconst DISCOVERY_MAX_AGE = 3600; // seconds, for every file here// ── Dynamic sitemap.xml (cached ~1h) ────────────────────────────────────────────────────────--let _sitemap = null, _sitemapAt = 0;++let _sitemap = null, _sitemapAt = 0, _sitemapLiveSignature = '';const SITEMAP_TTL_MS = 60 * 60 * 1000;const SITEMAP_CAP = 5000; // per content type--async function buildSitemap() {++async function buildSitemap(liveStreams = []) {const b = baseUrl();const urls = [];const entry = (loc, lastmod, changefreq, priority) => urls.push({ loc: b + loc, lastmod: lastmod || undefined, changefreq, priority });@@@ -84,13 +84,33 @@ async function buildSitemap() {// No /p/ here: pastes are OpenVibe.Community's, and its sitemap lists them under their canonical URL.const lists = { '/': later(newest.vod, newest.clip), '/content': later(newest.vod, newest.clip), '/vods': newest.vod, '/clips': newest.clip };for (const [u, cf, pr] of statics) entry(u, lists[u] || releasedAt, cf, pr);-- for (const u of seenChannels) if (u) entry(`/@${u}`, channelNewest.get(u) || null, 'daily', 0.6);++ const onAir = new Map();++ for (const stream of liveStreams) {++ if (!stream || !stream.is_live || stream.is_nsfw || !stream.username || !stream.started_at) continue;++ const thumb = stream.thumbnail_url && (String(stream.thumbnail_url).startsWith('http') ? stream.thumbnail_url : `${b}${stream.thumbnail_url}`);++ if (!thumb) continue;++ seenChannels.add(stream.username);++ onAir.set(stream.username, {++ thumbnail_loc: thumb, title: stream.title || `${stream.display_name || stream.username} is live`,++ description: `${stream.display_name || stream.username} is live on ${SITE_NAME}.`,++ player_loc: `${b}/@${stream.username}`, publication_date: isoTime(stream.started_at),++ uploader: stream.display_name || stream.username, uploader_info: `${b}/@${stream.username}`, live: true,++ });++ }++ for (const u of seenChannels) if (u) {++ const row = { loc: `${b}/@${u}`, lastmod: channelNewest.get(u) || null, changefreq: 'daily', priority: 0.6 };++ if (onAir.has(u)) row.videos = [onAir.get(u)];++ urls.push(row);++ }return seo.sitemapXml(urls);}async function sitemapHandler(req, res) {-- if (!_sitemap || (Date.now() - _sitemapAt) > SITEMAP_TTL_MS) {-- try { _sitemap = await buildSitemap(); _sitemapAt = Date.now(); }-- catch (e) { console.warn('[SEO] sitemap build failed:', e.message); if (!_sitemap) return res.status(500).end(); }++ const liveStreams = await db.getLiveStreams();++ const liveSignature = liveStreams.filter(s => s && s.is_live && !s.is_nsfw).map(s => `${s.id}:${s.started_at}`).sort().join('|');++ const liveChanged = liveSignature !== _sitemapLiveSignature;++ if (!_sitemap || (Date.now() - _sitemapAt) > SITEMAP_TTL_MS || liveChanged) {++ try { _sitemap = await buildSitemap(liveStreams); _sitemapAt = Date.now(); _sitemapLiveSignature = liveSignature; }++ catch (e) { console.warn('[SEO] sitemap build failed:', e.message); if (!_sitemap || liveChanged) return res.status(500).end(); }}res.set('Content-Type', 'application/xml; charset=utf-8');res.set('Cache-Control', cache.htmlHeaders({ maxAge: DISCOVERY_MAX_AGE }));@@@ -306,6 +306,17 @@ async function _channelMeta(username, page = 1) {};const profile = { ...seo.jsonLd.webPage({ type: 'ProfilePage', name: `${name} (${handle})`, url: abs(canonicalPath) }), mainEntity: person };const ld = [profile];++ const liveStart = liveNow && isoDate(liveNow.started_at);++ if (page === 1 && liveNow && liveStart && !acct.banned) {++ ld.push({++ '@type': 'VideoObject', name: clean(liveNow.title || `${name} is streaming`, 110),++ description: desc, url: abs(basePath), embedUrl: abs(basePath),++ ...(liveNow.thumbnail_url ? { thumbnailUrl: [abs(liveNow.thumbnail_url)] } : {}),++ uploadDate: liveStart, startDate: liveStart, isLiveBroadcast: true,++ author: { '@type': 'Person', name: clean(name, 80), url: abs(basePath) },++ creator: { '@type': 'Person', name: clean(name, 80), url: abs(basePath) },++ });++ }if (vods.length) {ld.push({'@context': 'https://schema.org', '@type': 'ItemList', name: `${name}'s videos${page > 1 ? `, page ${page}` : ''}`,@@@ -768,6 +779,10 @@ function _base() {return doc.html;}++function oneGraph(nodes) {++ return { '@context': 'https://schema.org', '@graph': nodes.map(({ '@context': _context, ...node }) => node) };++}++function _headBlock(meta) {const canonical = abs(meta.canonicalPath || '/');let tags = seo.headTags({@@@ -776,7 +791,7 @@ function _headBlock(meta) {// (Only when the canonical is on this site: a recording's or clip's canonical is on OpenVibe.Video.)alternates: canonical.startsWith(`${baseUrl()}/`) || canonical === baseUrl() ? [{ hreflang: 'en', href: canonical }, { hreflang: 'x-default', href: canonical }] : [],image: abs(meta.image || DEFAULT_OG_IMAGE), type: meta.ogType || 'website', siteName: SITE_NAME,-- robots: meta.robots || 'index,follow', jsonLd: meta.jsonLd || [],++ robots: meta.robots || 'index,follow', jsonLd: meta.jsonLd && meta.jsonLd.length ? oneGraph(meta.jsonLd) : [],});// og:url is the page itself: a shared AI clip previews as that clip, while its canonical names the source.if (meta.ogUrlPath) tags = tags.replace(`<meta property="og:url" content="${esc(canonical)}">`, `<meta property="og:url" content="${esc(abs(meta.ogUrlPath))}">`);@@@ -834,7 +849,8 @@ function render(meta, urlPath) {.replace(/\s*<meta\s+name=["']robots["'][^>]*>/ig, '').replace(/\s*<link\s+rel=["']canonical["'][^>]*>/ig, '').replace(/\s*<meta\s+property=["']og:[^"']*["'][^>]*>/ig, '')-- .replace(/\s*<meta\s+name=["']twitter:[^"']*["'][^>]*>/ig, '');++ .replace(/\s*<meta\s+name=["']twitter:[^"']*["'][^>]*>/ig, '')++ .replace(/\s*<script type="application\/ld\+json">[\s\S]*?<\/script>/ig, '');// Insert the fresh head block just before </head>.html = headEnd === -1@@@ -942,14 +958,17 @@ async function middleware(req, res, next) {// skip the check and keep serving pages that point at the previous asset versions._base();const cached = _cacheGet(key);-- if (cached) return send(cached.html, cached.ttl);++ if (cached) {++ if (!cached.html.includes('"isLiveBroadcast":true')) return send(cached.html, cached.ttl);++ _cache.delete(key);++ }const meta = await _pageMeta(p, { page });if (!meta) return next(); // unknown, private or not found: the SPA fallback answers (404 when it names nothing)const html = render(meta, p);if (!html) return next();const ttl = meta.cacheTtlMs || CACHE_TTL_MS;_cacheSet(key, html, ttl);-- return send(html, ttl);++ return send(html, html.includes('"isLiveBroadcast":true') ? 0 : ttl);} catch (e) {console.warn('[SEO] render failed for', p, '-', e.message);return next();@@@ -106,7 +106,11 @@ const titleOf = (html) => ((head(html).match(/<title>([^<]*)<\/title>/) || [])[1const descOf = (html) => ((head(html).match(/<meta name="description" content="([^"]*)">/) || [])[1] || '');const canonicalsOf = (html) => [...head(html).matchAll(/<link rel="canonical" href="([^"]+)">/g)].map((m) => m[1]);const robotsOf = (html) => [...head(html).matchAll(/<meta name="robots" content="([^"]+)">/g)].map((m) => m[1]);--const ldOf = (html) => [...head(html).matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => { try { return JSON.parse(m[1]); } catch { return null; } }).filter(Boolean);++const ldOf = (html) => {++ const scripts = [...head(html).matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)];++ assert.ok(scripts.length <= 1, 'one JSON-LD script');++ return scripts.flatMap((m) => JSON.parse(m[1])['@graph'] || []);++};const bodyOf = (html) => ((html.match(/<div id="seo-prerender"[^>]*>([\s\S]*?)<\/div>\n/) || [])[1] || '');const CLIENTS = [{ name: 'no Accept (curl, monitors)', headers: {} },@@@ -256,6 +260,17 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {assert.ok(p, 'a Person node');assert.strictEqual(p.image, 'https://openvibe.media/avatars/carol.png', 'the person is still pictured by their avatar');assert.strictEqual(p.description, 'I paint.', 'and described by their bio');++ const video = ldOf(html).find((n) => n['@type'] === 'VideoObject');++ assert.ok(video, 'the currently live channel carries a VideoObject');++ assert.strictEqual(video.isLiveBroadcast, true);++ assert.ok(video.startDate);++ assert.deepStrictEqual(video.creator, { '@type': 'Person', name: 'Carol', url: 'https://openvibe.live/@carol' });++ assert.deepStrictEqual(video.author, video.creator);++ assert.match((await get('/sitemap.xml')).body, /<video:live>yes<\/video:live>/);++ await db.run('UPDATE streams SET is_live = 0, ended_at = ov_now() WHERE user_id = 6');++ const ended = (await get('/@carol')).body;++ assert.ok(!ldOf(ended).some((n) => n['@type'] === 'VideoObject'), 'ended stream is absent even when the live page was cached');++ assert.ok(!(await get('/sitemap.xml')).body.includes('<video:live>'), 'ended stream is absent from the sitemap');});await check('the home page\'s JSON-LD lists are named by the snapshot\'s visible headings', async () => {
@@ -15,10 +15,10 @@ const DOCS_DIR = process.env.OV_APP_ROOT ? path.join(process.env.OV_APP_ROOT, 'd
@@ -15,10 +15,10 @@ const DOCS_DIR = process.env.OV_APP_ROOT ? path.join(process.env.OV_APP_ROOT, 'd
const DISCOVERY_MAX_AGE = 3600; // seconds, for every file here
const DISCOVERY_MAX_AGE = 3600; // seconds, for every file here
// ── Dynamic sitemap.xml (cached ~1h) ────────────────────────────────────────────────────────
// ── Dynamic sitemap.xml (cached ~1h) ────────────────────────────────────────────────────────
-let _sitemap = null, _sitemapAt = 0;
+let _sitemap = null, _sitemapAt = 0, _sitemapLiveSignature = '';
const SITEMAP_TTL_MS = 60 * 60 * 1000;
const SITEMAP_TTL_MS = 60 * 60 * 1000;
const SITEMAP_CAP = 5000; // per content type
const SITEMAP_CAP = 5000; // per content type
-async function buildSitemap() {
+async function buildSitemap(liveStreams = []) {
const b = baseUrl();
const b = baseUrl();
const urls = [];
const urls = [];
const entry = (loc, lastmod, changefreq, priority) => urls.push({ loc: b + loc, lastmod: lastmod || undefined, changefreq, priority });
const entry = (loc, lastmod, changefreq, priority) => urls.push({ loc: b + loc, lastmod: lastmod || undefined, changefreq, priority });
@@ -84,13 +84,33 @@ async function buildSitemap() {
@@ -84,13 +84,33 @@ async function buildSitemap() {
// No /p/ here: pastes are OpenVibe.Community's, and its sitemap lists them under their canonical URL.
// No /p/ here: pastes are OpenVibe.Community's, and its sitemap lists them under their canonical URL.
const lists = { '/': later(newest.vod, newest.clip), '/content': later(newest.vod, newest.clip), '/vods': newest.vod, '/clips': newest.clip };
const lists = { '/': later(newest.vod, newest.clip), '/content': later(newest.vod, newest.clip), '/vods': newest.vod, '/clips': newest.clip };
for (const [u, cf, pr] of statics) entry(u, lists[u] || releasedAt, cf, pr);
for (const [u, cf, pr] of statics) entry(u, lists[u] || releasedAt, cf, pr);
- for (const u of seenChannels) if (u) entry(`/@${u}`, channelNewest.get(u) || null, 'daily', 0.6);
+ const onAir = new Map();
+ for (const stream of liveStreams) {
+ if (!stream || !stream.is_live || stream.is_nsfw || !stream.username || !stream.started_at) continue;
+ const thumb = stream.thumbnail_url && (String(stream.thumbnail_url).startsWith('http') ? stream.thumbnail_url : `${b}${stream.thumbnail_url}`);
+ if (!thumb) continue;
+ seenChannels.add(stream.username);
+ onAir.set(stream.username, {
+ thumbnail_loc: thumb, title: stream.title || `${stream.display_name || stream.username} is live`,
+ description: `${stream.display_name || stream.username} is live on ${SITE_NAME}.`,
+ player_loc: `${b}/@${stream.username}`, publication_date: isoTime(stream.started_at),
+ uploader: stream.display_name || stream.username, uploader_info: `${b}/@${stream.username}`, live: true,
+ });
+ }
+ for (const u of seenChannels) if (u) {
+ const row = { loc: `${b}/@${u}`, lastmod: channelNewest.get(u) || null, changefreq: 'daily', priority: 0.6 };
+ if (onAir.has(u)) row.videos = [onAir.get(u)];
+ urls.push(row);
+ }
return seo.sitemapXml(urls);
return seo.sitemapXml(urls);
}
}
async function sitemapHandler(req, res) {
async function sitemapHandler(req, res) {
- if (!_sitemap || (Date.now() - _sitemapAt) > SITEMAP_TTL_MS) {
- try { _sitemap = await buildSitemap(); _sitemapAt = Date.now(); }
- catch (e) { console.warn('[SEO] sitemap build failed:', e.message); if (!_sitemap) return res.status(500).end(); }
+ const liveStreams = await db.getLiveStreams();
+ const liveSignature = liveStreams.filter(s => s && s.is_live && !s.is_nsfw).map(s => `${s.id}:${s.started_at}`).sort().join('|');
+ const liveChanged = liveSignature !== _sitemapLiveSignature;
+ if (!_sitemap || (Date.now() - _sitemapAt) > SITEMAP_TTL_MS || liveChanged) {
+ try { _sitemap = await buildSitemap(liveStreams); _sitemapAt = Date.now(); _sitemapLiveSignature = liveSignature; }
+ catch (e) { console.warn('[SEO] sitemap build failed:', e.message); if (!_sitemap || liveChanged) return res.status(500).end(); }
}
}
res.set('Content-Type', 'application/xml; charset=utf-8');
res.set('Content-Type', 'application/xml; charset=utf-8');
res.set('Cache-Control', cache.htmlHeaders({ maxAge: DISCOVERY_MAX_AGE }));
res.set('Cache-Control', cache.htmlHeaders({ maxAge: DISCOVERY_MAX_AGE }));
@@ -306,6 +306,17 @@ async function _channelMeta(username, page = 1) {
@@ -306,6 +306,17 @@ async function _channelMeta(username, page = 1) {
};
};
const profile = { ...seo.jsonLd.webPage({ type: 'ProfilePage', name: `${name} (${handle})`, url: abs(canonicalPath) }), mainEntity: person };
const profile = { ...seo.jsonLd.webPage({ type: 'ProfilePage', name: `${name} (${handle})`, url: abs(canonicalPath) }), mainEntity: person };
const ld = [profile];
const ld = [profile];
+ const liveStart = liveNow && isoDate(liveNow.started_at);
+ if (page === 1 && liveNow && liveStart && !acct.banned) {
+ ld.push({
+ '@type': 'VideoObject', name: clean(liveNow.title || `${name} is streaming`, 110),
+ description: desc, url: abs(basePath), embedUrl: abs(basePath),
+ ...(liveNow.thumbnail_url ? { thumbnailUrl: [abs(liveNow.thumbnail_url)] } : {}),
+ uploadDate: liveStart, startDate: liveStart, isLiveBroadcast: true,
+ author: { '@type': 'Person', name: clean(name, 80), url: abs(basePath) },
+ creator: { '@type': 'Person', name: clean(name, 80), url: abs(basePath) },
+ });
+ }
if (vods.length) {
if (vods.length) {
ld.push({
ld.push({
'@context': 'https://schema.org', '@type': 'ItemList', name: `${name}'s videos${page > 1 ? `, page ${page}` : ''}`,
'@context': 'https://schema.org', '@type': 'ItemList', name: `${name}'s videos${page > 1 ? `, page ${page}` : ''}`,
@@ -768,6 +779,10 @@ function _base() {
@@ -768,6 +779,10 @@ function _base() {
return doc.html;
return doc.html;
}
}
+function oneGraph(nodes) {
+ return { '@context': 'https://schema.org', '@graph': nodes.map(({ '@context': _context, ...node }) => node) };
+}
+
function _headBlock(meta) {
function _headBlock(meta) {
const canonical = abs(meta.canonicalPath || '/');
const canonical = abs(meta.canonicalPath || '/');
let tags = seo.headTags({
let tags = seo.headTags({
@@ -776,7 +791,7 @@ function _headBlock(meta) {
@@ -776,7 +791,7 @@ function _headBlock(meta) {
// (Only when the canonical is on this site: a recording's or clip's canonical is on OpenVibe.Video.)
// (Only when the canonical is on this site: a recording's or clip's canonical is on OpenVibe.Video.)
alternates: canonical.startsWith(`${baseUrl()}/`) || canonical === baseUrl() ? [{ hreflang: 'en', href: canonical }, { hreflang: 'x-default', href: canonical }] : [],
alternates: canonical.startsWith(`${baseUrl()}/`) || canonical === baseUrl() ? [{ hreflang: 'en', href: canonical }, { hreflang: 'x-default', href: canonical }] : [],
image: abs(meta.image || DEFAULT_OG_IMAGE), type: meta.ogType || 'website', siteName: SITE_NAME,
image: abs(meta.image || DEFAULT_OG_IMAGE), type: meta.ogType || 'website', siteName: SITE_NAME,
- robots: meta.robots || 'index,follow', jsonLd: meta.jsonLd || [],
+ robots: meta.robots || 'index,follow', jsonLd: meta.jsonLd && meta.jsonLd.length ? oneGraph(meta.jsonLd) : [],
});
});
// og:url is the page itself: a shared AI clip previews as that clip, while its canonical names the source.
// og:url is the page itself: a shared AI clip previews as that clip, while its canonical names the source.
if (meta.ogUrlPath) tags = tags.replace(`<meta property="og:url" content="${esc(canonical)}">`, `<meta property="og:url" content="${esc(abs(meta.ogUrlPath))}">`);
if (meta.ogUrlPath) tags = tags.replace(`<meta property="og:url" content="${esc(canonical)}">`, `<meta property="og:url" content="${esc(abs(meta.ogUrlPath))}">`);
@@ -834,7 +849,8 @@ function render(meta, urlPath) {
@@ -834,7 +849,8 @@ function render(meta, urlPath) {
.replace(/\s*<meta\s+name=["']robots["'][^>]*>/ig, '')
.replace(/\s*<meta\s+name=["']robots["'][^>]*>/ig, '')
.replace(/\s*<link\s+rel=["']canonical["'][^>]*>/ig, '')
.replace(/\s*<link\s+rel=["']canonical["'][^>]*>/ig, '')
.replace(/\s*<meta\s+property=["']og:[^"']*["'][^>]*>/ig, '')
.replace(/\s*<meta\s+property=["']og:[^"']*["'][^>]*>/ig, '')
- .replace(/\s*<meta\s+name=["']twitter:[^"']*["'][^>]*>/ig, '');
+ .replace(/\s*<meta\s+name=["']twitter:[^"']*["'][^>]*>/ig, '')
+ .replace(/\s*<script type="application\/ld\+json">[\s\S]*?<\/script>/ig, '');
// Insert the fresh head block just before </head>.
// Insert the fresh head block just before </head>.
html = headEnd === -1
html = headEnd === -1
@@ -942,14 +958,17 @@ async function middleware(req, res, next) {
@@ -942,14 +958,17 @@ async function middleware(req, res, next) {
// skip the check and keep serving pages that point at the previous asset versions.
// skip the check and keep serving pages that point at the previous asset versions.
_base();
_base();
const cached = _cacheGet(key);
const cached = _cacheGet(key);
- if (cached) return send(cached.html, cached.ttl);
+ if (cached) {
+ if (!cached.html.includes('"isLiveBroadcast":true')) return send(cached.html, cached.ttl);
+ _cache.delete(key);
+ }
const meta = await _pageMeta(p, { page });
const meta = await _pageMeta(p, { page });
if (!meta) return next(); // unknown, private or not found: the SPA fallback answers (404 when it names nothing)
if (!meta) return next(); // unknown, private or not found: the SPA fallback answers (404 when it names nothing)
const html = render(meta, p);
const html = render(meta, p);
if (!html) return next();
if (!html) return next();
const ttl = meta.cacheTtlMs || CACHE_TTL_MS;
const ttl = meta.cacheTtlMs || CACHE_TTL_MS;
_cacheSet(key, html, ttl);
_cacheSet(key, html, ttl);
- return send(html, ttl);
+ return send(html, html.includes('"isLiveBroadcast":true') ? 0 : ttl);
} catch (e) {
} catch (e) {
console.warn('[SEO] render failed for', p, '-', e.message);
console.warn('[SEO] render failed for', p, '-', e.message);
return next();
return next();
@@ -106,7 +106,11 @@ const titleOf = (html) => ((head(html).match(/<title>([^<]*)<\/title>/) || [])[1
@@ -106,7 +106,11 @@ const titleOf = (html) => ((head(html).match(/<title>([^<]*)<\/title>/) || [])[1
const descOf = (html) => ((head(html).match(/<meta name="description" content="([^"]*)">/) || [])[1] || '');
const descOf = (html) => ((head(html).match(/<meta name="description" content="([^"]*)">/) || [])[1] || '');
const canonicalsOf = (html) => [...head(html).matchAll(/<link rel="canonical" href="([^"]+)">/g)].map((m) => m[1]);
const canonicalsOf = (html) => [...head(html).matchAll(/<link rel="canonical" href="([^"]+)">/g)].map((m) => m[1]);
const robotsOf = (html) => [...head(html).matchAll(/<meta name="robots" content="([^"]+)">/g)].map((m) => m[1]);
const robotsOf = (html) => [...head(html).matchAll(/<meta name="robots" content="([^"]+)">/g)].map((m) => m[1]);
-const ldOf = (html) => [...head(html).matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => { try { return JSON.parse(m[1]); } catch { return null; } }).filter(Boolean);
+const ldOf = (html) => {
+ const scripts = [...head(html).matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)];
+ assert.ok(scripts.length <= 1, 'one JSON-LD script');
+ return scripts.flatMap((m) => JSON.parse(m[1])['@graph'] || []);
+};
const bodyOf = (html) => ((html.match(/<div id="seo-prerender"[^>]*>([\s\S]*?)<\/div>\n/) || [])[1] || '');
const bodyOf = (html) => ((html.match(/<div id="seo-prerender"[^>]*>([\s\S]*?)<\/div>\n/) || [])[1] || '');
const CLIENTS = [
const CLIENTS = [
{ name: 'no Accept (curl, monitors)', headers: {} },
{ name: 'no Accept (curl, monitors)', headers: {} },
@@ -256,6 +260,17 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {
@@ -256,6 +260,17 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {
assert.ok(p, 'a Person node');
assert.ok(p, 'a Person node');
assert.strictEqual(p.image, 'https://openvibe.media/avatars/carol.png', 'the person is still pictured by their avatar');
assert.strictEqual(p.image, 'https://openvibe.media/avatars/carol.png', 'the person is still pictured by their avatar');
assert.strictEqual(p.description, 'I paint.', 'and described by their bio');
assert.strictEqual(p.description, 'I paint.', 'and described by their bio');
+ const video = ldOf(html).find((n) => n['@type'] === 'VideoObject');
+ assert.ok(video, 'the currently live channel carries a VideoObject');
+ assert.strictEqual(video.isLiveBroadcast, true);
+ assert.ok(video.startDate);
+ assert.deepStrictEqual(video.creator, { '@type': 'Person', name: 'Carol', url: 'https://openvibe.live/@carol' });
+ assert.deepStrictEqual(video.author, video.creator);
+ assert.match((await get('/sitemap.xml')).body, /<video:live>yes<\/video:live>/);
+ await db.run('UPDATE streams SET is_live = 0, ended_at = ov_now() WHERE user_id = 6');
+ const ended = (await get('/@carol')).body;
+ assert.ok(!ldOf(ended).some((n) => n['@type'] === 'VideoObject'), 'ended stream is absent even when the live page was cached');
+ assert.ok(!(await get('/sitemap.xml')).body.includes('<video:live>'), 'ended stream is absent from the sitemap');
});
});
await check('the home page\'s JSON-LD lists are named by the snapshot\'s visible headings', async () => {
await check('the home page\'s JSON-LD lists are named by the snapshot\'s visible headings', async () => {