openvibers/openvibe.community Commits
Feed: a Rundown episode is its own card (#400)
Changes
nnew file mode 100644@@@ -0,0 +1,5 @@++-- phase: expand++-- The menu's Feed: a Rundown episode card keeps the streamer it is about beside the item (server/pulse/sources.js),++-- so the card can name "The Rundown" as the actor and the person as the subject. Plain JSON text; absent on every++-- older row, which is fine (the card has no subject).++ALTER TABLE pulse_items ADD COLUMN IF NOT EXISTS subject_json text COLLATE "C";@@@ -9,8 +9,13 @@** GET /?kind=live|stream|clip|video|pic|post|meme|game|update|wiki|quest|chat|event|member* &site=<source service>&q=<title or text>&following=1&cursor=<opaque>&limit=-- * → { items: [{ id, kind (live while on air; a go-live that is over is "stream"), url, title, text, at, site, site_name, actor: { name, avatar } | null,-- * media: { thumb, ratio, length } | null, counts: { views?, likes?, comments? }, live_now? }], next_cursor }++ * → { items: [{ id, kind (live while on air; a go-live that is over is "stream"; a Rundown episode is "rundown"),++ * url, title, text, at, site, site_name,++ * actor: { name, avatar } | { name: 'The Rundown', mark: 'rundown', avatar: null } | null,++ * subject?: { name, handle, avatar } (the streamer a Rundown episode is about),++ * image?: string (the episode's own picture; media.thumb carries it too),++ * media: { thumb, ratio, length } | null, duration_s?: number (a recording's length; never a bare++ * media.length badge), counts: { views?, likes?, comments? }, live_now? }], next_cursor }** Text only (the client draws every field as text); https links only; nothing from Frame without a picture and a title.* following=1 is the signed-in person's Network follows; q searches the card's title and text; counts are Community's@@@ -22,7 +27,7 @@ const express = require('express');const contracts = require('openvibe-contracts');const { run, fail, isoTime, sqlTime, encodeCursor } = require('../http/v1');--const KINDS = ['live', 'stream', 'clip', 'video', 'pic', 'post', 'meme', 'game', 'update', 'wiki', 'quest', 'chat', 'event', 'member', 'avatar', 'banner', 'paste', 'site', 'space', 'follow', 'episode'];++const KINDS = ['live', 'stream', 'clip', 'video', 'pic', 'post', 'meme', 'game', 'update', 'wiki', 'quest', 'chat', 'event', 'member', 'avatar', 'banner', 'paste', 'site', 'space', 'follow', 'episode', 'rundown'];const SITE_NAMES = { live: 'Live', 'media-hub': 'Video', video: 'Video', pics: 'Pics', space: 'Space', meme: 'Meme', games: 'Games', community: 'Community', blog: 'Blog', wiki: 'Wiki', quest: 'Quest', chat: 'Chat', tools: 'Tools', news: 'News', report: 'Report', today: 'Today', network: 'Network' };const MAX_PAGE_READS = 8;const https = (u) => { try { const x = new URL(String(u)); return x.protocol === 'https:' ? x.href : null; } catch { return null; } };@@@ -39,7 +44,7 @@ function kindOf(i) {if (t === 'site') return 'site';if (t === 'space') return 'space';if (t === 'paste') return 'paste';-- if (t === 'episode') return 'episode';++ if (t === 'episode') return 'rundown';if (t === 'clip' || t === 'moment') return 'clip';if (/^(vod|video|recording|upload)$/.test(t) || s === 'media-hub' || s === 'video') return 'video';if (/^(pic|picture|image|photo)$/.test(t) || s === 'pics') return 'pic';@@@ -56,10 +61,18 @@ function kindOf(i) {function card(i) {const site = String((i.source && i.source.service) || 'community');-- const a = i.actor || (i.actor_name ? { display_name: i.actor_name } : null);++ const a = (i.actor && (i.actor.display_name || i.actor.username)) ? i.actor : (i.actor_name ? { display_name: i.actor_name } : null);++ const kind = kindOf(i);++ // A Rundown episode is its own thing: the desk is the actor (never "System"), the streamer is the subject the++ // card is about, and the episode's own AI picture is the art (the stream thumbnail when there is none). A++ // recording carries its length as duration_s, never as a bare media.length badge.++ const rundown = kind === 'rundown';++ const subject = i.subject && typeof i.subject === 'object' && !Array.isArray(i.subject)++ ? { name: clean(i.subject.name, 80), handle: clean(i.subject.handle, 80) || null, avatar: https(i.subject.avatar) } : null;++ const duration = Number(i.media_length) > 0 ? Number(i.media_length) : null;return {id: `p:${i.id}`,-- kind: kindOf(i),++ kind,url: https(i.url) || i.url,title: clean(i.title, 300),text: clean(i.text, 280),@@@ -67,11 +80,14 @@ function card(i) {...(i.starts_at ? { starts_at: isoTime(i.starts_at) || i.starts_at } : {}),site,site_name: SITE_NAMES[site] || site.replace(/^app:/, ''),-- actor: a ? { name: clean(a.display_name || a.username || '', 80) || null, avatar: https(a.avatar_url) } : null,-- media: i.picture_url && https(i.picture_url) ? { thumb: https(i.picture_url), ...(i.preview_url && https(i.preview_url) ? { preview: https(i.preview_url) } : {}), ratio: Number(i.media_ratio) > 0 ? Number(i.media_ratio) : 16 / 9, ...(Number(i.media_length) > 0 ? { length: Number(i.media_length) } : {}) } : null,++ actor: rundown ? { name: 'The Rundown', mark: 'rundown', avatar: null } : (a ? { name: clean(a.display_name || a.username || '', 80) || null, avatar: https(a.avatar_url) } : null),++ ...(subject ? { subject } : {}),++ ...(rundown && i.picture_url && https(i.picture_url) ? { image: https(i.picture_url) } : {}),++ ...(duration && !rundown && kind === 'video' ? { duration_s: duration } : {}),++ media: i.picture_url && https(i.picture_url) ? { thumb: https(i.picture_url), ...(i.preview_url && https(i.preview_url) ? { preview: https(i.preview_url) } : {}), ratio: Number(i.media_ratio) > 0 ? Number(i.media_ratio) : 16 / 9, ...(duration && kind !== 'video' ? { length: duration } : {}) } : null,counts: {},...(i.visibility === 'followers' ? { visibility: 'followers' } : {}),-- ...(i.label ? { label: i.label } : {}),++ ...(!rundown && i.label ? { label: i.label } : {}),};}@@@ -254,7 +270,7 @@ function rankCards(cards, followedIds = new Set(), now = Date.now()) {function searchMatches(item, query) {const q = clean(query, 80).toLowerCase();if (!q) return true;-- const aliases = { clip: 'clip', clips: 'clip', video: 'video', videos: 'video', recording: 'video', recordings: 'video', photo: 'pic', photos: 'pic', pic: 'pic', pics: 'pic', post: 'post', posts: 'post', paste: 'paste', pastes: 'paste', stream: 'stream', streams: 'stream', live: 'live', game: 'game', games: 'game', update: 'update', updates: 'update', avatar: 'avatar', avatars: 'avatar', banner: 'banner', banners: 'banner', site: 'site', sites: 'site', space: 'space', spaces: 'space', follow: 'follow', follows: 'follow', episode: 'episode', episodes: 'episode' };++ const aliases = { clip: 'clip', clips: 'clip', video: 'video', videos: 'video', recording: 'video', recordings: 'video', photo: 'pic', photos: 'pic', pic: 'pic', pics: 'pic', post: 'post', posts: 'post', paste: 'paste', pastes: 'paste', stream: 'stream', streams: 'stream', live: 'live', game: 'game', games: 'game', update: 'update', updates: 'update', avatar: 'avatar', avatars: 'avatar', banner: 'banner', banners: 'banner', site: 'site', sites: 'site', space: 'space', spaces: 'space', follow: 'follow', follows: 'follow', episode: 'episode', episodes: 'episode', rundown: 'rundown', rundowns: 'rundown' };const kindWord = (q.match(/\b[a-z]+\b/g) || []).find((word) => aliases[word]);const kind = kindWord && aliases[kindWord];if (kind && item.kind !== kind && !(kind === 'stream' && item.kind === 'live')) return false;@@@ -263,7 +279,7 @@ function searchMatches(item, query) {if (/\byesterday\b/.test(q) && day !== new Date(Date.now() - 86_400_000).toISOString().slice(0, 10)) return false;const date = /\b\d{4}-\d{2}-\d{2}\b/.exec(q);if (date && day !== date[0]) return false;-- const words = q.replace(/\b(clips?|videos?|recordings?|photos?|pics?|posts?|pastes?|streams?|live|games?|updates?|avatars?|banners?|sites?|spaces?|follows?|episodes?|from|today|yesterday|new)\b/g, '').replace(/\b\d{4}-\d{2}-\d{2}\b/g, '').trim();++ const words = q.replace(/\b(clips?|videos?|recordings?|photos?|pics?|posts?|pastes?|streams?|live|games?|updates?|avatars?|banners?|sites?|spaces?|follows?|episodes?|rundowns?|from|today|yesterday|new)\b/g, '').replace(/\b\d{4}-\d{2}-\d{2}\b/g, '').trim();return !words || [item.title, item.text, item.site, item.site_name, item.kind, item.actor && item.actor.name].some((v) => String(v || '').toLowerCase().includes(words));}@@@ -22,6 +22,7 @@ const { fail, sqlTime, isoTime, encodeCursor, decodeCursor } = require('../http/const { createAuthors } = require('../identity/authors');const ORIGINS = ['user', 'ai', 'system'];++const parseJson = (v, d) => { try { const o = JSON.parse(v); return o == null ? d : o; } catch { return d; } };const PAGE = 30;const FUTURE_SKEW_MS = 5 * 60_000;@@@ -107,6 +108,8 @@ function createPulse({ db, network = null, config = {}, fetchImpl = globalThis.f...(i.preview_url ? { preview_url: i.preview_url } : {}),...(i.media_ratio ? { media_ratio: i.media_ratio } : {}),...(i.media_length ? { media_length: i.media_length } : {}),++ ...(i.media_length ? { duration_s: Number(i.media_length) } : {}),++ ...(i.subject_json ? { subject: parseJson(i.subject_json, null) } : {}),...(i.text ? { text: i.text } : {}),...(i.starts_at ? { starts_at: isoTime(i.starts_at) } : {}),...(i.actor_name ? { actor_name: i.actor_name } : {}),@@@ -284,11 +284,21 @@ function createPulseSources({ db, config = {}, fetchImpl = globalThis.fetch, nowif (!title) continue;seen.add(id);const prior = await db.prepare("SELECT actor_subject FROM pulse_items WHERE source_service = 'live' AND source_type = 'stream' AND source_id = ?").get(id);++ const user = s.user || {};++ // The card's subject is the streamer the episode is about; the actor is The Rundown itself.++ const subject = user.username ? {++ name: clean(user.display_name || user.username, 80) || null,++ handle: `@${clean(user.username, 64)}`,++ avatar: https(user.avatar_url),++ } : null;++ // The episode's own AI picture is the art; the stream's thumbnail stays the fallback until it exists.++ const picture = https(s.episode.picture_url);const r = await store.upsertItem(db, { source_service: 'live', source_type: 'episode', source_id: id,-- title: `Rundown: ${title}`, url: `${liveApi}/rundown/e/${id}`,-- actor_subject: prior && prior.actor_subject || null, origin: prior && prior.actor_subject ? 'user' : 'system',++ title, url: `${liveApi}/rundown/e/${id}`,++ actor_name: 'The Rundown', actor_subject: prior && prior.actor_subject || null, origin: 'system',occurred_at: sqlFrom(s.episode.aired_at || s.started_at, sqlTime(now())),-- text: clean(s.episode.logline, 280), picture_url: https(s.thumbnail_url) });++ text: clean(s.episode.logline, 280), picture_url: picture || https(s.thumbnail_url),++ media_ratio: picture ? 1200 / 630 : null, subject });if (r.created) created++;}before = typeof out.next === 'string' && out.next ? out.next : null;@@@ -32,6 +32,11 @@ function ms(v) {return Date.parse(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.test(s) ? `${s.replace(' ', 'T')}Z` : s);}++/** The subject a card is about (a Rundown episode's streamer), as JSON text or null. */++function subjectJsonOf(it) {++ return it && it.subject && typeof it.subject === 'object' && !Array.isArray(it.subject) ? JSON.stringify(it.subject) : null;++}++/** A live.stream.started item: one row per broadcast, not per reconnect. → { item, created } */async function upsertLiveItem(db, it) {it = { ...it, occurred_at: pulseTime(it.occurred_at) };@@@ -88,16 +93,16 @@ async function upsertItem(db, it) {// 2026-11-12); a card's person, once known, never changes.const person = it.actor_subject || null;if (it.touch) {-- await db.prepare('UPDATE pulse_items SET title = ?, url = ?, picture_url = ?, preview_url = ?, media_ratio = ?, media_length = ?, app_name = ?, text = ?, starts_at = ?, actor_name = ?, actor_subject = COALESCE(actor_subject, ?), visibility = ?, occurred_at = ? WHERE source_service = ? AND source_type = ? AND source_id = ?')-- .run(it.title, it.url, it.picture_url || null, it.preview_url || null, it.media_ratio || null, it.media_length || null, it.app_name || null, it.text == null ? null : String(it.text).slice(0, 300), pulseTime(it.starts_at) || null, it.actor_name || null, person, it.visibility || 'public', it.occurred_at, ...key);++ await db.prepare('UPDATE pulse_items SET title = ?, url = ?, picture_url = ?, preview_url = ?, media_ratio = ?, media_length = ?, app_name = ?, text = ?, starts_at = ?, actor_name = ?, actor_subject = COALESCE(actor_subject, ?), visibility = ?, occurred_at = ?, subject_json = ? WHERE source_service = ? AND source_type = ? AND source_id = ?')++ .run(it.title, it.url, it.picture_url || null, it.preview_url || null, it.media_ratio || null, it.media_length || null, it.app_name || null, it.text == null ? null : String(it.text).slice(0, 300), pulseTime(it.starts_at) || null, it.actor_name || null, person, it.visibility || 'public', it.occurred_at, subjectJsonOf(it), ...key);} else {-- await db.prepare('UPDATE pulse_items SET title = ?, url = ?, picture_url = ?, preview_url = ?, media_ratio = ?, media_length = ?, app_name = ?, text = ?, starts_at = ?, actor_name = ?, actor_subject = COALESCE(actor_subject, ?), visibility = ? WHERE source_service = ? AND source_type = ? AND source_id = ?')-- .run(it.title, it.url, it.picture_url || null, it.preview_url || null, it.media_ratio || null, it.media_length || null, it.app_name || null, it.text == null ? null : String(it.text).slice(0, 300), pulseTime(it.starts_at) || null, it.actor_name || null, person, it.visibility || 'public', ...key);++ await db.prepare('UPDATE pulse_items SET title = ?, url = ?, picture_url = ?, preview_url = ?, media_ratio = ?, media_length = ?, app_name = ?, text = ?, starts_at = ?, actor_name = ?, actor_subject = COALESCE(actor_subject, ?), visibility = ?, subject_json = ? WHERE source_service = ? AND source_type = ? AND source_id = ?')++ .run(it.title, it.url, it.picture_url || null, it.preview_url || null, it.media_ratio || null, it.media_length || null, it.app_name || null, it.text == null ? null : String(it.text).slice(0, 300), pulseTime(it.starts_at) || null, it.actor_name || null, person, it.visibility || 'public', subjectJsonOf(it), ...key);}return { item: await find.get(...key), created: false };}-- await db.prepare(`INSERT INTO pulse_items (source_service, source_type, source_id, title, url, actor_subject, origin, visibility, occurred_at, picture_url, preview_url, media_ratio, media_length, app_name, text, starts_at, actor_name)-- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(...key, it.title, it.url, it.actor_subject || null, it.origin, it.visibility || 'public', it.occurred_at, it.picture_url || null, it.preview_url || null, it.media_ratio || null, it.media_length || null, it.app_name || null, it.text == null ? null : String(it.text).slice(0, 300), pulseTime(it.starts_at) || null, it.actor_name || null);++ await db.prepare(`INSERT INTO pulse_items (source_service, source_type, source_id, title, url, actor_subject, origin, visibility, occurred_at, picture_url, preview_url, media_ratio, media_length, app_name, text, starts_at, actor_name, subject_json)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(...key, it.title, it.url, it.actor_subject || null, it.origin, it.visibility || 'public', it.occurred_at, it.picture_url || null, it.preview_url || null, it.media_ratio || null, it.media_length || null, it.app_name || null, it.text == null ? null : String(it.text).slice(0, 300), pulseTime(it.starts_at) || null, it.actor_name || null, subjectJsonOf(it));return { item: await find.get(...key), created: true };});}nnew file mode 100644@@@ -0,0 +1,91 @@++'use strict';++/**++ * The menu Feed's two Live cards (server/pulse/feed.js):++ * a Rundown episode is kind 'rundown': the actor is The Rundown with its own mark, the streamer it is about is++ * `subject` {name, handle, avatar}, the art is the episode's AI picture (the stream thumbnail when none), and it is++ * never labelled "System"; a recording keeps its own actor, carries its length as `duration_s` (never a bare++ * media.length badge) and links to the recording's watch page, not the channel.++ */++const assert = require('assert');++const http = require('http');++const { boot, check, done } = require('./helpers/app');++const { createPulseSources } = require('../server/pulse/sources');++++(async () => {++ const t = await boot();++ const now = new Date().toISOString().replace('T', ' ').slice(0, 19);++ const insert = t.db.prepare(`INSERT INTO pulse_items (source_service, source_type, source_id, title, url, actor_subject, actor_name, origin, visibility, occurred_at, picture_url, media_ratio, media_length, subject_json)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'public', ?, ?, ?, ?, ?)`);++ await insert.run('live', 'episode', '3005', 'WW3 Prep: The Case Against Smooth Apologies', 'https://openvibe.live/rundown/e/3005',++ null, 'The Rundown', 'system', now, 'https://openvibe.media/f/rundown-picture.jpg', 1200 / 630, null,++ JSON.stringify({ name: 'Maticus', handle: '@maticus', avatar: 'https://openvibe.media/o/med_01M6SV8E153FM2T70WAF14TB4W' }));++ await insert.run('live', 'recording', 'vod-6393', 'Recording: WW3 prep', 'https://openvibe.video/watch?v=ivkluOg7ieQ',++ 'usr_01KKT9AC60KM7CRTB3WN1Z8P56', 'Maticus', 'user', now, 'https://openvibe.media/t/vod-6393.jpg', 16 / 9, 5203, null);++++ const page = await t.get('/api/v1/feed?limit=10');++ assert.strictEqual(page.status, 200);++ const body = await page.json();++ const rundown = body.items.find((x) => x.id === 'p:1');++ assert.ok(rundown, 'the Rundown card is in the feed');++ assert.strictEqual(rundown.kind, 'rundown');++ assert.deepStrictEqual(rundown.actor, { name: 'The Rundown', mark: 'rundown', avatar: null });++ assert.deepStrictEqual(rundown.subject, { name: 'Maticus', handle: '@maticus', avatar: 'https://openvibe.media/o/med_01M6SV8E153FM2T70WAF14TB4W' });++ assert.strictEqual(rundown.image, 'https://openvibe.media/f/rundown-picture.jpg');++ assert.strictEqual(rundown.media.thumb, 'https://openvibe.media/f/rundown-picture.jpg');++ assert.ok(Math.abs(rundown.media.ratio - 1200 / 630) < 1e-9, 'the AI picture keeps its 1200×630 ratio');++ assert.strictEqual(rundown.label, undefined, 'The Rundown, not "System"');++ assert.ok(rundown.url.endsWith('/rundown/e/3005'));++++ const recording = body.items.find((x) => x.id === 'p:2');++ assert.ok(recording, 'the recording is in the feed');++ assert.strictEqual(recording.kind, 'video');++ assert.strictEqual(recording.actor.name, 'Maticus');++ assert.strictEqual(recording.duration_s, 5203, 'the length is a duration, not a bare number');++ assert.ok(!recording.media.length, 'no bare length badge on a recording');++ assert.strictEqual(recording.media.thumb, 'https://openvibe.media/t/vod-6393.jpg');++ assert.strictEqual(recording.url, 'https://openvibe.video/watch?v=ivkluOg7ieQ', "the recording's watch page, not the channel");++++ const only = await t.get('/api/v1/feed?kind=rundown&limit=10');++ const onlyBody = await only.json();++ assert.deepStrictEqual(onlyBody.items.map((x) => x.kind), ['rundown'], 'kind=rundown filters to the episode');++++ // The stored Pulse item carries the subject and duration too (the API a service reads).++ const pulse = await t.get('/api/v1/pulse');++ const items = (await pulse.json()).items;++ const pRundown = items.find((x) => x.source.type === 'episode');++ assert.deepStrictEqual(pRundown.subject, { name: 'Maticus', handle: '@maticus', avatar: 'https://openvibe.media/o/med_01M6SV8E153FM2T70WAF14TB4W' });++ assert.strictEqual(pRundown.duration_s, undefined);++ const pRecording = items.find((x) => x.source.type === 'recording');++ assert.strictEqual(pRecording.duration_s, 5203);++++ // ── The Live source itself: an episode's picture and subject are stored with the Pulse item ──++ const live = http.createServer((req, res) => {++ res.setHeader('content-type', 'application/json');++ if (req.url.startsWith('/api/rundown/streams')) return res.end(JSON.stringify({ streams: [{++ stream_id: 4242, live: false, thumbnail_url: 'https://openvibe.media/t/stream-thumb.jpg',++ user: { username: 'maticus', display_name: 'Maticus', avatar_url: 'https://openvibe.media/o/med_maticus' },++ episode: { title: 'The Fridge Files', logline: 'A night of cold takes.', aired_at: '2026-11-12 03:00:00', audience: 'public', picture_url: 'https://openvibe.media/f/episode-pic.jpg' },++ }], next: null }));++ res.statusCode = 404; res.end('{}');++ });++ await new Promise((r) => live.listen(0, '127.0.0.1', r));++ const url = `http://127.0.0.1:${live.address().port}`;++ const sources = createPulseSources({ db: t.db, config: { liveUrl: url, todayUrl: url, todayInternalUrl: url, networkUrl: url, networkInternalUrl: url, questUrl: url, questInternalUrl: url, memeUrl: url, memeInternalUrl: url, picsUrl: url, spaceUrl: url, playUrl: url } });++ const ran = await sources.runOnce({ force: true });++ const rundownSource = ran.find((x) => x.name === 'rundown-episodes');++ assert.ok(rundownSource && rundownSource.items === 1, 'the Rundown source read one episode');++ const stored = await t.db.prepare("SELECT * FROM pulse_items WHERE source_service = 'live' AND source_type = 'episode' AND source_id = '4242'").get();++ assert.strictEqual(stored.title, 'The Fridge Files', 'the actor carries the name, so the title does not repeat it');++ assert.strictEqual(stored.actor_name, 'The Rundown');++ assert.strictEqual(stored.picture_url, 'https://openvibe.media/f/episode-pic.jpg');++ assert.deepStrictEqual(JSON.parse(stored.subject_json), { name: 'Maticus', handle: '@maticus', avatar: 'https://openvibe.media/o/med_maticus' });++ assert.ok(Math.abs(Number(stored.media_ratio) - 1200 / 630) < 1e-9);++ const fed = await (await t.get('/api/v1/feed?limit=20')).json();++ const sourceCard = fed.items.find((x) => x.id === 'p:3');++ assert.strictEqual(sourceCard.kind, 'rundown');++ assert.strictEqual(sourceCard.subject.handle, '@maticus');++ live.close();++++ console.log('feed shape: Rundown kind/actor/mark/subject/image and recording duration_s/watch link ok');++ await done(t);++})().catch((e) => { console.error(e); process.exit(1); });@@@ -94,8 +94,10 @@ const day = (ms) => new Date(ms).toISOString().slice(0, 10);assert.strictEqual(meme.title, 'Open source meme');assert.strictEqual(meme.text, 'A public entry');assert.strictEqual(meme.url, 'https://openvibe.meme/m/open-source-meme');-- const episode = feed.items.find((x) => x.kind === 'episode');-- assert.strictEqual(episode.title, 'Rundown: The night sky');++ const episode = feed.items.find((x) => x.kind === 'rundown');++ assert.strictEqual(episode.title, 'The night sky');++ assert.deepStrictEqual(episode.actor, { name: 'The Rundown', mark: 'rundown', avatar: null }, 'The Rundown, not System');++ assert.strictEqual(episode.subject, undefined, 'the mock has no streamer row: no subject, never a made-up one');assert.strictEqual(episode.url, 'https://openvibe.live/rundown/e/123');assert.ok(feed.items.some((x) => x.kind === 'video' && x.title === 'Recording: Night stream'));assert.ok(feed.items.some((x) => x.kind === 'clip' && x.title === 'Night clip'));
new file mode 100644
new file mode 100644
@@ -0,0 +1,5 @@
@@ -0,0 +1,5 @@
+-- phase: expand
+-- The menu's Feed: a Rundown episode card keeps the streamer it is about beside the item (server/pulse/sources.js),
+-- so the card can name "The Rundown" as the actor and the person as the subject. Plain JSON text; absent on every
+-- older row, which is fine (the card has no subject).
+ALTER TABLE pulse_items ADD COLUMN IF NOT EXISTS subject_json text COLLATE "C";
@@ -9,8 +9,13 @@
@@ -9,8 +9,13 @@
*
*
* GET /?kind=live|stream|clip|video|pic|post|meme|game|update|wiki|quest|chat|event|member
* GET /?kind=live|stream|clip|video|pic|post|meme|game|update|wiki|quest|chat|event|member
* &site=<source service>&q=<title or text>&following=1&cursor=<opaque>&limit=
* &site=<source service>&q=<title or text>&following=1&cursor=<opaque>&limit=
- * → { items: [{ id, kind (live while on air; a go-live that is over is "stream"), url, title, text, at, site, site_name, actor: { name, avatar } | null,
- * media: { thumb, ratio, length } | null, counts: { views?, likes?, comments? }, live_now? }], next_cursor }
+ * → { items: [{ id, kind (live while on air; a go-live that is over is "stream"; a Rundown episode is "rundown"),
+ * url, title, text, at, site, site_name,
+ * actor: { name, avatar } | { name: 'The Rundown', mark: 'rundown', avatar: null } | null,
+ * subject?: { name, handle, avatar } (the streamer a Rundown episode is about),
+ * image?: string (the episode's own picture; media.thumb carries it too),
+ * media: { thumb, ratio, length } | null, duration_s?: number (a recording's length; never a bare
+ * media.length badge), counts: { views?, likes?, comments? }, live_now? }], next_cursor }
*
*
* 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.
* following=1 is the signed-in person's Network follows; q searches the card's title and text; counts are Community's
* following=1 is the signed-in person's Network follows; q searches the card's title and text; counts are Community's
@@ -22,7 +27,7 @@ const express = require('express');
@@ -22,7 +27,7 @@ const express = require('express');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
const { run, fail, isoTime, sqlTime, encodeCursor } = require('../http/v1');
const { run, fail, isoTime, sqlTime, encodeCursor } = require('../http/v1');
-const KINDS = ['live', 'stream', 'clip', 'video', 'pic', 'post', 'meme', 'game', 'update', 'wiki', 'quest', 'chat', 'event', 'member', 'avatar', 'banner', 'paste', 'site', 'space', 'follow', 'episode'];
+const KINDS = ['live', 'stream', 'clip', 'video', 'pic', 'post', 'meme', 'game', 'update', 'wiki', 'quest', 'chat', 'event', 'member', 'avatar', 'banner', 'paste', 'site', 'space', 'follow', 'episode', 'rundown'];
const SITE_NAMES = { live: 'Live', 'media-hub': 'Video', video: 'Video', pics: 'Pics', space: 'Space', meme: 'Meme', games: 'Games', community: 'Community', blog: 'Blog', wiki: 'Wiki', quest: 'Quest', chat: 'Chat', tools: 'Tools', news: 'News', report: 'Report', today: 'Today', network: 'Network' };
const SITE_NAMES = { live: 'Live', 'media-hub': 'Video', video: 'Video', pics: 'Pics', space: 'Space', meme: 'Meme', games: 'Games', community: 'Community', blog: 'Blog', wiki: 'Wiki', quest: 'Quest', chat: 'Chat', tools: 'Tools', news: 'News', report: 'Report', today: 'Today', network: 'Network' };
const MAX_PAGE_READS = 8;
const MAX_PAGE_READS = 8;
const https = (u) => { try { const x = new URL(String(u)); return x.protocol === 'https:' ? x.href : null; } catch { return null; } };
const https = (u) => { try { const x = new URL(String(u)); return x.protocol === 'https:' ? x.href : null; } catch { return null; } };
@@ -39,7 +44,7 @@ function kindOf(i) {
@@ -39,7 +44,7 @@ function kindOf(i) {
if (t === 'site') return 'site';
if (t === 'site') return 'site';
if (t === 'space') return 'space';
if (t === 'space') return 'space';
if (t === 'paste') return 'paste';
if (t === 'paste') return 'paste';
- if (t === 'episode') return 'episode';
+ if (t === 'episode') return 'rundown';
if (t === 'clip' || t === 'moment') return 'clip';
if (t === 'clip' || t === 'moment') return 'clip';
if (/^(vod|video|recording|upload)$/.test(t) || s === 'media-hub' || s === 'video') return 'video';
if (/^(vod|video|recording|upload)$/.test(t) || s === 'media-hub' || s === 'video') return 'video';
if (/^(pic|picture|image|photo)$/.test(t) || s === 'pics') return 'pic';
if (/^(pic|picture|image|photo)$/.test(t) || s === 'pics') return 'pic';
@@ -56,10 +61,18 @@ function kindOf(i) {
@@ -56,10 +61,18 @@ function kindOf(i) {
function card(i) {
function card(i) {
const site = String((i.source && i.source.service) || 'community');
const site = String((i.source && i.source.service) || 'community');
- const a = i.actor || (i.actor_name ? { display_name: i.actor_name } : null);
+ const a = (i.actor && (i.actor.display_name || i.actor.username)) ? i.actor : (i.actor_name ? { display_name: i.actor_name } : null);
+ const kind = kindOf(i);
+ // A Rundown episode is its own thing: the desk is the actor (never "System"), the streamer is the subject the
+ // card is about, and the episode's own AI picture is the art (the stream thumbnail when there is none). A
+ // recording carries its length as duration_s, never as a bare media.length badge.
+ const rundown = kind === 'rundown';
+ const subject = i.subject && typeof i.subject === 'object' && !Array.isArray(i.subject)
+ ? { name: clean(i.subject.name, 80), handle: clean(i.subject.handle, 80) || null, avatar: https(i.subject.avatar) } : null;
+ const duration = Number(i.media_length) > 0 ? Number(i.media_length) : null;
return {
return {
id: `p:${i.id}`,
id: `p:${i.id}`,
- kind: kindOf(i),
+ kind,
url: https(i.url) || i.url,
url: https(i.url) || i.url,
title: clean(i.title, 300),
title: clean(i.title, 300),
text: clean(i.text, 280),
text: clean(i.text, 280),
@@ -67,11 +80,14 @@ function card(i) {
@@ -67,11 +80,14 @@ function card(i) {
...(i.starts_at ? { starts_at: isoTime(i.starts_at) || i.starts_at } : {}),
...(i.starts_at ? { starts_at: isoTime(i.starts_at) || i.starts_at } : {}),
site,
site,
site_name: SITE_NAMES[site] || site.replace(/^app:/, ''),
site_name: SITE_NAMES[site] || site.replace(/^app:/, ''),
- actor: a ? { name: clean(a.display_name || a.username || '', 80) || null, avatar: https(a.avatar_url) } : null,
- media: i.picture_url && https(i.picture_url) ? { thumb: https(i.picture_url), ...(i.preview_url && https(i.preview_url) ? { preview: https(i.preview_url) } : {}), ratio: Number(i.media_ratio) > 0 ? Number(i.media_ratio) : 16 / 9, ...(Number(i.media_length) > 0 ? { length: Number(i.media_length) } : {}) } : null,
+ actor: rundown ? { name: 'The Rundown', mark: 'rundown', avatar: null } : (a ? { name: clean(a.display_name || a.username || '', 80) || null, avatar: https(a.avatar_url) } : null),
+ ...(subject ? { subject } : {}),
+ ...(rundown && i.picture_url && https(i.picture_url) ? { image: https(i.picture_url) } : {}),
+ ...(duration && !rundown && kind === 'video' ? { duration_s: duration } : {}),
+ media: i.picture_url && https(i.picture_url) ? { thumb: https(i.picture_url), ...(i.preview_url && https(i.preview_url) ? { preview: https(i.preview_url) } : {}), ratio: Number(i.media_ratio) > 0 ? Number(i.media_ratio) : 16 / 9, ...(duration && kind !== 'video' ? { length: duration } : {}) } : null,
counts: {},
counts: {},
...(i.visibility === 'followers' ? { visibility: 'followers' } : {}),
...(i.visibility === 'followers' ? { visibility: 'followers' } : {}),
- ...(i.label ? { label: i.label } : {}),
+ ...(!rundown && i.label ? { label: i.label } : {}),
};
};
}
}
@@ -254,7 +270,7 @@ function rankCards(cards, followedIds = new Set(), now = Date.now()) {
@@ -254,7 +270,7 @@ function rankCards(cards, followedIds = new Set(), now = Date.now()) {
function searchMatches(item, query) {
function searchMatches(item, query) {
const q = clean(query, 80).toLowerCase();
const q = clean(query, 80).toLowerCase();
if (!q) return true;
if (!q) return true;
- const aliases = { clip: 'clip', clips: 'clip', video: 'video', videos: 'video', recording: 'video', recordings: 'video', photo: 'pic', photos: 'pic', pic: 'pic', pics: 'pic', post: 'post', posts: 'post', paste: 'paste', pastes: 'paste', stream: 'stream', streams: 'stream', live: 'live', game: 'game', games: 'game', update: 'update', updates: 'update', avatar: 'avatar', avatars: 'avatar', banner: 'banner', banners: 'banner', site: 'site', sites: 'site', space: 'space', spaces: 'space', follow: 'follow', follows: 'follow', episode: 'episode', episodes: 'episode' };
+ const aliases = { clip: 'clip', clips: 'clip', video: 'video', videos: 'video', recording: 'video', recordings: 'video', photo: 'pic', photos: 'pic', pic: 'pic', pics: 'pic', post: 'post', posts: 'post', paste: 'paste', pastes: 'paste', stream: 'stream', streams: 'stream', live: 'live', game: 'game', games: 'game', update: 'update', updates: 'update', avatar: 'avatar', avatars: 'avatar', banner: 'banner', banners: 'banner', site: 'site', sites: 'site', space: 'space', spaces: 'space', follow: 'follow', follows: 'follow', episode: 'episode', episodes: 'episode', rundown: 'rundown', rundowns: 'rundown' };
const kindWord = (q.match(/\b[a-z]+\b/g) || []).find((word) => aliases[word]);
const kindWord = (q.match(/\b[a-z]+\b/g) || []).find((word) => aliases[word]);
const kind = kindWord && aliases[kindWord];
const kind = kindWord && aliases[kindWord];
if (kind && item.kind !== kind && !(kind === 'stream' && item.kind === 'live')) return false;
if (kind && item.kind !== kind && !(kind === 'stream' && item.kind === 'live')) return false;
@@ -263,7 +279,7 @@ function searchMatches(item, query) {
@@ -263,7 +279,7 @@ function searchMatches(item, query) {
if (/\byesterday\b/.test(q) && day !== new Date(Date.now() - 86_400_000).toISOString().slice(0, 10)) return false;
if (/\byesterday\b/.test(q) && day !== new Date(Date.now() - 86_400_000).toISOString().slice(0, 10)) return false;
const date = /\b\d{4}-\d{2}-\d{2}\b/.exec(q);
const date = /\b\d{4}-\d{2}-\d{2}\b/.exec(q);
if (date && day !== date[0]) return false;
if (date && day !== date[0]) return false;
- const words = q.replace(/\b(clips?|videos?|recordings?|photos?|pics?|posts?|pastes?|streams?|live|games?|updates?|avatars?|banners?|sites?|spaces?|follows?|episodes?|from|today|yesterday|new)\b/g, '').replace(/\b\d{4}-\d{2}-\d{2}\b/g, '').trim();
+ const words = q.replace(/\b(clips?|videos?|recordings?|photos?|pics?|posts?|pastes?|streams?|live|games?|updates?|avatars?|banners?|sites?|spaces?|follows?|episodes?|rundowns?|from|today|yesterday|new)\b/g, '').replace(/\b\d{4}-\d{2}-\d{2}\b/g, '').trim();
return !words || [item.title, item.text, item.site, item.site_name, item.kind, item.actor && item.actor.name]
return !words || [item.title, item.text, item.site, item.site_name, item.kind, item.actor && item.actor.name]
.some((v) => String(v || '').toLowerCase().includes(words));
.some((v) => String(v || '').toLowerCase().includes(words));
}
}
@@ -22,6 +22,7 @@ const { fail, sqlTime, isoTime, encodeCursor, decodeCursor } = require('../http/
@@ -22,6 +22,7 @@ const { fail, sqlTime, isoTime, encodeCursor, decodeCursor } = require('../http/
const { createAuthors } = require('../identity/authors');
const { createAuthors } = require('../identity/authors');
const ORIGINS = ['user', 'ai', 'system'];
const ORIGINS = ['user', 'ai', 'system'];
+const parseJson = (v, d) => { try { const o = JSON.parse(v); return o == null ? d : o; } catch { return d; } };
const PAGE = 30;
const PAGE = 30;
const FUTURE_SKEW_MS = 5 * 60_000;
const FUTURE_SKEW_MS = 5 * 60_000;
@@ -107,6 +108,8 @@ function createPulse({ db, network = null, config = {}, fetchImpl = globalThis.f
@@ -107,6 +108,8 @@ function createPulse({ db, network = null, config = {}, fetchImpl = globalThis.f
...(i.preview_url ? { preview_url: i.preview_url } : {}),
...(i.preview_url ? { preview_url: i.preview_url } : {}),
...(i.media_ratio ? { media_ratio: i.media_ratio } : {}),
...(i.media_ratio ? { media_ratio: i.media_ratio } : {}),
...(i.media_length ? { media_length: i.media_length } : {}),
...(i.media_length ? { media_length: i.media_length } : {}),
+ ...(i.media_length ? { duration_s: Number(i.media_length) } : {}),
+ ...(i.subject_json ? { subject: parseJson(i.subject_json, null) } : {}),
...(i.text ? { text: i.text } : {}),
...(i.text ? { text: i.text } : {}),
...(i.starts_at ? { starts_at: isoTime(i.starts_at) } : {}),
...(i.starts_at ? { starts_at: isoTime(i.starts_at) } : {}),
...(i.actor_name ? { actor_name: i.actor_name } : {}),
...(i.actor_name ? { actor_name: i.actor_name } : {}),
@@ -284,11 +284,21 @@ function createPulseSources({ db, config = {}, fetchImpl = globalThis.fetch, now
@@ -284,11 +284,21 @@ function createPulseSources({ db, config = {}, fetchImpl = globalThis.fetch, now
if (!title) continue;
if (!title) continue;
seen.add(id);
seen.add(id);
const prior = await db.prepare("SELECT actor_subject FROM pulse_items WHERE source_service = 'live' AND source_type = 'stream' AND source_id = ?").get(id);
const prior = await db.prepare("SELECT actor_subject FROM pulse_items WHERE source_service = 'live' AND source_type = 'stream' AND source_id = ?").get(id);
+ const user = s.user || {};
+ // The card's subject is the streamer the episode is about; the actor is The Rundown itself.
+ const subject = user.username ? {
+ name: clean(user.display_name || user.username, 80) || null,
+ handle: `@${clean(user.username, 64)}`,
+ avatar: https(user.avatar_url),
+ } : null;
+ // The episode's own AI picture is the art; the stream's thumbnail stays the fallback until it exists.
+ const picture = https(s.episode.picture_url);
const r = await store.upsertItem(db, { source_service: 'live', source_type: 'episode', source_id: id,
const r = await store.upsertItem(db, { source_service: 'live', source_type: 'episode', source_id: id,
- title: `Rundown: ${title}`, url: `${liveApi}/rundown/e/${id}`,
- actor_subject: prior && prior.actor_subject || null, origin: prior && prior.actor_subject ? 'user' : 'system',
+ title, url: `${liveApi}/rundown/e/${id}`,
+ actor_name: 'The Rundown', actor_subject: prior && prior.actor_subject || null, origin: 'system',
occurred_at: sqlFrom(s.episode.aired_at || s.started_at, sqlTime(now())),
occurred_at: sqlFrom(s.episode.aired_at || s.started_at, sqlTime(now())),
- text: clean(s.episode.logline, 280), picture_url: https(s.thumbnail_url) });
+ text: clean(s.episode.logline, 280), picture_url: picture || https(s.thumbnail_url),
+ media_ratio: picture ? 1200 / 630 : null, subject });
if (r.created) created++;
if (r.created) created++;
}
}
before = typeof out.next === 'string' && out.next ? out.next : null;
before = typeof out.next === 'string' && out.next ? out.next : null;
@@ -32,6 +32,11 @@ function ms(v) {
@@ -32,6 +32,11 @@ function ms(v) {
return Date.parse(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.test(s) ? `${s.replace(' ', 'T')}Z` : s);
return Date.parse(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.test(s) ? `${s.replace(' ', 'T')}Z` : s);
}
}
+/** The subject a card is about (a Rundown episode's streamer), as JSON text or null. */
+function subjectJsonOf(it) {
+ return it && it.subject && typeof it.subject === 'object' && !Array.isArray(it.subject) ? JSON.stringify(it.subject) : null;
+}
+
/** A live.stream.started item: one row per broadcast, not per reconnect. → { item, created } */
/** A live.stream.started item: one row per broadcast, not per reconnect. → { item, created } */
async function upsertLiveItem(db, it) {
async function upsertLiveItem(db, it) {
it = { ...it, occurred_at: pulseTime(it.occurred_at) };
it = { ...it, occurred_at: pulseTime(it.occurred_at) };
@@ -88,16 +93,16 @@ async function upsertItem(db, it) {
@@ -88,16 +93,16 @@ async function upsertItem(db, it) {
// 2026-11-12); a card's person, once known, never changes.
// 2026-11-12); a card's person, once known, never changes.
const person = it.actor_subject || null;
const person = it.actor_subject || null;
if (it.touch) {
if (it.touch) {
- await db.prepare('UPDATE pulse_items SET title = ?, url = ?, picture_url = ?, preview_url = ?, media_ratio = ?, media_length = ?, app_name = ?, text = ?, starts_at = ?, actor_name = ?, actor_subject = COALESCE(actor_subject, ?), visibility = ?, occurred_at = ? WHERE source_service = ? AND source_type = ? AND source_id = ?')
- .run(it.title, it.url, it.picture_url || null, it.preview_url || null, it.media_ratio || null, it.media_length || null, it.app_name || null, it.text == null ? null : String(it.text).slice(0, 300), pulseTime(it.starts_at) || null, it.actor_name || null, person, it.visibility || 'public', it.occurred_at, ...key);
+ await db.prepare('UPDATE pulse_items SET title = ?, url = ?, picture_url = ?, preview_url = ?, media_ratio = ?, media_length = ?, app_name = ?, text = ?, starts_at = ?, actor_name = ?, actor_subject = COALESCE(actor_subject, ?), visibility = ?, occurred_at = ?, subject_json = ? WHERE source_service = ? AND source_type = ? AND source_id = ?')
+ .run(it.title, it.url, it.picture_url || null, it.preview_url || null, it.media_ratio || null, it.media_length || null, it.app_name || null, it.text == null ? null : String(it.text).slice(0, 300), pulseTime(it.starts_at) || null, it.actor_name || null, person, it.visibility || 'public', it.occurred_at, subjectJsonOf(it), ...key);
} else {
} else {
- await db.prepare('UPDATE pulse_items SET title = ?, url = ?, picture_url = ?, preview_url = ?, media_ratio = ?, media_length = ?, app_name = ?, text = ?, starts_at = ?, actor_name = ?, actor_subject = COALESCE(actor_subject, ?), visibility = ? WHERE source_service = ? AND source_type = ? AND source_id = ?')
- .run(it.title, it.url, it.picture_url || null, it.preview_url || null, it.media_ratio || null, it.media_length || null, it.app_name || null, it.text == null ? null : String(it.text).slice(0, 300), pulseTime(it.starts_at) || null, it.actor_name || null, person, it.visibility || 'public', ...key);
+ await db.prepare('UPDATE pulse_items SET title = ?, url = ?, picture_url = ?, preview_url = ?, media_ratio = ?, media_length = ?, app_name = ?, text = ?, starts_at = ?, actor_name = ?, actor_subject = COALESCE(actor_subject, ?), visibility = ?, subject_json = ? WHERE source_service = ? AND source_type = ? AND source_id = ?')
+ .run(it.title, it.url, it.picture_url || null, it.preview_url || null, it.media_ratio || null, it.media_length || null, it.app_name || null, it.text == null ? null : String(it.text).slice(0, 300), pulseTime(it.starts_at) || null, it.actor_name || null, person, it.visibility || 'public', subjectJsonOf(it), ...key);
}
}
return { item: await find.get(...key), created: false };
return { item: await find.get(...key), created: false };
}
}
- await db.prepare(`INSERT INTO pulse_items (source_service, source_type, source_id, title, url, actor_subject, origin, visibility, occurred_at, picture_url, preview_url, media_ratio, media_length, app_name, text, starts_at, actor_name)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(...key, it.title, it.url, it.actor_subject || null, it.origin, it.visibility || 'public', it.occurred_at, it.picture_url || null, it.preview_url || null, it.media_ratio || null, it.media_length || null, it.app_name || null, it.text == null ? null : String(it.text).slice(0, 300), pulseTime(it.starts_at) || null, it.actor_name || null);
+ await db.prepare(`INSERT INTO pulse_items (source_service, source_type, source_id, title, url, actor_subject, origin, visibility, occurred_at, picture_url, preview_url, media_ratio, media_length, app_name, text, starts_at, actor_name, subject_json)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(...key, it.title, it.url, it.actor_subject || null, it.origin, it.visibility || 'public', it.occurred_at, it.picture_url || null, it.preview_url || null, it.media_ratio || null, it.media_length || null, it.app_name || null, it.text == null ? null : String(it.text).slice(0, 300), pulseTime(it.starts_at) || null, it.actor_name || null, subjectJsonOf(it));
return { item: await find.get(...key), created: true };
return { item: await find.get(...key), created: true };
});
});
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,91 @@
@@ -0,0 +1,91 @@
+'use strict';
+/**
+ * The menu Feed's two Live cards (server/pulse/feed.js):
+ * a Rundown episode is kind 'rundown': the actor is The Rundown with its own mark, the streamer it is about is
+ * `subject` {name, handle, avatar}, the art is the episode's AI picture (the stream thumbnail when none), and it is
+ * never labelled "System"; a recording keeps its own actor, carries its length as `duration_s` (never a bare
+ * media.length badge) and links to the recording's watch page, not the channel.
+ */
+const assert = require('assert');
+const http = require('http');
+const { boot, check, done } = require('./helpers/app');
+const { createPulseSources } = require('../server/pulse/sources');
+
+(async () => {
+ const t = await boot();
+ const now = new Date().toISOString().replace('T', ' ').slice(0, 19);
+ const insert = t.db.prepare(`INSERT INTO pulse_items (source_service, source_type, source_id, title, url, actor_subject, actor_name, origin, visibility, occurred_at, picture_url, media_ratio, media_length, subject_json)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'public', ?, ?, ?, ?, ?)`);
+ await insert.run('live', 'episode', '3005', 'WW3 Prep: The Case Against Smooth Apologies', 'https://openvibe.live/rundown/e/3005',
+ null, 'The Rundown', 'system', now, 'https://openvibe.media/f/rundown-picture.jpg', 1200 / 630, null,
+ JSON.stringify({ name: 'Maticus', handle: '@maticus', avatar: 'https://openvibe.media/o/med_01M6SV8E153FM2T70WAF14TB4W' }));
+ await insert.run('live', 'recording', 'vod-6393', 'Recording: WW3 prep', 'https://openvibe.video/watch?v=ivkluOg7ieQ',
+ 'usr_01KKT9AC60KM7CRTB3WN1Z8P56', 'Maticus', 'user', now, 'https://openvibe.media/t/vod-6393.jpg', 16 / 9, 5203, null);
+
+ const page = await t.get('/api/v1/feed?limit=10');
+ assert.strictEqual(page.status, 200);
+ const body = await page.json();
+ const rundown = body.items.find((x) => x.id === 'p:1');
+ assert.ok(rundown, 'the Rundown card is in the feed');
+ assert.strictEqual(rundown.kind, 'rundown');
+ assert.deepStrictEqual(rundown.actor, { name: 'The Rundown', mark: 'rundown', avatar: null });
+ assert.deepStrictEqual(rundown.subject, { name: 'Maticus', handle: '@maticus', avatar: 'https://openvibe.media/o/med_01M6SV8E153FM2T70WAF14TB4W' });
+ assert.strictEqual(rundown.image, 'https://openvibe.media/f/rundown-picture.jpg');
+ assert.strictEqual(rundown.media.thumb, 'https://openvibe.media/f/rundown-picture.jpg');
+ assert.ok(Math.abs(rundown.media.ratio - 1200 / 630) < 1e-9, 'the AI picture keeps its 1200×630 ratio');
+ assert.strictEqual(rundown.label, undefined, 'The Rundown, not "System"');
+ assert.ok(rundown.url.endsWith('/rundown/e/3005'));
+
+ const recording = body.items.find((x) => x.id === 'p:2');
+ assert.ok(recording, 'the recording is in the feed');
+ assert.strictEqual(recording.kind, 'video');
+ assert.strictEqual(recording.actor.name, 'Maticus');
+ assert.strictEqual(recording.duration_s, 5203, 'the length is a duration, not a bare number');
+ assert.ok(!recording.media.length, 'no bare length badge on a recording');
+ assert.strictEqual(recording.media.thumb, 'https://openvibe.media/t/vod-6393.jpg');
+ assert.strictEqual(recording.url, 'https://openvibe.video/watch?v=ivkluOg7ieQ', "the recording's watch page, not the channel");
+
+ const only = await t.get('/api/v1/feed?kind=rundown&limit=10');
+ const onlyBody = await only.json();
+ assert.deepStrictEqual(onlyBody.items.map((x) => x.kind), ['rundown'], 'kind=rundown filters to the episode');
+
+ // The stored Pulse item carries the subject and duration too (the API a service reads).
+ const pulse = await t.get('/api/v1/pulse');
+ const items = (await pulse.json()).items;
+ const pRundown = items.find((x) => x.source.type === 'episode');
+ assert.deepStrictEqual(pRundown.subject, { name: 'Maticus', handle: '@maticus', avatar: 'https://openvibe.media/o/med_01M6SV8E153FM2T70WAF14TB4W' });
+ assert.strictEqual(pRundown.duration_s, undefined);
+ const pRecording = items.find((x) => x.source.type === 'recording');
+ assert.strictEqual(pRecording.duration_s, 5203);
+
+ // ── The Live source itself: an episode's picture and subject are stored with the Pulse item ──
+ const live = http.createServer((req, res) => {
+ res.setHeader('content-type', 'application/json');
+ if (req.url.startsWith('/api/rundown/streams')) return res.end(JSON.stringify({ streams: [{
+ stream_id: 4242, live: false, thumbnail_url: 'https://openvibe.media/t/stream-thumb.jpg',
+ user: { username: 'maticus', display_name: 'Maticus', avatar_url: 'https://openvibe.media/o/med_maticus' },
+ episode: { title: 'The Fridge Files', logline: 'A night of cold takes.', aired_at: '2026-11-12 03:00:00', audience: 'public', picture_url: 'https://openvibe.media/f/episode-pic.jpg' },
+ }], next: null }));
+ res.statusCode = 404; res.end('{}');
+ });
+ await new Promise((r) => live.listen(0, '127.0.0.1', r));
+ const url = `http://127.0.0.1:${live.address().port}`;
+ const sources = createPulseSources({ db: t.db, config: { liveUrl: url, todayUrl: url, todayInternalUrl: url, networkUrl: url, networkInternalUrl: url, questUrl: url, questInternalUrl: url, memeUrl: url, memeInternalUrl: url, picsUrl: url, spaceUrl: url, playUrl: url } });
+ const ran = await sources.runOnce({ force: true });
+ const rundownSource = ran.find((x) => x.name === 'rundown-episodes');
+ assert.ok(rundownSource && rundownSource.items === 1, 'the Rundown source read one episode');
+ const stored = await t.db.prepare("SELECT * FROM pulse_items WHERE source_service = 'live' AND source_type = 'episode' AND source_id = '4242'").get();
+ assert.strictEqual(stored.title, 'The Fridge Files', 'the actor carries the name, so the title does not repeat it');
+ assert.strictEqual(stored.actor_name, 'The Rundown');
+ assert.strictEqual(stored.picture_url, 'https://openvibe.media/f/episode-pic.jpg');
+ assert.deepStrictEqual(JSON.parse(stored.subject_json), { name: 'Maticus', handle: '@maticus', avatar: 'https://openvibe.media/o/med_maticus' });
+ assert.ok(Math.abs(Number(stored.media_ratio) - 1200 / 630) < 1e-9);
+ const fed = await (await t.get('/api/v1/feed?limit=20')).json();
+ const sourceCard = fed.items.find((x) => x.id === 'p:3');
+ assert.strictEqual(sourceCard.kind, 'rundown');
+ assert.strictEqual(sourceCard.subject.handle, '@maticus');
+ live.close();
+
+ console.log('feed shape: Rundown kind/actor/mark/subject/image and recording duration_s/watch link ok');
+ await done(t);
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -94,8 +94,10 @@ const day = (ms) => new Date(ms).toISOString().slice(0, 10);
@@ -94,8 +94,10 @@ const day = (ms) => new Date(ms).toISOString().slice(0, 10);
assert.strictEqual(meme.title, 'Open source meme');
assert.strictEqual(meme.title, 'Open source meme');
assert.strictEqual(meme.text, 'A public entry');
assert.strictEqual(meme.text, 'A public entry');
assert.strictEqual(meme.url, 'https://openvibe.meme/m/open-source-meme');
assert.strictEqual(meme.url, 'https://openvibe.meme/m/open-source-meme');
- const episode = feed.items.find((x) => x.kind === 'episode');
- assert.strictEqual(episode.title, 'Rundown: The night sky');
+ const episode = feed.items.find((x) => x.kind === 'rundown');
+ assert.strictEqual(episode.title, 'The night sky');
+ assert.deepStrictEqual(episode.actor, { name: 'The Rundown', mark: 'rundown', avatar: null }, 'The Rundown, not System');
+ assert.strictEqual(episode.subject, undefined, 'the mock has no streamer row: no subject, never a made-up one');
assert.strictEqual(episode.url, 'https://openvibe.live/rundown/e/123');
assert.strictEqual(episode.url, 'https://openvibe.live/rundown/e/123');
assert.ok(feed.items.some((x) => x.kind === 'video' && x.title === 'Recording: Night stream'));
assert.ok(feed.items.some((x) => x.kind === 'video' && x.title === 'Recording: Night stream'));
assert.ok(feed.items.some((x) => x.kind === 'clip' && x.title === 'Night clip'));
assert.ok(feed.items.some((x) => x.kind === 'clip' && x.title === 'Night clip'));