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.

OP OpenVibers committed 24 Sept 2026 · 66a2b1b30f · 6 files, +40 −6


Changes

ddiff --git a/public/fragments/clip-player.html b/public/fragments/clip-player.html
iindex 697acd7d..3b4a88a0 100644
---- a/public/fragments/clip-player.html
++++ b/public/fragments/clip-player.html
@@@ -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>
ddiff --git a/public/js/app-channel.js b/public/js/app-channel.js
iindex 65adf916..0b97cd7b 100644
---- a/public/js/app-channel.js
++++ b/public/js/app-channel.js
@@@ -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 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));
ddiff --git a/public/js/app.js b/public/js/app.js
iindex 86a5f39b..a528cda9 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -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;
ddiff --git a/public/js/home-tour.js b/public/js/home-tour.js
iindex 1b60810d..e8bdee55 100644
---- a/public/js/home-tour.js
++++ b/public/js/home-tour.js
@@@ -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'],
];
ddiff --git a/server/seo/seo.js b/server/seo/seo.js
iindex 9e7f3ad9..52cfe9e5 100644
---- a/server/seo/seo.js
++++ b/server/seo/seo.js
@@@ -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'));
ddiff --git a/test/seo-ssr.test.js b/test/seo-ssr.test.js
iindex 8043e714..e7bc7f2e 100644
---- a/test/seo-ssr.test.js
++++ b/test/seo-ssr.test.js
@@@ -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"));