openvibers/openvibe.live Commits
Rewinding a live MP4 recording plays Media's playlist over the growing file (live-info dvr_hls), not a seekable copy; the file stays the fallback (#349)
Changes
@@@ -625,6 +625,9 @@ let dvrState = {savedSrcObject: null, // WebRTC MediaStream saved when switching to DVRsavedLivePlayer: null, // HLS/FLV player saved when switching to DVRseeking: false, // User is dragging the DVR progress bar++ hlsUrl: null, // an MP4 recording's DVR playlist (Media /v/<id>/dvr.m3u8): seeking with no snapshot copy++ dvrHls: null, // the hls.js instance playing it while rewound++ dvrNative: false, // or the browser's own HLS (Safari) playing it};function loadExternalScriptOnce(src) {@@@ -2332,10 +2335,11 @@ async function pollDVR(streamId) {const info = await api(`/vods/${vod.id}/live-info`);dvrState.duration = info.duration || 0;dvrState.seekable = info.seekable || false;++ dvrState.hlsUrl = typeof info.dvr_hls === 'string' && /^https:\/\//.test(info.dvr_hls) ? info.dvr_hls : null;} catch {}-- // Show DVR controls when we have a seekable copy-- if (dvrState.seekable && dvrState.vodFile) {++ // Show DVR controls when we have a seekable copy (or the recording's own playlist)++ if (dvrState.seekable && (dvrState.vodFile || dvrState.hlsUrl)) {dvrState.active = true;showDVRControls();}@@@ -2501,7 +2505,7 @@ function setupDVRSeek() {* @param {number} targetTime - Seek position in seconds*/function switchToDVR(targetTime) {-- if (!dvrState.vodFile || !dvrState.seekable) return;++ if ((!dvrState.vodFile && !dvrState.hlsUrl) || !dvrState.seekable) return;const video = document.getElementById('video-element');const canvas = document.getElementById('video-canvas');@@@ -2539,10 +2543,17 @@ function switchToDVR(targetTime) {// Ensure the video element is visible (JSMPEG normally hides it)video.style.display = 'block';-- // Switch video source from live to HTTP VOD filevideo.srcObject = null;-- video.src = `${dvrState.vodFile}?t=${Date.now()}`;++ if (dvrState.hlsUrl) _playDvrHls(video, targetTime);++ else _playDvrFile(video, targetTime);++++ console.log(`[DVR] Switched to VOD seeking at ${Math.round(targetTime)}s`);++}++/** The recording's HTTP file (Media serves a seekable copy of it while it records). */++function _playDvrFile(video, targetTime) {++ if (!dvrState.vodFile) return;++ video.src = `${dvrState.vodFile}?t=${Date.now()}`;video.addEventListener('loadedmetadata', function _seekOnLoad() {video.removeEventListener('loadedmetadata', _seekOnLoad);// Use server duration or video.duration, whichever is valid@@@ -2550,8 +2561,40 @@ function switchToDVR(targetTime) {video.currentTime = Math.min(targetTime, dur);video.play().catch(() => {});});++}-- console.log(`[DVR] Switched to VOD seeking at ${Math.round(targetTime)}s`);++/**++ * An MP4 recording's own playlist (Media /v/<id>/dvr.m3u8: slices of the file as it grows), with hls.js, or the++ * browser's HLS on Safari. A seek while already on it moves within it; a failure falls back to the file.++ */++function _playDvrHls(video, targetTime) {++ if (dvrState.dvrHls || dvrState.dvrNative) { video.currentTime = targetTime; video.play().catch(() => {}); return; }++ const fallback = () => { _destroyDvrHls(); dvrState.hlsUrl = null; if (!dvrState.isLive) _playDvrFile(video, targetTime); };++ loadExternalScriptOnce('https://cdn.jsdelivr.net/npm/hls.js@latest').then(() => {++ if (dvrState.isLive || !dvrState.hlsUrl) return; // back to live, or the playlist went away, meanwhile++ if (!window.Hls || !Hls.isSupported()) {++ if (!video.canPlayType('application/vnd.apple.mpegurl')) return fallback();++ dvrState.dvrNative = true;++ video.src = dvrState.hlsUrl;++ video.addEventListener('loadedmetadata', () => { video.currentTime = targetTime; video.play().catch(() => {}); }, { once: true });++ return;++ }++ const hls = new Hls({ enableWorker: true, startPosition: targetTime, backBufferLength: 60, maxBufferLength: 30 });++ dvrState.dvrHls = hls;++ hls.on(Hls.Events.MANIFEST_PARSED, () => { video.play().catch(() => {}); });++ hls.on(Hls.Events.ERROR, (_e, data) => {++ if (!data || !data.fatal) return;++ console.warn('[DVR] the recording\'s playlist failed, using its file:', data.details);++ fallback();++ });++ hls.loadSource(dvrState.hlsUrl);++ hls.attachMedia(video);++ }).catch(fallback);++}++++function _destroyDvrHls() {++ if (dvrState.dvrHls) { try { dvrState.dvrHls.destroy(); } catch {} dvrState.dvrHls = null; }++ dvrState.dvrNative = false;}/**@@@ -2565,6 +2608,7 @@ function jumpToLive() {if (!video) return;const liveBtn = document.getElementById('dvr-live-btn');++ _destroyDvrHls(); // the recording's playlist lets go of the video element firstif (playerType === 'webrtc') {// Restore WebRTC MediaStream@@@ -2630,6 +2674,8 @@ function jumpToLive() {function destroyDVR() {if (dvrState.pollTimer) { clearTimeout(dvrState.pollTimer); dvrState.pollTimer = null; }if (dvrState.updateTimer) { clearInterval(dvrState.updateTimer); dvrState.updateTimer = null; }++ _destroyDvrHls();++ dvrState.hlsUrl = null;dvrState.active = false;dvrState.isLive = true;dvrState.vodId = null;@@@ -304,6 +304,8 @@ async function generateThumbnail(kind, id, opts = {}) {// ── Public URL builders (MEDIA_PUBLIC_URL) ───────────────────────────────────function vodPlaybackUrl(id) { return `${MEDIA_PUBLIC_URL}/v/${id}`; }++/** An MP4 recording's DVR while it is written: Media's EVENT playlist over the file itself (no seekable copy). */++function vodDvrPlaylistUrl(id) { return `${MEDIA_PUBLIC_URL}/v/${id}/dvr.m3u8`; }function clipUrl(id) { return `${MEDIA_PUBLIC_URL}/c/${id}`; }function pasteScreenshotUrl(slug) { return `${MEDIA_PUBLIC_URL}/p/${encodeURIComponent(slug)}/screenshot`; }function thumbUrl(id) { return `${MEDIA_PUBLIC_URL}/t/${id}`; }@@@ -431,5 +433,5 @@ module.exports = {uploadFile, getFileMeta, deleteFile,uploadThumbnail, generateThumbnail,// URL builders-- vodPlaybackUrl, clipUrl, pasteScreenshotUrl, thumbUrl, fileUrl, publicUrl, setPlaybackUrls,++ vodPlaybackUrl, vodDvrPlaylistUrl, clipUrl, pasteScreenshotUrl, thumbUrl, fileUrl, publicUrl, setPlaybackUrls,};@@@ -313,6 +313,10 @@ router.get('/:id/live-info', optionalAuth, async (req, res) => {// and the VOD page's tail refresh (public/js/app-media.js) never ran: a viewer stayed at// the length the recording had when they opened it.seekable: !!vod.seekable || !recording || (Number(vod.file_size) || 0) > 0,++ // An MP4 recording (RTMP, OBS) seeks through Media's playlist over the file as it grows (/v/<id>/dvr.m3u8):++ // no copy of the recording per pass. Public and unlisted only: an HLS player sends no credentials.++ dvr_hls: recording && /\.mp4$/i.test(String(vod.file_path || '')) && (vod.visibility || (vod.is_public ? 'public' : 'private')) !== 'private'++ ? media.vodDvrPlaylistUrl(vod.id) : null,});} catch (err) {mediaErr(res, err, 'Failed to get live info');@@@ -28,7 +28,7 @@ function extract(src, head) {}const player = read('public/js/stream-player.js');--const code = ['let dvrState = {', 'async function initDVR(', 'async function pollDVR(', 'function destroyDVR(', 'function showStreamEnded('].map((h) => extract(player, h)).join('\n');++const code = ['let dvrState = {', 'async function initDVR(', 'async function pollDVR(', 'function destroyDVR(', 'function _destroyDvrHls(', 'function showStreamEnded('].map((h) => extract(player, h)).join('\n');// Fake timers, an api that answers 404 like the server does for an ended stream, and the few DOM calls made.const timers = new Map();@@@ -157,6 +157,11 @@ async function check(name, fn) {r = await call('GET', `/api/vods/${rtmpVod}/live-info`);assert.deepStrictEqual([r.json.isRecording, r.json.seekable, r.json.duration, r.json.fileSize], [true, true, 30, 4_000_000],'a growing recording with bytes is seekable, so the page refreshes toward the live edge');++ assert.strictEqual(r.json.dvr_hls, `https://media.test/v/${rtmpVod}/dvr.m3u8`, 'an MP4 recording seeks through Media\'s playlist over the file');++ VODS[rtmpVod].file_path = `vod-${rtmpVod}.webm`;++ r = await call('GET', `/api/vods/${rtmpVod}/live-info`);++ assert.strictEqual(r.json.dvr_hls, null, 'a WebM recording keeps its seekable copy');++ VODS[rtmpVod].file_path = `vod-${rtmpVod}.mp4`;});await check('live tail: the page plays the recording through /api/vods/<id>/file → Media /v/<id>', async () => {@@@ -184,6 +189,7 @@ async function check(name, fn) {Object.assign(VODS[rtmpVod], { status: 'ready', is_recording: false, duration: 3600, duration_seconds: 3600, file_size: 9e8 });const r = await call('GET', `/api/vods/${rtmpVod}/live-info`);assert.deepStrictEqual([r.json.isRecording, r.json.seekable, r.json.duration], [false, true, 3600]);++ assert.strictEqual(r.json.dvr_hls, null, 'finished: no DVR playlist');});await check('WebRTC/WHIP: PlainRTP consumers feed Media\'s ports; stream end stops the RTP ingest and closes them', async () => {
@@ -625,6 +625,9 @@ let dvrState = {
@@ -625,6 +625,9 @@ let dvrState = {
savedSrcObject: null, // WebRTC MediaStream saved when switching to DVR
savedSrcObject: null, // WebRTC MediaStream saved when switching to DVR
savedLivePlayer: null, // HLS/FLV player saved when switching to DVR
savedLivePlayer: null, // HLS/FLV player saved when switching to DVR
seeking: false, // User is dragging the DVR progress bar
seeking: false, // User is dragging the DVR progress bar
+ hlsUrl: null, // an MP4 recording's DVR playlist (Media /v/<id>/dvr.m3u8): seeking with no snapshot copy
+ dvrHls: null, // the hls.js instance playing it while rewound
+ dvrNative: false, // or the browser's own HLS (Safari) playing it
};
};
function loadExternalScriptOnce(src) {
function loadExternalScriptOnce(src) {
@@ -2332,10 +2335,11 @@ async function pollDVR(streamId) {
@@ -2332,10 +2335,11 @@ async function pollDVR(streamId) {
const info = await api(`/vods/${vod.id}/live-info`);
const info = await api(`/vods/${vod.id}/live-info`);
dvrState.duration = info.duration || 0;
dvrState.duration = info.duration || 0;
dvrState.seekable = info.seekable || false;
dvrState.seekable = info.seekable || false;
+ dvrState.hlsUrl = typeof info.dvr_hls === 'string' && /^https:\/\//.test(info.dvr_hls) ? info.dvr_hls : null;
} catch {}
} catch {}
- // Show DVR controls when we have a seekable copy
- if (dvrState.seekable && dvrState.vodFile) {
+ // Show DVR controls when we have a seekable copy (or the recording's own playlist)
+ if (dvrState.seekable && (dvrState.vodFile || dvrState.hlsUrl)) {
dvrState.active = true;
dvrState.active = true;
showDVRControls();
showDVRControls();
}
}
@@ -2501,7 +2505,7 @@ function setupDVRSeek() {
@@ -2501,7 +2505,7 @@ function setupDVRSeek() {
* @param {number} targetTime - Seek position in seconds
* @param {number} targetTime - Seek position in seconds
*/
*/
function switchToDVR(targetTime) {
function switchToDVR(targetTime) {
- if (!dvrState.vodFile || !dvrState.seekable) return;
+ if ((!dvrState.vodFile && !dvrState.hlsUrl) || !dvrState.seekable) return;
const video = document.getElementById('video-element');
const video = document.getElementById('video-element');
const canvas = document.getElementById('video-canvas');
const canvas = document.getElementById('video-canvas');
@@ -2539,10 +2543,17 @@ function switchToDVR(targetTime) {
@@ -2539,10 +2543,17 @@ function switchToDVR(targetTime) {
// Ensure the video element is visible (JSMPEG normally hides it)
// Ensure the video element is visible (JSMPEG normally hides it)
video.style.display = 'block';
video.style.display = 'block';
- // Switch video source from live to HTTP VOD file
video.srcObject = null;
video.srcObject = null;
- video.src = `${dvrState.vodFile}?t=${Date.now()}`;
+ if (dvrState.hlsUrl) _playDvrHls(video, targetTime);
+ else _playDvrFile(video, targetTime);
+
+ console.log(`[DVR] Switched to VOD seeking at ${Math.round(targetTime)}s`);
+}
+/** The recording's HTTP file (Media serves a seekable copy of it while it records). */
+function _playDvrFile(video, targetTime) {
+ if (!dvrState.vodFile) return;
+ video.src = `${dvrState.vodFile}?t=${Date.now()}`;
video.addEventListener('loadedmetadata', function _seekOnLoad() {
video.addEventListener('loadedmetadata', function _seekOnLoad() {
video.removeEventListener('loadedmetadata', _seekOnLoad);
video.removeEventListener('loadedmetadata', _seekOnLoad);
// Use server duration or video.duration, whichever is valid
// Use server duration or video.duration, whichever is valid
@@ -2550,8 +2561,40 @@ function switchToDVR(targetTime) {
@@ -2550,8 +2561,40 @@ function switchToDVR(targetTime) {
video.currentTime = Math.min(targetTime, dur);
video.currentTime = Math.min(targetTime, dur);
video.play().catch(() => {});
video.play().catch(() => {});
});
});
+}
- console.log(`[DVR] Switched to VOD seeking at ${Math.round(targetTime)}s`);
+/**
+ * An MP4 recording's own playlist (Media /v/<id>/dvr.m3u8: slices of the file as it grows), with hls.js, or the
+ * browser's HLS on Safari. A seek while already on it moves within it; a failure falls back to the file.
+ */
+function _playDvrHls(video, targetTime) {
+ if (dvrState.dvrHls || dvrState.dvrNative) { video.currentTime = targetTime; video.play().catch(() => {}); return; }
+ const fallback = () => { _destroyDvrHls(); dvrState.hlsUrl = null; if (!dvrState.isLive) _playDvrFile(video, targetTime); };
+ loadExternalScriptOnce('https://cdn.jsdelivr.net/npm/hls.js@latest').then(() => {
+ if (dvrState.isLive || !dvrState.hlsUrl) return; // back to live, or the playlist went away, meanwhile
+ if (!window.Hls || !Hls.isSupported()) {
+ if (!video.canPlayType('application/vnd.apple.mpegurl')) return fallback();
+ dvrState.dvrNative = true;
+ video.src = dvrState.hlsUrl;
+ video.addEventListener('loadedmetadata', () => { video.currentTime = targetTime; video.play().catch(() => {}); }, { once: true });
+ return;
+ }
+ const hls = new Hls({ enableWorker: true, startPosition: targetTime, backBufferLength: 60, maxBufferLength: 30 });
+ dvrState.dvrHls = hls;
+ hls.on(Hls.Events.MANIFEST_PARSED, () => { video.play().catch(() => {}); });
+ hls.on(Hls.Events.ERROR, (_e, data) => {
+ if (!data || !data.fatal) return;
+ console.warn('[DVR] the recording\'s playlist failed, using its file:', data.details);
+ fallback();
+ });
+ hls.loadSource(dvrState.hlsUrl);
+ hls.attachMedia(video);
+ }).catch(fallback);
+}
+
+function _destroyDvrHls() {
+ if (dvrState.dvrHls) { try { dvrState.dvrHls.destroy(); } catch {} dvrState.dvrHls = null; }
+ dvrState.dvrNative = false;
}
}
/**
/**
@@ -2565,6 +2608,7 @@ function jumpToLive() {
@@ -2565,6 +2608,7 @@ function jumpToLive() {
if (!video) return;
if (!video) return;
const liveBtn = document.getElementById('dvr-live-btn');
const liveBtn = document.getElementById('dvr-live-btn');
+ _destroyDvrHls(); // the recording's playlist lets go of the video element first
if (playerType === 'webrtc') {
if (playerType === 'webrtc') {
// Restore WebRTC MediaStream
// Restore WebRTC MediaStream
@@ -2630,6 +2674,8 @@ function jumpToLive() {
@@ -2630,6 +2674,8 @@ function jumpToLive() {
function destroyDVR() {
function destroyDVR() {
if (dvrState.pollTimer) { clearTimeout(dvrState.pollTimer); dvrState.pollTimer = null; }
if (dvrState.pollTimer) { clearTimeout(dvrState.pollTimer); dvrState.pollTimer = null; }
if (dvrState.updateTimer) { clearInterval(dvrState.updateTimer); dvrState.updateTimer = null; }
if (dvrState.updateTimer) { clearInterval(dvrState.updateTimer); dvrState.updateTimer = null; }
+ _destroyDvrHls();
+ dvrState.hlsUrl = null;
dvrState.active = false;
dvrState.active = false;
dvrState.isLive = true;
dvrState.isLive = true;
dvrState.vodId = null;
dvrState.vodId = null;
@@ -304,6 +304,8 @@ async function generateThumbnail(kind, id, opts = {}) {
@@ -304,6 +304,8 @@ async function generateThumbnail(kind, id, opts = {}) {
// ── Public URL builders (MEDIA_PUBLIC_URL) ───────────────────────────────────
// ── Public URL builders (MEDIA_PUBLIC_URL) ───────────────────────────────────
function vodPlaybackUrl(id) { return `${MEDIA_PUBLIC_URL}/v/${id}`; }
function vodPlaybackUrl(id) { return `${MEDIA_PUBLIC_URL}/v/${id}`; }
+/** An MP4 recording's DVR while it is written: Media's EVENT playlist over the file itself (no seekable copy). */
+function vodDvrPlaylistUrl(id) { return `${MEDIA_PUBLIC_URL}/v/${id}/dvr.m3u8`; }
function clipUrl(id) { return `${MEDIA_PUBLIC_URL}/c/${id}`; }
function clipUrl(id) { return `${MEDIA_PUBLIC_URL}/c/${id}`; }
function pasteScreenshotUrl(slug) { return `${MEDIA_PUBLIC_URL}/p/${encodeURIComponent(slug)}/screenshot`; }
function pasteScreenshotUrl(slug) { return `${MEDIA_PUBLIC_URL}/p/${encodeURIComponent(slug)}/screenshot`; }
function thumbUrl(id) { return `${MEDIA_PUBLIC_URL}/t/${id}`; }
function thumbUrl(id) { return `${MEDIA_PUBLIC_URL}/t/${id}`; }
@@ -431,5 +433,5 @@ module.exports = {
@@ -431,5 +433,5 @@ module.exports = {
uploadFile, getFileMeta, deleteFile,
uploadFile, getFileMeta, deleteFile,
uploadThumbnail, generateThumbnail,
uploadThumbnail, generateThumbnail,
// URL builders
// URL builders
- vodPlaybackUrl, clipUrl, pasteScreenshotUrl, thumbUrl, fileUrl, publicUrl, setPlaybackUrls,
+ vodPlaybackUrl, vodDvrPlaylistUrl, clipUrl, pasteScreenshotUrl, thumbUrl, fileUrl, publicUrl, setPlaybackUrls,
};
};
@@ -313,6 +313,10 @@ router.get('/:id/live-info', optionalAuth, async (req, res) => {
@@ -313,6 +313,10 @@ router.get('/:id/live-info', optionalAuth, async (req, res) => {
// and the VOD page's tail refresh (public/js/app-media.js) never ran: a viewer stayed at
// and the VOD page's tail refresh (public/js/app-media.js) never ran: a viewer stayed at
// the length the recording had when they opened it.
// the length the recording had when they opened it.
seekable: !!vod.seekable || !recording || (Number(vod.file_size) || 0) > 0,
seekable: !!vod.seekable || !recording || (Number(vod.file_size) || 0) > 0,
+ // An MP4 recording (RTMP, OBS) seeks through Media's playlist over the file as it grows (/v/<id>/dvr.m3u8):
+ // no copy of the recording per pass. Public and unlisted only: an HLS player sends no credentials.
+ dvr_hls: recording && /\.mp4$/i.test(String(vod.file_path || '')) && (vod.visibility || (vod.is_public ? 'public' : 'private')) !== 'private'
+ ? media.vodDvrPlaylistUrl(vod.id) : null,
});
});
} catch (err) {
} catch (err) {
mediaErr(res, err, 'Failed to get live info');
mediaErr(res, err, 'Failed to get live info');
@@ -28,7 +28,7 @@ function extract(src, head) {
@@ -28,7 +28,7 @@ function extract(src, head) {
}
}
const player = read('public/js/stream-player.js');
const player = read('public/js/stream-player.js');
-const code = ['let dvrState = {', 'async function initDVR(', 'async function pollDVR(', 'function destroyDVR(', 'function showStreamEnded('].map((h) => extract(player, h)).join('\n');
+const code = ['let dvrState = {', 'async function initDVR(', 'async function pollDVR(', 'function destroyDVR(', 'function _destroyDvrHls(', 'function showStreamEnded('].map((h) => extract(player, h)).join('\n');
// Fake timers, an api that answers 404 like the server does for an ended stream, and the few DOM calls made.
// Fake timers, an api that answers 404 like the server does for an ended stream, and the few DOM calls made.
const timers = new Map();
const timers = new Map();
@@ -157,6 +157,11 @@ async function check(name, fn) {
@@ -157,6 +157,11 @@ async function check(name, fn) {
r = await call('GET', `/api/vods/${rtmpVod}/live-info`);
r = await call('GET', `/api/vods/${rtmpVod}/live-info`);
assert.deepStrictEqual([r.json.isRecording, r.json.seekable, r.json.duration, r.json.fileSize], [true, true, 30, 4_000_000],
assert.deepStrictEqual([r.json.isRecording, r.json.seekable, r.json.duration, r.json.fileSize], [true, true, 30, 4_000_000],
'a growing recording with bytes is seekable, so the page refreshes toward the live edge');
'a growing recording with bytes is seekable, so the page refreshes toward the live edge');
+ assert.strictEqual(r.json.dvr_hls, `https://media.test/v/${rtmpVod}/dvr.m3u8`, 'an MP4 recording seeks through Media\'s playlist over the file');
+ VODS[rtmpVod].file_path = `vod-${rtmpVod}.webm`;
+ r = await call('GET', `/api/vods/${rtmpVod}/live-info`);
+ assert.strictEqual(r.json.dvr_hls, null, 'a WebM recording keeps its seekable copy');
+ VODS[rtmpVod].file_path = `vod-${rtmpVod}.mp4`;
});
});
await check('live tail: the page plays the recording through /api/vods/<id>/file → Media /v/<id>', async () => {
await check('live tail: the page plays the recording through /api/vods/<id>/file → Media /v/<id>', async () => {
@@ -184,6 +189,7 @@ async function check(name, fn) {
@@ -184,6 +189,7 @@ async function check(name, fn) {
Object.assign(VODS[rtmpVod], { status: 'ready', is_recording: false, duration: 3600, duration_seconds: 3600, file_size: 9e8 });
Object.assign(VODS[rtmpVod], { status: 'ready', is_recording: false, duration: 3600, duration_seconds: 3600, file_size: 9e8 });
const r = await call('GET', `/api/vods/${rtmpVod}/live-info`);
const r = await call('GET', `/api/vods/${rtmpVod}/live-info`);
assert.deepStrictEqual([r.json.isRecording, r.json.seekable, r.json.duration], [false, true, 3600]);
assert.deepStrictEqual([r.json.isRecording, r.json.seekable, r.json.duration], [false, true, 3600]);
+ assert.strictEqual(r.json.dvr_hls, null, 'finished: no DVR playlist');
});
});
await check('WebRTC/WHIP: PlainRTP consumers feed Media\'s ports; stream end stops the RTP ingest and closes them', async () => {
await check('WebRTC/WHIP: PlainRTP consumers feed Media\'s ports; stream end stops the RTP ingest and closes them', async () => {