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)

OP OpenVibers committed 20 days ago · 9b03178b79 · 8 files, +392 −0

Changes

ddiff --git a/.env.example b/.env.example
iindex c10021c..ea03380 100644
---- a/.env.example
++++ b/.env.example
@@@ -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
ddiff --git a/docs/media-fabric.md b/docs/media-fabric.md
iindex eb071f8..d72b0af 100644
---- a/docs/media-fabric.md
++++ b/docs/media-fabric.md
@@@ -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 for
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.
ddiff --git a/server/config.js b/server/config.js
iindex 7df7bae..98b5615 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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',
ddiff --git a/server/public/routes.js b/server/public/routes.js
iindex 71bd29d..2bda1af 100644
---- a/server/public/routes.js
++++ b/server/public/routes.js
@@@ -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 {
ddiff --git a/server/vod/finalize.js b/server/vod/finalize.js
iindex 121cf84..749d430 100644
---- a/server/vod/finalize.js
++++ b/server/vod/finalize.js
@@@ -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.
ddiff --git a/server/vod/fmp4-index.js b/server/vod/fmp4-index.js
nnew file mode 100644
iindex 0000000..d9ecb8d
---- /dev/null
++++ b/server/vod/fmp4-index.js
@@@ -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 };
ddiff --git a/server/vod/recorder.js b/server/vod/recorder.js
iindex 921e805..a59ed3a 100644
---- a/server/vod/recorder.js
++++ b/server/vod/recorder.js
@@@ -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(() => {});
}
ddiff --git a/test/dvr-playlist.test.js b/test/dvr-playlist.test.js
nnew file mode 100644
iindex 0000000..464b183
---- /dev/null
++++ b/test/dvr-playlist.test.js
@@@ -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); });