openvibers/openvibe.mediahub Commits
Publish Video's Trending card at /.well-known/openvibe-menu.json (#302)
Changes
nnew file mode 100644@@@ -0,0 +1,110 @@++'use strict';++++/**++ * The menu's network card for OpenVibe.Video (frame.menu-card@1): GET /.well-known/openvibe-menu.json.++ *++ * Video publishes "Trending": the most viewed public recordings and clips of the last seven days, each linking++ * to its openvibe.video watch page. The rows come from Live's public Content feed (GET /api/content/feed with++ * sort=top&window=week), which merges Media's public VODs and clips and already drops private, unlisted and++ * banned-account items; this module only picks the top six, merges the two lists and builds the card.++ *++ * The file is public and CORS *, cached for two minutes. A failed feed keeps the last good card; with none yet it++ * answers 503, and Network keeps the site's previous card for its own 30-minute window.++ */++const SITE = 'media-hub';++const CARD_ID = 'trending';++const BASE = 'https://openvibe.video';++const CARD_TTL_S = 300;++const CACHE_MS = 120_000;++const LIMIT = 6;++++/** Plain words for a card: markup and control characters out, 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);++}++++const views = (item) => (Number.isFinite(Number(item && item.views)) ? Number(item.views) : 0);++++/** The card itself, most viewed first. `items` are feed items ({ kind, id, title, views }). */++function cardFrom(items, at = Date.now()) {++ const seen = new Set();++ const rows = (Array.isArray(items) ? items : [])++ .filter((it) => it && (it.kind === 'vod' || it.kind === 'clip') && Number.isSafeInteger(Number(it.id)) && Number(it.id) > 0 && views(it) > 0)++ .filter((it) => {++ const key = `${it.kind}:${it.id}`;++ if (seen.has(key)) return false;++ seen.add(key);++ return true;++ })++ .sort((a, b) => views(b) - views(a))++ .slice(0, LIMIT)++ .map((it) => {++ const n = views(it);++ return {++ title: plain(it.title || (it.kind === 'clip' ? 'Clip' : 'Stream recording'), 80),++ meta: `${n.toLocaleString('en-US')} ${n === 1 ? 'view' : 'views'} this week`,++ url: `${BASE}/${it.kind === 'clip' ? 'c' : 'v'}/${Number(it.id)}`,++ };++ });++ return {++ id: CARD_ID,++ site: SITE,++ title: 'Trending',++ kind: 'rows',++ items: rows,++ more: { label: 'Watch more', url: `${BASE}/` },++ updated_at: new Date(at).toISOString(),++ ttl_s: CARD_TTL_S,++ };++}++++/**++ * The route. `feed` is Live's client's feed({ type, limit, sort, window }); `now` is injectable for the test.++ */++function createMenuCardHandler({ feed, 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 {++ const [vods, clips] = await Promise.all([++ Promise.resolve().then(() => feed({ type: 'vods', limit: LIMIT, sort: 'top', window: 'week' })).catch(() => null),++ Promise.resolve().then(() => feed({ type: 'clips', limit: LIMIT, sort: 'top', window: 'week' })).catch(() => null),++ ]);++ if (!vods && !clips) throw new Error('the content feed did not answer');++ const items = [...((vods && vods.items) || []), ...((clips && clips.items) || [])];++ cached = cardFrom(items, at);++ cachedAt = at;++ } catch (err) {++ if (!cached) throw err;++ }++ return cached;++ })().finally(() => { pending = null; });++ return pending;++ }++++ return async function menuCard(req, res) {++ try {++ const body = await card();++ res.set('Cache-Control', 'public, max-age=60')++ .set('Access-Control-Allow-Origin', '*')++ .set('Cross-Origin-Resource-Policy', 'cross-origin')++ .json(body);++ } catch {++ res.status(503).set('Cache-Control', 'no-store').set('Access-Control-Allow-Origin', '*')++ .json({ error: 'menu_unavailable' });++ }++ };++}++++module.exports = { createMenuCardHandler, cardFrom, plain, SITE, CARD_ID, CARD_TTL_S };@@@ -35,6 +35,7 @@ const { send, asset, assetVersion, SITE_NAME } = require('./layout');const { createLiveClient, LiveRefusedError } = require('./live-client');const { createChatClient } = require('./chat-client');const { createSearchReads, CATEGORIES, isCategory } = require('./search-reads');++const { createMenuCardHandler } = require('./menu-card');const rules = require('../drive/rules');const labelsUi = require('../render/labels-ui');/** 2147483648 → "2 GB" (the drive's formatBytes, without a trailing ".0"). */@@@ -639,6 +640,9 @@ function createVideoRoutes(ctx) {const chat = createChatClient({ config, fetchImpl: ctx.fetchImpl || globalThis.fetch, log });const shelves = createSearchReads({ config, fetchImpl: ctx.fetchImpl || globalThis.fetch, log });const r = asyncRouter();++ // The menu's network card (frame.menu-card@1): the week's most viewed public recordings and clips. Public,++ // CORS *, cached two minutes; the rows are Live's Content feed (which reads Media) and never a private item.++ r.get('/.well-known/openvibe-menu.json', createMenuCardHandler({ feed: (opts) => live.feed(opts) }));const base = String(config.video.url).replace(/\/+$/, '');const livePublic = String(config.video.livePublicUrl).replace(/\/+$/, '');const videoHostname = (() => { try { return new URL(config.video.url).hostname; } catch { return 'openvibe.video'; } })();nnew file mode 100644@@@ -0,0 +1,115 @@++'use strict';++// The menu's network card for OpenVibe.Video (frame.menu-card@1): GET /.well-known/openvibe-menu.json serves the++// week's most viewed public recordings and clips from Live's Content feed, CORS *, cached two minutes.++const assert = require('assert');++const { createMenuCardHandler, cardFrom, plain, CARD_TTL_S } = require('../server/video/menu-card');++++const vod = (id, views, title = `Recording ${id}`) => ({ kind: 'vod', id, title, views });++const clip = (id, views, title = `Clip ${id}`) => ({ kind: 'clip', id, title, views });++++// ── The card's shape, order and words ──────────────────────────────────++const card = cardFrom([vod(1, 120), clip(2, 900), vod(3, 30)], 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, 'trending');++assert.strictEqual(card.site, 'media-hub');++assert.strictEqual(card.title, 'Trending');++assert.strictEqual(card.kind, 'rows');++assert.strictEqual(card.ttl_s, 300);++assert.strictEqual(card.updated_at, '2026-11-07T08:00:00.000Z');++assert.deepStrictEqual(card.items, [++ { title: 'Clip 2', meta: '900 views this week', url: 'https://openvibe.video/c/2' },++ { title: 'Recording 1', meta: '120 views this week', url: 'https://openvibe.video/v/1' },++ { title: 'Recording 3', meta: '30 views this week', url: 'https://openvibe.video/v/3' },++]);++assert.deepStrictEqual(card.more, { label: 'Watch more', url: 'https://openvibe.video/' });++for (const item of card.items) {++ assert.ok(item.title.length <= 80 && item.meta.length <= 120);++ assert.ok(item.url.startsWith('https://openvibe.video/'));++}++++// At most six, one view is singular, a repeated item appears once, and anything that is not a public VOD/clip is dropped.++assert.strictEqual(cardFrom(Array.from({ length: 8 }, (_, i) => vod(i + 1, 100 - i))).items.length, 6);++assert.strictEqual(cardFrom([clip(1, 1)]).items[0].meta, '1 view this week');++assert.strictEqual(cardFrom([vod(1, 5), vod(1, 9)]).items.length, 1);++assert.deepStrictEqual(cardFrom([{ kind: 'paste', id: 3, views: 99 }, vod(0, 4), vod('x', 4)]).items, []);++assert.deepStrictEqual(cardFrom([]).items, []);++assert.strictEqual(plain('<b>Sunday</b>\n\t market ', 80), 'Sunday market');++assert.strictEqual(plain('x'.repeat(200), 80).length, 80);++++// ── The route: CORS *, 60 s browser cache, the two-minute card cache ──++function fakeRes() {++ const headers = {};++ let status = 200;++ let body = null;++ const res = {++ set(k, v) { headers[k] = v; return this; },++ status(n) { status = n; return this; },++ json(v) { body = v; return this; },++ };++ return { res, headers, status: () => status, body: () => body };++}++++(async () => {++ let t = 1000;++ let calls = 0;++ const feed = async ({ type, limit, sort, window }) => {++ calls++;++ assert.strictEqual(limit, 6);++ assert.strictEqual(sort, 'top');++ assert.strictEqual(window, 'week');++ return { items: type === 'vods' ? [vod(1, 40)] : [clip(2, 80)] };++ };++ const handler = createMenuCardHandler({ feed, now: () => t });++++ const first = fakeRes();++ await handler({}, first.res);++ assert.strictEqual(first.status(), 200);++ assert.strictEqual(first.headers['Access-Control-Allow-Origin'], '*');++ assert.strictEqual(first.headers['Cache-Control'], 'public, max-age=60');++ assert.strictEqual(first.body().site, 'media-hub');++ assert.deepStrictEqual(first.body().items.map((x) => x.url), ['https://openvibe.video/c/2', 'https://openvibe.video/v/1']);++ assert.strictEqual(calls, 2, 'one feed call for VODs and one for clips');++++ const second = fakeRes();++ await handler({}, second.res);++ assert.strictEqual(calls, 2, 'a second request inside the ttl is served from the card cache');++ t += CARD_TTL_S * 1000 + 1;++ await handler({}, fakeRes().res);++++ // One feed failing still serves the other; both failing keeps the last good card.++ const oneDown = createMenuCardHandler({++ feed: async ({ type }) => (type === 'clips' ? { items: [clip(2, 80)] } : Promise.reject(new Error('down'))),++ now: () => t,++ });++ const half = fakeRes();++ await oneDown({}, half.res);++ assert.strictEqual(half.status(), 200);++ assert.deepStrictEqual(half.body().items.map((x) => x.url), ['https://openvibe.video/c/2']);++++ let fail = false;++ let buildCalls = 0;++ const flaky = createMenuCardHandler({++ feed: async () => {++ buildCalls++;++ if (fail) throw new Error('down');++ return { items: [vod(1, 40)] };++ },++ now: () => t,++ });++ const good = fakeRes();++ await flaky({}, good.res);++ assert.strictEqual(good.status(), 200);++ fail = true;++ const kept = fakeRes();++ await flaky({}, kept.res);++ assert.strictEqual(kept.status(), 200);++ assert.strictEqual(kept.body().items[0].url, 'https://openvibe.video/v/1');++++ const never = createMenuCardHandler({ feed: async () => { throw new Error('down'); }, now: () => t });++ const none = fakeRes();++ await never({}, none.res);++ assert.strictEqual(none.status(), 503);++ assert.strictEqual(none.body().error, 'menu_unavailable');++++ console.log('video menu card: rows, week views, merge, cache, CORS, one source down and no card');++})().catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,110 @@
@@ -0,0 +1,110 @@
+'use strict';
+
+/**
+ * The menu's network card for OpenVibe.Video (frame.menu-card@1): GET /.well-known/openvibe-menu.json.
+ *
+ * Video publishes "Trending": the most viewed public recordings and clips of the last seven days, each linking
+ * to its openvibe.video watch page. The rows come from Live's public Content feed (GET /api/content/feed with
+ * sort=top&window=week), which merges Media's public VODs and clips and already drops private, unlisted and
+ * banned-account items; this module only picks the top six, merges the two lists and builds the card.
+ *
+ * The file is public and CORS *, cached for two minutes. A failed feed keeps the last good card; with none yet it
+ * answers 503, and Network keeps the site's previous card for its own 30-minute window.
+ */
+const SITE = 'media-hub';
+const CARD_ID = 'trending';
+const BASE = 'https://openvibe.video';
+const CARD_TTL_S = 300;
+const CACHE_MS = 120_000;
+const LIMIT = 6;
+
+/** Plain words for a card: markup and control characters out, 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);
+}
+
+const views = (item) => (Number.isFinite(Number(item && item.views)) ? Number(item.views) : 0);
+
+/** The card itself, most viewed first. `items` are feed items ({ kind, id, title, views }). */
+function cardFrom(items, at = Date.now()) {
+ const seen = new Set();
+ const rows = (Array.isArray(items) ? items : [])
+ .filter((it) => it && (it.kind === 'vod' || it.kind === 'clip') && Number.isSafeInteger(Number(it.id)) && Number(it.id) > 0 && views(it) > 0)
+ .filter((it) => {
+ const key = `${it.kind}:${it.id}`;
+ if (seen.has(key)) return false;
+ seen.add(key);
+ return true;
+ })
+ .sort((a, b) => views(b) - views(a))
+ .slice(0, LIMIT)
+ .map((it) => {
+ const n = views(it);
+ return {
+ title: plain(it.title || (it.kind === 'clip' ? 'Clip' : 'Stream recording'), 80),
+ meta: `${n.toLocaleString('en-US')} ${n === 1 ? 'view' : 'views'} this week`,
+ url: `${BASE}/${it.kind === 'clip' ? 'c' : 'v'}/${Number(it.id)}`,
+ };
+ });
+ return {
+ id: CARD_ID,
+ site: SITE,
+ title: 'Trending',
+ kind: 'rows',
+ items: rows,
+ more: { label: 'Watch more', url: `${BASE}/` },
+ updated_at: new Date(at).toISOString(),
+ ttl_s: CARD_TTL_S,
+ };
+}
+
+/**
+ * The route. `feed` is Live's client's feed({ type, limit, sort, window }); `now` is injectable for the test.
+ */
+function createMenuCardHandler({ feed, 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 {
+ const [vods, clips] = await Promise.all([
+ Promise.resolve().then(() => feed({ type: 'vods', limit: LIMIT, sort: 'top', window: 'week' })).catch(() => null),
+ Promise.resolve().then(() => feed({ type: 'clips', limit: LIMIT, sort: 'top', window: 'week' })).catch(() => null),
+ ]);
+ if (!vods && !clips) throw new Error('the content feed did not answer');
+ const items = [...((vods && vods.items) || []), ...((clips && clips.items) || [])];
+ cached = cardFrom(items, at);
+ cachedAt = at;
+ } catch (err) {
+ if (!cached) throw err;
+ }
+ return cached;
+ })().finally(() => { pending = null; });
+ return pending;
+ }
+
+ return async function menuCard(req, res) {
+ try {
+ const body = await card();
+ res.set('Cache-Control', 'public, max-age=60')
+ .set('Access-Control-Allow-Origin', '*')
+ .set('Cross-Origin-Resource-Policy', 'cross-origin')
+ .json(body);
+ } catch {
+ res.status(503).set('Cache-Control', 'no-store').set('Access-Control-Allow-Origin', '*')
+ .json({ error: 'menu_unavailable' });
+ }
+ };
+}
+
+module.exports = { createMenuCardHandler, cardFrom, plain, SITE, CARD_ID, CARD_TTL_S };
@@ -35,6 +35,7 @@ const { send, asset, assetVersion, SITE_NAME } = require('./layout');
@@ -35,6 +35,7 @@ const { send, asset, assetVersion, SITE_NAME } = require('./layout');
const { createLiveClient, LiveRefusedError } = require('./live-client');
const { createLiveClient, LiveRefusedError } = require('./live-client');
const { createChatClient } = require('./chat-client');
const { createChatClient } = require('./chat-client');
const { createSearchReads, CATEGORIES, isCategory } = require('./search-reads');
const { createSearchReads, CATEGORIES, isCategory } = require('./search-reads');
+const { createMenuCardHandler } = require('./menu-card');
const rules = require('../drive/rules');
const rules = require('../drive/rules');
const labelsUi = require('../render/labels-ui');
const labelsUi = require('../render/labels-ui');
/** 2147483648 → "2 GB" (the drive's formatBytes, without a trailing ".0"). */
/** 2147483648 → "2 GB" (the drive's formatBytes, without a trailing ".0"). */
@@ -639,6 +640,9 @@ function createVideoRoutes(ctx) {
@@ -639,6 +640,9 @@ function createVideoRoutes(ctx) {
const chat = createChatClient({ config, fetchImpl: ctx.fetchImpl || globalThis.fetch, log });
const chat = createChatClient({ config, fetchImpl: ctx.fetchImpl || globalThis.fetch, log });
const shelves = createSearchReads({ config, fetchImpl: ctx.fetchImpl || globalThis.fetch, log });
const shelves = createSearchReads({ config, fetchImpl: ctx.fetchImpl || globalThis.fetch, log });
const r = asyncRouter();
const r = asyncRouter();
+ // The menu's network card (frame.menu-card@1): the week's most viewed public recordings and clips. Public,
+ // CORS *, cached two minutes; the rows are Live's Content feed (which reads Media) and never a private item.
+ r.get('/.well-known/openvibe-menu.json', createMenuCardHandler({ feed: (opts) => live.feed(opts) }));
const base = String(config.video.url).replace(/\/+$/, '');
const base = String(config.video.url).replace(/\/+$/, '');
const livePublic = String(config.video.livePublicUrl).replace(/\/+$/, '');
const livePublic = String(config.video.livePublicUrl).replace(/\/+$/, '');
const videoHostname = (() => { try { return new URL(config.video.url).hostname; } catch { return 'openvibe.video'; } })();
const videoHostname = (() => { try { return new URL(config.video.url).hostname; } catch { return 'openvibe.video'; } })();
new file mode 100644
new file mode 100644
@@ -0,0 +1,115 @@
@@ -0,0 +1,115 @@
+'use strict';
+// The menu's network card for OpenVibe.Video (frame.menu-card@1): GET /.well-known/openvibe-menu.json serves the
+// week's most viewed public recordings and clips from Live's Content feed, CORS *, cached two minutes.
+const assert = require('assert');
+const { createMenuCardHandler, cardFrom, plain, CARD_TTL_S } = require('../server/video/menu-card');
+
+const vod = (id, views, title = `Recording ${id}`) => ({ kind: 'vod', id, title, views });
+const clip = (id, views, title = `Clip ${id}`) => ({ kind: 'clip', id, title, views });
+
+// ── The card's shape, order and words ──────────────────────────────────
+const card = cardFrom([vod(1, 120), clip(2, 900), vod(3, 30)], 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, 'trending');
+assert.strictEqual(card.site, 'media-hub');
+assert.strictEqual(card.title, 'Trending');
+assert.strictEqual(card.kind, 'rows');
+assert.strictEqual(card.ttl_s, 300);
+assert.strictEqual(card.updated_at, '2026-11-07T08:00:00.000Z');
+assert.deepStrictEqual(card.items, [
+ { title: 'Clip 2', meta: '900 views this week', url: 'https://openvibe.video/c/2' },
+ { title: 'Recording 1', meta: '120 views this week', url: 'https://openvibe.video/v/1' },
+ { title: 'Recording 3', meta: '30 views this week', url: 'https://openvibe.video/v/3' },
+]);
+assert.deepStrictEqual(card.more, { label: 'Watch more', url: 'https://openvibe.video/' });
+for (const item of card.items) {
+ assert.ok(item.title.length <= 80 && item.meta.length <= 120);
+ assert.ok(item.url.startsWith('https://openvibe.video/'));
+}
+
+// At most six, one view is singular, a repeated item appears once, and anything that is not a public VOD/clip is dropped.
+assert.strictEqual(cardFrom(Array.from({ length: 8 }, (_, i) => vod(i + 1, 100 - i))).items.length, 6);
+assert.strictEqual(cardFrom([clip(1, 1)]).items[0].meta, '1 view this week');
+assert.strictEqual(cardFrom([vod(1, 5), vod(1, 9)]).items.length, 1);
+assert.deepStrictEqual(cardFrom([{ kind: 'paste', id: 3, views: 99 }, vod(0, 4), vod('x', 4)]).items, []);
+assert.deepStrictEqual(cardFrom([]).items, []);
+assert.strictEqual(plain('<b>Sunday</b>\n\t market ', 80), 'Sunday market');
+assert.strictEqual(plain('x'.repeat(200), 80).length, 80);
+
+// ── The route: CORS *, 60 s browser cache, the two-minute card cache ──
+function fakeRes() {
+ const headers = {};
+ let status = 200;
+ let body = null;
+ const res = {
+ set(k, v) { headers[k] = v; return this; },
+ status(n) { status = n; return this; },
+ json(v) { body = v; return this; },
+ };
+ return { res, headers, status: () => status, body: () => body };
+}
+
+(async () => {
+ let t = 1000;
+ let calls = 0;
+ const feed = async ({ type, limit, sort, window }) => {
+ calls++;
+ assert.strictEqual(limit, 6);
+ assert.strictEqual(sort, 'top');
+ assert.strictEqual(window, 'week');
+ return { items: type === 'vods' ? [vod(1, 40)] : [clip(2, 80)] };
+ };
+ const handler = createMenuCardHandler({ feed, now: () => t });
+
+ const first = fakeRes();
+ await handler({}, first.res);
+ assert.strictEqual(first.status(), 200);
+ assert.strictEqual(first.headers['Access-Control-Allow-Origin'], '*');
+ assert.strictEqual(first.headers['Cache-Control'], 'public, max-age=60');
+ assert.strictEqual(first.body().site, 'media-hub');
+ assert.deepStrictEqual(first.body().items.map((x) => x.url), ['https://openvibe.video/c/2', 'https://openvibe.video/v/1']);
+ assert.strictEqual(calls, 2, 'one feed call for VODs and one for clips');
+
+ const second = fakeRes();
+ await handler({}, second.res);
+ assert.strictEqual(calls, 2, 'a second request inside the ttl is served from the card cache');
+ t += CARD_TTL_S * 1000 + 1;
+ await handler({}, fakeRes().res);
+
+ // One feed failing still serves the other; both failing keeps the last good card.
+ const oneDown = createMenuCardHandler({
+ feed: async ({ type }) => (type === 'clips' ? { items: [clip(2, 80)] } : Promise.reject(new Error('down'))),
+ now: () => t,
+ });
+ const half = fakeRes();
+ await oneDown({}, half.res);
+ assert.strictEqual(half.status(), 200);
+ assert.deepStrictEqual(half.body().items.map((x) => x.url), ['https://openvibe.video/c/2']);
+
+ let fail = false;
+ let buildCalls = 0;
+ const flaky = createMenuCardHandler({
+ feed: async () => {
+ buildCalls++;
+ if (fail) throw new Error('down');
+ return { items: [vod(1, 40)] };
+ },
+ now: () => t,
+ });
+ const good = fakeRes();
+ await flaky({}, good.res);
+ assert.strictEqual(good.status(), 200);
+ fail = true;
+ const kept = fakeRes();
+ await flaky({}, kept.res);
+ assert.strictEqual(kept.status(), 200);
+ assert.strictEqual(kept.body().items[0].url, 'https://openvibe.video/v/1');
+
+ const never = createMenuCardHandler({ feed: async () => { throw new Error('down'); }, now: () => t });
+ const none = fakeRes();
+ await never({}, none.res);
+ assert.strictEqual(none.status(), 503);
+ assert.strictEqual(none.body().error, 'menu_unavailable');
+
+ console.log('video menu card: rows, week views, merge, cache, CORS, one source down and no card');
+})().catch((err) => { console.error(err); process.exit(1); });