openvibers/openvibe.live Commits
Follow-ups from running the pages in a browser: the sitemap names channel pages from Live's accounts (Media VOD and clip rows carry user ids, not usernames, so /@user pages were never listed; banned accounts stay out); the channel page sets its tab title to the channel (the client reset it to the site default on boot, replacing the server-rendered title, including for crawlers that run scripts); the clip page's 'AI clip · from <streamer>'s stream' / 'Clipped by' line sits right under the title instead of below the source box; the client's default tab title and the home tour's 'Free restream' chip drop 'free' (owner copy rule); server-rendered durations read h:mm:ss from an hour up. test/seo-ssr.test.js checks the sitemap's channel pages and the duration format.
Changes
@@@ -52,10 +52,11 @@<span class="muted"><i class="fa-solid fa-eye"></i> <span id="clp-views">0</span></span><span id="clp-protocol"></span></div>++ <!-- Who made it, right under the title: "Clipped by <clipper>", or "AI clip · from <streamer>'s stream". -->++ <div id="clp-clipped-by" class="muted" style="margin-top:4px"></div><div id="clp-extra-details" class="vod-extra-details" style="display:none"></div><p id="clp-description" class="vod-player-description"></p><div id="clp-stream-source" class="vod-source-info muted" style="display:none"></div>-- <div id="clp-clipped-by" class="muted" style="margin-top:4px"></div><div id="clp-actions" style="margin-top:10px;display:none"></div></div>@@@ -591,6 +591,9 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionIconst data = await api(`/streams/channel/${username}?vodLimit=${CHANNEL_VODS_PAGE_SIZE}&vodOffset=${channelVodOffset}&clipLimit=${CHANNEL_CLIPS_PAGE_SIZE}&clipOffset=${channelClipOffset}${initialVodExtra}`);const ch = data.channel;++ // The tab says whose channel this is, as the server-rendered <title> did (a live stream++ // replaces it with the stream's title in activateChannelStream).++ if (ch && ch.username) setPageTitle(`${ch.display_name || ch.username} (@${ch.username})`);if (typeof applyChatLimits === 'function') applyChatLimits(ch && ch.chat_limits);if (typeof setChatLimitsContext === 'function') {const _canManageChat = !!(currentUser && ch && (ch.user_id === currentUser.id || currentUser.role === 'admin' || ch.viewer_can_edit_about));@@@ -962,7 +962,7 @@ function navigate(urlPath, replace = false) {routeFromURL();}--const DEFAULT_PAGE_TITLE = 'OpenVibe.Live — Free Open Source Live Streaming Platform';++const DEFAULT_PAGE_TITLE = 'OpenVibe.Live — Open-Source Live Streaming, Community Run';/** Set the browser tab title to the content being viewed (stream/VOD/clip/paste). */function setPageTitle(name) {document.title = name ? `${String(name).slice(0, 90)} · OpenVibe.Live` : DEFAULT_PAGE_TITLE;@@@ -29,7 +29,7 @@];const FEATURES = [['fa-gauge-high', 'Sub-second WebRTC'], ['fa-face-grin-squint-tears', '7TV / BTTV / FFZ emotes'], ['fa-volume-high', 'Sound commands'],-- ['fa-gamepad', 'Robot & hardware controls'], ['fa-satellite-dish', 'Free restream, all at once'], ['fa-scissors', 'VODs, clips, AI moments'],++ ['fa-gamepad', 'Robot & hardware controls'], ['fa-satellite-dish', 'Restream everywhere at once'], ['fa-scissors', 'VODs, clips, AI moments'],['fa-comments', 'One chat from every platform'], ['fa-language', 'Auto-translated chat'], ['fa-microphone-lines', 'The Arena: mic-judged beefs'],['fa-clipboard-list', 'After-show reports'], ['fa-brands fa-github', '100% open source'],];@@@ -421,7 +421,14 @@ function _mediaSection(heading, items) {}).join('');return `<section><h2>${esc(heading)}</h2><ul>${li}</ul></section>`;}--function _fmtDur(sec) { sec = Math.floor(Number(sec) || 0); const m = Math.floor(sec / 60), s = sec % 60; return sec ? `${m}:${String(s).padStart(2, '0')}` : ''; }++// m:ss, or h:mm:ss from an hour up; '' for nothing.++function _fmtDur(sec) {++ sec = Math.floor(Number(sec) || 0);++ if (!sec) return '';++ const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;++ const ss = String(s).padStart(2, '0');++ return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`;++}async function _homeMeta() {const title = 'OpenVibe.Live — Open-Source Live Streaming, Community Run';@@@ -1008,6 +1015,19 @@ function _urlTag(loc, lastmod, changefreq, priority) {async function buildSitemap() {const urls = [];const seenChannels = new Set();++ // Media rows carry Live user ids, not names: a channel is named from Live's accounts (banned++ // accounts are left out).++ const names = new Map();++ const channelOf = (row, id) => {++ if (row.username) return row.username;++ if (id == null) return null;++ if (!names.has(id)) {++ let u = null;++ try { u = db.getUserById(Number(id)); } catch { u = null; }++ names.set(id, u && !(u.is_banned === 1 || u.is_banned === true) ? u.username : null);++ }++ return names.get(id);++ };const statics = [['/', 'daily', '1.0'], ['/content', 'hourly', '0.9'], ['/moments', 'hourly', '0.7'], ['/vods', 'hourly', '0.8'], ['/clips', 'hourly', '0.8'], ['/pastes', 'hourly', '0.7'], ['/chat', 'daily', '0.5'], ['/arena', 'hourly', '0.6']];// Rendered docs (server/docs/routes.js): /docs is the index (README.md), the rest by file name.try {@@@ -1029,7 +1049,8 @@ async function buildSitemap() {};await page((l, o) => media.listVods({ limit: l, offset: o }).then(r => r?.vods || []), 200, SITEMAP_CAP, (v) => {urls.push(_urlTag(`/vod/${v.id}`, isoDate(v.created_at), 'weekly', '0.6'));-- if (v.username) seenChannels.add(v.username);++ const ch = channelOf(v, v.user_id);++ if (ch) seenChannels.add(ch);});// People's clips only: AI Moments are noindex and never listed (the /moments// collection above is their indexable form). The filter is checked again per row, so an@@@ -1037,7 +1058,8 @@ async function buildSitemap() {await page((l, o) => media.listClips({ limit: l, offset: o, auto_generated: 0 }).then(r => r?.clips || []), 200, SITEMAP_CAP, (c) => {if (isAiClip(c)) return;urls.push(_urlTag(`/clip/${c.id}`, isoDate(c.created_at), 'weekly', '0.6'));-- if (c.username) seenChannels.add(c.username);++ const ch = channelOf({}, c.channel_user_id != null ? c.channel_user_id : c.user_id);++ if (ch) seenChannels.add(ch);});// No /p/ here: pastes are OpenVibe.Community's, and its sitemap lists them under their canonical URL.for (const u of seenChannels) if (u) urls.push(_urlTag(`/@${u}`, null, 'daily', '0.6'));@@@ -152,6 +152,7 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {assert.ok(b.includes('I stream woodworking.'));for (let id = 100; id < 112; id++) assert.ok(b.includes(`https://openvibe.live/vod/${id}"`), `VOD ${id} listed`);assert.ok(!b.includes('/vod/112"'), 'page 1 holds 12 videos');++ assert.ok(b.includes('Build night 100</a> — 1:00:00 · '), 'an hour-long video reads 1:00:00');assert.ok(!b.includes('SECRET VOD'), 'no private VOD');assert.ok(b.includes('<a rel="next" href="https://openvibe.live/@alice?page=2">Older videos</a>'), 'next page link');assert.ok(b.includes('Page 1 of 3'));@@@ -292,6 +293,13 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {assert.ok(!/\$0\b/.test(meta));});++ await check('the sitemap lists the channel pages, named from Live\'s accounts (Media rows carry ids only)', async () => {++ const xml = await seo.buildSitemap();++ assert.ok(xml.includes('<loc>https://openvibe.live/@alice</loc>'), 'the channel with videos');++ assert.ok(xml.includes('<loc>https://openvibe.live/vod/100</loc>'));++ assert.ok(!xml.includes('/vod/199<'), 'no private VOD');++ });++await check('server/index.js sends the fallback shell through seo.shellHtml', () => {const src = fs.readFileSync(path.join(__dirname, '../server/index.js'), 'utf8');assert.ok(src.includes("require('./seo/seo').shellHtml"));
@@ -52,10 +52,11 @@
@@ -52,10 +52,11 @@
<span class="muted"><i class="fa-solid fa-eye"></i> <span id="clp-views">0</span></span>
<span class="muted"><i class="fa-solid fa-eye"></i> <span id="clp-views">0</span></span>
<span id="clp-protocol"></span>
<span id="clp-protocol"></span>
</div>
</div>
+ <!-- Who made it, right under the title: "Clipped by <clipper>", or "AI clip · from <streamer>'s stream". -->
+ <div id="clp-clipped-by" class="muted" style="margin-top:4px"></div>
<div id="clp-extra-details" class="vod-extra-details" style="display:none"></div>
<div id="clp-extra-details" class="vod-extra-details" style="display:none"></div>
<p id="clp-description" class="vod-player-description"></p>
<p id="clp-description" class="vod-player-description"></p>
<div id="clp-stream-source" class="vod-source-info muted" style="display:none"></div>
<div id="clp-stream-source" class="vod-source-info muted" style="display:none"></div>
- <div id="clp-clipped-by" class="muted" style="margin-top:4px"></div>
<div id="clp-actions" style="margin-top:10px;display:none"></div>
<div id="clp-actions" style="margin-top:10px;display:none"></div>
</div>
</div>
@@ -591,6 +591,9 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
@@ -591,6 +591,9 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
const data = await api(`/streams/channel/${username}?vodLimit=${CHANNEL_VODS_PAGE_SIZE}&vodOffset=${channelVodOffset}&clipLimit=${CHANNEL_CLIPS_PAGE_SIZE}&clipOffset=${channelClipOffset}${initialVodExtra}`);
const data = await api(`/streams/channel/${username}?vodLimit=${CHANNEL_VODS_PAGE_SIZE}&vodOffset=${channelVodOffset}&clipLimit=${CHANNEL_CLIPS_PAGE_SIZE}&clipOffset=${channelClipOffset}${initialVodExtra}`);
const ch = data.channel;
const ch = data.channel;
+ // The tab says whose channel this is, as the server-rendered <title> did (a live stream
+ // replaces it with the stream's title in activateChannelStream).
+ if (ch && ch.username) setPageTitle(`${ch.display_name || ch.username} (@${ch.username})`);
if (typeof applyChatLimits === 'function') applyChatLimits(ch && ch.chat_limits);
if (typeof applyChatLimits === 'function') applyChatLimits(ch && ch.chat_limits);
if (typeof setChatLimitsContext === 'function') {
if (typeof setChatLimitsContext === 'function') {
const _canManageChat = !!(currentUser && ch && (ch.user_id === currentUser.id || currentUser.role === 'admin' || ch.viewer_can_edit_about));
const _canManageChat = !!(currentUser && ch && (ch.user_id === currentUser.id || currentUser.role === 'admin' || ch.viewer_can_edit_about));
@@ -962,7 +962,7 @@ function navigate(urlPath, replace = false) {
@@ -962,7 +962,7 @@ function navigate(urlPath, replace = false) {
routeFromURL();
routeFromURL();
}
}
-const DEFAULT_PAGE_TITLE = 'OpenVibe.Live — Free Open Source Live Streaming Platform';
+const DEFAULT_PAGE_TITLE = 'OpenVibe.Live — Open-Source Live Streaming, Community Run';
/** Set the browser tab title to the content being viewed (stream/VOD/clip/paste). */
/** Set the browser tab title to the content being viewed (stream/VOD/clip/paste). */
function setPageTitle(name) {
function setPageTitle(name) {
document.title = name ? `${String(name).slice(0, 90)} · OpenVibe.Live` : DEFAULT_PAGE_TITLE;
document.title = name ? `${String(name).slice(0, 90)} · OpenVibe.Live` : DEFAULT_PAGE_TITLE;
@@ -29,7 +29,7 @@
@@ -29,7 +29,7 @@
];
];
const FEATURES = [
const FEATURES = [
['fa-gauge-high', 'Sub-second WebRTC'], ['fa-face-grin-squint-tears', '7TV / BTTV / FFZ emotes'], ['fa-volume-high', 'Sound commands'],
['fa-gauge-high', 'Sub-second WebRTC'], ['fa-face-grin-squint-tears', '7TV / BTTV / FFZ emotes'], ['fa-volume-high', 'Sound commands'],
- ['fa-gamepad', 'Robot & hardware controls'], ['fa-satellite-dish', 'Free restream, all at once'], ['fa-scissors', 'VODs, clips, AI moments'],
+ ['fa-gamepad', 'Robot & hardware controls'], ['fa-satellite-dish', 'Restream everywhere at once'], ['fa-scissors', 'VODs, clips, AI moments'],
['fa-comments', 'One chat from every platform'], ['fa-language', 'Auto-translated chat'], ['fa-microphone-lines', 'The Arena: mic-judged beefs'],
['fa-comments', 'One chat from every platform'], ['fa-language', 'Auto-translated chat'], ['fa-microphone-lines', 'The Arena: mic-judged beefs'],
['fa-clipboard-list', 'After-show reports'], ['fa-brands fa-github', '100% open source'],
['fa-clipboard-list', 'After-show reports'], ['fa-brands fa-github', '100% open source'],
];
];
@@ -421,7 +421,14 @@ function _mediaSection(heading, items) {
@@ -421,7 +421,14 @@ function _mediaSection(heading, items) {
}).join('');
}).join('');
return `<section><h2>${esc(heading)}</h2><ul>${li}</ul></section>`;
return `<section><h2>${esc(heading)}</h2><ul>${li}</ul></section>`;
}
}
-function _fmtDur(sec) { sec = Math.floor(Number(sec) || 0); const m = Math.floor(sec / 60), s = sec % 60; return sec ? `${m}:${String(s).padStart(2, '0')}` : ''; }
+// m:ss, or h:mm:ss from an hour up; '' for nothing.
+function _fmtDur(sec) {
+ sec = Math.floor(Number(sec) || 0);
+ if (!sec) return '';
+ const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;
+ const ss = String(s).padStart(2, '0');
+ return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`;
+}
async function _homeMeta() {
async function _homeMeta() {
const title = 'OpenVibe.Live — Open-Source Live Streaming, Community Run';
const title = 'OpenVibe.Live — Open-Source Live Streaming, Community Run';
@@ -1008,6 +1015,19 @@ function _urlTag(loc, lastmod, changefreq, priority) {
@@ -1008,6 +1015,19 @@ function _urlTag(loc, lastmod, changefreq, priority) {
async function buildSitemap() {
async function buildSitemap() {
const urls = [];
const urls = [];
const seenChannels = new Set();
const seenChannels = new Set();
+ // Media rows carry Live user ids, not names: a channel is named from Live's accounts (banned
+ // accounts are left out).
+ const names = new Map();
+ const channelOf = (row, id) => {
+ if (row.username) return row.username;
+ if (id == null) return null;
+ if (!names.has(id)) {
+ let u = null;
+ try { u = db.getUserById(Number(id)); } catch { u = null; }
+ names.set(id, u && !(u.is_banned === 1 || u.is_banned === true) ? u.username : null);
+ }
+ return names.get(id);
+ };
const statics = [['/', 'daily', '1.0'], ['/content', 'hourly', '0.9'], ['/moments', 'hourly', '0.7'], ['/vods', 'hourly', '0.8'], ['/clips', 'hourly', '0.8'], ['/pastes', 'hourly', '0.7'], ['/chat', 'daily', '0.5'], ['/arena', 'hourly', '0.6']];
const statics = [['/', 'daily', '1.0'], ['/content', 'hourly', '0.9'], ['/moments', 'hourly', '0.7'], ['/vods', 'hourly', '0.8'], ['/clips', 'hourly', '0.8'], ['/pastes', 'hourly', '0.7'], ['/chat', 'daily', '0.5'], ['/arena', 'hourly', '0.6']];
// Rendered docs (server/docs/routes.js): /docs is the index (README.md), the rest by file name.
// Rendered docs (server/docs/routes.js): /docs is the index (README.md), the rest by file name.
try {
try {
@@ -1029,7 +1049,8 @@ async function buildSitemap() {
@@ -1029,7 +1049,8 @@ async function buildSitemap() {
};
};
await page((l, o) => media.listVods({ limit: l, offset: o }).then(r => r?.vods || []), 200, SITEMAP_CAP, (v) => {
await page((l, o) => media.listVods({ limit: l, offset: o }).then(r => r?.vods || []), 200, SITEMAP_CAP, (v) => {
urls.push(_urlTag(`/vod/${v.id}`, isoDate(v.created_at), 'weekly', '0.6'));
urls.push(_urlTag(`/vod/${v.id}`, isoDate(v.created_at), 'weekly', '0.6'));
- if (v.username) seenChannels.add(v.username);
+ const ch = channelOf(v, v.user_id);
+ if (ch) seenChannels.add(ch);
});
});
// People's clips only: AI Moments are noindex and never listed (the /moments
// People's clips only: AI Moments are noindex and never listed (the /moments
// collection above is their indexable form). The filter is checked again per row, so an
// collection above is their indexable form). The filter is checked again per row, so an
@@ -1037,7 +1058,8 @@ async function buildSitemap() {
@@ -1037,7 +1058,8 @@ async function buildSitemap() {
await page((l, o) => media.listClips({ limit: l, offset: o, auto_generated: 0 }).then(r => r?.clips || []), 200, SITEMAP_CAP, (c) => {
await page((l, o) => media.listClips({ limit: l, offset: o, auto_generated: 0 }).then(r => r?.clips || []), 200, SITEMAP_CAP, (c) => {
if (isAiClip(c)) return;
if (isAiClip(c)) return;
urls.push(_urlTag(`/clip/${c.id}`, isoDate(c.created_at), 'weekly', '0.6'));
urls.push(_urlTag(`/clip/${c.id}`, isoDate(c.created_at), 'weekly', '0.6'));
- if (c.username) seenChannels.add(c.username);
+ const ch = channelOf({}, c.channel_user_id != null ? c.channel_user_id : c.user_id);
+ if (ch) seenChannels.add(ch);
});
});
// 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.
for (const u of seenChannels) if (u) urls.push(_urlTag(`/@${u}`, null, 'daily', '0.6'));
for (const u of seenChannels) if (u) urls.push(_urlTag(`/@${u}`, null, 'daily', '0.6'));
@@ -152,6 +152,7 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {
@@ -152,6 +152,7 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {
assert.ok(b.includes('I stream woodworking.'));
assert.ok(b.includes('I stream woodworking.'));
for (let id = 100; id < 112; id++) assert.ok(b.includes(`https://openvibe.live/vod/${id}"`), `VOD ${id} listed`);
for (let id = 100; id < 112; id++) assert.ok(b.includes(`https://openvibe.live/vod/${id}"`), `VOD ${id} listed`);
assert.ok(!b.includes('/vod/112"'), 'page 1 holds 12 videos');
assert.ok(!b.includes('/vod/112"'), 'page 1 holds 12 videos');
+ assert.ok(b.includes('Build night 100</a> — 1:00:00 · '), 'an hour-long video reads 1:00:00');
assert.ok(!b.includes('SECRET VOD'), 'no private VOD');
assert.ok(!b.includes('SECRET VOD'), 'no private VOD');
assert.ok(b.includes('<a rel="next" href="https://openvibe.live/@alice?page=2">Older videos</a>'), 'next page link');
assert.ok(b.includes('<a rel="next" href="https://openvibe.live/@alice?page=2">Older videos</a>'), 'next page link');
assert.ok(b.includes('Page 1 of 3'));
assert.ok(b.includes('Page 1 of 3'));
@@ -292,6 +293,13 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {
@@ -292,6 +293,13 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {
assert.ok(!/\$0\b/.test(meta));
assert.ok(!/\$0\b/.test(meta));
});
});
+ await check('the sitemap lists the channel pages, named from Live\'s accounts (Media rows carry ids only)', async () => {
+ const xml = await seo.buildSitemap();
+ assert.ok(xml.includes('<loc>https://openvibe.live/@alice</loc>'), 'the channel with videos');
+ assert.ok(xml.includes('<loc>https://openvibe.live/vod/100</loc>'));
+ assert.ok(!xml.includes('/vod/199<'), 'no private VOD');
+ });
+
await check('server/index.js sends the fallback shell through seo.shellHtml', () => {
await check('server/index.js sends the fallback shell through seo.shellHtml', () => {
const src = fs.readFileSync(path.join(__dirname, '../server/index.js'), 'utf8');
const src = fs.readFileSync(path.join(__dirname, '../server/index.js'), 'utf8');
assert.ok(src.includes("require('./seo/seo').shellHtml"));
assert.ok(src.includes("require('./seo/seo').shellHtml"));