openvibers/openvibe.surf Commits

Watch: a live stream that records to MP4 plays in the TV over Media's DVR playlist, a little behind live (and says so); other streams keep the link to their channel

OP OpenVibers committed 16 days ago · 4119093d8d · 5 files, +41 −8


Changes

ddiff --git a/README.md b/README.md
iindex 3164a7f..8b13d3b 100644
---- a/README.md
++++ b/README.md
@@@ -108,7 +108,7 @@ its status and prints its words. It is `private, no-store`; a `GET /web/go` star
| `/` | the Surf room: the run motif (ramps, speed lines, a ticking timer), the three tiles under the hero, scenes, questions |
| `/ride` | the maps with tier, stages, runs and the record, and the embed of the first map |
| `/ride/:map` | one map: name, tier, stages, records if Games answers, the embed |
--| `/watch` | the TV: the screen plays clips and moments in the page (HLS), the guide lists every channel; ↑ ↓, a swipe or a tap changes channel; live streams open on their channel |
++| `/watch` | the TV: the screen plays clips and moments in the page (HLS), the guide lists every channel; ↑ ↓, a swipe or a tap changes channel; a live stream that records to MP4 plays over Media's DVR playlist, a little behind live, and any other opens on its channel |
| `/web` | the Surf with Vibe form, examples, and what happens next |
| `/developers` | the API, for people and agents |
| anything else | the 404 page |
ddiff --git a/STATUS.json b/STATUS.json
iindex bd220bc..bf4a7a6 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -31,7 +31,7 @@
],
"notYet": [
"OpenVibe.Games' surf mode and GET /api/surf/maps: until they ship, Ride answers games: 'not_ready' and the page says the maps are being built",
-- "Live streams in the Watch TV itself: they open on their channel until a Live embed player exists"
++ "A browser (WHIP) live stream in the TV: it has no DVR playlist yet, so it opens on its channel; watch together"
],
"capabilitiesRegistered": false,
"serviceManifestRegistered": true,
ddiff --git a/server/watch/feed.js b/server/watch/feed.js
iindex caad8ab..50ab196 100644
---- a/server/watch/feed.js
++++ b/server/watch/feed.js
@@@ -7,6 +7,9 @@
*
* Sources (all on OV_LIVE_INTERNAL_URL):
* live GET /api/streams → { streams: [...] }
++ * GET /api/vods/stream/:id/live, /api/vods/:vod/live-info → dvr_hls: a stream that records to MP4 (RTMP,
++ * OBS) plays in the TV over Media's growing DVR playlist, a little behind the live edge; any other stream
++ * (a browser's WHIP stream, a private recording, a slow answer) keeps its link to the channel
* clips GET /api/content/feed?type=clips&sort=new… → { items: [...], next }
* moments GET /api/content/moments?type=clips&sort=top… → { items: [...], next }
*
@@@ -21,6 +24,7 @@
const DEFAULT_TOPIC = 'all';
const TOPICS = ['all', 'live', 'clips', 'moments'];
const SOURCE_NAMES = ['live', 'clips', 'moments'];
++const LIVE_DVR_MAX = 6;
const CLIP_PATH = '/api/content/feed';
const MOMENT_PATH = '/api/content/moments';
@@@ -188,10 +192,22 @@ function createWatchFeed({ config, fetchImpl = globalThis.fetch, log = console,
return data;
}
++ /** The live stream's DVR playlist on Media, or null (never an error: the card then links to the channel). */
++ async function dvrOf(streamId) {
++ if (!Number.isSafeInteger(streamId) || streamId <= 0) return null;
++ try {
++ const live = await getJson(`/api/vods/stream/${streamId}/live`);
++ const vodId = Number(live && live.vod && live.vod.id);
++ if (!Number.isSafeInteger(vodId) || vodId <= 0) return null;
++ const info = await getJson(`/api/vods/${vodId}/live-info`);
++ return mediaUrl(info && info.dvr_hls);
++ } catch { return null; }
++ }
++
async function loadLive() {
const data = await getJson('/api/streams');
-- const streams = Array.isArray(data.streams) ? data.streams : [];
-- const items = streams.filter((s) => s && typeof s.username === 'string' && s.username).map((s) => {
++ const streams = (Array.isArray(data.streams) ? data.streams : []).filter((s) => s && typeof s.username === 'string' && s.username);
++ const items = streams.map((s) => {
const viewers = num(s.total_viewer_count != null ? s.total_viewer_count : (s.viewer_count != null ? s.viewer_count : s.viewers));
return {
key: `live:${s.username}`,
@@@ -206,6 +222,12 @@ function createWatchFeed({ config, fetchImpl = globalThis.fetch, log = console,
created_at: s.started_at || null,
};
});
++ // The busiest LIVE_DVR_MAX streams are asked for their playlist, side by side (each call has the source timeout).
++ const busiest = items.map((it, i) => [it, Number(streams[i].id)]).sort((a, b) => b[0].viewers - a[0].viewers).slice(0, LIVE_DVR_MAX);
++ await Promise.all(busiest.map(async ([it, id]) => {
++ const src = await dvrOf(id);
++ if (src) { it.play = { type: 'hls', src, page: it.play.page }; it.why = `${it.why} · a little behind live`; }
++ }));
return { items, next: null };
}
ddiff --git a/test/feed.test.js b/test/feed.test.js
iindex 1fb3a78..dfd87d5 100644
---- a/test/feed.test.js
++++ b/test/feed.test.js
@@@ -32,11 +32,12 @@ const channels = (feed) => feed.items.map((i) => i.channel.username);
}
const live = b.items[0];
assert.strictEqual(live.key, 'live:bo');
-- assert.strictEqual(live.play.type, 'live');
-- assert.strictEqual(live.play.src, null);
-- assert.strictEqual(live.play.page, 'https://openvibe.live/@bo');
++ assert.deepStrictEqual(live.play, { type: 'hls', src: 'https://openvibe.media/v/9002/dvr.m3u8', page: 'https://openvibe.live/@bo' },
++ 'a stream that records to MP4 plays in the TV over Media\'s DVR playlist');
++ const ada = b.items.find((i) => i.key === 'live:ada');
++ assert.deepStrictEqual(ada.play, { type: 'live', src: null, page: 'https://openvibe.live/@ada' }, 'no recording: the card links to the channel');
assert.strictEqual(live.viewers, 38);
-- assert.strictEqual(live.why, 'Live now · 38 watching');
++ assert.strictEqual(live.why, 'Live now · 38 watching · a little behind live', 'the DVR playlist runs behind the live edge, and says so');
const moment = b.items.find((i) => i.kind === 'moment');
assert.strictEqual(moment.play.type, 'hls');
assert.strictEqual(moment.play.src, 'https://openvibe.media/c/201/master.m3u8');
ddiff --git a/test/helpers/upstreams.js b/test/helpers/upstreams.js
iindex f9d83e5..dfb6b15 100644
---- a/test/helpers/upstreams.js
++++ b/test/helpers/upstreams.js
@@@ -66,6 +66,8 @@ async function startUpstreams() {
top: JSON.parse(JSON.stringify(DEFAULT_TOP)),
gamesNotFound: false,
gamesStatus: null,
++ liveVods: { 2: 9002 },
++ dvr: { 9002: 'https://openvibe.media/v/9002/dvr.m3u8' },
fail: { streams: null, clips: null, moments: null },
delay: { streams: 0, clips: 0, moments: 0 },
actor: { status: 201, id: 'tsk_01J8SURFEXAMPLE00000000', problem: null },
@@@ -91,6 +93,14 @@ async function startUpstreams() {
if (fail('streams')) return;
return json(200, { streams: state.streams });
}
++ // A live stream's recording (bo records to MP4, so Media has its DVR playlist; ada and cy have none).
++ let m = p.match(/^\/api\/vods\/stream\/(\d+)\/live$/);
++ if (m) {
++ const vod = state.liveVods[m[1]];
++ return vod ? json(200, { vod: { id: vod, stream_id: Number(m[1]), is_recording: 1 } }) : json(404, { error: 'No active VOD recording' });
++ }
++ m = p.match(/^\/api\/vods\/(\d+)\/live-info$/);
++ if (m) return json(200, { id: Number(m[1]), isRecording: true, dvr_hls: state.dvr[m[1]] || null });
if (p === '/api/content/feed') {
state.counts.clips++;
if (state.delay.clips) await new Promise((r) => setTimeout(r, state.delay.clips));