openvibers/openvibe.live Commits
Deleting a VOD removes what the Rundown made from it (#605)
Changes
@@@ -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 };@@@ -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(); } } };nnew file mode 100644@@@ -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); });
@@ -10,7 +10,9 @@
@@ -10,7 +10,9 @@
* backfill (server/ai/backfill-job.js) takes newest-first and would otherwise keep offering for an
* 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);
* 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
* - 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.
* Lists and pages that cached the item (home pool, SSR detail, page status) expire within a minute.
*/
*/
const db = require('../db/database');
const db = require('../db/database');
@@ -22,6 +24,9 @@ async function afterDelete(kind, id) {
@@ -22,6 +24,9 @@ async function afterDelete(kind, id) {
commentsClient.hideThreadOf(kind, n);
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 { 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 */ }
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 };
module.exports = { afterDelete };
@@ -123,6 +123,38 @@ async function recountTopic(topicId) {
@@ -123,6 +123,38 @@ async function recountTopic(topicId) {
}
}
// ── Reading a stream ─────────────────────────────────────────
// ── 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; } }
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. */
/** Streams with unread speech or a missing episode: live ones first, then the newest ended ones. */
@@ -430,4 +462,4 @@ function start() {
@@ -430,4 +462,4 @@ function start() {
}
}
function stop() { if (typeof _job === 'function') _job(); _job = null; }
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(); } } };
new file mode 100644
new file mode 100644
@@ -0,0 +1,57 @@
@@ -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); });