openvibers/openvibe.live Commits
Rundown: apply the default audience to the streamer's existing episodes (#813)
Changes
@@@ -50,7 +50,7 @@ Speech-to-text hears a stream's whole audio track. On 2026-10-18 the Arena's "quA 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 |@@@ -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]');@@@ -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'); }});@@@ -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 };@@@ -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; } };nnew file mode 100644@@@ -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); });
@@ -50,7 +50,7 @@ Speech-to-text hears a stream's whole audio track. On 2026-10-18 the Arena's "qu
@@ -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`).
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)).
- **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.
- **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.
- **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").
- **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:
@@ -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 | `/react {type, id, reaction}` | segment or episode; fire, lol, dead, cap, facts; the same again takes it back |
| POST | `/hide {type, id}` | staff |
| POST | `/hide {type, id}` | staff |
| GET, PUT | `/opt-out {out}` | signed in: keep yourself out (or come back) |
| 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/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/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 |
| POST | `/delete/all {keep_out}` | signed in: delete all your Rundown, optionally keeping yourself out from now on |
@@ -390,8 +390,10 @@ function _rdMineBox(st) {
@@ -390,8 +390,10 @@ function _rdMineBox(st) {
<h2 id="rd-mine-h" class="rd-h2">Your Rundown</h2>
<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>
<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>
<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>
</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>
<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>
<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>
<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) {
@@ -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>`;
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'); }
} 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.
// The default for new write-ups saves as soon as it is picked.
document.addEventListener('change', async (e) => {
document.addEventListener('change', async (e) => {
const radio = e.target.closest && e.target.closest('[data-rd-default-aud]');
const radio = e.target.closest && e.target.closest('[data-rd-default-aud]');
@@ -126,9 +126,13 @@ router.get('/settings', requireAuth, async (req, res) => {
@@ -126,9 +126,13 @@ router.get('/settings', requireAuth, async (req, res) => {
});
});
router.put('/settings', requireAuth, async (req, res) => {
router.put('/settings', requireAuth, async (req, res) => {
try {
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);
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'); }
} catch (err) { fail(res, err, 'Failed to save your settings'); }
});
});
@@ -11,6 +11,7 @@
@@ -11,6 +11,7 @@
*/
*/
const db = require('../db/database');
const db = require('../db/database');
const visibility = require('./visibility');
const visibility = require('./visibility');
+const sharing = require('./sharing');
/** The streamer's default audience: Everyone until they change it. */
/** The streamer's default audience: Everyone until they change it. */
async function defaultAudience(userId) {
async function defaultAudience(userId) {
@@ -29,6 +30,15 @@ async function setDefaultAudience(userId, audience) {
@@ -29,6 +30,15 @@ async function setDefaultAudience(userId, audience) {
return 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. */
/** Whether the streamer keeps themselves out of The Rundown. */
async function optedOut(userId) {
async function optedOut(userId) {
return !!await db.get('SELECT 1 FROM arena_opt_outs WHERE user_id = ?', [Number(userId)]);
return !!await db.get('SELECT 1 FROM arena_opt_outs WHERE user_id = ?', [Number(userId)]);
@@ -42,4 +52,4 @@ async function settingsFor(userId) {
@@ -42,4 +52,4 @@ async function settingsFor(userId) {
};
};
}
}
-module.exports = { defaultAudience, setDefaultAudience, optedOut, settingsFor };
+module.exports = { defaultAudience, setDefaultAudience, applyToEpisodes, optedOut, settingsFor };
@@ -86,6 +86,26 @@ async function register(streamId, ownerSubject, audience = 'public') {
@@ -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). */
/** The episode's own setting, read straight from the share row (the pages read it for every item). */
async function audienceOf(streamId) {
async function audienceOf(streamId) {
const row = await db.get('SELECT audience FROM shares WHERE service = ? AND kind = ? AND resource_id = ?', [SERVICE, KIND, String(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() {
@@ -141,4 +161,4 @@ async function flushDeliveries() {
return await shares.flush();
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; } };
new file mode 100644
new file mode 100644
@@ -0,0 +1,54 @@
@@ -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); });