openvibers/openvibe.community Commits

Feed: a Rundown episode is its own card (#400)

OP OpenVibers committed 1 days ago · df8148bcd4 · 7 files, +154 −22

A Rundown episode is kind 'rundown', not 'episode': the actor is The
Rundown with the 'rundown' mark, the streamer it is about is subject
{name, handle, avatar}, and the episode's own AI picture is image and
media.thumb (the stream thumbnail when there is none). Pulse carries
subject and duration_s; a recording's duration_s replaces the bare
media.length badge and keeps its watch-page link.

Changes

ddiff --git a/migrations/0037_feed_subject.sql b/migrations/0037_feed_subject.sql
nnew file mode 100644
iindex 0000000..7cc2cb8
---- /dev/null
++++ b/migrations/0037_feed_subject.sql
@@@ -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";
ddiff --git a/server/pulse/feed.js b/server/pulse/feed.js
iindex 7f14bd7..10fbf70 100644
---- a/server/pulse/feed.js
++++ b/server/pulse/feed.js
@@@ -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));
}
ddiff --git a/server/pulse/service.js b/server/pulse/service.js
iindex f988b67..1694a95 100644
---- a/server/pulse/service.js
++++ b/server/pulse/service.js
@@@ -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 } : {}),
ddiff --git a/server/pulse/sources.js b/server/pulse/sources.js
iindex d6837ae..8cf29ff 100644
---- a/server/pulse/sources.js
++++ b/server/pulse/sources.js
@@@ -284,11 +284,21 @@ function createPulseSources({ db, config = {}, fetchImpl = globalThis.fetch, now
if (!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;
ddiff --git a/server/pulse/store.js b/server/pulse/store.js
iindex 1e4974b..3f0d586 100644
---- a/server/pulse/store.js
++++ b/server/pulse/store.js
@@@ -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 };
});
}
ddiff --git a/test/feed-shape.test.js b/test/feed-shape.test.js
nnew file mode 100644
iindex 0000000..212bc22
---- /dev/null
++++ b/test/feed-shape.test.js
@@@ -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); });
ddiff --git a/test/pulse-sources.test.js b/test/pulse-sources.test.js
iindex 01ef872..8bf526c 100644
---- a/test/pulse-sources.test.js
++++ b/test/pulse-sources.test.js
@@@ -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'));