openvibers/openvibe.live Commits
The Rundown: audiences, the recording rule, deleting (#634)
Changes
nnew file mode 100644@@@ -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()++);nnew file mode 100644@@@ -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); });@@@ -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';@@@ -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). */@@@ -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,@@@ -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.@@@ -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++; }}nnew file mode 100644@@@ -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 };@@@ -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' });nnew file mode 100644@@@ -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 };@@@ -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 SETtakes = (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_secFROM streams s LEFT JOIN rundown_progress p ON p.stream_id = s.idWHERE 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) DESCLIMIT 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 alreadyreturn { 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(); } } };@@@ -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_idWHERE 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 };@@@ -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;nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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; } };nnew file mode 100644@@@ -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,++};nnew file mode 100644@@@ -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); });nnew file mode 100644@@@ -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); });@@@ -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)',@@@ -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, CLr = 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() });
new file mode 100644
new file mode 100644
@@ -0,0 +1,38 @@
@@ -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()
+);
new file mode 100644
new file mode 100644
@@ -0,0 +1,44 @@
@@ -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); });
@@ -34,7 +34,7 @@
@@ -34,7 +34,7 @@
*/
*/
const fs = require('fs');
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 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_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';
const NETWORK_ENDPOINT = 'http://127.0.0.1:3000/internal/network-events';
@@ -34,26 +34,7 @@ function _setNetwork(client) {
@@ -34,26 +34,7 @@ function _setNetwork(client) {
/** Network's internal JSON client, with Live's service token for each call. */
/** Network's internal JSON client, with Live's service token for each call. */
function networkClient() {
function networkClient() {
if (networkOverride) return networkOverride;
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). */
/** Events go through Live's own outbox, inside the sharing transaction (the SDK outbox refuses otherwise). */
@@ -831,6 +831,9 @@ function shareRouterFor(req, res, next) {
@@ -831,6 +831,9 @@ function shareRouterFor(req, res, next) {
return { subject: { type: 'user', id: subject }, staff: ['global_mod', 'admin'].includes(request.user.role) };
return { subject: { type: 'user', id: subject }, staff: ['global_mod', 'admin'].includes(request.user.role) };
},
},
describe: async (ref) => {
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);
const stream = await require('./agent-streams/service').getByPublicId(ref.id);
return stream ? {
return stream ? {
title: stream.title,
title: stream.title,
@@ -26,6 +26,8 @@ const EVENT_TYPES = {
@@ -26,6 +26,8 @@ const EVENT_TYPES = {
'clip.failed': 'media.clip.failed',
'clip.failed': 'media.clip.failed',
'storage.alert': 'media.storage.alert',
'storage.alert': 'media.storage.alert',
'storage.recovered': 'media.storage.recovered',
'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]));
const OUTCOME_NAMES = Object.fromEntries(Object.entries(EVENT_TYPES).map(([k, v]) => [v, k]));
@@ -48,6 +50,14 @@ function subjectOf(event, data) {
@@ -48,6 +50,14 @@ function subjectOf(event, data) {
return { type, id: String(id == null ? 'unknown' : id) };
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) {
function dedupeKey(eventId, subject) {
return `media:${subject.type}:${subject.id}:${eventId}`;
return `media:${subject.type}:${subject.id}:${eventId}`;
}
}
@@ -169,6 +179,16 @@ async function apply(event, data) {
@@ -169,6 +179,16 @@ async function apply(event, data) {
case 'clip.failed':
case 'clip.failed':
if (data.id) { try { await db.setClipTranscriptStatus(data.id, 'failed', data.error || 'media reported failure'); } catch { /* */ } }
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)') };
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.alert':
case 'storage.recovered':
case 'storage.recovered':
// Media's VOD volume needs a human (drain stalled / disk critical) or is fine again.
// Media's VOD volume needs a human (drain stalled / disk critical) or is fine again.
@@ -400,7 +400,7 @@ router.post('/bulk-delete-old', requireAuth, async (req, res) => {
@@ -400,7 +400,7 @@ router.post('/bulk-delete-old', requireAuth, async (req, res) => {
for (const v of (out?.vods || [])) {
for (const v of (out?.vods || [])) {
if (!tooOld(v) || v.is_recording) continue;
if (!tooOld(v) || v.is_recording) continue;
if (action === 'delete') { if (await media.deleteVod(v.id).then(() => true, () => false)) await purge.afterDelete('vod', v.id); }
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++;
vodCount++;
}
}
}
}
@@ -689,6 +689,8 @@ router.put('/:id', requireAuth, async (req, res) => {
@@ -689,6 +689,8 @@ router.put('/:id', requireAuth, async (req, res) => {
else if (is_public !== undefined) fields.visibility = is_public ? 'public' : 'private';
else if (is_public !== undefined) fields.visibility = is_public ? 'public' : 'private';
if (nextLabels !== undefined) fields.labels = nextLabels;
if (nextLabels !== undefined) fields.labels = nextLabels;
await media.updateVod(req.params.id, fields);
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 })) });
res.json({ vod: await media.getVod(req.params.id).catch(() => ({ ...vod, ...fields })) });
} catch (err) {
} catch (err) {
mediaErr(res, err, 'Failed to update VOD');
mediaErr(res, err, 'Failed to update VOD');
@@ -713,7 +715,7 @@ router.post('/bulk', requireAuth, async (req, res) => {
@@ -713,7 +715,7 @@ router.post('/bulk', requireAuth, async (req, res) => {
if (!owns) { skipped++; continue; }
if (!owns) { skipped++; continue; }
try {
try {
if (action === 'delete') { await media.deleteVod(id); await purge.afterDelete('vod', id); }
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++;
done++;
} catch { skipped++; }
} catch { skipped++; }
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,39 @@
@@ -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 };
@@ -12,11 +12,35 @@ const { can } = require('../auth/permissions');
@@ -12,11 +12,35 @@ const { can } = require('../auth/permissions');
const recap = require('./recap');
const recap = require('./recap');
const { requireAuth, optionalAuth } = require('../auth/auth');
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 || '')]);
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' });
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) => {
router.get('/:streamId', optionalAuth, async (req, res) => {
@@ -26,6 +50,9 @@ router.get('/:streamId', optionalAuth, async (req, res) => {
@@ -26,6 +50,9 @@ router.get('/:streamId', optionalAuth, async (req, res) => {
const stream = await db.getStreamById(id);
const stream = await db.getStreamById(id);
if (!stream) return res.status(404).json({ error: 'Stream not found' });
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 });
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);
let r = await recap.getRecap(id);
if (!r) {
if (!r) {
const dur = Number(stream.duration_seconds) || 0;
const dur = Number(stream.duration_seconds) || 0;
@@ -33,8 +60,12 @@ router.get('/:streamId', optionalAuth, async (req, res) => {
@@ -33,8 +60,12 @@ router.get('/:streamId', optionalAuth, async (req, res) => {
r = await recap.buildRecap(id);
r = await recap.buildRecap(id);
}
}
if (!r) return res.status(404).json({ error: 'No report for this stream' });
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) {
} catch (err) {
console.error('[Recap] route:', err.message);
console.error('[Recap] route:', err.message);
res.status(500).json({ error: 'Failed to build the report' });
res.status(500).json({ error: 'Failed to build the report' });
new file mode 100644
new file mode 100644
@@ -0,0 +1,115 @@
@@ -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 };
@@ -114,6 +114,19 @@ async function topicFor(title, kind, at) {
@@ -114,6 +114,19 @@ async function topicFor(title, kind, at) {
}
}
return row;
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) {
async function recountTopic(topicId) {
await db.run(`UPDATE rundown_topics SET
await db.run(`UPDATE rundown_topics SET
takes = (SELECT COUNT(*) FROM rundown_segments WHERE topic_id = ? AND status = 'live'),
takes = (SELECT COUNT(*) FROM rundown_segments WHERE topic_id = ? AND status = 'live'),
@@ -163,6 +176,7 @@ async function candidates() {
@@ -163,6 +176,7 @@ async function candidates() {
COALESCE(p.read_to_sec, -1) AS read_to_sec
COALESCE(p.read_to_sec, -1) AS read_to_sec
FROM streams s LEFT JOIN rundown_progress p ON p.stream_id = s.id
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 >= ?)
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')
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
ORDER BY s.is_live DESC, COALESCE(s.ended_at, s.started_at) DESC
LIMIT 60`, [daysAgo(BACKFILL_DAYS), daysAgo(BACKFILL_DAYS)]);
LIMIT 60`, [daysAgo(BACKFILL_DAYS), daysAgo(BACKFILL_DAYS)]);
@@ -317,6 +331,7 @@ async function keepSegments(stream, stretch, segments, user, others = []) {
@@ -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,
[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]);
score, JSON.stringify(quotes), await mentionsIn(spanText, user.id), vod, at]);
if (topic) await recountTopic(topic.id);
if (topic) await recountTopic(topic.id);
+ await ensureShare(stream.id, user.id);
kept++;
kept++;
}
}
return kept;
return kept;
@@ -380,6 +395,7 @@ async function writeEpisode(stream, user) {
@@ -380,6 +395,7 @@ async function writeEpisode(stream, user) {
chapters_json = excluded.chapters_json, vod_id = excluded.vod_id, minutes = excluded.minutes`,
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())]);
[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 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
if (!rewrite) await announce(stream, user, title).catch(() => {}); // written again (a re-read): announced once already
return { called: true, episode: true };
return { called: true, episode: true };
}
}
@@ -462,4 +478,4 @@ function start() {
@@ -462,4 +478,4 @@ function start() {
}
}
function stop() { if (typeof _job === 'function') _job(); _job = null; }
function stop() { if (typeof _job === 'function') _job(); _job = null; }
-module.exports = { start, stop, tick, readStretch, writeEpisode, refreshTopic, keepSegments, topicFor, nextStretch, ready, optedOut, 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(); } } };
@@ -7,12 +7,19 @@
@@ -7,12 +7,19 @@
* streamsFeed({ before, viewer }) stream by stream: each recent stream with its episode and its best takes, newest first
* streamsFeed({ before, viewer }) stream by stream: each recent stream with its episode and its best takes, newest first
* topics({ sort, kind }) every topic
* topics({ sort, kind }) every topic
* topic(slug) one topic and every take on it
* 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
* 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)
* 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 db = require('../db/database');
const config = require('../config');
const config = require('../config');
+const visibility = require('./visibility');
+const sharing = require('./sharing');
const REACTIONS = ['fire', 'lol', 'dead', 'cap', 'facts'];
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
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) {
@@ -34,6 +41,24 @@ function absolute(url) {
}
}
const isNsfw = (row) => !!(Number(row && row.is_nsfw) || Number(row && row.channel_force_nsfw));
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
* 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
* 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) {
@@ -89,44 +114,90 @@ async function reactionsFor(type, ids, voter) {
return { counts, mine };
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. */
/** Segments as the pages show them, with their people, topics and reactions resolved in three queries. */
async function segmentsOut(rows, voter) {
async function segmentsOut(rows, voter) {
+ if (!rows.length) return [];
const ppl = await people(rows.flatMap((r) => [r.user_id, ...(r.mentions || [])]));
const ppl = await people(rows.flatMap((r) => [r.user_id, ...(r.mentions || [])]));
const tps = await topicsById(rows.map((r) => r.topic_id));
const tps = await topicsById(rows.map((r) => r.topic_id));
const rx = await reactionsFor('segment', rows.map((r) => r.id), voter);
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
// 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).
// broadcaster reconnected) starts later than the stream (streaming/stream-history.js).
const history = require('../streaming/stream-history');
const history = require('../streaming/stream-history');
const offsets = await history.vodOffsets(rows.map((r) => r.vod_id));
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)));
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) {
async function episodesOut(rows, voter) {
+ if (!rows.length) return [];
const ppl = await people(rows.map((r) => r.user_id));
const ppl = await people(rows.map((r) => r.user_id));
const rx = await reactionsFor('episode', rows.map((r) => r.stream_id), voter);
const rx = await reactionsFor('episode', rows.map((r) => r.stream_id), voter);
const ids = rows.map((r) => Number(r.stream_id));
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
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]) : [];
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();
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); }
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() {
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,
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() {
@@ -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);
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) {
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,
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
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)));
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,
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) }));
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) {
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
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 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
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();
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); }
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);
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) {
@@ -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. */
/** The lead: the best take of the last day (else the week), a quoted one first. */
async function lead({ viewer = null } = {}) {
async function lead({ viewer = null } = {}) {
+ const v = who(viewer);
for (const days of [1, 7]) {
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;
return null;
}
}
@@ -178,13 +256,15 @@ async function lead({ viewer = null } = {}) {
@@ -178,13 +256,15 @@ async function lead({ viewer = null } = {}) {
* time of a stream's latest take.
* time of a stream's latest take.
*/
*/
async function streamsFeed({ before = null, limit = STREAMS_PAGE, viewer = null, skip = null } = {}) {
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 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));
const ids = groups.map((g) => Number(g.stream_id));
if (!ids.length) return { streams: [], next: null };
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();
const best = new Map();
for (const s of segs) {
for (const s of segs) {
const l = best.get(Number(s.stream_id)) || [];
const l = best.get(Number(s.stream_id)) || [];
@@ -193,12 +273,13 @@ async function streamsFeed({ before = null, limit = STREAMS_PAGE, viewer = null,
@@ -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 chosen = [...best.values()].flat().sort((a, b) => Number(a.start_sec) - Number(b.start_sec));
const takes = new Map();
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,
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
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 ppl = await people([...streams.values()].map((s) => s.user_id));
const thumbs = await thumbnailsFor([...streams.values()]);
const thumbs = await thumbnailsFor([...streams.values()]);
const out = groups.map((g) => {
const out = groups.map((g) => {
@@ -212,10 +293,13 @@ async function streamsFeed({ before = null, limit = STREAMS_PAGE, viewer = null,
@@ -212,10 +293,13 @@ async function streamsFeed({ before = null, limit = STREAMS_PAGE, viewer = null,
}
}
async function home({ viewer = null } = {}) {
async function home({ viewer = null } = {}) {
+ const v = who(viewer);
const top = await lead({ 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 [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,
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).
// 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,
latest: [], next: null,
@@ -223,54 +307,90 @@ async function home({ viewer = null } = {}) {
@@ -223,54 +307,90 @@ async function home({ viewer = null } = {}) {
}
}
const KINDS = ['news', 'games', 'tech', 'life', 'music', 'sports', 'money', 'internet', 'chat', 'other'];
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 } = {}) {
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 order = sort === 'most' ? 'streamers DESC, takes DESC, last_seen_at DESC' : 'last_seen_at DESC';
const k = KINDS.includes(kind) ? kind : null;
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());
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),
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 };
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 } = {}) {
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)]);
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;
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
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 } = {}) {
async function episode(streamId, { viewer = null } = {}) {
const id = parseInt(streamId, 10);
const id = parseInt(streamId, 10);
if (!(id > 0)) return null;
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]);
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;
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]);
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 } = {}) {
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)]);
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;
if (!u) return null;
const out = !!await db.get('SELECT 1 FROM arena_opt_outs WHERE user_id = ?', [u.id]);
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: [] };
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
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 } */
/** One reaction per reader per item; the same reaction again takes it back. → { reactions, mine } */
@@ -311,4 +431,4 @@ async function setOptOut(userId, out) {
@@ -311,4 +431,4 @@ async function setOptOut(userId, out) {
return { opted_out: !!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 };
@@ -11,6 +11,11 @@
@@ -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 /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
* 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 /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)
* GET /voice/:user?t= a line in that streamer's chat voice (server/rundown/voice.js)
*/
*/
const crypto = require('crypto');
const crypto = require('crypto');
@@ -19,6 +24,8 @@ const { requireAuth, optionalAuth } = require('../auth/auth');
@@ -19,6 +24,8 @@ const { requireAuth, optionalAuth } = require('../auth/auth');
const permissions = require('../auth/permissions');
const permissions = require('../auth/permissions');
const db = require('../db/database');
const db = require('../db/database');
const read = require('./read');
const read = require('./read');
+const settings = require('./settings');
+const del = require('./delete');
const router = express.Router();
const router = express.Router();
const fail = (res, err, msg) => {
const fail = (res, err, msg) => {
@@ -33,31 +40,42 @@ async function voterKey(req) {
@@ -33,31 +40,42 @@ async function voterKey(req) {
try { salt = String(await db.getSetting('arena_vote_salt') || ''); } catch { /* */ }
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)}`;
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.
// 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');
const fresh = (res) => res.set('Cache-Control', 'no-store');
router.get('/', optionalAuth, async (req, res) => {
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) => {
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 {
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 }));
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'); }
} catch (err) { fail(res, err, 'Failed to load the topics'); }
});
});
router.get('/t/:slug', optionalAuth, async (req, res) => {
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'); }
catch (err) { fail(res, err, 'Failed to load the topic'); }
});
});
router.get('/e/:streamId', optionalAuth, async (req, res) => {
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'); }
catch (err) { fail(res, err, 'Failed to load the episode'); }
});
});
router.get('/s/:username', optionalAuth, async (req, res) => {
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'); }
catch (err) { fail(res, err, 'Failed to load the streamer'); }
});
});
router.post('/react', optionalAuth, async (req, res) => {
router.post('/react', optionalAuth, async (req, res) => {
@@ -73,6 +91,34 @@ router.get('/opt-out', requireAuth, async (req, res) => {
@@ -73,6 +91,34 @@ router.get('/opt-out', requireAuth, async (req, res) => {
router.put('/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'); }
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) => {
router.get('/voice/:user', optionalAuth, async (req, res) => {
try {
try {
const voice = require('./voice');
const voice = require('./voice');
@@ -92,8 +138,8 @@ function arenaRedirect(path) {
@@ -92,8 +138,8 @@ function arenaRedirect(path) {
const segs = String(path || '').split('/').filter(Boolean);
const segs = String(path || '').split('/').filter(Boolean);
let seg = '';
let seg = '';
try { seg = segs[1] ? decodeURIComponent(segs[1]) : ''; } catch { 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;
module.exports = router;
new file mode 100644
new file mode 100644
@@ -0,0 +1,45 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,144 @@
@@ -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; } };
new file mode 100644
new file mode 100644
@@ -0,0 +1,175 @@
@@ -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,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,51 @@
@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,194 @@
@@ -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); });
@@ -335,6 +335,7 @@ async function check(name, fn) {
@@ -335,6 +335,7 @@ async function check(name, fn) {
// URLs in these files go through egress (noted).
// URLs in these files go through egress (noted).
const REVIEWED = {
const REVIEWED = {
'server/net/egress.js': 'the guard itself',
'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/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/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)',
'server/config.js': 'Network registry (configured)',
@@ -48,7 +48,8 @@ const logs = () => { const lines = []; const log = (s) => lines.push(String(s));
@@ -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}`); };
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 () => {
(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');
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.
// 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
@@ -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 }] });
{ id: 'sub_openre', topic_pattern: 'openre.session.*', endpoint: 'http://127.0.0.1:3000/internal/openre-events', enabled: true }] });
let log = logs();
let log = logs();
let r = await run({ env: ENV, fetchImpl: s.fetchImpl, log });
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));
const posts = s.calls.filter((c) => c.method === 'POST' && c.url.endsWith('/api/v1/subscriptions')).map(c => JSON.parse(c.body));
assert.deepStrictEqual(posts, [
assert.deepStrictEqual(posts, [
{ topic_pattern: 'media.vod.*', endpoint: DEFAULT_ENDPOINT, secret: SECRET },
{ topic_pattern: 'media.vod.*', endpoint: DEFAULT_ENDPOINT, secret: SECRET },
{ topic_pattern: 'media.storage.*', 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);
noSecret(log.lines);
@@ -76,7 +78,7 @@ const noSecret = (lines) => { for (const l of lines) for (const s of [SECRET, CL
@@ -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 });
r = await run({ action: 'list', env: ENV, fetchImpl: s2.fetchImpl, log });
assert.ok(r.subscriptions.every(x => !x.existed));
assert.ok(r.subscriptions.every(x => !x.existed));
assert.ok(!s2.calls.some(c => c.method === 'POST' && !c.url.endsWith('/oauth/token')));
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).
// --disable / --enable (rollback and undo).
r = await run({ action: 'disable', env: ENV, fetchImpl: s.fetchImpl, log: logs() });
r = await run({ action: 'disable', env: ENV, fetchImpl: s.fetchImpl, log: logs() });