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
Changes
@@@ -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 |@@@ -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,@@@ -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 };}@@@ -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');@@@ -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));
@@ -108,7 +108,7 @@ its status and prints its words. It is `private, no-store`; a `GET /web/go` star
@@ -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 |
| `/` | 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` | 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 |
| `/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 |
| `/web` | the Surf with Vibe form, examples, and what happens next |
| `/developers` | the API, for people and agents |
| `/developers` | the API, for people and agents |
| anything else | the 404 page |
| anything else | the 404 page |
@@ -31,7 +31,7 @@
@@ -31,7 +31,7 @@
],
],
"notYet": [
"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",
"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,
"capabilitiesRegistered": false,
"serviceManifestRegistered": true,
"serviceManifestRegistered": true,
@@ -7,6 +7,9 @@
@@ -7,6 +7,9 @@
*
*
* Sources (all on OV_LIVE_INTERNAL_URL):
* Sources (all on OV_LIVE_INTERNAL_URL):
* live GET /api/streams → { streams: [...] }
* 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 }
* clips GET /api/content/feed?type=clips&sort=new… → { items: [...], next }
* moments GET /api/content/moments?type=clips&sort=top… → { items: [...], next }
* moments GET /api/content/moments?type=clips&sort=top… → { items: [...], next }
*
*
@@ -21,6 +24,7 @@
@@ -21,6 +24,7 @@
const DEFAULT_TOPIC = 'all';
const DEFAULT_TOPIC = 'all';
const TOPICS = ['all', 'live', 'clips', 'moments'];
const TOPICS = ['all', 'live', 'clips', 'moments'];
const SOURCE_NAMES = ['live', 'clips', 'moments'];
const SOURCE_NAMES = ['live', 'clips', 'moments'];
+const LIVE_DVR_MAX = 6;
const CLIP_PATH = '/api/content/feed';
const CLIP_PATH = '/api/content/feed';
const MOMENT_PATH = '/api/content/moments';
const MOMENT_PATH = '/api/content/moments';
@@ -188,10 +192,22 @@ function createWatchFeed({ config, fetchImpl = globalThis.fetch, log = console,
@@ -188,10 +192,22 @@ function createWatchFeed({ config, fetchImpl = globalThis.fetch, log = console,
return data;
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() {
async function loadLive() {
const data = await getJson('/api/streams');
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));
const viewers = num(s.total_viewer_count != null ? s.total_viewer_count : (s.viewer_count != null ? s.viewer_count : s.viewers));
return {
return {
key: `live:${s.username}`,
key: `live:${s.username}`,
@@ -206,6 +222,12 @@ function createWatchFeed({ config, fetchImpl = globalThis.fetch, log = console,
@@ -206,6 +222,12 @@ function createWatchFeed({ config, fetchImpl = globalThis.fetch, log = console,
created_at: s.started_at || null,
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 };
return { items, next: null };
}
}
@@ -32,11 +32,12 @@ const channels = (feed) => feed.items.map((i) => i.channel.username);
@@ -32,11 +32,12 @@ const channels = (feed) => feed.items.map((i) => i.channel.username);
}
}
const live = b.items[0];
const live = b.items[0];
assert.strictEqual(live.key, 'live:bo');
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.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');
const moment = b.items.find((i) => i.kind === 'moment');
assert.strictEqual(moment.play.type, 'hls');
assert.strictEqual(moment.play.type, 'hls');
assert.strictEqual(moment.play.src, 'https://openvibe.media/c/201/master.m3u8');
assert.strictEqual(moment.play.src, 'https://openvibe.media/c/201/master.m3u8');
@@ -66,6 +66,8 @@ async function startUpstreams() {
@@ -66,6 +66,8 @@ async function startUpstreams() {
top: JSON.parse(JSON.stringify(DEFAULT_TOP)),
top: JSON.parse(JSON.stringify(DEFAULT_TOP)),
gamesNotFound: false,
gamesNotFound: false,
gamesStatus: null,
gamesStatus: null,
+ liveVods: { 2: 9002 },
+ dvr: { 9002: 'https://openvibe.media/v/9002/dvr.m3u8' },
fail: { streams: null, clips: null, moments: null },
fail: { streams: null, clips: null, moments: null },
delay: { streams: 0, clips: 0, moments: 0 },
delay: { streams: 0, clips: 0, moments: 0 },
actor: { status: 201, id: 'tsk_01J8SURFEXAMPLE00000000', problem: null },
actor: { status: 201, id: 'tsk_01J8SURFEXAMPLE00000000', problem: null },
@@ -91,6 +93,14 @@ async function startUpstreams() {
@@ -91,6 +93,14 @@ async function startUpstreams() {
if (fail('streams')) return;
if (fail('streams')) return;
return json(200, { streams: state.streams });
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') {
if (p === '/api/content/feed') {
state.counts.clips++;
state.counts.clips++;
if (state.delay.clips) await new Promise((r) => setTimeout(r, state.delay.clips));
if (state.delay.clips) await new Promise((r) => setTimeout(r, state.delay.clips));