openvibers/openvibe.live Commits
Home cards: durable replay pictures and one channel-line shape (#780)
Changes
nnew file mode 100644@@@ -0,0 +1,170 @@++/**++ * channel-line.js — the one-line channel description on the home "Recently Online" cards.++ *++ * The stored overviews come from OpenVibe.AI (live.streamer.overview, model "openvibe-ai", refreshed by++ * server/ai/streamer-overview-job.js) in different shapes: "Maticus streams …", "AmberFieraGames37: …",++ * "Renyi1333 is a car-streamer who …". The card already carries the name, so the card line is reshaped to++ * one shape (owner, 2026-11-10): one sentence, what they stream first ("Streams League of Legends, mostly++ * ranked, with voice chat."), no name at the start, no colon, at most 90 characters, present tense. The full++ * overview keeps its detail; only the card line is reshaped. Describing people stays as AGENTS.md allows++ * (the owner lifted the old no-looks rule); only the explicit guard refuses a line.++ *++ * Used on the write path (db.upsertStreamerOverview and db.cleanupMalformedAiText, so the streamer-overview++ * job that already rewrites the stored rows refreshes them into this shape), by that job's deterministic++ * reshape pass (db.shapeStoredStreamerOverviewShorts), and on the read path (GET++ * /api/streams/recently-online shapes any row the job has not reached yet).++ */++'use strict';++++const { isExplicit } = require('./explicit');++++// The same sentence shape where there is too little to go on.++const FALLBACK_CHANNEL_LINE = 'Streams live on OpenVibe.';++// "Streams " + 1-81 content characters + "."; no colon, no other sentence end.++const CHANNEL_LINE_RE = /^Streams [^:.!?]{1,81}\.$/;++const MAX_LEN = 90;++const MAX_CONTENT = MAX_LEN - 'Streams '.length - 1; // 81++++// Joining words a cut may not end on ("… sessions and", "… with a").++const TRAIL_WORD = /^(?:a|an|the|and|or|but|with|from|in|on|at|by|for|of|to|as|that|while|when|often|usually|frequently|typically|mostly|generally|sometimes|featuring|including|presenting)$/i;++// The verb the card line opens with, wherever the overview puts it early ("the flaming feline streams …").++const VERB = /\b(?:streams?|broadcasts?|presents?|plays?|hosts?|covers?|shows?|builds?|creates?|chats?)\b/i;++// A description in the past tense is not the card's present tense: use the fallback shape instead.++const PAST = /\b(?:streamed|broadcast|used to|was|were|had|played|hosted|presented|built|created|chatted)\b/i;++// A full "Name: …" opener, after the name is off, can leave a leading separator.++const LEADING_JUNK = /^[\s:;,.\-–—]+/;++// Stop a single long phrase at the last whole prepositional phrase that still fits.++const TAIL_PREP = /\s(?:with|from|in|on|at|by|for|about|over|under|into)\s/gi;++++/** Plain text: a JSON-wrapped model answer and markdown noise become prose. */++function plain(text) {++ let t = String(text == null ? '' : text).trim();++ if (t.startsWith('{')) {++ try {++ const o = JSON.parse(t);++ t = String((o && (o.overview || o.description || o.text || o.line)) || t);++ } catch { /* not JSON */ }++ }++ return t.replace(/\*\*|__/g, '').replace(/`/g, '').replace(/\s+/g, ' ').trim();++}++++/** The first few sentences, each including its full stop when it has one. */++function sentences(text, max = 4) {++ const t = String(text || '').trim();++ if (!t) return [];++ const parts = t.match(/.*?[.!?](?:\s|$)|.+$/g) || [];++ return parts.map((s) => s.trim()).filter(Boolean).slice(0, max);++}++++function escRe(s) {++ return String(s || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');++}++++/** The name off the front ("Maticus streams …", "AmberFieraGames37: …" — the card has the name). */++function stripName(sentence, names) {++ let s = String(sentence || '').trim();++ for (const n of names) {++ const name = String(n || '').trim();++ if (!name) continue;++ const m = s.match(new RegExp(`^${escRe(name)}(?:'s)?\\s*:?\\s*`, 'i'));++ if (m) { s = s.slice(m[0].length); break; }++ }++ return s.replace(LEADING_JUNK, '').trim();++}++++/**++ * The content after the streaming verb, or null when the sentence has no verb early enough to be about++ * the channel ("A trading-focused channel where …" has none and falls back).++ */++function contentAfterVerb(sentence) {++ const m = VERB.exec(sentence);++ if (!m || m.index > 70) return null;++ return sentence.slice(m.index + m[0].length).replace(LEADING_JUNK, '').trim() || null;++}++++/** "is a car-streamer who …" keeps its role; a bare "is a streamer who …" says nothing. */++function roleOf(sentence) {++ const m = sentence.match(/^is\s+(?:an?\s+)?(.+?)\s+(?:who|that|whose|where)\b/i);++ if (!m) return null;++ const role = m[1].replace(/\s+/g, ' ').trim();++ if (!role || role.length > 40) return null;++ if (/^(?:an?\s+)?(?:streamer|content\s+creator|creator|broadcaster|user|channel|gamer)$/i.test(role)) return null;++ if (/\b(?:channel|server|community)$/i.test(role)) return null;++ return role;++}++++/** Cut a single long phrase at a whole word, dropping the joining word it would end on. */++function cutWords(s) {++ let t = s.slice(0, MAX_CONTENT).trim();++ for (;;) {++ const before = t;++ t = t.replace(/[\s,;:.!?…–—-]+$/g, '');++ t = t.replace(/\s+(\S+)$/, (m, w) => (TRAIL_WORD.test(w) ? '' : m));++ if (t === before) break;++ }++ return t;++}++++/** One long phrase may end at a whole prepositional phrase ("… gameplay with a focus on …"). */++function cutPhrase(s) {++ if (s.length <= MAX_CONTENT) return s;++ TAIL_PREP.lastIndex = 0;++ for (let m; (m = TAIL_PREP.exec(s));) {++ // The first whole prepositional phrase that still leaves a real line is the cut: after it++ // comes a new phrase the card line cannot hold ("… gameplay with a focus on immersive worlds").++ if (m.index >= 35 && m.index <= MAX_CONTENT) return s.slice(0, m.index).trim();++ }++ return cutWords(s);++}++++/** Content at most MAX_CONTENT, preferring whole comma-separated phrases. */++function fitContent(content) {++ const s = String(content || '').replace(/\s+/g, ' ').trim();++ if (!s) return '';++ if (s.length <= MAX_CONTENT) return s;++ const phrases = s.split(/\s*,\s*/).filter(Boolean);++ if (phrases.length > 1) {++ let out = phrases[0];++ for (let i = 1; i < phrases.length; i++) {++ const next = `${out}, ${phrases[i]}`;++ if (next.length > MAX_CONTENT) break;++ out = next;++ }++ if (out.length <= MAX_CONTENT && out !== s) return out;++ if (out.length > MAX_CONTENT) return cutPhrase(out);++ }++ return cutPhrase(s);++}++++/**++ * Shape a stored overview into the card line. `names` are the username/display name to strip.++ * Returns the shaped line, or FALLBACK_CHANNEL_LINE when there is too little to go on.++ */++function shapeChannelLine({ username = null, displayName = null, overview = '' } = {}) {++ for (const raw of sentences(plain(overview))) {++ // A label sentence ("Name (@handle).") says nothing on its own; the next one carries the content.++ const sentence = stripName(raw, [displayName, username]);++ if (!sentence || PAST.test(sentence)) continue;++ // "is a streamer whose broadcasts …" says nothing about what they stream.++ if (/^is\s+(?:an?\s+)?(?:streamer|content\s+creator|creator|broadcaster|user|gamer)\b/i.test(sentence)) continue;++++ let content = contentAfterVerb(sentence);++ if (!content) {++ const role = roleOf(sentence);++ if (role) content = `as a ${role}`;++ }++ if (!content) continue;++++ // A colon never reaches a card line; a comma keeps the sentence reading.++ content = fitContent(content.replace(/:/g, ',')).replace(/[.!?…]+$/g, '').trim();++ if (!content) continue;++ const line = `Streams ${content}.`;++ if (line.length > MAX_LEN || !CHANNEL_LINE_RE.test(line)) continue;++ // The owner's line (AGENTS.md, server/ai/explicit.js): people may be described; sex acts never are.++ if (isExplicit(line)) return FALLBACK_CHANNEL_LINE;++ return line;++ }++ return FALLBACK_CHANNEL_LINE;++}++++module.exports = { shapeChannelLine, CHANNEL_LINE_RE, FALLBACK_CHANNEL_LINE, MAX_LEN };@@@ -21,9 +21,17 @@ let _timer = null;let _busy = false;async function tick() {-- if (_busy || !await ai.isEnabled()) return;++ if (_busy) return;_busy = true;try {++ // The stored card line is brought into the one shape even when AI is off (no model call: the full++ // overview is already stored). This is the job that rewrites the stored rows, so the old name-first++ // lines are refreshed here, not by hand.++ try {++ const reshaped = await db.shapeStoredStreamerOverviewShorts(100);++ if (reshaped) console.log(`[AI] Streamer-overview job reshaped ${reshaped} stored card line(s)`);++ } catch (e) { console.warn('[AI] streamer overview reshape:', e.message); }++ if (!await ai.isEnabled()) return;const due = await db.getStreamersNeedingOverview({ decentLen: DECENT_LEN, limit: BATCH }) || [];for (const row of due) {try { await ai.generateStreamerOverview(row.user_id); }@@@ -463,6 +463,19 @@ function _shortOverview(text) {const sp = cut.lastIndexOf(' ');return (sp > 40 ? cut.slice(0, sp) : cut).trim() + '…';}++/** A streamer's card line in the one channel shape (server/ai/channel-line.js); the name comes off via their row. */++async function _shapedChannelLine(userId, overview) {++ let username = null, displayName = null;++ try {++ const u = await get('SELECT username, display_name FROM users WHERE id = ?', [userId]);++ if (u) { username = u.username; displayName = u.display_name; }++ } catch { /* without the name the line still shapes, minus the name strip */ }++ try {++ return require('../ai/channel-line').shapeChannelLine({ username, displayName, overview: _plainProse(overview || '') });++ } catch {++ return _shortOverview(overview);++ }++}async function updateStreamAiOverview(streamId, text) {return await run('UPDATE streams SET ai_overview = ?, ai_overview_short = ? WHERE id = ?', [text || null, _shortOverview(text), streamId]);}@@@ -720,7 +733,7 @@ async function cleanupMalformedAiText() {WHERE overview ~ '(^|\n)\\s*([-*•#]|\\d+[.)])\\s' OR overview LIKE '%**%' OR overview_short ~ '^\\s*[-*•#]'`);for (const r of rows) {const clean = _plainProse(r.v);-- await run('UPDATE streamer_overviews SET overview = ?, overview_short = ? WHERE user_id = ?', [clean, _shortOverview(clean), r.k]);++ await run('UPDATE streamer_overviews SET overview = ?, overview_short = ? WHERE user_id = ?', [clean, await _shapedChannelLine(r.k, clean), r.k]);fixed++;}} catch { /* */ }@@@ -916,16 +929,41 @@ async function getStreamTranscriptSegments(streamId) {// countAutoClipsSince() removed — the media subsystem (vods/clips/pastes writes) moved to OpenVibe.Media.async function upsertStreamerOverview(userId, { overview, model = null, sources = null }) {++ // The card line is stored in the one channel shape, so the streamer-overview job refreshes stored lines++ // through this write; rows it has not rewritten yet are shaped on read (GET /api/streams/recently-online).++ const short = await _shapedChannelLine(userId, overview);return await run(`INSERT INTO streamer_overviews (user_id, overview, overview_short, model, sources, generated_at)VALUES (?, ?, ?, ?, ?, ov_now())ON CONFLICT(user_id) DO UPDATE SEToverview = excluded.overview, overview_short = excluded.overview_short, model = excluded.model,sources = excluded.sources, generated_at = ov_now()`,-- [userId, _plainProse(overview || ''), _shortOverview(overview), model, sources]);++ [userId, _plainProse(overview || ''), short, model, sources]);}async function getStreamerOverview(userId) {return await get('SELECT * FROM streamer_overviews WHERE user_id = ?', [userId]);}++/**++ * The stored card lines the streamer-overview job wrote before the one shape (name first, a colon, past the++ * 90 characters) are reshaped from the stored full overview without an AI call. The job calls this every tick;++ * the read path also shapes, so no page shows an old shape while this catches up. Returns how many changed.++ */++async function shapeStoredStreamerOverviewShorts(limit = 20) {++ const rows = await all(`SELECT o.user_id, o.overview, o.overview_short, u.username, u.display_name++ FROM streamer_overviews o LEFT JOIN users u ON u.id = o.user_id++ WHERE o.overview IS NOT NULL AND TRIM(o.overview) <> ''++ AND (o.overview_short IS NULL OR LENGTH(o.overview_short) > 90 OR o.overview_short LIKE '%:%'++ OR o.overview_short NOT LIKE 'Streams %')++ ORDER BY o.generated_at ASC NULLS FIRST LIMIT ?`, [limit]);++ let changed = 0;++ for (const r of rows) {++ const short = await _shapedChannelLine(r.user_id, r.overview);++ if (short && short !== r.overview_short) {++ await run('UPDATE streamer_overviews SET overview_short = ? WHERE user_id = ?', [short, r.user_id]);++ changed++;++ }++ }++ return changed;++}// Assemble the full AI timeline for a streamer from already-generated AI data (no LLM cost):// the whole-streamer overview + every session that has an AI overview or captured memories,@@@ -3750,7 +3788,7 @@ module.exports = {cleanupMalformedAiText, publicMemoryText: _publicMemoryText, recordAiUsage, getAiCostToday, getAiCostTodayForUser, getAiUsageSummary,getStreamMemoriesByUser, countStreamMemoriesByUser, getStreamTranscriptSegments,addTimelineEvents, getTimeline, getTimelineText, getTimelineCoverage, linkTimelineToVod, getTimelineByVod, getTimelineVodId,-- upsertStreamerOverview, getStreamerOverview, getAllStreamerOverviews, getStreamersNeedingOverview,++ upsertStreamerOverview, getStreamerOverview, shapeStoredStreamerOverviewShorts, getAllStreamerOverviews, getStreamersNeedingOverview,readStreamerAiTimelineCache, buildStreamerAiTimeline, assembleStreamerAiTimeline, setStreamAiTitle, getUntitledAiSessions, clearAiTimelineCache,// Homepage helpersgetRecentlyOnlineStreamers, countRecentlyOnlineStreamers,@@@ -1324,6 +1324,8 @@ router.get('/recent', async (req, res) => {});// ── Recently Online (grouped by user) ────────────────────────++// The card's numbers: every card carries all five, present or null, so the page lays them out the same.++const RECENT_FACT_KEYS = ['streams_this_week', 'hours_this_week', 'usual_time', 'days_in_a_row', 'last_stream_seconds'];router.get('/recently-online', async (req, res) => {try {const limit = Math.min(Math.max(parseInt(req.query.limit || '20', 10), 1), 100);@@@ -1366,6 +1368,7 @@ router.get('/recently-online', async (req, res) => {if (t && !ms.vod_thumbnail) ms.vod_thumbnail = media.publicUrl(t.thumbnail_url);// The recording the row's picture is from: the row opens its replay (with the chat in time).if (t && t.vod_id && !ms.vod_id) ms.vod_id = t.vod_id;++ if (t && t.audio_only != null && ms.audio_only == null) ms.audio_only = !!t.audio_only;}}}@@@ -1382,6 +1385,7 @@ router.get('/recently-online', async (req, res) => {const v = vods.get(Number(ms.stream_id));if (v && v.thumbnail_url) ms.vod_thumbnail = media.publicUrl(v.thumbnail_url);if (v && v.id) ms.vod_id = v.id;++ if (v && v.audio_only != null && ms.audio_only == null) ms.audio_only = !!v.audio_only;});}} catch (err) {@@@ -1418,12 +1422,35 @@ router.get('/recently-online', async (req, res) => {const summarize = require('../home/recent-facts').summarizeHistory;for (const s of streamers) {s.recent_facts = summarize(rows.filter(r => r.user_id === s.user_id));++ for (const k of RECENT_FACT_KEYS) if (s.recent_facts[k] === undefined) s.recent_facts[k] = null;for (const h of s.recent_facts.streams) {const slot = (s.managed_streams || []).find(ms => Number(ms.managed_stream_id) === Number(h.managed_stream_id));-- if (slot) { h.vod_id = slot.vod_id || null; h.thumbnail_url = h.thumbnail_url || slot.vod_thumbnail || null; }++ if (slot) {++ h.vod_id = slot.vod_id || null;++ // A replay's picture is its recording's durable thumbnail. streams.thumbnail_url is the++ // stream's last live capture (/api/thumbnails/stream-*.jpg), deleted when the stream ends++ // (or a stale Media URL from before a regeneration), so every replay row named a 404 or a++ // 1×1 pixel (owner, 2026-11-10). While live the capture still exists and stays first; with++ // no recording the row carries null and the page marks it, instead of an address that fails.++ h.thumbnail_url = h.is_live ? (h.thumbnail_url || null) : (slot.vod_thumbnail || null);++ h.audio_only = !h.is_live && slot.audio_only != null ? !!slot.audio_only : null;++ } else {++ h.vod_id = null;++ h.audio_only = null;++ if (!h.is_live) h.thumbnail_url = null;++ }}}}++ // Every card carries the same numbers, present or null, and its line in the one channel shape++ // (stored lines the job has not rewritten yet are shaped here).++ const { shapeChannelLine } = require('../ai/channel-line');++ for (const s of streamers) {++ if (!s.recent_facts) { s.recent_facts = { streams: [] }; for (const k of RECENT_FACT_KEYS) s.recent_facts[k] = null; }++ try {++ s.ai_overview_short = shapeChannelLine({ username: s.username, displayName: s.display_name, overview: s.ai_overview || s.ai_overview_short });++ } catch { /* the stored line stands */ }++ }res.json({ streamers, total, limit, offset, hasMore: offset + streamers.length < total });} catch (err) {console.error('[Streaming]', err.message);nnew file mode 100644@@@ -0,0 +1,102 @@++'use strict';++// The home "Recently Online" card line has one shape (owner, 2026-11-10): one sentence, what they stream++// first, no name at the start (the card has the name), no colon, at most 90 characters, in the present++// tense — and the same fallback sentence shape where there is too little to go on. Ten transcripts, each++// with the stored overview the streamer-overview job wrote from it, all checked against CHANNEL_LINE_RE.++// node test/channel-line-shape.test.js++const assert = require('assert');++++const { shapeChannelLine, CHANNEL_LINE_RE, FALLBACK_CHANNEL_LINE } = require('../server/ai/channel-line');++++// Each fixture: a transcript excerpt (what the streamer said), the stored overview the job wrote from it,++// and the card line. Present tense is pinned structurally: the shaper only ever opens with "Streams" and++// the test refuses a past-tense verb.++const FIXTURES = [++ {++ name: 'a ranked grinder, named and long',++ username: 'izor_one', displayName: 'iZoR_One',++ transcript: '… we go mid, we fight this — nice, clutch! … ranked grind continues …',++ overview: 'iZoR_One streams League of Legends, with a focus on ranked grind, clutch plays, funny moments, and chill vibes. A webcam is typically in the bottom-left.',++ line: 'Streams League of Legends, with a focus on ranked grind, clutch plays, funny moments.',++ },++ {++ name: 'a "Name: …" opener with the verb inside',++ username: 'amberfiera', displayName: 'AmberFieraGames37',++ transcript: '… welcome to the Zaibatsu Q&A, drop your stream ideas …',++ overview: 'AmberFieraGames37: Spicy and fiery, the flaming feline streams a wide range of Roblox and fantasy-adventure gameplay with a focus on immersive worlds.',++ line: 'Streams a wide range of Roblox and fantasy-adventure gameplay.',++ },++ {++ name: 'a copula sentence keeps its role',++ username: 'renyi1333', displayName: 'Renyi1333',++ transcript: '… driving downtown now, say hi to my friend … casual chats …',++ overview: 'Renyi1333 is a car-streamer who often sits in the front seat with a friend nearby, streaming casual chats and drive-time streams.',++ line: 'Streams as a car-streamer.',++ },++ {++ name: 'a bare "is a streamer who/whose" says nothing: fallback',++ username: 'ggvoorlarz', displayName: 'GGvoorLarz',++ transcript: '… [mostly silence, game sounds] …',++ overview: 'GGvoorLarz is a streamer whose broadcasts center around off-screen or minimal-visual sessions, with the live UI featuring chat.',++ line: FALLBACK_CHANNEL_LINE,++ },++ {++ name: 'no overview at all: fallback',++ username: 'jimenezjohary', displayName: 'jimenezjohary',++ transcript: '… sorteo entre amigos, cien boletos …',++ overview: '',++ line: FALLBACK_CHANNEL_LINE,++ },++ {++ name: 'the tried-and-empty sentinel: fallback',++ username: 'quietone', displayName: 'quietone',++ transcript: '',++ overview: ' ',++ line: FALLBACK_CHANNEL_LINE,++ },++ {++ name: 'multi-sentence: the first sentence only, whole phrases kept',++ username: 'maticus', displayName: 'Maticus',++ transcript: '… the robot arm is live, multi-cam is up, ask me about the setup …',++ overview: 'Maticus streams a multi-camera, tech-robot focus with live chats and overlays, often presenting and reacting to surveillance-style feeds. The current threads loop back to robotics discussions.',++ line: 'Streams a multi-camera, tech-robot focus with live chats and overlays.',++ },++ {++ name: 'a long line keeps whole comma-separated phrases',++ username: 'okayy', displayName: 'okayy',++ transcript: '… chilling outside with everyone, come say hi …',++ overview: 'okayy streams outdoors and indoors in relaxed, conversational IRL sessions, often with multiple people chatting on camera for hours.',++ line: 'Streams outdoors and indoors in relaxed, conversational IRL sessions.',++ },++ {++ name: 'a line near the cap stays whole',++ username: 'sh4olan', displayName: 'sh4olan',++ transcript: '… cozy room tour, chatting with you all …',++ overview: 'sh4olan streams as an irl creator who chats with viewers from a cozy, decor-filled room.',++ line: 'Streams as an irl creator who chats with viewers from a cozy, decor-filled room.',++ },++ {++ name: 'beyond the owner\u2019s rule (sex acts): fallback, never the line',++ username: 'badactor', displayName: 'badactor',++ transcript: '…',++ overview: 'badactor streams pornographic content every night with explicit adult videos.',++ line: FALLBACK_CHANNEL_LINE,++ },++];++++assert.strictEqual(FIXTURES.length, 10, 'ten transcripts');++for (const f of FIXTURES) {++ const line = shapeChannelLine({ username: f.username, displayName: f.displayName, overview: f.overview });++ assert.strictEqual(line, f.line, f.name);++ assert.match(line, CHANNEL_LINE_RE, `${f.name}: ${line}`);++ assert.ok(line.length <= 90, `${f.name}: ${line.length} chars`);++ assert.ok(!line.includes(':'), `${f.name}: no colon`);++ assert.ok(!/\b(?:streamed|used to|was|were|had|played|hosted|presented)\b/i.test(line), `${f.name}: present tense`);++ assert.ok(!line.toLowerCase().startsWith(f.username.toLowerCase()), `${f.name}: no name at the start`);++ assert.ok(!line.toLowerCase().startsWith(String(f.displayName || '').toLowerCase()), `${f.name}: no display name at the start`);++}++assert.match(FALLBACK_CHANNEL_LINE, CHANNEL_LINE_RE, 'the fallback is the same shape');++assert.strictEqual(shapeChannelLine(), FALLBACK_CHANNEL_LINE, 'no arguments');++assert.strictEqual(shapeChannelLine({ overview: null }), FALLBACK_CHANNEL_LINE, 'null overview');++assert.strictEqual(shapeChannelLine({ username: 'Maticus', displayName: 'Maticus', overview: '{"overview":"Maticus streams chess and cooking."}' }), 'Streams chess and cooking.', 'JSON-wrapped model text');++console.log('channel-line shape: all checks passed');nnew file mode 100644@@@ -0,0 +1,102 @@++'use strict';++// GET /api/streams/recently-online cards (server/streaming/routes.js): a replay row's picture is its++// recording's durable thumbnail — streams.thumbnail_url is the stream's last live capture++// (/api/thumbnails/stream-*.jpg), deleted when the stream ends, so every replay row named a 404++// (owner, 2026-11-10). With no recording the row carries nulls instead of an address that fails;++// a live row keeps its capture. Every card carries the same five numbers, present or null, and its++// line in the one channel shape (server/ai/channel-line.js).++// node --import ./test/helpers/pg-preload.mjs test/recently-online-cards.test.js++const assert = require('assert');++const http = require('http');++const express = require('express');++++process.env.NODE_ENV = 'test';++console.warn = () => {};++const quiet = console.log;++console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };++++const db = require('../server/db/database');++const { CHANNEL_LINE_RE } = require('../server/ai/channel-line');++const KEYS = ['streams_this_week', 'hours_this_week', 'usual_time', 'days_in_a_row', 'last_stream_seconds'];++++(async () => {++ await db.initDb();++ const mk = async (username) => { await db.createUser({ username, email: `${username}@x`, password_hash: 'x', display_name: username, stream_key: username.padEnd(32, '0') }); const id = (await db.get('SELECT id FROM users WHERE username = ?', [username])).id; await db.ensureChannel(id); return id; };++ const pic = await mk('picasso'), nov = await mk('novod'), liv = await mk('liveone');++ const slot = async (uid, slug) => Number((await db.run('INSERT INTO managed_streams (user_id, slug, title, protocol, stream_key, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ov_now(), ov_now()) RETURNING id', [uid, slug, `${slug} title`, 'webrtc', `${slug}-key-${uid}`])).lastInsertRowid);++ const picSlot = await slot(pic, 'main'), livSlot = await slot(liv, 'main');++ const stream = async (uid, ms, { live = false, startedAgo, endedAgo = null, thumb = null }) => await db.run(++ `INSERT INTO streams (user_id, managed_stream_id, title, protocol, is_live, viewer_count, started_at, ended_at, thumbnail_url, created_at)++ VALUES (?, ?, ?, 'webrtc', ?, 0, to_char(now() - make_interval(mins => ?), 'YYYY-MM-DD HH24:MI:SS'), ${live ? 'NULL' : "to_char(now() - make_interval(mins => ?), 'YYYY-MM-DD HH24:MI:SS')"}, ?, ov_now())`,++ live ? [uid, ms, 'LIVE NOW', 1, startedAgo, thumb] : [uid, ms, 'old', 0, startedAgo, endedAgo, thumb]);++ // Ended streams whose stored picture is the deleted live capture.++ await stream(pic, picSlot, { startedAgo: 3000, endedAgo: 2900, thumb: '/api/thumbnails/stream-9101-1.jpg' });++ await stream(pic, picSlot, { startedAgo: 600, endedAgo: 500, thumb: '/api/thumbnails/stream-9102-2.jpg' });++ // An ended stream with no slot and no recording anywhere.++ await stream(nov, null, { startedAgo: 700, endedAgo: 650, thumb: '/api/thumbnails/stream-9103-3.jpg' });++ // A live stream: its capture still exists.++ await stream(liv, livSlot, { live: true, startedAgo: 10, thumb: '/api/thumbnails/stream-9104-live.jpg' });++++ // A stored card line in the old shape (name first, colon, two sentences).++ await db.run("INSERT INTO streamer_overviews (user_id, overview, overview_short, model, sources, generated_at) VALUES (?, ?, ?, 'x', '{}', ov_now())",++ [pic, 'picasso streams late-night Rust raids with the whole crew: zergs, door-camping and betrayals. The chat runs the raid targets.', 'picasso streams late-night Rust raids with the whole crew: zergs, door-camping and betrayals.']);++++ // The stored old-shape line is refreshed by the job that rewrites stored overviews (no AI call).++ assert.ok(await db.shapeStoredStreamerOverviewShorts(50) >= 1);++ assert.match((await db.getStreamerOverview(pic)).overview_short, CHANNEL_LINE_RE, 'stored line reshaped in place');++++ // Media: the slot's recording has a durable picture and is audio-only; the loose stream has no VOD.++ const media = require('../server/media-client');++ media.request = async (method, p) => {++ assert.strictEqual(p, '/vods/latest-thumbs');++ return { thumbs: { [picSlot]: { vod_id: 9, thumbnail_url: '/t/vod-9-new.jpg', audio_only: true } } };++ };++ require('../server/media-proxy/lookups').publicVodsForStreams = async () => new Map();++++ const app = express();++ app.use('/api/streams', require('../server/streaming/routes'));++ const server = await new Promise((r) => { const s = http.createServer(app).listen(0, '127.0.0.1', () => r(s)); });++ try {++ const res = await fetch(`http://127.0.0.1:${server.address().port}/api/streams/recently-online?limit=10`);++ assert.strictEqual(res.status, 200);++ const body = await res.json();++ const by = (u) => body.streamers.find((s) => s.username === u);++ const p = by('picasso'), n = by('novod'), l = by('liveone');++ assert.ok(p && n && l, JSON.stringify(body.streamers.map((s) => s.username)));++++ // The replay rows picture the recording, not the deleted capture.++ assert.strictEqual(p.recent_facts.streams.length, 2);++ for (const h of p.recent_facts.streams) {++ assert.ok(String(h.thumbnail_url).endsWith('/t/vod-9-new.jpg'), h.thumbnail_url);++ assert.strictEqual(h.vod_id, 9);++ assert.strictEqual(h.audio_only, true, 'audio-only says so with a field');++ }++ // No recording: nulls, never a dead address.++ assert.strictEqual(n.recent_facts.streams.length, 1);++ assert.deepStrictEqual(++ [n.recent_facts.streams[0].thumbnail_url, n.recent_facts.streams[0].vod_id, n.recent_facts.streams[0].audio_only],++ [null, null, null]);++ // Live: the capture stays.++ const liveRow = l.recent_facts.streams.find((h) => h.is_live);++ assert.ok(liveRow, 'the live row is listed');++ assert.strictEqual(liveRow.thumbnail_url, '/api/thumbnails/stream-9104-live.jpg');++ assert.strictEqual(liveRow.audio_only, null, 'a live row never borrows an old recording\u2019s flag');++++ // The same five numbers on every card, present or null.++ for (const s of body.streamers) {++ for (const k of KEYS) assert.ok(s.recent_facts[k] !== undefined, `${s.username}.${k}`);++ assert.ok(Array.isArray(s.recent_facts.streams) && s.recent_facts.streams.length <= 2);++ }++ assert.strictEqual(typeof p.recent_facts.streams_this_week, 'number');++ assert.strictEqual(n.recent_facts.usual_time, null, 'one stream: no usual time');++ assert.strictEqual(n.recent_facts.days_in_a_row, null, 'one stream: no streak');++++ // The stored old-shape line is shaped on read.++ assert.match(p.ai_overview_short, CHANNEL_LINE_RE, p.ai_overview_short);++ assert.ok(!p.ai_overview_short.toLowerCase().startsWith('picasso'), p.ai_overview_short);++ assert.match(n.ai_overview_short, CHANNEL_LINE_RE, 'no stored overview: the fallback shape');++++ console.log('recently-online cards: all checks passed');++ } finally { server.close(); }++ process.exit(0);++})().catch((e) => { console.error(e); process.exit(1); });@@@ -6,6 +6,7 @@ const assert = require('assert');console.warn = () => {};const db = require('../server/db/database');++const { CHANNEL_LINE_RE } = require('../server/ai/channel-line');(async () => {await db.initDb();const mk = async (username) => { await db.createUser({ username, email: `${username}@x`, password_hash: 'x', display_name: username, stream_key: username.padEnd(32, '0') }); return (await db.get('SELECT id FROM users WHERE username = ?', [username])).id; };@@@ -14,7 +15,7 @@ const db = require('../server/db/database');await db.upsertStreamerOverview(fixer, { overview: '## Overview\n- Streamer handle: finditfixit (@finditfixit).\n- **Content:** repairs old TVs and radios on stream\n- Tone: calm, patient' });const a = await db.getStreamerOverview(fixer);assert.strictEqual(a.overview, 'finditfixit (@finditfixit). repairs old TVs and radios on stream. calm, patient.');-- assert.ok(!/^[-*#\s]/.test(a.overview_short) && !a.overview_short.includes('**'), a.overview_short);++ assert.match(a.overview_short, CHANNEL_LINE_RE, 'the card line is the one shape');const prose = 'plainann streams Rust and talks through every build. Tone: chill.\nThe chat is small and friendly.';await db.upsertStreamerOverview(plain, { overview: prose });@@@ -26,11 +27,13 @@ const db = require('../server/db/database');await db.cleanupMalformedAiText();const b = await db.getStreamerOverview(old);assert.strictEqual(b.overview, 'oldlist (@oldlist). Streams Minecraft builds most nights.');-- assert.strictEqual(b.overview_short, 'oldlist (@oldlist). Streams Minecraft builds most nights.');++ assert.strictEqual(b.overview_short, 'Streams Minecraft builds most nights.', 'the label sentence is skipped for the line');assert.strictEqual((await db.getStreamerOverview(plain)).overview, prose, 'the repair leaves prose alone');await db.upsertStreamerOverview(plain, { overview: ' ' });-- assert.strictEqual((await db.getStreamerOverview(plain)).overview_short, ' ', 'the tried-and-empty sentinel round-trips');++ const sentinel = await db.getStreamerOverview(plain);++ assert.strictEqual(sentinel.overview, ' ', 'the tried-and-empty sentinel round-trips in the full overview');++ assert.match(sentinel.overview_short, CHANNEL_LINE_RE, 'a sentinel still has the fallback card line');console.log('streamer-overview-prose: all checks passed');process.exit(0);
new file mode 100644
new file mode 100644
@@ -0,0 +1,170 @@
@@ -0,0 +1,170 @@
+/**
+ * channel-line.js — the one-line channel description on the home "Recently Online" cards.
+ *
+ * The stored overviews come from OpenVibe.AI (live.streamer.overview, model "openvibe-ai", refreshed by
+ * server/ai/streamer-overview-job.js) in different shapes: "Maticus streams …", "AmberFieraGames37: …",
+ * "Renyi1333 is a car-streamer who …". The card already carries the name, so the card line is reshaped to
+ * one shape (owner, 2026-11-10): one sentence, what they stream first ("Streams League of Legends, mostly
+ * ranked, with voice chat."), no name at the start, no colon, at most 90 characters, present tense. The full
+ * overview keeps its detail; only the card line is reshaped. Describing people stays as AGENTS.md allows
+ * (the owner lifted the old no-looks rule); only the explicit guard refuses a line.
+ *
+ * Used on the write path (db.upsertStreamerOverview and db.cleanupMalformedAiText, so the streamer-overview
+ * job that already rewrites the stored rows refreshes them into this shape), by that job's deterministic
+ * reshape pass (db.shapeStoredStreamerOverviewShorts), and on the read path (GET
+ * /api/streams/recently-online shapes any row the job has not reached yet).
+ */
+'use strict';
+
+const { isExplicit } = require('./explicit');
+
+// The same sentence shape where there is too little to go on.
+const FALLBACK_CHANNEL_LINE = 'Streams live on OpenVibe.';
+// "Streams " + 1-81 content characters + "."; no colon, no other sentence end.
+const CHANNEL_LINE_RE = /^Streams [^:.!?]{1,81}\.$/;
+const MAX_LEN = 90;
+const MAX_CONTENT = MAX_LEN - 'Streams '.length - 1; // 81
+
+// Joining words a cut may not end on ("… sessions and", "… with a").
+const TRAIL_WORD = /^(?:a|an|the|and|or|but|with|from|in|on|at|by|for|of|to|as|that|while|when|often|usually|frequently|typically|mostly|generally|sometimes|featuring|including|presenting)$/i;
+// The verb the card line opens with, wherever the overview puts it early ("the flaming feline streams …").
+const VERB = /\b(?:streams?|broadcasts?|presents?|plays?|hosts?|covers?|shows?|builds?|creates?|chats?)\b/i;
+// A description in the past tense is not the card's present tense: use the fallback shape instead.
+const PAST = /\b(?:streamed|broadcast|used to|was|were|had|played|hosted|presented|built|created|chatted)\b/i;
+// A full "Name: …" opener, after the name is off, can leave a leading separator.
+const LEADING_JUNK = /^[\s:;,.\-–—]+/;
+// Stop a single long phrase at the last whole prepositional phrase that still fits.
+const TAIL_PREP = /\s(?:with|from|in|on|at|by|for|about|over|under|into)\s/gi;
+
+/** Plain text: a JSON-wrapped model answer and markdown noise become prose. */
+function plain(text) {
+ let t = String(text == null ? '' : text).trim();
+ if (t.startsWith('{')) {
+ try {
+ const o = JSON.parse(t);
+ t = String((o && (o.overview || o.description || o.text || o.line)) || t);
+ } catch { /* not JSON */ }
+ }
+ return t.replace(/\*\*|__/g, '').replace(/`/g, '').replace(/\s+/g, ' ').trim();
+}
+
+/** The first few sentences, each including its full stop when it has one. */
+function sentences(text, max = 4) {
+ const t = String(text || '').trim();
+ if (!t) return [];
+ const parts = t.match(/.*?[.!?](?:\s|$)|.+$/g) || [];
+ return parts.map((s) => s.trim()).filter(Boolean).slice(0, max);
+}
+
+function escRe(s) {
+ return String(s || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
+/** The name off the front ("Maticus streams …", "AmberFieraGames37: …" — the card has the name). */
+function stripName(sentence, names) {
+ let s = String(sentence || '').trim();
+ for (const n of names) {
+ const name = String(n || '').trim();
+ if (!name) continue;
+ const m = s.match(new RegExp(`^${escRe(name)}(?:'s)?\\s*:?\\s*`, 'i'));
+ if (m) { s = s.slice(m[0].length); break; }
+ }
+ return s.replace(LEADING_JUNK, '').trim();
+}
+
+/**
+ * The content after the streaming verb, or null when the sentence has no verb early enough to be about
+ * the channel ("A trading-focused channel where …" has none and falls back).
+ */
+function contentAfterVerb(sentence) {
+ const m = VERB.exec(sentence);
+ if (!m || m.index > 70) return null;
+ return sentence.slice(m.index + m[0].length).replace(LEADING_JUNK, '').trim() || null;
+}
+
+/** "is a car-streamer who …" keeps its role; a bare "is a streamer who …" says nothing. */
+function roleOf(sentence) {
+ const m = sentence.match(/^is\s+(?:an?\s+)?(.+?)\s+(?:who|that|whose|where)\b/i);
+ if (!m) return null;
+ const role = m[1].replace(/\s+/g, ' ').trim();
+ if (!role || role.length > 40) return null;
+ if (/^(?:an?\s+)?(?:streamer|content\s+creator|creator|broadcaster|user|channel|gamer)$/i.test(role)) return null;
+ if (/\b(?:channel|server|community)$/i.test(role)) return null;
+ return role;
+}
+
+/** Cut a single long phrase at a whole word, dropping the joining word it would end on. */
+function cutWords(s) {
+ let t = s.slice(0, MAX_CONTENT).trim();
+ for (;;) {
+ const before = t;
+ t = t.replace(/[\s,;:.!?…–—-]+$/g, '');
+ t = t.replace(/\s+(\S+)$/, (m, w) => (TRAIL_WORD.test(w) ? '' : m));
+ if (t === before) break;
+ }
+ return t;
+}
+
+/** One long phrase may end at a whole prepositional phrase ("… gameplay with a focus on …"). */
+function cutPhrase(s) {
+ if (s.length <= MAX_CONTENT) return s;
+ TAIL_PREP.lastIndex = 0;
+ for (let m; (m = TAIL_PREP.exec(s));) {
+ // The first whole prepositional phrase that still leaves a real line is the cut: after it
+ // comes a new phrase the card line cannot hold ("… gameplay with a focus on immersive worlds").
+ if (m.index >= 35 && m.index <= MAX_CONTENT) return s.slice(0, m.index).trim();
+ }
+ return cutWords(s);
+}
+
+/** Content at most MAX_CONTENT, preferring whole comma-separated phrases. */
+function fitContent(content) {
+ const s = String(content || '').replace(/\s+/g, ' ').trim();
+ if (!s) return '';
+ if (s.length <= MAX_CONTENT) return s;
+ const phrases = s.split(/\s*,\s*/).filter(Boolean);
+ if (phrases.length > 1) {
+ let out = phrases[0];
+ for (let i = 1; i < phrases.length; i++) {
+ const next = `${out}, ${phrases[i]}`;
+ if (next.length > MAX_CONTENT) break;
+ out = next;
+ }
+ if (out.length <= MAX_CONTENT && out !== s) return out;
+ if (out.length > MAX_CONTENT) return cutPhrase(out);
+ }
+ return cutPhrase(s);
+}
+
+/**
+ * Shape a stored overview into the card line. `names` are the username/display name to strip.
+ * Returns the shaped line, or FALLBACK_CHANNEL_LINE when there is too little to go on.
+ */
+function shapeChannelLine({ username = null, displayName = null, overview = '' } = {}) {
+ for (const raw of sentences(plain(overview))) {
+ // A label sentence ("Name (@handle).") says nothing on its own; the next one carries the content.
+ const sentence = stripName(raw, [displayName, username]);
+ if (!sentence || PAST.test(sentence)) continue;
+ // "is a streamer whose broadcasts …" says nothing about what they stream.
+ if (/^is\s+(?:an?\s+)?(?:streamer|content\s+creator|creator|broadcaster|user|gamer)\b/i.test(sentence)) continue;
+
+ let content = contentAfterVerb(sentence);
+ if (!content) {
+ const role = roleOf(sentence);
+ if (role) content = `as a ${role}`;
+ }
+ if (!content) continue;
+
+ // A colon never reaches a card line; a comma keeps the sentence reading.
+ content = fitContent(content.replace(/:/g, ',')).replace(/[.!?…]+$/g, '').trim();
+ if (!content) continue;
+ const line = `Streams ${content}.`;
+ if (line.length > MAX_LEN || !CHANNEL_LINE_RE.test(line)) continue;
+ // The owner's line (AGENTS.md, server/ai/explicit.js): people may be described; sex acts never are.
+ if (isExplicit(line)) return FALLBACK_CHANNEL_LINE;
+ return line;
+ }
+ return FALLBACK_CHANNEL_LINE;
+}
+
+module.exports = { shapeChannelLine, CHANNEL_LINE_RE, FALLBACK_CHANNEL_LINE, MAX_LEN };
@@ -21,9 +21,17 @@ let _timer = null;
@@ -21,9 +21,17 @@ let _timer = null;
let _busy = false;
let _busy = false;
async function tick() {
async function tick() {
- if (_busy || !await ai.isEnabled()) return;
+ if (_busy) return;
_busy = true;
_busy = true;
try {
try {
+ // The stored card line is brought into the one shape even when AI is off (no model call: the full
+ // overview is already stored). This is the job that rewrites the stored rows, so the old name-first
+ // lines are refreshed here, not by hand.
+ try {
+ const reshaped = await db.shapeStoredStreamerOverviewShorts(100);
+ if (reshaped) console.log(`[AI] Streamer-overview job reshaped ${reshaped} stored card line(s)`);
+ } catch (e) { console.warn('[AI] streamer overview reshape:', e.message); }
+ if (!await ai.isEnabled()) return;
const due = await db.getStreamersNeedingOverview({ decentLen: DECENT_LEN, limit: BATCH }) || [];
const due = await db.getStreamersNeedingOverview({ decentLen: DECENT_LEN, limit: BATCH }) || [];
for (const row of due) {
for (const row of due) {
try { await ai.generateStreamerOverview(row.user_id); }
try { await ai.generateStreamerOverview(row.user_id); }
@@ -463,6 +463,19 @@ function _shortOverview(text) {
@@ -463,6 +463,19 @@ function _shortOverview(text) {
const sp = cut.lastIndexOf(' ');
const sp = cut.lastIndexOf(' ');
return (sp > 40 ? cut.slice(0, sp) : cut).trim() + '…';
return (sp > 40 ? cut.slice(0, sp) : cut).trim() + '…';
}
}
+/** A streamer's card line in the one channel shape (server/ai/channel-line.js); the name comes off via their row. */
+async function _shapedChannelLine(userId, overview) {
+ let username = null, displayName = null;
+ try {
+ const u = await get('SELECT username, display_name FROM users WHERE id = ?', [userId]);
+ if (u) { username = u.username; displayName = u.display_name; }
+ } catch { /* without the name the line still shapes, minus the name strip */ }
+ try {
+ return require('../ai/channel-line').shapeChannelLine({ username, displayName, overview: _plainProse(overview || '') });
+ } catch {
+ return _shortOverview(overview);
+ }
+}
async function updateStreamAiOverview(streamId, text) {
async function updateStreamAiOverview(streamId, text) {
return await run('UPDATE streams SET ai_overview = ?, ai_overview_short = ? WHERE id = ?', [text || null, _shortOverview(text), streamId]);
return await run('UPDATE streams SET ai_overview = ?, ai_overview_short = ? WHERE id = ?', [text || null, _shortOverview(text), streamId]);
}
}
@@ -720,7 +733,7 @@ async function cleanupMalformedAiText() {
@@ -720,7 +733,7 @@ async function cleanupMalformedAiText() {
WHERE overview ~ '(^|\n)\\s*([-*•#]|\\d+[.)])\\s' OR overview LIKE '%**%' OR overview_short ~ '^\\s*[-*•#]'`);
WHERE overview ~ '(^|\n)\\s*([-*•#]|\\d+[.)])\\s' OR overview LIKE '%**%' OR overview_short ~ '^\\s*[-*•#]'`);
for (const r of rows) {
for (const r of rows) {
const clean = _plainProse(r.v);
const clean = _plainProse(r.v);
- await run('UPDATE streamer_overviews SET overview = ?, overview_short = ? WHERE user_id = ?', [clean, _shortOverview(clean), r.k]);
+ await run('UPDATE streamer_overviews SET overview = ?, overview_short = ? WHERE user_id = ?', [clean, await _shapedChannelLine(r.k, clean), r.k]);
fixed++;
fixed++;
}
}
} catch { /* */ }
} catch { /* */ }
@@ -916,16 +929,41 @@ async function getStreamTranscriptSegments(streamId) {
@@ -916,16 +929,41 @@ async function getStreamTranscriptSegments(streamId) {
// countAutoClipsSince() removed — the media subsystem (vods/clips/pastes writes) moved to OpenVibe.Media.
// countAutoClipsSince() removed — the media subsystem (vods/clips/pastes writes) moved to OpenVibe.Media.
async function upsertStreamerOverview(userId, { overview, model = null, sources = null }) {
async function upsertStreamerOverview(userId, { overview, model = null, sources = null }) {
+ // The card line is stored in the one channel shape, so the streamer-overview job refreshes stored lines
+ // through this write; rows it has not rewritten yet are shaped on read (GET /api/streams/recently-online).
+ const short = await _shapedChannelLine(userId, overview);
return await run(`INSERT INTO streamer_overviews (user_id, overview, overview_short, model, sources, generated_at)
return await run(`INSERT INTO streamer_overviews (user_id, overview, overview_short, model, sources, generated_at)
VALUES (?, ?, ?, ?, ?, ov_now())
VALUES (?, ?, ?, ?, ?, ov_now())
ON CONFLICT(user_id) DO UPDATE SET
ON CONFLICT(user_id) DO UPDATE SET
overview = excluded.overview, overview_short = excluded.overview_short, model = excluded.model,
overview = excluded.overview, overview_short = excluded.overview_short, model = excluded.model,
sources = excluded.sources, generated_at = ov_now()`,
sources = excluded.sources, generated_at = ov_now()`,
- [userId, _plainProse(overview || ''), _shortOverview(overview), model, sources]);
+ [userId, _plainProse(overview || ''), short, model, sources]);
}
}
async function getStreamerOverview(userId) {
async function getStreamerOverview(userId) {
return await get('SELECT * FROM streamer_overviews WHERE user_id = ?', [userId]);
return await get('SELECT * FROM streamer_overviews WHERE user_id = ?', [userId]);
}
}
+/**
+ * The stored card lines the streamer-overview job wrote before the one shape (name first, a colon, past the
+ * 90 characters) are reshaped from the stored full overview without an AI call. The job calls this every tick;
+ * the read path also shapes, so no page shows an old shape while this catches up. Returns how many changed.
+ */
+async function shapeStoredStreamerOverviewShorts(limit = 20) {
+ const rows = await all(`SELECT o.user_id, o.overview, o.overview_short, u.username, u.display_name
+ FROM streamer_overviews o LEFT JOIN users u ON u.id = o.user_id
+ WHERE o.overview IS NOT NULL AND TRIM(o.overview) <> ''
+ AND (o.overview_short IS NULL OR LENGTH(o.overview_short) > 90 OR o.overview_short LIKE '%:%'
+ OR o.overview_short NOT LIKE 'Streams %')
+ ORDER BY o.generated_at ASC NULLS FIRST LIMIT ?`, [limit]);
+ let changed = 0;
+ for (const r of rows) {
+ const short = await _shapedChannelLine(r.user_id, r.overview);
+ if (short && short !== r.overview_short) {
+ await run('UPDATE streamer_overviews SET overview_short = ? WHERE user_id = ?', [short, r.user_id]);
+ changed++;
+ }
+ }
+ return changed;
+}
// Assemble the full AI timeline for a streamer from already-generated AI data (no LLM cost):
// Assemble the full AI timeline for a streamer from already-generated AI data (no LLM cost):
// the whole-streamer overview + every session that has an AI overview or captured memories,
// the whole-streamer overview + every session that has an AI overview or captured memories,
@@ -3750,7 +3788,7 @@ module.exports = {
@@ -3750,7 +3788,7 @@ module.exports = {
cleanupMalformedAiText, publicMemoryText: _publicMemoryText, recordAiUsage, getAiCostToday, getAiCostTodayForUser, getAiUsageSummary,
cleanupMalformedAiText, publicMemoryText: _publicMemoryText, recordAiUsage, getAiCostToday, getAiCostTodayForUser, getAiUsageSummary,
getStreamMemoriesByUser, countStreamMemoriesByUser, getStreamTranscriptSegments,
getStreamMemoriesByUser, countStreamMemoriesByUser, getStreamTranscriptSegments,
addTimelineEvents, getTimeline, getTimelineText, getTimelineCoverage, linkTimelineToVod, getTimelineByVod, getTimelineVodId,
addTimelineEvents, getTimeline, getTimelineText, getTimelineCoverage, linkTimelineToVod, getTimelineByVod, getTimelineVodId,
- upsertStreamerOverview, getStreamerOverview, getAllStreamerOverviews, getStreamersNeedingOverview,
+ upsertStreamerOverview, getStreamerOverview, shapeStoredStreamerOverviewShorts, getAllStreamerOverviews, getStreamersNeedingOverview,
readStreamerAiTimelineCache, buildStreamerAiTimeline, assembleStreamerAiTimeline, setStreamAiTitle, getUntitledAiSessions, clearAiTimelineCache,
readStreamerAiTimelineCache, buildStreamerAiTimeline, assembleStreamerAiTimeline, setStreamAiTitle, getUntitledAiSessions, clearAiTimelineCache,
// Homepage helpers
// Homepage helpers
getRecentlyOnlineStreamers, countRecentlyOnlineStreamers,
getRecentlyOnlineStreamers, countRecentlyOnlineStreamers,
@@ -1324,6 +1324,8 @@ router.get('/recent', async (req, res) => {
@@ -1324,6 +1324,8 @@ router.get('/recent', async (req, res) => {
});
});
// ── Recently Online (grouped by user) ────────────────────────
// ── Recently Online (grouped by user) ────────────────────────
+// The card's numbers: every card carries all five, present or null, so the page lays them out the same.
+const RECENT_FACT_KEYS = ['streams_this_week', 'hours_this_week', 'usual_time', 'days_in_a_row', 'last_stream_seconds'];
router.get('/recently-online', async (req, res) => {
router.get('/recently-online', async (req, res) => {
try {
try {
const limit = Math.min(Math.max(parseInt(req.query.limit || '20', 10), 1), 100);
const limit = Math.min(Math.max(parseInt(req.query.limit || '20', 10), 1), 100);
@@ -1366,6 +1368,7 @@ router.get('/recently-online', async (req, res) => {
@@ -1366,6 +1368,7 @@ router.get('/recently-online', async (req, res) => {
if (t && !ms.vod_thumbnail) ms.vod_thumbnail = media.publicUrl(t.thumbnail_url);
if (t && !ms.vod_thumbnail) ms.vod_thumbnail = media.publicUrl(t.thumbnail_url);
// The recording the row's picture is from: the row opens its replay (with the chat in time).
// The recording the row's picture is from: the row opens its replay (with the chat in time).
if (t && t.vod_id && !ms.vod_id) ms.vod_id = t.vod_id;
if (t && t.vod_id && !ms.vod_id) ms.vod_id = t.vod_id;
+ if (t && t.audio_only != null && ms.audio_only == null) ms.audio_only = !!t.audio_only;
}
}
}
}
}
}
@@ -1382,6 +1385,7 @@ router.get('/recently-online', async (req, res) => {
@@ -1382,6 +1385,7 @@ router.get('/recently-online', async (req, res) => {
const v = vods.get(Number(ms.stream_id));
const v = vods.get(Number(ms.stream_id));
if (v && v.thumbnail_url) ms.vod_thumbnail = media.publicUrl(v.thumbnail_url);
if (v && v.thumbnail_url) ms.vod_thumbnail = media.publicUrl(v.thumbnail_url);
if (v && v.id) ms.vod_id = v.id;
if (v && v.id) ms.vod_id = v.id;
+ if (v && v.audio_only != null && ms.audio_only == null) ms.audio_only = !!v.audio_only;
});
});
}
}
} catch (err) {
} catch (err) {
@@ -1418,12 +1422,35 @@ router.get('/recently-online', async (req, res) => {
@@ -1418,12 +1422,35 @@ router.get('/recently-online', async (req, res) => {
const summarize = require('../home/recent-facts').summarizeHistory;
const summarize = require('../home/recent-facts').summarizeHistory;
for (const s of streamers) {
for (const s of streamers) {
s.recent_facts = summarize(rows.filter(r => r.user_id === s.user_id));
s.recent_facts = summarize(rows.filter(r => r.user_id === s.user_id));
+ for (const k of RECENT_FACT_KEYS) if (s.recent_facts[k] === undefined) s.recent_facts[k] = null;
for (const h of s.recent_facts.streams) {
for (const h of s.recent_facts.streams) {
const slot = (s.managed_streams || []).find(ms => Number(ms.managed_stream_id) === Number(h.managed_stream_id));
const slot = (s.managed_streams || []).find(ms => Number(ms.managed_stream_id) === Number(h.managed_stream_id));
- if (slot) { h.vod_id = slot.vod_id || null; h.thumbnail_url = h.thumbnail_url || slot.vod_thumbnail || null; }
+ if (slot) {
+ h.vod_id = slot.vod_id || null;
+ // A replay's picture is its recording's durable thumbnail. streams.thumbnail_url is the
+ // stream's last live capture (/api/thumbnails/stream-*.jpg), deleted when the stream ends
+ // (or a stale Media URL from before a regeneration), so every replay row named a 404 or a
+ // 1×1 pixel (owner, 2026-11-10). While live the capture still exists and stays first; with
+ // no recording the row carries null and the page marks it, instead of an address that fails.
+ h.thumbnail_url = h.is_live ? (h.thumbnail_url || null) : (slot.vod_thumbnail || null);
+ h.audio_only = !h.is_live && slot.audio_only != null ? !!slot.audio_only : null;
+ } else {
+ h.vod_id = null;
+ h.audio_only = null;
+ if (!h.is_live) h.thumbnail_url = null;
+ }
}
}
}
}
}
}
+ // Every card carries the same numbers, present or null, and its line in the one channel shape
+ // (stored lines the job has not rewritten yet are shaped here).
+ const { shapeChannelLine } = require('../ai/channel-line');
+ for (const s of streamers) {
+ if (!s.recent_facts) { s.recent_facts = { streams: [] }; for (const k of RECENT_FACT_KEYS) s.recent_facts[k] = null; }
+ try {
+ s.ai_overview_short = shapeChannelLine({ username: s.username, displayName: s.display_name, overview: s.ai_overview || s.ai_overview_short });
+ } catch { /* the stored line stands */ }
+ }
res.json({ streamers, total, limit, offset, hasMore: offset + streamers.length < total });
res.json({ streamers, total, limit, offset, hasMore: offset + streamers.length < total });
} catch (err) {
} catch (err) {
console.error('[Streaming]', err.message);
console.error('[Streaming]', err.message);
new file mode 100644
new file mode 100644
@@ -0,0 +1,102 @@
@@ -0,0 +1,102 @@
+'use strict';
+// The home "Recently Online" card line has one shape (owner, 2026-11-10): one sentence, what they stream
+// first, no name at the start (the card has the name), no colon, at most 90 characters, in the present
+// tense — and the same fallback sentence shape where there is too little to go on. Ten transcripts, each
+// with the stored overview the streamer-overview job wrote from it, all checked against CHANNEL_LINE_RE.
+// node test/channel-line-shape.test.js
+const assert = require('assert');
+
+const { shapeChannelLine, CHANNEL_LINE_RE, FALLBACK_CHANNEL_LINE } = require('../server/ai/channel-line');
+
+// Each fixture: a transcript excerpt (what the streamer said), the stored overview the job wrote from it,
+// and the card line. Present tense is pinned structurally: the shaper only ever opens with "Streams" and
+// the test refuses a past-tense verb.
+const FIXTURES = [
+ {
+ name: 'a ranked grinder, named and long',
+ username: 'izor_one', displayName: 'iZoR_One',
+ transcript: '… we go mid, we fight this — nice, clutch! … ranked grind continues …',
+ overview: 'iZoR_One streams League of Legends, with a focus on ranked grind, clutch plays, funny moments, and chill vibes. A webcam is typically in the bottom-left.',
+ line: 'Streams League of Legends, with a focus on ranked grind, clutch plays, funny moments.',
+ },
+ {
+ name: 'a "Name: …" opener with the verb inside',
+ username: 'amberfiera', displayName: 'AmberFieraGames37',
+ transcript: '… welcome to the Zaibatsu Q&A, drop your stream ideas …',
+ overview: 'AmberFieraGames37: Spicy and fiery, the flaming feline streams a wide range of Roblox and fantasy-adventure gameplay with a focus on immersive worlds.',
+ line: 'Streams a wide range of Roblox and fantasy-adventure gameplay.',
+ },
+ {
+ name: 'a copula sentence keeps its role',
+ username: 'renyi1333', displayName: 'Renyi1333',
+ transcript: '… driving downtown now, say hi to my friend … casual chats …',
+ overview: 'Renyi1333 is a car-streamer who often sits in the front seat with a friend nearby, streaming casual chats and drive-time streams.',
+ line: 'Streams as a car-streamer.',
+ },
+ {
+ name: 'a bare "is a streamer who/whose" says nothing: fallback',
+ username: 'ggvoorlarz', displayName: 'GGvoorLarz',
+ transcript: '… [mostly silence, game sounds] …',
+ overview: 'GGvoorLarz is a streamer whose broadcasts center around off-screen or minimal-visual sessions, with the live UI featuring chat.',
+ line: FALLBACK_CHANNEL_LINE,
+ },
+ {
+ name: 'no overview at all: fallback',
+ username: 'jimenezjohary', displayName: 'jimenezjohary',
+ transcript: '… sorteo entre amigos, cien boletos …',
+ overview: '',
+ line: FALLBACK_CHANNEL_LINE,
+ },
+ {
+ name: 'the tried-and-empty sentinel: fallback',
+ username: 'quietone', displayName: 'quietone',
+ transcript: '',
+ overview: ' ',
+ line: FALLBACK_CHANNEL_LINE,
+ },
+ {
+ name: 'multi-sentence: the first sentence only, whole phrases kept',
+ username: 'maticus', displayName: 'Maticus',
+ transcript: '… the robot arm is live, multi-cam is up, ask me about the setup …',
+ overview: 'Maticus streams a multi-camera, tech-robot focus with live chats and overlays, often presenting and reacting to surveillance-style feeds. The current threads loop back to robotics discussions.',
+ line: 'Streams a multi-camera, tech-robot focus with live chats and overlays.',
+ },
+ {
+ name: 'a long line keeps whole comma-separated phrases',
+ username: 'okayy', displayName: 'okayy',
+ transcript: '… chilling outside with everyone, come say hi …',
+ overview: 'okayy streams outdoors and indoors in relaxed, conversational IRL sessions, often with multiple people chatting on camera for hours.',
+ line: 'Streams outdoors and indoors in relaxed, conversational IRL sessions.',
+ },
+ {
+ name: 'a line near the cap stays whole',
+ username: 'sh4olan', displayName: 'sh4olan',
+ transcript: '… cozy room tour, chatting with you all …',
+ overview: 'sh4olan streams as an irl creator who chats with viewers from a cozy, decor-filled room.',
+ line: 'Streams as an irl creator who chats with viewers from a cozy, decor-filled room.',
+ },
+ {
+ name: 'beyond the owner\u2019s rule (sex acts): fallback, never the line',
+ username: 'badactor', displayName: 'badactor',
+ transcript: '…',
+ overview: 'badactor streams pornographic content every night with explicit adult videos.',
+ line: FALLBACK_CHANNEL_LINE,
+ },
+];
+
+assert.strictEqual(FIXTURES.length, 10, 'ten transcripts');
+for (const f of FIXTURES) {
+ const line = shapeChannelLine({ username: f.username, displayName: f.displayName, overview: f.overview });
+ assert.strictEqual(line, f.line, f.name);
+ assert.match(line, CHANNEL_LINE_RE, `${f.name}: ${line}`);
+ assert.ok(line.length <= 90, `${f.name}: ${line.length} chars`);
+ assert.ok(!line.includes(':'), `${f.name}: no colon`);
+ assert.ok(!/\b(?:streamed|used to|was|were|had|played|hosted|presented)\b/i.test(line), `${f.name}: present tense`);
+ assert.ok(!line.toLowerCase().startsWith(f.username.toLowerCase()), `${f.name}: no name at the start`);
+ assert.ok(!line.toLowerCase().startsWith(String(f.displayName || '').toLowerCase()), `${f.name}: no display name at the start`);
+}
+assert.match(FALLBACK_CHANNEL_LINE, CHANNEL_LINE_RE, 'the fallback is the same shape');
+assert.strictEqual(shapeChannelLine(), FALLBACK_CHANNEL_LINE, 'no arguments');
+assert.strictEqual(shapeChannelLine({ overview: null }), FALLBACK_CHANNEL_LINE, 'null overview');
+assert.strictEqual(shapeChannelLine({ username: 'Maticus', displayName: 'Maticus', overview: '{"overview":"Maticus streams chess and cooking."}' }), 'Streams chess and cooking.', 'JSON-wrapped model text');
+console.log('channel-line shape: all checks passed');
new file mode 100644
new file mode 100644
@@ -0,0 +1,102 @@
@@ -0,0 +1,102 @@
+'use strict';
+// GET /api/streams/recently-online cards (server/streaming/routes.js): a replay row's picture is its
+// recording's durable thumbnail — streams.thumbnail_url is the stream's last live capture
+// (/api/thumbnails/stream-*.jpg), deleted when the stream ends, so every replay row named a 404
+// (owner, 2026-11-10). With no recording the row carries nulls instead of an address that fails;
+// a live row keeps its capture. Every card carries the same five numbers, present or null, and its
+// line in the one channel shape (server/ai/channel-line.js).
+// node --import ./test/helpers/pg-preload.mjs test/recently-online-cards.test.js
+const assert = require('assert');
+const http = require('http');
+const express = require('express');
+
+process.env.NODE_ENV = 'test';
+console.warn = () => {};
+const quiet = console.log;
+console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
+
+const db = require('../server/db/database');
+const { CHANNEL_LINE_RE } = require('../server/ai/channel-line');
+const KEYS = ['streams_this_week', 'hours_this_week', 'usual_time', 'days_in_a_row', 'last_stream_seconds'];
+
+(async () => {
+ await db.initDb();
+ const mk = async (username) => { await db.createUser({ username, email: `${username}@x`, password_hash: 'x', display_name: username, stream_key: username.padEnd(32, '0') }); const id = (await db.get('SELECT id FROM users WHERE username = ?', [username])).id; await db.ensureChannel(id); return id; };
+ const pic = await mk('picasso'), nov = await mk('novod'), liv = await mk('liveone');
+ const slot = async (uid, slug) => Number((await db.run('INSERT INTO managed_streams (user_id, slug, title, protocol, stream_key, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ov_now(), ov_now()) RETURNING id', [uid, slug, `${slug} title`, 'webrtc', `${slug}-key-${uid}`])).lastInsertRowid);
+ const picSlot = await slot(pic, 'main'), livSlot = await slot(liv, 'main');
+ const stream = async (uid, ms, { live = false, startedAgo, endedAgo = null, thumb = null }) => await db.run(
+ `INSERT INTO streams (user_id, managed_stream_id, title, protocol, is_live, viewer_count, started_at, ended_at, thumbnail_url, created_at)
+ VALUES (?, ?, ?, 'webrtc', ?, 0, to_char(now() - make_interval(mins => ?), 'YYYY-MM-DD HH24:MI:SS'), ${live ? 'NULL' : "to_char(now() - make_interval(mins => ?), 'YYYY-MM-DD HH24:MI:SS')"}, ?, ov_now())`,
+ live ? [uid, ms, 'LIVE NOW', 1, startedAgo, thumb] : [uid, ms, 'old', 0, startedAgo, endedAgo, thumb]);
+ // Ended streams whose stored picture is the deleted live capture.
+ await stream(pic, picSlot, { startedAgo: 3000, endedAgo: 2900, thumb: '/api/thumbnails/stream-9101-1.jpg' });
+ await stream(pic, picSlot, { startedAgo: 600, endedAgo: 500, thumb: '/api/thumbnails/stream-9102-2.jpg' });
+ // An ended stream with no slot and no recording anywhere.
+ await stream(nov, null, { startedAgo: 700, endedAgo: 650, thumb: '/api/thumbnails/stream-9103-3.jpg' });
+ // A live stream: its capture still exists.
+ await stream(liv, livSlot, { live: true, startedAgo: 10, thumb: '/api/thumbnails/stream-9104-live.jpg' });
+
+ // A stored card line in the old shape (name first, colon, two sentences).
+ await db.run("INSERT INTO streamer_overviews (user_id, overview, overview_short, model, sources, generated_at) VALUES (?, ?, ?, 'x', '{}', ov_now())",
+ [pic, 'picasso streams late-night Rust raids with the whole crew: zergs, door-camping and betrayals. The chat runs the raid targets.', 'picasso streams late-night Rust raids with the whole crew: zergs, door-camping and betrayals.']);
+
+ // The stored old-shape line is refreshed by the job that rewrites stored overviews (no AI call).
+ assert.ok(await db.shapeStoredStreamerOverviewShorts(50) >= 1);
+ assert.match((await db.getStreamerOverview(pic)).overview_short, CHANNEL_LINE_RE, 'stored line reshaped in place');
+
+ // Media: the slot's recording has a durable picture and is audio-only; the loose stream has no VOD.
+ const media = require('../server/media-client');
+ media.request = async (method, p) => {
+ assert.strictEqual(p, '/vods/latest-thumbs');
+ return { thumbs: { [picSlot]: { vod_id: 9, thumbnail_url: '/t/vod-9-new.jpg', audio_only: true } } };
+ };
+ require('../server/media-proxy/lookups').publicVodsForStreams = async () => new Map();
+
+ const app = express();
+ app.use('/api/streams', require('../server/streaming/routes'));
+ const server = await new Promise((r) => { const s = http.createServer(app).listen(0, '127.0.0.1', () => r(s)); });
+ try {
+ const res = await fetch(`http://127.0.0.1:${server.address().port}/api/streams/recently-online?limit=10`);
+ assert.strictEqual(res.status, 200);
+ const body = await res.json();
+ const by = (u) => body.streamers.find((s) => s.username === u);
+ const p = by('picasso'), n = by('novod'), l = by('liveone');
+ assert.ok(p && n && l, JSON.stringify(body.streamers.map((s) => s.username)));
+
+ // The replay rows picture the recording, not the deleted capture.
+ assert.strictEqual(p.recent_facts.streams.length, 2);
+ for (const h of p.recent_facts.streams) {
+ assert.ok(String(h.thumbnail_url).endsWith('/t/vod-9-new.jpg'), h.thumbnail_url);
+ assert.strictEqual(h.vod_id, 9);
+ assert.strictEqual(h.audio_only, true, 'audio-only says so with a field');
+ }
+ // No recording: nulls, never a dead address.
+ assert.strictEqual(n.recent_facts.streams.length, 1);
+ assert.deepStrictEqual(
+ [n.recent_facts.streams[0].thumbnail_url, n.recent_facts.streams[0].vod_id, n.recent_facts.streams[0].audio_only],
+ [null, null, null]);
+ // Live: the capture stays.
+ const liveRow = l.recent_facts.streams.find((h) => h.is_live);
+ assert.ok(liveRow, 'the live row is listed');
+ assert.strictEqual(liveRow.thumbnail_url, '/api/thumbnails/stream-9104-live.jpg');
+ assert.strictEqual(liveRow.audio_only, null, 'a live row never borrows an old recording\u2019s flag');
+
+ // The same five numbers on every card, present or null.
+ for (const s of body.streamers) {
+ for (const k of KEYS) assert.ok(s.recent_facts[k] !== undefined, `${s.username}.${k}`);
+ assert.ok(Array.isArray(s.recent_facts.streams) && s.recent_facts.streams.length <= 2);
+ }
+ assert.strictEqual(typeof p.recent_facts.streams_this_week, 'number');
+ assert.strictEqual(n.recent_facts.usual_time, null, 'one stream: no usual time');
+ assert.strictEqual(n.recent_facts.days_in_a_row, null, 'one stream: no streak');
+
+ // The stored old-shape line is shaped on read.
+ assert.match(p.ai_overview_short, CHANNEL_LINE_RE, p.ai_overview_short);
+ assert.ok(!p.ai_overview_short.toLowerCase().startsWith('picasso'), p.ai_overview_short);
+ assert.match(n.ai_overview_short, CHANNEL_LINE_RE, 'no stored overview: the fallback shape');
+
+ console.log('recently-online cards: all checks passed');
+ } finally { server.close(); }
+ process.exit(0);
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -6,6 +6,7 @@ const assert = require('assert');
@@ -6,6 +6,7 @@ const assert = require('assert');
console.warn = () => {};
console.warn = () => {};
const db = require('../server/db/database');
const db = require('../server/db/database');
+const { CHANNEL_LINE_RE } = require('../server/ai/channel-line');
(async () => {
(async () => {
await db.initDb();
await db.initDb();
const mk = async (username) => { await db.createUser({ username, email: `${username}@x`, password_hash: 'x', display_name: username, stream_key: username.padEnd(32, '0') }); return (await db.get('SELECT id FROM users WHERE username = ?', [username])).id; };
const mk = async (username) => { await db.createUser({ username, email: `${username}@x`, password_hash: 'x', display_name: username, stream_key: username.padEnd(32, '0') }); return (await db.get('SELECT id FROM users WHERE username = ?', [username])).id; };
@@ -14,7 +15,7 @@ const db = require('../server/db/database');
@@ -14,7 +15,7 @@ const db = require('../server/db/database');
await db.upsertStreamerOverview(fixer, { overview: '## Overview\n- Streamer handle: finditfixit (@finditfixit).\n- **Content:** repairs old TVs and radios on stream\n- Tone: calm, patient' });
await db.upsertStreamerOverview(fixer, { overview: '## Overview\n- Streamer handle: finditfixit (@finditfixit).\n- **Content:** repairs old TVs and radios on stream\n- Tone: calm, patient' });
const a = await db.getStreamerOverview(fixer);
const a = await db.getStreamerOverview(fixer);
assert.strictEqual(a.overview, 'finditfixit (@finditfixit). repairs old TVs and radios on stream. calm, patient.');
assert.strictEqual(a.overview, 'finditfixit (@finditfixit). repairs old TVs and radios on stream. calm, patient.');
- assert.ok(!/^[-*#\s]/.test(a.overview_short) && !a.overview_short.includes('**'), a.overview_short);
+ assert.match(a.overview_short, CHANNEL_LINE_RE, 'the card line is the one shape');
const prose = 'plainann streams Rust and talks through every build. Tone: chill.\nThe chat is small and friendly.';
const prose = 'plainann streams Rust and talks through every build. Tone: chill.\nThe chat is small and friendly.';
await db.upsertStreamerOverview(plain, { overview: prose });
await db.upsertStreamerOverview(plain, { overview: prose });
@@ -26,11 +27,13 @@ const db = require('../server/db/database');
@@ -26,11 +27,13 @@ const db = require('../server/db/database');
await db.cleanupMalformedAiText();
await db.cleanupMalformedAiText();
const b = await db.getStreamerOverview(old);
const b = await db.getStreamerOverview(old);
assert.strictEqual(b.overview, 'oldlist (@oldlist). Streams Minecraft builds most nights.');
assert.strictEqual(b.overview, 'oldlist (@oldlist). Streams Minecraft builds most nights.');
- assert.strictEqual(b.overview_short, 'oldlist (@oldlist). Streams Minecraft builds most nights.');
+ assert.strictEqual(b.overview_short, 'Streams Minecraft builds most nights.', 'the label sentence is skipped for the line');
assert.strictEqual((await db.getStreamerOverview(plain)).overview, prose, 'the repair leaves prose alone');
assert.strictEqual((await db.getStreamerOverview(plain)).overview, prose, 'the repair leaves prose alone');
await db.upsertStreamerOverview(plain, { overview: ' ' });
await db.upsertStreamerOverview(plain, { overview: ' ' });
- assert.strictEqual((await db.getStreamerOverview(plain)).overview_short, ' ', 'the tried-and-empty sentinel round-trips');
+ const sentinel = await db.getStreamerOverview(plain);
+ assert.strictEqual(sentinel.overview, ' ', 'the tried-and-empty sentinel round-trips in the full overview');
+ assert.match(sentinel.overview_short, CHANNEL_LINE_RE, 'a sentinel still has the fallback card line');
console.log('streamer-overview-prose: all checks passed');
console.log('streamer-overview-prose: all checks passed');
process.exit(0);
process.exit(0);