openvibers/openvibe.community Commits
Feed: LIVE only while the channel is live now (#309)
Changes
@@@ -8,7 +8,7 @@** GET /?kind=live|clip|video|pic|post|meme|game|update&cursor=<opaque>&limit= anyone (the signed-in person too)* → { items: [{ id, kind, url, title, text, at, site, site_name, actor: { name, avatar } | null,-- * media: { thumb, ratio, length } | null, counts: {} }], next_cursor }++ * media: { thumb, ratio, length } | null, counts: {}, live_now? }], next_cursor } live_now: on Live's live list now** Text only (the client draws every field as text); https links only; nothing from Frame without a picture and a title.*/@@@ -80,12 +80,31 @@ function createTrending({ networkUrl, fetchImpl }) {};}--function createFeedApi({ pulse, viewers, limits, networkUrl = 'https://openvibe.network', fetchImpl = globalThis.fetch }) {++/** Who is live on Live right now (GET /api/streams, cached a minute): a "went live" card says LIVE only for them. */++function createLiveNow({ liveUrl, fetchImpl }) {++ let cache = new Set(); let at = 0;++ return async function liveNow() {++ if (Date.now() - at < 60_000) return cache;++ at = Date.now();++ try {++ const r = await fetchImpl(`${liveUrl}/api/streams`, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(3000) });++ const j = r.ok ? await r.json() : null;++ cache = new Set(((j && j.streams) || []).map((x) => String((x && x.username) || '').toLowerCase()).filter(Boolean));++ } catch { /* keep the last answer */ }++ return cache;++ };++}++const channelOf = (url) => { try { const u = new URL(url); return /(^|\.)openvibe\.live$/.test(u.hostname) ? (/^\/@?([A-Za-z0-9_.-]{1,40})\/?$/.exec(u.pathname) || [])[1] || '' : ''; } catch { return ''; } };++++function createFeedApi({ pulse, viewers, limits, networkUrl = 'https://openvibe.network', liveUrl = 'https://openvibe.live', fetchImpl = globalThis.fetch }) {const router = express.Router();router.use(contracts.http.middleware());router.use(viewers.middleware());router.use(limits.reads('community.feed.read'));const trending = createTrending({ networkUrl, fetchImpl });++ const liveNow = createLiveNow({ liveUrl, fetchImpl });++ // A "went live" item is live now only while that channel is on Live's live list.++ const mark = (cards, live) => cards.forEach((c) => { if (c.kind === 'live') c.live_now = live.has(channelOf(c.url).toLowerCase()); });router.get('/', run(async (req) => {const kind = KINDS.includes(String(req.query.kind || '')) ? String(req.query.kind) : '';@@@ -109,13 +128,15 @@ function createFeedApi({ pulse, viewers, limits, networkUrl = 'https://openvibe.let k = 0;items.forEach((x, n) => { woven.push(x); if ((n + 1) % 3 === 0 && k < t.length) woven.push(t[k++]); });while (woven.length < want && k < t.length) woven.push(t[k++]);++ mark(woven, await liveNow());return { items: woven, next_cursor: next };}}++ mark(items, await liveNow());return { items, next_cursor: next };}));router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));return router;}--module.exports = { createFeedApi, kindOf, card };++module.exports = { createFeedApi, kindOf, card, channelOf };@@@ -15,7 +15,8 @@ const FRAME = { cards: [] },{ site: 'news', title: 'Top stories', items: [{ title: 'Not a kind the feed shows', url: 'https://openvibe.news/a', image: { src: 'https://openvibe.media/t/n.webp' } }] },] };--const feedFetch = async (url) => ({ ok: /\/api\/frame/.test(String(url)), json: async () => FRAME });++const LIVE = { streams: [{ username: 'ada' }] };++const feedFetch = async (url) => ({ ok: /\/api\/(frame|streams)/.test(String(url)), json: async () => (/\/api\/streams/.test(String(url)) ? LIVE : FRAME) });(async () => {const t = await boot({ authority: 'community', pasteLimits: { cooldownSeconds: 0 }, appOpts: { feedFetch } });@@@ -53,6 +54,17 @@ const feedFetch = async (url) => ({ ok: /\/api\/frame/.test(String(url)), json:assert.strictEqual(tr[0].media.thumb, 'https://openvibe.media/t/1.webp');});++ await check('LIVE only for a channel that is live now', async () => {++ const mk = (id, who) => t.get('/api/v1/pulse/items', { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${svc}`, 'x-ov-subject': alex.subject_id },++ body: JSON.stringify({ ref: { service: 'live', type: 'stream', id }, title: `${who} went live: a stream`, url: `https://openvibe.live/@${who}` }) });++ assert.strictEqual((await mk('s1', 'ada')).status, 201);++ assert.strictEqual((await mk('s2', 'bo')).status, 201);++ const j = (await t.get('/api/v1/feed?kind=live')).json();++ const by = Object.fromEntries(j.items.map((x) => [x.url, x.live_now]));++ assert.strictEqual(by['https://openvibe.live/@ada'], true, 'ada is on the live list');++ assert.strictEqual(by['https://openvibe.live/@bo'], false, 'bo went live earlier and is not live now');++ });++await check('a kind filters the stream', async () => {const j = (await t.get('/api/v1/feed?kind=clip')).json();assert.ok(j.items.length >= 1 && j.items.every((x) => x.kind === 'clip'), JSON.stringify(j.items.map((x) => x.kind)));@@@ -35,7 +35,7 @@ const INVENTORY = {'server/pastes/review.js': [2, 'OpenVibe.Vision /api/v1/review (OV_VISION_URL, fixed path) with a service token; only the paste\'s Media URL is sent'],'server/observability.js': [1, '/api/ready probes of the configured Media health URL'],'server/pulse/consumer.js': [2, 'Events subscriptions API (EVENTS_URL) with a service token'],-- 'server/pulse/feed.js': [1, 'Network /api/frame at the configured networkUrl (trending cards), no user input in the URL'],++ 'server/pulse/feed.js': [2, 'Network /api/frame at networkUrl (trending cards) and Live /api/streams at liveUrl (who is live now); no user input in either URL'],'server/pulse/service.js': [1, 'Network /api/v1/me/follows at the configured networkInternalUrl, with the viewer token and a bounded cursor'],'server/relay/discord-gateway.js': [1, 'DISCORD_GATEWAY_URL (owner) or the resume_gateway_url Discord\'s READY names'],'server/relay/discord.js': [1, 'a Discord webhook URL read from an environment variable the owner allowlisted; mappings name the variable, never a URL'],
@@ -8,7 +8,7 @@
@@ -8,7 +8,7 @@
*
*
* GET /?kind=live|clip|video|pic|post|meme|game|update&cursor=<opaque>&limit= anyone (the signed-in person too)
* GET /?kind=live|clip|video|pic|post|meme|game|update&cursor=<opaque>&limit= anyone (the signed-in person too)
* → { items: [{ id, kind, url, title, text, at, site, site_name, actor: { name, avatar } | null,
* → { items: [{ id, kind, url, title, text, at, site, site_name, actor: { name, avatar } | null,
- * media: { thumb, ratio, length } | null, counts: {} }], next_cursor }
+ * media: { thumb, ratio, length } | null, counts: {}, live_now? }], next_cursor } live_now: on Live's live list now
*
*
* Text only (the client draws every field as text); https links only; nothing from Frame without a picture and a title.
* Text only (the client draws every field as text); https links only; nothing from Frame without a picture and a title.
*/
*/
@@ -80,12 +80,31 @@ function createTrending({ networkUrl, fetchImpl }) {
@@ -80,12 +80,31 @@ function createTrending({ networkUrl, fetchImpl }) {
};
};
}
}
-function createFeedApi({ pulse, viewers, limits, networkUrl = 'https://openvibe.network', fetchImpl = globalThis.fetch }) {
+/** Who is live on Live right now (GET /api/streams, cached a minute): a "went live" card says LIVE only for them. */
+function createLiveNow({ liveUrl, fetchImpl }) {
+ let cache = new Set(); let at = 0;
+ return async function liveNow() {
+ if (Date.now() - at < 60_000) return cache;
+ at = Date.now();
+ try {
+ const r = await fetchImpl(`${liveUrl}/api/streams`, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(3000) });
+ const j = r.ok ? await r.json() : null;
+ cache = new Set(((j && j.streams) || []).map((x) => String((x && x.username) || '').toLowerCase()).filter(Boolean));
+ } catch { /* keep the last answer */ }
+ return cache;
+ };
+}
+const channelOf = (url) => { try { const u = new URL(url); return /(^|\.)openvibe\.live$/.test(u.hostname) ? (/^\/@?([A-Za-z0-9_.-]{1,40})\/?$/.exec(u.pathname) || [])[1] || '' : ''; } catch { return ''; } };
+
+function createFeedApi({ pulse, viewers, limits, networkUrl = 'https://openvibe.network', liveUrl = 'https://openvibe.live', fetchImpl = globalThis.fetch }) {
const router = express.Router();
const router = express.Router();
router.use(contracts.http.middleware());
router.use(contracts.http.middleware());
router.use(viewers.middleware());
router.use(viewers.middleware());
router.use(limits.reads('community.feed.read'));
router.use(limits.reads('community.feed.read'));
const trending = createTrending({ networkUrl, fetchImpl });
const trending = createTrending({ networkUrl, fetchImpl });
+ const liveNow = createLiveNow({ liveUrl, fetchImpl });
+ // A "went live" item is live now only while that channel is on Live's live list.
+ const mark = (cards, live) => cards.forEach((c) => { if (c.kind === 'live') c.live_now = live.has(channelOf(c.url).toLowerCase()); });
router.get('/', run(async (req) => {
router.get('/', run(async (req) => {
const kind = KINDS.includes(String(req.query.kind || '')) ? String(req.query.kind) : '';
const kind = KINDS.includes(String(req.query.kind || '')) ? String(req.query.kind) : '';
@@ -109,13 +128,15 @@ function createFeedApi({ pulse, viewers, limits, networkUrl = 'https://openvibe.
@@ -109,13 +128,15 @@ function createFeedApi({ pulse, viewers, limits, networkUrl = 'https://openvibe.
let k = 0;
let k = 0;
items.forEach((x, n) => { woven.push(x); if ((n + 1) % 3 === 0 && k < t.length) woven.push(t[k++]); });
items.forEach((x, n) => { woven.push(x); if ((n + 1) % 3 === 0 && k < t.length) woven.push(t[k++]); });
while (woven.length < want && k < t.length) woven.push(t[k++]);
while (woven.length < want && k < t.length) woven.push(t[k++]);
+ mark(woven, await liveNow());
return { items: woven, next_cursor: next };
return { items: woven, next_cursor: next };
}
}
}
}
+ mark(items, await liveNow());
return { items, next_cursor: next };
return { items, next_cursor: next };
}));
}));
router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));
router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));
return router;
return router;
}
}
-module.exports = { createFeedApi, kindOf, card };
+module.exports = { createFeedApi, kindOf, card, channelOf };
@@ -15,7 +15,8 @@ const FRAME = { cards: [
@@ -15,7 +15,8 @@ const FRAME = { cards: [
] },
] },
{ site: 'news', title: 'Top stories', items: [{ title: 'Not a kind the feed shows', url: 'https://openvibe.news/a', image: { src: 'https://openvibe.media/t/n.webp' } }] },
{ site: 'news', title: 'Top stories', items: [{ title: 'Not a kind the feed shows', url: 'https://openvibe.news/a', image: { src: 'https://openvibe.media/t/n.webp' } }] },
] };
] };
-const feedFetch = async (url) => ({ ok: /\/api\/frame/.test(String(url)), json: async () => FRAME });
+const LIVE = { streams: [{ username: 'ada' }] };
+const feedFetch = async (url) => ({ ok: /\/api\/(frame|streams)/.test(String(url)), json: async () => (/\/api\/streams/.test(String(url)) ? LIVE : FRAME) });
(async () => {
(async () => {
const t = await boot({ authority: 'community', pasteLimits: { cooldownSeconds: 0 }, appOpts: { feedFetch } });
const t = await boot({ authority: 'community', pasteLimits: { cooldownSeconds: 0 }, appOpts: { feedFetch } });
@@ -53,6 +54,17 @@ const feedFetch = async (url) => ({ ok: /\/api\/frame/.test(String(url)), json:
@@ -53,6 +54,17 @@ const feedFetch = async (url) => ({ ok: /\/api\/frame/.test(String(url)), json:
assert.strictEqual(tr[0].media.thumb, 'https://openvibe.media/t/1.webp');
assert.strictEqual(tr[0].media.thumb, 'https://openvibe.media/t/1.webp');
});
});
+ await check('LIVE only for a channel that is live now', async () => {
+ const mk = (id, who) => t.get('/api/v1/pulse/items', { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${svc}`, 'x-ov-subject': alex.subject_id },
+ body: JSON.stringify({ ref: { service: 'live', type: 'stream', id }, title: `${who} went live: a stream`, url: `https://openvibe.live/@${who}` }) });
+ assert.strictEqual((await mk('s1', 'ada')).status, 201);
+ assert.strictEqual((await mk('s2', 'bo')).status, 201);
+ const j = (await t.get('/api/v1/feed?kind=live')).json();
+ const by = Object.fromEntries(j.items.map((x) => [x.url, x.live_now]));
+ assert.strictEqual(by['https://openvibe.live/@ada'], true, 'ada is on the live list');
+ assert.strictEqual(by['https://openvibe.live/@bo'], false, 'bo went live earlier and is not live now');
+ });
+
await check('a kind filters the stream', async () => {
await check('a kind filters the stream', async () => {
const j = (await t.get('/api/v1/feed?kind=clip')).json();
const j = (await t.get('/api/v1/feed?kind=clip')).json();
assert.ok(j.items.length >= 1 && j.items.every((x) => x.kind === 'clip'), JSON.stringify(j.items.map((x) => x.kind)));
assert.ok(j.items.length >= 1 && j.items.every((x) => x.kind === 'clip'), JSON.stringify(j.items.map((x) => x.kind)));
@@ -35,7 +35,7 @@ const INVENTORY = {
@@ -35,7 +35,7 @@ const INVENTORY = {
'server/pastes/review.js': [2, 'OpenVibe.Vision /api/v1/review (OV_VISION_URL, fixed path) with a service token; only the paste\'s Media URL is sent'],
'server/pastes/review.js': [2, 'OpenVibe.Vision /api/v1/review (OV_VISION_URL, fixed path) with a service token; only the paste\'s Media URL is sent'],
'server/observability.js': [1, '/api/ready probes of the configured Media health URL'],
'server/observability.js': [1, '/api/ready probes of the configured Media health URL'],
'server/pulse/consumer.js': [2, 'Events subscriptions API (EVENTS_URL) with a service token'],
'server/pulse/consumer.js': [2, 'Events subscriptions API (EVENTS_URL) with a service token'],
- 'server/pulse/feed.js': [1, 'Network /api/frame at the configured networkUrl (trending cards), no user input in the URL'],
+ 'server/pulse/feed.js': [2, 'Network /api/frame at networkUrl (trending cards) and Live /api/streams at liveUrl (who is live now); no user input in either URL'],
'server/pulse/service.js': [1, 'Network /api/v1/me/follows at the configured networkInternalUrl, with the viewer token and a bounded cursor'],
'server/pulse/service.js': [1, 'Network /api/v1/me/follows at the configured networkInternalUrl, with the viewer token and a bounded cursor'],
'server/relay/discord-gateway.js': [1, 'DISCORD_GATEWAY_URL (owner) or the resume_gateway_url Discord\'s READY names'],
'server/relay/discord-gateway.js': [1, 'DISCORD_GATEWAY_URL (owner) or the resume_gateway_url Discord\'s READY names'],
'server/relay/discord.js': [1, 'a Discord webhook URL read from an environment variable the owner allowlisted; mappings name the variable, never a URL'],
'server/relay/discord.js': [1, 'a Discord webhook URL read from an environment variable the owner allowlisted; mappings name the variable, never a URL'],