openvibers/openvibe.media Commits
A recording in progress is its own DVR: /v/:id/dvr.m3u8 names its fragments as slices of the growing file (no seekable copy needed); VOD_DVR_SNAPSHOT_MP4=0 stops the MP4 copy (#168)
Changes
@@@ -110,6 +110,10 @@ MEDIA_CAPABILITY_GATE=report# Under this much free space the largest running recording is stopped (one per minute), so a long stream cannot fill# the disk PostgreSQL and every service share. Under VOD_DISK_CRIT_GB only new recordings are refused.# VOD_DISK_STOP_GB=3++# While an MP4 recording is written, its DVR is the playlist over the file itself (/v/:id/dvr.m3u8). 0 also stops the++# periodic seekable copy of MP4 recordings (twice the disk and the I/O of a long stream) once every DVR player reads++# the playlist; WebM recordings keep their copy.++# VOD_DVR_SNAPSHOT_MP4=1# MAX_VOD_SIZE_MB=2048# VOD_NO_NORMALIZE=0@@@ -221,6 +221,11 @@ med_xyz/{captions/*.vtt, storyboard.webp+.vtt, waveform, metadata}never decoded end to end. The layout (`sprite: { count, interval_seconds, columns, rows, tile_width, tile_height }`)and metadata are the seek path's, so the player contract is unchanged. A source without a timeline, or with`MEDIA_HLS_ENABLED` off, keeps the one-fast-seek-per-frame path; captions stay with AI's `media.analyze`.++ **F3.2, first step (2026-10-24): the DVR playlist over the recording itself.** While an MP4 recording is written,++ `/v/:id/dvr.m3u8` is an EVENT playlist whose init segment and ~6 s segments are slices of the growing file++ (`dvr/init.mp4`, `dvr/<offset>-<length>.m4s`; server/vod/fmp4-index.js reads its moof/mdat pairs as they are++ appended), so Live's DVR seeks with no second copy; `VOD_DVR_SNAPSHOT_MP4=0` then stops the per-pass copy of MP4++ recordings. Public and unlisted recordings only (an HLS player sends no credentials).**Still open in F3:** the growing live/DVR playlist written as OpenRestream segments (F3.2), write-behind durability forlive segments and its upload-lag metric, captions on the timeline, and Live's player moving to HLS. Segment-bucketdemand (F2.4) now records reads per timeline segment; deciding placement per segment stays F5's. Renditions are F4.@@@ -37,6 +37,10 @@ const config = {path: process.env.VOD_PATH || './data/vods',clipsPath: process.env.CLIPS_PATH || './data/clips',maxSizeMb: intEnv('MAX_VOD_SIZE_MB', 2048),++ // While an MP4 recording is written, its DVR is the playlist over the file itself (/v/:id/dvr.m3u8,++ // vod/fmp4-index.js). VOD_DVR_SNAPSHOT_MP4=0 also stops the periodic seekable copy of MP4 recordings (a full++ // copy-remux each pass: twice the disk and the I/O) once every DVR player reads the playlist. WebM keeps it.++ dvrSnapshotMp4: process.env.VOD_DVR_SNAPSHOT_MP4 !== '0',},pastes: {path: process.env.PASTES_PATH || './data/pastes',@@@ -248,6 +248,55 @@ router.get('/v/:id', optionalIdentity, async (req, res) => {}});++// ── DVR of a recording in progress (vod/fmp4-index.js; docs/media-fabric.md F3.2) ──++// While an MP4 recording is written, /v/:id/dvr.m3u8 is an EVENT playlist over the file itself: dvr/init.mp4 (its ftyp++// and moov) and dvr/<offset>-<length>.m4s (whole moof+mdat fragments, about 6 s a segment), so a DVR player seeks++// anywhere in the stream without a second copy of the recording. Public and unlisted recordings only (an HLS player++// sends no credentials), CORS-open like the other public media. The playlist is never cached; a segment's bytes never++// change while the file grows. Not recording (finalize rewrites the file) or not an MP4: 404, and players keep /v/:id.++const DVR_SEGMENT = /^(\d{1,15})-(\d{1,10})\.m4s$/;++const DVR_SEGMENT_MAX_BYTES = 64 * 1024 * 1024;++async function dvrSource(req, res) {++ if (drill.refuseBytes(res)) return null;++ if (!/^\d+$/.test(req.params.id)) { notFound(res); return null; }++ const vod = await db.getVodById(parseInt(req.params.id, 10));++ if (!vod || vod.clips_only || !vod.is_recording || recordVisibility(vod) === 'private') { notFound(res); return null; }++ const vodStorage = require('../vod/vod-storage');++ const filePath = [vodStorage.localPathForVod(vod), vod.file_path].find((p) => p && p.endsWith('.mp4') && fs.existsSync(p));++ if (!filePath) { notFound(res); return null; }++ const index = await require('../vod/fmp4-index').refresh(filePath);++ if (index.broken || !index.initEnd) { notFound(res); return null; }++ res.set({ 'Access-Control-Allow-Origin': '*', 'Cross-Origin-Resource-Policy': 'cross-origin', 'X-Content-Type-Options': 'nosniff', 'X-Robots-Tag': 'noindex' });++ return { vod, filePath, index };++}++const dvrRoute = (fn) => async (req, res) => {++ try {++ const s = await dvrSource(req, res);++ if (s) await fn(req, res, s);++ } catch (err) {++ console.error('[Public] DVR error:', err.message);++ if (!res.headersSent) res.status(500).json({ error: 'Failed to serve the DVR' });++ }++};++router.get('/v/:id/dvr.m3u8', dvrRoute(async (req, res, { index }) => {++ res.set('Cache-Control', 'no-cache, no-store, must-revalidate');++ res.type('application/vnd.apple.mpegurl').send(require('../vod/fmp4-index').playlist(index));++}));++router.get('/v/:id/dvr/init.mp4', dvrRoute(async (req, res, { filePath, index }) => {++ res.writeHead(200, { 'Content-Type': 'video/mp4', 'Content-Length': index.initEnd, 'Cache-Control': 'public, max-age=3600' });++ sendFileStream(res, filePath, { start: 0, end: index.initEnd - 1 });++}));++router.get('/v/:id/dvr/:segment', dvrRoute(async (req, res, { filePath, index }) => {++ const m = DVR_SEGMENT.exec(String(req.params.segment || ''));++ const last = index.fragments[index.fragments.length - 1];++ const offset = m ? Number(m[1]) : NaN;++ const length = m ? Number(m[2]) : NaN;++ // Only bytes already indexed (whole fragments after the init segment): never a part still being written.++ if (!m || !last || length <= 0 || length > DVR_SEGMENT_MAX_BYTES || offset < index.initEnd || offset + length > last.offset + last.length) return notFound(res);++ res.writeHead(200, { 'Content-Type': 'video/iso.segment', 'Content-Length': length, 'Cache-Control': 'public, max-age=3600' });++ sendFileStream(res, filePath, { start: offset, end: offset + length - 1 });++}));++// ── Clip playback ────────────────────────────────────────────router.get('/c/:id', optionalIdentity, async (req, res) => {try {@@@ -258,6 +258,7 @@ async function _doFinalize(vodId, opts) {// The live seekable copy is no longer needed: drop it (and stop a live pass still writing one)// before the final remux, whose own copy of the recording needs that room on the disk.await tools.cleanupSeekableFile(filePath);++ require('./fmp4-index').forget(filePath); // the DVR playlist's index: the remux below rewrites the file// Remux for proper seeking support (fast copy-mode, no re-encode). Its last packet time is a// measurement of the file (source 'remux'); a failed remux measures nothing.nnew file mode 100644@@@ -0,0 +1,199 @@++'use strict';++/**++ * OpenVibe.Media — the DVR index of a recording while it is written (docs/media-fabric.md §3, F3.2, first step).++ *++ * A recording in progress is a fragmented MP4 (recorder.js: +frag_keyframe+empty_moov+default_base_moof): ftyp and an++ * empty moov first (the init segment), then a moof+mdat pair per keyframe. Each pair is a CMAF fragment a player can++ * start at, so the growing file already is an HLS stream: this module walks its top-level boxes as they are appended++ * and answers the fragments with their offsets and times. The DVR playlist (public/routes.js /v/:id/dvr.m3u8) names++ * them as slices of the file itself, so live seeking needs no second copy of the recording (until 2026-10, a full++ * copy-remux every pass doubled the disk and the I/O of every long stream).++ *++ * Only whole boxes are indexed: a fragment still being written (its moof or mdat runs past the end of the file) waits++ * for the next read. Each read starts where the last stopped, so a refresh costs a stat and the new boxes' headers (a++ * moof is read whole, a few KB; an mdat only by its header). Times come from the timescale in the moov's mdhd and each++ * fragment's tfdt and trun (or tfhd/trex defaults), on the video track when there is one.++ */++const fsp = require('fs').promises;++++const MAX_FRAGMENT_BYTES = 256 * 1024 * 1024; // a moof+mdat bigger than this is not a fragment the recorder writes++const MAX_MOOV_BYTES = 4 * 1024 * 1024;++const _states = new Map(); // file path → index state (one per recording; dropped by forget())++++function emptyState() {++ return { offset: 0, initEnd: 0, tracks: new Map(), videoId: null, fragments: [], pendingMoof: null, broken: null };++}++++const u32 = (b, o) => b.readUInt32BE(o);++const u64 = (b, o) => Number(b.readBigUInt64BE(o));++const type4 = (b, o) => b.toString('latin1', o, o + 4);++++/** The child boxes of a box body: [{ type, start, size, body }] (start/body relative to `buf`). */++function children(buf, start = 0, end = buf.length) {++ const out = [];++ let o = start;++ while (o + 8 <= end) {++ let size = u32(buf, o);++ const type = type4(buf, o + 4);++ let header = 8;++ if (size === 1) { if (o + 16 > end) break; size = u64(buf, o + 8); header = 16; }++ else if (size === 0) size = end - o;++ if (size < header || o + size > end) break;++ out.push({ type, start: o, size, body: o + header });++ o += size;++ }++ return out;++}++const child = (buf, box, type) => children(buf, box.body, box.start + box.size).find((c) => c.type === type) || null;++++/** Tracks from a moov: Map(track_ID → { timescale, handler, defaultDuration }). */++function parseMoov(buf) {++ const tracks = new Map();++ const root = { start: 0, size: buf.length, body: 0 };++ const defaults = new Map();++ const mvex = child(buf, root, 'mvex');++ if (mvex) {++ for (const trex of children(buf, mvex.body, mvex.start + mvex.size).filter((c) => c.type === 'trex')) {++ const p = trex.body + 4; // version + flags++ defaults.set(u32(buf, p), u32(buf, p + 8)); // track_ID, default_sample_description_index, default_sample_duration++ }++ }++ for (const trak of children(buf, root.body, root.size).filter((c) => c.type === 'trak')) {++ const tkhd = child(buf, trak, 'tkhd');++ const mdia = child(buf, trak, 'mdia');++ if (!tkhd || !mdia) continue;++ const v = buf[tkhd.body];++ const id = u32(buf, tkhd.body + 4 + (v === 1 ? 16 : 8));++ const mdhd = child(buf, mdia, 'mdhd');++ const hdlr = child(buf, mdia, 'hdlr');++ if (!mdhd) continue;++ const mv = buf[mdhd.body];++ const timescale = u32(buf, mdhd.body + 4 + (mv === 1 ? 16 : 8));++ const handler = hdlr ? type4(buf, hdlr.body + 8) : '';++ tracks.set(id, { timescale, handler, defaultDuration: defaults.get(id) || 0 });++ }++ return tracks;++}++++/** One moof's per-track { start, duration } in that track's timescale. */++function parseMoof(buf, tracks) {++ const out = new Map();++ const root = { start: 0, size: buf.length, body: 0 };++ for (const traf of children(buf, root.body, root.size).filter((c) => c.type === 'traf')) {++ const tfhd = child(buf, traf, 'tfhd');++ if (!tfhd) continue;++ const flags = u32(buf, tfhd.body) & 0xffffff;++ const id = u32(buf, tfhd.body + 4);++ let p = tfhd.body + 8;++ if (flags & 0x01) p += 8; // base_data_offset++ if (flags & 0x02) p += 4; // sample_description_index++ let defaultDuration = (tracks.get(id) || {}).defaultDuration || 0;++ if (flags & 0x08) defaultDuration = u32(buf, p);++ const tfdt = child(buf, traf, 'tfdt');++ const start = tfdt ? (buf[tfdt.body] === 1 ? u64(buf, tfdt.body + 4) : u32(buf, tfdt.body + 4)) : null;++ let duration = 0;++ for (const trun of children(buf, traf.body, traf.start + traf.size).filter((c) => c.type === 'trun')) {++ const tf = u32(buf, trun.body) & 0xffffff;++ const count = u32(buf, trun.body + 4);++ let q = trun.body + 8;++ if (tf & 0x001) q += 4; // data_offset++ if (tf & 0x004) q += 4; // first_sample_flags++ const per = ((tf & 0x100) ? 4 : 0) + ((tf & 0x200) ? 4 : 0) + ((tf & 0x400) ? 4 : 0) + ((tf & 0x800) ? 4 : 0);++ if (tf & 0x100) { for (let i = 0; i < count; i++) duration += u32(buf, q + i * per); }++ else duration += count * defaultDuration;++ }++ out.set(id, { start, duration });++ }++ return out;++}++++async function readAt(fh, offset, length) {++ const buf = Buffer.alloc(length);++ const { bytesRead } = await fh.read(buf, 0, length, offset);++ return bytesRead === length ? buf : null;++}++++/**++ * Index what was appended to `filePath` since the last call. → { initEnd, fragments: [{ offset, length, start, duration }],++ * size } with times in seconds, or { broken } when the file is not a fragmented MP4 this module reads.++ */++async function refresh(filePath) {++ let st = _states.get(filePath);++ if (!st) _states.set(filePath, st = emptyState());++ if (st.broken) return { broken: st.broken };++ const fh = await fsp.open(filePath, 'r');++ try {++ const size = (await fh.stat()).size;++ while (st.offset + 8 <= size) {++ const head = await readAt(fh, st.offset, Math.min(16, size - st.offset));++ if (!head) break;++ let boxSize = u32(head, 0);++ const type = type4(head, 4);++ let header = 8;++ if (boxSize === 1) { if (head.length < 16) break; boxSize = u64(head, 8); header = 16; }++ if (boxSize === 0) break; // runs to the end: still being written++ if (boxSize < header) { st.broken = `bad box at ${st.offset}`; break; }++ if (st.offset + boxSize > size) break; // not whole yet++ if (type === 'moov') {++ if (boxSize > MAX_MOOV_BYTES) { st.broken = 'moov too large'; break; }++ const body = await readAt(fh, st.offset + header, boxSize - header);++ st.tracks = parseMoov(body);++ for (const [id, t] of st.tracks) if (t.handler === 'vide' && st.videoId == null) st.videoId = id;++ st.initEnd = st.offset + boxSize;++ } else if (type === 'moof') {++ if (!st.initEnd) { st.broken = 'moof before moov'; break; }++ const body = await readAt(fh, st.offset + header, boxSize - header);++ st.pendingMoof = { offset: st.offset, times: parseMoof(body, st.tracks) };++ } else if (type === 'mdat' && st.pendingMoof) {++ const m = st.pendingMoof;++ const length = st.offset + boxSize - m.offset;++ const id = st.videoId != null && m.times.has(st.videoId) ? st.videoId : m.times.keys().next().value;++ const t = m.times.get(id);++ const track = st.tracks.get(id);++ if (t && track && track.timescale && length <= MAX_FRAGMENT_BYTES) {++ const start = t.start != null ? t.start / track.timescale : (st.fragments.length ? st.fragments[st.fragments.length - 1].start + st.fragments[st.fragments.length - 1].duration : 0);++ st.fragments.push({ offset: m.offset, length, start, duration: t.duration / track.timescale });++ }++ st.pendingMoof = null;++ } else if (type === 'ftyp' || type === 'free' || type === 'skip' || type === 'styp' || type === 'sidx' || type === 'mfra' || type === 'prft') {++ // nothing to index++ } else if (!st.initEnd && type !== 'mdat') {++ st.broken = `unexpected ${type} before moov`; break;++ }++ st.offset += boxSize;++ }++ return { initEnd: st.initEnd, fragments: st.fragments, size, broken: st.broken || undefined };++ } finally {++ await fh.close();++ }++}++++/** Fragments grouped into segments of at least `target` seconds (each starts on a fragment, so on a keyframe). */++function segments(fragments, target = 6) {++ const out = [];++ let cur = null;++ for (const f of fragments) {++ if (cur && cur.offset + cur.length === f.offset && cur.duration < target) { cur.length += f.length; cur.duration += f.duration; }++ else { cur = { offset: f.offset, length: f.length, start: f.start, duration: f.duration }; out.push(cur); }++ }++ return out;++}++++/**++ * The EVENT playlist of a recording in progress: the init segment and every whole segment so far, as URIs relative to++ * the playlist (dvr/init.mp4, dvr/<offset>-<length>.m4s). `ended` closes it (#EXT-X-ENDLIST).++ */++function playlist(index, { target = 6, ended = false } = {}) {++ const segs = segments(index.fragments, target);++ const longest = segs.reduce((m, s) => Math.max(m, s.duration), 0);++ const lines = ['#EXTM3U', '#EXT-X-VERSION:7', `#EXT-X-TARGETDURATION:${Math.max(target + 4, Math.ceil(longest))}`,++ '#EXT-X-MEDIA-SEQUENCE:0', '#EXT-X-PLAYLIST-TYPE:EVENT', '#EXT-X-INDEPENDENT-SEGMENTS', `#EXT-X-MAP:URI="dvr/init.mp4"`];++ for (const s of segs) lines.push(`#EXTINF:${s.duration.toFixed(3)},`, `dvr/${s.offset}-${s.length}.m4s`);++ if (ended) lines.push('#EXT-X-ENDLIST');++ return `${lines.join('\n')}\n`;++}++++/** Forget a recording's index (finalized, or its file replaced). */++function forget(filePath) { _states.delete(filePath); }++++module.exports = { refresh, segments, playlist, forget, _parseMoov: parseMoov, _parseMoof: parseMoof, MAX_FRAGMENT_BYTES };@@@ -335,6 +335,8 @@ class StreamRecorder {[elapsed, stat.size, rec.vodId]));} catch {}++ // An MP4's DVR is its playlist (/v/:id/dvr.m3u8): with VOD_DVR_SNAPSHOT_MP4=0 it gets no copy at all.++ if (rec.filePath.endsWith('.mp4') && !config.vod.dvrSnapshotMp4) return;tools.remuxForLiveSeeking(rec.filePath).catch(() => {});}nnew file mode 100644@@@ -0,0 +1,128 @@++'use strict';++// The DVR playlist of a recording in progress (vod/fmp4-index.js, /v/:id/dvr.m3u8): with real ffmpeg, a fragmented MP4++// written the way the recorder writes it (+frag_keyframe+empty_moov+default_base_moof) is indexed as it grows, a++// fragment cut off at the end of the file waits for its bytes, the playlist names the init segment and whole segments as++// slices of the file, the slices are the file's own bytes, and init + segments decode to the whole recording. Private,++// finished or out-of-range requests are 404s.++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const { spawnSync } = require('child_process');++++(async () => {++ if (spawnSync('ffmpeg', ['-version']).status !== 0 || spawnSync('ffprobe', ['-version']).status !== 0) {++ console.log('dvr playlist: skipped (ffmpeg not found)');++ process.exit(0);++ }++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-dvr-hls-'));++ const dir = (n) => { const d = path.join(tmp, n); fs.mkdirSync(d, { recursive: true }); return d; };++ process.env.VOD_PATH = dir('vods');++ process.env.CLIPS_PATH = dir('clips');++ process.env.FILES_PATH = dir('files');++ process.env.OBJECTS_PATH = dir('objects');++ process.env.THUMBNAILS_PATH = dir('thumbnails');++ process.env.PASTES_PATH = dir('pastes');++ process.env.MEDIA_PUBLIC_URL = 'https://media.test';++++ const ffmpeg = (...args) => { const r = spawnSync('ffmpeg', ['-v', 'error', '-y', ...args], { encoding: 'utf8' }); assert.strictEqual(r.status, 0, r.stderr); };++ const duration = (file) => Number(spawnSync('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', file], { encoding: 'utf8' }).stdout.trim());++ // 12 s, a keyframe every second, H.264-less on purpose (mpeg4 + AAC, like dvr-seek.test.js): what matters is the box layout.++ const full = path.join(tmp, 'full.mp4');++ ffmpeg('-f', 'lavfi', '-i', 'testsrc=duration=12:size=160x90:rate=10', '-f', 'lavfi', '-i', 'sine=duration=12',++ '-c:v', 'mpeg4', '-g', '10', '-c:a', 'aac', '-movflags', '+frag_keyframe+empty_moov+default_base_moof', '-f', 'mp4', full);++ const bytes = fs.readFileSync(full);++++ const fmp4 = require('../server/vod/fmp4-index');++++ // The whole file.++ const whole = await fmp4.refresh(full);++ assert.ok(whole.initEnd > 0 && whole.initEnd < 4096, `init segment ${whole.initEnd} bytes`);++ assert.ok(whole.fragments.length >= 11 && whole.fragments.length <= 13, `${whole.fragments.length} fragments`);++ const total = whole.fragments.reduce((s, f) => s + f.duration, 0);++ assert.ok(Math.abs(total - 12) < 0.3, `fragments cover ${total} s`);++ assert.strictEqual(whole.fragments[0].offset, whole.initEnd, 'the first fragment follows the init segment');++ for (let i = 1; i < whole.fragments.length; i++) {++ const a = whole.fragments[i - 1], b = whole.fragments[i];++ assert.strictEqual(b.offset, a.offset + a.length, 'fragments are contiguous');++ assert.ok(Math.abs(b.start - (a.start + a.duration)) < 0.05, 'times follow on');++ }++ const segs = fmp4.segments(whole.fragments, 6);++ assert.strictEqual(segs.length, 2, 'two ~6 s segments');++ assert.ok(segs.every((s) => s.duration >= 5.9), 'each segment at least the target');++++ // Growing: half the file, a fragment cut off mid-way, then the rest.++ const growing = path.join(process.env.VOD_PATH, 'vod-live-7.mp4');++ const cut = whole.fragments[5].offset + Math.floor(whole.fragments[5].length / 2);++ fs.writeFileSync(growing, bytes.subarray(0, cut));++ const part = await fmp4.refresh(growing);++ assert.strictEqual(part.fragments.length, 5, 'the cut-off fragment is not indexed');++ fs.appendFileSync(growing, bytes.subarray(cut));++ const grown = await fmp4.refresh(growing);++ assert.deepStrictEqual(grown.fragments.map((f) => [f.offset, f.length]), whole.fragments.map((f) => [f.offset, f.length]), 'the next read finishes it');++++ // Routes.++ const db = require('../server/db/database');++ await db.upsertApp({ app_id: 'live', api_key: 'live-key' });++ fmp4.forget(growing);++ fs.writeFileSync(growing, bytes.subarray(0, cut)); // still being written++ await db.run(`INSERT INTO vods (id, app_id, user_id, title, file_path, is_public, visibility, is_recording) OVERRIDING SYSTEM VALUE++ VALUES (7, 'live', 5, 'Still live', ?, 1, 'public', 1)`, [growing]);++ const priv = path.join(process.env.VOD_PATH, 'vod-live-8.mp4');++ fs.writeFileSync(priv, bytes);++ await db.run(`INSERT INTO vods (id, app_id, user_id, title, file_path, is_public, visibility, is_recording) OVERRIDING SYSTEM VALUE++ VALUES (8, 'live', 5, 'Private live', ?, 0, 'private', 1)`, [priv]);++ const express = require('express');++ const app = express();++ app.use('/', require('../server/public/routes'));++ const server = http.createServer(app);++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ const base = `http://127.0.0.1:${server.address().port}`;++ const get = async (p) => { const r = await fetch(base + p); return { status: r.status, headers: r.headers, body: Buffer.from(await r.arrayBuffer()) }; };++++ let pl = await get('/v/7/dvr.m3u8');++ assert.strictEqual(pl.status, 200);++ assert.match(pl.headers.get('content-type'), /mpegurl/);++ assert.strictEqual(pl.headers.get('access-control-allow-origin'), '*');++ assert.match(pl.headers.get('cache-control'), /no-store/);++ let text = pl.body.toString();++ assert.match(text, /#EXT-X-PLAYLIST-TYPE:EVENT/);++ assert.match(text, /#EXT-X-MAP:URI="dvr\/init.mp4"/);++ assert.ok(!/#EXT-X-ENDLIST/.test(text), 'still recording: no end');++ const uris = () => text.split('\n').filter((l) => l.startsWith('dvr/') && l.endsWith('.m4s'));++ assert.strictEqual(uris().length, 1, 'five fragments so far: one ~6 s segment is not yet full, so one open segment');++++ const init = await get('/v/7/dvr/init.mp4');++ assert.strictEqual(init.status, 200);++ assert.ok(init.body.equals(bytes.subarray(0, whole.initEnd)), 'the init segment is the file\'s head');++ const seg = await get(`/${'v/7/'}${uris()[0]}`);++ assert.strictEqual(seg.status, 200);++ assert.strictEqual(seg.headers.get('content-type'), 'video/iso.segment');++ const [, o, l] = /dvr\/(\d+)-(\d+)\.m4s/.exec(uris()[0]).map(Number);++ assert.ok(seg.body.equals(bytes.subarray(o, o + l)), 'a segment is the file\'s own bytes');++ assert.strictEqual((await get(`/v/7/dvr/${cut}-100.m4s`)).status, 404, 'never bytes still being written');++ assert.strictEqual((await get('/v/7/dvr/0-100.m4s')).status, 404, 'not the init segment as a media segment');++ assert.strictEqual((await get('/v/7/dvr/x.m4s')).status, 404);++++ // The rest arrives: the playlist grows; init + every segment decode to the whole recording.++ fs.appendFileSync(growing, bytes.subarray(cut));++ text = (await get('/v/7/dvr.m3u8')).body.toString();++ assert.strictEqual(uris().length, 2);++ const joined = path.join(tmp, 'joined.mp4');++ const parts = [init.body];++ for (const u of uris()) parts.push((await get(`/v/7/${u}`)).body);++ fs.writeFileSync(joined, Buffer.concat(parts));++ assert.ok(Math.abs(duration(joined) - 12) < 0.3, `init + segments play ${duration(joined)} s`);++++ // Private, finished, missing: 404 like /v/:id's own answers.++ assert.strictEqual((await get('/v/8/dvr.m3u8')).status, 404, 'a private recording has no open playlist');++ await db.run('UPDATE vods SET is_recording = 0 WHERE id = 7');++ assert.strictEqual((await get('/v/7/dvr.m3u8')).status, 404, 'finished: /v/:id serves it');++ assert.strictEqual((await get('/v/999/dvr.m3u8')).status, 404);++++ server.close();++ fs.rmSync(tmp, { recursive: true, force: true });++ console.log('dvr playlist: indexing as it grows, the playlist, slices of the file, and a decodable whole all pass');++ process.exit(0);++})().catch((e) => { console.error(e); process.exit(1); });
@@ -110,6 +110,10 @@ MEDIA_CAPABILITY_GATE=report
@@ -110,6 +110,10 @@ MEDIA_CAPABILITY_GATE=report
# Under this much free space the largest running recording is stopped (one per minute), so a long stream cannot fill
# Under this much free space the largest running recording is stopped (one per minute), so a long stream cannot fill
# the disk PostgreSQL and every service share. Under VOD_DISK_CRIT_GB only new recordings are refused.
# the disk PostgreSQL and every service share. Under VOD_DISK_CRIT_GB only new recordings are refused.
# VOD_DISK_STOP_GB=3
# VOD_DISK_STOP_GB=3
+# While an MP4 recording is written, its DVR is the playlist over the file itself (/v/:id/dvr.m3u8). 0 also stops the
+# periodic seekable copy of MP4 recordings (twice the disk and the I/O of a long stream) once every DVR player reads
+# the playlist; WebM recordings keep their copy.
+# VOD_DVR_SNAPSHOT_MP4=1
# MAX_VOD_SIZE_MB=2048
# MAX_VOD_SIZE_MB=2048
# VOD_NO_NORMALIZE=0
# VOD_NO_NORMALIZE=0
@@ -221,6 +221,11 @@ med_xyz/{captions/*.vtt, storyboard.webp+.vtt, waveform, metadata}
@@ -221,6 +221,11 @@ med_xyz/{captions/*.vtt, storyboard.webp+.vtt, waveform, metadata}
never decoded end to end. The layout (`sprite: { count, interval_seconds, columns, rows, tile_width, tile_height }`)
never decoded end to end. The layout (`sprite: { count, interval_seconds, columns, rows, tile_width, tile_height }`)
and metadata are the seek path's, so the player contract is unchanged. A source without a timeline, or with
and metadata are the seek path's, so the player contract is unchanged. A source without a timeline, or with
`MEDIA_HLS_ENABLED` off, keeps the one-fast-seek-per-frame path; captions stay with AI's `media.analyze`.
`MEDIA_HLS_ENABLED` off, keeps the one-fast-seek-per-frame path; captions stay with AI's `media.analyze`.
+ **F3.2, first step (2026-10-24): the DVR playlist over the recording itself.** While an MP4 recording is written,
+ `/v/:id/dvr.m3u8` is an EVENT playlist whose init segment and ~6 s segments are slices of the growing file
+ (`dvr/init.mp4`, `dvr/<offset>-<length>.m4s`; server/vod/fmp4-index.js reads its moof/mdat pairs as they are
+ appended), so Live's DVR seeks with no second copy; `VOD_DVR_SNAPSHOT_MP4=0` then stops the per-pass copy of MP4
+ recordings. Public and unlisted recordings only (an HLS player sends no credentials).
**Still open in F3:** the growing live/DVR playlist written as OpenRestream segments (F3.2), write-behind durability for
**Still open in F3:** the growing live/DVR playlist written as OpenRestream segments (F3.2), write-behind durability for
live segments and its upload-lag metric, captions on the timeline, and Live's player moving to HLS. Segment-bucket
live segments and its upload-lag metric, captions on the timeline, and Live's player moving to HLS. Segment-bucket
demand (F2.4) now records reads per timeline segment; deciding placement per segment stays F5's. Renditions are F4.
demand (F2.4) now records reads per timeline segment; deciding placement per segment stays F5's. Renditions are F4.
@@ -37,6 +37,10 @@ const config = {
@@ -37,6 +37,10 @@ const config = {
path: process.env.VOD_PATH || './data/vods',
path: process.env.VOD_PATH || './data/vods',
clipsPath: process.env.CLIPS_PATH || './data/clips',
clipsPath: process.env.CLIPS_PATH || './data/clips',
maxSizeMb: intEnv('MAX_VOD_SIZE_MB', 2048),
maxSizeMb: intEnv('MAX_VOD_SIZE_MB', 2048),
+ // While an MP4 recording is written, its DVR is the playlist over the file itself (/v/:id/dvr.m3u8,
+ // vod/fmp4-index.js). VOD_DVR_SNAPSHOT_MP4=0 also stops the periodic seekable copy of MP4 recordings (a full
+ // copy-remux each pass: twice the disk and the I/O) once every DVR player reads the playlist. WebM keeps it.
+ dvrSnapshotMp4: process.env.VOD_DVR_SNAPSHOT_MP4 !== '0',
},
},
pastes: {
pastes: {
path: process.env.PASTES_PATH || './data/pastes',
path: process.env.PASTES_PATH || './data/pastes',
@@ -248,6 +248,55 @@ router.get('/v/:id', optionalIdentity, async (req, res) => {
@@ -248,6 +248,55 @@ router.get('/v/:id', optionalIdentity, async (req, res) => {
}
}
});
});
+// ── DVR of a recording in progress (vod/fmp4-index.js; docs/media-fabric.md F3.2) ──
+// While an MP4 recording is written, /v/:id/dvr.m3u8 is an EVENT playlist over the file itself: dvr/init.mp4 (its ftyp
+// and moov) and dvr/<offset>-<length>.m4s (whole moof+mdat fragments, about 6 s a segment), so a DVR player seeks
+// anywhere in the stream without a second copy of the recording. Public and unlisted recordings only (an HLS player
+// sends no credentials), CORS-open like the other public media. The playlist is never cached; a segment's bytes never
+// change while the file grows. Not recording (finalize rewrites the file) or not an MP4: 404, and players keep /v/:id.
+const DVR_SEGMENT = /^(\d{1,15})-(\d{1,10})\.m4s$/;
+const DVR_SEGMENT_MAX_BYTES = 64 * 1024 * 1024;
+async function dvrSource(req, res) {
+ if (drill.refuseBytes(res)) return null;
+ if (!/^\d+$/.test(req.params.id)) { notFound(res); return null; }
+ const vod = await db.getVodById(parseInt(req.params.id, 10));
+ if (!vod || vod.clips_only || !vod.is_recording || recordVisibility(vod) === 'private') { notFound(res); return null; }
+ const vodStorage = require('../vod/vod-storage');
+ const filePath = [vodStorage.localPathForVod(vod), vod.file_path].find((p) => p && p.endsWith('.mp4') && fs.existsSync(p));
+ if (!filePath) { notFound(res); return null; }
+ const index = await require('../vod/fmp4-index').refresh(filePath);
+ if (index.broken || !index.initEnd) { notFound(res); return null; }
+ res.set({ 'Access-Control-Allow-Origin': '*', 'Cross-Origin-Resource-Policy': 'cross-origin', 'X-Content-Type-Options': 'nosniff', 'X-Robots-Tag': 'noindex' });
+ return { vod, filePath, index };
+}
+const dvrRoute = (fn) => async (req, res) => {
+ try {
+ const s = await dvrSource(req, res);
+ if (s) await fn(req, res, s);
+ } catch (err) {
+ console.error('[Public] DVR error:', err.message);
+ if (!res.headersSent) res.status(500).json({ error: 'Failed to serve the DVR' });
+ }
+};
+router.get('/v/:id/dvr.m3u8', dvrRoute(async (req, res, { index }) => {
+ res.set('Cache-Control', 'no-cache, no-store, must-revalidate');
+ res.type('application/vnd.apple.mpegurl').send(require('../vod/fmp4-index').playlist(index));
+}));
+router.get('/v/:id/dvr/init.mp4', dvrRoute(async (req, res, { filePath, index }) => {
+ res.writeHead(200, { 'Content-Type': 'video/mp4', 'Content-Length': index.initEnd, 'Cache-Control': 'public, max-age=3600' });
+ sendFileStream(res, filePath, { start: 0, end: index.initEnd - 1 });
+}));
+router.get('/v/:id/dvr/:segment', dvrRoute(async (req, res, { filePath, index }) => {
+ const m = DVR_SEGMENT.exec(String(req.params.segment || ''));
+ const last = index.fragments[index.fragments.length - 1];
+ const offset = m ? Number(m[1]) : NaN;
+ const length = m ? Number(m[2]) : NaN;
+ // Only bytes already indexed (whole fragments after the init segment): never a part still being written.
+ if (!m || !last || length <= 0 || length > DVR_SEGMENT_MAX_BYTES || offset < index.initEnd || offset + length > last.offset + last.length) return notFound(res);
+ res.writeHead(200, { 'Content-Type': 'video/iso.segment', 'Content-Length': length, 'Cache-Control': 'public, max-age=3600' });
+ sendFileStream(res, filePath, { start: offset, end: offset + length - 1 });
+}));
+
// ── Clip playback ────────────────────────────────────────────
// ── Clip playback ────────────────────────────────────────────
router.get('/c/:id', optionalIdentity, async (req, res) => {
router.get('/c/:id', optionalIdentity, async (req, res) => {
try {
try {
@@ -258,6 +258,7 @@ async function _doFinalize(vodId, opts) {
@@ -258,6 +258,7 @@ async function _doFinalize(vodId, opts) {
// The live seekable copy is no longer needed: drop it (and stop a live pass still writing one)
// The live seekable copy is no longer needed: drop it (and stop a live pass still writing one)
// before the final remux, whose own copy of the recording needs that room on the disk.
// before the final remux, whose own copy of the recording needs that room on the disk.
await tools.cleanupSeekableFile(filePath);
await tools.cleanupSeekableFile(filePath);
+ require('./fmp4-index').forget(filePath); // the DVR playlist's index: the remux below rewrites the file
// Remux for proper seeking support (fast copy-mode, no re-encode). Its last packet time is a
// Remux for proper seeking support (fast copy-mode, no re-encode). Its last packet time is a
// measurement of the file (source 'remux'); a failed remux measures nothing.
// measurement of the file (source 'remux'); a failed remux measures nothing.
new file mode 100644
new file mode 100644
@@ -0,0 +1,199 @@
@@ -0,0 +1,199 @@
+'use strict';
+/**
+ * OpenVibe.Media — the DVR index of a recording while it is written (docs/media-fabric.md §3, F3.2, first step).
+ *
+ * A recording in progress is a fragmented MP4 (recorder.js: +frag_keyframe+empty_moov+default_base_moof): ftyp and an
+ * empty moov first (the init segment), then a moof+mdat pair per keyframe. Each pair is a CMAF fragment a player can
+ * start at, so the growing file already is an HLS stream: this module walks its top-level boxes as they are appended
+ * and answers the fragments with their offsets and times. The DVR playlist (public/routes.js /v/:id/dvr.m3u8) names
+ * them as slices of the file itself, so live seeking needs no second copy of the recording (until 2026-10, a full
+ * copy-remux every pass doubled the disk and the I/O of every long stream).
+ *
+ * Only whole boxes are indexed: a fragment still being written (its moof or mdat runs past the end of the file) waits
+ * for the next read. Each read starts where the last stopped, so a refresh costs a stat and the new boxes' headers (a
+ * moof is read whole, a few KB; an mdat only by its header). Times come from the timescale in the moov's mdhd and each
+ * fragment's tfdt and trun (or tfhd/trex defaults), on the video track when there is one.
+ */
+const fsp = require('fs').promises;
+
+const MAX_FRAGMENT_BYTES = 256 * 1024 * 1024; // a moof+mdat bigger than this is not a fragment the recorder writes
+const MAX_MOOV_BYTES = 4 * 1024 * 1024;
+const _states = new Map(); // file path → index state (one per recording; dropped by forget())
+
+function emptyState() {
+ return { offset: 0, initEnd: 0, tracks: new Map(), videoId: null, fragments: [], pendingMoof: null, broken: null };
+}
+
+const u32 = (b, o) => b.readUInt32BE(o);
+const u64 = (b, o) => Number(b.readBigUInt64BE(o));
+const type4 = (b, o) => b.toString('latin1', o, o + 4);
+
+/** The child boxes of a box body: [{ type, start, size, body }] (start/body relative to `buf`). */
+function children(buf, start = 0, end = buf.length) {
+ const out = [];
+ let o = start;
+ while (o + 8 <= end) {
+ let size = u32(buf, o);
+ const type = type4(buf, o + 4);
+ let header = 8;
+ if (size === 1) { if (o + 16 > end) break; size = u64(buf, o + 8); header = 16; }
+ else if (size === 0) size = end - o;
+ if (size < header || o + size > end) break;
+ out.push({ type, start: o, size, body: o + header });
+ o += size;
+ }
+ return out;
+}
+const child = (buf, box, type) => children(buf, box.body, box.start + box.size).find((c) => c.type === type) || null;
+
+/** Tracks from a moov: Map(track_ID → { timescale, handler, defaultDuration }). */
+function parseMoov(buf) {
+ const tracks = new Map();
+ const root = { start: 0, size: buf.length, body: 0 };
+ const defaults = new Map();
+ const mvex = child(buf, root, 'mvex');
+ if (mvex) {
+ for (const trex of children(buf, mvex.body, mvex.start + mvex.size).filter((c) => c.type === 'trex')) {
+ const p = trex.body + 4; // version + flags
+ defaults.set(u32(buf, p), u32(buf, p + 8)); // track_ID, default_sample_description_index, default_sample_duration
+ }
+ }
+ for (const trak of children(buf, root.body, root.size).filter((c) => c.type === 'trak')) {
+ const tkhd = child(buf, trak, 'tkhd');
+ const mdia = child(buf, trak, 'mdia');
+ if (!tkhd || !mdia) continue;
+ const v = buf[tkhd.body];
+ const id = u32(buf, tkhd.body + 4 + (v === 1 ? 16 : 8));
+ const mdhd = child(buf, mdia, 'mdhd');
+ const hdlr = child(buf, mdia, 'hdlr');
+ if (!mdhd) continue;
+ const mv = buf[mdhd.body];
+ const timescale = u32(buf, mdhd.body + 4 + (mv === 1 ? 16 : 8));
+ const handler = hdlr ? type4(buf, hdlr.body + 8) : '';
+ tracks.set(id, { timescale, handler, defaultDuration: defaults.get(id) || 0 });
+ }
+ return tracks;
+}
+
+/** One moof's per-track { start, duration } in that track's timescale. */
+function parseMoof(buf, tracks) {
+ const out = new Map();
+ const root = { start: 0, size: buf.length, body: 0 };
+ for (const traf of children(buf, root.body, root.size).filter((c) => c.type === 'traf')) {
+ const tfhd = child(buf, traf, 'tfhd');
+ if (!tfhd) continue;
+ const flags = u32(buf, tfhd.body) & 0xffffff;
+ const id = u32(buf, tfhd.body + 4);
+ let p = tfhd.body + 8;
+ if (flags & 0x01) p += 8; // base_data_offset
+ if (flags & 0x02) p += 4; // sample_description_index
+ let defaultDuration = (tracks.get(id) || {}).defaultDuration || 0;
+ if (flags & 0x08) defaultDuration = u32(buf, p);
+ const tfdt = child(buf, traf, 'tfdt');
+ const start = tfdt ? (buf[tfdt.body] === 1 ? u64(buf, tfdt.body + 4) : u32(buf, tfdt.body + 4)) : null;
+ let duration = 0;
+ for (const trun of children(buf, traf.body, traf.start + traf.size).filter((c) => c.type === 'trun')) {
+ const tf = u32(buf, trun.body) & 0xffffff;
+ const count = u32(buf, trun.body + 4);
+ let q = trun.body + 8;
+ if (tf & 0x001) q += 4; // data_offset
+ if (tf & 0x004) q += 4; // first_sample_flags
+ const per = ((tf & 0x100) ? 4 : 0) + ((tf & 0x200) ? 4 : 0) + ((tf & 0x400) ? 4 : 0) + ((tf & 0x800) ? 4 : 0);
+ if (tf & 0x100) { for (let i = 0; i < count; i++) duration += u32(buf, q + i * per); }
+ else duration += count * defaultDuration;
+ }
+ out.set(id, { start, duration });
+ }
+ return out;
+}
+
+async function readAt(fh, offset, length) {
+ const buf = Buffer.alloc(length);
+ const { bytesRead } = await fh.read(buf, 0, length, offset);
+ return bytesRead === length ? buf : null;
+}
+
+/**
+ * Index what was appended to `filePath` since the last call. → { initEnd, fragments: [{ offset, length, start, duration }],
+ * size } with times in seconds, or { broken } when the file is not a fragmented MP4 this module reads.
+ */
+async function refresh(filePath) {
+ let st = _states.get(filePath);
+ if (!st) _states.set(filePath, st = emptyState());
+ if (st.broken) return { broken: st.broken };
+ const fh = await fsp.open(filePath, 'r');
+ try {
+ const size = (await fh.stat()).size;
+ while (st.offset + 8 <= size) {
+ const head = await readAt(fh, st.offset, Math.min(16, size - st.offset));
+ if (!head) break;
+ let boxSize = u32(head, 0);
+ const type = type4(head, 4);
+ let header = 8;
+ if (boxSize === 1) { if (head.length < 16) break; boxSize = u64(head, 8); header = 16; }
+ if (boxSize === 0) break; // runs to the end: still being written
+ if (boxSize < header) { st.broken = `bad box at ${st.offset}`; break; }
+ if (st.offset + boxSize > size) break; // not whole yet
+ if (type === 'moov') {
+ if (boxSize > MAX_MOOV_BYTES) { st.broken = 'moov too large'; break; }
+ const body = await readAt(fh, st.offset + header, boxSize - header);
+ st.tracks = parseMoov(body);
+ for (const [id, t] of st.tracks) if (t.handler === 'vide' && st.videoId == null) st.videoId = id;
+ st.initEnd = st.offset + boxSize;
+ } else if (type === 'moof') {
+ if (!st.initEnd) { st.broken = 'moof before moov'; break; }
+ const body = await readAt(fh, st.offset + header, boxSize - header);
+ st.pendingMoof = { offset: st.offset, times: parseMoof(body, st.tracks) };
+ } else if (type === 'mdat' && st.pendingMoof) {
+ const m = st.pendingMoof;
+ const length = st.offset + boxSize - m.offset;
+ const id = st.videoId != null && m.times.has(st.videoId) ? st.videoId : m.times.keys().next().value;
+ const t = m.times.get(id);
+ const track = st.tracks.get(id);
+ if (t && track && track.timescale && length <= MAX_FRAGMENT_BYTES) {
+ const start = t.start != null ? t.start / track.timescale : (st.fragments.length ? st.fragments[st.fragments.length - 1].start + st.fragments[st.fragments.length - 1].duration : 0);
+ st.fragments.push({ offset: m.offset, length, start, duration: t.duration / track.timescale });
+ }
+ st.pendingMoof = null;
+ } else if (type === 'ftyp' || type === 'free' || type === 'skip' || type === 'styp' || type === 'sidx' || type === 'mfra' || type === 'prft') {
+ // nothing to index
+ } else if (!st.initEnd && type !== 'mdat') {
+ st.broken = `unexpected ${type} before moov`; break;
+ }
+ st.offset += boxSize;
+ }
+ return { initEnd: st.initEnd, fragments: st.fragments, size, broken: st.broken || undefined };
+ } finally {
+ await fh.close();
+ }
+}
+
+/** Fragments grouped into segments of at least `target` seconds (each starts on a fragment, so on a keyframe). */
+function segments(fragments, target = 6) {
+ const out = [];
+ let cur = null;
+ for (const f of fragments) {
+ if (cur && cur.offset + cur.length === f.offset && cur.duration < target) { cur.length += f.length; cur.duration += f.duration; }
+ else { cur = { offset: f.offset, length: f.length, start: f.start, duration: f.duration }; out.push(cur); }
+ }
+ return out;
+}
+
+/**
+ * The EVENT playlist of a recording in progress: the init segment and every whole segment so far, as URIs relative to
+ * the playlist (dvr/init.mp4, dvr/<offset>-<length>.m4s). `ended` closes it (#EXT-X-ENDLIST).
+ */
+function playlist(index, { target = 6, ended = false } = {}) {
+ const segs = segments(index.fragments, target);
+ const longest = segs.reduce((m, s) => Math.max(m, s.duration), 0);
+ const lines = ['#EXTM3U', '#EXT-X-VERSION:7', `#EXT-X-TARGETDURATION:${Math.max(target + 4, Math.ceil(longest))}`,
+ '#EXT-X-MEDIA-SEQUENCE:0', '#EXT-X-PLAYLIST-TYPE:EVENT', '#EXT-X-INDEPENDENT-SEGMENTS', `#EXT-X-MAP:URI="dvr/init.mp4"`];
+ for (const s of segs) lines.push(`#EXTINF:${s.duration.toFixed(3)},`, `dvr/${s.offset}-${s.length}.m4s`);
+ if (ended) lines.push('#EXT-X-ENDLIST');
+ return `${lines.join('\n')}\n`;
+}
+
+/** Forget a recording's index (finalized, or its file replaced). */
+function forget(filePath) { _states.delete(filePath); }
+
+module.exports = { refresh, segments, playlist, forget, _parseMoov: parseMoov, _parseMoof: parseMoof, MAX_FRAGMENT_BYTES };
@@ -335,6 +335,8 @@ class StreamRecorder {
@@ -335,6 +335,8 @@ class StreamRecorder {
[elapsed, stat.size, rec.vodId]));
[elapsed, stat.size, rec.vodId]));
} catch {}
} catch {}
+ // An MP4's DVR is its playlist (/v/:id/dvr.m3u8): with VOD_DVR_SNAPSHOT_MP4=0 it gets no copy at all.
+ if (rec.filePath.endsWith('.mp4') && !config.vod.dvrSnapshotMp4) return;
tools.remuxForLiveSeeking(rec.filePath).catch(() => {});
tools.remuxForLiveSeeking(rec.filePath).catch(() => {});
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,128 @@
@@ -0,0 +1,128 @@
+'use strict';
+// The DVR playlist of a recording in progress (vod/fmp4-index.js, /v/:id/dvr.m3u8): with real ffmpeg, a fragmented MP4
+// written the way the recorder writes it (+frag_keyframe+empty_moov+default_base_moof) is indexed as it grows, a
+// fragment cut off at the end of the file waits for its bytes, the playlist names the init segment and whole segments as
+// slices of the file, the slices are the file's own bytes, and init + segments decode to the whole recording. Private,
+// finished or out-of-range requests are 404s.
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const { spawnSync } = require('child_process');
+
+(async () => {
+ if (spawnSync('ffmpeg', ['-version']).status !== 0 || spawnSync('ffprobe', ['-version']).status !== 0) {
+ console.log('dvr playlist: skipped (ffmpeg not found)');
+ process.exit(0);
+ }
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-dvr-hls-'));
+ const dir = (n) => { const d = path.join(tmp, n); fs.mkdirSync(d, { recursive: true }); return d; };
+ process.env.VOD_PATH = dir('vods');
+ process.env.CLIPS_PATH = dir('clips');
+ process.env.FILES_PATH = dir('files');
+ process.env.OBJECTS_PATH = dir('objects');
+ process.env.THUMBNAILS_PATH = dir('thumbnails');
+ process.env.PASTES_PATH = dir('pastes');
+ process.env.MEDIA_PUBLIC_URL = 'https://media.test';
+
+ const ffmpeg = (...args) => { const r = spawnSync('ffmpeg', ['-v', 'error', '-y', ...args], { encoding: 'utf8' }); assert.strictEqual(r.status, 0, r.stderr); };
+ const duration = (file) => Number(spawnSync('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', file], { encoding: 'utf8' }).stdout.trim());
+ // 12 s, a keyframe every second, H.264-less on purpose (mpeg4 + AAC, like dvr-seek.test.js): what matters is the box layout.
+ const full = path.join(tmp, 'full.mp4');
+ ffmpeg('-f', 'lavfi', '-i', 'testsrc=duration=12:size=160x90:rate=10', '-f', 'lavfi', '-i', 'sine=duration=12',
+ '-c:v', 'mpeg4', '-g', '10', '-c:a', 'aac', '-movflags', '+frag_keyframe+empty_moov+default_base_moof', '-f', 'mp4', full);
+ const bytes = fs.readFileSync(full);
+
+ const fmp4 = require('../server/vod/fmp4-index');
+
+ // The whole file.
+ const whole = await fmp4.refresh(full);
+ assert.ok(whole.initEnd > 0 && whole.initEnd < 4096, `init segment ${whole.initEnd} bytes`);
+ assert.ok(whole.fragments.length >= 11 && whole.fragments.length <= 13, `${whole.fragments.length} fragments`);
+ const total = whole.fragments.reduce((s, f) => s + f.duration, 0);
+ assert.ok(Math.abs(total - 12) < 0.3, `fragments cover ${total} s`);
+ assert.strictEqual(whole.fragments[0].offset, whole.initEnd, 'the first fragment follows the init segment');
+ for (let i = 1; i < whole.fragments.length; i++) {
+ const a = whole.fragments[i - 1], b = whole.fragments[i];
+ assert.strictEqual(b.offset, a.offset + a.length, 'fragments are contiguous');
+ assert.ok(Math.abs(b.start - (a.start + a.duration)) < 0.05, 'times follow on');
+ }
+ const segs = fmp4.segments(whole.fragments, 6);
+ assert.strictEqual(segs.length, 2, 'two ~6 s segments');
+ assert.ok(segs.every((s) => s.duration >= 5.9), 'each segment at least the target');
+
+ // Growing: half the file, a fragment cut off mid-way, then the rest.
+ const growing = path.join(process.env.VOD_PATH, 'vod-live-7.mp4');
+ const cut = whole.fragments[5].offset + Math.floor(whole.fragments[5].length / 2);
+ fs.writeFileSync(growing, bytes.subarray(0, cut));
+ const part = await fmp4.refresh(growing);
+ assert.strictEqual(part.fragments.length, 5, 'the cut-off fragment is not indexed');
+ fs.appendFileSync(growing, bytes.subarray(cut));
+ const grown = await fmp4.refresh(growing);
+ assert.deepStrictEqual(grown.fragments.map((f) => [f.offset, f.length]), whole.fragments.map((f) => [f.offset, f.length]), 'the next read finishes it');
+
+ // Routes.
+ const db = require('../server/db/database');
+ await db.upsertApp({ app_id: 'live', api_key: 'live-key' });
+ fmp4.forget(growing);
+ fs.writeFileSync(growing, bytes.subarray(0, cut)); // still being written
+ await db.run(`INSERT INTO vods (id, app_id, user_id, title, file_path, is_public, visibility, is_recording) OVERRIDING SYSTEM VALUE
+ VALUES (7, 'live', 5, 'Still live', ?, 1, 'public', 1)`, [growing]);
+ const priv = path.join(process.env.VOD_PATH, 'vod-live-8.mp4');
+ fs.writeFileSync(priv, bytes);
+ await db.run(`INSERT INTO vods (id, app_id, user_id, title, file_path, is_public, visibility, is_recording) OVERRIDING SYSTEM VALUE
+ VALUES (8, 'live', 5, 'Private live', ?, 0, 'private', 1)`, [priv]);
+ const express = require('express');
+ const app = express();
+ app.use('/', require('../server/public/routes'));
+ const server = http.createServer(app);
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const get = async (p) => { const r = await fetch(base + p); return { status: r.status, headers: r.headers, body: Buffer.from(await r.arrayBuffer()) }; };
+
+ let pl = await get('/v/7/dvr.m3u8');
+ assert.strictEqual(pl.status, 200);
+ assert.match(pl.headers.get('content-type'), /mpegurl/);
+ assert.strictEqual(pl.headers.get('access-control-allow-origin'), '*');
+ assert.match(pl.headers.get('cache-control'), /no-store/);
+ let text = pl.body.toString();
+ assert.match(text, /#EXT-X-PLAYLIST-TYPE:EVENT/);
+ assert.match(text, /#EXT-X-MAP:URI="dvr\/init.mp4"/);
+ assert.ok(!/#EXT-X-ENDLIST/.test(text), 'still recording: no end');
+ const uris = () => text.split('\n').filter((l) => l.startsWith('dvr/') && l.endsWith('.m4s'));
+ assert.strictEqual(uris().length, 1, 'five fragments so far: one ~6 s segment is not yet full, so one open segment');
+
+ const init = await get('/v/7/dvr/init.mp4');
+ assert.strictEqual(init.status, 200);
+ assert.ok(init.body.equals(bytes.subarray(0, whole.initEnd)), 'the init segment is the file\'s head');
+ const seg = await get(`/${'v/7/'}${uris()[0]}`);
+ assert.strictEqual(seg.status, 200);
+ assert.strictEqual(seg.headers.get('content-type'), 'video/iso.segment');
+ const [, o, l] = /dvr\/(\d+)-(\d+)\.m4s/.exec(uris()[0]).map(Number);
+ assert.ok(seg.body.equals(bytes.subarray(o, o + l)), 'a segment is the file\'s own bytes');
+ assert.strictEqual((await get(`/v/7/dvr/${cut}-100.m4s`)).status, 404, 'never bytes still being written');
+ assert.strictEqual((await get('/v/7/dvr/0-100.m4s')).status, 404, 'not the init segment as a media segment');
+ assert.strictEqual((await get('/v/7/dvr/x.m4s')).status, 404);
+
+ // The rest arrives: the playlist grows; init + every segment decode to the whole recording.
+ fs.appendFileSync(growing, bytes.subarray(cut));
+ text = (await get('/v/7/dvr.m3u8')).body.toString();
+ assert.strictEqual(uris().length, 2);
+ const joined = path.join(tmp, 'joined.mp4');
+ const parts = [init.body];
+ for (const u of uris()) parts.push((await get(`/v/7/${u}`)).body);
+ fs.writeFileSync(joined, Buffer.concat(parts));
+ assert.ok(Math.abs(duration(joined) - 12) < 0.3, `init + segments play ${duration(joined)} s`);
+
+ // Private, finished, missing: 404 like /v/:id's own answers.
+ assert.strictEqual((await get('/v/8/dvr.m3u8')).status, 404, 'a private recording has no open playlist');
+ await db.run('UPDATE vods SET is_recording = 0 WHERE id = 7');
+ assert.strictEqual((await get('/v/7/dvr.m3u8')).status, 404, 'finished: /v/:id serves it');
+ assert.strictEqual((await get('/v/999/dvr.m3u8')).status, 404);
+
+ server.close();
+ fs.rmSync(tmp, { recursive: true, force: true });
+ console.log('dvr playlist: indexing as it grows, the playlist, slices of the file, and a decodable whole all pass');
+ process.exit(0);
+})().catch((e) => { console.error(e); process.exit(1); });