openvibers/openvibe.live Commits
Purges and deletes stay consistent with replay (roadmap D20 parity: replay purge consistency). (1) The dashboard's chat purge, its preview and the log filter send ISO instants ('...T...Z') and chat_messages keeps SQLite's 'YYYY-MM-DD HH:MM:SS'; compared as TEXT ('T' sorts after ' ') a range matched nothing on its first day and all of its last, so a purge removed lines outside the chosen range, kept the ones inside it, and VOD chat replay kept showing them. Live's range queries (purge, preview, logs, replay) now use datetime(?), as OpenVibe.Chat does since e26e063: this is the CHAT_AUTHORITY=live path and the mirror Live keeps under chat. (2) Deleting a VOD or clip only hid its comment thread: Live kept its AI state (the backfill takes vod_ai_state newest-first, so deleted VODs sat at the head of the overview queue for good), its unique views, and after an admin or older-than delete its Search document until the daily refresh. Every delete path now ends in server/media-proxy/purge.js once Media confirmed it: thread hidden, vod_ai_state/clip_ai_state and content_views rows dropped (db.forgetMediaItem), Search document re-read. docs/chat-system.md had the purge body as fromTime/toTime; it is from/to. test/replay-purge.test.js: ISO ranges agree across preview, purge, logs and replay (same day and across midnight), purges are soft and owner-scoped, and each delete path drops exactly the deleted items' rows.
Changes
@@@ -87,15 +87,19 @@ GET /api/chat/admin/logs/export?format=json### Purge Preview```POST /api/chat/admin/purge/preview--Body: { "streamId": 123, "fromTime": "2024-01-01T00:00", "toTime": "2024-01-02T00:00" }++Body: { "streamId": 123, "from": "2024-01-01T00:00:00Z", "to": "2024-01-02T00:00:00Z" }```### Execute Purge```DELETE /api/chat/admin/purge--Body: { "streamId": 123, "fromTime": "2024-01-01T00:00", "toTime": "2024-01-02T00:00" }++Body: { "streamId": 123, "from": "2024-01-01T00:00:00Z", "to": "2024-01-02T00:00:00Z" }```++`from` and `to` (also on the log filter and `/api/chat/:streamId/replay`) are UTC, as ISO instants++or SQLite's `YYYY-MM-DD HH:MM:SS`; both bounds are inclusive. The preview, the purge, the log filter++and VOD chat replay all read them the same way, so a purged range disappears from replay exactly.++## WebSocket Protocol### Connect@@@ -53,6 +53,9 @@ Clips are **unlisted by default** — the stream owner can publish or delete theFrom the dashboard:- Toggle VODs between **public** and **private**- **Bulk delete** old media by age (e.g., delete VODs older than 30 days)++- Every delete (one, bulk, by age, or from the admin storage page) ends in `server/media-proxy/purge.js`++ once Media confirmed it: the comment thread is hidden, Live's own rows about the item go++ (`vod_ai_state`/`clip_ai_state`, `content_views`) and its Search document is re-read at once- **Thumbnails** are auto-generated; broken thumbnails auto-regenerate on load## Who sees a private VOD or clip@@@ -1071,7 +1071,7 @@ router.get('/storage', (req, res) => {// proxy the Media API; the tiering + media-tools endpoints return a stub note// so the admin SPA shows where the controls went instead of erroring.const mediaClient = require('../media-client');--const commentsClient = require('../comments-client');++const purge = require('../media-proxy/purge'); // what Live drops once Media deleted an item// ── GET /api/admin/storage/vods — proxied VOD listing ────────router.get('/storage/vods', async (req, res) => {@@@ -1109,7 +1109,7 @@ router.delete('/storage/vods/bulk', async (req, res) => {}freed += vod.file_size || 0;await mediaClient.deleteVod(id);-- commentsClient.hideThreadOf('vod', Number(id));++ purge.afterDelete('vod', id);deleted++;} catch (err) {errors.push(`VOD ${id}: ${err.message}`);@@@ -1141,8 +1141,7 @@ router.delete('/storage/clips/bulk', async (req, res) => {errors.push(`Clip ${id}: protected (owner content)`); continue;}await mediaClient.deleteClip(id);-- db.run("DELETE FROM content_views WHERE content_type = 'clip' AND content_id = ?", [id]);-- commentsClient.hideThreadOf('clip', Number(id)); // comments are Community threads; Live's old table is read-only++ purge.afterDelete('clip', id);deleted++;} catch (err) {errors.push(`Clip ${id}: ${err.message}`);@@@ -3064,6 +3064,16 @@ function getVodAiState(vodId) {function getClipAiState(clipId) {return get('SELECT * FROM clip_ai_state WHERE clip_id = ?', [clipId]);}++/** A VOD or clip OpenVibe.Media deleted: Live's own rows about it go (server/media-proxy/purge.js). */++function forgetMediaItem(kind, id) {++ return getDb().transaction(() => {++ const ai = kind === 'clip'++ ? run('DELETE FROM clip_ai_state WHERE clip_id = ?', [id])++ : run('DELETE FROM vod_ai_state WHERE vod_id = ?', [id]);++ const views = run('DELETE FROM content_views WHERE content_type = ? AND content_id = ?', [kind === 'clip' ? 'clip' : 'vod', id]);++ return { ai: ai.changes, views: views.changes };++ })();++}function setVodAiOverview(vodId, text) {_ensureVodAiState(vodId);// Store the FULL overview alongside the derived short — the card expander swaps@@@ -6381,8 +6391,9 @@ function getChatReplay(streamId, fromTime, toTime) {WHERE cm.stream_id = ? AND cm.is_deleted = 0 AND cm.message_type = 'chat'AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP)`;const params = [streamId];-- if (fromTime) { sql += ` AND cm.timestamp >= ?`; params.push(fromTime); }-- if (toTime) { sql += ` AND cm.timestamp <= ?`; params.push(toTime); }++ // The same bounds as a purge (see deleteChatMessagesByTimeRange): either timestamp form, as UTC.++ if (fromTime) { sql += ` AND cm.timestamp >= datetime(?)`; params.push(fromTime); }++ if (toTime) { sql += ` AND cm.timestamp <= datetime(?)`; params.push(toTime); }sql += ` ORDER BY cm.timestamp ASC`;return all(sql, params);}@@@ -7485,18 +7496,23 @@ function saveUserPreferences(userId, chatSettings) {/* ── Chat Log Management ──────────────────────────────────── */++// Time ranges (purge, its preview, the log filter, chat replay): the dashboard sends ISO instants++// ('…T…Z') and rows keep SQLite's 'YYYY-MM-DD HH:MM:SS'. Compared as TEXT, 'T' sorts after ' ', so a++// range matched nothing on its first day and all of its last: a purge removed lines outside the range++// the streamer chose and kept the ones inside it (and VOD chat replay showed them). datetime(?) reads++// both forms as UTC. OpenVibe.Chat, which serves these routes with CHAT_AUTHORITY=chat, does the same.function deleteChatMessagesByTimeRange(streamId, fromTime, toTime, deletedBy) {if (streamId) {return run(`UPDATE chat_messages SET is_deleted = 1, deleted_by = ?, deleted_at = CURRENT_TIMESTAMP-- WHERE stream_id = ? AND timestamp >= ? AND timestamp <= ? AND is_deleted = 0`,++ WHERE stream_id = ? AND timestamp >= datetime(?) AND timestamp <= datetime(?) AND is_deleted = 0`,[deletedBy, streamId, fromTime, toTime]);}// Global chat (is_global = 1)return run(`UPDATE chat_messages SET is_deleted = 1, deleted_by = ?, deleted_at = CURRENT_TIMESTAMP-- WHERE is_global = 1 AND timestamp >= ? AND timestamp <= ? AND is_deleted = 0`,++ WHERE is_global = 1 AND timestamp >= datetime(?) AND timestamp <= datetime(?) AND is_deleted = 0`,[deletedBy, fromTime, toTime]);}@@@ -7506,13 +7522,13 @@ function countChatMessagesByTimeRange(streamId, fromTime, toTime) {if (streamId) {row = get(`SELECT COUNT(*) as cnt FROM chat_messages-- WHERE stream_id = ? AND timestamp >= ? AND timestamp <= ? AND is_deleted = 0`,++ WHERE stream_id = ? AND timestamp >= datetime(?) AND timestamp <= datetime(?) AND is_deleted = 0`,[streamId, fromTime, toTime]);} else {row = get(`SELECT COUNT(*) as cnt FROM chat_messages-- WHERE is_global = 1 AND timestamp >= ? AND timestamp <= ? AND is_deleted = 0`,++ WHERE is_global = 1 AND timestamp >= datetime(?) AND timestamp <= datetime(?) AND is_deleted = 0`,[fromTime, toTime]);}@@@ -7526,8 +7542,8 @@ function getChatLogs({ streamId, username, search, from, to, messageType, page =if (streamId) { conditions.push('stream_id = ?'); params.push(streamId); }if (username) { conditions.push('username LIKE ?'); params.push(`%${username}%`); }if (search) { conditions.push('message LIKE ?'); params.push(`%${search}%`); }-- if (from) { conditions.push('timestamp >= ?'); params.push(from); }-- if (to) { conditions.push('timestamp <= ?'); params.push(to); }++ if (from) { conditions.push('timestamp >= datetime(?)'); params.push(from); }++ if (to) { conditions.push('timestamp <= datetime(?)'); params.push(to); }if (messageType) { conditions.push('message_type = ?'); params.push(messageType); }if (!includeDeleted) { conditions.push('is_deleted = 0'); }@@@ -7645,7 +7661,7 @@ module.exports = {publicStream,getConcurrencyBaseline,getHomeStatSeries, HOME_SERIES_KEYS, vibesStatsSince, _computeHomeStats,-- getVodAiState, getClipAiState,++ getVodAiState, getClipAiState, forgetMediaItem,scheduleClipNotifyState, bumpClipNotifyNowState, markClipNotifiedState, getDueClipNotifies,getDb, initDb, run, get, all, close, announceModerationAction: _announceModerationAction,mergeChatMessageMetadata, getTimelineSpeechSince,@@@ -11,7 +11,7 @@ const media = require('../media-client');const { requireAuth, optionalAuth } = require('../auth/auth');const permissions = require('../auth/permissions');const access = require('./access');--const commentsClient = require('../comments-client');++const purge = require('./purge');const { commentCount } = require('./comments');const lineage = require('../lineage/resolver');@@@ -345,7 +345,7 @@ router.post('/bulk', requireAuth, async (req, res) => {const allowed = clip && (action === 'delete' ? await canActorDeleteClip(req.user, clip) : await canActorModerateClip(req.user, clip));if (!allowed) { skipped++; continue; }try {-- if (action === 'delete') { await media.deleteClip(id); commentsClient.hideThreadOf('clip', id); }++ if (action === 'delete') { await media.deleteClip(id); purge.afterDelete('clip', id); }else await media.updateClip(id, { visibility: action });done++;} catch { skipped++; }@@@ -363,7 +363,7 @@ router.delete('/:id', requireAuth, async (req, res) => {if (!clip) return res.status(404).json({ error: 'Clip not found' });if (!(await canActorDeleteClip(req.user, clip))) return refuse(req, res, clip, 'Not authorized to delete this clip');await media.deleteClip(clip.id);-- commentsClient.hideThreadOf('clip', clip.id);++ purge.afterDelete('clip', clip.id);res.json({ message: 'Clip deleted' });} catch (err) {mediaErr(res, err, 'Failed to delete clip');nnew file mode 100644@@@ -0,0 +1,26 @@++'use strict';++/**++ * OpenVibe.Live — what Live does once OpenVibe.Media has deleted a VOD or clip for it.++ *++ * Every delete path calls afterDelete() after Media confirmed the delete: the owner's /api/vods and++ * /api/clips routes (one, bulk, older-than) and the admin storage page. It++ * - hides the item's comment thread on OpenVibe.Community (comments-client.js);++ * - drops Live's own rows about it: the AI state (vod_ai_state / clip_ai_state), which the AI++ * backfill (server/ai/backfill-job.js) takes newest-first and would otherwise keep offering for an++ * id Media no longer has, and the unique-view rows (content_views);++ * - re-reads its OpenVibe.Search document now (Media's 404 sends the tombstone) instead of leaving++ * it to the daily refresh.++ * Lists and pages that cached the item (home pool, SSR detail, page status) expire within a minute.++ */++const db = require('../db/database');++const commentsClient = require('../comments-client');++++function afterDelete(kind, id) {++ const n = Number(id);++ if ((kind !== 'vod' && kind !== 'clip') || !Number.isInteger(n) || n <= 0) return;++ commentsClient.hideThreadOf(kind, n);++ try { db.forgetMediaItem(kind, n); } catch (err) { console.warn(`[Media] could not drop Live's rows for deleted ${kind} ${n}:`, err.message); }++ try { require('../events/search-media-documents').touchLater(kind, n); } catch { /* search is optional */ }++}++++module.exports = { afterDelete };@@@ -16,7 +16,7 @@ const recorder = require('../streaming/recorder');const { requireAuth, optionalAuth } = require('../auth/auth');const permissions = require('../auth/permissions');const access = require('./access');--const commentsClient = require('../comments-client');++const purge = require('./purge');const { commentCount } = require('./comments');const router = express.Router();@@@ -357,7 +357,7 @@ router.post('/bulk-delete-old', requireAuth, async (req, res) => {const out = await media.listVods({ user_id: req.user.id, include_private: 1, limit: 1000 });for (const v of (out?.vods || [])) {if (!tooOld(v) || v.is_recording) continue;-- if (action === 'delete') { if (await media.deleteVod(v.id).then(() => true, () => false)) commentsClient.hideThreadOf('vod', v.id); }++ if (action === 'delete') { if (await media.deleteVod(v.id).then(() => true, () => false)) purge.afterDelete('vod', v.id); }else await media.updateVod(v.id, { visibility: action }).catch(() => {});vodCount++;}@@@ -366,7 +366,7 @@ router.post('/bulk-delete-old', requireAuth, async (req, res) => {const out = await media.listClips({ user_id: req.user.id, include_private: 1, limit: 1000 });for (const c of (out?.clips || [])) {if (!tooOld(c)) continue;-- if (action === 'delete') { if (await media.deleteClip(c.id).then(() => true, () => false)) commentsClient.hideThreadOf('clip', c.id); }++ if (action === 'delete') { if (await media.deleteClip(c.id).then(() => true, () => false)) purge.afterDelete('clip', c.id); }else await media.updateClip(c.id, { visibility: action }).catch(() => {});clipCount++;}@@@ -557,7 +557,7 @@ router.post('/bulk', requireAuth, async (req, res) => {if (!owns) owns = permissions.canModerateContentOwner(req.user, vod.user_id ? db.getUserById(vod.user_id) : null);if (!owns) { skipped++; continue; }try {-- if (action === 'delete') { await media.deleteVod(id); commentsClient.hideThreadOf('vod', id); }++ if (action === 'delete') { await media.deleteVod(id); purge.afterDelete('vod', id); }else await media.updateVod(id, { visibility: action });done++;} catch { skipped++; }@@@ -573,7 +573,7 @@ router.delete('/:id', requireAuth, async (req, res) => {if (!vod) return;try {await media.deleteVod(req.params.id);-- commentsClient.hideThreadOf('vod', Number(req.params.id));++ purge.afterDelete('vod', req.params.id);res.json({ message: 'VOD deleted' });} catch (err) {mediaErr(res, err, 'Failed to delete VOD');nnew file mode 100644@@@ -0,0 +1,243 @@++/**++ * Legacy parity (roadmap D20): purging chat and deleting replays stays consistent.++ *++ * 1. Chat purge ↔ VOD chat replay. The dashboard's "Purge Range" (and its preview and log filter)++ * sends ISO instants ('2026-09-20T10:00:00.000Z'); chat_messages keeps SQLite's++ * '2026-09-20 10:00:00'. Compared as TEXT ('T' sorts after ' '), a range matched nothing on its++ * first day and ALL of its last day: the purge removed lines outside the chosen range, kept the ones++ * inside it, and VOD chat replay went on showing them. Live's local routes (CHAT_AUTHORITY=live, and++ * the mirror Live keeps under CHAT_AUTHORITY=chat) now read both forms as UTC, as OpenVibe.Chat does.++ *++ * 2. Deleting a VOD or clip. Media deletes the item; Live used to only hide the comment thread, and++ * kept its own rows: the AI state the backfill takes newest-first (a deleted VOD's row sat at the++ * head of the overview queue for good) and the unique views. The admin storage page and the++ * "older than" action also left the item's Search document until the daily refresh. Every delete++ * path now goes through server/media-proxy/purge.js.++ *++ * node test/replay-purge.test.js++ */++'use strict';++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-replay-purge-'));++process.env.DB_PATH = path.join(tmp, 'live.db');++process.env.DATA_DIR = tmp;++process.env.NODE_ENV = 'test';++const quiet = console.log;++console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };++console.warn = () => {};++++const db = require('../server/db/database');++db.initDb();++const raw = db.getDb();++++const auth = require('../server/auth/auth');++const signIn = (req) => {++ const id = Number(req.headers['x-test-user'] || 0);++ const u = id ? db.getUserById(id) : null;++ if (u) { req.user = u; req.authSource = 'network'; }++ return u;++};++auth.requireAuth = (req, res, next) => (signIn(req) ? next() : res.status(401).json({ error: 'Authentication required' }));++auth.optionalAuth = (req, res, next) => { signIn(req); next(); };++++const addUser = (id, username, role) => raw.prepare(++ `INSERT INTO users (id, username, display_name, email, password_hash, role, created_at)++ VALUES (?, ?, ?, ?, 'x', ?, '2025-01-01 00:00:00')`).run(id, username, username, `${username}@x`, role);++addUser(1, 'admin', 'admin');++addUser(3, 'alice', 'streamer');++addUser(7, 'mallory', 'streamer');++db.ensureChannel(3);++db.ensureChannel(7);++const streamA = Number(db.createStream({ user_id: 3, channel_id: db.getChannelByUserId(3).id, title: 'A', protocol: 'webrtc' }).lastInsertRowid);++raw.prepare("UPDATE streams SET started_at = '2026-09-20 09:00:00', ended_at = '2026-09-21 03:00:00', is_live = 0 WHERE id = ?").run(streamA);++Number(db.createStream({ user_id: 7, channel_id: db.getChannelByUserId(7).id, title: 'M', protocol: 'webrtc' }).lastInsertRowid);++++// Alice's chat during her stream, as Live stores it (UTC, SQLite form).++const LINES = [++ ['2026-09-20 09:59:59', 'before'],++ ['2026-09-20 10:00:00', 'first in range'],++ ['2026-09-20 10:30:00', 'middle'],++ ['2026-09-20 11:00:00', 'last in range'],++ ['2026-09-20 11:00:01', 'just after'],++ ['2026-09-20 23:30:00', 'late that evening'],++ ['2026-09-21 01:00:00', 'after midnight'],++ ['2026-09-21 02:30:00', 'next morning'],++];++for (const [ts, text] of LINES) {++ raw.prepare(`INSERT INTO chat_messages (stream_id, channel_user_id, user_id, username, message, message_type, timestamp)++ VALUES (?, 3, 7, 'mallory', ?, 'chat', ?)`).run(streamA, text, ts);++}++++// ── the chat server the purge route announces to ──++const announced = [];++require.cache[require.resolve('../server/chat/chat-server')] = {++ id: 'chat-server-stub', filename: 'chat-server-stub', loaded: true,++ exports: { broadcastToStream: (sid, p) => announced.push([sid, p]), broadcastGlobal: (p) => announced.push(['global', p]) },++};++++// ── Media, Community and Search stubs for the delete paths ──++const media = require('../server/media-client');++const VODS = {++ 100: { id: 100, user_id: 3, title: 'one', visibility: 'public', is_public: 1, status: 'ready', created_at: '2020-01-01 00:00:00' },++ 101: { id: 101, user_id: 3, title: 'bulk', visibility: 'public', is_public: 1, status: 'ready', created_at: '2020-01-01 00:00:00' },++ 102: { id: 102, user_id: 3, title: 'kept', visibility: 'public', is_public: 1, status: 'ready', created_at: '2020-01-01 00:00:00' },++ 103: { id: 103, user_id: 3, title: 'old', visibility: 'public', is_public: 1, status: 'ready', created_at: '2020-01-01 00:00:00' },++ 104: { id: 104, user_id: 3, title: 'media refuses', visibility: 'public', is_public: 1, status: 'ready', created_at: '2020-01-01 00:00:00' },++};++const CLIPS = { 200: { id: 200, user_id: 3, channel_user_id: 3, title: 'clip', visibility: 'public', is_public: 1, status: 'ready' } };++const missing = (what) => new media.MediaApiError(`${what} not found`, 404, { error: `${what} not found` });++media.getVod = async (id) => { const v = VODS[Number(id)]; if (!v) throw missing('VOD'); return { ...v }; };++media.getClip = async (id) => { const c = CLIPS[Number(id)]; if (!c) throw missing('Clip'); return { ...c }; };++media.deleteVod = async (id) => { if (Number(id) === 104) throw new media.MediaApiError('busy', 409, { error: 'busy' }); delete VODS[Number(id)]; };++media.deleteClip = async (id) => { delete CLIPS[Number(id)]; };++media.listVods = async (q) => ({ vods: q.user_id === 3 ? [VODS[103]].filter(Boolean) : [] });++media.listClips = async () => ({ clips: [] });++const commentsClient = require('../server/comments-client');++const hidden = [];++commentsClient.hideThreadOf = (type, id) => { hidden.push(`${type}:${id}`); return Promise.resolve(); };++const searchDocs = require('../server/events/search-media-documents');++const touched = [];++searchDocs.touchLater = (kind, ids) => { touched.push(`${kind}:${ids}`); };++++for (const id of [100, 101, 102, 103, 104]) {++ raw.prepare("INSERT INTO vod_ai_state (vod_id, transcript_status) VALUES (?, 'pending')").run(id);++ raw.prepare("INSERT INTO content_views (content_type, content_id, ip) VALUES ('vod', ?, '10.0.0.1')").run(id);++}++raw.prepare("INSERT INTO clip_ai_state (clip_id, transcript_status) VALUES (200, 'pending')").run();++raw.prepare("INSERT INTO content_views (content_type, content_id, ip) VALUES ('clip', 200, '10.0.0.1')").run();++++const express = require('express');++const app = express();++app.use(express.json());++app.use('/api/chat', require('../server/chat/routes'));++app.use('/api/vods', require('../server/media-proxy/vods'));++app.use('/api/clips', require('../server/media-proxy/clips'));++const server = http.createServer(app).listen(0);++++function call(method, p, user, body) {++ return new Promise((resolve, reject) => {++ const data = body ? JSON.stringify(body) : null;++ const headers = { 'content-type': 'application/json' };++ if (data) headers['content-length'] = Buffer.byteLength(data);++ if (user) headers['x-test-user'] = String(user);++ const req = http.request({ port: server.address().port, path: p, method, headers }, (res) => {++ let text = '';++ res.on('data', (c) => { text += c; });++ res.on('end', () => { let json = null; try { json = JSON.parse(text); } catch { /* */ } resolve({ status: res.statusCode, json }); });++ });++ req.on('error', reject);++ if (data) req.write(data);++ req.end();++ });++}++const replayTexts = async () => (await call('GET', `/api/chat/${streamA}/replay?from=${encodeURIComponent('2026-09-20 09:00:00')}&to=${encodeURIComponent('2026-09-21 03:00:00')}`)).json.messages.map((m) => m.message);++const aiRow = (id) => raw.prepare('SELECT 1 FROM vod_ai_state WHERE vod_id = ?').get(id);++const views = (type, id) => raw.prepare('SELECT COUNT(*) AS n FROM content_views WHERE content_type = ? AND content_id = ?').get(type, id).n;++++let failures = 0;++async function check(name, fn) {++ try { await fn(); quiet(' ✓', name); } catch (e) { failures++; quiet(' ✗', name, '\n ', e.stack || e.message); }++}++++(async () => {++ await new Promise((r) => server.once('listening', r));++ const all = LINES.map(([, t]) => t);++++ await check('chat replay covers the whole stream before any purge', async () => {++ assert.deepStrictEqual(await replayTexts(), all);++ });++++ // What the dashboard sends for 10:00–11:00 UTC (new Date(input).toISOString()).++ const range = { from: '2026-09-20T10:00:00.000Z', to: '2026-09-20T11:00:00.000Z' };++++ await check('the log filter, the purge preview and the purge agree on an ISO range', async () => {++ const q = new URLSearchParams(range).toString();++ const logs = await call('GET', `/api/chat/admin/logs?${q}`, 3);++ assert.strictEqual(logs.status, 200);++ assert.deepStrictEqual(logs.json.rows.map((r) => r.message).sort(), ['first in range', 'last in range', 'middle']);++ const preview = await call('POST', '/api/chat/admin/purge/preview', 3, range);++ assert.deepStrictEqual(preview.json, { count: 3 }, 'the preview counts exactly the lines in the range');++ const purge = await call('DELETE', '/api/chat/admin/purge', 3, range);++ assert.deepStrictEqual(purge.json, { deleted: 3 }, 'and the purge removes exactly those');++ assert.deepStrictEqual(announced.pop(), [streamA, { type: 'purge', streamId: streamA, ...range, by: 'alice' }], 'live chat is told');++ });++++ await check('VOD chat replay no longer shows the purged lines, and still shows every other one', async () => {++ assert.deepStrictEqual(await replayTexts(), ['before', 'just after', 'late that evening', 'after midnight', 'next morning']);++ });++++ await check('a range across midnight purges only its own hours, not the whole last day', async () => {++ const overnight = { from: '2026-09-20T23:00:00.000Z', to: '2026-09-21T01:30:00.000Z' };++ assert.deepStrictEqual((await call('POST', '/api/chat/admin/purge/preview', 3, overnight)).json, { count: 2 });++ assert.deepStrictEqual((await call('DELETE', '/api/chat/admin/purge', 3, overnight)).json, { deleted: 2 });++ assert.deepStrictEqual(await replayTexts(), ['before', 'just after', 'next morning'], '02:30 the next morning is kept');++ });++++ await check('a clip\'s replay window (SQLite form) still works, and staff see purged lines only on request', async () => {++ const r = await call('GET', `/api/chat/${streamA}/replay?from=${encodeURIComponent('2026-09-20 09:59:00')}&to=${encodeURIComponent('2026-09-20 11:00:05')}`);++ assert.deepStrictEqual(r.json.messages.map((m) => m.message), ['before', 'just after']);++ const staff = await call('GET', `/api/chat/admin/logs?streamId=${streamA}&includeDeleted=true&limit=50`, 1);++ assert.strictEqual(staff.json.total, LINES.length, 'the purge is a soft delete an admin can still audit');++ const owner = await call('GET', `/api/chat/admin/logs?streamId=${streamA}&includeDeleted=true&limit=50`, 3);++ assert.strictEqual(owner.json.total, 3, 'the streamer sees only what is left');++ });++++ await check('nobody purges someone else\'s stream', async () => {++ const r = await call('DELETE', '/api/chat/admin/purge', 7, { streamId: streamA, from: '2026-09-20T00:00:00Z', to: '2026-09-22T00:00:00Z' });++ assert.strictEqual(r.status, 403);++ assert.deepStrictEqual(await replayTexts(), ['before', 'just after', 'next morning']);++ });++++ const before = db.getVodsNeedingOverview(6).map((r) => r.id);++ await check('deleting a VOD drops Live\'s rows about it, hides its thread and re-reads its Search document', async () => {++ assert.ok(before.includes(100), 'the VOD was queued for an AI overview');++ const r = await call('DELETE', '/api/vods/100', 3);++ assert.strictEqual(r.status, 200);++ assert.ok(!aiRow(100), 'vod_ai_state row gone');++ assert.strictEqual(views('vod', 100), 0, 'unique views gone');++ assert.ok(!db.getVodsNeedingOverview(6).some((row) => row.id === 100), 'no longer at the head of the AI backfill');++ assert.ok(hidden.includes('vod:100') && touched.includes('vod:100'));++ });++++ await check('bulk delete and "older than" delete do the same; a delete Media refused keeps everything', async () => {++ const bulk = await call('POST', '/api/vods/bulk', 3, { ids: [101, 104], action: 'delete' });++ assert.deepStrictEqual(bulk.json, { done: 1, skipped: 1 });++ const old = await call('POST', '/api/vods/bulk-delete-old', 3, { olderThanDays: 1, deleteClips: false });++ assert.strictEqual(old.json.deleted.vods, 1);++ for (const id of [101, 103]) {++ assert.ok(!aiRow(id) && views('vod', id) === 0, `VOD ${id}: Live's rows gone`);++ assert.ok(hidden.includes(`vod:${id}`) && touched.includes(`vod:${id}`), `VOD ${id}: thread hidden, Search touched`);++ }++ assert.ok(aiRow(104) && views('vod', 104) === 1 && !hidden.includes('vod:104'), 'Media refused 104: nothing of it is dropped');++ assert.ok(aiRow(102) && views('vod', 102) === 1, 'an untouched VOD keeps its rows');++ });++++ await check('deleting a clip drops its AI state (and pending chat announce) and views', async () => {++ const r = await call('DELETE', '/api/clips/200', 3);++ assert.strictEqual(r.status, 200);++ assert.ok(!raw.prepare('SELECT 1 FROM clip_ai_state WHERE clip_id = 200').get());++ assert.strictEqual(views('clip', 200), 0);++ assert.ok(hidden.includes('clip:200') && touched.includes('clip:200'));++ });++++ await check('every delete path, the admin storage page included, goes through purge.afterDelete', async () => {++ const root = path.join(__dirname, '..', 'server');++ const admin = fs.readFileSync(path.join(root, 'admin', 'routes.js'), 'utf8');++ assert.match(admin, /await mediaClient\.deleteVod\(id\);\s*purge\.afterDelete\('vod', id\);/);++ assert.match(admin, /await mediaClient\.deleteClip\(id\);\s*purge\.afterDelete\('clip', id\);/);++ for (const f of ['media-proxy/vods.js', 'media-proxy/clips.js', 'admin/routes.js']) {++ const src = fs.readFileSync(path.join(root, f), 'utf8');++ assert.ok(!/hideThreadOf/.test(src), `${f} hides threads only through purge.afterDelete`);++ }++ });++++ server.close();++ if (failures) { quiet(`\n${failures} check(s) failed`); process.exit(1); }++ quiet('\nAll replay/purge consistency checks passed');++ process.exit(0);++})().catch((e) => { quiet(e); process.exit(1); });
@@ -87,15 +87,19 @@ GET /api/chat/admin/logs/export?format=json
@@ -87,15 +87,19 @@ GET /api/chat/admin/logs/export?format=json
### Purge Preview
### Purge Preview
```
```
POST /api/chat/admin/purge/preview
POST /api/chat/admin/purge/preview
-Body: { "streamId": 123, "fromTime": "2024-01-01T00:00", "toTime": "2024-01-02T00:00" }
+Body: { "streamId": 123, "from": "2024-01-01T00:00:00Z", "to": "2024-01-02T00:00:00Z" }
```
```
### Execute Purge
### Execute Purge
```
```
DELETE /api/chat/admin/purge
DELETE /api/chat/admin/purge
-Body: { "streamId": 123, "fromTime": "2024-01-01T00:00", "toTime": "2024-01-02T00:00" }
+Body: { "streamId": 123, "from": "2024-01-01T00:00:00Z", "to": "2024-01-02T00:00:00Z" }
```
```
+`from` and `to` (also on the log filter and `/api/chat/:streamId/replay`) are UTC, as ISO instants
+or SQLite's `YYYY-MM-DD HH:MM:SS`; both bounds are inclusive. The preview, the purge, the log filter
+and VOD chat replay all read them the same way, so a purged range disappears from replay exactly.
+
## WebSocket Protocol
## WebSocket Protocol
### Connect
### Connect
@@ -53,6 +53,9 @@ Clips are **unlisted by default** — the stream owner can publish or delete the
@@ -53,6 +53,9 @@ Clips are **unlisted by default** — the stream owner can publish or delete the
From the dashboard:
From the dashboard:
- Toggle VODs between **public** and **private**
- Toggle VODs between **public** and **private**
- **Bulk delete** old media by age (e.g., delete VODs older than 30 days)
- **Bulk delete** old media by age (e.g., delete VODs older than 30 days)
+- Every delete (one, bulk, by age, or from the admin storage page) ends in `server/media-proxy/purge.js`
+ once Media confirmed it: the comment thread is hidden, Live's own rows about the item go
+ (`vod_ai_state`/`clip_ai_state`, `content_views`) and its Search document is re-read at once
- **Thumbnails** are auto-generated; broken thumbnails auto-regenerate on load
- **Thumbnails** are auto-generated; broken thumbnails auto-regenerate on load
## Who sees a private VOD or clip
## Who sees a private VOD or clip
@@ -1071,7 +1071,7 @@ router.get('/storage', (req, res) => {
@@ -1071,7 +1071,7 @@ router.get('/storage', (req, res) => {
// proxy the Media API; the tiering + media-tools endpoints return a stub note
// proxy the Media API; the tiering + media-tools endpoints return a stub note
// so the admin SPA shows where the controls went instead of erroring.
// so the admin SPA shows where the controls went instead of erroring.
const mediaClient = require('../media-client');
const mediaClient = require('../media-client');
-const commentsClient = require('../comments-client');
+const purge = require('../media-proxy/purge'); // what Live drops once Media deleted an item
// ── GET /api/admin/storage/vods — proxied VOD listing ────────
// ── GET /api/admin/storage/vods — proxied VOD listing ────────
router.get('/storage/vods', async (req, res) => {
router.get('/storage/vods', async (req, res) => {
@@ -1109,7 +1109,7 @@ router.delete('/storage/vods/bulk', async (req, res) => {
@@ -1109,7 +1109,7 @@ router.delete('/storage/vods/bulk', async (req, res) => {
}
}
freed += vod.file_size || 0;
freed += vod.file_size || 0;
await mediaClient.deleteVod(id);
await mediaClient.deleteVod(id);
- commentsClient.hideThreadOf('vod', Number(id));
+ purge.afterDelete('vod', id);
deleted++;
deleted++;
} catch (err) {
} catch (err) {
errors.push(`VOD ${id}: ${err.message}`);
errors.push(`VOD ${id}: ${err.message}`);
@@ -1141,8 +1141,7 @@ router.delete('/storage/clips/bulk', async (req, res) => {
@@ -1141,8 +1141,7 @@ router.delete('/storage/clips/bulk', async (req, res) => {
errors.push(`Clip ${id}: protected (owner content)`); continue;
errors.push(`Clip ${id}: protected (owner content)`); continue;
}
}
await mediaClient.deleteClip(id);
await mediaClient.deleteClip(id);
- db.run("DELETE FROM content_views WHERE content_type = 'clip' AND content_id = ?", [id]);
- commentsClient.hideThreadOf('clip', Number(id)); // comments are Community threads; Live's old table is read-only
+ purge.afterDelete('clip', id);
deleted++;
deleted++;
} catch (err) {
} catch (err) {
errors.push(`Clip ${id}: ${err.message}`);
errors.push(`Clip ${id}: ${err.message}`);
@@ -3064,6 +3064,16 @@ function getVodAiState(vodId) {
@@ -3064,6 +3064,16 @@ function getVodAiState(vodId) {
function getClipAiState(clipId) {
function getClipAiState(clipId) {
return get('SELECT * FROM clip_ai_state WHERE clip_id = ?', [clipId]);
return get('SELECT * FROM clip_ai_state WHERE clip_id = ?', [clipId]);
}
}
+/** A VOD or clip OpenVibe.Media deleted: Live's own rows about it go (server/media-proxy/purge.js). */
+function forgetMediaItem(kind, id) {
+ return getDb().transaction(() => {
+ const ai = kind === 'clip'
+ ? run('DELETE FROM clip_ai_state WHERE clip_id = ?', [id])
+ : run('DELETE FROM vod_ai_state WHERE vod_id = ?', [id]);
+ const views = run('DELETE FROM content_views WHERE content_type = ? AND content_id = ?', [kind === 'clip' ? 'clip' : 'vod', id]);
+ return { ai: ai.changes, views: views.changes };
+ })();
+}
function setVodAiOverview(vodId, text) {
function setVodAiOverview(vodId, text) {
_ensureVodAiState(vodId);
_ensureVodAiState(vodId);
// Store the FULL overview alongside the derived short — the card expander swaps
// Store the FULL overview alongside the derived short — the card expander swaps
@@ -6381,8 +6391,9 @@ function getChatReplay(streamId, fromTime, toTime) {
@@ -6381,8 +6391,9 @@ function getChatReplay(streamId, fromTime, toTime) {
WHERE cm.stream_id = ? AND cm.is_deleted = 0 AND cm.message_type = 'chat'
WHERE cm.stream_id = ? AND cm.is_deleted = 0 AND cm.message_type = 'chat'
AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP)`;
AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP)`;
const params = [streamId];
const params = [streamId];
- if (fromTime) { sql += ` AND cm.timestamp >= ?`; params.push(fromTime); }
- if (toTime) { sql += ` AND cm.timestamp <= ?`; params.push(toTime); }
+ // The same bounds as a purge (see deleteChatMessagesByTimeRange): either timestamp form, as UTC.
+ if (fromTime) { sql += ` AND cm.timestamp >= datetime(?)`; params.push(fromTime); }
+ if (toTime) { sql += ` AND cm.timestamp <= datetime(?)`; params.push(toTime); }
sql += ` ORDER BY cm.timestamp ASC`;
sql += ` ORDER BY cm.timestamp ASC`;
return all(sql, params);
return all(sql, params);
}
}
@@ -7485,18 +7496,23 @@ function saveUserPreferences(userId, chatSettings) {
@@ -7485,18 +7496,23 @@ function saveUserPreferences(userId, chatSettings) {
/* ── Chat Log Management ──────────────────────────────────── */
/* ── Chat Log Management ──────────────────────────────────── */
+// Time ranges (purge, its preview, the log filter, chat replay): the dashboard sends ISO instants
+// ('…T…Z') and rows keep SQLite's 'YYYY-MM-DD HH:MM:SS'. Compared as TEXT, 'T' sorts after ' ', so a
+// range matched nothing on its first day and all of its last: a purge removed lines outside the range
+// the streamer chose and kept the ones inside it (and VOD chat replay showed them). datetime(?) reads
+// both forms as UTC. OpenVibe.Chat, which serves these routes with CHAT_AUTHORITY=chat, does the same.
function deleteChatMessagesByTimeRange(streamId, fromTime, toTime, deletedBy) {
function deleteChatMessagesByTimeRange(streamId, fromTime, toTime, deletedBy) {
if (streamId) {
if (streamId) {
return run(
return run(
`UPDATE chat_messages SET is_deleted = 1, deleted_by = ?, deleted_at = CURRENT_TIMESTAMP
`UPDATE chat_messages SET is_deleted = 1, deleted_by = ?, deleted_at = CURRENT_TIMESTAMP
- WHERE stream_id = ? AND timestamp >= ? AND timestamp <= ? AND is_deleted = 0`,
+ WHERE stream_id = ? AND timestamp >= datetime(?) AND timestamp <= datetime(?) AND is_deleted = 0`,
[deletedBy, streamId, fromTime, toTime]
[deletedBy, streamId, fromTime, toTime]
);
);
}
}
// Global chat (is_global = 1)
// Global chat (is_global = 1)
return run(
return run(
`UPDATE chat_messages SET is_deleted = 1, deleted_by = ?, deleted_at = CURRENT_TIMESTAMP
`UPDATE chat_messages SET is_deleted = 1, deleted_by = ?, deleted_at = CURRENT_TIMESTAMP
- WHERE is_global = 1 AND timestamp >= ? AND timestamp <= ? AND is_deleted = 0`,
+ WHERE is_global = 1 AND timestamp >= datetime(?) AND timestamp <= datetime(?) AND is_deleted = 0`,
[deletedBy, fromTime, toTime]
[deletedBy, fromTime, toTime]
);
);
}
}
@@ -7506,13 +7522,13 @@ function countChatMessagesByTimeRange(streamId, fromTime, toTime) {
@@ -7506,13 +7522,13 @@ function countChatMessagesByTimeRange(streamId, fromTime, toTime) {
if (streamId) {
if (streamId) {
row = get(
row = get(
`SELECT COUNT(*) as cnt FROM chat_messages
`SELECT COUNT(*) as cnt FROM chat_messages
- WHERE stream_id = ? AND timestamp >= ? AND timestamp <= ? AND is_deleted = 0`,
+ WHERE stream_id = ? AND timestamp >= datetime(?) AND timestamp <= datetime(?) AND is_deleted = 0`,
[streamId, fromTime, toTime]
[streamId, fromTime, toTime]
);
);
} else {
} else {
row = get(
row = get(
`SELECT COUNT(*) as cnt FROM chat_messages
`SELECT COUNT(*) as cnt FROM chat_messages
- WHERE is_global = 1 AND timestamp >= ? AND timestamp <= ? AND is_deleted = 0`,
+ WHERE is_global = 1 AND timestamp >= datetime(?) AND timestamp <= datetime(?) AND is_deleted = 0`,
[fromTime, toTime]
[fromTime, toTime]
);
);
}
}
@@ -7526,8 +7542,8 @@ function getChatLogs({ streamId, username, search, from, to, messageType, page =
@@ -7526,8 +7542,8 @@ function getChatLogs({ streamId, username, search, from, to, messageType, page =
if (streamId) { conditions.push('stream_id = ?'); params.push(streamId); }
if (streamId) { conditions.push('stream_id = ?'); params.push(streamId); }
if (username) { conditions.push('username LIKE ?'); params.push(`%${username}%`); }
if (username) { conditions.push('username LIKE ?'); params.push(`%${username}%`); }
if (search) { conditions.push('message LIKE ?'); params.push(`%${search}%`); }
if (search) { conditions.push('message LIKE ?'); params.push(`%${search}%`); }
- if (from) { conditions.push('timestamp >= ?'); params.push(from); }
- if (to) { conditions.push('timestamp <= ?'); params.push(to); }
+ if (from) { conditions.push('timestamp >= datetime(?)'); params.push(from); }
+ if (to) { conditions.push('timestamp <= datetime(?)'); params.push(to); }
if (messageType) { conditions.push('message_type = ?'); params.push(messageType); }
if (messageType) { conditions.push('message_type = ?'); params.push(messageType); }
if (!includeDeleted) { conditions.push('is_deleted = 0'); }
if (!includeDeleted) { conditions.push('is_deleted = 0'); }
@@ -7645,7 +7661,7 @@ module.exports = {
@@ -7645,7 +7661,7 @@ module.exports = {
publicStream,
publicStream,
getConcurrencyBaseline,
getConcurrencyBaseline,
getHomeStatSeries, HOME_SERIES_KEYS, vibesStatsSince, _computeHomeStats,
getHomeStatSeries, HOME_SERIES_KEYS, vibesStatsSince, _computeHomeStats,
- getVodAiState, getClipAiState,
+ getVodAiState, getClipAiState, forgetMediaItem,
scheduleClipNotifyState, bumpClipNotifyNowState, markClipNotifiedState, getDueClipNotifies,
scheduleClipNotifyState, bumpClipNotifyNowState, markClipNotifiedState, getDueClipNotifies,
getDb, initDb, run, get, all, close, announceModerationAction: _announceModerationAction,
getDb, initDb, run, get, all, close, announceModerationAction: _announceModerationAction,
mergeChatMessageMetadata, getTimelineSpeechSince,
mergeChatMessageMetadata, getTimelineSpeechSince,
@@ -11,7 +11,7 @@ const media = require('../media-client');
@@ -11,7 +11,7 @@ const media = require('../media-client');
const { requireAuth, optionalAuth } = require('../auth/auth');
const { requireAuth, optionalAuth } = require('../auth/auth');
const permissions = require('../auth/permissions');
const permissions = require('../auth/permissions');
const access = require('./access');
const access = require('./access');
-const commentsClient = require('../comments-client');
+const purge = require('./purge');
const { commentCount } = require('./comments');
const { commentCount } = require('./comments');
const lineage = require('../lineage/resolver');
const lineage = require('../lineage/resolver');
@@ -345,7 +345,7 @@ router.post('/bulk', requireAuth, async (req, res) => {
@@ -345,7 +345,7 @@ router.post('/bulk', requireAuth, async (req, res) => {
const allowed = clip && (action === 'delete' ? await canActorDeleteClip(req.user, clip) : await canActorModerateClip(req.user, clip));
const allowed = clip && (action === 'delete' ? await canActorDeleteClip(req.user, clip) : await canActorModerateClip(req.user, clip));
if (!allowed) { skipped++; continue; }
if (!allowed) { skipped++; continue; }
try {
try {
- if (action === 'delete') { await media.deleteClip(id); commentsClient.hideThreadOf('clip', id); }
+ if (action === 'delete') { await media.deleteClip(id); purge.afterDelete('clip', id); }
else await media.updateClip(id, { visibility: action });
else await media.updateClip(id, { visibility: action });
done++;
done++;
} catch { skipped++; }
} catch { skipped++; }
@@ -363,7 +363,7 @@ router.delete('/:id', requireAuth, async (req, res) => {
@@ -363,7 +363,7 @@ router.delete('/:id', requireAuth, async (req, res) => {
if (!clip) return res.status(404).json({ error: 'Clip not found' });
if (!clip) return res.status(404).json({ error: 'Clip not found' });
if (!(await canActorDeleteClip(req.user, clip))) return refuse(req, res, clip, 'Not authorized to delete this clip');
if (!(await canActorDeleteClip(req.user, clip))) return refuse(req, res, clip, 'Not authorized to delete this clip');
await media.deleteClip(clip.id);
await media.deleteClip(clip.id);
- commentsClient.hideThreadOf('clip', clip.id);
+ purge.afterDelete('clip', clip.id);
res.json({ message: 'Clip deleted' });
res.json({ message: 'Clip deleted' });
} catch (err) {
} catch (err) {
mediaErr(res, err, 'Failed to delete clip');
mediaErr(res, err, 'Failed to delete clip');
new file mode 100644
new file mode 100644
@@ -0,0 +1,26 @@
@@ -0,0 +1,26 @@
+'use strict';
+/**
+ * OpenVibe.Live — what Live does once OpenVibe.Media has deleted a VOD or clip for it.
+ *
+ * Every delete path calls afterDelete() after Media confirmed the delete: the owner's /api/vods and
+ * /api/clips routes (one, bulk, older-than) and the admin storage page. It
+ * - hides the item's comment thread on OpenVibe.Community (comments-client.js);
+ * - drops Live's own rows about it: the AI state (vod_ai_state / clip_ai_state), which the AI
+ * backfill (server/ai/backfill-job.js) takes newest-first and would otherwise keep offering for an
+ * id Media no longer has, and the unique-view rows (content_views);
+ * - re-reads its OpenVibe.Search document now (Media's 404 sends the tombstone) instead of leaving
+ * it to the daily refresh.
+ * Lists and pages that cached the item (home pool, SSR detail, page status) expire within a minute.
+ */
+const db = require('../db/database');
+const commentsClient = require('../comments-client');
+
+function afterDelete(kind, id) {
+ const n = Number(id);
+ if ((kind !== 'vod' && kind !== 'clip') || !Number.isInteger(n) || n <= 0) return;
+ commentsClient.hideThreadOf(kind, n);
+ try { db.forgetMediaItem(kind, n); } catch (err) { console.warn(`[Media] could not drop Live's rows for deleted ${kind} ${n}:`, err.message); }
+ try { require('../events/search-media-documents').touchLater(kind, n); } catch { /* search is optional */ }
+}
+
+module.exports = { afterDelete };
@@ -16,7 +16,7 @@ const recorder = require('../streaming/recorder');
@@ -16,7 +16,7 @@ const recorder = require('../streaming/recorder');
const { requireAuth, optionalAuth } = require('../auth/auth');
const { requireAuth, optionalAuth } = require('../auth/auth');
const permissions = require('../auth/permissions');
const permissions = require('../auth/permissions');
const access = require('./access');
const access = require('./access');
-const commentsClient = require('../comments-client');
+const purge = require('./purge');
const { commentCount } = require('./comments');
const { commentCount } = require('./comments');
const router = express.Router();
const router = express.Router();
@@ -357,7 +357,7 @@ router.post('/bulk-delete-old', requireAuth, async (req, res) => {
@@ -357,7 +357,7 @@ router.post('/bulk-delete-old', requireAuth, async (req, res) => {
const out = await media.listVods({ user_id: req.user.id, include_private: 1, limit: 1000 });
const out = await media.listVods({ user_id: req.user.id, include_private: 1, limit: 1000 });
for (const v of (out?.vods || [])) {
for (const v of (out?.vods || [])) {
if (!tooOld(v) || v.is_recording) continue;
if (!tooOld(v) || v.is_recording) continue;
- if (action === 'delete') { if (await media.deleteVod(v.id).then(() => true, () => false)) commentsClient.hideThreadOf('vod', v.id); }
+ if (action === 'delete') { if (await media.deleteVod(v.id).then(() => true, () => false)) purge.afterDelete('vod', v.id); }
else await media.updateVod(v.id, { visibility: action }).catch(() => {});
else await media.updateVod(v.id, { visibility: action }).catch(() => {});
vodCount++;
vodCount++;
}
}
@@ -366,7 +366,7 @@ router.post('/bulk-delete-old', requireAuth, async (req, res) => {
@@ -366,7 +366,7 @@ router.post('/bulk-delete-old', requireAuth, async (req, res) => {
const out = await media.listClips({ user_id: req.user.id, include_private: 1, limit: 1000 });
const out = await media.listClips({ user_id: req.user.id, include_private: 1, limit: 1000 });
for (const c of (out?.clips || [])) {
for (const c of (out?.clips || [])) {
if (!tooOld(c)) continue;
if (!tooOld(c)) continue;
- if (action === 'delete') { if (await media.deleteClip(c.id).then(() => true, () => false)) commentsClient.hideThreadOf('clip', c.id); }
+ if (action === 'delete') { if (await media.deleteClip(c.id).then(() => true, () => false)) purge.afterDelete('clip', c.id); }
else await media.updateClip(c.id, { visibility: action }).catch(() => {});
else await media.updateClip(c.id, { visibility: action }).catch(() => {});
clipCount++;
clipCount++;
}
}
@@ -557,7 +557,7 @@ router.post('/bulk', requireAuth, async (req, res) => {
@@ -557,7 +557,7 @@ router.post('/bulk', requireAuth, async (req, res) => {
if (!owns) owns = permissions.canModerateContentOwner(req.user, vod.user_id ? db.getUserById(vod.user_id) : null);
if (!owns) owns = permissions.canModerateContentOwner(req.user, vod.user_id ? db.getUserById(vod.user_id) : null);
if (!owns) { skipped++; continue; }
if (!owns) { skipped++; continue; }
try {
try {
- if (action === 'delete') { await media.deleteVod(id); commentsClient.hideThreadOf('vod', id); }
+ if (action === 'delete') { await media.deleteVod(id); purge.afterDelete('vod', id); }
else await media.updateVod(id, { visibility: action });
else await media.updateVod(id, { visibility: action });
done++;
done++;
} catch { skipped++; }
} catch { skipped++; }
@@ -573,7 +573,7 @@ router.delete('/:id', requireAuth, async (req, res) => {
@@ -573,7 +573,7 @@ router.delete('/:id', requireAuth, async (req, res) => {
if (!vod) return;
if (!vod) return;
try {
try {
await media.deleteVod(req.params.id);
await media.deleteVod(req.params.id);
- commentsClient.hideThreadOf('vod', Number(req.params.id));
+ purge.afterDelete('vod', req.params.id);
res.json({ message: 'VOD deleted' });
res.json({ message: 'VOD deleted' });
} catch (err) {
} catch (err) {
mediaErr(res, err, 'Failed to delete VOD');
mediaErr(res, err, 'Failed to delete VOD');
new file mode 100644
new file mode 100644
@@ -0,0 +1,243 @@
@@ -0,0 +1,243 @@
+/**
+ * Legacy parity (roadmap D20): purging chat and deleting replays stays consistent.
+ *
+ * 1. Chat purge ↔ VOD chat replay. The dashboard's "Purge Range" (and its preview and log filter)
+ * sends ISO instants ('2026-09-20T10:00:00.000Z'); chat_messages keeps SQLite's
+ * '2026-09-20 10:00:00'. Compared as TEXT ('T' sorts after ' '), a range matched nothing on its
+ * first day and ALL of its last day: the purge removed lines outside the chosen range, kept the ones
+ * inside it, and VOD chat replay went on showing them. Live's local routes (CHAT_AUTHORITY=live, and
+ * the mirror Live keeps under CHAT_AUTHORITY=chat) now read both forms as UTC, as OpenVibe.Chat does.
+ *
+ * 2. Deleting a VOD or clip. Media deletes the item; Live used to only hide the comment thread, and
+ * kept its own rows: the AI state the backfill takes newest-first (a deleted VOD's row sat at the
+ * head of the overview queue for good) and the unique views. The admin storage page and the
+ * "older than" action also left the item's Search document until the daily refresh. Every delete
+ * path now goes through server/media-proxy/purge.js.
+ *
+ * node test/replay-purge.test.js
+ */
+'use strict';
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+
+const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-replay-purge-'));
+process.env.DB_PATH = path.join(tmp, 'live.db');
+process.env.DATA_DIR = tmp;
+process.env.NODE_ENV = 'test';
+const quiet = console.log;
+console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
+console.warn = () => {};
+
+const db = require('../server/db/database');
+db.initDb();
+const raw = db.getDb();
+
+const auth = require('../server/auth/auth');
+const signIn = (req) => {
+ const id = Number(req.headers['x-test-user'] || 0);
+ const u = id ? db.getUserById(id) : null;
+ if (u) { req.user = u; req.authSource = 'network'; }
+ return u;
+};
+auth.requireAuth = (req, res, next) => (signIn(req) ? next() : res.status(401).json({ error: 'Authentication required' }));
+auth.optionalAuth = (req, res, next) => { signIn(req); next(); };
+
+const addUser = (id, username, role) => raw.prepare(
+ `INSERT INTO users (id, username, display_name, email, password_hash, role, created_at)
+ VALUES (?, ?, ?, ?, 'x', ?, '2025-01-01 00:00:00')`).run(id, username, username, `${username}@x`, role);
+addUser(1, 'admin', 'admin');
+addUser(3, 'alice', 'streamer');
+addUser(7, 'mallory', 'streamer');
+db.ensureChannel(3);
+db.ensureChannel(7);
+const streamA = Number(db.createStream({ user_id: 3, channel_id: db.getChannelByUserId(3).id, title: 'A', protocol: 'webrtc' }).lastInsertRowid);
+raw.prepare("UPDATE streams SET started_at = '2026-09-20 09:00:00', ended_at = '2026-09-21 03:00:00', is_live = 0 WHERE id = ?").run(streamA);
+Number(db.createStream({ user_id: 7, channel_id: db.getChannelByUserId(7).id, title: 'M', protocol: 'webrtc' }).lastInsertRowid);
+
+// Alice's chat during her stream, as Live stores it (UTC, SQLite form).
+const LINES = [
+ ['2026-09-20 09:59:59', 'before'],
+ ['2026-09-20 10:00:00', 'first in range'],
+ ['2026-09-20 10:30:00', 'middle'],
+ ['2026-09-20 11:00:00', 'last in range'],
+ ['2026-09-20 11:00:01', 'just after'],
+ ['2026-09-20 23:30:00', 'late that evening'],
+ ['2026-09-21 01:00:00', 'after midnight'],
+ ['2026-09-21 02:30:00', 'next morning'],
+];
+for (const [ts, text] of LINES) {
+ raw.prepare(`INSERT INTO chat_messages (stream_id, channel_user_id, user_id, username, message, message_type, timestamp)
+ VALUES (?, 3, 7, 'mallory', ?, 'chat', ?)`).run(streamA, text, ts);
+}
+
+// ── the chat server the purge route announces to ──
+const announced = [];
+require.cache[require.resolve('../server/chat/chat-server')] = {
+ id: 'chat-server-stub', filename: 'chat-server-stub', loaded: true,
+ exports: { broadcastToStream: (sid, p) => announced.push([sid, p]), broadcastGlobal: (p) => announced.push(['global', p]) },
+};
+
+// ── Media, Community and Search stubs for the delete paths ──
+const media = require('../server/media-client');
+const VODS = {
+ 100: { id: 100, user_id: 3, title: 'one', visibility: 'public', is_public: 1, status: 'ready', created_at: '2020-01-01 00:00:00' },
+ 101: { id: 101, user_id: 3, title: 'bulk', visibility: 'public', is_public: 1, status: 'ready', created_at: '2020-01-01 00:00:00' },
+ 102: { id: 102, user_id: 3, title: 'kept', visibility: 'public', is_public: 1, status: 'ready', created_at: '2020-01-01 00:00:00' },
+ 103: { id: 103, user_id: 3, title: 'old', visibility: 'public', is_public: 1, status: 'ready', created_at: '2020-01-01 00:00:00' },
+ 104: { id: 104, user_id: 3, title: 'media refuses', visibility: 'public', is_public: 1, status: 'ready', created_at: '2020-01-01 00:00:00' },
+};
+const CLIPS = { 200: { id: 200, user_id: 3, channel_user_id: 3, title: 'clip', visibility: 'public', is_public: 1, status: 'ready' } };
+const missing = (what) => new media.MediaApiError(`${what} not found`, 404, { error: `${what} not found` });
+media.getVod = async (id) => { const v = VODS[Number(id)]; if (!v) throw missing('VOD'); return { ...v }; };
+media.getClip = async (id) => { const c = CLIPS[Number(id)]; if (!c) throw missing('Clip'); return { ...c }; };
+media.deleteVod = async (id) => { if (Number(id) === 104) throw new media.MediaApiError('busy', 409, { error: 'busy' }); delete VODS[Number(id)]; };
+media.deleteClip = async (id) => { delete CLIPS[Number(id)]; };
+media.listVods = async (q) => ({ vods: q.user_id === 3 ? [VODS[103]].filter(Boolean) : [] });
+media.listClips = async () => ({ clips: [] });
+const commentsClient = require('../server/comments-client');
+const hidden = [];
+commentsClient.hideThreadOf = (type, id) => { hidden.push(`${type}:${id}`); return Promise.resolve(); };
+const searchDocs = require('../server/events/search-media-documents');
+const touched = [];
+searchDocs.touchLater = (kind, ids) => { touched.push(`${kind}:${ids}`); };
+
+for (const id of [100, 101, 102, 103, 104]) {
+ raw.prepare("INSERT INTO vod_ai_state (vod_id, transcript_status) VALUES (?, 'pending')").run(id);
+ raw.prepare("INSERT INTO content_views (content_type, content_id, ip) VALUES ('vod', ?, '10.0.0.1')").run(id);
+}
+raw.prepare("INSERT INTO clip_ai_state (clip_id, transcript_status) VALUES (200, 'pending')").run();
+raw.prepare("INSERT INTO content_views (content_type, content_id, ip) VALUES ('clip', 200, '10.0.0.1')").run();
+
+const express = require('express');
+const app = express();
+app.use(express.json());
+app.use('/api/chat', require('../server/chat/routes'));
+app.use('/api/vods', require('../server/media-proxy/vods'));
+app.use('/api/clips', require('../server/media-proxy/clips'));
+const server = http.createServer(app).listen(0);
+
+function call(method, p, user, body) {
+ return new Promise((resolve, reject) => {
+ const data = body ? JSON.stringify(body) : null;
+ const headers = { 'content-type': 'application/json' };
+ if (data) headers['content-length'] = Buffer.byteLength(data);
+ if (user) headers['x-test-user'] = String(user);
+ const req = http.request({ port: server.address().port, path: p, method, headers }, (res) => {
+ let text = '';
+ res.on('data', (c) => { text += c; });
+ res.on('end', () => { let json = null; try { json = JSON.parse(text); } catch { /* */ } resolve({ status: res.statusCode, json }); });
+ });
+ req.on('error', reject);
+ if (data) req.write(data);
+ req.end();
+ });
+}
+const replayTexts = async () => (await call('GET', `/api/chat/${streamA}/replay?from=${encodeURIComponent('2026-09-20 09:00:00')}&to=${encodeURIComponent('2026-09-21 03:00:00')}`)).json.messages.map((m) => m.message);
+const aiRow = (id) => raw.prepare('SELECT 1 FROM vod_ai_state WHERE vod_id = ?').get(id);
+const views = (type, id) => raw.prepare('SELECT COUNT(*) AS n FROM content_views WHERE content_type = ? AND content_id = ?').get(type, id).n;
+
+let failures = 0;
+async function check(name, fn) {
+ try { await fn(); quiet(' ✓', name); } catch (e) { failures++; quiet(' ✗', name, '\n ', e.stack || e.message); }
+}
+
+(async () => {
+ await new Promise((r) => server.once('listening', r));
+ const all = LINES.map(([, t]) => t);
+
+ await check('chat replay covers the whole stream before any purge', async () => {
+ assert.deepStrictEqual(await replayTexts(), all);
+ });
+
+ // What the dashboard sends for 10:00–11:00 UTC (new Date(input).toISOString()).
+ const range = { from: '2026-09-20T10:00:00.000Z', to: '2026-09-20T11:00:00.000Z' };
+
+ await check('the log filter, the purge preview and the purge agree on an ISO range', async () => {
+ const q = new URLSearchParams(range).toString();
+ const logs = await call('GET', `/api/chat/admin/logs?${q}`, 3);
+ assert.strictEqual(logs.status, 200);
+ assert.deepStrictEqual(logs.json.rows.map((r) => r.message).sort(), ['first in range', 'last in range', 'middle']);
+ const preview = await call('POST', '/api/chat/admin/purge/preview', 3, range);
+ assert.deepStrictEqual(preview.json, { count: 3 }, 'the preview counts exactly the lines in the range');
+ const purge = await call('DELETE', '/api/chat/admin/purge', 3, range);
+ assert.deepStrictEqual(purge.json, { deleted: 3 }, 'and the purge removes exactly those');
+ assert.deepStrictEqual(announced.pop(), [streamA, { type: 'purge', streamId: streamA, ...range, by: 'alice' }], 'live chat is told');
+ });
+
+ await check('VOD chat replay no longer shows the purged lines, and still shows every other one', async () => {
+ assert.deepStrictEqual(await replayTexts(), ['before', 'just after', 'late that evening', 'after midnight', 'next morning']);
+ });
+
+ await check('a range across midnight purges only its own hours, not the whole last day', async () => {
+ const overnight = { from: '2026-09-20T23:00:00.000Z', to: '2026-09-21T01:30:00.000Z' };
+ assert.deepStrictEqual((await call('POST', '/api/chat/admin/purge/preview', 3, overnight)).json, { count: 2 });
+ assert.deepStrictEqual((await call('DELETE', '/api/chat/admin/purge', 3, overnight)).json, { deleted: 2 });
+ assert.deepStrictEqual(await replayTexts(), ['before', 'just after', 'next morning'], '02:30 the next morning is kept');
+ });
+
+ await check('a clip\'s replay window (SQLite form) still works, and staff see purged lines only on request', async () => {
+ const r = await call('GET', `/api/chat/${streamA}/replay?from=${encodeURIComponent('2026-09-20 09:59:00')}&to=${encodeURIComponent('2026-09-20 11:00:05')}`);
+ assert.deepStrictEqual(r.json.messages.map((m) => m.message), ['before', 'just after']);
+ const staff = await call('GET', `/api/chat/admin/logs?streamId=${streamA}&includeDeleted=true&limit=50`, 1);
+ assert.strictEqual(staff.json.total, LINES.length, 'the purge is a soft delete an admin can still audit');
+ const owner = await call('GET', `/api/chat/admin/logs?streamId=${streamA}&includeDeleted=true&limit=50`, 3);
+ assert.strictEqual(owner.json.total, 3, 'the streamer sees only what is left');
+ });
+
+ await check('nobody purges someone else\'s stream', async () => {
+ const r = await call('DELETE', '/api/chat/admin/purge', 7, { streamId: streamA, from: '2026-09-20T00:00:00Z', to: '2026-09-22T00:00:00Z' });
+ assert.strictEqual(r.status, 403);
+ assert.deepStrictEqual(await replayTexts(), ['before', 'just after', 'next morning']);
+ });
+
+ const before = db.getVodsNeedingOverview(6).map((r) => r.id);
+ await check('deleting a VOD drops Live\'s rows about it, hides its thread and re-reads its Search document', async () => {
+ assert.ok(before.includes(100), 'the VOD was queued for an AI overview');
+ const r = await call('DELETE', '/api/vods/100', 3);
+ assert.strictEqual(r.status, 200);
+ assert.ok(!aiRow(100), 'vod_ai_state row gone');
+ assert.strictEqual(views('vod', 100), 0, 'unique views gone');
+ assert.ok(!db.getVodsNeedingOverview(6).some((row) => row.id === 100), 'no longer at the head of the AI backfill');
+ assert.ok(hidden.includes('vod:100') && touched.includes('vod:100'));
+ });
+
+ await check('bulk delete and "older than" delete do the same; a delete Media refused keeps everything', async () => {
+ const bulk = await call('POST', '/api/vods/bulk', 3, { ids: [101, 104], action: 'delete' });
+ assert.deepStrictEqual(bulk.json, { done: 1, skipped: 1 });
+ const old = await call('POST', '/api/vods/bulk-delete-old', 3, { olderThanDays: 1, deleteClips: false });
+ assert.strictEqual(old.json.deleted.vods, 1);
+ for (const id of [101, 103]) {
+ assert.ok(!aiRow(id) && views('vod', id) === 0, `VOD ${id}: Live's rows gone`);
+ assert.ok(hidden.includes(`vod:${id}`) && touched.includes(`vod:${id}`), `VOD ${id}: thread hidden, Search touched`);
+ }
+ assert.ok(aiRow(104) && views('vod', 104) === 1 && !hidden.includes('vod:104'), 'Media refused 104: nothing of it is dropped');
+ assert.ok(aiRow(102) && views('vod', 102) === 1, 'an untouched VOD keeps its rows');
+ });
+
+ await check('deleting a clip drops its AI state (and pending chat announce) and views', async () => {
+ const r = await call('DELETE', '/api/clips/200', 3);
+ assert.strictEqual(r.status, 200);
+ assert.ok(!raw.prepare('SELECT 1 FROM clip_ai_state WHERE clip_id = 200').get());
+ assert.strictEqual(views('clip', 200), 0);
+ assert.ok(hidden.includes('clip:200') && touched.includes('clip:200'));
+ });
+
+ await check('every delete path, the admin storage page included, goes through purge.afterDelete', async () => {
+ const root = path.join(__dirname, '..', 'server');
+ const admin = fs.readFileSync(path.join(root, 'admin', 'routes.js'), 'utf8');
+ assert.match(admin, /await mediaClient\.deleteVod\(id\);\s*purge\.afterDelete\('vod', id\);/);
+ assert.match(admin, /await mediaClient\.deleteClip\(id\);\s*purge\.afterDelete\('clip', id\);/);
+ for (const f of ['media-proxy/vods.js', 'media-proxy/clips.js', 'admin/routes.js']) {
+ const src = fs.readFileSync(path.join(root, f), 'utf8');
+ assert.ok(!/hideThreadOf/.test(src), `${f} hides threads only through purge.afterDelete`);
+ }
+ });
+
+ server.close();
+ if (failures) { quiet(`\n${failures} check(s) failed`); process.exit(1); }
+ quiet('\nAll replay/purge consistency checks passed');
+ process.exit(0);
+})().catch((e) => { quiet(e); process.exit(1); });