openvibers/openvibe.mediahub Commits

Publish Video's Trending card at /.well-known/openvibe-menu.json (#302)

OP OpenVibers committed 6 days ago · 7586978b24 · 3 files, +229 −0

Changes

ddiff --git a/server/video/menu-card.js b/server/video/menu-card.js
nnew file mode 100644
iindex 0000000..7704d45
---- /dev/null
++++ b/server/video/menu-card.js
@@@ -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 };
ddiff --git a/server/video/pages.js b/server/video/pages.js
iindex 7ffc54b..e25c7e5 100644
---- a/server/video/pages.js
++++ b/server/video/pages.js
@@@ -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'; } })();
ddiff --git a/test/video-menu-card.test.js b/test/video-menu-card.test.js
nnew file mode 100644
iindex 0000000..040fd03
---- /dev/null
++++ b/test/video-menu-card.test.js
@@@ -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); });