openvibers/openvibe.live Commits

Home cards: durable replay pictures and one channel-line shape (#780)

OP OpenVibers committed 3 days ago · 5cf03df3af · 7 files, +458 −8

Changes

ddiff --git a/server/ai/channel-line.js b/server/ai/channel-line.js
nnew file mode 100644
iindex 00000000..68a389ea
---- /dev/null
++++ b/server/ai/channel-line.js
@@@ -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 };
ddiff --git a/server/ai/streamer-overview-job.js b/server/ai/streamer-overview-job.js
iindex da80dfac..eafd63fe 100644
---- a/server/ai/streamer-overview-job.js
++++ b/server/ai/streamer-overview-job.js
@@@ -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); }
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 44307106..a31c4ebb 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -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 SET
overview = 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 helpers
getRecentlyOnlineStreamers, countRecentlyOnlineStreamers,
ddiff --git a/server/streaming/routes.js b/server/streaming/routes.js
iindex f05895ef..ed083deb 100644
---- a/server/streaming/routes.js
++++ b/server/streaming/routes.js
@@@ -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);
ddiff --git a/test/channel-line-shape.test.js b/test/channel-line-shape.test.js
nnew file mode 100644
iindex 00000000..f6fabf7a
---- /dev/null
++++ b/test/channel-line-shape.test.js
@@@ -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');
ddiff --git a/test/recently-online-cards.test.js b/test/recently-online-cards.test.js
nnew file mode 100644
iindex 00000000..c864a492
---- /dev/null
++++ b/test/recently-online-cards.test.js
@@@ -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); });
ddiff --git a/test/streamer-overview-prose.test.js b/test/streamer-overview-prose.test.js
iindex ded9387a..81ce2582 100644
---- a/test/streamer-overview-prose.test.js
++++ b/test/streamer-overview-prose.test.js
@@@ -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);