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)

OP OpenVibers committed 20 days ago · f57ce8932d · 5 files, +66 −8

Changes

ddiff --git a/public/js/stream-player.js b/public/js/stream-player.js
iindex f2a126d1..c4f9e61a 100644
---- a/public/js/stream-player.js
++++ b/public/js/stream-player.js
@@@ -625,6 +625,9 @@ let dvrState = {
savedSrcObject: null, // WebRTC MediaStream saved when switching to DVR
savedLivePlayer: null, // HLS/FLV player saved when switching to DVR
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) {
@@@ -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 file
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.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 first
if (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;
ddiff --git a/server/media-client.js b/server/media-client.js
iindex c504532a..7ce05ff6 100644
---- a/server/media-client.js
++++ b/server/media-client.js
@@@ -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,
};
ddiff --git a/server/media-proxy/vods.js b/server/media-proxy/vods.js
iindex 679bcb35..11e5e711 100644
---- a/server/media-proxy/vods.js
++++ b/server/media-proxy/vods.js
@@@ -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');
ddiff --git a/test/stream-ended-teardown.test.js b/test/stream-ended-teardown.test.js
iindex 0e4e48b7..aa3f0a95 100644
---- a/test/stream-ended-teardown.test.js
++++ b/test/stream-ended-teardown.test.js
@@@ -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();
ddiff --git a/test/vod-tail-finalize.test.js b/test/vod-tail-finalize.test.js
iindex 797646d6..15894573 100644
---- a/test/vod-tail-finalize.test.js
++++ b/test/vod-tail-finalize.test.js
@@@ -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 () => {