openvibers/openvibe.live Commits

Add Rundown day summary and timeline API (#752)

OP OpenVibers committed 3 days ago · 213b0400f8 · 6 files, +142 −1

Changes

ddiff --git a/docs/rundown.md b/docs/rundown.md
iindex c9c585fd..843f7779 100644
---- a/docs/rundown.md
++++ b/docs/rundown.md
@@@ -120,6 +120,24 @@ The streamer can delete their own items. Each delete asks first, in the page (a
## API
++`GET /api/rundown/day/:date` (also `today`) returns an AI Today's UTC day summary and a paginated, chronological
++timeline. Query `offset` (default `0`) and `limit` (default `50`, maximum `100`) to page the timeline. The response
++uses the same public audience rules as the Rundown pages; signed-in readers may also see their own non-public items.
++The summary is owner-switch controlled by the owner-only site setting `ai_rundown_day_summary_enabled` (default off), is cached per UTC day, refreshes
++at most hourly while that day is live and remains fixed after it ends. Days with fewer than three visible takes return
++`summary.status: "too_few_takes"`. Each generated sentence carries `take_ids`; sentences without valid take citations
++or which fail the existing explicit-content check are omitted; each sentence also includes `channel_ids` for channel links.
++
++Example response:
++
++```json
++{
++ "date": "2026-11-09",
++ "summary": {"status": "ready", "generated_at": 1794272400000, "sentences": [{"text": "Ari discussed the new game update.", "take_ids": [481], "channel_ids": [12]}]},
++ "timeline": {"items": [{"id": 481, "time": "2026-11-09 18:32:00", "channel": {"id": 12, "username": "ari"}, "topic": {"title": "Game update", "slug": "game-update"}, "vod": {"id": 90, "sec": 625}}], "total": 1, "offset": 0, "limit": 50, "next_offset": null}
++}
++```
++
`/api/rundown` (server/rundown/routes.js):
| Method | Path | Notes |
ddiff --git a/server/rundown/day.js b/server/rundown/day.js
nnew file mode 100644
iindex 00000000..54f28ee5
---- /dev/null
++++ b/server/rundown/day.js
@@@ -0,0 +1,91 @@
++'use strict';
++
++const db = require('../db/database');
++const read = require('./read');
++const explicit = require('../ai/explicit');
++
++const parseJson = (s, fallback) => { try { return JSON.parse(s); } catch { return fallback; } };
++const validDate = (s) => {
++ if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return false;
++ const d = new Date(`${s}T00:00:00Z`);
++ return Number.isFinite(d.getTime()) && d.toISOString().slice(0, 10) === s;
++};
++const splitSentences = (s) => String(s || '').split(/(?<=[.!?])\s+(?=[A-Z])|(?<=\])\s+(?=[A-Z])/).map((x) => x.trim()).filter(Boolean);
++
++async function dayData(date, { offset = 0, limit = 50, viewer = null } = {}) {
++ if (!validDate(date)) throw Object.assign(new Error('Date must be YYYY-MM-DD or today'), { status: 400 });
++ const v = viewer || {};
++ const query = await db.all(`SELECT s.id, s.stream_id, s.user_id, s.topic_id, s.start_sec, s.vod_id, s.said_at,
++ s.title, s.what, s.take, s.status, s.quotes_json, s.mentions, u.username, u.display_name,
++ st.channel_id, t.title AS topic_title, t.slug AS topic_slug,
++ st.ended_at, e.stream_id AS episode_id
++ FROM rundown_segments s JOIN streams st ON st.id = s.stream_id
++ JOIN users u ON u.id = s.user_id LEFT JOIN channels c ON c.id = st.channel_id
++ LEFT JOIN rundown_topics t ON t.id = s.topic_id
++ LEFT JOIN rundown_episodes e ON e.stream_id = st.id AND e.status = 'live'
++ LEFT JOIN shares sh ON sh.service='live' AND sh.kind='rundown-episode' AND sh.resource_id=CAST(st.id AS text)
++ LEFT JOIN rundown_visibility vis ON vis.stream_id=st.id
++ WHERE s.status='live' AND DATE(s.said_at) = ?
++ AND (s.user_id = ? OR (COALESCE(sh.audience,'public')='public' AND COALESCE(vis.vod_audience,'public')='public'))
++ ORDER BY s.said_at ASC, s.id ASC LIMIT ? OFFSET ?`, [date, v.userId || -1, Math.min(100, limit), Math.max(0, offset)]);
++ const history = require('../streaming/stream-history');
++ const offsets = await history.vodOffsets(query.map((r) => r.vod_id));
++ const all = query.map((r) => { const sec = Math.max(0, Math.floor(history.vodTime(Number(r.start_sec) || 0, r.vod_id ? offsets.get(Number(r.vod_id)) : 0))); return { id: Number(r.id), stream_id: Number(r.stream_id), take: r.take || null, what: r.what,
++ time: r.said_at, channel: { id: r.channel_id == null ? null : Number(r.channel_id), username: r.username },
++ topic: r.topic_title ? { title: r.topic_title, slug: r.topic_slug } : null,
++ vod: r.vod_id == null ? null : { id: Number(r.vod_id), sec, url: `/vod/${Number(r.vod_id)}?t=${sec}` } }; });
++ const countRow = await db.get(`SELECT COUNT(*) AS n FROM rundown_segments s JOIN streams st ON st.id=s.stream_id
++ LEFT JOIN shares sh ON sh.service='live' AND sh.kind='rundown-episode' AND sh.resource_id=CAST(st.id AS text)
++ LEFT JOIN rundown_visibility vis ON vis.stream_id=st.id
++ WHERE s.status='live' AND DATE(s.said_at)=? AND (s.user_id=? OR (COALESCE(sh.audience,'public')='public' AND COALESCE(vis.vod_audience,'public')='public'))`, [date, v.userId || -1]);
++ return { all, total: Number(countRow.n || 0) };
++}
++
++async function summary(date, takes) {
++ if (takes.length < 3) return { status: 'too_few_takes', minimum: 3, takes: takes.length, sentences: [] };
++ const ended = Date.parse(`${date}T23:59:59Z`) < Date.now();
++ const key = `rundown_day_summary:${date}`;
++ let cached = null;
++ try { cached = parseJson(await db.getSetting(key), null); } catch { /* cache is best effort */ }
++ if (cached && (ended || Date.now() - Number(cached.generated_at) < 3600000)) return cached;
++ const enabled = await db.getSetting('ai_rundown_day_summary_enabled');
++ if (!['true', '1'].includes(String(enabled))) return { status: 'disabled', sentences: [] };
++ const llm = require('../ai/llm');
++ if (!await llm.isEnabled() || !await llm.withinBudget()) return { status: 'unavailable', sentences: [] };
++ // Reuse the Rundown's existing structured writing task and AI client. Take ids are included in the source
++ // labels; citations are accepted only when the model returns them verbatim as [takes: id,id].
++ const input = { topic: `AI Today's UTC day ${date}`, takes: takes.slice(0, 30).map((t) => ({
++ streamer: t.channel.username, when: t.time, what: `[take:${t.id}] ${t.what}`, take: t.take,
++ quote: t.topic && t.topic.title ? `Topic: ${t.topic.title}` : null,
++ })) };
++ let out;
++ try { out = await require('../ai/ai-service').structured('live.rundown.topic', input, { meter: { kind: 'rundown_day', role: 'chat', source: 'rundown' } }); }
++ catch { return { status: 'unavailable', sentences: [] }; }
++ const ids = new Set(takes.map((t) => t.id));
++ const sentences = splitSentences(out && out.summary).map((text) => {
++ const m = text.match(/\[takes?:\s*([\d, ]+)\]/i);
++ if (!m) return null;
++ const take_ids = m[1].split(',').map((x) => Number(x.trim()));
++ if (!take_ids.length || take_ids.some((id) => !ids.has(id))) return null;
++ const clean = text.replace(/\s*\[takes?:\s*[\d, ]+\]/i, '').trim();
++ if (!clean || explicit.isExplicit(clean)) return null;
++ return { text: clean, take_ids };
++ }).filter(Boolean).slice(0, 5);
++ const channelByTake = new Map(takes.map((t) => [t.id, t.channel.id]));
++ for (const sentence of sentences) sentence.channel_ids = [...new Set(sentence.take_ids.map((id) => channelByTake.get(id)).filter((id) => id != null))];
++ const result = { status: sentences.length ? 'ready' : 'unavailable', generated_at: Date.now(), sentences };
++ if (sentences.length) await db.setSetting(key, JSON.stringify(result));
++ return result;
++}
++
++async function getDay(date, opts = {}) {
++ if (date === 'today') date = new Date().toISOString().slice(0, 10);
++ const offset = Math.max(0, parseInt(opts.offset, 10) || 0);
++ const limit = Math.max(1, Math.min(100, parseInt(opts.limit, 10) || 50));
++ const { all, total } = await dayData(date, { ...opts, offset, limit });
++ const summaryRows = offset === 0 && limit >= 100 ? all : (await dayData(date, { viewer: opts.viewer, offset: 0, limit: 100 })).all;
++ const summaryData = await summary(date, summaryRows);
++ return { date, summary: summaryData, timeline: { items: all, total, offset, limit, next_offset: offset + all.length < total ? offset + all.length : null } };
++}
++
++module.exports = { getDay, dayData, summary, validDate, splitSentences };
ddiff --git a/server/rundown/routes.js b/server/rundown/routes.js
iindex ed318e53..44b7fb57 100644
---- a/server/rundown/routes.js
++++ b/server/rundown/routes.js
@@@ -31,6 +31,7 @@ const browse = require('./browse');
const settings = require('./settings');
const del = require('./delete');
const { createCitations } = require('./citations');
++const day = require('./day');
const router = express.Router();
const fail = (res, err, msg) => {
@@@ -66,6 +67,14 @@ router.get('/browse', optionalAuth, async (req, res) => {
try { fresh(res); res.json(await browse.browse(req.query || {}, { viewer: await viewerOf(req) })); }
catch (err) { fail(res, err, 'The search could not be run'); }
});
++router.get('/day/:date', optionalAuth, async (req, res) => {
++ try {
++ const viewer = await viewerOf(req);
++ const payload = await day.getDay(String(req.params.date || ''), { viewer, offset: req.query.offset, limit: req.query.limit });
++ res.set('Cache-Control', viewer.userId ? 'private, no-store' : 'public, max-age=60, stale-while-revalidate=300');
++ res.json(payload);
++ } catch (err) { fail(res, err, 'Failed to load the day'); }
++});
router.get('/options', optionalAuth, async (req, res) => {
try { fresh(res); res.json(await browse.options()); }
catch (err) { fail(res, err, 'The filters could not be loaded'); }
ddiff --git a/server/seo/discovery.js b/server/seo/discovery.js
iindex 62dddadd..0b8c214b 100644
---- a/server/seo/discovery.js
++++ b/server/seo/discovery.js
@@@ -180,7 +180,7 @@ async function llmsTxt() {
{ title: 'Clip pages', url: 'https://openvibe.video/c/<id>', note: 'on OpenVibe.Video (Live\'s /clip/<id> redirects there)' },
{ title: 'Pastes', url: 'https://openvibe.community/p/<slug>', note: 'pastes live on OpenVibe.Community (Live\'s /p/<slug> redirects there)' },
{ title: 'Chat', url: `${b}/chat` },
-- { title: 'The Rundown', url: `${b}/rundown`, note: 'what streamers talked about, written up: topics, takes and episodes' },
++ { title: 'The Rundown', url: `${b}/rundown`, note: `what streamers talked about, written up: topics, takes, episodes, and the UTC day summary and timeline at ${b}/api/rundown/day/:date (or /today)` },
] },
{ title: 'What the AI made (AI Moments)', links: [
{ title: 'AI Moments', url: `${b}/moments`, note: `auto-clips cut when chat reacted, frames the AI picked from streams, and AI-written after-show recaps. JSON: ${b}/api/content/moments. AI Moments pages are noindex; the /moments collection is their indexable form. They are never listed in the sitemap. An AI clip says "AI clip · from <streamer>'s stream"; it is never presented as something the streamer or a viewer clipped. A streamer can turn AI Moments off for their channel; the AI then makes no new ones from their streams.` },
ddiff --git a/test/fixtures/rundown-day.json b/test/fixtures/rundown-day.json
nnew file mode 100644
iindex 00000000..4c4747db
---- /dev/null
++++ b/test/fixtures/rundown-day.json
@@@ -0,0 +1,9 @@
++{
++ "date": "2026-11-09",
++ "takes": [
++ {"id": 481, "what": "Ari discussed the game update."},
++ {"id": 482, "what": "Ari said the new map loads faster."},
++ {"id": 483, "what": "Ari compared the update with last season."}
++ ],
++ "summary": "Ari discussed the game update. [takes: 481,482] The new map loads faster than last season. [takes: 482,483]"
++}
ddiff --git a/test/rundown-day.test.js b/test/rundown-day.test.js
nnew file mode 100644
iindex 00000000..a4fe27bc
---- /dev/null
++++ b/test/rundown-day.test.js
@@@ -0,0 +1,14 @@
++'use strict';
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const day = require('../server/rundown/day');
++const fixture = JSON.parse(fs.readFileSync(path.join(__dirname, 'fixtures/rundown-day.json'), 'utf8'));
++assert.strictEqual(day.validDate(fixture.date), true);
++assert.strictEqual(day.validDate('2026-02-30'), false);
++assert.strictEqual(day.validDate('today'), false);
++assert.deepStrictEqual(day.splitSentences(fixture.summary), [
++ 'Ari discussed the game update. [takes: 481,482]',
++ 'The new map loads faster than last season. [takes: 482,483]',
++]);
++console.log('rundown day fixtures: OK');