openvibers/openvibe.live Commits

Publish Live's Live now card at /.well-known/openvibe-menu.json (#661)

OP OpenVibers committed 6 days ago · fe48472d46 · 3 files, +178 −0

* Publish Live's Live now card at /.well-known/openvibe-menu.json

* Point Live now's See all link at the home page

* Publish the Live now card as a frame.menu-cards array

Changes

ddiff --git a/server/index.js b/server/index.js
iindex 30bd1705..8c7170e9 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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 the
ddiff --git a/server/menu-card.js b/server/menu-card.js
nnew file mode 100644
iindex 00000000..8c2841ec
---- /dev/null
++++ b/server/menu-card.js
@@@ -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 };
ddiff --git a/test/menu-card.test.js b/test/menu-card.test.js
nnew file mode 100644
iindex 00000000..97faf220
---- /dev/null
++++ b/test/menu-card.test.js
@@@ -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); });