openvibers/openvibe.network Commits
Feed: relay the Rundown kind, and the owner's picture settings (#866)
Changes
@@@ -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.@@@ -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 {@@@ -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') : '';@@@ -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');
@@ -3,3 +3,42 @@
@@ -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` 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/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.
- `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.
@@ -144,6 +144,10 @@ async function loadAi() {
@@ -144,6 +144,10 @@ async function loadAi() {
<div id="ai-explorer-detail" style="margin-top:12px"></div>
<div id="ai-explorer-detail" style="margin-top:12px"></div>
</fieldset>
</fieldset>
${currentUser.is_owner ? `
${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">
<form id="ai-form">
<fieldset style="border:1px solid var(--border);border-radius:8px;padding:12px 16px;margin-bottom:16px">
<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>
<legend style="padding:0 8px;font-weight:700">Configuration</legend>
@@ -157,9 +161,68 @@ async function loadAi() {
@@ -157,9 +161,68 @@ async function loadAi() {
</fieldset>`}
</fieldset>`}
</div>`;
</div>`;
loadAiViewersFleet();
loadAiViewersFleet();
+ loadRundownPictures();
} catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
} 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() {
async function loadAiViewersFleet() {
const el = document.getElementById('ai-viewers-fleet'); if (!el) return;
const el = document.getElementById('ai-viewers-fleet'); if (!el) return;
try {
try {
@@ -655,8 +655,9 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
@@ -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
// 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.
// kept 30 s per query; personal ones are not kept. Community not answering is an empty page, never an error.
const relayed = new Map();
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) {
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' });
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') : '';
const auth = /^Bearer [A-Za-z0-9._~+/=-]{10,4096}$/.test(String(req.get('authorization') || '')) ? req.get('authorization') : '';
@@ -73,7 +73,7 @@ const { createFrameService } = require('../server/frame/service');
@@ -73,7 +73,7 @@ const { createFrameService } = require('../server/frame/service');
const f = new URL(asked[asked.length - 1].url, 'http://x');
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.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');
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'));
({ d } = await get('/feed?site=../x&q=' + 'a'.repeat(200) + '&following=yes'));
const g = new URL(asked[asked.length - 1].url, 'http://x');
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');
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');