openvibers/openvibe.live Commits

Deleting a VOD removes what the Rundown made from it (#605)

OP OpenVibers committed 10 days ago · 87c5ac8d10 · 3 files, +96 −2

Deleting a VOD left its Rundown segments, the stream's episode and the topic pages written from them in place,
still pointing at the deleted recording. purge.afterDelete, which every delete path calls (the owner's own and bulk
deletes, the admin storage page, and a VOD found deleted on Media), now calls desk.forgetVod: the VOD's segments and
their reactions go, the episodes they fed and their reactions go, and each topic they were filed under is recounted
and its page cleared, so the desk writes it again from what is left. The stream's reading progress stays, so the
deleted recording is not read again.

Changes

ddiff --git a/server/media-proxy/purge.js b/server/media-proxy/purge.js
iindex d2fe3d18..58b771e2 100644
---- a/server/media-proxy/purge.js
++++ b/server/media-proxy/purge.js
@@@ -10,7 +10,9 @@
* 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.
++ * it to the daily refresh;
++ * - for a VOD, drops what the Rundown made from it: its segments, the episodes they fed, their reactions, and the
++ * pages of the topics they were filed under (server/rundown/desk.js forgetVod).
* Lists and pages that cached the item (home pool, SSR detail, page status) expire within a minute.
*/
const db = require('../db/database');
@@@ -22,6 +24,9 @@ async function afterDelete(kind, id) {
commentsClient.hideThreadOf(kind, n);
try { await 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 */ }
++ if (kind === 'vod') {
++ try { await require('../rundown/desk').forgetVod(n); } catch (err) { console.warn(`[Rundown] could not drop what it made from deleted VOD ${n}:`, err.message); }
++ }
}
module.exports = { afterDelete };
ddiff --git a/server/rundown/desk.js b/server/rundown/desk.js
iindex 5dfa0300..47200fd0 100644
---- a/server/rundown/desk.js
++++ b/server/rundown/desk.js
@@@ -123,6 +123,38 @@ async function recountTopic(topicId) {
}
// ── Reading a stream ─────────────────────────────────────────
++/**
++ * A VOD was deleted (server/media-proxy/purge.js afterDelete, on every delete path): what the Rundown made from it goes.
++ * Its segments and their reactions; the episode of each stream those segments came from (an episode is written from
++ * them), with its reactions; each topic they were filed under is recounted, and its page, written from takes that are
++ * gone, is cleared so the desk writes it again from what is left (two takes or more) or leaves it empty. The stream's
++ * progress stays, so the desk does not read the deleted recording again. → { segments, episodes, topics }
++ */
++async function forgetVod(vodId) {
++ const id = Number(vodId);
++ const none = { segments: 0, episodes: 0, topics: 0 };
++ if (!Number.isSafeInteger(id) || id <= 0) return none;
++ const segs = await db.all('SELECT id, stream_id, topic_id FROM rundown_segments WHERE vod_id = ?', [id]);
++ const segIds = segs.map((s) => Number(s.id));
++ const streams = [...new Set(segs.map((s) => Number(s.stream_id)))];
++ const episodes = (await db.all('SELECT stream_id FROM rundown_episodes WHERE vod_id = ? OR stream_id = ANY(?)', [id, streams])).map((e) => Number(e.stream_id));
++ if (!segIds.length && !episodes.length) return none;
++ if (segIds.length) {
++ await db.run("DELETE FROM rundown_reactions WHERE item_type = 'segment' AND item_id = ANY(?)", [segIds]);
++ await db.run('DELETE FROM rundown_segments WHERE id = ANY(?)', [segIds]);
++ }
++ if (episodes.length) {
++ await db.run("DELETE FROM rundown_reactions WHERE item_type = 'episode' AND item_id = ANY(?)", [episodes]);
++ await db.run('DELETE FROM rundown_episodes WHERE stream_id = ANY(?)', [episodes]);
++ }
++ const topics = [...new Set(segs.map((s) => s.topic_id).filter((t) => t != null).map(Number))];
++ for (const topicId of topics) {
++ await recountTopic(topicId);
++ await db.run('UPDATE rundown_topics SET headline = NULL, summary = NULL, summary_takes = 0, summary_at = NULL WHERE id = ?', [topicId]);
++ }
++ return { segments: segIds.length, episodes: episodes.length, topics: topics.length };
++}
++
async function optedOut(userId) { try { return !!await db.get('SELECT 1 FROM arena_opt_outs WHERE user_id = ?', [userId]); } catch { return false; } }
/** Streams with unread speech or a missing episode: live ones first, then the newest ended ones. */
@@@ -430,4 +462,4 @@ function start() {
}
function stop() { if (typeof _job === 'function') _job(); _job = null; }
--module.exports = { start, stop, tick, readStretch, writeEpisode, refreshTopic, keepSegments, topicFor, nextStretch, ready, optedOut, MAX_LINES, _test: { reset() { _calls = []; _announced = []; _fails = new Map(); _repeated = { at: 0, set: new Set() }; _langs = new Map(); _people = { at: 0, entries: [] }; _chat = new Map(); } } };
++module.exports = { start, stop, tick, readStretch, writeEpisode, refreshTopic, keepSegments, topicFor, nextStretch, ready, optedOut, forgetVod, MAX_LINES, _test: { reset() { _calls = []; _announced = []; _fails = new Map(); _repeated = { at: 0, set: new Set() }; _langs = new Map(); _people = { at: 0, entries: [] }; _chat = new Map(); } } };
ddiff --git a/test/rundown-forget-vod.test.js b/test/rundown-forget-vod.test.js
nnew file mode 100644
iindex 00000000..32a4dd53
---- /dev/null
++++ b/test/rundown-forget-vod.test.js
@@@ -0,0 +1,57 @@
++'use strict';
++/**
++ * Deleting a VOD takes what the Rundown made from it (server/rundown/desk.js forgetVod, called by
++ * server/media-proxy/purge.js afterDelete on every delete path): its segments and their reactions, the episode of the
++ * stream they fed, and the page of each topic they were filed under (recounted, cleared to be written again). Another
++ * VOD's segments, and the stream's reading progress, stay.
++ *
++ * node --import ./test/helpers/pg-preload.mjs test/rundown-forget-vod.test.js
++ */
++const assert = require('assert');
++const db = require('../server/db/database');
++
++(async () => {
++ await db.initDb();
++ const desk = require('../server/rundown/desk');
++ const purge = require('../server/media-proxy/purge');
++
++ await db.createUser({ username: 'forgetme', email: 'forgetme@x', password_hash: 'x', display_name: 'forgetme', stream_key: 'forgetme'.padEnd(32, '0') });
++ const uid = (await db.get('SELECT id FROM users WHERE username = ?', ['forgetme'])).id;
++ await db.ensureChannel(uid);
++ const sid = Number((await db.createStream({ user_id: uid, title: 'Two parts', category: 'Just Chatting', protocol: 'rtmp' })).lastInsertRowid);
++ const other = Number((await db.createStream({ user_id: uid, title: 'Another day', category: 'Just Chatting', protocol: 'rtmp' })).lastInsertRowid);
++ const topic = (await db.get(`INSERT INTO rundown_topics (slug, title, headline, summary, summary_takes, summary_at, takes, streamers, first_seen_at, last_seen_at)
++ VALUES ('fridges', 'Fridges', 'Fridges, again', 'Everyone is talking fridges', 3, ov_now(), 3, 1, ov_now(), ov_now()) RETURNING id`)).id;
++ const seg = async (stream, vod, title) => (await db.get(`INSERT INTO rundown_segments (stream_id, user_id, topic_id, start_sec, end_sec, title, what, vod_id, said_at)
++ VALUES (?, ?, ?, 10, 60, ?, 'what was said', ?, ov_now()) RETURNING id`, [stream, uid, topic, title, vod])).id;
++ const gone1 = await seg(sid, 900, 'Fridge hack');
++ const gone2 = await seg(sid, 900, 'Fridge passwords');
++ const kept = await seg(other, 901, 'Fridges are fine');
++ await db.run(`INSERT INTO rundown_episodes (stream_id, user_id, title, logline, recap, chapters_json, vod_id, minutes, aired_at) VALUES (?, ?, 'Two parts', 'fridges', 'recap', '[]', 900, 60, ov_now())`, [sid, uid]);
++ await db.run(`INSERT INTO rundown_episodes (stream_id, user_id, title, logline, recap, chapters_json, vod_id, minutes, aired_at) VALUES (?, ?, 'Another day', 'fine', 'recap', '[]', 901, 30, ov_now())`, [other, uid]);
++ await db.run("INSERT INTO rundown_reactions (item_type, item_id, voter, reaction) VALUES ('segment', ?, 'v1', 'up'), ('segment', ?, 'v1', 'up'), ('episode', ?, 'v1', 'up'), ('episode', ?, 'v1', 'up')", [gone1, kept, sid, other]);
++ await db.run('INSERT INTO rundown_progress (stream_id, read_to) VALUES (?, 3600) ON CONFLICT DO NOTHING', [sid]).catch(() => {});
++
++ // Deleting VOD 900 through the one hook every delete path calls.
++ await purge.afterDelete('vod', 900);
++
++ const ids = (await db.all('SELECT id FROM rundown_segments WHERE topic_id = ? ORDER BY id', [topic])).map((r) => Number(r.id));
++ assert.deepStrictEqual(ids, [Number(kept)], 'the deleted VOD\'s segments are gone; another VOD\'s stay');
++ assert.ok(!await db.get('SELECT 1 FROM rundown_episodes WHERE stream_id = ?', [sid]), 'the episode they fed is gone');
++ assert.ok(await db.get('SELECT 1 FROM rundown_episodes WHERE stream_id = ?', [other]), 'another stream\'s episode stays');
++ const reactions = await db.all('SELECT item_type, item_id FROM rundown_reactions ORDER BY item_type, item_id');
++ assert.deepStrictEqual(reactions.map((r) => `${r.item_type}:${Number(r.item_id)}`).sort(), [`episode:${other}`, `segment:${kept}`].sort(), 'only the surviving items keep their reactions');
++ const t = await db.get('SELECT takes, streamers, headline, summary, summary_takes, summary_at FROM rundown_topics WHERE id = ?', [topic]);
++ assert.deepStrictEqual([Number(t.takes), Number(t.streamers)], [1, 1], 'the topic is recounted');
++ assert.deepStrictEqual([t.headline, t.summary, Number(t.summary_takes), t.summary_at], [null, null, 0, null], 'its page, written from takes that are gone, is cleared');
++ assert.ok(!gone2 || !await db.get('SELECT 1 FROM rundown_segments WHERE id = ?', [gone2]));
++
++ // A VOD the Rundown never used, a clip, and a bad id change nothing.
++ assert.deepStrictEqual(await desk.forgetVod(12345), { segments: 0, episodes: 0, topics: 0 });
++ assert.deepStrictEqual(await desk.forgetVod('x'), { segments: 0, episodes: 0, topics: 0 });
++ await purge.afterDelete('clip', 901);
++ assert.ok(await db.get('SELECT 1 FROM rundown_segments WHERE id = ?', [kept]), 'deleting a clip leaves the Rundown alone');
++
++ console.log('rundown forget vod: all checks passed');
++ process.exit(0);
++})().catch((e) => { console.error(e); process.exit(1); });