openvibers/openvibe.live Commits

Rundown: follow a recording shared with specific people (#641)

OP OpenVibers committed 6 days ago · baae5b156c · 4 files, +58 −24

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

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

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

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

* Live subscribes to Media's visibility changes

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

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

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

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

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

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

* Rundown: follow a recording shared with specific people

Media gained the share.v1 audience people in step 5, so Live's MEDIA_AUDIENCE maps it instead of flattening it to private. The recording's audience and member list are recorded together (from Media's API re-check and from media.object.visibility_changed, which now carries both), and the Rundown's effective audience takes the recording's people when the episode is public or a link, the intersection when both sides name people, and never widens past either list. A recording that is people-only no longer makes the write-up disappear; exactly its people see the episode, and nobody else does. Stacked on #634.

* Merge remote-tracking branch 'origin/main' into feat/rundown-people-audience

* Merge remote-tracking branch 'origin/main' into feat/rundown-people-audience

# Conflicts:
#	server/media-proxy/outcomes.js
#	server/media-proxy/vods.js
#	server/rundown/visibility.js
#	test/rundown-audiences.test.js

* Rundown: run the audience migration after the voice migration

Merging #634 after #636 left two 0029 migrations on main (0029_agent_stream_voice and 0029_rundown_audiences), and the migrator refuses the pair before any test runs. 0029_agent_stream_voice merged first and stays; the Rundown audience migration is 0030, with its docs reference.

Changes

ddiff --git a/server/media-proxy/outcomes.js b/server/media-proxy/outcomes.js
iindex 1086a09c..c9253711 100644
---- a/server/media-proxy/outcomes.js
++++ b/server/media-proxy/outcomes.js
@@@ -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':
ddiff --git a/server/media-proxy/vods.js b/server/media-proxy/vods.js
iindex 7cbd14b1..9300f438 100644
---- a/server/media-proxy/vods.js
++++ b/server/media-proxy/vods.js
@@@ -400,7 +400,7 @@ router.post('/bulk-delete-old', requireAuth, async (req, res) => {
for (const v of (out?.vods || [])) {
if (!tooOld(v) || v.is_recording) continue;
if (action === 'delete') { if (await media.deleteVod(v.id).then(() => true, () => false)) await purge.afterDelete('vod', v.id); }
-- else await media.updateVod(v.id, { visibility: action }).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++; }
}
ddiff --git a/server/rundown/visibility.js b/server/rundown/visibility.js
iindex 678ec6b0..aadb991e 100644
---- a/server/rundown/visibility.js
++++ b/server/rundown/visibility.js
@@@ -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;
}
ddiff --git a/test/rundown-audiences.test.js b/test/rundown-audiences.test.js
iindex cae08727..e0ab97d2 100644
---- a/test/rundown-audiences.test.js
++++ b/test/rundown-audiences.test.js
@@@ -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');
{