openvibers/openvibe.network Commits

Feed: relay the Rundown kind, and the owner's picture settings (#866)

OP OpenVibers committed 24 h ago · 6c6215a36d · 4 files, +106 −3

The menu Feed passes kind=rundown through to Community, and its docs
describe the Rundown and recording card fields: actor mark, subject,
image, duration_s and the recording's watch link. The admin AI tab adds
The Rundown's episode pictures: model, size, style, monthly cap, this
month's spend and Remake, through Live's owner-only API.

Changes

ddiff --git a/docs/menu-home-feed-m1.md b/docs/menu-home-feed-m1.md
iindex 9265a398..ddf6055f 100644
---- a/docs/menu-home-feed-m1.md
++++ b/docs/menu-home-feed-m1.md
@@@ -3,3 +3,42 @@
- `GET` and `PUT /api/v1/me/home-screen` use a user Bearer token. The Network stores the validated `network.home-screen@1` value in `user_preferences.home_screen`; the account export includes `home-screen.json`, and account deletion removes the preferences row.
- `GET /api/v1/me/menu-feeds` answers `network.menu-feeds@2` `{ order, off, updated_at }`. Every offered card is on unless its id is in `off`. `PUT` accepts v2 or v1 `{ on }`; old stored `on` lists become the start of `order` on read.
- `GET /api/v1/frame/today` is public, CORS open, and cached for five minutes. It reads Report's `/api/v1/stats?days=30` and selects the latest fourteen UTC dates. `people` is Report's daily **new accounts** count. Report has no daily stream, post or new-video series yet, so those fields are `null`; a future `activity_daily` series can fill them without changing this API.
++
++## The Feed's items
++
++`GET /api/frame/feed` (and `/feed/search`, `/wall`) relay OpenVibe.Community's `/api/v1/feed`: Network keeps no copy,
++and Community builds every card from Pulse. A card is:
++
++```json
++{
++ "id": "p:2173",
++ "kind": "rundown",
++ "url": "https://openvibe.live/rundown/e/3005",
++ "title": "WW3 Prep: The Case Against Smooth Apologies",
++ "text": "Maticus warns AI companions that draft apologies and smooth conflict may outsource our emotional work…",
++ "at": "2026-11-12T02:51:39.000Z",
++ "site": "live",
++ "site_name": "Live",
++ "actor": { "name": "The Rundown", "mark": "rundown", "avatar": null },
++ "subject": { "name": "Maticus", "handle": "@maticus", "avatar": "https://openvibe.media/o/med_…" },
++ "image": "https://openvibe.media/f/….jpg",
++ "media": { "thumb": "https://openvibe.media/f/….jpg", "ratio": 1.9047619047619047 },
++ "counts": {}
++}
++```
++
++- **`kind`** is the card's shape: `live` (on air now), `stream` (a go-live that is over), `clip`, `video` (a
++ recording), `pic`, `post`, `meme`, `game`, `update`, `wiki`, `quest`, `chat`, `event`, `member`, `rundown` (a
++ Rundown episode). A Rundown episode used to be `episode`; the new kind is the one to draw distinctly.
++- **`actor`** is who or what the card is from: `{name, avatar}` for a person, or for a Rundown episode
++ `{name: "The Rundown", mark: "rundown", avatar: null}`. `mark` is the glyph id the menu draws (a site id or a glyph
++ id; `rundown` is The Rundown's own).
++- **`subject`** (Rundown only) is the streamer the episode is about: `{name, handle, avatar}`. It is not the actor;
++ the card can show both ("The Rundown" and "about @maticus").
++- **`image`** (Rundown only) is the episode's own AI picture; `media.thumb` carries the same picture for the card's
++ art. When no picture exists, both are the stream's thumbnail, so a card always has art. `media.ratio` is 1200/630
++ for an AI picture.
++- **`duration_s`** is a recording's or video's length in seconds. A recording never carries a bare `media.length`
++ badge; `duration_s` is what the card formats ("1:26:43"). `media.length` is still there for clips.
++- **`url`** is the thing the card opens: a Rundown episode's page (`/rundown/e/<streamId>`) or a recording's watch
++ page (OpenVibe.Video), never the channel page.
ddiff --git a/public/js/admin/ai.js b/public/js/admin/ai.js
iindex 510ee162..3941cc70 100644
---- a/public/js/admin/ai.js
++++ b/public/js/admin/ai.js
@@@ -144,6 +144,10 @@ async function loadAi() {
<div id="ai-explorer-detail" style="margin-top:12px"></div>
</fieldset>
${currentUser.is_owner ? `
++ <fieldset id="rundown-pictures-admin" style="border:1px solid var(--border);border-radius:8px;padding:12px 16px;margin-bottom:16px">
++ <legend style="padding:0 8px;font-weight:700">The Rundown's episode pictures</legend>
++ <div id="rundown-pictures-body"><span class="muted">Loading…</span></div>
++ </fieldset>
<form id="ai-form">
<fieldset style="border:1px solid var(--border);border-radius:8px;padding:12px 16px;margin-bottom:16px">
<legend style="padding:0 8px;font-weight:700">Configuration</legend>
@@@ -157,9 +161,68 @@ async function loadAi() {
</fieldset>`}
</div>`;
loadAiViewersFleet();
++ loadRundownPictures();
} catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
}
++// ── The Rundown's episode pictures (Live, owner only) ─────────
++// One AI illustration per episode, from the episode's own words; the stream thumbnail is the fallback. The settings,
++// the month's spend and "Remake" are Live's /api/admin/rundown, reached through the admin proxy.
++async function loadRundownPictures() {
++ const el = document.getElementById('rundown-pictures-body'); if (!el) return;
++ if (!currentUser || !currentUser.is_owner) { el.innerHTML = '<p class="muted" style="margin:0"><i class="fa-solid fa-lock"></i> Owner only: these settings decide where the picture money goes.</p>'; return; }
++ try {
++ const d = await api('/api/admin/streamer/rundown');
++ const s = d.settings || {}, money = (n) => '$' + (Number(n || 0)).toFixed(2);
++ const cap = s.monthly_cap_usd == null ? 'no cap' : money(s.monthly_cap_usd);
++ const spend = d.spend || {};
++ const badge = { done: 'Made', pending: 'Queued', working: 'Making', refused: 'Refused', failed: 'Failed' };
++ const rows = (d.pictures || []).slice(0, 12).map((p) => `<tr>
++ <td style="padding:4px 6px">${p.image ? `<img src="${esc(p.image)}" alt="" loading="lazy" style="width:64px;height:34px;object-fit:cover;border-radius:4px">` : '<span class="muted">—</span>'}</td>
++ <td style="padding:4px 6px">${esc(p.title || ('stream ' + p.stream_id))}<br><small class="muted">${esc(p.username ? '@' + p.username : '')} · ${esc(p.model || '')} ${p.size ? '· ' + esc(p.size) : ''}</small></td>
++ <td style="padding:4px 6px;white-space:nowrap">${esc(badge[p.status] || p.status)}${p.cost_usd ? `<br><small class="muted">${money(p.cost_usd)}</small>` : ''}</td>
++ <td style="padding:4px 6px;max-width:280px"><small class="muted">${esc(p.error || '')}</small></td>
++ <td style="padding:4px 6px;white-space:nowrap"><button type="button" class="btn btn-sm" onclick="remakeRundownPicture(${Number(p.stream_id)})">Remake</button></td>
++ </tr>`).join('');
++ el.innerHTML = `
++ <p class="muted" style="margin:0 0 10px">Every Rundown episode gets one editorial illustration made from its title, topics, takes and recap, stored in Media at 1200×630. Explicit words (sex acts, or anything involving a minor) are refused before a prompt leaves Live; the stream thumbnail stays the fallback. This month: <b>${money(spend.usd || 0)}</b> of ${esc(cap)} · ${Number(spend.pictures || 0)} picture${Number(spend.pictures || 0) === 1 ? '' : 's'} · ${Number((d.counts || {}).pending || 0)} queued, ${Number((d.counts || {}).refused || 0)} refused.</p>
++ <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:8px;margin-bottom:10px">
++ <label class="muted" style="font-size:12px"><input id="rp-enabled" type="checkbox" ${s.enabled ? 'checked' : ''} style="width:16px;height:16px;vertical-align:-3px"> On</label>
++ <label class="muted" style="font-size:12px">Model<br><input id="rp-model" value="${esc(s.model || '')}" spellcheck="false" style="width:100%;padding:6px 8px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--text)"></label>
++ <label class="muted" style="font-size:12px">Size<br><input id="rp-size" value="${esc(s.size || '')}" spellcheck="false" style="width:100%;padding:6px 8px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--text)"></label>
++ <label class="muted" style="font-size:12px">Monthly cap (USD)<br><input id="rp-cap" type="number" min="0" max="1000" step="0.01" value="${s.monthly_cap_usd == null ? '' : esc(s.monthly_cap_usd)}" style="width:100%;padding:6px 8px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--text)"></label>
++ <label class="muted" style="font-size:12px;grid-column:1/-1">Style line<br><input id="rp-style" value="${esc(s.style || '')}" style="width:100%;padding:6px 8px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--text)"></label>
++ </div>
++ <button type="button" class="btn btn-primary" onclick="saveRundownPictures()"><i class="fa-solid fa-floppy-disk"></i> Save picture settings</button>
++ ${rows ? `<table style="width:100%;font-size:13px;border-collapse:collapse;margin-top:12px"><thead><tr><th style="text-align:left">Picture</th><th style="text-align:left">Episode</th><th style="text-align:left">State</th><th style="text-align:left">Why</th><th></th></tr></thead><tbody>${rows}</tbody></table>` : '<p class="muted" style="margin:10px 0 0">No episode pictures yet.</p>'}`;
++ } catch (e) {
++ el.innerHTML = `<p class="muted" style="margin:0">${e && e.status === 403 ? 'Owner only.' : 'Live did not answer: ' + esc(e.message)}</p>`;
++ }
++}
++
++async function saveRundownPictures() {
++ const settings = {
++ enabled: document.getElementById('rp-enabled').checked,
++ model: document.getElementById('rp-model').value.trim(),
++ size: document.getElementById('rp-size').value.trim(),
++ style: document.getElementById('rp-style').value.trim(),
++ monthly_cap_usd: document.getElementById('rp-cap').value === '' ? null : Number(document.getElementById('rp-cap').value),
++ };
++ try {
++ const d = await api('/api/admin/streamer/rundown/settings', { method: 'PUT', body: { settings } });
++ toast(`Picture settings saved (${Number((d && d.pictures || []).length)} recent)`, 'success');
++ loadRundownPictures();
++ } catch (e) { toast(e.message, 'error'); }
++}
++
++async function remakeRundownPicture(streamId) {
++ try {
++ const d = await api('/api/admin/streamer/rundown/episodes/' + Number(streamId) + '/remake', { method: 'POST', body: {} });
++ toast(d.message || 'Picture queued', 'success');
++ loadRundownPictures();
++ } catch (e) { toast(e.message, 'error'); }
++}
++
async function loadAiViewersFleet() {
const el = document.getElementById('ai-viewers-fleet'); if (!el) return;
try {
ddiff --git a/server/frame/service.js b/server/frame/service.js
iindex 8f65a70b..79aa32f9 100644
---- a/server/frame/service.js
++++ b/server/frame/service.js
@@@ -655,8 +655,9 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
// goes along when the menu sends one (a personal wall, the composer's can_post), never a cookie. Anonymous answers are
// kept 30 s per query; personal ones are not kept. Community not answering is an empty page, never an error.
const relayed = new Map();
-- // Community's feed kinds (server/pulse/feed.js KINDS; #310 added wiki, quest, chat, event and member).
-- const FEED_KINDS = ['live', 'stream', 'clip', 'video', 'pic', 'post', 'meme', 'game', 'update', 'wiki', 'quest', 'chat', 'event', 'member'];
++ // Community's feed kinds (server/pulse/feed.js KINDS; #310 added wiki, quest, chat, event and member; a Rundown
++ // episode is its own kind so the menu can draw it apart from a stream or a recording).
++ const FEED_KINDS = ['live', 'stream', 'clip', 'video', 'pic', 'post', 'meme', 'game', 'update', 'wiki', 'quest', 'chat', 'event', 'member', 'rundown'];
async function relay(req, res, path, params) {
res.set({ 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Authorization', 'Vary': 'Authorization', 'Content-Type': 'application/json; charset=utf-8' });
const auth = /^Bearer [A-Za-z0-9._~+/=-]{10,4096}$/.test(String(req.get('authorization') || '')) ? req.get('authorization') : '';
ddiff --git a/test/frame-feed.test.js b/test/frame-feed.test.js
iindex 0f51e104..9470358a 100644
---- a/test/frame-feed.test.js
++++ b/test/frame-feed.test.js
@@@ -73,7 +73,7 @@ const { createFrameService } = require('../server/frame/service');
const f = new URL(asked[asked.length - 1].url, 'http://x');
assert.deepStrictEqual([f.searchParams.get('kind'), f.searchParams.get('site'), f.searchParams.get('q'), f.searchParams.get('following')], ['quest', 'wiki', 'head slider', '1'], 'kind, site, words and following pass');
assert.strictEqual(asked[asked.length - 1].auth, 'Bearer abcdefghijklmnop.qrstu', 'following goes with the person');
-- for (const k of ['wiki', 'chat', 'event', 'member']) { ({ d } = await get(`/feed?kind=${k}`)); assert.strictEqual(new URL(asked[asked.length - 1].url, 'http://x').searchParams.get('kind'), k, `${k} passes`); }
++ for (const k of ['wiki', 'chat', 'event', 'member', 'rundown']) { ({ d } = await get(`/feed?kind=${k}`)); assert.strictEqual(new URL(asked[asked.length - 1].url, 'http://x').searchParams.get('kind'), k, `${k} passes`); }
({ d } = await get('/feed?site=../x&q=' + 'a'.repeat(200) + '&following=yes'));
const g = new URL(asked[asked.length - 1].url, 'http://x');
assert.ok(!g.searchParams.has('site') && g.searchParams.get('q').length === 80 && !g.searchParams.has('following'), 'an odd site and following are dropped, words capped at 80');