openvibers/openvibe.mediahub Commits

SEO-17: an image entry per video in openvibe.video's sitemap (#186)

OP OpenVibers committed 11 days ago · 539617a7ea · 3 files, +18 −2

* SEO-17: an image entry per video in openvibe.video's sitemap

Each <url> that names a <video:video> now also names that video's thumbnail as
<image:image><image:loc> (seo.sitemapXml), built from the thumbnail_loc the
video entry already had, so an image is only there when the URL exists and is
https. Video entries, lastmods and the ten-minute cache are unchanged; entries
with no thumbnail keep no image. openvibe.download's private shares and
openvibe.pics (/i/ is in its robots' disallow) are untouched.

test/video-pages.test.js pins the image on the /v/101 entry and asserts, for
every <url>, that the image is exactly its video thumbnail and that an entry
without a thumbnail carries none.

Co-authored-by: CommandCodeBot <[email protected]>

* Merge remote-tracking branch 'origin/main' into refresh-186

# Conflicts:
#	CHANGELOG.md

* Merge remote-tracking branch 'origin/main' into refresh-186

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex f495aba..d4c2823 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -22,6 +22,7 @@ What changed in OpenVibe.MediaHub, newest first. Each site also publishes its pa
for web players, JSON for programs, CSV for spreadsheets) and the transcript says it was written by AI, and can be
wrong. The menus on a phone open above the app bar, so their buttons are not behind it.
++- openvibe.video's sitemap carries an image entry per video (SEO-17): the same entry that names `<video:video>` also names its thumbnail as `<image:image><image:loc>` — built from the thumbnail the video entry already has, so it is only there when the URL exists and is https. Entries without a thumbnail keep no image, the video entries and the ten-minute cache are unchanged, and openvibe.download's private shares and openvibe.pics (`/i/` is in its robots' disallow) are untouched. `test/video-pages.test.js` pins one image per video entry, the same URL as its `thumbnail_loc`.
- openvibe.video's search basics (2026-10-20). A watch page nobody wrote about gets a description of its own (who,
how long, when, its first words) instead of the site tagline. A recording under two minutes with nothing written
about it is noindex and out of the sitemap: 118 of 785 were under a minute, mostly test streams. The sitemap lists
ddiff --git a/server/video/pages.js b/server/video/pages.js
iindex d14e821..957955f 100644
---- a/server/video/pages.js
++++ b/server/video/pages.js
@@@ -1791,7 +1791,9 @@ ${isOwner ? html`<p class="ovv-fine">You can reorder items with the up and down
...((shelf && shelf.categories) || []).filter((c) => c.count >= 4).map((c) => ({ loc: `${base}/category/${c.slug}` })),
...items.map((it) => {
const video = sitemapVideo(it);
-- return { loc: `${base}${it.kind === 'clip' ? '/c/' : '/v/'}${Number(it.id)}`, lastmod: day(it.created_at), ...(video ? { videos: [video] } : {}) };
++ // The same thumbnail as an <image:image> on the video's own entry (SEO-17): an image entry needs a URL
++ // this site really serves, and sitemapVideo only builds one from a thumbnail that exists and is https.
++ return { loc: `${base}${it.kind === 'clip' ? '/c/' : '/v/'}${Number(it.id)}`, lastmod: day(it.created_at), ...(video ? { videos: [video], images: [video.thumbnail_loc] } : {}) };
}),
...uploaded.map((x) => ({ loc: `${base}/u/${x.id}`, lastmod: day(x.updated_at) })),
...[...channels].map(([u, d]) => ({ loc: `${base}/@${u}`, lastmod: d })),
ddiff --git a/test/video-pages.test.js b/test/video-pages.test.js
iindex bfd781a..3de4cab 100644
---- a/test/video-pages.test.js
++++ b/test/video-pages.test.js
@@@ -571,12 +571,25 @@ function countOccurrences(haystack, needle) {
await check('the sitemap carries a video entry for each listed recording and clip', async () => {
const map = (await getVideo('/sitemap.xml')).text;
assert.ok(map.includes('xmlns:video="http://www.google.com/schemas/sitemap-video/1.1"'));
++ assert.ok(map.includes('xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"'));
assert.ok(!map.includes('<video:live>'), 'recordings and clips are not currently live');
-- assert.ok(map.includes('<loc>https://openvibe.video/v/101</loc><lastmod>2026-10-12</lastmod><video:video><video:thumbnail_loc>https://openvibe.media/t/101.jpg</video:thumbnail_loc>'
++ assert.ok(map.includes('<loc>https://openvibe.video/v/101</loc><lastmod>2026-10-12</lastmod>'
++ + '<image:image><image:loc>https://openvibe.media/t/101.jpg</image:loc></image:image>'
++ + '<video:video><video:thumbnail_loc>https://openvibe.media/t/101.jpg</video:thumbnail_loc>'
+ '<video:title>A public recording</video:title><video:description>A public stream</video:description>'
+ '<video:content_loc>https://openvibe.media/v/101?raw=1</video:content_loc><video:duration>45</video:duration><video:view_count>100</video:view_count>'
+ '<video:publication_date>2026-10-12T10:00:00.000Z</video:publication_date><video:uploader info="https://openvibe.video/@streamer">streamer</video:uploader></video:video></url>'), map);
assert.ok(map.includes('<loc>https://openvibe.video/c/201</loc><lastmod>2026-10-12</lastmod></url>'), 'a clip without a thumbnail has no video entry');
++ // Every entry with a video thumbnail carries that thumbnail as <image:image> on the same entry (SEO-17), and
++ // an entry with nothing to show carries no image either.
++ const entries = [...map.matchAll(/<url>([\s\S]*?)<\/url>/g)].map((m) => m[1]);
++ assert.ok(entries.some((body) => body.includes('<video:thumbnail_loc>')));
++ for (const body of entries) {
++ const thumb = /<video:thumbnail_loc>([^<]+)<\/video:thumbnail_loc>/.exec(body);
++ const image = /<image:image><image:loc>([^<]+)<\/image:loc><\/image:image>/.exec(body);
++ if (thumb) assert.deepStrictEqual(image && image[1], thumb[1], `the image is the video's own thumbnail: ${body.slice(0, 160)}`);
++ else assert.strictEqual(image, null, `no image without a thumbnail: ${body.slice(0, 160)}`);
++ }
});
await check('robots.txt and llms.txt answer on the video host', async () => {