openvibers/openvibe.live Commits

Rundown: apply the default audience to the streamer's existing episodes (#813)

OP OpenVibers committed 2 days ago · b53f25050a · 6 files, +118 −7

Changes

ddiff --git a/docs/rundown.md b/docs/rundown.md
iindex 843f7779..5d349146 100644
---- a/docs/rundown.md
++++ b/docs/rundown.md
@@@ -50,7 +50,7 @@ Speech-to-text hears a stream's whole audio track. On 2026-10-18 the Arena's "qu
A streamer decides who sees their Rundown with the same Share sheet the rest of the network uses: **Everyone**, **Anyone with the link**, **Specific people**, **Only me** (`server/rundown/visibility.js`, `server/rundown/sharing.js`).
- **One setting per episode, not per take.** An episode is registered in the sharing system when the desk writes it up (`kind rundown-episode`, service `live`, the stream id as its resource), and its Share sheet changes it afterwards. **A segment follows its episode:** the pages read the episode's setting, never a second one. The share row goes when the episode is deleted (see [Deleting](#deleting)).
--- **The default** is in **Your Rundown**, the box at the top of the streamer's own Rundown page (`/rundown/<username>#mine`; the line under the Rundown's title links to it when signed in) (`GET|PUT /api/rundown/settings`, `server/rundown/settings.js`): Everyone until the streamer changes it. It applies to the write-ups made from then on. An episode that already has its own setting keeps it; a default never overrules the sheet.
++- **The default** is in **Your Rundown**, the box at the top of the streamer's own Rundown page (`/rundown/<username>#mine`; the line under the Rundown's title links to it when signed in) (`GET|PUT /api/rundown/settings`, `server/rundown/settings.js`): Everyone until the streamer changes it. It applies to the write-ups made from then on. **Apply…** beside it (one confirmation) moves every live episode the streamer already has to the chosen audience, through the sharing system's own update; an episode that already has its own setting keeps it until they ask for that. Specific people is never applied in bulk: it needs a list per episode, and the Share sheet stays the place for it.
- **Each episode has its own Share sheet.** That is the only place its setting is changed (`/api/v1/<kind>/<id>/share`, the network's own share API, `server/rundown/sharing.js` and `shareRouterFor` in `server/index.js`); there is no second Rundown-only control. On their own episode the streamer sees **Who sees it** (and, when the recording is stricter, that the episode follows it and where to change that), **Share** and **Delete episode**; the episode and streamer reads answer `mine`, so these never wait for the page's signed-in state. An episode written up before the sharing system had no share row, so its owner's first visit registers it as Everyone, which it already was.
- **Keep me out** stays and is stronger: it means Only me for everything and no new write-ups at all while it is on. Coming back restores the write-ups, never anything staff took down.
- **The streamer sees all their own items everywhere**, each non-public one marked with a small chip ("Only me", "Link", "3 people").
@@@ -151,7 +151,7 @@ Example response:
| POST | `/react {type, id, reaction}` | segment or episode; fire, lol, dead, cap, facts; the same again takes it back |
| POST | `/hide {type, id}` | staff |
| GET, PUT | `/opt-out {out}` | signed in: keep yourself out (or come back) |
--| GET, PUT | `/settings` | signed in: "Your Rundown": the default audience for new write-ups, and whether you keep yourself out |
++| GET, PUT | `/settings` | signed in: "Your Rundown": the default audience for new write-ups (`apply: true` also moves the existing live episodes), and whether you keep yourself out |
| POST | `/delete/take {id}` | signed in: delete one of your takes |
| POST | `/delete/episode {stream_id}` | signed in: delete one of your episodes with its takes |
| POST | `/delete/all {keep_out}` | signed in: delete all your Rundown, optionally keeping yourself out from now on |
ddiff --git a/public/js/rundown.js b/public/js/rundown.js
iindex 6a29a8e9..70eba01d 100644
---- a/public/js/rundown.js
++++ b/public/js/rundown.js
@@@ -390,8 +390,10 @@ function _rdMineBox(st) {
<h2 id="rd-mine-h" class="rd-h2">Your Rundown</h2>
<fieldset class="rd-aud-pick"${st.opted_out ? ' disabled' : ''}><legend>Who sees what the desk writes up next</legend>
<div class="rd-aud-opts">${radios}</div>
-- <p class="rd-quiet">Episodes already written keep their own setting: change one with Share on its page.</p>
++ <p class="rd-quiet">Episodes already written keep their own setting until you apply this one to them; change one with Share on its page.</p>
</fieldset>
++ <div class="rd-mine-row"><div><p class="rd-mine-t" id="rd-apply-l">Apply it to episodes already written</p><p class="rd-quiet">Changes who sees every live episode you have. Specific people is chosen per episode.</p></div>
++ <button type="button" class="rd-own-btn" aria-labelledby="rd-apply-l" data-rd-apply-aud${st.opted_out ? ' disabled' : ''}>Apply…</button></div>
<div class="rd-mine-row"><div><p class="rd-mine-t" id="rd-out-l">Keep me out of The Rundown</p><p class="rd-quiet">${st.opted_out ? 'On: nothing about your streams is shown, and the desk does not read them.' : 'Hides everything written about your streams and stops the desk reading them. You can come back any time.'}</p></div>
<button type="button" class="rd-switch" role="switch" aria-checked="${st.opted_out ? 'true' : 'false'}" aria-labelledby="rd-out-l" data-rd-optout="${st.opted_out ? '0' : '1'}"></button></div>
<div class="rd-mine-row"><div><p class="rd-mine-t">Delete my Rundown</p><p class="rd-quiet">Every episode and take written about your streams, for everyone. Your recordings stay.</p></div>
@@@ -491,6 +493,27 @@ async function _rdRestore(btn) {
if (p) p.innerHTML = `<span class="rd-own-icon"><i class="fa-solid fa-pen-nib" aria-hidden="true"></i></span><h1 class="rd-h2">The desk will write it up again</h1><p>It reads the stream once more and the episode comes back here when it is done, with your default audience.</p>`;
} catch (err) { btn.disabled = false; _rdToast(err.message || 'That did not work', 'error'); }
}
++// "Apply to all my existing episodes" (VISIBILITY-EVERYWHERE rule 2): confirm, then the same setting.
++document.addEventListener('click', async (e) => {
++ const btn = e.target.closest && e.target.closest('[data-rd-apply-aud]');
++ if (!btn) return;
++ const radio = document.querySelector('[data-rd-default-aud]:checked');
++ if (!radio) return;
++ const words = (RD_AUD[radio.value] || RD_AUD.public)[0];
++ const answer = await _rdConfirm({
++ title: `Apply "${words}" to every episode?`,
++ body: `Every live episode already written about your streams changes to ${words}. Specific people stays a choice on each episode's Share sheet.`,
++ confirm: 'Apply to all',
++ });
++ if (!answer.ok) return;
++ btn.disabled = true;
++ try {
++ const st = await api('/rundown/settings', { method: 'PUT', body: { default_audience: radio.value, apply: true } });
++ _rdToast(`Applied to ${Number(st.applied) || 0} episode${Number(st.applied) === 1 ? '' : 's'}`, 'success');
++ _rdReload();
++ } catch (err) { btn.disabled = false; _rdToast(err.message || 'That did not work', 'error'); }
++});
++
// The default for new write-ups saves as soon as it is picked.
document.addEventListener('change', async (e) => {
const radio = e.target.closest && e.target.closest('[data-rd-default-aud]');
ddiff --git a/server/rundown/routes.js b/server/rundown/routes.js
iindex 44b7fb57..8de95cdc 100644
---- a/server/rundown/routes.js
++++ b/server/rundown/routes.js
@@@ -126,9 +126,13 @@ router.get('/settings', requireAuth, async (req, res) => {
});
router.put('/settings', requireAuth, async (req, res) => {
try {
-- const audience = String((req.body && req.body.default_audience) || '');
++ const b = req.body || {};
++ const audience = String(b.default_audience || '');
await settings.setDefaultAudience(req.user.id, audience);
-- res.json(await settings.settingsFor(req.user.id));
++ // "Apply to all my existing episodes" (VISIBILITY-EVERYWHERE rule 2): the same request, so the
++ // default and what it was applied to can never disagree.
++ const applied = b.apply ? await settings.applyToEpisodes(req.user.id, req.user.subject, audience) : 0;
++ res.json({ ...(await settings.settingsFor(req.user.id)), applied });
} catch (err) { fail(res, err, 'Failed to save your settings'); }
});
ddiff --git a/server/rundown/settings.js b/server/rundown/settings.js
iindex 87346884..0447f7bd 100644
---- a/server/rundown/settings.js
++++ b/server/rundown/settings.js
@@@ -11,6 +11,7 @@
*/
const db = require('../db/database');
const visibility = require('./visibility');
++const sharing = require('./sharing');
/** The streamer's default audience: Everyone until they change it. */
async function defaultAudience(userId) {
@@@ -29,6 +30,15 @@ async function setDefaultAudience(userId, audience) {
return audience;
}
++/**
++ * Rule 2: apply the default to the episodes the streamer already has. Only live episodes: a hidden one
++ * is staff-hidden and an `out` one is the streamer's whole-Rundown opt-out, which this must not undo.
++ */
++async function applyToEpisodes(userId, ownerSubject, audience) {
++ const rows = await db.all("SELECT stream_id FROM rundown_episodes WHERE user_id = ? AND status = 'live' ORDER BY stream_id", [Number(userId)]);
++ return await sharing.applyAudience(ownerSubject, rows.map((r) => Number(r.stream_id)), 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)]);
@@@ -42,4 +52,4 @@ async function settingsFor(userId) {
};
}
--module.exports = { defaultAudience, setDefaultAudience, optedOut, settingsFor };
++module.exports = { defaultAudience, setDefaultAudience, applyToEpisodes, optedOut, settingsFor };
ddiff --git a/server/rundown/sharing.js b/server/rundown/sharing.js
iindex 85a18a66..61e98a71 100644
---- a/server/rundown/sharing.js
++++ b/server/rundown/sharing.js
@@@ -86,6 +86,26 @@ async function register(streamId, ownerSubject, audience = 'public') {
}
}
++/**
++ * Apply one audience to the streamer's existing episodes (VISIBILITY-EVERYWHERE rule 2, "My Rundown").
++ * Each episode goes through the SDK's own update, so the share row, the log and the pages move together.
++ * Specific people needs a list per episode, so it is not applied in bulk; the sheet remains the place
++ * for that. Only the episodes the caller owns are passed in by settings.applyToEpisodes.
++ */
++async function applyAudience(ownerSubject, streamIds, audience) {
++ const shares = sdk();
++ if (!shares || !ownerSubject || !streamIds || !streamIds.length) return 0;
++ if (!['public', 'link', 'private'].includes(audience)) {
++ throw Object.assign(new Error('Specific people is chosen per episode'), { status: 400 });
++ }
++ let changed = 0;
++ for (const streamId of streamIds) {
++ await shares.update({ subject: { type: 'user', id: ownerSubject } }, { service: SERVICE, kind: KIND, id: String(streamId) }, { audience, members: [] });
++ changed++;
++ }
++ return changed;
++}
++
/** 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)]);
@@@ -141,4 +161,4 @@ async function flushDeliveries() {
return await shares.flush();
}
--module.exports = { SERVICE, KIND, available, register, audienceOf, memberSubjects, maySee, describe, router, flushDeliveries, _setNetwork, _reset() { store = undefined; loaded = false; networkOverride = null; } };
++module.exports = { SERVICE, KIND, available, register, applyAudience, audienceOf, memberSubjects, maySee, describe, router, flushDeliveries, _setNetwork, _reset() { store = undefined; loaded = false; networkOverride = null; } };
ddiff --git a/test/rundown-settings-apply.test.js b/test/rundown-settings-apply.test.js
nnew file mode 100644
iindex 00000000..c653194c
---- /dev/null
++++ b/test/rundown-settings-apply.test.js
@@@ -0,0 +1,54 @@
++'use strict';
++/**
++ * "Apply to all my existing episodes" (VISIBILITY-EVERYWHERE rule 2, My Rundown): the default moves the
++ * streamer's live episodes, and only theirs; Specific people stays a per-episode choice.
++ */
++const assert = require('assert');
++const db = require('../server/db/database');
++
++(async () => {
++ await db.initDb();
++ const settings = require('../server/rundown/settings');
++ const sharing = require('../server/rundown/sharing');
++
++ const SUB_GOOSE = 'usr_01HQZX9K3M5N7P8R9T0V1W2XYZ';
++ 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 other = await mk('watcher');
++ const NOW = new Date().toISOString().slice(0, 19).replace('T', ' ');
++ const stream = async (user, title) => {
++ const id = Number((await db.createStream({ user_id: user, title, category: 'Science & Technology', protocol: 'rtmp' })).lastInsertRowid);
++ await db.run('UPDATE streams SET is_live = 0, started_at = ?, ended_at = ? WHERE id = ?', [NOW, NOW, id]);
++ await db.run("INSERT INTO rundown_episodes (stream_id, user_id, title, aired_at, status) VALUES (?, ?, ?, ?, 'live')", [id, user, title, NOW]);
++ return id;
++ };
++ const a = await stream(goose, 'Episode A');
++ const b = await stream(goose, 'Episode B');
++ const theirs = await stream(other, 'Not mine');
++ assert.strictEqual(await sharing.register(a, SUB_GOOSE, 'public'), true);
++ assert.strictEqual(await sharing.register(b, SUB_GOOSE, 'public'), true);
++ assert.strictEqual(await sharing.register(theirs, 'usr_01HQZX9K3M5N7P8R9T0V1W2XZC', 'public'), true);
++ const aud = async (id) => (await db.get("SELECT audience FROM shares WHERE service = 'live' AND kind = 'rundown-episode' AND resource_id = ?", [String(id)])).audience;
++
++ const applied = await settings.applyToEpisodes(goose, SUB_GOOSE, 'private');
++ assert.strictEqual(applied, 2, 'both of Goose’s live episodes');
++ assert.strictEqual(await aud(a), 'private');
++ assert.strictEqual(await aud(b), 'private');
++ assert.strictEqual(await aud(theirs), 'public', 'somebody else’s episode is untouched');
++
++ const back = await settings.applyToEpisodes(goose, SUB_GOOSE, 'link');
++ assert.strictEqual(back, 2);
++ assert.strictEqual(await aud(a), 'link');
++ assert.strictEqual(await aud(b), 'link');
++ assert.strictEqual(await aud(theirs), 'public');
++
++ await assert.rejects(() => settings.applyToEpisodes(goose, SUB_GOOSE, 'people'), (err) => err.status === 400);
++ assert.strictEqual(await aud(a), 'link', 'a refused apply changes nothing');
++ console.log('\nall passed');
++ process.exit(0);
++})().catch((e) => { console.error(e); process.exit(1); });