openvibers/openvibe.mediahub Commits
MediaHub: openvibe.video's sitemap names the home, channels and 300 items, all dated (#30)
Changes
@@@ -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 }) {@@@ -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 clipsconst 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));});@@@ -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'));
@@ -83,8 +83,10 @@ function createLiveClient({ config, fetchImpl = globalThis.fetch, log = console
@@ -83,8 +83,10 @@ function createLiveClient({ config, fetchImpl = globalThis.fetch, log = console
}
}
return {
return {
- feed({ type, limit }) {
+ feed({ type, limit, cursor = null }) {
const qs = new URLSearchParams({ type, limit: String(limit) });
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()}`);
return getJson(`/api/content/feed?${qs.toString()}`);
},
},
vodsByUser(username, { limit, offset = 0 }) {
vodsByUser(username, { limit, offset = 0 }) {
@@ -29,7 +29,8 @@ const { createChatClient } = require('./chat-client');
@@ -29,7 +29,8 @@ const { createChatClient } = require('./chat-client');
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
const HOME_LIMIT = 10;
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 CHANNEL_CLIPS_LIMIT = 12;
const PER_PAGE = 24;
const PER_PAGE = 24;
@@ -515,13 +516,37 @@ ${clips.length ? html`<section class="ovv-section" aria-labelledby="h-clips"><h2
@@ -515,13 +516,37 @@ ${clips.length ? html`<section class="ovv-section" aria-labelledby="h-clips"><h2
});
});
// ── Discovery: robots.txt, sitemap.xml, llms.txt ────────
// ── 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) => {
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));
return res.type('application/xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(seo.sitemapXml(urls));
});
});
@@ -295,6 +295,9 @@ function countOccurrences(haystack, needle) {
@@ -295,6 +295,9 @@ function countOccurrences(haystack, needle) {
await check('the sitemap lists public items and leaves the unlisted VOD out', async () => {
await check('the sitemap lists public items and leaves the unlisted VOD out', async () => {
const r = await getVideo('/sitemap.xml');
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.strictEqual(r.status, 200);
assert.ok(r.text.includes('https://openvibe.video/v/101'));
assert.ok(r.text.includes('https://openvibe.video/v/101'));
assert.ok(r.text.includes('https://openvibe.video/c/201'));
assert.ok(r.text.includes('https://openvibe.video/c/201'));