openvibers/openvibe.mediahub Commits
SEO-17: an image entry per video in openvibe.video's sitemap (#186)
Changes
@@@ -22,6 +22,7 @@ What changed in OpenVibe.MediaHub, newest first. Each site also publishes its pafor web players, JSON for programs, CSV for spreadsheets) and the transcript says it was written by AI, and can bewrong. 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 writtenabout it is noindex and out of the sitemap: 118 of 785 were under a minute, mostly test streams. The sitemap lists@@@ -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 })),@@@ -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 () => {
@@ -22,6 +22,7 @@ What changed in OpenVibe.MediaHub, newest first. Each site also publishes its pa
@@ -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
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.
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,
- 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
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
about it is noindex and out of the sitemap: 118 of 785 were under a minute, mostly test streams. The sitemap lists
@@ -1791,7 +1791,9 @@ ${isOwner ? html`<p class="ovv-fine">You can reorder items with the up and down
@@ -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}` })),
...((shelf && shelf.categories) || []).filter((c) => c.count >= 4).map((c) => ({ loc: `${base}/category/${c.slug}` })),
...items.map((it) => {
...items.map((it) => {
const video = sitemapVideo(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) })),
...uploaded.map((x) => ({ loc: `${base}/u/${x.id}`, lastmod: day(x.updated_at) })),
...[...channels].map(([u, d]) => ({ loc: `${base}/@${u}`, lastmod: d })),
...[...channels].map(([u, d]) => ({ loc: `${base}/@${u}`, lastmod: d })),
@@ -571,12 +571,25 @@ function countOccurrences(haystack, needle) {
@@ -571,12 +571,25 @@ function countOccurrences(haystack, needle) {
await check('the sitemap carries a video entry for each listed recording and clip', async () => {
await check('the sitemap carries a video entry for each listed recording and clip', async () => {
const map = (await getVideo('/sitemap.xml')).text;
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: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('<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: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: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);
+ '<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');
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 () => {
await check('robots.txt and llms.txt answer on the video host', async () => {