openvibers/openvibe.live Commits
Rundown: follow a recording shared with specific people (#641)
Changes
@@@ -186,7 +186,7 @@ async function apply(event, data) {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);++ await require('../rundown/visibility').noteVodChange(vodId, data.stream_id || null, data.audience || data.visibility, data.members);return { outcome: 'object_visibility_changed' };}case 'storage.alert':@@@ -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 }).then(() => require('../rundown/visibility').noteVodChange(v.id, v.stream_id, action)).catch(() => {});++ else await media.updateVod(v.id, { visibility: action }).then(() => require('../rundown/visibility').noteVodChange(v.id, v.stream_id, action, [])).catch(() => {});vodCount++;}}@@@ -690,7 +690,7 @@ router.put('/:id', requireAuth, async (req, res) => {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);++ 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');@@@ -715,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 }); await require('../rundown/visibility').noteVodChange(id, vod.stream_id, action); }++ else { await media.updateVod(id, { visibility: action }); await require('../rundown/visibility').noteVodChange(id, vod.stream_id, action, []); }done++;} catch { skipped++; }}@@@ -11,9 +11,9 @@* 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.++ * Media's own vocabulary is mapped onto the four audiences. Since Media gained "Specific people" (step 5), a VOD can++ * itself be `people`: Live records that audience and the VOD's member list from Media's API/event (the audience and the++ * subject ids, never a title). When both the episode and the VOD name people, the people on both lists keep access.*/const db = require('../db/database');@@@ -24,9 +24,9 @@ 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' };++const MEDIA_AUDIENCE = { public: 'public', unlisted: 'link', people: 'people', 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' };++const MEDIA_WORD = { public: 'public', link: 'unlisted', people: 'people', private: 'private' };/** The chip a streamer sees on their own non-public items. */const LABELS = { public: 'Everyone', link: 'Link', private: 'Only me' };@@@ -93,13 +93,14 @@ async function memberSubjects(streamId) {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++ const audience = stricter(own, vod);++ let members = [];++ if (audience === 'people') {++ const ownList = own === 'people' ? await memberSubjects(streamId) : null;++ const vodList = vod === 'people' ? await vodMembers(streamId) : null;++ // Specific people on the recording alone: its people. On the episode alone: its people. On both:++ // the people on both lists. The effective audience is never wider than either list.++ members = ownList && vodList ? ownList.filter((id) => vodList.includes(id)) : (vodList || ownList || []);}return { own, vod, audience, members, following_vod: audience !== own, member_count: members.length };}@@@ -108,12 +109,16 @@ async function effective(streamId) {* 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) {++async function noteVodVisibility(streamId, mediaVisibility, members = null) {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]);++ const list = audience === 'people'++ ? (Array.isArray(members) ? members : []).map((m) => ({ id: String(m && m.id ? m.id : m) })).filter((m) => m.id)++ : [];++ await db.run(`INSERT INTO rundown_visibility (stream_id, vod_audience, vod_members, updated_at) VALUES (?, ?, ?::jsonb, ov_now())++ ON CONFLICT (stream_id) DO UPDATE SET vod_audience = excluded.vod_audience, vod_members = excluded.vod_members,++ updated_at = excluded.updated_at`, [id, audience, JSON.stringify(list)]);return audience;}@@@ -121,7 +126,7 @@ async function noteVodVisibility(streamId, mediaVisibility) {* 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) {++async function noteVodChange(vodId, streamId, mediaVisibility, members = null) {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)]);@@@ -129,7 +134,7 @@ async function noteVodChange(vodId, streamId, mediaVisibility) {}if (!Number.isSafeInteger(sid) || sid <= 0) return null;forget(sid);-- return await noteVodVisibility(sid, mediaVisibility);++ return await noteVodVisibility(sid, mediaVisibility, members);}/** The recording of a stream, when there is one. */@@@ -153,14 +158,17 @@ async function recheck(streamId) {if (hit && Date.now() - hit.at < RECHECK_MS) return hit.audience;const vodId = await vodIdFor(id);if (!vodId) return null;-- let audience = null;++ let audience = null, members = null;try {const vod = await require('../media-client').getVod(vodId);-- if (vod && vod.visibility) audience = mediaAudience(vod.visibility);++ // Media's `audience` is the share.v1 answer (people included); `visibility` keeps old servers working.++ if (vod && (vod.audience || vod.visibility)) audience = mediaAudience(vod.audience || vod.visibility);++ if (vod && Array.isArray(vod.members)) members = vod.members;++ else if (vod && Array.isArray(vod.share_members)) members = vod.share_members;} catch { /* Media unreachable: the last thing we heard stays */ }if (!audience) return null;_rechecked.set(id, { at: Date.now(), audience });-- await noteVodVisibility(id, audience);++ await noteVodVisibility(id, audience, members);return audience;}@@@ -155,6 +155,32 @@ await db.run('UPDATE rundown_visibility SET vod_members = ?::jsonb WHERE stream_assert.strictEqual(await read.episode(streamId, { viewer: { key: 'anon:x', userId: null, subject: SUB_OTHER } }), null, 'the person on only one list does not');}++// ── A recording shared with Specific people (Media's new audience) ──++await setAudience('public');++await db.run("DELETE FROM share_members WHERE service = 'live' AND kind = 'rundown-episode' AND resource_id = ?", [String(streamId)]);++await visibility.noteVodVisibility(streamId, 'people', [SUB_WATCH]);++{++ const eff = await visibility.effective(streamId);++ assert.strictEqual(eff.vod, 'people', 'Media\'s people audience is recorded, not flattened to private');++ assert.strictEqual(eff.audience, 'people', 'the recording makes the episode Specific people');++ assert.deepStrictEqual(eff.members, [SUB_WATCH], 'with the recording\'s member list');++ assert.ok(await read.episode(streamId, { viewer: { key: `user:${watcher}`, userId: watcher, subject: SUB_WATCH } }), 'a person on the recording opens the episode');++ assert.strictEqual(await read.episode(streamId, { viewer: { key: 'anon:x', userId: null, subject: SUB_OTHER } }), null, 'somebody else does not');++ const home = await read.home({ viewer: null });++ assert.strictEqual(home.counts.takes, 0, 'it is not counted on the gathered front page');++ // The event path (Media reports the audience and the member list together) writes the same row.++ await visibility.noteVodChange(4242, streamId, 'people', [SUB_WATCH, SUB_OTHER]);++ const stored = await db.get('SELECT vod_audience, vod_members FROM rundown_visibility WHERE stream_id = ?', [streamId]);++ assert.strictEqual(stored.vod_audience, 'people');++ assert.deepStrictEqual(stored.vod_members.map((m) => m.id), [SUB_WATCH, SUB_OTHER]);++ // The episode's own link setting is not wider than the recording: people wins.++ await setAudience('link');++ assert.strictEqual((await visibility.effective(streamId)).audience, 'people', 'people is stricter than link');++ // Only me on the episode stays Only me whatever the recording says.++ await setAudience('private');++ assert.strictEqual((await visibility.effective(streamId)).audience, 'private', 'the episode can always narrow further');++}++// ── Deleting ──await setAudience('public');{
@@ -186,7 +186,7 @@ async function apply(event, data) {
@@ -186,7 +186,7 @@ async function apply(event, data) {
if (kind !== 'vod') return { outcome: 'ignored' };
if (kind !== 'vod') return { outcome: 'ignored' };
const vodId = vodIdOf(data);
const vodId = vodIdOf(data);
if (!vodId) return { outcome: 'ignored' };
if (!vodId) return { outcome: 'ignored' };
- await require('../rundown/visibility').noteVodChange(vodId, data.stream_id || null, data.visibility);
+ await require('../rundown/visibility').noteVodChange(vodId, data.stream_id || null, data.audience || data.visibility, data.members);
return { outcome: 'object_visibility_changed' };
return { outcome: 'object_visibility_changed' };
}
}
case 'storage.alert':
case 'storage.alert':
@@ -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 }).then(() => require('../rundown/visibility').noteVodChange(v.id, v.stream_id, action)).catch(() => {});
+ else await media.updateVod(v.id, { visibility: action }).then(() => require('../rundown/visibility').noteVodChange(v.id, v.stream_id, action, [])).catch(() => {});
vodCount++;
vodCount++;
}
}
}
}
@@ -690,7 +690,7 @@ router.put('/:id', requireAuth, async (req, res) => {
@@ -690,7 +690,7 @@ router.put('/:id', requireAuth, async (req, res) => {
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).
// 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);
+ 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');
@@ -715,7 +715,7 @@ router.post('/bulk', requireAuth, async (req, res) => {
@@ -715,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 }); await require('../rundown/visibility').noteVodChange(id, vod.stream_id, action); }
+ else { await media.updateVod(id, { visibility: action }); await require('../rundown/visibility').noteVodChange(id, vod.stream_id, action, []); }
done++;
done++;
} catch { skipped++; }
} catch { skipped++; }
}
}
@@ -11,9 +11,9 @@
@@ -11,9 +11,9 @@
* so a VOD that turns stricter is followed at once (the episode, its segments and the after-show report) and a VOD that
* 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.
* 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.
+ * Media's own vocabulary is mapped onto the four audiences. Since Media gained "Specific people" (step 5), a VOD can
+ * itself be `people`: Live records that audience and the VOD's member list from Media's API/event (the audience and the
+ * subject ids, never a title). When both the episode and the VOD name people, the people on both lists keep access.
*/
*/
const db = require('../db/database');
const db = require('../db/database');
@@ -24,9 +24,9 @@ const STRICTNESS = { private: 0, people: 1, link: 2, public: 3 };
@@ -24,9 +24,9 @@ const STRICTNESS = { private: 0, people: 1, link: 2, public: 3 };
/** Nobody chose anything yet: Everyone. */
/** Nobody chose anything yet: Everyone. */
const DEFAULT_AUDIENCE = 'public';
const DEFAULT_AUDIENCE = 'public';
/** Media's visibility vocabulary, mapped onto the four audiences. */
/** Media's visibility vocabulary, mapped onto the four audiences. */
-const MEDIA_AUDIENCE = { public: 'public', unlisted: 'link', private: 'private' };
+const MEDIA_AUDIENCE = { public: 'public', unlisted: 'link', people: 'people', private: 'private' };
/** And back again: what the episode page says when it follows the recording ("Following the VOD: unlisted"). */
/** 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' };
+const MEDIA_WORD = { public: 'public', link: 'unlisted', people: 'people', private: 'private' };
/** The chip a streamer sees on their own non-public items. */
/** The chip a streamer sees on their own non-public items. */
const LABELS = { public: 'Everyone', link: 'Link', private: 'Only me' };
const LABELS = { public: 'Everyone', link: 'Link', private: 'Only me' };
@@ -93,13 +93,14 @@ async function memberSubjects(streamId) {
@@ -93,13 +93,14 @@ async function memberSubjects(streamId) {
async function effective(streamId) {
async function effective(streamId) {
const own = await ownAudience(streamId);
const own = await ownAudience(streamId);
const vod = await vodAudience(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
+ const audience = stricter(own, vod);
+ let members = [];
+ if (audience === 'people') {
+ const ownList = own === 'people' ? await memberSubjects(streamId) : null;
+ const vodList = vod === 'people' ? await vodMembers(streamId) : null;
+ // Specific people on the recording alone: its people. On the episode alone: its people. On both:
+ // the people on both lists. The effective audience is never wider than either list.
+ members = ownList && vodList ? ownList.filter((id) => vodList.includes(id)) : (vodList || ownList || []);
}
}
return { own, vod, audience, members, following_vod: audience !== own, member_count: members.length };
return { own, vod, audience, members, following_vod: audience !== own, member_count: members.length };
}
}
@@ -108,12 +109,16 @@ async function effective(streamId) {
@@ -108,12 +109,16 @@ async function effective(streamId) {
* Record what the recording decides. Called in the same request as a visibility change made through Live's own pages
* 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).
* (server/media-proxy/vods.js) and when Media's own event arrives (server/media-proxy/outcomes.js).
*/
*/
-async function noteVodVisibility(streamId, mediaVisibility) {
+async function noteVodVisibility(streamId, mediaVisibility, members = null) {
const id = Number(streamId);
const id = Number(streamId);
if (!Number.isSafeInteger(id) || id <= 0) return null;
if (!Number.isSafeInteger(id) || id <= 0) return null;
const audience = mediaAudience(mediaVisibility);
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]);
+ const list = audience === 'people'
+ ? (Array.isArray(members) ? members : []).map((m) => ({ id: String(m && m.id ? m.id : m) })).filter((m) => m.id)
+ : [];
+ await db.run(`INSERT INTO rundown_visibility (stream_id, vod_audience, vod_members, updated_at) VALUES (?, ?, ?::jsonb, ov_now())
+ ON CONFLICT (stream_id) DO UPDATE SET vod_audience = excluded.vod_audience, vod_members = excluded.vod_members,
+ updated_at = excluded.updated_at`, [id, audience, JSON.stringify(list)]);
return audience;
return audience;
}
}
@@ -121,7 +126,7 @@ async function noteVodVisibility(streamId, mediaVisibility) {
@@ -121,7 +126,7 @@ async function noteVodVisibility(streamId, mediaVisibility) {
* A recording changed its visibility: the write-up of that stream follows at once. Called from Live's own pages
* 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).
* (server/media-proxy/vods.js) and from Media's event (server/media-proxy/outcomes.js).
*/
*/
-async function noteVodChange(vodId, streamId, mediaVisibility) {
+async function noteVodChange(vodId, streamId, mediaVisibility, members = null) {
let sid = Number(streamId);
let sid = Number(streamId);
if (!Number.isSafeInteger(sid) || sid <= 0) {
if (!Number.isSafeInteger(sid) || sid <= 0) {
const row = await db.get('SELECT stream_id FROM rundown_episodes WHERE vod_id = ?', [Number(vodId)]);
const row = await db.get('SELECT stream_id FROM rundown_episodes WHERE vod_id = ?', [Number(vodId)]);
@@ -129,7 +134,7 @@ async function noteVodChange(vodId, streamId, mediaVisibility) {
@@ -129,7 +134,7 @@ async function noteVodChange(vodId, streamId, mediaVisibility) {
}
}
if (!Number.isSafeInteger(sid) || sid <= 0) return null;
if (!Number.isSafeInteger(sid) || sid <= 0) return null;
forget(sid);
forget(sid);
- return await noteVodVisibility(sid, mediaVisibility);
+ return await noteVodVisibility(sid, mediaVisibility, members);
}
}
/** The recording of a stream, when there is one. */
/** The recording of a stream, when there is one. */
@@ -153,14 +158,17 @@ async function recheck(streamId) {
@@ -153,14 +158,17 @@ async function recheck(streamId) {
if (hit && Date.now() - hit.at < RECHECK_MS) return hit.audience;
if (hit && Date.now() - hit.at < RECHECK_MS) return hit.audience;
const vodId = await vodIdFor(id);
const vodId = await vodIdFor(id);
if (!vodId) return null;
if (!vodId) return null;
- let audience = null;
+ let audience = null, members = null;
try {
try {
const vod = await require('../media-client').getVod(vodId);
const vod = await require('../media-client').getVod(vodId);
- if (vod && vod.visibility) audience = mediaAudience(vod.visibility);
+ // Media's `audience` is the share.v1 answer (people included); `visibility` keeps old servers working.
+ if (vod && (vod.audience || vod.visibility)) audience = mediaAudience(vod.audience || vod.visibility);
+ if (vod && Array.isArray(vod.members)) members = vod.members;
+ else if (vod && Array.isArray(vod.share_members)) members = vod.share_members;
} catch { /* Media unreachable: the last thing we heard stays */ }
} catch { /* Media unreachable: the last thing we heard stays */ }
if (!audience) return null;
if (!audience) return null;
_rechecked.set(id, { at: Date.now(), audience });
_rechecked.set(id, { at: Date.now(), audience });
- await noteVodVisibility(id, audience);
+ await noteVodVisibility(id, audience, members);
return audience;
return audience;
}
}
@@ -155,6 +155,32 @@ await db.run('UPDATE rundown_visibility SET vod_members = ?::jsonb WHERE stream_
@@ -155,6 +155,32 @@ await db.run('UPDATE rundown_visibility SET vod_members = ?::jsonb WHERE stream_
assert.strictEqual(await read.episode(streamId, { viewer: { key: 'anon:x', userId: null, subject: SUB_OTHER } }), null, 'the person on only one list does not');
assert.strictEqual(await read.episode(streamId, { viewer: { key: 'anon:x', userId: null, subject: SUB_OTHER } }), null, 'the person on only one list does not');
}
}
+// ── A recording shared with Specific people (Media's new audience) ──
+await setAudience('public');
+await db.run("DELETE FROM share_members WHERE service = 'live' AND kind = 'rundown-episode' AND resource_id = ?", [String(streamId)]);
+await visibility.noteVodVisibility(streamId, 'people', [SUB_WATCH]);
+{
+ const eff = await visibility.effective(streamId);
+ assert.strictEqual(eff.vod, 'people', 'Media\'s people audience is recorded, not flattened to private');
+ assert.strictEqual(eff.audience, 'people', 'the recording makes the episode Specific people');
+ assert.deepStrictEqual(eff.members, [SUB_WATCH], 'with the recording\'s member list');
+ assert.ok(await read.episode(streamId, { viewer: { key: `user:${watcher}`, userId: watcher, subject: SUB_WATCH } }), 'a person on the recording opens the episode');
+ assert.strictEqual(await read.episode(streamId, { viewer: { key: 'anon:x', userId: null, subject: SUB_OTHER } }), null, 'somebody else does not');
+ const home = await read.home({ viewer: null });
+ assert.strictEqual(home.counts.takes, 0, 'it is not counted on the gathered front page');
+ // The event path (Media reports the audience and the member list together) writes the same row.
+ await visibility.noteVodChange(4242, streamId, 'people', [SUB_WATCH, SUB_OTHER]);
+ const stored = await db.get('SELECT vod_audience, vod_members FROM rundown_visibility WHERE stream_id = ?', [streamId]);
+ assert.strictEqual(stored.vod_audience, 'people');
+ assert.deepStrictEqual(stored.vod_members.map((m) => m.id), [SUB_WATCH, SUB_OTHER]);
+ // The episode's own link setting is not wider than the recording: people wins.
+ await setAudience('link');
+ assert.strictEqual((await visibility.effective(streamId)).audience, 'people', 'people is stricter than link');
+ // Only me on the episode stays Only me whatever the recording says.
+ await setAudience('private');
+ assert.strictEqual((await visibility.effective(streamId)).audience, 'private', 'the episode can always narrow further');
+}
+
// ── Deleting ──
// ── Deleting ──
await setAudience('public');
await setAudience('public');
{
{