openvibers/openvibe.mediahub Commits

MediaHub: openvibe.video's sitemap names the home, channels and 300 items, all dated (#30)

OP OpenVibers committed 12 Oct 2026 · 9ad3a0ea72 · 3 files, +38 −8

openvibe.video's sitemap listed only the latest 30 recordings and 30 clips, undated, with no home or channel pages (the SEO survey's "sitemap-no-lastmod").

- It now follows Live's feed cursor for up to five pages of each kind (Live's page size is 30): 150 recordings and 150 clips.
- It adds the home page, dated by the newest item, and every channel those items come from, dated by that channel's newest item.
- Every URL carries a lastmod.
- The Live client passes the opaque cursor on only when it is a plain token. A page that fails after the first ends the walk without failing the sitemap.

Checked against production data: 344 URLs (150 + 150 + 43 channels + the home), all dated. Tests: video-pages (asserts the dated home, a dated recording and its channel), video-chapters.

Changes

ddiff --git a/server/video/live-client.js b/server/video/live-client.js
iindex ccc420b..24dba4e 100644
---- a/server/video/live-client.js
++++ b/server/video/live-client.js
@@@ -83,8 +83,10 @@ function createLiveClient({ config, fetchImpl = globalThis.fetch, log = console
}
return {
-- feed({ type, limit }) {
++ feed({ type, limit, cursor = null }) {
const qs = new URLSearchParams({ type, limit: String(limit) });
++ // Live's feed pages by an opaque cursor (its `next`); only a plain token is passed on.
++ if (cursor && /^[A-Za-z0-9_-]{1,200}$/.test(String(cursor))) qs.set('cursor', String(cursor));
return getJson(`/api/content/feed?${qs.toString()}`);
},
vodsByUser(username, { limit, offset = 0 }) {
ddiff --git a/server/video/pages.js b/server/video/pages.js
iindex a891da4..8df6dde 100644
---- a/server/video/pages.js
++++ b/server/video/pages.js
@@@ -29,7 +29,8 @@ const { createChatClient } = require('./chat-client');
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
const HOME_LIMIT = 10;
--const SITEMAP_LIMIT = 30;
++const SITEMAP_LIMIT = 30; // Live's feed page size (its MAX_LIMIT)
++const SITEMAP_PAGES = 5; // per kind: up to 150 recordings and 150 clips
const CHANNEL_CLIPS_LIMIT = 12;
const PER_PAGE = 24;
@@@ -515,13 +516,37 @@ ${clips.length ? html`<section class="ovv-section" aria-labelledby="h-clips"><h2
});
// ── Discovery: robots.txt, sitemap.xml, llms.txt ────────
++ /** Up to SITEMAP_PAGES pages of one feed, following Live's cursor; a page that fails ends the walk. */
++ async function feedItems(type) {
++ const items = [];
++ let cursor = null;
++ for (let i = 0; i < SITEMAP_PAGES; i++) {
++ let page;
++ try { page = await live.feed({ type, limit: SITEMAP_LIMIT, cursor }); } catch (err) { if (!items.length) throw err; break; }
++ items.push(...((page && Array.isArray(page.items)) ? page.items : []));
++ cursor = page && page.next;
++ if (!cursor) break;
++ }
++ return items;
++ }
++
r.get('/sitemap.xml', async (req, res) => {
-- const [vods, clips] = await Promise.all([
-- live.feed({ type: 'vods', limit: SITEMAP_LIMIT }),
-- live.feed({ type: 'clips', limit: SITEMAP_LIMIT }),
-- ]);
-- const items = [...((vods && vods.items) || []), ...((clips && clips.items) || [])];
-- const urls = items.map((it) => ({ loc: `${base}${it.kind === 'clip' ? '/c/' : '/v/'}${it.id}` }));
++ const [vods, clips] = await Promise.all([feedItems('vods'), feedItems('clips')]);
++ const day = (iso) => { const d = new Date(String(iso || '')); return Number.isNaN(d.getTime()) ? null : d.toISOString().slice(0, 10); };
++ const newest = (list) => list.map(day).filter(Boolean).sort().pop() || null;
++ const items = [...vods, ...clips].filter((it) => it && Number.isSafeInteger(Number(it.id)));
++ const channels = new Map(); // username → its newest item's day
++ for (const it of items) {
++ const u = it.channel && it.channel.username;
++ if (!u || !/^[A-Za-z0-9_]{1,32}$/.test(u)) continue;
++ const d = day(it.created_at);
++ if (!channels.has(u) || (d && d > channels.get(u))) channels.set(u, d);
++ }
++ const urls = [
++ { loc: `${base}/`, lastmod: newest(items.map((it) => it.created_at)) },
++ ...items.map((it) => ({ loc: `${base}${it.kind === 'clip' ? '/c/' : '/v/'}${Number(it.id)}`, lastmod: day(it.created_at) })),
++ ...[...channels].map(([u, d]) => ({ loc: `${base}/@${u}`, lastmod: d })),
++ ].map((u) => (u.lastmod ? u : { loc: u.loc }));
return res.type('application/xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(seo.sitemapXml(urls));
});
ddiff --git a/test/video-pages.test.js b/test/video-pages.test.js
iindex e1f1ae7..6f87d9a 100644
---- a/test/video-pages.test.js
++++ b/test/video-pages.test.js
@@@ -295,6 +295,9 @@ function countOccurrences(haystack, needle) {
await check('the sitemap lists public items and leaves the unlisted VOD out', async () => {
const r = await getVideo('/sitemap.xml');
++ assert.match(r.text, /<loc>https:\/\/openvibe\.video\/<\/loc><lastmod>\d{4}-\d{2}-\d{2}<\/lastmod>/, 'the home, dated by the newest item');
++ assert.match(r.text, /<loc>https:\/\/openvibe\.video\/v\/101<\/loc><lastmod>2026-10-12<\/lastmod>/, 'a recording, dated');
++ assert.match(r.text, /<loc>https:\/\/openvibe\.video\/@streamer<\/loc>/, 'its channel');
assert.strictEqual(r.status, 200);
assert.ok(r.text.includes('https://openvibe.video/v/101'));
assert.ok(r.text.includes('https://openvibe.video/c/201'));