openvibers/openvibe.community Commits
Keep historical stream cards ended when channel is live again (#370)
Changes
@@@ -816,7 +816,7 @@ async function createApp(opts = {}) {const item = rows[index];const card = feedTools.card(item);if (card.kind === 'live') {-- card.live_now = live.has(feedTools.channelOf(card.url).toLowerCase());++ card.live_now = feedTools.isCurrentLive(card, live);if (!card.live_now) card.kind = 'stream';}seen.push(card);@@@ -173,19 +173,30 @@ function createTrending({ networkUrl, fetchImpl }) {/** 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;++ let cache = new Map(); 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));++ cache = new Map(((j && j.streams) || []).map((x) => {++ const name = String((x && x.username) || '').toLowerCase();++ const raw = String((x && x.started_at) || '');++ const stamp = /^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d$/.test(raw) ? `${raw.replace(' ', 'T')}Z` : raw;++ return [name, Date.parse(stamp)];++ }).filter(([name]) => name));} 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 ''; } };++const isCurrentLive = (card, live) => {++ const name = channelOf(card.url).toLowerCase();++ if (!live.has(name)) return false;++ const started = live.get(name), recorded = Date.parse(card.at || '');++ return !Number.isFinite(started) || !Number.isFinite(recorded) || Math.abs(started - recorded) <= 10 * 60_000;++};function searchMatches(item, query) {const q = clean(query, 80).toLowerCase();@@@ -221,7 +232,7 @@ function createFeedApi({ pulse, viewers, limits, db, networkUrl = 'https://openvconst 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()); if (!c.live_now) c.kind = 'stream'; } });++ const mark = (cards, live) => cards.forEach((c) => { if (c.kind === 'live') { c.live_now = isCurrentLive(c, live); if (!c.live_now) c.kind = 'stream'; } });router.get('/', run(async (req) => {const kind = KINDS.includes(String(req.query.kind || '')) ? String(req.query.kind) : '';@@@ -321,4 +332,4 @@ function createFeedApi({ pulse, viewers, limits, db, networkUrl = 'https://openvreturn router;}--module.exports = { createFeedApi, kindOf, card, countsFor, channelOf, createLiveNow, searchMatches, searchExamples, foldBursts };++module.exports = { createFeedApi, kindOf, card, countsFor, channelOf, createLiveNow, isCurrentLive, searchMatches, searchExamples, foldBursts };@@@ -282,6 +282,19 @@ function createPulseSources({ db, config = {}, fetchImpl = globalThis.fetch, nowasync function publicContent() {const seen = new Set();let cursor = null, created = 0;++ const live = await getJson(`${liveApi}/api/streams`);++ const onAir = new Map();++ for (const s of (Array.isArray(live.streams) ? live.streams : [])) {++ const id = Number(s && s.id), channel = clean(s && s.username, 64);++ const started = sqlFrom(s && s.started_at, null);++ if (!Number.isSafeInteger(id) || id < 1 || !/^[A-Za-z0-9_.-]{1,64}$/.test(channel) || !started) continue;++ onAir.set(channel.toLowerCase(), Date.parse(`${started.replace(' ', 'T')}Z`));++ const name = clean(s.display_name || channel, 80);++ const r = await store.upsertItem(db, { source_service: 'live', source_type: 'stream', source_id: String(id),++ title: `${name} went live: ${clean(s.title, 160) || 'a stream'}`, url: `${liveApi}/@${encodeURIComponent(channel)}`,++ actor_name: name, origin: 'user', occurred_at: started });++ if (r.created) created++;++ }for (let page = 0; page < 100; page++) {const url = new URL('/api/content/feed', liveApi);url.searchParams.set('sort', 'new');@@@ -295,10 +308,13 @@ function createPulseSources({ db, config = {}, fetchImpl = globalThis.fetch, nowconst id = clean(v.id, 80);if (!/^[A-Za-z0-9_-]{1,80}$/.test(id)) continue;const type = v.kind === 'vod' ? 'recording' : 'clip';-- seen.add(`${type}:${id}`);const at = sqlFrom(v.created_at, null);if (!at) continue;const channel = clean(v.channel && v.channel.username, 64);++ const currentStart = onAir.get(channel.toLowerCase());++ if (type === 'recording' && Number.isFinite(currentStart)++ && Math.abs(Date.parse(`${at.replace(' ', 'T')}Z`) - currentStart) <= 10 * MINUTE) continue;++ seen.add(`${type}:${id}`);let prior = null;if (type === 'recording' && channel) {prior = await db.prepare(`SELECT actor_subject, source_id FROM pulse_items WHERE source_service = 'live' AND source_type = 'stream'@@@ -16,7 +16,7 @@ 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 LIVE = { streams: [{ username: 'alex' }, { username: 'ada' }] };++const LIVE = { streams: [{ username: 'alex', started_at: new Date().toISOString() }, { username: 'ada', started_at: new Date().toISOString() }] };const feedFetch = async (url) => {const u = String(url);return { ok: /\/api\/(frame|streams)/.test(u), json: async () => (/\/api\/streams/.test(u) ? LIVE : FRAME) };@@@ -96,7 +96,7 @@ const at = (minsAgo) => new Date(Date.now() - minsAgo * 60_000).toISOString().reassert.strictEqual(r.status, 200, r.text);const j = r.json();const kinds = new Set(j.items.map((x) => x.kind));-- for (const k of ['live', 'clip', 'video', 'pic', 'paste', 'meme', 'game', 'update', 'wiki', 'quest', 'chat', 'event', 'member']) assert.ok(kinds.has(k), `kind ${k} (${[...kinds]})`);++ for (const k of ['stream', 'clip', 'video', 'pic', 'paste', 'meme', 'game', 'update', 'wiki', 'quest', 'chat', 'event', 'member']) assert.ok(kinds.has(k), `kind ${k} (${[...kinds]})`);assert.ok('next_cursor' in j);for (const x of j.items) for (const k of ['id', 'kind', 'url', 'title', 'text', 'at', 'site', 'site_name', 'actor', 'media', 'counts']) assert.ok(k in x, `${k} on every card`);const c = j.items.find((x) => x.url === 'https://openvibe.live/clip/c1');@@@ -144,14 +144,17 @@ const at = (minsAgo) => new Date(Date.now() - minsAgo * 60_000).toISOString().re});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 ${netSvc}`, '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}` }) });++ const mk = (id, who, at = null) => t.get('/api/v1/pulse/items', { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${netSvc}`, 'x-ov-subject': alex.subject_id },++ body: JSON.stringify({ ref: { service: 'live', type: 'stream', id }, title: `${who} went live: ${at ? 'an older stream' : 'a stream'}`, url: `https://openvibe.live/@${who}`, ...(at ? { occurred_at: at } : {}) }) });assert.strictEqual((await mk('s1', 'ada')).status, 201);assert.strictEqual((await mk('s2', 'bo')).status, 201);++ assert.strictEqual((await mk('s3', 'alex', new Date(Date.now() - 2 * 86_400_000).toISOString())).status, 201);const j = (await t.get('/api/v1/feed?site=live&limit=40')).json();const by = Object.fromEntries(j.items.map((x) => [x.url, [x.kind, x.live_now]]));assert.deepStrictEqual(by['https://openvibe.live/@ada'], ['live', true], 'ada is on the live list');assert.deepStrictEqual(by['https://openvibe.live/@bo'], ['stream', false], 'bo went live earlier: a stream that happened, never LIVE');++ const older = j.items.find((x) => x.title === 'alex went live: an older stream');++ assert.deepStrictEqual([older.kind, older.live_now], ['stream', false], 'an old broadcast stays ended while the same channel is live again');for (const kind of ['live', 'stream']) {const filtered = (await t.get(`/api/v1/feed?kind=${kind}&limit=40`)).json();assert.ok(filtered.items.length > 0, `${kind} has a card`);@@@ -14,6 +14,7 @@ const day = (ms) => new Date(ms).toISOString().slice(0, 10);const yesterday = day(Date.now() - 86_400_000);const badgeAt = iso(Date.now() - 3_600_000);const calls = [];++ let liveStreams = [];let galleryRows = [{ id: 'pic_01JAB2C3D4E5F6G7H8J9K0MNPQ', title: 'First photo', author: 'Alex', listed: true, published_at: badgeAt,links: { page: 'https://openvibe.pics/i/firstpic', thumb: 'https://openvibe.pics/i/firstpic/thumb.webp' }, width: 800, height: 600 },@@@ -37,6 +38,7 @@ const day = (ms) => new Date(ms).toISOString().slice(0, 10);if (u.includes('/api/rundown/streams')) return { ok: true, json: async () => ({ streams: [{ stream_id: 123, live: false,title: 'Night stream', started_at: badgeAt, thumbnail_url: 'https://openvibe.media/night.webp',episode: { title: 'The night sky', logline: 'A recap', aired_at: badgeAt, audience: 'public' } }], next: null }) };++ if (u.endsWith('/api/streams')) return { ok: true, json: async () => ({ streams: liveStreams }) };if (u.includes('/api/content/feed')) return { ok: true, json: async () => ({ items: [{ id: 123, kind: 'vod', title: 'Night stream', href: 'https://openvibe.video/watch?v=night', thumbnail_url: 'https://openvibe.media/night.webp', created_at: badgeAt, channel: { username: 'alex', display_name: 'Alex' }, duration_seconds: 500 },{ id: 124, kind: 'clip', title: 'Night clip', href: 'https://openvibe.video/clip/night', thumbnail_url: 'https://openvibe.media/clip.webp', created_at: badgeAt, channel: { username: 'alex', display_name: 'Alex' }, duration_seconds: 25 },@@@ -149,5 +151,18 @@ const day = (ms) => new Date(ms).toISOString().slice(0, 10);assert.strictEqual(store.pulseTime('2030-01-02T08:00:00+02:00'), '2030-01-02 06:00:00', 'an offset is read, not dropped');});++ await check('a current broadcast stays one Live item until it becomes a recording', async () => {++ const db = t.app.locals.db;++ const item = (type, id) => db.prepare('SELECT title FROM pulse_items WHERE source_service = ? AND source_type = ? AND source_id = ?').get('live', type, String(id));++ liveStreams = [{ id: 999, username: 'alex', display_name: 'Alex', title: 'Night stream', started_at: badgeAt }];++ await t.app.locals.pulseSources.runOnce({ force: true });++ assert.ok(await item('stream', 999));++ assert.ok(!(await item('recording', 123)), 'a recording for the broadcast is suppressed while it is live');++ liveStreams = [];++ await t.app.locals.pulseSources.runOnce({ force: true });++ assert.ok(!(await item('stream', 999)), 'the ended Live item leaves when the recording arrives');++ assert.ok(await item('recording', 123));++ });++await done(t);})();
@@ -816,7 +816,7 @@ async function createApp(opts = {}) {
@@ -816,7 +816,7 @@ async function createApp(opts = {}) {
const item = rows[index];
const item = rows[index];
const card = feedTools.card(item);
const card = feedTools.card(item);
if (card.kind === 'live') {
if (card.kind === 'live') {
- card.live_now = live.has(feedTools.channelOf(card.url).toLowerCase());
+ card.live_now = feedTools.isCurrentLive(card, live);
if (!card.live_now) card.kind = 'stream';
if (!card.live_now) card.kind = 'stream';
}
}
seen.push(card);
seen.push(card);
@@ -173,19 +173,30 @@ function createTrending({ networkUrl, fetchImpl }) {
@@ -173,19 +173,30 @@ function createTrending({ networkUrl, fetchImpl }) {
/** Who is live on Live right now (GET /api/streams, cached a minute): a "went live" card says LIVE only for them. */
/** 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 }) {
function createLiveNow({ liveUrl, fetchImpl }) {
- let cache = new Set(); let at = 0;
+ let cache = new Map(); let at = 0;
return async function liveNow() {
return async function liveNow() {
if (Date.now() - at < 60_000) return cache;
if (Date.now() - at < 60_000) return cache;
at = Date.now();
at = Date.now();
try {
try {
const r = await fetchImpl(`${liveUrl}/api/streams`, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(3000) });
const r = await fetchImpl(`${liveUrl}/api/streams`, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(3000) });
const j = r.ok ? await r.json() : null;
const j = r.ok ? await r.json() : null;
- cache = new Set(((j && j.streams) || []).map((x) => String((x && x.username) || '').toLowerCase()).filter(Boolean));
+ cache = new Map(((j && j.streams) || []).map((x) => {
+ const name = String((x && x.username) || '').toLowerCase();
+ const raw = String((x && x.started_at) || '');
+ const stamp = /^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d$/.test(raw) ? `${raw.replace(' ', 'T')}Z` : raw;
+ return [name, Date.parse(stamp)];
+ }).filter(([name]) => name));
} catch { /* keep the last answer */ }
} catch { /* keep the last answer */ }
return cache;
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 ''; } };
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 ''; } };
+const isCurrentLive = (card, live) => {
+ const name = channelOf(card.url).toLowerCase();
+ if (!live.has(name)) return false;
+ const started = live.get(name), recorded = Date.parse(card.at || '');
+ return !Number.isFinite(started) || !Number.isFinite(recorded) || Math.abs(started - recorded) <= 10 * 60_000;
+};
function searchMatches(item, query) {
function searchMatches(item, query) {
const q = clean(query, 80).toLowerCase();
const q = clean(query, 80).toLowerCase();
@@ -221,7 +232,7 @@ function createFeedApi({ pulse, viewers, limits, db, networkUrl = 'https://openv
@@ -221,7 +232,7 @@ function createFeedApi({ pulse, viewers, limits, db, networkUrl = 'https://openv
const trending = createTrending({ networkUrl, fetchImpl });
const trending = createTrending({ networkUrl, fetchImpl });
const liveNow = createLiveNow({ liveUrl, fetchImpl });
const liveNow = createLiveNow({ liveUrl, fetchImpl });
// A "went live" item is live now only while that channel is on Live's live list.
// 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()); if (!c.live_now) c.kind = 'stream'; } });
+ const mark = (cards, live) => cards.forEach((c) => { if (c.kind === 'live') { c.live_now = isCurrentLive(c, live); if (!c.live_now) c.kind = 'stream'; } });
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) : '';
@@ -321,4 +332,4 @@ function createFeedApi({ pulse, viewers, limits, db, networkUrl = 'https://openv
@@ -321,4 +332,4 @@ function createFeedApi({ pulse, viewers, limits, db, networkUrl = 'https://openv
return router;
return router;
}
}
-module.exports = { createFeedApi, kindOf, card, countsFor, channelOf, createLiveNow, searchMatches, searchExamples, foldBursts };
+module.exports = { createFeedApi, kindOf, card, countsFor, channelOf, createLiveNow, isCurrentLive, searchMatches, searchExamples, foldBursts };
@@ -282,6 +282,19 @@ function createPulseSources({ db, config = {}, fetchImpl = globalThis.fetch, now
@@ -282,6 +282,19 @@ function createPulseSources({ db, config = {}, fetchImpl = globalThis.fetch, now
async function publicContent() {
async function publicContent() {
const seen = new Set();
const seen = new Set();
let cursor = null, created = 0;
let cursor = null, created = 0;
+ const live = await getJson(`${liveApi}/api/streams`);
+ const onAir = new Map();
+ for (const s of (Array.isArray(live.streams) ? live.streams : [])) {
+ const id = Number(s && s.id), channel = clean(s && s.username, 64);
+ const started = sqlFrom(s && s.started_at, null);
+ if (!Number.isSafeInteger(id) || id < 1 || !/^[A-Za-z0-9_.-]{1,64}$/.test(channel) || !started) continue;
+ onAir.set(channel.toLowerCase(), Date.parse(`${started.replace(' ', 'T')}Z`));
+ const name = clean(s.display_name || channel, 80);
+ const r = await store.upsertItem(db, { source_service: 'live', source_type: 'stream', source_id: String(id),
+ title: `${name} went live: ${clean(s.title, 160) || 'a stream'}`, url: `${liveApi}/@${encodeURIComponent(channel)}`,
+ actor_name: name, origin: 'user', occurred_at: started });
+ if (r.created) created++;
+ }
for (let page = 0; page < 100; page++) {
for (let page = 0; page < 100; page++) {
const url = new URL('/api/content/feed', liveApi);
const url = new URL('/api/content/feed', liveApi);
url.searchParams.set('sort', 'new');
url.searchParams.set('sort', 'new');
@@ -295,10 +308,13 @@ function createPulseSources({ db, config = {}, fetchImpl = globalThis.fetch, now
@@ -295,10 +308,13 @@ function createPulseSources({ db, config = {}, fetchImpl = globalThis.fetch, now
const id = clean(v.id, 80);
const id = clean(v.id, 80);
if (!/^[A-Za-z0-9_-]{1,80}$/.test(id)) continue;
if (!/^[A-Za-z0-9_-]{1,80}$/.test(id)) continue;
const type = v.kind === 'vod' ? 'recording' : 'clip';
const type = v.kind === 'vod' ? 'recording' : 'clip';
- seen.add(`${type}:${id}`);
const at = sqlFrom(v.created_at, null);
const at = sqlFrom(v.created_at, null);
if (!at) continue;
if (!at) continue;
const channel = clean(v.channel && v.channel.username, 64);
const channel = clean(v.channel && v.channel.username, 64);
+ const currentStart = onAir.get(channel.toLowerCase());
+ if (type === 'recording' && Number.isFinite(currentStart)
+ && Math.abs(Date.parse(`${at.replace(' ', 'T')}Z`) - currentStart) <= 10 * MINUTE) continue;
+ seen.add(`${type}:${id}`);
let prior = null;
let prior = null;
if (type === 'recording' && channel) {
if (type === 'recording' && channel) {
prior = await db.prepare(`SELECT actor_subject, source_id FROM pulse_items WHERE source_service = 'live' AND source_type = 'stream'
prior = await db.prepare(`SELECT actor_subject, source_id FROM pulse_items WHERE source_service = 'live' AND source_type = 'stream'
@@ -16,7 +16,7 @@ const FRAME = { cards: [
@@ -16,7 +16,7 @@ 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 LIVE = { streams: [{ username: 'alex' }, { username: 'ada' }] };
+const LIVE = { streams: [{ username: 'alex', started_at: new Date().toISOString() }, { username: 'ada', started_at: new Date().toISOString() }] };
const feedFetch = async (url) => {
const feedFetch = async (url) => {
const u = String(url);
const u = String(url);
return { ok: /\/api\/(frame|streams)/.test(u), json: async () => (/\/api\/streams/.test(u) ? LIVE : FRAME) };
return { ok: /\/api\/(frame|streams)/.test(u), json: async () => (/\/api\/streams/.test(u) ? LIVE : FRAME) };
@@ -96,7 +96,7 @@ const at = (minsAgo) => new Date(Date.now() - minsAgo * 60_000).toISOString().re
@@ -96,7 +96,7 @@ const at = (minsAgo) => new Date(Date.now() - minsAgo * 60_000).toISOString().re
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.status, 200, r.text);
const j = r.json();
const j = r.json();
const kinds = new Set(j.items.map((x) => x.kind));
const kinds = new Set(j.items.map((x) => x.kind));
- for (const k of ['live', 'clip', 'video', 'pic', 'paste', 'meme', 'game', 'update', 'wiki', 'quest', 'chat', 'event', 'member']) assert.ok(kinds.has(k), `kind ${k} (${[...kinds]})`);
+ for (const k of ['stream', 'clip', 'video', 'pic', 'paste', 'meme', 'game', 'update', 'wiki', 'quest', 'chat', 'event', 'member']) assert.ok(kinds.has(k), `kind ${k} (${[...kinds]})`);
assert.ok('next_cursor' in j);
assert.ok('next_cursor' in j);
for (const x of j.items) for (const k of ['id', 'kind', 'url', 'title', 'text', 'at', 'site', 'site_name', 'actor', 'media', 'counts']) assert.ok(k in x, `${k} on every card`);
for (const x of j.items) for (const k of ['id', 'kind', 'url', 'title', 'text', 'at', 'site', 'site_name', 'actor', 'media', 'counts']) assert.ok(k in x, `${k} on every card`);
const c = j.items.find((x) => x.url === 'https://openvibe.live/clip/c1');
const c = j.items.find((x) => x.url === 'https://openvibe.live/clip/c1');
@@ -144,14 +144,17 @@ const at = (minsAgo) => new Date(Date.now() - minsAgo * 60_000).toISOString().re
@@ -144,14 +144,17 @@ const at = (minsAgo) => new Date(Date.now() - minsAgo * 60_000).toISOString().re
});
});
await check('LIVE only for a channel that is live now', async () => {
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 ${netSvc}`, '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}` }) });
+ const mk = (id, who, at = null) => t.get('/api/v1/pulse/items', { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${netSvc}`, 'x-ov-subject': alex.subject_id },
+ body: JSON.stringify({ ref: { service: 'live', type: 'stream', id }, title: `${who} went live: ${at ? 'an older stream' : 'a stream'}`, url: `https://openvibe.live/@${who}`, ...(at ? { occurred_at: at } : {}) }) });
assert.strictEqual((await mk('s1', 'ada')).status, 201);
assert.strictEqual((await mk('s1', 'ada')).status, 201);
assert.strictEqual((await mk('s2', 'bo')).status, 201);
assert.strictEqual((await mk('s2', 'bo')).status, 201);
+ assert.strictEqual((await mk('s3', 'alex', new Date(Date.now() - 2 * 86_400_000).toISOString())).status, 201);
const j = (await t.get('/api/v1/feed?site=live&limit=40')).json();
const j = (await t.get('/api/v1/feed?site=live&limit=40')).json();
const by = Object.fromEntries(j.items.map((x) => [x.url, [x.kind, x.live_now]]));
const by = Object.fromEntries(j.items.map((x) => [x.url, [x.kind, x.live_now]]));
assert.deepStrictEqual(by['https://openvibe.live/@ada'], ['live', true], 'ada is on the live list');
assert.deepStrictEqual(by['https://openvibe.live/@ada'], ['live', true], 'ada is on the live list');
assert.deepStrictEqual(by['https://openvibe.live/@bo'], ['stream', false], 'bo went live earlier: a stream that happened, never LIVE');
assert.deepStrictEqual(by['https://openvibe.live/@bo'], ['stream', false], 'bo went live earlier: a stream that happened, never LIVE');
+ const older = j.items.find((x) => x.title === 'alex went live: an older stream');
+ assert.deepStrictEqual([older.kind, older.live_now], ['stream', false], 'an old broadcast stays ended while the same channel is live again');
for (const kind of ['live', 'stream']) {
for (const kind of ['live', 'stream']) {
const filtered = (await t.get(`/api/v1/feed?kind=${kind}&limit=40`)).json();
const filtered = (await t.get(`/api/v1/feed?kind=${kind}&limit=40`)).json();
assert.ok(filtered.items.length > 0, `${kind} has a card`);
assert.ok(filtered.items.length > 0, `${kind} has a card`);
@@ -14,6 +14,7 @@ const day = (ms) => new Date(ms).toISOString().slice(0, 10);
@@ -14,6 +14,7 @@ const day = (ms) => new Date(ms).toISOString().slice(0, 10);
const yesterday = day(Date.now() - 86_400_000);
const yesterday = day(Date.now() - 86_400_000);
const badgeAt = iso(Date.now() - 3_600_000);
const badgeAt = iso(Date.now() - 3_600_000);
const calls = [];
const calls = [];
+ let liveStreams = [];
let galleryRows = [
let galleryRows = [
{ id: 'pic_01JAB2C3D4E5F6G7H8J9K0MNPQ', title: 'First photo', author: 'Alex', listed: true, published_at: badgeAt,
{ id: 'pic_01JAB2C3D4E5F6G7H8J9K0MNPQ', title: 'First photo', author: 'Alex', listed: true, published_at: badgeAt,
links: { page: 'https://openvibe.pics/i/firstpic', thumb: 'https://openvibe.pics/i/firstpic/thumb.webp' }, width: 800, height: 600 },
links: { page: 'https://openvibe.pics/i/firstpic', thumb: 'https://openvibe.pics/i/firstpic/thumb.webp' }, width: 800, height: 600 },
@@ -37,6 +38,7 @@ const day = (ms) => new Date(ms).toISOString().slice(0, 10);
@@ -37,6 +38,7 @@ const day = (ms) => new Date(ms).toISOString().slice(0, 10);
if (u.includes('/api/rundown/streams')) return { ok: true, json: async () => ({ streams: [{ stream_id: 123, live: false,
if (u.includes('/api/rundown/streams')) return { ok: true, json: async () => ({ streams: [{ stream_id: 123, live: false,
title: 'Night stream', started_at: badgeAt, thumbnail_url: 'https://openvibe.media/night.webp',
title: 'Night stream', started_at: badgeAt, thumbnail_url: 'https://openvibe.media/night.webp',
episode: { title: 'The night sky', logline: 'A recap', aired_at: badgeAt, audience: 'public' } }], next: null }) };
episode: { title: 'The night sky', logline: 'A recap', aired_at: badgeAt, audience: 'public' } }], next: null }) };
+ if (u.endsWith('/api/streams')) return { ok: true, json: async () => ({ streams: liveStreams }) };
if (u.includes('/api/content/feed')) return { ok: true, json: async () => ({ items: [
if (u.includes('/api/content/feed')) return { ok: true, json: async () => ({ items: [
{ id: 123, kind: 'vod', title: 'Night stream', href: 'https://openvibe.video/watch?v=night', thumbnail_url: 'https://openvibe.media/night.webp', created_at: badgeAt, channel: { username: 'alex', display_name: 'Alex' }, duration_seconds: 500 },
{ id: 123, kind: 'vod', title: 'Night stream', href: 'https://openvibe.video/watch?v=night', thumbnail_url: 'https://openvibe.media/night.webp', created_at: badgeAt, channel: { username: 'alex', display_name: 'Alex' }, duration_seconds: 500 },
{ id: 124, kind: 'clip', title: 'Night clip', href: 'https://openvibe.video/clip/night', thumbnail_url: 'https://openvibe.media/clip.webp', created_at: badgeAt, channel: { username: 'alex', display_name: 'Alex' }, duration_seconds: 25 },
{ id: 124, kind: 'clip', title: 'Night clip', href: 'https://openvibe.video/clip/night', thumbnail_url: 'https://openvibe.media/clip.webp', created_at: badgeAt, channel: { username: 'alex', display_name: 'Alex' }, duration_seconds: 25 },
@@ -149,5 +151,18 @@ const day = (ms) => new Date(ms).toISOString().slice(0, 10);
@@ -149,5 +151,18 @@ const day = (ms) => new Date(ms).toISOString().slice(0, 10);
assert.strictEqual(store.pulseTime('2030-01-02T08:00:00+02:00'), '2030-01-02 06:00:00', 'an offset is read, not dropped');
assert.strictEqual(store.pulseTime('2030-01-02T08:00:00+02:00'), '2030-01-02 06:00:00', 'an offset is read, not dropped');
});
});
+ await check('a current broadcast stays one Live item until it becomes a recording', async () => {
+ const db = t.app.locals.db;
+ const item = (type, id) => db.prepare('SELECT title FROM pulse_items WHERE source_service = ? AND source_type = ? AND source_id = ?').get('live', type, String(id));
+ liveStreams = [{ id: 999, username: 'alex', display_name: 'Alex', title: 'Night stream', started_at: badgeAt }];
+ await t.app.locals.pulseSources.runOnce({ force: true });
+ assert.ok(await item('stream', 999));
+ assert.ok(!(await item('recording', 123)), 'a recording for the broadcast is suppressed while it is live');
+ liveStreams = [];
+ await t.app.locals.pulseSources.runOnce({ force: true });
+ assert.ok(!(await item('stream', 999)), 'the ended Live item leaves when the recording arrives');
+ assert.ok(await item('recording', 123));
+ });
+
await done(t);
await done(t);
})();
})();