openvibers/openvibe.live Commits
Publish Live's Live now card at /.well-known/openvibe-menu.json (#661)
Changes
@@@ -678,6 +678,9 @@ app.use('/fragments', assets.versionedStatic('/fragments'), express.static(path.require('./web/paste-handover').register(app);require('./web/video-handover').register(app);try { require('./seo').register(app); } catch (e) { console.warn('[SEO] not registered:', e.message); }++// The menu's network cards (frame.menu-card@1): the "Live now" rail every site's Frame can show. Public, CORS *,++// 30 s cache; it reads only the rows GET /api/streams already serves anonymously.++app.get('/.well-known/openvibe-menu.json', require('./menu-card').handler);// The agent layer (openvibe-shared/agent-layer): /.well-known/agent.json, and the Markdown twins of the home,// channel pages and VOD/clip lists. Mounted after the SEO middleware, so a browser keeps the page's own head and// cache (which now names the twin) and only a twin request reaches this renderer; before the static files and thennew file mode 100644@@@ -0,0 +1,96 @@++'use strict';++// The menu's network card for OpenVibe.Live (frame.menu-card@1): GET /.well-known/openvibe-menu.json.++//++// Every open site publishes the cards the network's Frame menu shows for it. Live's is the "Live now" rail:++// the public channels that are live right now, the most watched first, each linking to its channel page. With++// nobody live the card is still published, with no items, and Network skips it. The file is public and CORS *,++// cached for 30 seconds (the card's ttl) so the home page's polling never hits the streams table.++//++// Only what GET /api/streams already shows anonymously is read: getLiveStreams() rows, with the 18+ channels++// the home page leaves out, capped at the schema's six items. No ingest key, channel ZIP or owner field is++// copied into the card — the items are built field by field.++const db = require('./db/database');++const contentLabels = require('./labels');++++const SITE = 'live';++const BASE = 'https://openvibe.live';++const CARD_TTL_S = 30;++const CACHE_MS = CARD_TTL_S * 1000;++++/** Plain words for a card: no markup, no control characters, one line, within the schema's limit. */++function plain(value, max) {++ return String(value == null ? '' : value)++ .replace(/<[^>]*>/g, ' ')++ .replace(/[\u0000-\u001f\u007f]+/g, ' ')++ .replace(/\s+/g, ' ')++ .trim()++ .slice(0, max);++}++++/** The card itself. `rows` are Live's live rows in their own order (viewer_count DESC, started_at DESC). */++function cardFrom(rows, at = Date.now()) {++ const items = (Array.isArray(rows) ? rows : [])++ .filter((s) => s && s.username && !contentLabels.labelsOf(s).labels.includes('18+'))++ .slice(0, 6)++ .map((s) => {++ const viewers = Number(s.viewer_count);++ return {++ title: plain(s.display_name, 80) || plain(s.username, 80) || 'Live',++ meta: `${Number.isFinite(viewers) && viewers > 0 ? Math.round(viewers) : 0} watching`,++ url: `${BASE}/@${encodeURIComponent(String(s.username))}`,++ };++ });++ return {++ id: 'live-now',++ site: SITE,++ title: 'Live now',++ kind: 'rail',++ items,++ more: { label: 'See all', url: `${BASE}/` },++ updated_at: new Date(at).toISOString(),++ ttl_s: CARD_TTL_S,++ };++}++++/**++ * A card cache with the 30 s ttl. `load` and `now` are injectable so the test can run the whole file without a++ * database; the running server uses the defaults.++ */++function createMenuCard({ load = () => db.getLiveStreams(), now = Date.now } = {}) {++ let cached = null;++ let cachedAt = 0;++ let pending = null;++++ async function card() {++ const at = now();++ if (cached && at - cachedAt < CACHE_MS) return cached;++ if (pending) return pending;++ pending = (async () => {++ try {++ cached = cardFrom(await load(), at);++ cachedAt = at;++ } catch {++ // Keep the last good card rather than publish an empty rail on one database hiccup; the next++ // request retries after the cache window.++ if (!cached) {++ cached = cardFrom([], at);++ cachedAt = at;++ }++ }++ return cached;++ })().finally(() => { pending = null; });++ return pending;++ }++++ async function handler(_req, res) {++ const body = await card();++ res.set('Cache-Control', `public, max-age=${CARD_TTL_S}`)++ .set('Access-Control-Allow-Origin', '*')++ .set('Cross-Origin-Resource-Policy', 'cross-origin')++ .json([body]);++ }++++ return { card, handler, cardFrom, cacheMs: CACHE_MS };++}++++module.exports = { ...createMenuCard(), createMenuCard, cardFrom, plain, SITE, CARD_TTL_S };nnew file mode 100644@@@ -0,0 +1,79 @@++'use strict';++// The menu's network card (frame.menu-card@1): GET /.well-known/openvibe-menu.json serves Live's "Live now"++// rail — the public channels live right now, most watched first — with no items when nobody is live, CORS * and++// a 30 s cache. The card never carries a row's ingest key or owner-only fields, and 18+ channels stay off it.++const assert = require('assert');++const { createMenuCard, cardFrom, plain, CARD_TTL_S } = require('../server/menu-card');++++const row = (over = {}) => ({++ id: 1, user_id: 2, username: 'nova', display_name: 'Nova', viewer_count: 12, is_live: 1,++ content_labels: null, ...over,++});++++// ── The card's shape and words ───────────────────────────────────────────++const card = cardFrom([row(), row({ username: 'kitchen', display_name: 'Kitchen radio', viewer_count: 3 })], Date.parse('2026-11-07T08:00:00Z'));++assert.deepStrictEqual(Object.keys(card).sort(), ['id', 'items', 'kind', 'more', 'site', 'title', 'ttl_s', 'updated_at']);++assert.strictEqual(card.id, 'live-now');++assert.strictEqual(card.site, 'live');++assert.strictEqual(card.title, 'Live now');++assert.strictEqual(card.kind, 'rail');++assert.strictEqual(card.ttl_s, 30);++assert.strictEqual(card.updated_at, '2026-11-07T08:00:00.000Z');++assert.deepStrictEqual(card.items, [++ { title: 'Nova', meta: '12 watching', url: 'https://openvibe.live/@nova' },++ { title: 'Kitchen radio', meta: '3 watching', url: 'https://openvibe.live/@kitchen' },++]);++assert.deepStrictEqual(card.more, { label: 'See all', url: 'https://openvibe.live/' });++assert.ok(card.items.length <= 6 && card.title.length <= 60);++for (const item of card.items) assert.ok(item.title.length <= 80 && item.meta.length <= 120 && item.url.startsWith('https://openvibe.live/'));++++// Nobody live: the card is still published, with no items, and Network skips it.++assert.deepStrictEqual(cardFrom([]).items, []);++assert.deepStrictEqual(cardFrom(null).items, []);++++// Only public channels: the 18+ labels the home page hides are left off, and at most six items are sent.++const many = Array.from({ length: 8 }, (_, i) => row({ id: i, username: `user${i}`, display_name: `User ${i}`, viewer_count: 8 - i }));++assert.strictEqual(cardFrom(many).items.length, 6);++assert.deepStrictEqual(cardFrom([row({ content_labels: JSON.stringify({ labels: ['18+'], suggested: [] }) })]).items, []);++++// Text is plain words: markup and control characters are stripped, whitespace collapsed and the title cut to 80.++assert.strictEqual(plain('<b>Nova</b>\n\t live ', 80), 'Nova live');++assert.strictEqual(plain('x'.repeat(200), 80).length, 80);++assert.strictEqual(cardFrom([row({ display_name: '<img src=x onerror=alert(1)>Nova' })]).items[0].title, 'Nova');++assert.strictEqual(cardFrom([row({ display_name: ' ', username: 'quiet' })]).items[0].title, 'quiet');++++// ── The 30 s cache and the route's headers ──────────────────────────────++(async () => {++ let t = 1000;++ let loads = 0;++ const menu = createMenuCard({ load: async () => { loads++; return [row()]; }, now: () => t });++ const first = await menu.card();++ assert.strictEqual(loads, 1);++ assert.strictEqual(await menu.card(), first);++ assert.strictEqual(loads, 1, 'a second request inside the ttl is served from the cache');++ t += CARD_TTL_S * 1000 + 1;++ await menu.card();++ assert.strictEqual(loads, 2, 'after the ttl the card is rebuilt');++++ const headers = {};++ let body = null;++ const res = {++ set(k, v) { headers[k] = v; return this; },++ json(v) { body = v; return this; },++ };++ await menu.handler({}, res);++ assert.strictEqual(headers['Access-Control-Allow-Origin'], '*');++ assert.strictEqual(headers['Cache-Control'], 'public, max-age=30');++ assert.ok(Array.isArray(body) && body.length === 1, 'the file is an array of cards');++ assert.strictEqual(body[0].site, 'live');++++ // A failing database keeps the last good card instead of publishing an empty rail.++ let fail = false;++ const flaky = createMenuCard({ load: async () => { if (fail) throw new Error('db down'); return [row()]; }, now: () => t });++ const good = await flaky.card();++ fail = true;++ t += CARD_TTL_S * 1000 + 1;++ assert.strictEqual(await flaky.card(), good);++++ console.log('menu card: shape, empty rail, 18+ filter, plain text, 30 s cache, CORS and last-good card');++})().catch((err) => { console.error(err); process.exit(1); });
@@ -678,6 +678,9 @@ app.use('/fragments', assets.versionedStatic('/fragments'), express.static(path.
@@ -678,6 +678,9 @@ app.use('/fragments', assets.versionedStatic('/fragments'), express.static(path.
require('./web/paste-handover').register(app);
require('./web/paste-handover').register(app);
require('./web/video-handover').register(app);
require('./web/video-handover').register(app);
try { require('./seo').register(app); } catch (e) { console.warn('[SEO] not registered:', e.message); }
try { require('./seo').register(app); } catch (e) { console.warn('[SEO] not registered:', e.message); }
+// The menu's network cards (frame.menu-card@1): the "Live now" rail every site's Frame can show. Public, CORS *,
+// 30 s cache; it reads only the rows GET /api/streams already serves anonymously.
+app.get('/.well-known/openvibe-menu.json', require('./menu-card').handler);
// The agent layer (openvibe-shared/agent-layer): /.well-known/agent.json, and the Markdown twins of the home,
// The agent layer (openvibe-shared/agent-layer): /.well-known/agent.json, and the Markdown twins of the home,
// channel pages and VOD/clip lists. Mounted after the SEO middleware, so a browser keeps the page's own head and
// channel pages and VOD/clip lists. Mounted after the SEO middleware, so a browser keeps the page's own head and
// cache (which now names the twin) and only a twin request reaches this renderer; before the static files and the
// cache (which now names the twin) and only a twin request reaches this renderer; before the static files and the
new file mode 100644
new file mode 100644
@@ -0,0 +1,96 @@
@@ -0,0 +1,96 @@
+'use strict';
+// The menu's network card for OpenVibe.Live (frame.menu-card@1): GET /.well-known/openvibe-menu.json.
+//
+// Every open site publishes the cards the network's Frame menu shows for it. Live's is the "Live now" rail:
+// the public channels that are live right now, the most watched first, each linking to its channel page. With
+// nobody live the card is still published, with no items, and Network skips it. The file is public and CORS *,
+// cached for 30 seconds (the card's ttl) so the home page's polling never hits the streams table.
+//
+// Only what GET /api/streams already shows anonymously is read: getLiveStreams() rows, with the 18+ channels
+// the home page leaves out, capped at the schema's six items. No ingest key, channel ZIP or owner field is
+// copied into the card — the items are built field by field.
+const db = require('./db/database');
+const contentLabels = require('./labels');
+
+const SITE = 'live';
+const BASE = 'https://openvibe.live';
+const CARD_TTL_S = 30;
+const CACHE_MS = CARD_TTL_S * 1000;
+
+/** Plain words for a card: no markup, no control characters, one line, within the schema's limit. */
+function plain(value, max) {
+ return String(value == null ? '' : value)
+ .replace(/<[^>]*>/g, ' ')
+ .replace(/[\u0000-\u001f\u007f]+/g, ' ')
+ .replace(/\s+/g, ' ')
+ .trim()
+ .slice(0, max);
+}
+
+/** The card itself. `rows` are Live's live rows in their own order (viewer_count DESC, started_at DESC). */
+function cardFrom(rows, at = Date.now()) {
+ const items = (Array.isArray(rows) ? rows : [])
+ .filter((s) => s && s.username && !contentLabels.labelsOf(s).labels.includes('18+'))
+ .slice(0, 6)
+ .map((s) => {
+ const viewers = Number(s.viewer_count);
+ return {
+ title: plain(s.display_name, 80) || plain(s.username, 80) || 'Live',
+ meta: `${Number.isFinite(viewers) && viewers > 0 ? Math.round(viewers) : 0} watching`,
+ url: `${BASE}/@${encodeURIComponent(String(s.username))}`,
+ };
+ });
+ return {
+ id: 'live-now',
+ site: SITE,
+ title: 'Live now',
+ kind: 'rail',
+ items,
+ more: { label: 'See all', url: `${BASE}/` },
+ updated_at: new Date(at).toISOString(),
+ ttl_s: CARD_TTL_S,
+ };
+}
+
+/**
+ * A card cache with the 30 s ttl. `load` and `now` are injectable so the test can run the whole file without a
+ * database; the running server uses the defaults.
+ */
+function createMenuCard({ load = () => db.getLiveStreams(), now = Date.now } = {}) {
+ let cached = null;
+ let cachedAt = 0;
+ let pending = null;
+
+ async function card() {
+ const at = now();
+ if (cached && at - cachedAt < CACHE_MS) return cached;
+ if (pending) return pending;
+ pending = (async () => {
+ try {
+ cached = cardFrom(await load(), at);
+ cachedAt = at;
+ } catch {
+ // Keep the last good card rather than publish an empty rail on one database hiccup; the next
+ // request retries after the cache window.
+ if (!cached) {
+ cached = cardFrom([], at);
+ cachedAt = at;
+ }
+ }
+ return cached;
+ })().finally(() => { pending = null; });
+ return pending;
+ }
+
+ async function handler(_req, res) {
+ const body = await card();
+ res.set('Cache-Control', `public, max-age=${CARD_TTL_S}`)
+ .set('Access-Control-Allow-Origin', '*')
+ .set('Cross-Origin-Resource-Policy', 'cross-origin')
+ .json([body]);
+ }
+
+ return { card, handler, cardFrom, cacheMs: CACHE_MS };
+}
+
+module.exports = { ...createMenuCard(), createMenuCard, cardFrom, plain, SITE, CARD_TTL_S };
new file mode 100644
new file mode 100644
@@ -0,0 +1,79 @@
@@ -0,0 +1,79 @@
+'use strict';
+// The menu's network card (frame.menu-card@1): GET /.well-known/openvibe-menu.json serves Live's "Live now"
+// rail — the public channels live right now, most watched first — with no items when nobody is live, CORS * and
+// a 30 s cache. The card never carries a row's ingest key or owner-only fields, and 18+ channels stay off it.
+const assert = require('assert');
+const { createMenuCard, cardFrom, plain, CARD_TTL_S } = require('../server/menu-card');
+
+const row = (over = {}) => ({
+ id: 1, user_id: 2, username: 'nova', display_name: 'Nova', viewer_count: 12, is_live: 1,
+ content_labels: null, ...over,
+});
+
+// ── The card's shape and words ───────────────────────────────────────────
+const card = cardFrom([row(), row({ username: 'kitchen', display_name: 'Kitchen radio', viewer_count: 3 })], Date.parse('2026-11-07T08:00:00Z'));
+assert.deepStrictEqual(Object.keys(card).sort(), ['id', 'items', 'kind', 'more', 'site', 'title', 'ttl_s', 'updated_at']);
+assert.strictEqual(card.id, 'live-now');
+assert.strictEqual(card.site, 'live');
+assert.strictEqual(card.title, 'Live now');
+assert.strictEqual(card.kind, 'rail');
+assert.strictEqual(card.ttl_s, 30);
+assert.strictEqual(card.updated_at, '2026-11-07T08:00:00.000Z');
+assert.deepStrictEqual(card.items, [
+ { title: 'Nova', meta: '12 watching', url: 'https://openvibe.live/@nova' },
+ { title: 'Kitchen radio', meta: '3 watching', url: 'https://openvibe.live/@kitchen' },
+]);
+assert.deepStrictEqual(card.more, { label: 'See all', url: 'https://openvibe.live/' });
+assert.ok(card.items.length <= 6 && card.title.length <= 60);
+for (const item of card.items) assert.ok(item.title.length <= 80 && item.meta.length <= 120 && item.url.startsWith('https://openvibe.live/'));
+
+// Nobody live: the card is still published, with no items, and Network skips it.
+assert.deepStrictEqual(cardFrom([]).items, []);
+assert.deepStrictEqual(cardFrom(null).items, []);
+
+// Only public channels: the 18+ labels the home page hides are left off, and at most six items are sent.
+const many = Array.from({ length: 8 }, (_, i) => row({ id: i, username: `user${i}`, display_name: `User ${i}`, viewer_count: 8 - i }));
+assert.strictEqual(cardFrom(many).items.length, 6);
+assert.deepStrictEqual(cardFrom([row({ content_labels: JSON.stringify({ labels: ['18+'], suggested: [] }) })]).items, []);
+
+// Text is plain words: markup and control characters are stripped, whitespace collapsed and the title cut to 80.
+assert.strictEqual(plain('<b>Nova</b>\n\t live ', 80), 'Nova live');
+assert.strictEqual(plain('x'.repeat(200), 80).length, 80);
+assert.strictEqual(cardFrom([row({ display_name: '<img src=x onerror=alert(1)>Nova' })]).items[0].title, 'Nova');
+assert.strictEqual(cardFrom([row({ display_name: ' ', username: 'quiet' })]).items[0].title, 'quiet');
+
+// ── The 30 s cache and the route's headers ──────────────────────────────
+(async () => {
+ let t = 1000;
+ let loads = 0;
+ const menu = createMenuCard({ load: async () => { loads++; return [row()]; }, now: () => t });
+ const first = await menu.card();
+ assert.strictEqual(loads, 1);
+ assert.strictEqual(await menu.card(), first);
+ assert.strictEqual(loads, 1, 'a second request inside the ttl is served from the cache');
+ t += CARD_TTL_S * 1000 + 1;
+ await menu.card();
+ assert.strictEqual(loads, 2, 'after the ttl the card is rebuilt');
+
+ const headers = {};
+ let body = null;
+ const res = {
+ set(k, v) { headers[k] = v; return this; },
+ json(v) { body = v; return this; },
+ };
+ await menu.handler({}, res);
+ assert.strictEqual(headers['Access-Control-Allow-Origin'], '*');
+ assert.strictEqual(headers['Cache-Control'], 'public, max-age=30');
+ assert.ok(Array.isArray(body) && body.length === 1, 'the file is an array of cards');
+ assert.strictEqual(body[0].site, 'live');
+
+ // A failing database keeps the last good card instead of publishing an empty rail.
+ let fail = false;
+ const flaky = createMenuCard({ load: async () => { if (fail) throw new Error('db down'); return [row()]; }, now: () => t });
+ const good = await flaky.card();
+ fail = true;
+ t += CARD_TTL_S * 1000 + 1;
+ assert.strictEqual(await flaky.card(), good);
+
+ console.log('menu card: shape, empty rail, 18+ filter, plain text, 30 s cache, CORS and last-good card');
+})().catch((err) => { console.error(err); process.exit(1); });