openvibers/openvibe.media Commits
Sitemaps: every entry carries an honest lastmod (item changes, release time for static pages) (#96)
Changes
@@@ -549,6 +549,8 @@ aggregated and stay queued. The runbook is `docs/cutover-media-billing-readings.| `GET /llms-full.txt` | the same map plus the full text of the index and the newest watch pages Media is the canonical home of (`openvibe-shared/seo.llmsFull`; rendering stops at 512 KB, and any further pages become its "truncated" line) || `GET /live/:sel/frame.jpg` | **live frame API** — near-realtime JPEG frame of an actively-live stream slot, extracted from its in-progress recording. `:sel` = slot id (`1`), slot **slug** (`whip`), or **`@username`** (that streamer's top-viewed live slot; slug/username resolve via the app's `/api/streams` listing, cached 5s). Optional `?w=64..1920` scales the width, `?app=` selects the tenant (default `live`; internal base URLs from `APP_INTERNAL_URLS` JSON env or `LIVE_APP_INTERNAL_URL`). Cached **5s per slot** (that cache is the rate limit), CORS-open for external APIs/bots/dashboards. Not live → **404 with a styled OFFLINE card** (real JPEG bytes — dev pipelines decode the body as image/jpeg) so `<img>` embeds degrade nicely (`?format=json` for JSON errors); `503` + card when live but a frame can't be cut. |++Sitemap entries carry lastmod from the release commit for the index and each watch item's recorded time.++Watch pages (`/v/:id`, `/c/:id` on a browser navigation) offer a player only when the item's[readiness](#readiness) is `playable`; otherwise they say why (still being recorded, still processing,not available) and are `noindex`. They carry a schema.org `VideoObject` when the item is public and@@@ -14,6 +14,7 @@ const express = require('express');const config = require('../config');const db = require('../db/database');const seo = require('openvibe-shared/seo');++const releaseLastmod = require('../sitemap-lastmod');const cache = require('openvibe-shared/cache-policy');const pages = require('./pages');const readiness = require('../objects/readiness');@@@ -192,7 +193,7 @@ router.get('/llms-full.txt', async (req, res) => {});router.get('/sitemap.xml', async (req, res) => {try {-- send(res, 'application/xml', seo.sitemapXml([{ loc: `${config.publicUrl}/`, changefreq: 'hourly' }, ...await sitemapEntries()]));++ send(res, 'application/xml', seo.sitemapXml([{ loc: `${config.publicUrl}/`, lastmod: releaseLastmod, changefreq: 'hourly' }, ...await sitemapEntries()]));} catch (err) {console.error('[Public] sitemap error:', err.message);res.status(500).type('text/plain').send('sitemap unavailable');nnew file mode 100644@@@ -0,0 +1,12 @@++'use strict';++++// The deployed code's commit time. RELEASE_AT is the release manifest's override.++const path = require('path');++const { execFileSync } = require('child_process');++++let value = process.env.RELEASE_AT;++if (!value) {++ try { value = execFileSync('git', ['log', '-1', '--format=%cI'], { cwd: path.join(__dirname, '..'), encoding: 'utf8', timeout: 2000 }).trim(); }++ catch { /* A source archive without release metadata has no defensible lastmod. */ }++}++module.exports = value && !Number.isNaN(Date.parse(value)) ? new Date(value).toISOString() : undefined;@@@ -5,6 +5,17 @@// private/unlisted/sandbox), and every listed page renders `index, follow`; /llms.txt maps the site;// public watch pages carry a VideoObject; AI clips are noindex with no Person author, like on Live.const assert = require('assert');++++function assertSitemapLastmods(xml, tag = 'url') {++ const entries = [...xml.matchAll(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, 'g'))];++ for (const [, body] of entries) {++ const match = /<lastmod>(\d{4}-\d{2}-\d{2})<\/lastmod>/.exec(body);++ assert.ok(match, body);++ assert.strictEqual(new Date(match[1]).toISOString().slice(0, 10), match[1]);++ }++ return entries;++}++const fs = require('fs');const os = require('os');const path = require('path');@@@ -78,6 +89,9 @@ const express = require('express');const locs = [...r.body.matchAll(/<loc>([^<]+)<\/loc>/g)].map(m => m[1]);assert.deepStrictEqual(locs.filter(l => !l.includes('/t/')).sort(), ['https://media.test/', 'https://media.test/c/11', 'https://media.test/v/1'].sort());assert.ok(r.body.includes('<image:loc>https://media.test/t/g1.jpg</image:loc>'), 'thumbnails as image entries');++ assert.strictEqual(assertSitemapLastmods(r.body).length, 3);++ assert.match(r.body, /<loc>https:\/\/media\.test\/v\/1<\/loc><lastmod>2026-09-20<\/lastmod>/);++ assert.match(r.body, /<loc>https:\/\/media\.test\/c\/11<\/loc><lastmod>2026-09-22<\/lastmod>/);for (const never of ['/v/2', '/v/3', '/v/4', '/v/5', '/v/6', '/v/7', '/v/8', '/c/12', '/c/13', '/c/14', '/c/15']) {assert.ok(!locs.includes(`https://media.test${never}`), `${never} is not listed`);}
@@ -549,6 +549,8 @@ aggregated and stay queued. The runbook is `docs/cutover-media-billing-readings.
@@ -549,6 +549,8 @@ aggregated and stay queued. The runbook is `docs/cutover-media-billing-readings.
| `GET /llms-full.txt` | the same map plus the full text of the index and the newest watch pages Media is the canonical home of (`openvibe-shared/seo.llmsFull`; rendering stops at 512 KB, and any further pages become its "truncated" line) |
| `GET /llms-full.txt` | the same map plus the full text of the index and the newest watch pages Media is the canonical home of (`openvibe-shared/seo.llmsFull`; rendering stops at 512 KB, and any further pages become its "truncated" line) |
| `GET /live/:sel/frame.jpg` | **live frame API** — near-realtime JPEG frame of an actively-live stream slot, extracted from its in-progress recording. `:sel` = slot id (`1`), slot **slug** (`whip`), or **`@username`** (that streamer's top-viewed live slot; slug/username resolve via the app's `/api/streams` listing, cached 5s). Optional `?w=64..1920` scales the width, `?app=` selects the tenant (default `live`; internal base URLs from `APP_INTERNAL_URLS` JSON env or `LIVE_APP_INTERNAL_URL`). Cached **5s per slot** (that cache is the rate limit), CORS-open for external APIs/bots/dashboards. Not live → **404 with a styled OFFLINE card** (real JPEG bytes — dev pipelines decode the body as image/jpeg) so `<img>` embeds degrade nicely (`?format=json` for JSON errors); `503` + card when live but a frame can't be cut. |
| `GET /live/:sel/frame.jpg` | **live frame API** — near-realtime JPEG frame of an actively-live stream slot, extracted from its in-progress recording. `:sel` = slot id (`1`), slot **slug** (`whip`), or **`@username`** (that streamer's top-viewed live slot; slug/username resolve via the app's `/api/streams` listing, cached 5s). Optional `?w=64..1920` scales the width, `?app=` selects the tenant (default `live`; internal base URLs from `APP_INTERNAL_URLS` JSON env or `LIVE_APP_INTERNAL_URL`). Cached **5s per slot** (that cache is the rate limit), CORS-open for external APIs/bots/dashboards. Not live → **404 with a styled OFFLINE card** (real JPEG bytes — dev pipelines decode the body as image/jpeg) so `<img>` embeds degrade nicely (`?format=json` for JSON errors); `503` + card when live but a frame can't be cut. |
+Sitemap entries carry lastmod from the release commit for the index and each watch item's recorded time.
+
Watch pages (`/v/:id`, `/c/:id` on a browser navigation) offer a player only when the item's
Watch pages (`/v/:id`, `/c/:id` on a browser navigation) offer a player only when the item's
[readiness](#readiness) is `playable`; otherwise they say why (still being recorded, still processing,
[readiness](#readiness) is `playable`; otherwise they say why (still being recorded, still processing,
not available) and are `noindex`. They carry a schema.org `VideoObject` when the item is public and
not available) and are `noindex`. They carry a schema.org `VideoObject` when the item is public and
@@ -14,6 +14,7 @@ const express = require('express');
@@ -14,6 +14,7 @@ const express = require('express');
const config = require('../config');
const config = require('../config');
const db = require('../db/database');
const db = require('../db/database');
const seo = require('openvibe-shared/seo');
const seo = require('openvibe-shared/seo');
+const releaseLastmod = require('../sitemap-lastmod');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
const pages = require('./pages');
const pages = require('./pages');
const readiness = require('../objects/readiness');
const readiness = require('../objects/readiness');
@@ -192,7 +193,7 @@ router.get('/llms-full.txt', async (req, res) => {
@@ -192,7 +193,7 @@ router.get('/llms-full.txt', async (req, res) => {
});
});
router.get('/sitemap.xml', async (req, res) => {
router.get('/sitemap.xml', async (req, res) => {
try {
try {
- send(res, 'application/xml', seo.sitemapXml([{ loc: `${config.publicUrl}/`, changefreq: 'hourly' }, ...await sitemapEntries()]));
+ send(res, 'application/xml', seo.sitemapXml([{ loc: `${config.publicUrl}/`, lastmod: releaseLastmod, changefreq: 'hourly' }, ...await sitemapEntries()]));
} catch (err) {
} catch (err) {
console.error('[Public] sitemap error:', err.message);
console.error('[Public] sitemap error:', err.message);
res.status(500).type('text/plain').send('sitemap unavailable');
res.status(500).type('text/plain').send('sitemap unavailable');
new file mode 100644
new file mode 100644
@@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
+'use strict';
+
+// The deployed code's commit time. RELEASE_AT is the release manifest's override.
+const path = require('path');
+const { execFileSync } = require('child_process');
+
+let value = process.env.RELEASE_AT;
+if (!value) {
+ try { value = execFileSync('git', ['log', '-1', '--format=%cI'], { cwd: path.join(__dirname, '..'), encoding: 'utf8', timeout: 2000 }).trim(); }
+ catch { /* A source archive without release metadata has no defensible lastmod. */ }
+}
+module.exports = value && !Number.isNaN(Date.parse(value)) ? new Date(value).toISOString() : undefined;
@@ -5,6 +5,17 @@
@@ -5,6 +5,17 @@
// private/unlisted/sandbox), and every listed page renders `index, follow`; /llms.txt maps the site;
// private/unlisted/sandbox), and every listed page renders `index, follow`; /llms.txt maps the site;
// public watch pages carry a VideoObject; AI clips are noindex with no Person author, like on Live.
// public watch pages carry a VideoObject; AI clips are noindex with no Person author, like on Live.
const assert = require('assert');
const assert = require('assert');
+
+function assertSitemapLastmods(xml, tag = 'url') {
+ const entries = [...xml.matchAll(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, 'g'))];
+ for (const [, body] of entries) {
+ const match = /<lastmod>(\d{4}-\d{2}-\d{2})<\/lastmod>/.exec(body);
+ assert.ok(match, body);
+ assert.strictEqual(new Date(match[1]).toISOString().slice(0, 10), match[1]);
+ }
+ return entries;
+}
+
const fs = require('fs');
const fs = require('fs');
const os = require('os');
const os = require('os');
const path = require('path');
const path = require('path');
@@ -78,6 +89,9 @@ const express = require('express');
@@ -78,6 +89,9 @@ const express = require('express');
const locs = [...r.body.matchAll(/<loc>([^<]+)<\/loc>/g)].map(m => m[1]);
const locs = [...r.body.matchAll(/<loc>([^<]+)<\/loc>/g)].map(m => m[1]);
assert.deepStrictEqual(locs.filter(l => !l.includes('/t/')).sort(), ['https://media.test/', 'https://media.test/c/11', 'https://media.test/v/1'].sort());
assert.deepStrictEqual(locs.filter(l => !l.includes('/t/')).sort(), ['https://media.test/', 'https://media.test/c/11', 'https://media.test/v/1'].sort());
assert.ok(r.body.includes('<image:loc>https://media.test/t/g1.jpg</image:loc>'), 'thumbnails as image entries');
assert.ok(r.body.includes('<image:loc>https://media.test/t/g1.jpg</image:loc>'), 'thumbnails as image entries');
+ assert.strictEqual(assertSitemapLastmods(r.body).length, 3);
+ assert.match(r.body, /<loc>https:\/\/media\.test\/v\/1<\/loc><lastmod>2026-09-20<\/lastmod>/);
+ assert.match(r.body, /<loc>https:\/\/media\.test\/c\/11<\/loc><lastmod>2026-09-22<\/lastmod>/);
for (const never of ['/v/2', '/v/3', '/v/4', '/v/5', '/v/6', '/v/7', '/v/8', '/c/12', '/c/13', '/c/14', '/c/15']) {
for (const never of ['/v/2', '/v/3', '/v/4', '/v/5', '/v/6', '/v/7', '/v/8', '/c/12', '/c/13', '/c/14', '/c/15']) {
assert.ok(!locs.includes(`https://media.test${never}`), `${never} is not listed`);
assert.ok(!locs.includes(`https://media.test${never}`), `${never} is not listed`);
}
}