openvibers/openvibe.live Commits

The Rundown: audiences, the recording rule, deleting (#634)

OP OpenVibers committed 6 days ago · 2deac8b22e · 20 files, +1164 −97

* The Rundown: audiences, the recording's rule, deleting

Who sees a streamer's Rundown is one setting, not four: Everyone, Anyone with the link, Specific people or Only me. It is the sharing system's own setting (openvibe-sdk/share, share.v1, kind rundown-episode), so the Share sheet is the only place it is set. A segment follows its episode.

The recording decides first: an episode is the stricter of its own setting and its VOD's visibility. Changes made through Live's own pages apply in the same request; changes made elsewhere arrive with Media's media.object.visibility_changed event; until it lands, the episode page re-checks through media-client (60 s cache). The gathered pages show Everyone only and count only Everyone; the streamer sees all their own items everywhere with an audience chip; a take that names another streamer never reaches their page.

Deleting is deleting: the rows go, topics recount, a topic left with nothing is removed, the sharing row goes with the episode, the VOD stays, and the desk does not write that stream up again unless the streamer asks. Staff hiding stays as it is.

* Live subscribes to Media's visibility changes

media.object.* carries media.object.visibility_changed: Media's own change of an object's visibility. The Rundown needs it: a recording that is unlisted elsewhere must unlist its episode, its takes and its after-show report at once. The test learns the new topic.

* The Network client is on the reviewed outbound-request list

server/net/network-json.js (the one client the sharing system uses for Network's identity lookups) makes an outbound request of its own: it is on the reviewed list with the reason.

* Backfill each written-up stream's recording visibility from Media (127 private and 2 unlisted VODs already exist)

* After-show reports follow the stream's Rundown audience: a private recording has no public report, and lists leave it out

Report (openvibe.report) reads these anonymously, so its copies follow too.

Changes

ddiff --git a/migrations/0029_rundown_audiences.sql b/migrations/0029_rundown_audiences.sql
nnew file mode 100644
iindex 00000000..447113c6
---- /dev/null
++++ b/migrations/0029_rundown_audiences.sql
@@@ -0,0 +1,38 @@
++-- phase: expand
++-- The audiences of The Rundown (design 2026-11-06, plan-state-2026-10-22/RUNDOWN-CONTROLS-AND-BROWSE-DESIGN-2026-11-06.md).
++--
++-- Who sees a streamer's Rundown is one setting, not four: Everyone, Anyone with the link, Specific people or Only me.
++-- Those are the sharing system's own audiences (openvibe-sdk/share, share.v1), and the episode's own setting lives
++-- there, in the share row of kind 'rundown-episode' (server/rundown/sharing.js). This table holds only what the
++-- sharing system does not know:
++--
++-- vod_audience what the recording decides, as the last thing Live heard about it. Live applies the rule itself:
++-- the episode's audience is the stricter of its own setting and this (Only me, then Specific people,
++-- then Anyone with the link, then Everyone). A change made through Live's own pages is applied in the
++-- same request (server/media-proxy/vods.js); a change made elsewhere arrives as Media's
++-- media.object.visibility_changed event; until that lands the episode page re-checks the recording
++-- through media-client (cached 60 s, server/rundown/visibility.js).
++--
++-- Expand only: a stream with no row is Everyone, so every older release and every older row keeps working.
++CREATE TABLE IF NOT EXISTS rundown_visibility (
++ stream_id bigint PRIMARY KEY REFERENCES streams(id) ON DELETE CASCADE,
++ vod_audience text COLLATE "C" NOT NULL DEFAULT 'public',
++ vod_members jsonb NOT NULL DEFAULT '[]'::jsonb CHECK (vod_audience IN ('public','link','people','private')),
++ updated_at text COLLATE "C" DEFAULT ov_now()
++);
++
++-- The streamer's own default for the episodes written up for them ("My Rundown" settings). Nobody has a row until
++-- they change it: no row is Everyone.
++CREATE TABLE IF NOT EXISTS rundown_prefs (
++ user_id bigint PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
++ default_audience text COLLATE "C" NOT NULL DEFAULT 'public' CHECK (default_audience IN ('public','link','people','private')),
++ updated_at text COLLATE "C" DEFAULT ov_now()
++);
++
++-- A write-up the streamer deleted. The desk must not write that stream up again on its own; only the streamer asking
++-- for it ("Write this stream up again") clears the row.
++CREATE TABLE IF NOT EXISTS rundown_deleted (
++ stream_id bigint PRIMARY KEY REFERENCES streams(id) ON DELETE CASCADE,
++ user_id bigint NOT NULL,
++ deleted_at text COLLATE "C" DEFAULT ov_now()
++);
ddiff --git a/scripts/rundown-vod-backfill.js b/scripts/rundown-vod-backfill.js
nnew file mode 100644
iindex 00000000..1150fd90
---- /dev/null
++++ b/scripts/rundown-vod-backfill.js
@@@ -0,0 +1,44 @@
++#!/usr/bin/env node
++'use strict';
++/**
++ * Backfill what the Rundown knows about each written-up stream's recording (design RUNDOWN-CONTROLS 2026-11-06, "the
++ * VOD decides first"). The gathered pages read rundown_visibility, which events and Live's own VOD pages fill from now
++ * on; recordings made private or unlisted before this shipped (127 private and 2 unlisted on 2026-11-06) would
++ * otherwise count as public until someone opened their episode page. This asks Media for every one, paced.
++ *
++ * sudo -u <live user> node scripts/rundown-vod-backfill.js (dry run: what it would record)
++ * sudo -u <live user> node scripts/rundown-vod-backfill.js --apply
++ */
++const db = require('../server/db/database');
++const visibility = require('../server/rundown/visibility');
++const media = require('../server/media-client');
++
++const APPLY = process.argv.includes('--apply');
++const PACE_MS = 120;
++const wait = (ms) => new Promise((r) => setTimeout(r, ms));
++
++(async () => {
++ const rows = await db.getDb().prepare(`SELECT DISTINCT stream_id, vod_id FROM (
++ SELECT stream_id, vod_id FROM rundown_episodes WHERE vod_id IS NOT NULL
++ UNION SELECT stream_id, vod_id FROM rundown_segments WHERE vod_id IS NOT NULL) x ORDER BY stream_id`).all();
++ const counts = { checked: 0, gone: 0, failed: 0 };
++ const seen = new Set();
++ for (const row of rows) {
++ if (seen.has(row.stream_id)) continue;
++ seen.add(row.stream_id);
++ counts.checked += 1;
++ let vod = null;
++ try { vod = await media.getVod(Number(row.vod_id)); } catch (err) {
++ if (err && (err.status === 404 || err.status === 410)) { counts.gone += 1; continue; }
++ counts.failed += 1; continue;
++ }
++ if (!vod || !vod.visibility) { counts.gone += 1; continue; }
++ const audience = visibility.mediaAudience(vod.visibility);
++ if (!audience) { counts.failed += 1; continue; }
++ counts[audience] = (counts[audience] || 0) + 1;
++ if (APPLY && audience !== 'public') await visibility.noteVodVisibility(Number(row.stream_id), audience);
++ await wait(PACE_MS);
++ }
++ console.log(`${APPLY ? 'applied' : 'dry run'}: ${JSON.stringify(counts)}`);
++ process.exit(counts.failed ? 1 : 0);
++})().catch((e) => { console.error(e.message); process.exit(2); });
ddiff --git a/scripts/subscribe-media-events.js b/scripts/subscribe-media-events.js
iindex ca4d426c..7f7fd121 100644
---- a/scripts/subscribe-media-events.js
++++ b/scripts/subscribe-media-events.js
@@@ -34,7 +34,7 @@
*/
const fs = require('fs');
--const TOPICS = ['media.vod.*', 'media.clip.*', 'media.storage.*'];
++const TOPICS = ['media.vod.*', 'media.clip.*', 'media.storage.*', 'media.object.*'];
const DEFAULT_ENDPOINT = 'http://127.0.0.1:3000/internal/media-events';
const NETWORK_TOPICS = ['network.user.token_valid_after', 'network.user.updated', 'network.follow.created', 'network.follow.deleted', 'network.subject.merged', 'network.account.export_requested', 'network.account.deleted'];
const NETWORK_ENDPOINT = 'http://127.0.0.1:3000/internal/network-events';
ddiff --git a/server/agent-streams/sharing.js b/server/agent-streams/sharing.js
iindex dc90b2ad..e87215b6 100644
---- a/server/agent-streams/sharing.js
++++ b/server/agent-streams/sharing.js
@@@ -34,26 +34,7 @@ function _setNetwork(client) {
/** Network's internal JSON client, with Live's service token for each call. */
function networkClient() {
if (networkOverride) return networkOverride;
-- const principal = require('../net/network-principal');
-- const base = String(process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000').replace(/\/+$/, '');
-- return {
-- async json({ path, method = 'GET', query, json }) {
-- const headers = await principal.headersFor(path);
-- const q = query ? `?${new URLSearchParams(query)}` : '';
-- const res = await fetch(`${base}${path}${q}`, {
-- method,
-- headers: { 'Content-Type': 'application/json', ...headers },
-- ...(json === undefined ? {} : { body: JSON.stringify(json) }),
-- signal: AbortSignal.timeout(15_000),
-- });
-- if (!res.ok) {
-- const err = new Error(`Network ${path} answered ${res.status}`);
-- err.status = res.status;
-- throw err;
-- }
-- return await res.json();
-- },
-- };
++ return require('../net/network-json').client();
}
/** Events go through Live's own outbox, inside the sharing transaction (the SDK outbox refuses otherwise). */
ddiff --git a/server/index.js b/server/index.js
iindex d9d01977..30bd1705 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -831,6 +831,9 @@ function shareRouterFor(req, res, next) {
return { subject: { type: 'user', id: subject }, staff: ['global_mod', 'admin'].includes(request.user.role) };
},
describe: async (ref) => {
++ // The Rundown's episodes are shared the same way (kind 'rundown-episode'): the sheet only asks, the
++ // service decides (server/rundown/sharing.js).
++ if (ref.kind === 'rundown-episode') return require('./rundown/sharing').describe(ref);
const stream = await require('./agent-streams/service').getByPublicId(ref.id);
return stream ? {
title: stream.title,
ddiff --git a/server/media-proxy/outcomes.js b/server/media-proxy/outcomes.js
iindex ede64f64..1086a09c 100644
---- a/server/media-proxy/outcomes.js
++++ b/server/media-proxy/outcomes.js
@@@ -26,6 +26,8 @@ const EVENT_TYPES = {
'clip.failed': 'media.clip.failed',
'storage.alert': 'media.storage.alert',
'storage.recovered': 'media.storage.recovered',
++ // Media's own visibility change (Media publishes it for every object whose visibility moves).
++ 'object.visibility_changed': 'media.object.visibility_changed',
};
const OUTCOME_NAMES = Object.fromEntries(Object.entries(EVENT_TYPES).map(([k, v]) => [v, k]));
@@@ -48,6 +50,14 @@ function subjectOf(event, data) {
return { type, id: String(id == null ? 'unknown' : id) };
}
++/** The recording an object event is about: Media's legacy reference first (legacy:live:vod:<id>), then a numeric id. */
++function vodIdOf(data) {
++ const m = /^legacy:[^:]+:vod:(\d+)$/.exec(String((data && data.legacy_ref) || ''));
++ if (m) return Number(m[1]);
++ const id = Number(data && data.object_id);
++ return Number.isSafeInteger(id) && id > 0 ? id : null;
++}
++
function dedupeKey(eventId, subject) {
return `media:${subject.type}:${subject.id}:${eventId}`;
}
@@@ -169,6 +179,16 @@ async function apply(event, data) {
case 'clip.failed':
if (data.id) { try { await db.setClipTranscriptStatus(data.id, 'failed', data.error || 'media reported failure'); } catch { /* */ } }
return { outcome: 'clip_failed', after: () => console.warn(`[MediaOutcome] Clip ${data.id} failed:`, data.error || '(no detail)') };
++ case 'object.visibility_changed': {
++ // The recording decides first (design 2026-11-06): the write-up of that stream follows at once, wherever
++ // the change was made (MediaHub, openvibe.video, Live's own pages).
++ const kind = String(data.kind || 'vod');
++ if (kind !== 'vod') return { outcome: 'ignored' };
++ const vodId = vodIdOf(data);
++ if (!vodId) return { outcome: 'ignored' };
++ await require('../rundown/visibility').noteVodChange(vodId, data.stream_id || null, data.visibility);
++ return { outcome: 'object_visibility_changed' };
++ }
case 'storage.alert':
case 'storage.recovered':
// Media's VOD volume needs a human (drain stalled / disk critical) or is fine again.
ddiff --git a/server/media-proxy/vods.js b/server/media-proxy/vods.js
iindex 8e1bfc3a..7cbd14b1 100644
---- a/server/media-proxy/vods.js
++++ b/server/media-proxy/vods.js
@@@ -400,7 +400,7 @@ router.post('/bulk-delete-old', requireAuth, async (req, res) => {
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)) await purge.afterDelete('vod', v.id); }
-- else await media.updateVod(v.id, { visibility: action }).catch(() => {});
++ else await media.updateVod(v.id, { visibility: action }).then(() => require('../rundown/visibility').noteVodChange(v.id, v.stream_id, action)).catch(() => {});
vodCount++;
}
}
@@@ -689,6 +689,8 @@ router.put('/:id', requireAuth, async (req, res) => {
else if (is_public !== undefined) fields.visibility = is_public ? 'public' : 'private';
if (nextLabels !== undefined) fields.labels = nextLabels;
await media.updateVod(req.params.id, fields);
++ // The recording decides first: the write-up of that stream follows at once (design 2026-11-06).
++ if (fields.visibility) await require('../rundown/visibility').noteVodChange(req.params.id, vod.stream_id, fields.visibility);
res.json({ vod: await media.getVod(req.params.id).catch(() => ({ ...vod, ...fields })) });
} catch (err) {
mediaErr(res, err, 'Failed to update VOD');
@@@ -713,7 +715,7 @@ router.post('/bulk', requireAuth, async (req, res) => {
if (!owns) { skipped++; continue; }
try {
if (action === 'delete') { await media.deleteVod(id); await purge.afterDelete('vod', id); }
-- else await media.updateVod(id, { visibility: action });
++ else { await media.updateVod(id, { visibility: action }); await require('../rundown/visibility').noteVodChange(id, vod.stream_id, action); }
done++;
} catch { skipped++; }
}
ddiff --git a/server/net/network-json.js b/server/net/network-json.js
nnew file mode 100644
iindex 00000000..d1fbb071
---- /dev/null
++++ b/server/net/network-json.js
@@@ -0,0 +1,39 @@
++'use strict';
++/**
++ * Network's internal JSON client, with Live's service token for each call (server/net/network-principal.js).
++ *
++ * The sharing system (openvibe-sdk/share) needs Network twice: to turn a name into a person and to resolve people for
++ * display. One client for both, so a test that answers Network's lookups locally (agent streams, the Rundown) changes
++ * one place.
++ */
++let override = null;
++
++/** Tests and a restore drill: answer Network's calls locally. */
++function _setNetwork(client) { override = client || null; }
++
++/** The client: one call, one service token, a timeout, and the status on the error. */
++function client() {
++ if (override) return override;
++ const principal = require('./network-principal');
++ const base = String(process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000').replace(/\/+$/, '');
++ return {
++ async json({ path, method = 'GET', query, json }) {
++ const headers = await principal.headersFor(path);
++ const q = query ? `?${new URLSearchParams(query)}` : '';
++ const res = await fetch(`${base}${path}${q}`, {
++ method,
++ headers: { 'Content-Type': 'application/json', ...headers },
++ ...(json === undefined ? {} : { body: JSON.stringify(json) }),
++ signal: AbortSignal.timeout(15_000),
++ });
++ if (!res.ok) {
++ const err = new Error(`Network ${path} answered ${res.status}`);
++ err.status = res.status;
++ throw err;
++ }
++ return await res.json();
++ },
++ };
++}
++
++module.exports = { client, _setNetwork };
ddiff --git a/server/recap/routes.js b/server/recap/routes.js
iindex f0a895ff..58bc2301 100644
---- a/server/recap/routes.js
++++ b/server/recap/routes.js
@@@ -12,11 +12,35 @@ const { can } = require('../auth/permissions');
const recap = require('./recap');
const { requireAuth, optionalAuth } = require('../auth/auth');
--router.get('/channel/:username', async (req, res) => {
++// An after-show report follows its stream's Rundown audience (RUNDOWN-CONTROLS design 2026-11-06): the stricter of the
++// episode's own setting and its recording's. Everyone and Anyone with the link read it; Specific people and Only me are
++// for those people and the streamer; a list never shows a report its reader may not open.
++const visibility = require('../rundown/visibility');
++const sharing = require('../rundown/sharing');
++async function readerOf(req) {
++ if (!req.user || !req.user.id) return { userId: null, subject: null };
++ let subject = null;
++ try { subject = await require('../auth/identity-sync').subjectOf(req.user.id); } catch { subject = null; }
++ return { userId: Number(req.user.id), subject };
++}
++async function mayRead(streamId, ownerUserId, reader) {
++ const eff = await visibility.effective(streamId);
++ return { ok: sharing.maySee(eff, { ownerUserId, viewer: reader }), eff };
++}
++
++router.get('/channel/:username', optionalAuth, async (req, res) => {
const user = await db.get('SELECT * FROM users WHERE lower(username) = lower(?)', [String(req.params.username || '')]);
if (!user) return res.status(404).json({ error: 'Channel not found' });
-- res.set('Cache-Control', 'public, max-age=60');
-- res.json({ recaps: await recap.listRecaps(user.id, Math.min(12, parseInt(req.query.limit || '6', 10) || 6)) });
++ const reader = await readerOf(req);
++ const all = await recap.listRecaps(user.id, Math.min(12, parseInt(req.query.limit || '6', 10) || 6));
++ // A list shows Everyone reports only, plus the streamer's own.
++ const shown = [];
++ for (const r of all) {
++ const eff = await visibility.effective(r.stream_id);
++ if (eff.audience === 'public' || (reader.userId != null && reader.userId === Number(user.id))) shown.push(r);
++ }
++ res.set('Cache-Control', reader.userId ? 'private, no-store' : 'public, max-age=60');
++ res.json({ recaps: shown });
});
router.get('/:streamId', optionalAuth, async (req, res) => {
@@@ -26,6 +50,9 @@ router.get('/:streamId', optionalAuth, async (req, res) => {
const stream = await db.getStreamById(id);
if (!stream) return res.status(404).json({ error: 'Stream not found' });
if (stream.is_live) return res.status(409).json({ error: 'Still live — the report comes after the stream ends', live: true });
++ const reader = await readerOf(req);
++ const gate = await mayRead(id, stream.user_id, reader);
++ if (!gate.ok) return res.status(404).json({ error: 'No report for this stream' });
let r = await recap.getRecap(id);
if (!r) {
const dur = Number(stream.duration_seconds) || 0;
@@@ -33,8 +60,12 @@ router.get('/:streamId', optionalAuth, async (req, res) => {
r = await recap.buildRecap(id);
}
if (!r) return res.status(404).json({ error: 'No report for this stream' });
-- res.set('Cache-Control', 'public, max-age=120');
-- res.json({ recap: r, more: (await recap.listRecaps(r.streamer.id, 6)).filter(x => x.stream_id !== id) });
++ res.set('Cache-Control', gate.eff.audience === 'public' ? 'public, max-age=120' : 'private, no-store');
++ const more = [];
++ for (const x of (await recap.listRecaps(r.streamer.id, 6)).filter((y) => y.stream_id !== id)) {
++ if ((await visibility.effective(x.stream_id)).audience === 'public') more.push(x);
++ }
++ res.json({ recap: r, more });
} catch (err) {
console.error('[Recap] route:', err.message);
res.status(500).json({ error: 'Failed to build the report' });
ddiff --git a/server/rundown/delete.js b/server/rundown/delete.js
nnew file mode 100644
iindex 00000000..f68f1148
---- /dev/null
++++ b/server/rundown/delete.js
@@@ -0,0 +1,115 @@
++'use strict';
++/**
++ * Deleting The Rundown (design 2026-11-06).
++ *
++ * Deleted is deleted: the rows are removed, not hidden. Topics recount, a topic left with nothing is removed, the
++ * sharing row goes with the episode, and the desk does not write that stream up again unless the streamer asks
++ * ("Write this stream up again"). The recording stays: deleting a write-up never touches the VOD.
++ *
++ * Staff hiding (status 'hidden') is a moderation tool and stays as it is.
++ */
++const db = require('../db/database');
++
++/** The receipt: "Deleted 1 episode and 12 takes". */
++function receipt(episodes, takes) {
++ const e = Number(episodes) || 0, t = Number(takes) || 0;
++ const parts = [];
++ if (e) parts.push(`${e} episode${e === 1 ? '' : 's'}`);
++ if (t) parts.push(`${t} take${t === 1 ? '' : 's'}`);
++ return { episodes: e, takes: t, message: parts.length ? `Deleted ${parts.join(' and ')}` : 'Nothing to delete' };
++}
++
++/** The topics these takes were filed under, recounted; a topic left with nothing is removed. */
++async function recountTopics(topicIds) {
++ const ids = [...new Set((topicIds || []).map(Number).filter(Boolean))];
++ for (const id of ids) {
++ await db.run(`UPDATE rundown_topics SET
++ takes = (SELECT COUNT(*) FROM rundown_segments WHERE topic_id = ? AND status = 'live'),
++ streamers = (SELECT COUNT(DISTINCT user_id) FROM rundown_segments WHERE topic_id = ? AND status = 'live'),
++ last_seen_at = COALESCE((SELECT MAX(said_at) FROM rundown_segments WHERE topic_id = ? AND status = 'live'), last_seen_at)
++ WHERE id = ?`, [id, id, id, id]);
++ // The page of a topic was written from takes that are gone: it is cleared, so the desk writes it again from
++ // what is left (two takes or more) or leaves it empty.
++ await db.run('UPDATE rundown_topics SET headline = NULL, summary = NULL, summary_takes = 0, summary_at = NULL WHERE id = ?', [id]);
++ const left = await db.get('SELECT COUNT(*) AS n FROM rundown_segments WHERE topic_id = ? AND status = \'live\'', [id]);
++ if (!Number(left && left.n)) await db.run('DELETE FROM rundown_topics WHERE id = ?', [id]);
++ }
++}
++
++/** The sharing row of an episode goes with it: there is nothing left to share. */
++async function forgetShare(streamId) {
++ const id = String(streamId);
++ await db.run("DELETE FROM share_members WHERE service = 'live' AND kind = 'rundown-episode' AND resource_id = ?", [id]);
++ await db.run("DELETE FROM shares WHERE service = 'live' AND kind = 'rundown-episode' AND resource_id = ?", [id]);
++}
++
++/** The desk must not write that stream up again on its own. */
++async function markDeleted(streamId, userId) {
++ await db.run(`INSERT INTO rundown_deleted (stream_id, user_id, deleted_at) VALUES (?, ?, ov_now())
++ ON CONFLICT (stream_id) DO UPDATE SET user_id = excluded.user_id, deleted_at = excluded.deleted_at`, [Number(streamId), Number(userId)]);
++ await db.run('UPDATE rundown_progress SET done = 1, updated_at = ov_now() WHERE stream_id = ?', [Number(streamId)]);
++}
++
++/** One take: the segment, its reactions, and its topic's summary (rewritten from what is left at the next desk pass). */
++async function deleteTake(segmentId, user) {
++ const seg = await db.get('SELECT id, stream_id, user_id, topic_id FROM rundown_segments WHERE id = ?', [Number(segmentId)]);
++ if (!seg) throw Object.assign(new Error('No such take'), { status: 404 });
++ if (Number(seg.user_id) !== Number(user.id)) throw Object.assign(new Error('Not your take'), { status: 403 });
++ await db.run("DELETE FROM rundown_reactions WHERE item_type = 'segment' AND item_id = ?", [seg.id]);
++ await db.run('DELETE FROM rundown_segments WHERE id = ?', [seg.id]);
++ await recountTopics(seg.topic_id ? [seg.topic_id] : []);
++ // The episode the take was part of stays, with one take less.
++ return receipt(0, 1);
++}
++
++/** One episode: the episode, all its segments, its reactions, and the topics they were filed under. The VOD stays. */
++async function deleteEpisode(streamId, user) {
++ const id = Number(streamId);
++ const ep = await db.get('SELECT stream_id, user_id FROM rundown_episodes WHERE stream_id = ?', [id]);
++ const owner = ep ? ep.user_id : (await db.get('SELECT user_id FROM streams WHERE id = ?', [id]) || {}).user_id;
++ if (!owner) throw Object.assign(new Error('No such episode'), { status: 404 });
++ if (Number(owner) !== Number(user.id)) throw Object.assign(new Error('Not your episode'), { status: 403 });
++ const segs = await db.all('SELECT id, topic_id FROM rundown_segments WHERE stream_id = ?', [id]);
++ const segIds = segs.map((s) => Number(s.id));
++ 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_reactions WHERE item_type = 'episode' AND item_id = ?", [id]);
++ if (segIds.length) await db.run('DELETE FROM rundown_segments WHERE id = ANY(?)', [segIds]);
++ await db.run('DELETE FROM rundown_episodes WHERE stream_id = ?', [id]);
++ await recountTopics(segs.map((s) => s.topic_id).filter(Boolean));
++ await forgetShare(id);
++ await markDeleted(id, user.id);
++ return receipt(1, segIds.length);
++}
++
++/** All my Rundown: every episode and take of the streamer's. Their VODs stay. */
++async function deleteAll(user, { keepOut = false } = {}) {
++ const eps = await db.all('SELECT stream_id FROM rundown_episodes WHERE user_id = ?', [user.id]);
++ const segs = await db.all('SELECT id, topic_id, stream_id FROM rundown_segments WHERE user_id = ?', [user.id]);
++ const segIds = segs.map((s) => Number(s.id));
++ if (segIds.length) await db.run("DELETE FROM rundown_reactions WHERE item_type = 'segment' AND item_id = ANY(?)", [segIds]);
++ const epIds = eps.map((e) => Number(e.stream_id));
++ if (epIds.length) await db.run("DELETE FROM rundown_reactions WHERE item_type = 'episode' AND item_id = ANY(?)", [epIds]);
++ if (segIds.length) await db.run('DELETE FROM rundown_segments WHERE id = ANY(?)', [segIds]);
++ if (epIds.length) await db.run('DELETE FROM rundown_episodes WHERE stream_id = ANY(?)', [epIds]);
++ await recountTopics(segs.map((s) => s.topic_id).filter(Boolean));
++ for (const id of epIds) { await forgetShare(id); await markDeleted(id, user.id); }
++ // A take without an episode (a stream still live) is a write-up too: the desk must not make it again.
++ for (const s of segs) if (!epIds.includes(Number(s.stream_id))) await markDeleted(s.stream_id, user.id);
++ if (keepOut) {
++ await db.run('INSERT INTO arena_opt_outs (user_id) VALUES (?) ON CONFLICT DO NOTHING', [user.id]);
++ }
++ return receipt(epIds.length, segIds.length);
++}
++
++/** "Write this stream up again": the desk may read that stream once more. */
++async function restore(streamId, user) {
++ const id = Number(streamId);
++ const row = await db.get('SELECT user_id FROM rundown_deleted WHERE stream_id = ?', [id]);
++ if (!row) throw Object.assign(new Error('Nothing to write up again'), { status: 404 });
++ if (Number(row.user_id) !== Number(user.id)) throw Object.assign(new Error('Not your stream'), { status: 403 });
++ await db.run('DELETE FROM rundown_deleted WHERE stream_id = ?', [id]);
++ await db.run('DELETE FROM rundown_progress WHERE stream_id = ?', [id]);
++ return { restored: true, stream_id: id };
++}
++
++module.exports = { receipt, recountTopics, deleteTake, deleteEpisode, deleteAll, restore, markDeleted, forgetShare };
ddiff --git a/server/rundown/desk.js b/server/rundown/desk.js
iindex 47200fd0..69017efe 100644
---- a/server/rundown/desk.js
++++ b/server/rundown/desk.js
@@@ -114,6 +114,19 @@ async function topicFor(title, kind, at) {
}
return row;
}
++/**
++ * The episode's own setting (design 2026-11-06): the write-up registers itself with the streamer's default audience
++ * the first time the desk writes it up, and the Share sheet changes it afterwards. A write-up the streamer deleted is
++ * never written again unless they ask for it ("Write this stream up again").
++ */
++async function ensureShare(streamId, userId) {
++ let subject = null;
++ try { subject = await require('../auth/identity-sync').subjectOf(userId); } catch { subject = null; }
++ if (!subject) return false;
++ const audience = await require('./settings').defaultAudience(userId);
++ return await require('./sharing').register(streamId, subject, audience);
++}
++
async function recountTopic(topicId) {
await db.run(`UPDATE rundown_topics SET
takes = (SELECT COUNT(*) FROM rundown_segments WHERE topic_id = ? AND status = 'live'),
@@@ -163,6 +176,7 @@ async function candidates() {
COALESCE(p.read_to_sec, -1) AS read_to_sec
FROM streams s LEFT JOIN rundown_progress p ON p.stream_id = s.id
WHERE COALESCE(p.done, 0) = 0 AND (s.is_live = 1 OR s.ended_at >= ? OR s.started_at >= ?)
++ AND s.id NOT IN (SELECT stream_id FROM rundown_deleted)
AND EXISTS (SELECT 1 FROM stream_timeline_events e WHERE e.stream_id = s.id AND e.kind = 'speech')
ORDER BY s.is_live DESC, COALESCE(s.ended_at, s.started_at) DESC
LIMIT 60`, [daysAgo(BACKFILL_DAYS), daysAgo(BACKFILL_DAYS)]);
@@@ -317,6 +331,7 @@ async function keepSegments(stream, stretch, segments, user, others = []) {
[stream.id, user.id, topic ? topic.id : null, Number(s.start) || 0, Number(s.end) || Number(s.start) || 0, title, what, take, quip,
score, JSON.stringify(quotes), await mentionsIn(spanText, user.id), vod, at]);
if (topic) await recountTopic(topic.id);
++ await ensureShare(stream.id, user.id);
kept++;
}
return kept;
@@@ -380,6 +395,7 @@ async function writeEpisode(stream, user) {
chapters_json = excluded.chapters_json, vod_id = excluded.vod_id, minutes = excluded.minutes`,
[stream.id, user.id, title, clean(out.logline, 220), recap ? recap.replace(/ ¶ /g, '\n\n') : null, JSON.stringify(chapters), vod, minutes, stream.started_at || sqlTime(Date.now())]);
await setProgress(stream.id, stream.read_to_sec, 1);
++ await ensureShare(stream.id, user.id);
if (!rewrite) await announce(stream, user, title).catch(() => {}); // written again (a re-read): announced once already
return { called: true, episode: true };
}
@@@ -462,4 +478,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, 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(); } } };
++module.exports = { start, stop, tick, readStretch, writeEpisode, refreshTopic, keepSegments, topicFor, nextStretch, ready, optedOut, forgetVod, ensureShare, 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/server/rundown/read.js b/server/rundown/read.js
iindex 32c52b7d..6d774281 100644
---- a/server/rundown/read.js
++++ b/server/rundown/read.js
@@@ -7,12 +7,19 @@
* streamsFeed({ before, viewer }) stream by stream: each recent stream with its episode and its best takes, newest first
* topics({ sort, kind }) every topic
* topic(slug) one topic and every take on it
-- * episode(streamId) one stream written up, with its segments and the streamer's other episodes
++ * episode(streamId) one stream written up, with its segments
* streamer(username) their episodes, topics, lines and crossovers
* react(type, id, voter, r) one reaction per reader per item (a reader is their account, else a salted address hash)
++ *
++ * Who sees what (design 2026-11-06): the gathered pages show Everyone only — the front page, the topics, the topic
++ * pages, trending, who's been talking, the streamer list and the counts. The streamer sees all their own items
++ * everywhere, each non-public one marked with a small audience chip. The recording decides first: an item is Everyone
++ * only when its episode is Everyone and its VOD is not stricter than that.
*/
const db = require('../db/database');
const config = require('../config');
++const visibility = require('./visibility');
++const sharing = require('./sharing');
const REACTIONS = ['fire', 'lol', 'dead', 'cap', 'facts'];
const FEED_MIN_SCORE = 4; // the front page: a take scored 4 or 5, a crossover or an episode
@@@ -34,6 +41,24 @@ function absolute(url) {
}
const isNsfw = (row) => !!(Number(row && row.is_nsfw) || Number(row && row.channel_force_nsfw));
++/**
++ * The reader: their account (when signed in) and their voter key. The voter key is what reactions are counted by; the
++ * account is what the audience rule is counted by.
++ */
++function who(viewer) {
++ if (!viewer) return { key: null, userId: null, subject: null };
++ if (typeof viewer === 'string') return { key: viewer, userId: null, subject: null };
++ return { key: viewer.key || null, userId: viewer.userId == null ? null : Number(viewer.userId), subject: viewer.subject || null };
++}
++
++/**
++ * Who may see an item on a gathered page: the reader's own items, or items that are Everyone. "Everyone" means the
++ * episode's own setting is Everyone and the recording is not stricter than that (the recording decides first).
++ */
++const VISIBLE = (alias) => `(${alias}.user_id = @viewer OR (COALESCE(sh.audience, 'public') = 'public' AND COALESCE(v.vod_audience, 'public') = 'public'))`;
++const AUDIENCE_JOINS = (alias) => `LEFT JOIN shares sh ON sh.service = 'live' AND sh.kind = 'rundown-episode' AND sh.resource_id = CAST(${alias}.stream_id AS text)
++ LEFT JOIN rundown_visibility v ON v.stream_id = ${alias}.stream_id`;
++
/**
* stream id → card picture. A live stream shows its live frame; an ended one asks Media for its public VOD's
* thumbnail (one batched, five-minute-cached lookup per call, media-proxy/lookups.js). NSFW channels and streams
@@@ -89,44 +114,90 @@ async function reactionsFor(type, ids, voter) {
return { counts, mine };
}
++/**
++ * The audience of every stream on this page, in one batch: the episode's own setting and what the recording decides.
++ * The chip a streamer sees on their own items comes from here.
++ */
++async function audiencesFor(streamIds) {
++ const ids = [...new Set((streamIds || []).map(Number).filter((n) => Number.isSafeInteger(n) && n > 0))];
++ const out = new Map();
++ if (!ids.length) return out;
++ let own = [], vod = [];
++ try {
++ own = await db.all("SELECT resource_id, audience FROM shares WHERE service = 'live' AND kind = 'rundown-episode' AND resource_id = ANY(?)",
++ [ids.map((n) => String(n))]);
++ } catch { own = []; }
++ try {
++ vod = await db.all('SELECT stream_id, vod_audience FROM rundown_visibility WHERE stream_id = ANY(?)', [ids]);
++ } catch { vod = []; }
++ const ownBy = new Map(own.map((r) => [Number(r.resource_id), r.audience]));
++ const vodBy = new Map(vod.map((r) => [Number(r.stream_id), r.vod_audience]));
++ for (const id of ids) {
++ const o = ownBy.get(id) || visibility.DEFAULT_AUDIENCE;
++ const w = vodBy.get(id) || visibility.DEFAULT_AUDIENCE;
++ const audience = visibility.stricter(o, w);
++ out.set(id, {
++ own: o, vod: w, audience,
++ chip: audience === visibility.DEFAULT_AUDIENCE ? null : visibility.label(audience),
++ following_vod: audience !== o,
++ following_vod_label: audience !== o ? visibility.mediaWord(w) : null,
++ });
++ }
++ return out;
++}
++
/** Segments as the pages show them, with their people, topics and reactions resolved in three queries. */
async function segmentsOut(rows, voter) {
++ if (!rows.length) return [];
const ppl = await people(rows.flatMap((r) => [r.user_id, ...(r.mentions || [])]));
const tps = await topicsById(rows.map((r) => r.topic_id));
const rx = await reactionsFor('segment', rows.map((r) => r.id), voter);
++ const aud = await audiencesFor(rows.map((r) => r.stream_id));
// Segment and quote times are stream time; `sec` links into vod_id, which for a stream recorded in parts (its
// broadcaster reconnected) starts later than the stream (streaming/stream-history.js).
const history = require('../streaming/stream-history');
const offsets = await history.vodOffsets(rows.map((r) => r.vod_id));
const inVod = (r, sec) => Math.max(0, Math.floor(history.vodTime(sec, r.vod_id ? offsets.get(Number(r.vod_id)) : 0)));
-- return rows.map((r) => ({
-- type: 'segment', id: Number(r.id), title: r.title, what: r.what, take: r.take || null, quip: r.quip || null, score: Number(r.score),
-- quotes: parseJson(r.quotes_json, []).map((q) => ({ text: q.text, en: q.en || null, sec: inVod(r, q.sec) })),
-- topic: tps.get(Number(r.topic_id)) || null, user: ppl.get(Number(r.user_id)) || null,
-- mentions: (r.mentions || []).map((id) => ppl.get(Number(id))).filter(Boolean),
-- stream_id: Number(r.stream_id), vod_id: r.vod_id ? Number(r.vod_id) : null, sec: inVod(r, r.start_sec),
-- said_at: r.said_at, reactions: rx.counts.get(Number(r.id)) || {}, my_reaction: rx.mine.get(Number(r.id)) || null,
-- }));
++ return rows.map((r) => {
++ const a = aud.get(Number(r.stream_id)) || {};
++ return {
++ type: 'segment', id: Number(r.id), title: r.title, what: r.what, take: r.take || null, quip: r.quip || null, score: Number(r.score),
++ quotes: parseJson(r.quotes_json, []).map((q) => ({ text: q.text, en: q.en || null, sec: inVod(r, q.sec) })),
++ topic: tps.get(Number(r.topic_id)) || null, user: ppl.get(Number(r.user_id)) || null,
++ mentions: (r.mentions || []).map((id) => ppl.get(Number(id))).filter(Boolean),
++ stream_id: Number(r.stream_id), vod_id: r.vod_id ? Number(r.vod_id) : null, sec: inVod(r, r.start_sec),
++ said_at: r.said_at, reactions: rx.counts.get(Number(r.id)) || {}, my_reaction: rx.mine.get(Number(r.id)) || null,
++ audience: a.audience || 'public', audience_chip: a.chip || null,
++ };
++ });
}
async function episodesOut(rows, voter) {
++ if (!rows.length) return [];
const ppl = await people(rows.map((r) => r.user_id));
const rx = await reactionsFor('episode', rows.map((r) => r.stream_id), voter);
const ids = rows.map((r) => Number(r.stream_id));
++ const aud = await audiencesFor(ids);
const topicRows = ids.length ? await db.all(`SELECT DISTINCT ON (s.stream_id, t.id) s.stream_id, t.slug, t.title FROM rundown_segments s JOIN rundown_topics t ON t.id = s.topic_id
WHERE s.stream_id = ANY(?) AND s.status = 'live' AND t.status = 'live' ORDER BY s.stream_id, t.id`, [ids]) : [];
const byStream = new Map();
for (const t of topicRows) { const l = byStream.get(Number(t.stream_id)) || []; if (l.length < 6) l.push({ slug: t.slug, title: t.title }); byStream.set(Number(t.stream_id), l); }
-- return rows.map((r) => ({
-- type: 'episode', stream_id: Number(r.stream_id), title: r.title, logline: r.logline || null, recap: r.recap || null,
-- chapters: parseJson(r.chapters_json, []).map((c) => ({ sec: Math.max(0, Math.floor(Number(c.sec) || 0)), title: c.title, segment_id: c.segment_id ? Number(c.segment_id) : null })),
-- user: ppl.get(Number(r.user_id)) || null, vod_id: r.vod_id ? Number(r.vod_id) : null, minutes: r.minutes == null ? null : Number(r.minutes),
-- aired_at: r.aired_at, topics: byStream.get(Number(r.stream_id)) || [],
-- reactions: rx.counts.get(Number(r.stream_id)) || {}, my_reaction: rx.mine.get(Number(r.stream_id)) || null,
-- }));
++ return rows.map((r) => {
++ const a = aud.get(Number(r.stream_id)) || {};
++ return {
++ type: 'episode', stream_id: Number(r.stream_id), title: r.title, logline: r.logline || null, recap: r.recap || null,
++ chapters: parseJson(r.chapters_json, []).map((c) => ({ sec: Math.max(0, Math.floor(Number(c.sec) || 0)), title: c.title, segment_id: c.segment_id ? Number(c.segment_id) : null })),
++ user: ppl.get(Number(r.user_id)) || null, vod_id: r.vod_id ? Number(r.vod_id) : null, minutes: r.minutes == null ? null : Number(r.minutes),
++ aired_at: r.aired_at, topics: byStream.get(Number(r.stream_id)) || [],
++ reactions: rx.counts.get(Number(r.stream_id)) || {}, my_reaction: rx.mine.get(Number(r.stream_id)) || null,
++ audience: a.audience || 'public', audience_chip: a.chip || null,
++ following_vod: !!a.following_vod, following_vod_label: a.following_vod_label || null,
++ };
++ });
}
--const SEGMENT_COLS = 'id, stream_id, user_id, topic_id, start_sec, title, what, take, quip, score, quotes_json, mentions, vod_id, said_at';
--const EPISODE_COLS = 'stream_id, user_id, title, logline, recap, chapters_json, vod_id, minutes, aired_at';
++// The columns are qualified: the audience joins bring a second table with the same names.
++const SEGMENT_COLS = 's.id, s.stream_id, s.user_id, s.topic_id, s.start_sec, s.title, s.what, s.take, s.quip, s.score, s.quotes_json, s.mentions, s.vod_id, s.said_at';
++const EPISODE_COLS = 'r.stream_id, r.user_id, r.title, r.logline, r.recap, r.chapters_json, r.vod_id, r.minutes, r.aired_at';
async function onAir() {
const rows = await db.all(`SELECT s.id AS stream_id, s.user_id, s.title AS stream_title, s.is_nsfw, s.thumbnail_url,
@@@ -141,22 +212,27 @@ async function onAir() {
return rows.map((r) => ({ stream_id: Number(r.stream_id), user: ppl.get(Number(r.user_id)) || null, stream_title: r.stream_title || null, thumbnail_url: thumbs.get(Number(r.stream_id)) || null, talking_about: r.talking_about || null, topic: r.topic_slug ? { slug: r.topic_slug, title: r.topic_title } : null })).filter((r) => r.user);
}
++/** Trending topics: only what everyone may see is counted. */
async function trending(limit = 12, days = 7) {
const rows = await db.all(`SELECT t.id, t.slug, t.title, t.kind, t.headline, t.summary, t.last_seen_at,
COUNT(s.id) AS week_takes, COUNT(DISTINCT s.user_id) AS week_streamers, array_agg(DISTINCT s.user_id) AS users
-- FROM rundown_topics t JOIN rundown_segments s ON s.topic_id = t.id AND s.status = 'live' AND s.said_at >= ?
-- WHERE t.status = 'live' GROUP BY t.id ORDER BY COUNT(DISTINCT s.user_id) DESC, COUNT(s.id) DESC, MAX(s.said_at) DESC LIMIT ?`, [daysAgo(days), limit]);
++ FROM rundown_topics t JOIN rundown_segments s ON s.topic_id = t.id AND s.status = 'live' AND s.said_at >= @since
++ ${AUDIENCE_JOINS('s')}
++ WHERE t.status = 'live' AND ${VISIBLE('s')} GROUP BY t.id ORDER BY COUNT(DISTINCT s.user_id) DESC, COUNT(s.id) DESC, MAX(s.said_at) DESC LIMIT @limit`,
++ { since: daysAgo(days), limit, viewer: null });
const ppl = await people(rows.flatMap((r) => (r.users || []).slice(0, 4)));
return rows.map((r) => ({ slug: r.slug, title: r.title, kind: r.kind, headline: r.headline || null, summary: r.summary || null, last_seen_at: r.last_seen_at,
takes: Number(r.week_takes), streamers: Number(r.week_streamers), people: (r.users || []).slice(0, 4).map((id) => ppl.get(Number(id))).filter(Boolean) }));
}
++/** Who's been talking: only what everyone may see is counted. */
async function voices(days = 7) {
const rows = await db.all(`SELECT s.user_id, COUNT(*) AS segments, COUNT(DISTINCT s.topic_id) AS topics, MAX(s.said_at) AS last_seen
-- FROM rundown_segments s WHERE s.status = 'live' AND s.said_at >= ? GROUP BY s.user_id ORDER BY MAX(s.said_at) DESC LIMIT 16`, [daysAgo(days)]);
++ FROM rundown_segments s ${AUDIENCE_JOINS('s')}
++ WHERE s.status = 'live' AND s.said_at >= @since AND ${VISIBLE('s')} GROUP BY s.user_id ORDER BY MAX(s.said_at) DESC LIMIT 16`, { since: daysAgo(days), viewer: null });
const ppl = await people(rows.map((r) => r.user_id));
const tops = rows.length ? await db.all(`SELECT s.user_id, t.slug, t.title, COUNT(*) AS n FROM rundown_segments s JOIN rundown_topics t ON t.id = s.topic_id
-- WHERE s.status = 'live' AND t.status = 'live' AND s.said_at >= ? AND s.user_id = ANY(?) GROUP BY s.user_id, t.slug, t.title ORDER BY COUNT(*) DESC`, [daysAgo(days), rows.map((r) => Number(r.user_id))]) : [];
++ WHERE s.status = 'live' AND t.status = 'live' AND s.said_at >= @since AND s.user_id = ANY(@users) GROUP BY s.user_id, t.slug, t.title ORDER BY COUNT(*) DESC`, { since: daysAgo(days), users: rows.map((r) => Number(r.user_id)) }) : [];
const byUser = new Map();
for (const t of tops) { const l = byUser.get(Number(t.user_id)) || []; if (l.length < 3) l.push({ slug: t.slug, title: t.title }); byUser.set(Number(t.user_id), l); }
return rows.map((r) => ({ user: ppl.get(Number(r.user_id)) || null, segments: Number(r.segments), topics: Number(r.topics), last_seen: r.last_seen, top_topics: byUser.get(Number(r.user_id)) || [] })).filter((v) => v.user);
@@@ -164,10 +240,12 @@ async function voices(days = 7) {
/** The lead: the best take of the last day (else the week), a quoted one first. */
async function lead({ viewer = null } = {}) {
++ const v = who(viewer);
for (const days of [1, 7]) {
-- const rows = await db.all(`SELECT ${SEGMENT_COLS} FROM rundown_segments WHERE status = 'live' AND said_at >= ? AND score >= ?
-- ORDER BY score DESC, (quotes_json <> '[]') DESC, said_at DESC LIMIT 1`, [daysAgo(days), FEED_MIN_SCORE]);
-- if (rows.length) return (await segmentsOut(rows, viewer))[0];
++ const rows = await db.all(`SELECT ${SEGMENT_COLS} FROM rundown_segments s ${AUDIENCE_JOINS('s')}
++ WHERE s.status = 'live' AND s.said_at >= @since AND s.score >= @score AND ${VISIBLE('s')}
++ ORDER BY s.score DESC, (s.quotes_json <> '[]') DESC, s.said_at DESC LIMIT 1`, { since: daysAgo(days), score: FEED_MIN_SCORE, viewer: v.userId });
++ if (rows.length) return (await segmentsOut(rows, v.key))[0];
}
return null;
}
@@@ -178,13 +256,15 @@ async function lead({ viewer = null } = {}) {
* time of a stream's latest take.
*/
async function streamsFeed({ before = null, limit = STREAMS_PAGE, viewer = null, skip = null } = {}) {
++ const v = who(viewer);
const cut = before ? String(before).slice(0, 19) : '9999-12-31 00:00:00';
-- const groups = await db.all(`SELECT stream_id, MAX(said_at) AS last_at, COUNT(*) AS takes FROM rundown_segments WHERE status = 'live'
-- GROUP BY stream_id HAVING MAX(said_at) < ? ORDER BY MAX(said_at) DESC LIMIT ?`, [cut, limit]);
++ const groups = await db.all(`SELECT s.stream_id, MAX(s.said_at) AS last_at, COUNT(*) AS takes FROM rundown_segments s ${AUDIENCE_JOINS('s')}
++ WHERE s.status = 'live' AND ${VISIBLE('s')} GROUP BY s.stream_id HAVING MAX(s.said_at) < @cut ORDER BY MAX(s.said_at) DESC LIMIT @limit`, { cut, limit, viewer: v.userId });
const ids = groups.map((g) => Number(g.stream_id));
if (!ids.length) return { streams: [], next: null };
-- const segs = await db.all(`SELECT ${SEGMENT_COLS} FROM rundown_segments WHERE status = 'live' AND stream_id = ANY(?)
-- ORDER BY score DESC, cardinality(mentions) DESC, (quotes_json <> '[]') DESC, start_sec ASC`, [ids]);
++ const segs = await db.all(`SELECT ${SEGMENT_COLS} FROM rundown_segments s ${AUDIENCE_JOINS('s')}
++ WHERE s.status = 'live' AND s.stream_id = ANY(@ids) AND ${VISIBLE('s')}
++ ORDER BY s.score DESC, cardinality(s.mentions) DESC, (s.quotes_json <> '[]') DESC, s.start_sec ASC`, { ids, viewer: v.userId });
const best = new Map();
for (const s of segs) {
const l = best.get(Number(s.stream_id)) || [];
@@@ -193,12 +273,13 @@ async function streamsFeed({ before = null, limit = STREAMS_PAGE, viewer = null,
}
const chosen = [...best.values()].flat().sort((a, b) => Number(a.start_sec) - Number(b.start_sec));
const takes = new Map();
-- for (const t of await segmentsOut(chosen, viewer)) { const l = takes.get(t.stream_id) || []; l.push(t); takes.set(t.stream_id, l); }
-- const eps = new Map((await episodesOut(await db.all(`SELECT ${EPISODE_COLS} FROM rundown_episodes WHERE status = 'live' AND stream_id = ANY(?)`, [ids]), viewer))
-- .map((e) => [e.stream_id, { title: e.title, logline: e.logline, minutes: e.minutes, aired_at: e.aired_at, topics: e.topics, reactions: e.reactions, my_reaction: e.my_reaction }]));
++ for (const t of await segmentsOut(chosen, v.key)) { const l = takes.get(t.stream_id) || []; l.push(t); takes.set(t.stream_id, l); }
++ const eps = new Map((await episodesOut(await db.all(`SELECT ${EPISODE_COLS} FROM rundown_episodes r ${AUDIENCE_JOINS('r')}
++ WHERE r.status = 'live' AND r.stream_id = ANY(@ids) AND ${VISIBLE('r')}`, { ids, viewer: v.userId }), v.key))
++ .map((e) => [e.stream_id, { title: e.title, logline: e.logline, minutes: e.minutes, aired_at: e.aired_at, topics: e.topics, reactions: e.reactions, my_reaction: e.my_reaction, audience: e.audience, audience_chip: e.audience_chip }]));
const streams = new Map((await db.all(`SELECT s.id, s.user_id, s.title, s.is_live, s.started_at, s.ended_at, s.duration_seconds,
s.is_nsfw, s.thumbnail_url, COALESCE(c.force_nsfw, 0) AS channel_force_nsfw
-- FROM streams s LEFT JOIN channels c ON c.user_id = s.user_id WHERE s.id = ANY(?)`, [ids])).map((s) => [Number(s.id), s]));
++ FROM streams s LEFT JOIN channels c ON c.user_id = s.user_id WHERE s.id = ANY(@ids)`, { ids })).map((s) => [Number(s.id), s]));
const ppl = await people([...streams.values()].map((s) => s.user_id));
const thumbs = await thumbnailsFor([...streams.values()]);
const out = groups.map((g) => {
@@@ -212,10 +293,13 @@ async function streamsFeed({ before = null, limit = STREAMS_PAGE, viewer = null,
}
async function home({ viewer = null } = {}) {
++ const v = who(viewer);
const top = await lead({ viewer });
const [air, feed, topics, talk] = await Promise.all([onAir(), streamsFeed({ viewer, skip: top ? top.id : null }), trending(), voices()]);
-- const counts = await db.get(`SELECT (SELECT COUNT(*) FROM rundown_topics WHERE status = 'live') AS topics,
-- (SELECT COUNT(*) FROM rundown_episodes WHERE status = 'live') AS episodes, (SELECT COUNT(*) FROM rundown_segments WHERE status = 'live') AS takes`);
++ const counts = await db.get(`SELECT
++ (SELECT COUNT(DISTINCT t.id) FROM rundown_topics t JOIN rundown_segments s ON s.topic_id = t.id AND s.status = 'live' ${AUDIENCE_JOINS('s')} WHERE t.status = 'live' AND ${VISIBLE('s')}) AS topics,
++ (SELECT COUNT(*) FROM rundown_episodes r ${AUDIENCE_JOINS('r')} WHERE r.status = 'live' AND ${VISIBLE('r')}) AS episodes,
++ (SELECT COUNT(*) FROM rundown_segments s ${AUDIENCE_JOINS('s')} WHERE s.status = 'live' AND ${VISIBLE('s')}) AS takes`, { viewer: v.userId });
return { on_air: air, lead: top, streams: feed.streams, streams_next: feed.next, trending: topics, voices: talk,
// Empty, with no query behind them: the N-1 fixture's reply still has the old river's keys (gone once it is re-recorded).
latest: [], next: null,
@@@ -223,54 +307,90 @@ async function home({ viewer = null } = {}) {
}
const KINDS = ['news', 'games', 'tech', 'life', 'music', 'sports', 'money', 'internet', 'chat', 'other'];
++/** Every topic, with the takes everyone may see counted (a topic whose takes are all non-public disappears). */
async function topics({ sort = 'recent', kind = null, limit = 60, offset = 0 } = {}) {
const order = sort === 'most' ? 'streamers DESC, takes DESC, last_seen_at DESC' : 'last_seen_at DESC';
const k = KINDS.includes(kind) ? kind : null;
-- const rows = await db.all(`SELECT id, slug, title, kind, headline, summary, takes, streamers, first_seen_at, last_seen_at FROM rundown_topics
-- WHERE status = 'live' AND takes > 0 AND (?::text IS NULL OR kind = ?) ORDER BY ${order} LIMIT ? OFFSET ?`, [k, k, Math.min(100, limit), Math.max(0, offset)]);
-- const users = rows.length ? await db.all(`SELECT topic_id, array_agg(DISTINCT user_id) AS users FROM rundown_segments WHERE status = 'live' AND topic_id = ANY(?) GROUP BY topic_id`, [rows.map((r) => Number(r.id))]) : [];
-- const byTopic = new Map(users.map((u) => [Number(u.topic_id), (u.users || []).slice(0, 4)]));
++ const rows = await db.all(`SELECT t.id, t.slug, t.title, t.kind, t.headline, t.summary, t.first_seen_at, t.last_seen_at,
++ COUNT(s.id) AS takes, COUNT(DISTINCT s.user_id) AS streamers, array_agg(DISTINCT s.user_id) AS users
++ FROM rundown_topics t JOIN rundown_segments s ON s.topic_id = t.id AND s.status = 'live' ${AUDIENCE_JOINS('s')}
++ WHERE t.status = 'live' AND ${VISIBLE('s')} AND (@kind::text IS NULL OR t.kind = @kind)
++ GROUP BY t.id ORDER BY ${order} LIMIT @limit OFFSET @offset`, { kind: k, limit: Math.min(100, limit), offset: Math.max(0, offset), viewer: null });
++ const byTopic = new Map(rows.map((u) => [Number(u.id), (u.users || []).slice(0, 4)]));
const ppl = await people([...byTopic.values()].flat());
return { topics: rows.map((r) => ({ slug: r.slug, title: r.title, kind: r.kind, headline: r.headline || null, summary: r.summary || null, takes: Number(r.takes), streamers: Number(r.streamers),
first_seen_at: r.first_seen_at, last_seen_at: r.last_seen_at, people: (byTopic.get(Number(r.id)) || []).map((id) => ppl.get(Number(id))).filter(Boolean) })), kinds: KINDS };
}
++/** One topic and the takes on it that everyone may see. */
async function topic(slug, { viewer = null } = {}) {
++ const v = who(viewer);
const t = await db.get("SELECT id, slug, title, kind, headline, summary, takes, streamers, first_seen_at, last_seen_at FROM rundown_topics WHERE slug = ? AND status = 'live'", [String(slug || '').slice(0, 80)]);
if (!t) return null;
-- const rows = await db.all(`SELECT ${SEGMENT_COLS} FROM rundown_segments WHERE topic_id = ? AND status = 'live' ORDER BY said_at DESC LIMIT 100`, [t.id]);
++ const rows = await db.all(`SELECT ${SEGMENT_COLS} FROM rundown_segments s ${AUDIENCE_JOINS('s')}
++ WHERE s.topic_id = @topic AND s.status = 'live' AND ${VISIBLE('s')} ORDER BY s.said_at DESC LIMIT 100`, { topic: t.id, viewer: v.userId });
++ if (!rows.length) return null; // every take on it is non-public: the topic is not there for this reader
++ const counts = await db.get(`SELECT COUNT(*) AS takes, COUNT(DISTINCT s.user_id) AS streamers FROM rundown_segments s ${AUDIENCE_JOINS('s')}
++ WHERE s.topic_id = @topic AND s.status = 'live' AND ${VISIBLE('s')}`, { topic: t.id, viewer: v.userId });
const related = await db.all(`SELECT DISTINCT t2.slug, t2.title, t2.last_seen_at FROM rundown_segments s JOIN rundown_segments s2 ON s2.stream_id = s.stream_id AND s2.topic_id <> s.topic_id
-- JOIN rundown_topics t2 ON t2.id = s2.topic_id AND t2.status = 'live' WHERE s.topic_id = ? ORDER BY t2.last_seen_at DESC LIMIT 8`, [t.id]);
-- return { topic: { slug: t.slug, title: t.title, kind: t.kind, headline: t.headline || null, summary: t.summary || null, takes: Number(t.takes), streamers: Number(t.streamers), first_seen_at: t.first_seen_at, last_seen_at: t.last_seen_at },
-- takes: await segmentsOut(rows, viewer), related: related.map((r) => ({ slug: r.slug, title: r.title })) };
++ JOIN rundown_topics t2 ON t2.id = s2.topic_id AND t2.status = 'live' WHERE s.topic_id = @topic ORDER BY t2.last_seen_at DESC LIMIT 8`, { topic: t.id });
++ return { topic: { slug: t.slug, title: t.title, kind: t.kind, headline: t.headline || null, summary: t.summary || null, takes: Number(counts.takes), streamers: Number(counts.streamers), first_seen_at: t.first_seen_at, last_seen_at: t.last_seen_at },
++ takes: await segmentsOut(rows, v.key), related: related.map((r) => ({ slug: r.slug, title: r.title })) };
}
++/**
++ * One stream written up: the episode, its segments and the streamer's other episodes. The episode's own audience
++ * decides who may open it: anybody with the link, the people it is shared with, or the streamer.
++ */
async function episode(streamId, { viewer = null } = {}) {
const id = parseInt(streamId, 10);
if (!(id > 0)) return null;
-- const e = await db.get(`SELECT ${EPISODE_COLS} FROM rundown_episodes WHERE stream_id = ? AND status = 'live'`, [id]);
-- const segs = await db.all(`SELECT ${SEGMENT_COLS} FROM rundown_segments WHERE stream_id = ? AND status = 'live' ORDER BY start_sec ASC LIMIT 60`, [id]);
-- if (!e && !segs.length) return null;
++ const v = who(viewer);
++ const e = await db.get(`SELECT ${EPISODE_COLS} FROM rundown_episodes r ${AUDIENCE_JOINS('r')} WHERE r.stream_id = @id AND r.status = 'live'`, { id });
const stream = await db.get('SELECT id, user_id, title, is_live, started_at, ended_at FROM streams WHERE id = ?', [id]);
if (!stream) return null;
-- const others = await db.all(`SELECT ${EPISODE_COLS} FROM rundown_episodes WHERE user_id = ? AND status = 'live' AND stream_id <> ? ORDER BY aired_at DESC LIMIT 6`, [stream.user_id, id]);
++ // Until Media's event lands, the episode page re-checks the recording (cached 60 s).
++ await visibility.recheck(id);
++ const eff = await visibility.effective(id);
++ const ownerUserId = e ? e.user_id : stream.user_id;
++ if (!sharing.maySee(eff, { ownerUserId, viewer: v })) return null;
++ const segs = await db.all(`SELECT ${SEGMENT_COLS} FROM rundown_segments s WHERE s.stream_id = @id AND s.status = 'live' ORDER BY s.start_sec ASC LIMIT 60`, { id });
++ if (!e && !segs.length) return null;
++ const others = await db.all(`SELECT ${EPISODE_COLS} FROM rundown_episodes r ${AUDIENCE_JOINS('r')}
++ WHERE r.user_id = @user AND r.status = 'live' AND r.stream_id <> @id AND ${VISIBLE('r')} ORDER BY r.aired_at DESC LIMIT 6`, { user: stream.user_id, id, viewer: v.userId });
const ppl = await people([stream.user_id]);
-- return { episode: e ? (await episodesOut([e], viewer))[0] : null, stream: { id: Number(stream.id), title: stream.title || null, is_live: !!Number(stream.is_live), started_at: stream.started_at, ended_at: stream.ended_at, user: ppl.get(Number(stream.user_id)) || null },
-- segments: await segmentsOut(segs, viewer), more: await episodesOut(others, viewer) };
++ const eps = e ? await episodesOut([e], v.key) : [];
++ const episodeOut = eps[0] || null;
++ if (episodeOut) {
++ episodeOut.following_vod = eff.following_vod;
++ episodeOut.following_vod_label = eff.following_vod ? visibility.mediaWord(eff.vod) : null;
++ episodeOut.audience = eff.audience;
++ episodeOut.audience_chip = eff.audience === 'public' ? null : visibility.label(eff.audience, eff.member_count);
++ }
++ return { episode: episodeOut, stream: { id: Number(stream.id), title: stream.title || null, is_live: !!Number(stream.is_live), started_at: stream.started_at, ended_at: stream.ended_at, user: ppl.get(Number(stream.user_id)) || null },
++ segments: await segmentsOut(segs, v.key), more: await episodesOut(others, v.key) };
}
++/** A streamer's own page: their episodes, topics, lines and the crossovers (takes that name them). */
async function streamer(username, { viewer = null } = {}) {
++ const v = who(viewer);
const u = await db.get('SELECT id, username, display_name, avatar_url, profile_color FROM users WHERE lower(username) = lower(?)', [String(username || '').replace(/^@/, '').slice(0, 64)]);
if (!u) return null;
const out = !!await db.get('SELECT 1 FROM arena_opt_outs WHERE user_id = ?', [u.id]);
if (out) return { user: person(u), opted_out: true, episodes: [], topics: [], lines: [], crossovers: [] };
-- const eps = await db.all(`SELECT ${EPISODE_COLS} FROM rundown_episodes WHERE user_id = ? AND status = 'live' ORDER BY aired_at DESC LIMIT 30`, [u.id]);
++ const eps = await db.all(`SELECT ${EPISODE_COLS} FROM rundown_episodes r ${AUDIENCE_JOINS('r')}
++ WHERE r.user_id = @user AND r.status = 'live' AND ${VISIBLE('r')} ORDER BY r.aired_at DESC LIMIT 30`, { user: u.id, viewer: v.userId });
const tops = await db.all(`SELECT t.slug, t.title, t.kind, COUNT(*) AS n, MAX(s.said_at) AS last FROM rundown_segments s JOIN rundown_topics t ON t.id = s.topic_id
-- WHERE s.user_id = ? AND s.status = 'live' AND t.status = 'live' GROUP BY t.slug, t.title, t.kind ORDER BY COUNT(*) DESC, MAX(s.said_at) DESC LIMIT 24`, [u.id]);
-- const lines = await db.all(`SELECT ${SEGMENT_COLS} FROM rundown_segments WHERE user_id = ? AND status = 'live' AND quotes_json <> '[]' ORDER BY score DESC, said_at DESC LIMIT 12`, [u.id]);
-- const cross = await db.all(`SELECT ${SEGMENT_COLS} FROM rundown_segments WHERE status = 'live' AND (? = ANY(mentions) OR (user_id = ? AND cardinality(mentions) > 0)) ORDER BY said_at DESC LIMIT 12`, [u.id, u.id]);
-- return { user: person(u), opted_out: false, episodes: await episodesOut(eps, viewer), topics: tops.map((t) => ({ slug: t.slug, title: t.title, kind: t.kind, takes: Number(t.n), last_seen_at: t.last })),
-- lines: await segmentsOut(lines, viewer), crossovers: await segmentsOut(cross, viewer) };
++ ${AUDIENCE_JOINS('s')}
++ WHERE s.user_id = @user AND s.status = 'live' AND t.status = 'live' AND ${VISIBLE('s')} GROUP BY t.slug, t.title, t.kind ORDER BY COUNT(*) DESC, MAX(s.said_at) DESC LIMIT 24`, { user: u.id, viewer: v.userId });
++ const lines = await db.all(`SELECT ${SEGMENT_COLS} FROM rundown_segments s ${AUDIENCE_JOINS('s')}
++ WHERE s.user_id = @user AND s.status = 'live' AND s.quotes_json <> '[]' AND ${VISIBLE('s')} ORDER BY s.score DESC, s.said_at DESC LIMIT 12`, { user: u.id, viewer: v.userId });
++ // A take that names them is a crossover — but only when everyone may see it. A non-public take never reaches
++ // another streamer's page and never tells them they were named.
++ const cross = await db.all(`SELECT ${SEGMENT_COLS} FROM rundown_segments s ${AUDIENCE_JOINS('s')}
++ WHERE s.status = 'live' AND (@user = ANY(s.mentions) OR (s.user_id = @user AND cardinality(s.mentions) > 0)) AND ${VISIBLE('s')} ORDER BY s.said_at DESC LIMIT 12`, { user: u.id, viewer: v.userId });
++ return { user: person(u), opted_out: false, episodes: await episodesOut(eps, v.key), topics: tops.map((t) => ({ slug: t.slug, title: t.title, kind: t.kind, takes: Number(t.n), last_seen_at: t.last })),
++ lines: await segmentsOut(lines, v.key), crossovers: await segmentsOut(cross, v.key) };
}
/** One reaction per reader per item; the same reaction again takes it back. → { reactions, mine } */
@@@ -311,4 +431,4 @@ async function setOptOut(userId, out) {
return { opted_out: !!out };
}
--module.exports = { home, lead, streamsFeed, topics, topic, episode, streamer, react, hide, optedOut, setOptOut, trending, voices, onAir, REACTIONS, KINDS, person };
++module.exports = { home, lead, streamsFeed, topics, topic, episode, streamer, react, hide, optedOut, setOptOut, trending, voices, onAir, audiencesFor, who, REACTIONS, KINDS, person };
ddiff --git a/server/rundown/routes.js b/server/rundown/routes.js
iindex 6f16a5d1..ddd8d5fe 100644
---- a/server/rundown/routes.js
++++ b/server/rundown/routes.js
@@@ -11,6 +11,11 @@
* POST /react {type, id, reaction} one per reader per item (fire, lol, dead, cap, facts); the same again takes it back
* POST /hide {type, id} staff: take a segment, episode or topic down
* GET|PUT /opt-out {out} signed in: keep yourself out of the Rundown (or come back)
++ * GET|PUT /settings signed in: "My Rundown" — the default audience for new write-ups
++ * POST /delete/take signed in: delete one take of yours
++ * POST /delete/episode signed in: delete one episode of yours (with its takes)
++ * POST /delete/all signed in: delete all my Rundown
++ * POST /restore signed in: "Write this stream up again"
* GET /voice/:user?t= a line in that streamer's chat voice (server/rundown/voice.js)
*/
const crypto = require('crypto');
@@@ -19,6 +24,8 @@ const { requireAuth, optionalAuth } = require('../auth/auth');
const permissions = require('../auth/permissions');
const db = require('../db/database');
const read = require('./read');
++const settings = require('./settings');
++const del = require('./delete');
const router = express.Router();
const fail = (res, err, msg) => {
@@@ -33,31 +40,42 @@ async function voterKey(req) {
try { salt = String(await db.getSetting('arena_vote_salt') || ''); } catch { /* */ }
return `anon:${crypto.createHash('sha256').update(`${ip}|${salt || process.env.JWT_SECRET || 'rundown'}`).digest('hex').slice(0, 24)}`;
}
++/**
++ * The reader: their voter key (what reactions are counted by) and their account (what the audience rule is counted
++ * by). The account's canonical subject is what the sharing system knows them by.
++ */
++async function viewerOf(req) {
++ const key = await voterKey(req);
++ if (!req.user || !req.user.id) return { key, userId: null, subject: null };
++ let subject = null;
++ try { subject = await require('../auth/identity-sync').subjectOf(req.user.id); } catch { subject = null; }
++ return { key, userId: req.user.id, subject: subject || null };
++}
// Pages with reactions are per reader; the rest is the same for everyone for a minute.
const fresh = (res) => res.set('Cache-Control', 'no-store');
router.get('/', optionalAuth, async (req, res) => {
-- try { fresh(res); res.json(await read.home({ viewer: await voterKey(req) })); } catch (err) { fail(res, err, 'The Rundown is unavailable'); }
++ try { fresh(res); res.json(await read.home({ viewer: await viewerOf(req) })); } catch (err) { fail(res, err, 'The Rundown is unavailable'); }
});
router.get('/streams', optionalAuth, async (req, res) => {
-- try { fresh(res); res.json(await read.streamsFeed({ before: req.query.before ? String(req.query.before) : null, viewer: await voterKey(req) })); } catch (err) { fail(res, err, 'Failed to load the streams'); }
++ try { fresh(res); res.json(await read.streamsFeed({ before: req.query.before ? String(req.query.before) : null, viewer: await viewerOf(req) })); } catch (err) { fail(res, err, 'Failed to load the streams'); }
});
--router.get('/topics', async (req, res) => {
++router.get('/topics', optionalAuth, async (req, res) => {
try {
-- res.set('Cache-Control', 'public, max-age=60');
++ fresh(res);
res.json(await read.topics({ sort: req.query.sort === 'most' ? 'most' : 'recent', kind: req.query.kind ? String(req.query.kind) : null, offset: parseInt(req.query.offset, 10) || 0 }));
} catch (err) { fail(res, err, 'Failed to load the topics'); }
});
router.get('/t/:slug', optionalAuth, async (req, res) => {
-- try { const t = await read.topic(req.params.slug, { viewer: await voterKey(req) }); if (!t) return res.status(404).json({ error: 'No such topic' }); fresh(res); res.json(t); }
++ try { const t = await read.topic(req.params.slug, { viewer: await viewerOf(req) }); if (!t) return res.status(404).json({ error: 'No such topic' }); fresh(res); res.json(t); }
catch (err) { fail(res, err, 'Failed to load the topic'); }
});
router.get('/e/:streamId', optionalAuth, async (req, res) => {
-- try { const e = await read.episode(req.params.streamId, { viewer: await voterKey(req) }); if (!e) return res.status(404).json({ error: 'No such episode' }); fresh(res); res.json(e); }
++ try { const e = await read.episode(req.params.streamId, { viewer: await viewerOf(req) }); if (!e) return res.status(404).json({ error: 'No such episode' }); fresh(res); res.json(e); }
catch (err) { fail(res, err, 'Failed to load the episode'); }
});
router.get('/s/:username', optionalAuth, async (req, res) => {
-- try { const s = await read.streamer(req.params.username, { viewer: await voterKey(req) }); if (!s) return res.status(404).json({ error: 'No such streamer' }); fresh(res); res.json(s); }
++ try { const s = await read.streamer(req.params.username, { viewer: await viewerOf(req) }); if (!s) return res.status(404).json({ error: 'No such streamer' }); fresh(res); res.json(s); }
catch (err) { fail(res, err, 'Failed to load the streamer'); }
});
router.post('/react', optionalAuth, async (req, res) => {
@@@ -73,6 +91,34 @@ router.get('/opt-out', requireAuth, async (req, res) => {
router.put('/opt-out', requireAuth, async (req, res) => {
try { res.json(await read.setOptOut(req.user.id, !!(req.body && req.body.out))); } catch (err) { fail(res, err, 'Failed'); }
});
++
++/** "My Rundown": the streamer's default audience and whether they keep themselves out. */
++router.get('/settings', requireAuth, async (req, res) => {
++ try { res.json(await settings.settingsFor(req.user.id)); } catch (err) { fail(res, err, 'Failed to load your settings'); }
++});
++router.put('/settings', requireAuth, async (req, res) => {
++ try {
++ const audience = String((req.body && req.body.default_audience) || '');
++ await settings.setDefaultAudience(req.user.id, audience);
++ res.json(await settings.settingsFor(req.user.id));
++ } catch (err) { fail(res, err, 'Failed to save your settings'); }
++});
++
++/** Deleting: the streamer's own items only, each with a receipt. */
++router.post('/delete/take', requireAuth, async (req, res) => {
++ try { res.json(await del.deleteTake((req.body || {}).id, req.user)); } catch (err) { fail(res, err, 'Failed to delete that take'); }
++});
++router.post('/delete/episode', requireAuth, async (req, res) => {
++ try { res.json(await del.deleteEpisode((req.body || {}).stream_id, req.user)); } catch (err) { fail(res, err, 'Failed to delete that episode'); }
++});
++router.post('/delete/all', requireAuth, async (req, res) => {
++ try { res.json(await del.deleteAll(req.user, { keepOut: !!(req.body && req.body.keep_out) })); } catch (err) { fail(res, err, 'Failed to delete your Rundown'); }
++});
++/** "Write this stream up again": the desk may read that stream once more. */
++router.post('/restore', requireAuth, async (req, res) => {
++ try { res.json(await del.restore((req.body || {}).stream_id, req.user)); } catch (err) { fail(res, err, 'Failed to write that stream up again'); }
++});
++
router.get('/voice/:user', optionalAuth, async (req, res) => {
try {
const voice = require('./voice');
@@@ -92,8 +138,8 @@ function arenaRedirect(path) {
const segs = String(path || '').split('/').filter(Boolean);
let seg = '';
try { seg = segs[1] ? decodeURIComponent(segs[1]) : ''; } catch { seg = ''; }
-- const fighter = segs[0] === 'arena' && segs.length === 2 && /^[A-Za-z0-9_.-]{1,64}$/.test(seg) && !/^(drama|beef|live|topic|chatter)$/i.test(seg);
-- return fighter ? `/rundown/${encodeURIComponent(seg)}` : '/rundown';
++ const fighter = segs[0] === 'arena' && segs.length === 2 && /^[A-Za-z0-9_.-]{1,64}$/.test(segs[1]) && !/^(drama|beef|live|topic|chatter)$/i.test(segs[1]);
++ return fighter ? `/rundown/${encodeURIComponent(segs[1])}` : '/rundown';
}
module.exports = router;
ddiff --git a/server/rundown/settings.js b/server/rundown/settings.js
nnew file mode 100644
iindex 00000000..87346884
---- /dev/null
++++ b/server/rundown/settings.js
@@@ -0,0 +1,45 @@
++'use strict';
++/**
++ * "My Rundown": the streamer's own settings (design 2026-11-06).
++ *
++ * Two things live here: the default audience for the write-ups made from now on (Everyone until the streamer changes it)
++ * and the whole-Rundown opt-out that already existed ("Keep me out", status `out`), which means Only me for everything
++ * and no new write-ups at all while it is on.
++ *
++ * The default applies to the episodes written up from now on. An episode that already has its own setting keeps it: the
++ * Share sheet is the streamer's decision about that episode, and a default never overrules it.
++ */
++const db = require('../db/database');
++const visibility = require('./visibility');
++
++/** The streamer's default audience: Everyone until they change it. */
++async function defaultAudience(userId) {
++ const row = await db.get('SELECT default_audience FROM rundown_prefs WHERE user_id = ?', [Number(userId)]);
++ return row && visibility.AUDIENCES.includes(row.default_audience) ? row.default_audience : visibility.DEFAULT_AUDIENCE;
++}
++
++/** Change the streamer's default audience. */
++async function setDefaultAudience(userId, audience) {
++ if (!visibility.AUDIENCES.includes(audience)) {
++ throw Object.assign(new Error('Unknown audience'), { status: 400 });
++ }
++ await db.run(`INSERT INTO rundown_prefs (user_id, default_audience, updated_at) VALUES (?, ?, ov_now())
++ ON CONFLICT (user_id) DO UPDATE SET default_audience = excluded.default_audience, updated_at = excluded.updated_at`,
++ [Number(userId), audience]);
++ return audience;
++}
++
++/** Whether the streamer keeps themselves out of The Rundown. */
++async function optedOut(userId) {
++ return !!await db.get('SELECT 1 FROM arena_opt_outs WHERE user_id = ?', [Number(userId)]);
++}
++
++/** Everything the "My Rundown" sheet shows. */
++async function settingsFor(userId) {
++ return {
++ default_audience: await defaultAudience(userId),
++ opted_out: await optedOut(userId),
++ };
++}
++
++module.exports = { defaultAudience, setDefaultAudience, optedOut, settingsFor };
ddiff --git a/server/rundown/sharing.js b/server/rundown/sharing.js
nnew file mode 100644
iindex 00000000..85a18a66
---- /dev/null
++++ b/server/rundown/sharing.js
@@@ -0,0 +1,144 @@
++'use strict';
++/**
++ * The Rundown's episodes in the sharing system (openvibe-sdk/share, share.v1), kind 'rundown-episode'.
++ *
++ * Who sees a streamer's Rundown is one setting, not four, and it is the sharing system's own setting: the same one the
++ * Share sheet offers everywhere on the network (Everyone / Anyone with the link / Specific people / Only me). An
++ * episode registers itself when the desk writes it up, with the streamer's default audience, and the sheet changes it
++ * afterwards. A segment follows its episode: the pages read the episode's share row, never a second setting.
++ *
++ * The SDK module is optional at runtime: Live must boot and serve its other pages on a release whose pinned
++ * openvibe-sdk predates `share`. Without it, only Everyone items are published and nobody can open a private episode —
++ * fail closed. The tables (share_*) are created by Live's migration 0027 from the SDK's own expand-only schema; this
++ * module never creates one at runtime.
++ */
++const db = require('../db/database');
++
++const SERVICE = 'live';
++const KIND = 'rundown-episode';
++
++let store = null;
++let loaded = false;
++let networkOverride = null;
++
++/** Tests: answer Network's name lookups locally. */
++function _setNetwork(client) { networkOverride = client || null; store = undefined; loaded = false; }
++
++/** Events go through Live's own outbox, inside the sharing transaction (the SDK outbox refuses otherwise). */
++const events = {
++ async publish(envelope) {
++ try {
++ const outbox = require('../events/stream-events');
++ outbox.init();
++ if (await outbox.enqueue(envelope)) outbox.kick();
++ } catch (err) {
++ // The sharing transaction must not fail because the outbox is unhappy: the setting is the streamer's, not
++ // the outbox's.
++ console.warn(`[Rundown] share event not queued: ${err.message}`);
++ }
++ },
++};
++
++function sdk() {
++ if (loaded) return store;
++ loaded = true;
++ let mod;
++ try { mod = require('openvibe-sdk/share'); } catch { mod = null; }
++ if (!mod || typeof mod.createShares !== 'function') return (store = null);
++ try {
++ store = mod.createShares({
++ db: db.getDb(),
++ network: networkOverride || require('../net/network-json').client(),
++ events,
++ // Nobody is told about a Rundown episode yet: the streamer shares it deliberately, and the pages
++ // themselves are the notice.
++ notify: async () => {},
++ });
++ } catch (err) {
++ console.warn(`[Rundown] sharing unavailable: ${err.message}`);
++ store = null;
++ }
++ return store;
++}
++
++/** Whether the sharing system is installed in this release. */
++function available() { try { return !!sdk(); } catch { return false; } }
++
++/**
++ * Register an episode the desk has just written up, with the streamer's default audience. An episode that is already
++ * registered keeps the audience its owner chose: the default is a default, not a setting that overrules the sheet.
++ */
++async function register(streamId, ownerSubject, audience = 'public') {
++ const shares = sdk();
++ if (!shares || !ownerSubject) return false;
++ const id = String(streamId);
++ try {
++ const row = await db.get("SELECT audience FROM shares WHERE service = ? AND kind = ? AND resource_id = ?", [SERVICE, KIND, id]);
++ if (row) return false;
++ await shares.register({ service: SERVICE, kind: KIND, id }, {
++ owner_subject: { type: 'user', id: ownerSubject },
++ audience: ['public', 'link', 'people', 'private'].includes(audience) ? audience : 'public',
++ });
++ return true;
++ } catch (err) {
++ console.warn(`[Rundown] share registration for ${id} failed: ${err.message}`);
++ return false;
++ }
++}
++
++/** The episode's own setting, read straight from the share row (the pages read it for every item). */
++async function audienceOf(streamId) {
++ const row = await db.get('SELECT audience FROM shares WHERE service = ? AND kind = ? AND resource_id = ?', [SERVICE, KIND, String(streamId)]);
++ return row ? row.audience : null;
++}
++
++/** The people an episode is shared with, as subject ids. */
++async function memberSubjects(streamId) {
++ const rows = await db.all(`SELECT subject->>'id' AS id FROM share_members
++ WHERE service = ? AND kind = ? AND resource_id = ? AND (expires_at IS NULL OR expires_at > NOW())`, [SERVICE, KIND, String(streamId)]);
++ return rows.map((r) => r.id).filter(Boolean);
++}
++
++/**
++ * May this reader open the episode? "Anyone with the link" is the episode's own address, so anybody may; "Specific
++ * people" is the people on the list; "Only me" is the streamer.
++ */
++function maySee(eff, { ownerUserId = null, viewer = null } = {}) {
++ if (!eff) return false;
++ if (eff.audience === 'public' || eff.audience === 'link') return true;
++ if (!viewer) return false;
++ if (ownerUserId != null && viewer.userId != null && Number(viewer.userId) === Number(ownerUserId)) return true;
++ if (eff.audience === 'people' && viewer.subject && (eff.members || []).includes(viewer.subject)) return true;
++ return false;
++}
++
++/** The Share sheet's description of an episode: its title and where it lives. */
++async function describe(ref) {
++ const id = Number(ref && ref.id);
++ if (!Number.isSafeInteger(id) || id <= 0) return {};
++ const ep = await db.get('SELECT title FROM rundown_episodes WHERE stream_id = ?', [id]);
++ let base = 'https://openvibe.live';
++ try { base = String(require('../config').baseUrl || base).replace(/\/+$/, ''); } catch { /* */ }
++ return {
++ title: ep && ep.title ? ep.title : 'Episode',
++ link: `${base}/rundown/e/${id}`,
++ roles: ['viewer'],
++ transferable: false,
++ };
++}
++
++/** The share router, mounted at /api/v1 (the Share sheet's access part). Null when the SDK has no sharing. */
++function router(options) {
++ const shares = sdk();
++ if (!shares) return null;
++ return shares.router({ service: SERVICE, ...options });
++}
++
++/** Send the sharing system's queued deliveries. Called by the housekeeping loop, never inline. */
++async function flushDeliveries() {
++ const shares = sdk();
++ if (!shares) return 0;
++ return await shares.flush();
++}
++
++module.exports = { SERVICE, KIND, available, register, audienceOf, memberSubjects, maySee, describe, router, flushDeliveries, _setNetwork, _reset() { store = undefined; loaded = false; networkOverride = null; } };
ddiff --git a/server/rundown/visibility.js b/server/rundown/visibility.js
nnew file mode 100644
iindex 00000000..678ec6b0
---- /dev/null
++++ b/server/rundown/visibility.js
@@@ -0,0 +1,175 @@
++'use strict';
++/**
++ * Who sees The Rundown (design 2026-11-06, plan-state-2026-10-22/RUNDOWN-CONTROLS-AND-BROWSE-DESIGN-2026-11-06.md).
++ *
++ * There is one setting, not four: Everyone, Anyone with the link, Specific people or Only me. Those are the sharing
++ * system's own audiences (openvibe-sdk/share, share.v1), so an episode's own setting lives in the share row of kind
++ * 'rundown-episode' (server/rundown/sharing.js) and the Share sheet is the only place it is set.
++ *
++ * The recording decides first. An episode's audience is the stricter of its own setting and its VOD's visibility:
++ * Only me, then Specific people, then Anyone with the link, then Everyone. The rule is applied where the pages read it,
++ * so a VOD that turns stricter is followed at once (the episode, its segments and the after-show report) and a VOD that
++ * becomes public again gives the episode back its own setting, never a wider one.
++ *
++ * Media's own vocabulary (public, unlisted, private) is mapped onto the four audiences. Media has no "specific people"
++ * visibility of its own, so the people on a VOD are the ones Live knows about; the people of both sides are the ones on
++ * both lists.
++ */
++const db = require('../db/database');
++
++/** The four audiences, in the sharing system's own words. */
++const AUDIENCES = ['public', 'link', 'people', 'private'];
++/** How strict each one is: the lower the number, the stricter. The stricter of two wins. */
++const STRICTNESS = { private: 0, people: 1, link: 2, public: 3 };
++/** Nobody chose anything yet: Everyone. */
++const DEFAULT_AUDIENCE = 'public';
++/** Media's visibility vocabulary, mapped onto the four audiences. */
++const MEDIA_AUDIENCE = { public: 'public', unlisted: 'link', private: 'private' };
++/** And back again: what the episode page says when it follows the recording ("Following the VOD: unlisted"). */
++const MEDIA_WORD = { public: 'public', link: 'unlisted', private: 'private' };
++/** The chip a streamer sees on their own non-public items. */
++const LABELS = { public: 'Everyone', link: 'Link', private: 'Only me' };
++
++/** The stricter of two audiences. An unknown one is treated as Everyone (the old default). */
++function stricter(a, b) {
++ const x = STRICTNESS[a], y = STRICTNESS[b];
++ if (x === undefined && y === undefined) return DEFAULT_AUDIENCE;
++ if (x === undefined) return b;
++ if (y === undefined) return a;
++ return x <= y ? a : b;
++}
++
++/** Media's visibility as one of the four audiences. */
++function mediaAudience(visibility) {
++ return MEDIA_AUDIENCE[String(visibility || '').trim().toLowerCase()] || DEFAULT_AUDIENCE;
++}
++
++/** What the recording is called where the pages speak about it. */
++function mediaWord(audience) {
++ return MEDIA_WORD[audience] || 'public';
++}
++
++/** The chip: "Everyone", "Link", "3 people", "Only me". */
++function label(audience, memberCount = 0) {
++ if (audience === 'people') return memberCount > 1 ? `${memberCount} people` : 'Specific people';
++ return LABELS[audience] || LABELS[DEFAULT_AUDIENCE];
++}
++
++/** The episode's own setting: the share row's audience. No row yet means Everyone. */
++async function ownAudience(streamId) {
++ try {
++ const row = await db.get("SELECT audience FROM shares WHERE service = 'live' AND kind = 'rundown-episode' AND resource_id = ?", [String(streamId)]);
++ return row && AUDIENCES.includes(row.audience) ? row.audience : DEFAULT_AUDIENCE;
++ } catch { return DEFAULT_AUDIENCE; }
++}
++
++/** What the recording decides, as the last thing Live heard about it. */
++async function vodAudience(streamId) {
++ try {
++ const row = await db.get('SELECT vod_audience FROM rundown_visibility WHERE stream_id = ?', [Number(streamId)]);
++ return row && AUDIENCES.includes(row.vod_audience) ? row.vod_audience : DEFAULT_AUDIENCE;
++ } catch { return DEFAULT_AUDIENCE; }
++}
++
++/** The people the recording is shared with (the other side of "the people on both lists"). */
++async function vodMembers(streamId) {
++ try {
++ const row = await db.get('SELECT vod_members FROM rundown_visibility WHERE stream_id = ?', [Number(streamId)]);
++ const list = row && row.vod_members ? (typeof row.vod_members === 'string' ? JSON.parse(row.vod_members) : row.vod_members) : [];
++ return (Array.isArray(list) ? list : []).map((m) => String(m && m.id ? m.id : m)).filter(Boolean);
++ } catch { return []; }
++}
++
++/** The people an episode is shared with (the share row's members), as subject ids. */
++async function memberSubjects(streamId) {
++ try {
++ const rows = await db.all(`SELECT subject->>'id' AS id FROM share_members
++ WHERE service = 'live' AND kind = 'rundown-episode' AND resource_id = ? AND (expires_at IS NULL OR expires_at > NOW())`, [String(streamId)]);
++ return rows.map((r) => r.id).filter(Boolean);
++ } catch { return []; }
++}
++
++/** What the episode page says: the effective audience and where it came from. */
++async function effective(streamId) {
++ const own = await ownAudience(streamId);
++ const vod = await vodAudience(streamId);
++ let audience = stricter(own, vod);
++ let members = audience === 'people' ? await memberSubjects(streamId) : [];
++ if (own === 'people' && vod === 'people') {
++ // Specific people on both sides: the people on both lists.
++ const vodList = await vodMembers(streamId);
++ members = members.filter((id) => vodList.includes(id));
++ if (!members.length) audience = 'private'; // nobody but the streamer: never wider than the lists say
++ }
++ return { own, vod, audience, members, following_vod: audience !== own, member_count: members.length };
++}
++
++/**
++ * Record what the recording decides. Called in the same request as a visibility change made through Live's own pages
++ * (server/media-proxy/vods.js) and when Media's own event arrives (server/media-proxy/outcomes.js).
++ */
++async function noteVodVisibility(streamId, mediaVisibility) {
++ const id = Number(streamId);
++ if (!Number.isSafeInteger(id) || id <= 0) return null;
++ const audience = mediaAudience(mediaVisibility);
++ await db.run(`INSERT INTO rundown_visibility (stream_id, vod_audience, updated_at) VALUES (?, ?, ov_now())
++ ON CONFLICT (stream_id) DO UPDATE SET vod_audience = excluded.vod_audience, updated_at = excluded.updated_at`, [id, audience]);
++ return audience;
++}
++
++/**
++ * A recording changed its visibility: the write-up of that stream follows at once. Called from Live's own pages
++ * (server/media-proxy/vods.js) and from Media's event (server/media-proxy/outcomes.js).
++ */
++async function noteVodChange(vodId, streamId, mediaVisibility) {
++ let sid = Number(streamId);
++ if (!Number.isSafeInteger(sid) || sid <= 0) {
++ const row = await db.get('SELECT stream_id FROM rundown_episodes WHERE vod_id = ?', [Number(vodId)]);
++ sid = row ? Number(row.stream_id) : null;
++ }
++ if (!Number.isSafeInteger(sid) || sid <= 0) return null;
++ forget(sid);
++ return await noteVodVisibility(sid, mediaVisibility);
++}
++
++/** The recording of a stream, when there is one. */
++async function vodIdFor(streamId) {
++ const row = await db.get('SELECT vod_id FROM rundown_episodes WHERE stream_id = ? AND vod_id IS NOT NULL', [Number(streamId)])
++ || await db.get('SELECT vod_id FROM rundown_segments WHERE stream_id = ? AND vod_id IS NOT NULL LIMIT 1', [Number(streamId)]);
++ return row && row.vod_id ? Number(row.vod_id) : null;
++}
++
++const RECHECK_MS = 60 * 1000;
++const _rechecked = new Map(); // stream id → { at, audience }
++
++/**
++ * Ask Media what the recording is now, until its event says the same thing (design: "until the event lands, the episode
++ * page re-checks the VOD's visibility through media-client when it is opened, cached 60 s").
++ */
++async function recheck(streamId) {
++ const id = Number(streamId);
++ if (!Number.isSafeInteger(id) || id <= 0) return null;
++ const hit = _rechecked.get(id);
++ if (hit && Date.now() - hit.at < RECHECK_MS) return hit.audience;
++ const vodId = await vodIdFor(id);
++ if (!vodId) return null;
++ let audience = null;
++ try {
++ const vod = await require('../media-client').getVod(vodId);
++ if (vod && vod.visibility) audience = mediaAudience(vod.visibility);
++ } catch { /* Media unreachable: the last thing we heard stays */ }
++ if (!audience) return null;
++ _rechecked.set(id, { at: Date.now(), audience });
++ await noteVodVisibility(id, audience);
++ return audience;
++}
++
++/** Forget the cached re-check (a visibility change Live applied itself). */
++function forget(streamId) { _rechecked.delete(Number(streamId)); }
++
++module.exports = {
++ AUDIENCES, DEFAULT_AUDIENCE, LABELS,
++ stricter, mediaAudience, mediaWord, label,
++ ownAudience, vodAudience, vodMembers, memberSubjects, effective,
++ noteVodVisibility, noteVodChange, vodIdFor, recheck, forget,
++};
ddiff --git a/test/recap-audience.test.js b/test/recap-audience.test.js
nnew file mode 100644
iindex 00000000..4b1c56a5
---- /dev/null
++++ b/test/recap-audience.test.js
@@@ -0,0 +1,51 @@
++'use strict';
++/**
++ * An after-show report follows its stream's Rundown audience (RUNDOWN-CONTROLS design 2026-11-06): a stream whose
++ * recording is private has no public report, the channel's report list leaves it out, and a public one still answers.
++ */
++const assert = require('assert');
++const http = require('http');
++const express = require('express');
++const db = require('../server/db/database');
++
++(async () => {
++ await db.initDb();
++ const visibility = require('../server/rundown/visibility');
++ const recap = require('../server/recap/recap');
++ // The report itself is not under test: a stand-in answers for any stream the gate lets through.
++ const served = [];
++ recap.getRecap = async (id) => { served.push(id); return { stream_id: id, streamer: { id: 0 }, headline: 'x' }; };
++ recap.listRecaps = async () => [{ stream_id: privateId }, { stream_id: publicId }];
++
++ await db.createUser({ username: 'recapper', email: 'recapper@x', password_hash: 'x', display_name: 'recapper', stream_key: 'recapper'.padEnd(32, '0') });
++ const owner = Number((await db.get('SELECT id FROM users WHERE username = ?', ['recapper'])).id);
++ const mkStream = async (title) => {
++ const id = Number((await db.createStream({ user_id: owner, title, category: 'Just Chatting', protocol: 'rtmp' })).lastInsertRowid);
++ await db.run("UPDATE streams SET is_live = 0, duration_seconds = 7200 WHERE id = ?", [id]);
++ await db.run('INSERT INTO rundown_episodes (stream_id, user_id, title, vod_id) VALUES (?, ?, ?, ?)', [id, owner, title, id + 9000]).catch(() => {});
++ return id;
++ };
++ var privateId = await mkStream('Private one');
++ var publicId = await mkStream('Public one');
++ await visibility.noteVodVisibility(privateId, 'private');
++ await visibility.noteVodVisibility(publicId, 'public');
++
++ const app = express();
++ app.use('/api/recap', require('../server/recap/routes'));
++ const server = await new Promise((ok) => { const s = http.createServer(app).listen(0, '127.0.0.1', () => ok(s)); });
++ const base = `http://127.0.0.1:${server.address().port}`;
++ try {
++ const hidden = await fetch(`${base}/api/recap/${privateId}`);
++ assert.strictEqual(hidden.status, 404, 'a private recording has no public report');
++ assert.ok(!served.includes(privateId), 'the report is not even read');
++ const shown = await fetch(`${base}/api/recap/${publicId}`);
++ assert.strictEqual(shown.status, 200, 'a public one still answers');
++ assert.match(shown.headers.get('cache-control') || '', /public/);
++ const list = await (await fetch(`${base}/api/recap/channel/recapper`)).json();
++ assert.deepStrictEqual(list.recaps.map((r) => r.stream_id), [publicId], 'the list leaves the private one out');
++ console.log('recap audience: a private recording has no public report, and lists leave it out');
++ } finally {
++ server.close();
++ }
++ process.exit(0);
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/rundown-audiences.test.js b/test/rundown-audiences.test.js
nnew file mode 100644
iindex 00000000..cae08727
---- /dev/null
++++ b/test/rundown-audiences.test.js
@@@ -0,0 +1,194 @@
++'use strict';
++/**
++ * Who sees The Rundown (design 2026-11-06, plan-state-2026-10-22/RUNDOWN-CONTROLS-AND-BROWSE-DESIGN-2026-11-06.md):
++ * the four audiences and who may see each one
++ * the gathered pages and their counts show Everyone only
++ * a non-public take never reaches another streamer's page
++ * the recording decides first: unlisting the VOD unlists the episode, making it public again gives the episode back
++ * its own setting (never a wider one), Specific people on both sides keeps the people on both lists
++ * deleting: a take, an episode and all my Rundown remove exactly their rows; topics recount; an empty topic goes;
++ * the VOD stays; another person's items cannot be deleted
++ */
++const assert = require('assert');
++const db = require('../server/db/database');
++
++(async () => {
++await db.initDb();
++const read = require('../server/rundown/read');
++const sharing = require('../server/rundown/sharing');
++const visibility = require('../server/rundown/visibility');
++const del = require('../server/rundown/delete');
++
++const SUB_GOOSE = 'usr_01HQZX9K3M5N7P8R9T0V1W2XYZ';
++const SUB_WATCH = 'usr_01HQZX9K3M5N7P8R9T0V1W2XZA';
++const SUB_OTHER = 'usr_01HQZX9K3M5N7P8R9T0V1W2XZB';
++
++const mk = async (username) => {
++ await db.createUser({ username, email: `${username}@x`, password_hash: 'x', display_name: username, stream_key: username.padEnd(32, '0') });
++ const id = Number((await db.get('SELECT id FROM users WHERE username = ?', [username])).id);
++ await db.ensureChannel(id);
++ return id;
++};
++const goose = await mk('goosely');
++const watcher = await mk('watcher');
++
++const NOW = new Date().toISOString().slice(0, 19).replace('T', ' ');
++const streamId = Number((await db.createStream({ user_id: goose, title: 'Fridges', category: 'Science & Technology', protocol: 'rtmp' })).lastInsertRowid);
++await db.run("UPDATE streams SET is_live = 0, started_at = datetime('now', '-180 minutes'), ended_at = datetime('now', '-60 minutes'), duration_seconds = 7200 WHERE id = ?", [streamId]);
++await db.run("INSERT INTO rundown_topics (slug, title, kind, first_seen_at, last_seen_at, takes, streamers) VALUES ('fridges', 'Fridges', 'tech', ?, ?, 1, 1)", [NOW, NOW]);
++const topicId = Number((await db.get('SELECT id FROM rundown_topics WHERE slug = ?', ['fridges'])).id);
++await db.run("INSERT INTO rundown_segments (stream_id, user_id, topic_id, start_sec, end_sec, title, what, score, quotes_json, mentions, vod_id, said_at) VALUES (?, ?, ?, 0, 30, 'French fridges', 'goosely talked about fridges', 5, '[]', ?, 4242, ?)", [streamId, goose, topicId, [watcher], NOW]);
++await db.run("INSERT INTO rundown_episodes (stream_id, user_id, title, logline, vod_id, minutes, aired_at) VALUES (?, ?, 'The Fridge Files', 'Cold takes.', 4242, 120, ?)", [streamId, goose, NOW]);
++const segId = Number((await db.get('SELECT id FROM rundown_segments WHERE stream_id = ?', [streamId])).id);
++
++// The Share sheet changes the audience of an episode that is registered (the SDK row is what the pages read).
++const setAudience = async (a) => { await db.run("UPDATE shares SET audience = ? WHERE service = 'live' AND kind = 'rundown-episode' AND resource_id = ?", [a, String(streamId)]); };
++
++// ── Everyone: the gathered pages and their counts ──
++{
++ const home = await read.home({ viewer: null });
++ assert.ok(home.counts.takes >= 1, 'the take is counted while it is Everyone');
++ assert.ok(home.counts.episodes >= 1, 'the episode is counted while it is Everyone');
++ const feed = await read.streamsFeed({ viewer: null });
++ assert.ok(feed.streams.some((s) => s.stream_id === streamId), 'the stream is on the front page');
++ const topics = await read.topics({});
++ assert.ok(topics.topics.some((t) => t.slug === 'fridges' && t.takes >= 1), 'the topic is there with its count');
++ assert.ok(await read.topic('fridges', { viewer: null }), 'the topic page opens');
++ assert.ok(await read.episode(streamId, { viewer: null }), 'the episode page opens for anybody');
++}
++
++// ── Only me: the streamer still sees it, nobody else does ──
++await sharing.register(streamId, SUB_GOOSE, 'private');
++{
++ const home = await read.home({ viewer: null });
++ assert.strictEqual(home.counts.takes, 0, 'a take that is not Everyone is not counted');
++ assert.strictEqual(home.counts.episodes, 0, 'an episode that is not Everyone is not counted');
++ assert.ok(!home.streams.some((s) => s.stream_id === streamId), 'the stream is off the front page');
++ const topics = await read.topics({});
++ assert.ok(!topics.topics.some((t) => t.slug === 'fridges'), 'a topic whose takes are all non-public disappears');
++ assert.strictEqual(await read.topic('fridges', { viewer: null }), null, 'the topic page is gone for everybody else');
++ assert.strictEqual(await read.episode(streamId, { viewer: null }), null, 'the episode page is not there for anybody else');
++ const mine = await read.home({ viewer: { key: `user:${goose}`, userId: goose, subject: SUB_GOOSE } });
++ assert.strictEqual(mine.counts.takes, 1, 'the streamer sees their own take');
++ const myFeed = await read.streamsFeed({ viewer: { key: `user:${goose}`, userId: goose, subject: SUB_GOOSE } });
++ assert.ok(myFeed.streams.some((s) => s.stream_id === streamId), 'the streamer sees their own stream');
++ const ep = await read.episode(streamId, { viewer: { key: `user:${goose}`, userId: goose, subject: SUB_GOOSE } });
++ assert.ok(ep && ep.episode, 'the streamer opens their own episode');
++ assert.strictEqual(ep.episode.audience, 'private', 'the episode knows its audience');
++ assert.strictEqual(ep.episode.audience_chip, 'Only me', 'the chip says who may see it');
++ const page = await read.streamer('goosely', { viewer: { key: `user:${goose}`, userId: goose, subject: SUB_GOOSE } });
++ assert.ok(page.episodes.some((e) => e.stream_id === streamId), 'the streamer page shows their own episode');
++ assert.strictEqual(page.episodes.find((e) => e.stream_id === streamId).audience_chip, 'Only me', 'with the chip');
++}
++
++// ── A take that names another streamer never reaches their page ──
++{
++ const theirs = await read.streamer('watcher', { viewer: { key: `user:${watcher}`, userId: watcher, subject: SUB_WATCH } });
++ assert.ok(!theirs.crossovers.some((c) => c.id === segId), 'a non-public take never appears on the page of the streamer it names');
++ const mine = await read.streamer('goosely', { viewer: { key: `user:${goose}`, userId: goose, subject: SUB_GOOSE } });
++ assert.ok(mine.crossovers.some((c) => c.id === segId), 'the streamer sees their own take that names somebody');
++}
++
++// ── Specific people: the people on the list, and nobody else ──
++await setAudience('people');
++await db.run("INSERT INTO share_members (service, kind, resource_id, subject, role, added_by, added_at) VALUES ('live', 'rundown-episode', ?, ?::jsonb, 'viewer', ?::jsonb, NOW())",
++ [String(streamId), JSON.stringify({ type: 'user', id: SUB_WATCH }), JSON.stringify({ type: 'user', id: SUB_GOOSE })]);
++{
++ const seen = await read.episode(streamId, { viewer: { key: `user:${watcher}`, userId: watcher, subject: SUB_WATCH } });
++ assert.ok(seen && seen.episode, 'a person on the list opens the episode');
++ const stranger = await read.episode(streamId, { viewer: { key: `anon:stranger`, userId: null, subject: SUB_OTHER } });
++ assert.strictEqual(stranger, null, 'a person who is not on the list does not');
++ const home = await read.home({ viewer: null });
++ assert.strictEqual(home.counts.takes, 0, 'a take for specific people is not counted on the front page');
++}
++
++// ── The recording decides first ──
++await setAudience('public');
++await visibility.noteVodVisibility(streamId, 'unlisted');
++{
++ const eff = await visibility.effective(streamId);
++ assert.strictEqual(eff.own, 'public', 'the episode keeps its own setting');
++ assert.strictEqual(eff.vod, 'link', 'an unlisted recording is Anyone with the link');
++ assert.strictEqual(eff.audience, 'link', 'the recording decides: the episode is not Everybody any more');
++ const ep = await read.episode(streamId, { viewer: { key: `user:${goose}`, userId: goose, subject: SUB_GOOSE } });
++ assert.strictEqual(ep.episode.following_vod, true, 'the episode says it follows the recording');
++ assert.strictEqual(ep.episode.following_vod_label, 'unlisted', 'with the recording\'s own word');
++ const home = await read.home({ viewer: null });
++ assert.strictEqual(home.counts.takes, 0, 'an episode that is not Everyone is off the front page');
++ assert.ok(await read.episode(streamId, { viewer: null }), 'but anybody with the link still opens it');
++}
++// Unlisting the recording makes the episode only-me for everybody: nobody but the streamer.
++await visibility.noteVodVisibility(streamId, 'private');
++{
++ const eff = await visibility.effective(streamId);
++ assert.strictEqual(eff.audience, 'private', 'a private recording makes the episode only me');
++ assert.strictEqual(await read.episode(streamId, { viewer: null }), null, 'and nobody opens it');
++}
++// A recording that becomes public again gives the episode back its own setting, never a wider one.
++await visibility.noteVodVisibility(streamId, 'public');
++{
++ const eff = await visibility.effective(streamId);
++ assert.strictEqual(eff.own, 'public', 'the episode still has its own setting');
++ assert.strictEqual(eff.audience, 'public', 'the episode goes back to its own setting');
++}
++// Never wider than the episode's own setting.
++await setAudience('link');
++{
++ const eff = await visibility.effective(streamId);
++ assert.strictEqual(eff.audience, 'link', 'a public recording never makes the episode wider than its own setting');
++}
++
++// ── Specific people on both sides: the people on both lists ──
++await setAudience('people');
++await db.run("DELETE FROM share_members WHERE service = 'live' AND kind = 'rundown-episode' AND resource_id = ?", [String(streamId)]);
++for (const s of [SUB_WATCH, SUB_OTHER]) {
++ await db.run("INSERT INTO share_members (service, kind, resource_id, subject, role, added_by, added_at) VALUES ('live', 'rundown-episode', ?, ?::jsonb, 'viewer', ?::jsonb, NOW())",
++ [String(streamId), JSON.stringify({ type: 'user', id: s }), JSON.stringify({ type: 'user', id: SUB_GOOSE })]);
++}
++await db.run('UPDATE rundown_visibility SET vod_audience = ? WHERE stream_id = ?', ['people', streamId]);
++await db.run('UPDATE rundown_visibility SET vod_members = ?::jsonb WHERE stream_id = ?', [JSON.stringify([{ id: SUB_WATCH }]), streamId]);
++{
++ const eff = await visibility.effective(streamId);
++ assert.strictEqual(eff.audience, 'people', 'both sides say specific people');
++ assert.deepStrictEqual(eff.members, [SUB_WATCH], 'the people on both lists');
++ assert.ok(await read.episode(streamId, { viewer: { key: `user:${watcher}`, userId: watcher, subject: SUB_WATCH } }), 'the person on both lists opens it');
++ assert.strictEqual(await read.episode(streamId, { viewer: { key: 'anon:x', userId: null, subject: SUB_OTHER } }), null, 'the person on only one list does not');
++}
++
++// ── Deleting ──
++await setAudience('public');
++{
++ // A topic with two takes recounts when one of them goes.
++ await db.run("INSERT INTO rundown_topics (slug, title, kind, first_seen_at, last_seen_at, takes, streamers) VALUES ('ranked', 'Ranked', 'games', ?, ?, 2, 1)", [NOW, NOW]);
++ const topic2 = Number((await db.get('SELECT id FROM rundown_topics WHERE slug = ?', ['ranked'])).id);
++ await db.run("INSERT INTO rundown_segments (stream_id, user_id, topic_id, start_sec, end_sec, title, what, score, quotes_json, mentions, vod_id, said_at) VALUES (?, ?, ?, 60, 90, 'Ranked one', 'goosely talked about ranked', 3, '[]', '{}', 4242, ?)", [streamId, goose, topic2, NOW]);
++ await db.run("INSERT INTO rundown_segments (stream_id, user_id, topic_id, start_sec, end_sec, title, what, score, quotes_json, mentions, vod_id, said_at) VALUES (?, ?, ?, 90, 120, 'Ranked two', 'goosely talked about ranked again', 3, '[]', '{}', 4242, ?)", [streamId, goose, topic2, NOW]);
++ const segA = Number((await db.get('SELECT id FROM rundown_segments WHERE topic_id = ? ORDER BY start_sec ASC LIMIT 1', [topic2])).id);
++ await del.deleteTake(segA, { id: goose });
++ assert.strictEqual(Number((await db.get('SELECT takes FROM rundown_topics WHERE id = ?', [topic2])).takes), 1, 'the topic recounted the take that went');
++ assert.ok(await db.get('SELECT id FROM rundown_topics WHERE id = ?', [topic2]), 'a topic with a take left stays');
++ // A take.
++ const before = Number((await db.get('SELECT takes FROM rundown_topics WHERE id = ?', [topicId])).takes);
++ assert.strictEqual(before, 1, 'the topic counted the take');
++ const r1 = await del.deleteTake(segId, { id: goose });
++ assert.strictEqual(r1.takes, 1, 'one take deleted');
++ assert.ok(!(await db.get('SELECT id FROM rundown_segments WHERE id = ?', [segId])), 'the take is gone');
++ assert.ok(!(await db.get('SELECT id FROM rundown_topics WHERE id = ?', [topicId])), 'the topic recounted and, left with nothing, was removed');
++ // Somebody else cannot delete it.
++ let refused = false;
++ try { await del.deleteEpisode(streamId, { id: watcher }); } catch (e) { refused = e.status === 403; }
++ assert.ok(refused, 'another person\'s episode is not deleted');
++ assert.ok(await db.get('SELECT stream_id FROM rundown_episodes WHERE stream_id = ?', [streamId]), 'the episode is still there');
++ // The episode.
++ const r2 = await del.deleteEpisode(streamId, { id: goose });
++ assert.strictEqual(r2.episodes, 1, 'one episode deleted');
++ assert.ok(!(await db.get('SELECT stream_id FROM rundown_episodes WHERE stream_id = ?', [streamId])), 'the episode is gone');
++ assert.ok(!(await db.get("SELECT audience FROM shares WHERE service = 'live' AND kind = 'rundown-episode' AND resource_id = ?", [String(streamId)])), 'the sharing row went with it');
++ assert.ok(await db.get('SELECT id FROM streams WHERE id = ?', [streamId]), 'the stream stays');
++ assert.ok(await db.get('SELECT stream_id FROM rundown_deleted WHERE stream_id = ?', [streamId]), 'the desk will not write it up again');
++ const r3 = await del.restore(streamId, { id: goose });
++ assert.strictEqual(r3.restored, true, 'the streamer can ask for it to be written up again');
++}
++
++console.log('✅ rundown: audiences, the recording\'s rule, deleting');
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 15815e76..63a7647e 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -335,6 +335,7 @@ async function check(name, fn) {
// URLs in these files go through egress (noted).
const REVIEWED = {
'server/net/egress.js': 'the guard itself',
++ 'server/net/network-json.js': 'OpenVibe.Network internal identity API for the sharing system (configured OV_NETWORK_INTERNAL_URL, fixed paths, no URL from a person)',
'server/drill.js': 'the restore-drill guard (refuses connections)',
'server/report.js': 'OpenVibe.Report (OV_REPORT_INTERNAL_URL, configured) for the report of an agent stream, and the Network token endpoint (OV_NETWORK_INTERNAL_URL, configured) for the Live service token; the paths are fixed, no URL from a person',
'server/config.js': 'Network registry (configured)',
ddiff --git a/test/subscribe-media-events.test.js b/test/subscribe-media-events.test.js
iindex ebaa36d4..ff5c7388 100644
---- a/test/subscribe-media-events.test.js
++++ b/test/subscribe-media-events.test.js
@@@ -48,7 +48,8 @@ const logs = () => { const lines = []; const log = (s) => lines.push(String(s));
const noSecret = (lines) => { for (const l of lines) for (const s of [SECRET, CLIENT_SECRET, 'tok-live']) assert.ok(!l.includes(s), `printed a secret: ${l}`); };
(async () => {
-- assert.deepStrictEqual(TOPICS, ['media.vod.*', 'media.clip.*', 'media.storage.*']);
++ // media.object.* carries Media's visibility changes (media.object.visibility_changed): the Rundown follows them.
++ assert.deepStrictEqual(TOPICS, ['media.vod.*', 'media.clip.*', 'media.storage.*', 'media.object.*']);
assert.strictEqual(DEFAULT_ENDPOINT, 'http://127.0.0.1:3000/internal/media-events');
// Create: one per topic, Live's secret, the media-events endpoint; one already there is reported.
@@@ -56,11 +57,12 @@ const noSecret = (lines) => { for (const l of lines) for (const s of [SECRET, CL
{ id: 'sub_openre', topic_pattern: 'openre.session.*', endpoint: 'http://127.0.0.1:3000/internal/openre-events', enabled: true }] });
let log = logs();
let r = await run({ env: ENV, fetchImpl: s.fetchImpl, log });
-- assert.deepStrictEqual(r.subscriptions.map(x => [x.topic, x.created]), [['media.vod.*', true], ['media.clip.*', false], ['media.storage.*', true]]);
++ assert.deepStrictEqual(r.subscriptions.map(x => [x.topic, x.created]), [['media.vod.*', true], ['media.clip.*', false], ['media.storage.*', true], ['media.object.*', true]]);
const posts = s.calls.filter((c) => c.method === 'POST' && c.url.endsWith('/api/v1/subscriptions')).map(c => JSON.parse(c.body));
assert.deepStrictEqual(posts, [
{ topic_pattern: 'media.vod.*', endpoint: DEFAULT_ENDPOINT, secret: SECRET },
{ topic_pattern: 'media.storage.*', endpoint: DEFAULT_ENDPOINT, secret: SECRET },
++ { topic_pattern: 'media.object.*', endpoint: DEFAULT_ENDPOINT, secret: SECRET },
]);
noSecret(log.lines);
@@@ -76,7 +78,7 @@ const noSecret = (lines) => { for (const l of lines) for (const s of [SECRET, CL
r = await run({ action: 'list', env: ENV, fetchImpl: s2.fetchImpl, log });
assert.ok(r.subscriptions.every(x => !x.existed));
assert.ok(!s2.calls.some(c => c.method === 'POST' && !c.url.endsWith('/oauth/token')));
-- assert.strictEqual(log.lines.filter(l => /would create/.test(l)).length, 3);
++ assert.strictEqual(log.lines.filter(l => /would create/.test(l)).length, 4);
// --disable / --enable (rollback and undo).
r = await run({ action: 'disable', env: ENV, fetchImpl: s.fetchImpl, log: logs() });