openvibers/openvibe.live Commits
A stream's history: recording parts, picture shapes and reconnects in stream time, used by studio clips, the rewind and transcripts (#379)
Changes
@@@ -77,6 +77,34 @@ Viewers can seek backwards in a live stream using the DVR controls:DVR availability appears after ~30 seconds of recording.++The bar spans the whole stream, not only the part being recorded. A broadcaster who reconnects (a phone dropping its++connection, a browser tab reloading) is recorded in parts, each its own VOD ("BIRD DRAMA (Part 2)"); the player reads++the stream's history and plays an earlier part from its own VOD when you seek into it, then carries on into the next.++Rewinding across a phone turn shows each stretch at its own shape: Media's DVR playlist starts a new period, with its++own init segment, at each change of shape.++++## A stream's history++++While a stream is live, Live keeps what happened to it, in stream time (seconds since it started), in++`stream_history` (`server/streaming/stream-history.js`):++++| Kind | What | Kept from |++|------|------|-----------|++| `part` | a recording part: its VOD, where it starts and stops, why it rolled | the recorder |++| `shape` | the picture the broadcaster sends changed size (`WxH`) | the studio's outbound video stats |++| `reconnect` | the broadcaster came back, and how long it was gone | the broadcast socket |++++`GET /api/streams/:id/history` answers `{ started_at, is_live, now_sec, parts, shapes, reconnects }`; a part whose++VOD the viewer cannot see is listed without it. Stream time minus a part's start is that part's own time, and that is++how the history is used:++++- **Clips from the go-live studio**: the clip panel's marks are stream time; the server cuts the part that recorded the++ moment, in that part's time (`POST /api/vods/clips` with `stream_id`, `stream_time: true`, `start_time`, `end_time`).++ Stream 2795's studio sent those times to part 1 and Media refused them ("Start time exceeds video duration").++- **Transcripts, the Rundown, Vibe's voice clips and AI moments** move stream-time lines into the VOD's own time. The++ speech capture's offsets are stream time across a capture restart (they used to fall behind by every gap).++- **The rewind** above.++## ClipsViewers can create clips from live streams:nnew file mode 100644@@@ -0,0 +1,24 @@++-- phase: expand++-- What happened during a live stream, kept so a seek, a clip or a transcript can use it later++-- (server/streaming/stream-history.js; owner, 2026-10-26: "save the historical events/changes that happen during a++-- livestream so that we can use that data when the user seeks"). One row per event, at_sec in stream time (seconds since++-- streams.started_at):++-- part a recording part: at_sec when it started, end_sec when it stopped, vod_id its VOD, detail++-- {"part":2,"why":"..."}. A stream whose broadcaster reconnects is recorded in parts, each its own VOD, so++-- stream time minus a part's at_sec is that VOD's time.++-- shape the broadcaster's picture changed size: detail {"width":1280,"height":720}, vod_id the part recording it.++-- reconnect the broadcaster came back after dropping: detail {"gap_s":4.2}.++-- A new table, so the N-1 release neither reads nor writes it.++CREATE TABLE IF NOT EXISTS stream_history (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ stream_id bigint NOT NULL,++ kind text COLLATE "C" NOT NULL,++ at_sec double precision NOT NULL,++ end_sec double precision,++ vod_id bigint,++ detail text COLLATE "C",++ created_at text COLLATE "C" DEFAULT ov_now(),++ FOREIGN KEY (stream_id) REFERENCES streams(id) ON DELETE CASCADE++);++CREATE INDEX IF NOT EXISTS idx_stream_history_stream ON stream_history (stream_id, at_sec);++CREATE INDEX IF NOT EXISTS idx_stream_history_vod ON stream_history (vod_id) WHERE vod_id IS NOT NULL;@@@ -2096,7 +2096,19 @@ async function _aiTlToggleTranscript(btn) {}const nSpeech = blocks.filter(b => b.kind === 'speech').length, nSound = blocks.filter(b => b.kind === 'sounds').length;const cov = data.coverageSec ? `${_aiTimeFmt(data.coverageSec)} of speech transcribed` : '';-- const jumpFor = (t, cls = 'ai-tl-ts') => vod ? `<a class="${cls}" href="${videoHref('vod', vod, Math.floor(t))}" onclick="return handleLinkClick(event, '${videoHref('vod', vod, Math.floor(t))}')" title="Watch from here">${_aiTimeFmt(t)}</a>` : `<span class="${cls}">${_aiTimeFmt(t)}</span>`;++ // A stream recorded in parts links each moment into the part that recorded it, at that part's own time.++ const parts = Array.isArray(data.parts) ? data.parts.filter(p => p && p.vod_id && Number.isFinite(p.at)) : [];++ const where = (t) => {++ let p = null;++ for (const x of parts) if (x.at <= t + 0.5) p = x;++ return p ? { vod: p.vod_id, at: Math.max(0, t - p.at) } : vod ? { vod, at: t } : null;++ };++ const jumpFor = (t, cls = 'ai-tl-ts') => {++ const w = where(t);++ if (!w) return `<span class="${cls}">${_aiTimeFmt(t)}</span>`;++ const href = videoHref('vod', w.vod, Math.floor(w.at));++ return `<a class="${cls}" href="${href}" onclick="return handleLinkClick(event, '${href}')" title="Watch from here">${_aiTimeFmt(t)}</a>`;++ };const toolbar = `<div class="ai-tl-tr-bar"><div class="ai-tl-tr-filter" role="tablist"><button type="button" class="active" data-f="all">All</button>@@@ -2212,6 +2212,19 @@ function setNavLiveIndicator(isLive) {/* ── Global Display Timers (Stats + Uptime) ──────────────────── */++/**++ * The picture the encoder sends changed size (the phone turned, WebRTC lowered the resolution): tell the server, which++ * keeps it in the stream's history (server/streaming/stream-history.js) for seeks and the record. Only what the encoder++ * really sends counts (its outbound stats), and only a change.++ */++function _reportBroadcastShape(ss, resolution) {++ const m = /^(\d+)x(\d+)$/.exec(resolution || '');++ if (!m || ss._reportedShape === resolution) return;++ const ws = ss.signalingWs;++ if (!ws || ws.readyState !== WebSocket.OPEN) return;++ try { ws.send(JSON.stringify({ type: 'shape', width: Number(m[1]), height: Number(m[2]) })); ss._reportedShape = resolution; } catch { /* next tick */ }++}++function startGlobalDisplayTimers() {// Stats polling — reads from active streamclearInterval(_globalStatsInterval);@@@ -2288,6 +2301,7 @@ function startGlobalDisplayTimers() {setT('bc-stat-bitrate', bitrateText);setT('bc-stat-fps', `${Math.round(frameRate)} fps`);setT('bc-stat-resolution', resolution || '--');++ _reportBroadcastShape(ss, hasStats ? resolution : '');setT('bc-stat-status', connState);setT('bc-stat-codec', codec || '--');@@@ -3864,6 +3878,7 @@ function connectSignaling(streamId) {const wsUrl = `${protocol}://${host}${portSuffix}/ws/broadcast?token=${token}&streamId=${ss.streamData.id}&role=broadcaster`;const ws = new WebSocket(wsUrl);ss.signalingWs = ws;++ ss._reportedShape = null; // a new socket (or a restarted server) hears the current shape againss.signalingIntentionalClose = false;ws.onopen = () => {@@@ -5513,18 +5528,7 @@ async function createBroadcastClip() {const ss = getActiveStreamState();if (!ss) { toast('No active stream', 'error'); return null; }-- // Auto-retry if VOD not ready yet (up to 3 attempts, 2s apart)-- if (!ss.vodId) {-- const btn = document.getElementById('bc-clip-create-btn');-- if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Waiting for VOD…'; }-- for (let attempt = 0; attempt < 3; attempt++) {-- await new Promise(r => setTimeout(r, 2000));-- const retry = getActiveStreamState();-- if (retry?.vodId) { ss.vodId = retry.vodId; break; }-- }-- if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fa-solid fa-scissors"></i> Create Clip'; }-- if (!ss.vodId) { toast('VOD recording not ready yet — try again in a few seconds', 'error'); return null; }-- }++ if (!ss.streamData?.id) { toast('The stream is still starting — try again in a few seconds', 'error'); return null; }const duration = _bcClipEnd - _bcClipStart;if (duration <= 0) { toast('End time must be after start time', 'error'); return null; }@@@ -5541,9 +5545,12 @@ async function createBroadcastClip() {const res = await fetch('/api/vods/clips', {method: 'POST',headers: { 'Content-Type': 'application/json' },++ // The marks are seconds since the stream started. A broadcaster who reconnects is recorded in parts, each++ // its own VOD, so the server picks the part that recorded the moment and turns the marks into its time++ // (stream 2795: these times sent to part 1 were past its end).body: JSON.stringify({-- vod_id: ss.vodId,-- stream_id: ss.streamData?.id,++ stream_id: ss.streamData.id,++ stream_time: true,start_time: _bcClipStart,end_time: _bcClipEnd,title: typed,@@@ -628,6 +628,12 @@ let dvrState = {hlsUrl: null, // an MP4 recording's DVR playlist (Media /v/<id>/dvr.m3u8): seeking with no snapshot copydvrHls: null, // the hls.js instance playing it while rewounddvrNative: false, // or the browser's own HLS (Safari) playing it++ dvrSrc: null, // the source the rewound video is playing (the recording's playlist or file, or an earlier part's)++ // A broadcaster who reconnects is recorded in parts, each its own VOD (the stream's history: GET /streams/:id/history).++ // The bar spans them all, in stream time: an earlier part plays from its own VOD.++ earlier: [], // finished parts before the recording one: [{ vodId, at, end, src, hls }] (at/end: stream seconds)++ activeAt: null, // where the recording part starts, in stream seconds (null: no history, the bar is that part)++ playingPart: null, // the earlier part playing while rewound, or null for the recording part};function loadExternalScriptOnce(src) {@@@ -2297,9 +2303,11 @@ async function initDVR(stream) {* Poll the server for live VOD info and update DVR state.*/async function pollDVR(streamId) {++ let recording = false;try {const data = await api(`/vods/stream/${streamId}/live`);if (data && data.vod) {++ recording = true;const vod = data.vod;dvrState.vodId = vod.id;if (vod.file_path) {@@@ -2314,20 +2322,75 @@ async function pollDVR(streamId) {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 (or the recording's own playlist)-- if (dvrState.seekable && (dvrState.vodFile || dvrState.hlsUrl)) {-- dvrState.active = true;-- showDVRControls();-- }}} catch {// No live VOD yet — normal for first ~30-60s}++ if (recording) await _pollDvrParts(streamId); // an ended stream (its live recording a 404) asks nothing more++ // Show DVR controls when we have a seekable copy (or the recording's own playlist), or earlier parts to rewind into++ if ((dvrState.seekable && (dvrState.vodFile || dvrState.hlsUrl)) || dvrState.earlier.length) {++ dvrState.active = true;++ showDVRControls();++ }// Continue polling every 20sdvrState.pollTimer = setTimeout(() => pollDVR(streamId), 20000);}++/** The stream's recording parts (its history): the earlier, finished ones to rewind into, and where the recording one starts. */++async function _pollDvrParts(streamId) {++ let h = null;++ try { h = await api(`/streams/${streamId}/history`); } catch { return; }++ const parts = Array.isArray(h && h.parts) ? h.parts.filter((p) => Number.isFinite(p.at)) : [];++ const rec = parts.find((p) => p.recording) || null;++ dvrState.activeAt = rec ? rec.at : null;++ dvrState.earlier = rec ? parts++ .filter((p) => !p.recording && p.vod_id && typeof p.src === 'string' && /^https:\/\//.test(p.src) && Number.isFinite(p.end) && p.end > p.at + 1 && p.at < rec.at)++ .map((p) => ({ vodId: p.vod_id, at: p.at, end: Math.min(p.end, rec.at), src: p.src, hls: !!p.hls })) : [];++}++++/**++ * The bar's span in seconds: from the first earlier part's start to the live edge of the recording part, in stream time;++ * without history, the recording part alone. → { start, total } (start in stream seconds).++ */++function _dvrSpan() {++ const first = dvrState.earlier[0];++ if (!first || dvrState.activeAt == null) return { start: dvrState.activeAt || 0, total: dvrState.duration };++ return { start: first.at, total: dvrState.activeAt - first.at + dvrState.duration };++}++++/** Where the rewound video is on the bar, in seconds from its start. */++function _dvrBarTime(video) {++ const span = _dvrSpan();++ const p = dvrState.playingPart;++ const base = p ? p.at : (dvrState.activeAt != null && dvrState.earlier.length ? dvrState.activeAt : span.start);++ return (base - span.start) + (video ? video.currentTime : 0);++}++++/** Seek to a point of the bar (seconds from its start): the recording part, or the earlier part that holds it. */++function _dvrSeekBar(barTime) {++ const span = _dvrSpan();++ const t = span.start + Math.max(0, Math.min(barTime, span.total));++ if (!dvrState.earlier.length || dvrState.activeAt == null || t >= dvrState.activeAt) {++ if (dvrState.seekable && (dvrState.vodFile || dvrState.hlsUrl)) return switchToDVR(t - (dvrState.earlier.length ? dvrState.activeAt : span.start));++ if (!dvrState.earlier.length) return;++ }++ let part = dvrState.earlier[0];++ for (const p of dvrState.earlier) if (p.at <= t) part = p;++ switchToDVR(Math.max(0, Math.min(t - part.at, part.end - part.at - 0.5)), part);++}++++/** Move the rewound video by `delta` seconds: within what it is playing, or on to the part that holds the new time. */++function _dvrNudge(video, delta) {++ const span = _dvrSpan();++ const target = Math.max(0, Math.min(_dvrBarTime(video) + delta, span.total));++ const p = dvrState.playingPart;++ const local = target - ((p ? p.at : (dvrState.earlier.length ? dvrState.activeAt : span.start)) - span.start);++ const length = p ? p.end - p.at : dvrState.duration;++ if (local >= 0 && local <= length) { video.currentTime = local; updateDVRUI(); return; }++ _dvrSeekBar(target);++}++/*** Show the DVR progress bar and related controls.*/@@@ -2379,13 +2442,15 @@ function updateDVRUI() {} else {// Rewound: how far behind live, with the position in the buffer as its titleconst video = document.getElementById('video-element');-- if (video && dvrState.duration > 0 && !dvrState.seeking) {-- const pct = (video.currentTime / dvrState.duration) * 100;++ const span = _dvrSpan();++ if (video && span.total > 0 && !dvrState.seeking) {++ const at = _dvrBarTime(video);++ const pct = (at / span.total) * 100;if (fill) fill.style.width = Math.min(pct, 100) + '%';if (timeEl) {timeEl.hidden = false;-- timeEl.textContent = `\u2212${_dvrFmtTime(dvrState.duration - video.currentTime)}`;-- timeEl.title = `${_dvrFmtTime(video.currentTime)} of ${_dvrFmtTime(dvrState.duration)} recorded; behind live`;++ timeEl.textContent = `\u2212${_dvrFmtTime(span.total - at)}`;++ timeEl.title = `${_dvrFmtTime(at)} of ${_dvrFmtTime(span.total)} recorded; behind live`;}}if (container) container.classList.add('dvr-rewound');@@@ -2406,13 +2471,13 @@ function setupDVRSeek() {wrap._dvrListenersAttached = true;const seekToPercent = (pct) => {-- if (!dvrState.seekable || dvrState.duration <= 0) return;-- const targetTime = pct * dvrState.duration;-- switchToDVR(targetTime);++ const span = _dvrSpan();++ if (!(dvrState.seekable || dvrState.earlier.length) || span.total <= 0) return;++ _dvrSeekBar(pct * span.total);};wrap.addEventListener('click', (e) => {-- if (!dvrState.active || !dvrState.seekable) return;++ if (!dvrState.active || !(dvrState.seekable || dvrState.earlier.length)) return;const rect = wrap.getBoundingClientRect();const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));seekToPercent(pct);@@@ -2420,7 +2485,7 @@ function setupDVRSeek() {// Drag to seekwrap.addEventListener('mousedown', (e) => {-- if (!dvrState.active || !dvrState.seekable) return;++ if (!dvrState.active || !(dvrState.seekable || dvrState.earlier.length)) return;dvrState.seeking = true;const rect = wrap.getBoundingClientRect();const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));@@@ -2445,7 +2510,7 @@ function setupDVRSeek() {// Touch seek (scoped to the bar so it works on mobile without a hover).wrap.addEventListener('touchstart', (e) => {-- if (!dvrState.active || !dvrState.seekable || !e.touches[0]) return;++ if (!dvrState.active || !(dvrState.seekable || dvrState.earlier.length) || !e.touches[0]) return;dvrState.seeking = true;const rect = wrap.getBoundingClientRect();const pct = Math.max(0, Math.min(1, (e.touches[0].clientX - rect.left) / rect.width));@@@ -2480,8 +2545,8 @@ function setupDVRSeek() {* and HLS/RTMP (detach live player).* @param {number} targetTime - Seek position in seconds*/--function switchToDVR(targetTime) {-- if ((!dvrState.vodFile && !dvrState.hlsUrl) || !dvrState.seekable) return;++function switchToDVR(targetTime, part = null) {++ if (!part && ((!dvrState.vodFile && !dvrState.hlsUrl) || !dvrState.seekable)) return;const video = document.getElementById('video-element');const canvas = document.getElementById('video-canvas');@@@ -2520,16 +2585,31 @@ function switchToDVR(targetTime) {video.style.display = 'block';video.srcObject = null;-- if (dvrState.hlsUrl) _playDvrHls(video, targetTime);++ dvrState.playingPart = part;++ if (part) {++ if (part.hls) _playDvrHls(video, targetTime, part.src);++ else _playDvrFile(video, targetTime, part.src);++ } else if (dvrState.hlsUrl) _playDvrHls(video, targetTime);else _playDvrFile(video, targetTime);-- console.log(`[DVR] Switched to VOD seeking at ${Math.round(targetTime)}s`);++ console.log(`[DVR] Switched to ${part ? `part from ${Math.round(part.at)}s` : 'the recording'} at ${Math.round(targetTime)}s`);++}++++/** An earlier part played to its end: carry on into the next part (or the recording one) without a gap of choice. */++function _dvrPartEnded() {++ const p = dvrState.playingPart;++ if (!p || dvrState.isLive) return;++ _dvrSeekBar(p.end - _dvrSpan().start + 0.01);}/** 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()}`;++function _playDvrFile(video, targetTime, src = null) {++ const file = src || dvrState.vodFile;++ if (!file) return;++ _destroyDvrHls();++ dvrState.dvrSrc = file;++ video.src = src ? src : `${file}?t=${Date.now()}`;++ if (src) video.addEventListener('ended', _dvrPartEnded, { once: true });video.addEventListener('loadedmetadata', function _seekOnLoad() {video.removeEventListener('loadedmetadata', _seekOnLoad);// Use server duration or video.duration, whichever is valid@@@ -2543,15 +2623,27 @@ function _playDvrFile(video, targetTime) {* 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); };++function _playDvrHls(video, targetTime, src = null) {++ const url = src || dvrState.hlsUrl;++ if ((dvrState.dvrHls || dvrState.dvrNative) && dvrState.dvrSrc === url) { video.currentTime = targetTime; video.play().catch(() => {}); return; }++ _destroyDvrHls();++ dvrState.dvrSrc = url;++ // The recording's playlist falls back to its file; an earlier part has no other source.++ // A recording that rolled to a new part meanwhile has a new playlist and file: its old one is gone, so back to live.++ const fallback = () => {++ _destroyDvrHls();++ if (src) return;++ if (url !== dvrState.hlsUrl) { if (!dvrState.isLive) jumpToLive(); return; }++ 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 (dvrState.isLive || dvrState.dvrSrc !== url) return; // back to live, or another source, meanwhile++ if (src) video.addEventListener('ended', _dvrPartEnded, { once: true });if (!window.Hls || !Hls.isSupported()) {if (!video.canPlayType('application/vnd.apple.mpegurl')) return fallback();dvrState.dvrNative = true;-- video.src = dvrState.hlsUrl;++ video.src = url;video.addEventListener('loadedmetadata', () => { video.currentTime = targetTime; video.play().catch(() => {}); }, { once: true });return;}@@@ -2560,10 +2652,10 @@ function _playDvrHls(video, targetTime) {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);++ console.warn(`[DVR] ${src ? 'the earlier part\'s' : 'the recording\'s'} playlist failed${src ? '' : ', using its file'}:`, data.details);fallback();});-- hls.loadSource(dvrState.hlsUrl);++ hls.loadSource(url);hls.attachMedia(video);}).catch(fallback);}@@@ -2571,6 +2663,9 @@ function _playDvrHls(video, targetTime) {function _destroyDvrHls() {if (dvrState.dvrHls) { try { dvrState.dvrHls.destroy(); } catch {} dvrState.dvrHls = null; }dvrState.dvrNative = false;++ dvrState.dvrSrc = null;++ const video = document.getElementById('video-element');++ if (video) video.removeEventListener('ended', _dvrPartEnded);}/**@@@ -2633,6 +2728,7 @@ function jumpToLive() {}dvrState.isLive = true;++ dvrState.playingPart = null;// Reset live buttonif (liveBtn) {@@@ -2652,6 +2748,9 @@ function destroyDVR() {if (dvrState.updateTimer) { clearInterval(dvrState.updateTimer); dvrState.updateTimer = null; }_destroyDvrHls();dvrState.hlsUrl = null;++ dvrState.earlier = [];++ dvrState.activeAt = null;++ dvrState.playingPart = null;dvrState.active = false;dvrState.isLive = true;dvrState.vodId = null;@@@ -2833,34 +2932,23 @@ function setupVideoControls() {if (container) {container.tabIndex = 0;container.addEventListener('keydown', (e) => {-- if (!dvrState.active || !dvrState.seekable) return;++ if (!dvrState.active || !(dvrState.seekable || dvrState.earlier.length)) return;const video = document.getElementById('video-element');if (!video) return;++ const span = _dvrSpan();switch (e.key) {case 'ArrowLeft':e.preventDefault();-- if (dvrState.isLive) {-- // First rewind: jump to 10s before live edge-- const pos = Math.max(0, dvrState.duration - 10);-- switchToDVR(pos);-- } else {-- // Already in DVR — rewind 5 more seconds-- const newTime = Math.max(0, video.currentTime - 5);-- video.currentTime = newTime;-- updateDVRUI();-- }++ // First rewind: 10 s before the live edge; then 5 s more each time, into an earlier part when it gets there++ if (dvrState.isLive) _dvrSeekBar(Math.max(0, span.total - 10));++ else _dvrNudge(video, -5);break;case 'ArrowRight':e.preventDefault();if (!dvrState.isLive) {-- const newTime = video.currentTime + 5;-- if (newTime >= dvrState.duration - 2) {-- jumpToLive();-- } else {-- video.currentTime = newTime;-- updateDVRUI();-- }++ if (_dvrBarTime(video) + 5 >= span.total - 2) jumpToLive();++ else _dvrNudge(video, 5);}break;}@@@ -167,7 +167,28 @@ async function _momentContext(streamId, vodId) {sounds = _sample((await db.getTimeline(streamId, { kind: 'sound', limit: 400 }) || []).filter(e => Number(e.confidence || 0) >= 0.45), 30);} catch { /* timeline optional */ }-- return { memories, transcript, clipTimes, spikes, sounds };++ return _inPart({ memories, transcript, clipTimes, spikes, sounds }, vodId);++}++++/**++ * The signals above are in stream time. A VOD that is one part of a stream (its broadcaster reconnected) starts later++ * (streaming/stream-history.js): its moments are picked from the signals inside it, moved to its own time. Clip times are++ * the VOD's own already. A VOD with no part on record keeps the stream's times, as before.++ */++async function _inPart(ctx, vodId) {++ const history = require('../streaming/stream-history');++ const part = await history.partForVod(vodId);++ if (!part) return ctx;++ const len = part.end != null ? part.end - part.at : Infinity;++ const inside = (t) => Number.isFinite(t) && t >= part.at && t - part.at <= len;++ const move = (t) => Math.round((t - part.at) * 10) / 10;++ return {++ ...ctx,++ memories: ctx.memories.filter(m => inside(Number(m.offset_seconds) || 0)).map(m => ({ ...m, offset_seconds: move(Number(m.offset_seconds) || 0) })),++ transcript: ctx.transcript.filter(t => inside(Number(t.start) || 0)).map(t => ({ ...t, start: move(Number(t.start) || 0), end: t.end == null ? null : move(Number(t.end)) })),++ spikes: ctx.spikes.filter(sp => inside(Number(sp.offset))).map(sp => ({ ...sp, offset: move(Number(sp.offset)) })),++ sounds: ctx.sounds.filter(e => inside(Number(e.start_sec))).map(e => ({ ...e, start_sec: move(Number(e.start_sec)), end_sec: e.end_sec == null ? null : move(Number(e.end_sec)) })),++ };}function _nearestMemory(memories, offset) {let best = null, bestD = Infinity;@@@ -248,15 +248,20 @@ router.get('/vod-transcripts', async (req, res) => {const shown = await Promise.all(ids.map((id) => media.getVod(id).then((v) => (v && !isPrivate(v) ? id : null), () => null)));const out = {};++ // Timeline rows are in stream time; a VOD that is one part of a stream (the broadcaster reconnected) starts++ // later than the stream did, so its rows are moved to its own time (streaming/stream-history.js).++ const history = require('../streaming/stream-history');++ const offsets = await history.vodOffsets(shown.filter((id) => id != null));for (const vodId of shown.filter((id) => id != null)) {let segments = [];let events = [];++ const at = (sec) => (sec == null ? sec : offsets.has(vodId) ? history.vodTime(sec, offsets.get(vodId)) : sec);// Prefer the timeline: it covers the whole stream and carries sound events.try {const rows = await db.getTimelineByVod(vodId);for (const r of rows) {-- if (r.kind === 'speech') segments.push({ start: r.start_sec, end: r.end_sec, text: r.text });-- else events.push({ start: r.start_sec, end: r.end_sec, label: r.label, confidence: r.confidence });++ if (r.kind === 'speech') segments.push({ start: at(r.start_sec), end: at(r.end_sec), text: r.text });++ else events.push({ start: at(r.start_sec), end: at(r.end_sec), label: r.label, confidence: r.confidence });}} catch { /* */ }// The timeline and the batch-transcribed blob are two views of the same audio@@@ -316,7 +321,17 @@ router.get('/transcript/:streamId', async (req, res) => {try { events = await db.getTimeline(sid, { kind: 'sound', limit: 2000 }) || []; } catch { /* */ }let coverageSec = 0;try { coverageSec = await db.getTimelineCoverage(sid) || 0; } catch { /* */ }-- res.json({ streamId: sid, vodId, segments, events, coverageSec });++ // A stream recorded in parts (its broadcaster reconnected): each part's VOD and where it starts in stream time, so++ // a line links into the part that recorded it. Public parts only, like vodId.++ let parts = [];++ try {++ const media = require('../media-client');++ const { isPrivate } = require('../media-proxy/access');++ const h = await require('../streaming/stream-history').forStream(sid);++ const shown = await Promise.all(h.parts.map((p) => (p.vod_id ? media.getVod(p.vod_id).then((v) => (v && !isPrivate(v) ? p : null), () => null) : null)));++ parts = shown.filter(Boolean).map((p) => ({ vod_id: p.vod_id, at: p.at, end: p.end }));++ } catch { /* no history: vodId alone */ }++ res.json({ streamId: sid, vodId, parts, segments, events, coverageSec });} catch (err) {res.status(500).json({ error: 'Failed to load transcript' });}@@@ -236,6 +236,18 @@ const _capturing = new Map(); // streamId -> { ff, dir, cleanup, startedAt, pr// Streams whose spool this process has started: only the first start drops leftover segments (a previous process's,// numbered from 0 again); a restart in this process continues the numbering and keeps what was captured.const _startedHere = new Set();++// streamId → [{ firstIndex, baseSec }]: each capture run's first segment number and when it began in stream time++// (seconds since streams.started_at). A capture that restarts (the broadcaster reconnected) numbers on from the last++// segment, but the seconds it missed are not in any segment: offsets counted from the index alone fell behind the++// stream by every gap, and started a few seconds after it went live. With each run's start, a segment's offset is++// stream time, the time the stream's history measures recording parts in (streaming/stream-history.js).++const _runs = new Map();++function noteRun(streamId, firstIndex, baseSec) {++ if (baseSec == null) return;++ const runs = (_runs.get(streamId) || []).filter((r) => r.firstIndex < firstIndex);++ runs.push({ firstIndex, baseSec });++ _runs.set(streamId, runs);++}// ffmpeg that ends soon after it started (no RTP from a dead producer, an input it cannot probe) is retried after a// growing pause instead of every timeline tick, and logged once per streak with its last error line.const _retry = new Map(); // streamId -> { at, delayMs, fails, reason }@@@ -293,6 +305,7 @@ async function startContinuousCapture(stream) {// every 22 s dropped its own segment each time).let firstIndex = 0;if (!_startedHere.has(stream.id)) {++ _runs.delete(stream.id);try {const stale = fs.readdirSync(dir).filter(n => /^seg-\d{6}\.wav$/.test(n));for (const n of stale) { try { fs.unlinkSync(path.join(dir, n)); } catch { /* */ } }@@@ -337,6 +350,7 @@ async function startContinuousCapture(stream) {const entry = { ff, dir, cleanup, startedAt: Date.now(), producerId, firstIndex };_capturing.set(stream.id, entry);_startedHere.add(stream.id);++ noteRun(stream.id, firstIndex, require('../streaming/stream-history').streamSeconds(stream, entry.startedAt));let lastErr = '';ff.stderr.on('data', (d) => { const lines = String(d).trim().split('\n'); lastErr = (lines[lines.length - 1] || lastErr).slice(0, 200); });let exited = false;@@@ -404,13 +418,16 @@ function pendingSegments(streamId, { includeLast = false } = {}) {const cur = _capturing.get(streamId) || {};const startedAt = cur.startedAt || null;const firstIndex = cur.firstIndex || 0;++ const runs = _runs.get(streamId) || [];return ready.slice(overflow).map(name => {const index = parseInt(name.slice(4, 10), 10);++ let run = null;++ for (const r of runs) if (r.firstIndex <= index) run = r;return {name,path: path.join(dir, name),index,-- offsetSec: index * SEGMENT_SEC,++ offsetSec: run ? Math.round((run.baseSec + (index - run.firstIndex) * SEGMENT_SEC) * 10) / 10 : index * SEGMENT_SEC,// Wall-clock instant this segment's audio ENDED, so the job can report how far// behind live the transcript actually is.endedAtMs: startedAt && index >= firstIndex ? startedAt + (index - firstIndex + 1) * SEGMENT_SEC * 1000 : null,@@@ -434,6 +451,7 @@ function spooledStreamIds() {/** Remove a stream's whole spool directory (call when the stream ends). */function purgeSpool(streamId) {_startedHere.delete(streamId);++ _runs.delete(streamId);_retry.delete(streamId);try { fs.rmSync(spoolDir(streamId), { recursive: true, force: true }); return true; }catch { return false; }@@@ -443,5 +461,5 @@ module.exports = {captureAudioChunk,startContinuousCapture, stopContinuousCapture, stopAllCaptures, isCapturing,pendingSegments, discardSegment, purgeSpool, spoolDir, spooledStreamIds,-- SEGMENT_SEC,++ SEGMENT_SEC, _noteRun: noteRun,};@@@ -112,8 +112,12 @@ async function clipMoment(stream, ev, settings, deps) {const media = deps.media || require('../media-client');const rec = recorder.getActiveRecording(stream.id);if (!rec || !rec.vodId) return say(stream, 'I can\'t clip right now: this stream isn\'t being recorded.', { speak: settings.speak, deps });-- const edge = Math.max(0, ((deps.now || Date.now)() - rec.startedAt) / 1000);-- const end = Math.min(Number(ev.start_sec) + 1, edge - 0.5);++ const t0 = rec.mediaStartedAt || rec.startedAt;++ const edge = Math.max(0, ((deps.now || Date.now)() - t0) / 1000);++ // The phrase's time is stream time; the recording (a later part, when the broadcaster reconnected) starts at t0.++ const partAt = require('../streaming/stream-history').streamSeconds(stream, t0);++ const said = Number(ev.start_sec) - (partAt != null && rec.mediaStartedAt ? partAt : 0);++ const end = Math.min(said + 1, edge - 0.5);const start = Math.max(0, end - CLIP_SEC);if (end - start < 5) return say(stream, 'Give me a few seconds of the stream first, then ask again.', { speak: settings.speak, deps });const title = `${String(stream.title || 'Stream moment').slice(0, 70)} (clipped by voice)`;@@@ -692,7 +692,8 @@ router.post('/clips', requireAuth, memUpload.single('video'), async (req, res) =const parsedVodId = vod_id ? parseInt(vod_id, 10) : null;const startTime = Number.parseFloat(req.body?.start_time);const endTime = Number.parseFloat(req.body?.end_time);-- const isLiveClip = !!req.file || ['true', '1', true].includes(req.body.live);++ const streamTime = ['true', '1', true].includes(req.body?.stream_time); // the studio's clip panel: a live stream's moment++ const isLiveClip = !!req.file || ['true', '1', true].includes(req.body.live) || streamTime;const sanitizedTitle = sanitizeClipTitle(title);if (accountTooNew(req.user)) return res.status(403).json({ error: 'Your account is too new to create clips. Please wait a minute.' });@@@ -730,6 +731,39 @@ router.post('/clips', requireAuth, memUpload.single('video'), async (req, res) =return res.status(201).json({ clip });}++ // ── Stream-time clip (the go-live studio's clip panel): start/end in seconds since the stream started ──++ // A stream whose broadcaster reconnected is recorded in parts, each its own VOD (stream 2795: VODs 6025 and++ // 6026). The panel measures from the stream's start, and used to send those times to the VOD it knew first:++ // "Start time exceeds video duration". The stream's history (streaming/stream-history.js) knows where each++ // part began, so the window goes to the part recording its end, in that part's own time.++ if (!req.file && parsedStreamId && !parsedVodId && streamTime) {++ if (!Number.isFinite(startTime) || !Number.isFinite(endTime)) return res.status(400).json({ error: 'start_time and end_time required' });++ if (startTime < 0 || endTime < 0) return res.status(400).json({ error: 'Time values cannot be negative' });++ const maxClipDuration = await db.getSetting('max_clip_duration') || 60;++ if (endTime - startTime < 1) return res.status(400).json({ error: 'Clip must be at least 1 second' });++ if (endTime - startTime > maxClipDuration) return res.status(400).json({ error: `Clips are limited to ${maxClipDuration} seconds` });++ const stream = await db.getStreamById(parsedStreamId);++ if (!stream) return res.status(404).json({ error: 'Stream not found' });++ if (!await db.isStreamClipRecordingEnabled(stream)) return res.status(403).json({ error: 'Clipping is disabled for this stream.' });++ const place = await clipPlace(stream, startTime, endTime);++ if (!place) return res.status(409).json({ error: 'This stream was not being recorded at that moment, so it cannot be clipped.', no_recording: true });++ let vod;++ try { vod = await media.getVod(place.vodId); } catch (err) { return mediaErr(res, err, 'VOD not found'); }++ if (!vod || !await access.canView(req.user, vod)) return vodNotFound(res);++ const clip = await media.createClip({++ vod_id: place.vodId,++ start_s: place.start,++ end_s: place.end,++ title: defaultClipTitle(sanitizedTitle, stream.title),++ user_id: req.user.id,++ stream_id: stream.id,++ visibility: await db.resolveStreamClipVisibility(stream),++ });++ noteClip(req.user.id);++ await _notifyStreamerOfClip(stream.id, req.user, sanitizedTitle, clip?.id);++ return res.status(201).json({ clip, vod_id: place.vodId, part: place.part });++ }++// ── LIVE clip — cut server-side from the stream's active Media recording ──if (isLiveClip && parsedStreamId && !parsedVodId) {const stream = await db.getStreamById(parsedStreamId);@@@ -753,7 +787,7 @@ router.post('/clips', requireAuth, memUpload.single('video'), async (req, res) =let dur = parseFloat(req.body.duration);if (!Number.isFinite(dur) || dur < 1) dur = 30;dur = Math.min(dur, maxDur);-- const recElapsed = Math.max(0, (Date.now() - rec.startedAt) / 1000);++ const recElapsed = Math.max(0, (Date.now() - (rec.mediaStartedAt || rec.startedAt)) / 1000);const safeEdge = Math.max(0, recElapsed - 1.5); // stay inside flushed footagelet clipEnd = parseFloat(req.body.at);if (!Number.isFinite(clipEnd) || clipEnd <= 0) clipEnd = safeEdge;@@@ -811,6 +845,29 @@ router.post('/clips', requireAuth, memUpload.single('video'), async (req, res) =}});++/**++ * Where a window of stream time (seconds since the stream started) was recorded: the part recording its end, and the++ * window in that part's own seconds → { vodId, part, start, end }, or null when nothing recorded it. Without a history++ * row (a part started before the history existed) the active recording is measured from its start.++ */++async function clipPlace(stream, startTime, endTime) {++ const history = require('../streaming/stream-history');++ let part = await history.partAt(stream.id, endTime);++ if (!part) {++ const rec = recorder.getActiveRecording(stream.id);++ const at = rec && rec.vodId ? history.streamSeconds(stream, rec.mediaStartedAt || rec.startedAt) : null;++ if (at == null || at > endTime) return null;++ part = { vod_id: rec.vodId, part: rec.part || 1, at, end: null };++ }++ if (!part.vod_id) return null;++ let end = endTime - part.at;++ if (part.end != null) end = Math.min(end, part.end - part.at);++ // A window that begins before its part did (the reconnect's gap): the part's own seconds only, at most as long as asked.++ const start = Math.max(0, end - (endTime - startTime));++ if (end - start < 1) return null;++ return { vodId: part.vod_id, part: part.part, start: Math.round(start * 10) / 10, end: Math.round(end * 10) / 10 };++}++async function _notifyStreamerOfClip(streamId, clipper, title, clipId) {try {if (!streamId) return;@@@ -53,12 +53,17 @@ async function segmentsOut(rows, voter) {const ppl = await people(rows.flatMap((r) => [r.user_id, ...(r.mentions || [])]));const tps = await topicsById(rows.map((r) => r.topic_id));const rx = await reactionsFor('segment', rows.map((r) => r.id), voter);++ // Segment and quote times are stream time; `sec` links into vod_id, which for a stream recorded in parts (its++ // broadcaster reconnected) starts later than the stream (streaming/stream-history.js).++ const history = require('../streaming/stream-history');++ const offsets = await history.vodOffsets(rows.map((r) => r.vod_id));++ const inVod = (r, sec) => Math.max(0, Math.floor(history.vodTime(sec, r.vod_id ? offsets.get(Number(r.vod_id)) : 0)));return rows.map((r) => ({type: 'segment', id: Number(r.id), title: r.title, what: r.what, take: r.take || null, quip: r.quip || null, score: Number(r.score),-- quotes: parseJson(r.quotes_json, []).map((q) => ({ text: q.text, en: q.en || null, sec: Math.max(0, Math.floor(Number(q.sec) || 0)) })),++ quotes: parseJson(r.quotes_json, []).map((q) => ({ text: q.text, en: q.en || null, sec: inVod(r, q.sec) })),topic: tps.get(Number(r.topic_id)) || null, user: ppl.get(Number(r.user_id)) || null,mentions: (r.mentions || []).map((id) => ppl.get(Number(id))).filter(Boolean),-- stream_id: Number(r.stream_id), vod_id: r.vod_id ? Number(r.vod_id) : null, sec: Math.max(0, Math.floor(Number(r.start_sec) || 0)),++ stream_id: Number(r.stream_id), vod_id: r.vod_id ? Number(r.vod_id) : null, sec: inVod(r, r.start_sec),said_at: r.said_at, reactions: rx.counts.get(Number(r.id)) || {}, my_reaction: rx.mine.get(Number(r.id)) || null,}));}@@@ -294,6 +294,7 @@ class BroadcastServer extends EventEmitter {}room.broadcaster = ws;console.log(`[Broadcast] Broadcaster connected: stream ${streamId} (${user.username})`);++ require('./stream-history').broadcasterBack(streamId).catch(() => {});this.emit('broadcaster-connected', { streamId, userId: user.id });// Notify existing viewers to re-negotiate@@@ -411,6 +412,12 @@ class BroadcastServer extends EventEmitter {}break;++ case 'shape':++ // The broadcaster's studio: the picture it sends changed size (a phone turned, WebRTC lowered the++ // resolution). Kept in the stream's history (stream-history.js) for seeks and the record.++ if (client.role === 'broadcaster') require('./stream-history').shape(client.streamId, msg.width, msg.height);++ break;++case 'chat-tts':// TTS message from chat to broadcasterif (room.broadcaster && room.broadcaster.readyState === WebSocket.OPEN) {@@@ -504,6 +511,7 @@ class BroadcastServer extends EventEmitter {room.broadcaster = null;room._sfuProduceRequestedAt = 0;console.log(`[Broadcast] Broadcaster disconnected: stream ${client.streamId}`);++ require('./stream-history').broadcasterLeft(client.streamId);// Start a grace timer — if broadcaster doesn't reconnect, end the stream cleanlyif (room._disconnectTimer) clearTimeout(room._disconnectTimer);@@@ -99,7 +99,7 @@ class StreamRecorder {/** Stop a WebRTC recording and start its next part a moment later, on the producers there are then. */_rollRecording(streamId, rec, why) {rec._rolling = true;-- const next = { mode: rec.mode, part: (rec.part || 1) + 1, baseTitle: rec.baseTitle };++ const next = { mode: rec.mode, part: (rec.part || 1) + 1, baseTitle: rec.baseTitle, why };console.log(`[VOD] Stream ${streamId}: ${why} — rolling recording to part ${next.part}`);this.stopRecording(streamId);const timer = setTimeout(async () => {@@@ -139,6 +139,7 @@ class StreamRecorder {clipsOnly: mode === 'clips',startedAt: Date.now(),part: opts.part || 1,++ why: opts.why || null,baseTitle: opts.baseTitle || stream.title || 'Stream Recording',type: protocol === 'rtmp' ? 'rtmp' : 'rtp',webrtcState: null,@@@ -224,6 +225,7 @@ class StreamRecorder {if (rec._cancel) return this._abort(streamId, rec);const rtmpUrl = `rtmp://127.0.0.1:${config.rtmp.port}/live/${endpoint.streamKey}`;await media.ingestRtmp(rec.vodId, rtmpUrl);++ this._partStarted(stream, rec);console.log(`[VOD] Media RTMP ingest started: stream ${streamId} → vod ${rec.vodId} (${rec.mode})`);}@@@ -314,9 +316,21 @@ class StreamRecorder {}rec.webrtcState = webrtcState;++ this._partStarted(stream, rec);console.log(`[VOD] Media RTP ingest started: stream ${streamId} → vod ${rec.vodId} (v:${videoPort}${audioPort ? ` a:${audioPort}` : ''}, ${rec.mode})`);}++ /**++ * The recording's footage starts now (Media is ingesting): mediaStartedAt is its time 0, which a live clip measures++ * from (startedAt is earlier by the VOD creation and the wait for the microphone), and the stream's history notes the++ * part (stream-history.js), so stream time can be turned into this VOD's time later.++ */++ _partStarted(stream, rec) {++ if (!rec.mediaStartedAt) rec.mediaStartedAt = Date.now();++ if (rec.clipsOnly) return;++ require('./stream-history').partStarted(stream, { vodId: rec.vodId, part: rec.part, why: rec.why, atMs: rec.mediaStartedAt }).catch(() => {});++ }++_closePlainConsumers(webrtcState) {if (!webrtcState) return;try {@@@ -345,6 +359,8 @@ class StreamRecorder {startedAt: Date.now(), part: 1, baseTitle: null, type: 'chunks', webrtcState: null,};this.activeRecordings.set(streamId, rec);++ rec.mediaStartedAt = rec.startedAt;++ db.getStreamById(streamId).then((stream) => { if (stream) this._partStarted(stream, rec); }).catch(() => {});return rec;}@@@ -367,9 +383,11 @@ class StreamRecorder {this.activeRecordings.delete(streamId);this._finalizing.add(streamId);++ const stoppedAt = Date.now();const finish = (async () => {this._closePlainConsumers(rec.webrtcState);if (!rec.vodId) return;++ if (rec.mediaStartedAt && !rec.clipsOnly) require('./stream-history').partEnded(streamId, rec.vodId, stoppedAt).catch(() => {});if (rec.type === 'rtp') {await media.ingestRtpStop(rec.vodId).catch((e) => console.warn(`[VOD] rtp stop failed (vod ${rec.vodId}):`, e.message));} else if (rec.type === 'chunks') {@@@ -1800,6 +1800,46 @@ router.post('/managed/:id/regenerate-key', requireAuth, async (req, res) => {}});++// ── What happened during a stream (stream-history.js) ───────++// Its recording parts (each its own VOD, with where it starts in stream time), the broadcaster's picture sizes and++// reconnects, in seconds since the stream started. The player rewinds across parts with it; a part whose VOD the++// viewer cannot see is listed without its VOD.++router.get('/:id/history', optionalAuth, async (req, res) => {++ try {++ const stream = await db.getStreamById(req.params.id);++ if (!stream) return res.status(404).json({ error: 'Stream not found' });++ const history = require('./stream-history');++ const media = require('../media-client');++ const access = require('../media-proxy/access');++ const h = await history.forStream(stream.id);++ let active = null;++ try { active = require('./recorder').getActiveRecording(stream.id)?.vodId || null; } catch { /* */ }++ const parts = await Promise.all(h.parts.map(async (p) => {++ let vod = null;++ try { vod = p.vod_id ? await media.getVod(p.vod_id) : null; } catch { /* Media unreachable: listed without it */ }++ const seen = vod && await access.canView(req.user, vod).catch(() => false);++ const recording = !!(active && p.vod_id === active && stream.is_live);++ // A finished part plays from Media's timeline playlist when it has one (one period per shape), else its file.++ if (seen) media.setPlaybackUrls(vod, 'vod');++ const src = seen && !recording ? (vod.hls_url || vod.file_url || null) : null;++ return { part: p.part, vod_id: seen ? p.vod_id : null, at: p.at, end: p.end, recording, src, hls: !!(src && /\.m3u8(\?|$)/.test(src)) };++ }));++ res.set('Cache-Control', 'private, max-age=0');++ res.json({++ stream_id: stream.id,++ started_at: stream.started_at || null,++ is_live: !!stream.is_live,++ now_sec: stream.is_live ? history.streamSeconds(stream) : null,++ parts,++ shapes: h.shapes.map(({ at, width, height }) => ({ at, width, height })),++ reconnects: h.reconnects,++ });++ } catch (err) {++ console.error('[Streams] history error:', err.message);++ res.status(500).json({ error: 'Failed to get the stream history' });++ }++});++// ── Get Stream Details ───────────────────────────────────────router.get('/:id', optionalAuth, async (req, res) => {try {nnew file mode 100644@@@ -0,0 +1,210 @@++'use strict';++/**++ * What happened during a live stream, in stream time (seconds since streams.started_at), kept so a seek, a clip or a++ * transcript can use it later (migration 0020_stream_history; owner, 2026-10-26).++ *++ * Stream 2795 showed why: its broadcaster reconnected 11 minutes in, so it was recorded in two parts (VODs 6025 and++ * 6026), and nothing remembered where part 2 began. The studio's clip panel measures from the stream's start and sent++ * those times to part 1 ("Start time exceeds video duration"); a viewer's rewind could not reach part 1; part 2's++ * transcript lines pointed 11 minutes late. With each part's start on record, stream time minus that start is the++ * part's own time.++ *++ * partStarted(stream, { vodId, part, why }) a recording part began (recorder.js, once Media made its VOD)++ * partEnded(streamId, vodId) it stopped++ * shape(streamId, width, height) the broadcaster's picture changed size (its studio reports it); a burst++ * of changes is written at most every SHAPE_MIN_MS, the last one kept++ * broadcasterLeft(streamId) / broadcasterBack(streamId) a reconnect, with its gap++ * forStream(streamId) { parts, shapes, reconnects } in time order++ * partAt(streamId, sec) the part recording stream time `sec` (or the nearest one before it)++ * partForVod(vodId) a VOD's part row, or null++ *++ * Writes never throw: the history is a record, not a gate on going live or recording.++ */++const db = require('../db/database');++++const SHAPE_MIN_MS = 2000;++const MAX_SIDE = 16384;++++/** Epoch ms of a stored timestamp ('YYYY-MM-DD HH:MM:SS' is UTC), or null. */++function epochMs(v) {++ if (!v) return null;++ if (v instanceof Date) return isNaN(v) ? null : v.getTime();++ const t = String(v);++ const d = new Date(/^\d{4}-\d{2}-\d{2} /.test(t) ? `${t.replace(' ', 'T')}Z` : t);++ return isNaN(d) ? null : d.getTime();++}++++/** Seconds since the stream started at `atMs` (now by default); null when it has no start. */++function streamSeconds(stream, atMs = Date.now()) {++ const start = epochMs(stream && stream.started_at);++ return start == null ? null : Math.max(0, Math.round((atMs - start) / 100) / 10);++}++++const _streams = new Map(); // streamId → { started: epoch ms, shape: { width, height, writtenAt, pending, timer }, leftAt }++++async function startOf(streamId) {++ const cur = _streams.get(streamId);++ if (cur && cur.started != null) return cur.started;++ let started = null;++ try { started = epochMs((await db.getStreamById(streamId))?.started_at); } catch { /* */ }++ if (started != null) entry(streamId).started = started;++ return started;++}++const MAX_STREAMS = 500; // in-memory state of streams seen lately; the oldest goes first (rows stay in the table)++function entry(streamId) {++ let e = _streams.get(streamId);++ if (!e) {++ _streams.set(streamId, e = {});++ for (const id of _streams.keys()) { if (_streams.size <= MAX_STREAMS) break; forget(id); }++ }++ return e;++}++++async function add(row) {++ try {++ await db.run(`INSERT INTO stream_history (stream_id, kind, at_sec, end_sec, vod_id, detail) VALUES (?, ?, ?, ?, ?, ?)`,++ [row.stream_id, row.kind, row.at_sec, row.end_sec == null ? null : row.end_sec, row.vod_id || null, row.detail ? JSON.stringify(row.detail) : null]);++ return true;++ } catch (err) {++ console.warn(`[History] stream ${row.stream_id} ${row.kind} not recorded:`, err.message);++ return false;++ }++}++++/** The part recording now, for a shape row: the recorder's active VOD. */++function recordingVod(streamId) {++ try { return require('./recorder').getActiveRecording(streamId)?.vodId || null; } catch { return null; }++}++++async function partStarted(stream, { vodId, part = 1, why = null, atMs = Date.now() } = {}) {++ if (!stream || !stream.id || !vodId) return false;++ const at = streamSeconds(stream, atMs);++ if (at == null) return false;++ entry(stream.id).started = epochMs(stream.started_at);++ return add({ stream_id: stream.id, kind: 'part', at_sec: at, vod_id: vodId, detail: { part, ...(why ? { why } : {}) } });++}++++async function partEnded(streamId, vodId, atMs = Date.now()) {++ if (!streamId || !vodId) return false;++ const start = await startOf(streamId);++ if (start == null) return false;++ try {++ await db.run(`UPDATE stream_history SET end_sec = ? WHERE stream_id = ? AND kind = 'part' AND vod_id = ? AND end_sec IS NULL`,++ [Math.max(0, Math.round((atMs - start) / 100) / 10), streamId, vodId]);++ return true;++ } catch { return false; }++}++++async function writeShape(streamId, shape) {++ const start = await startOf(streamId);++ if (start == null) return;++ const e = entry(streamId);++ e.shape = { ...(e.shape || {}), width: shape.width, height: shape.height, writtenAt: Date.now() };++ await add({ stream_id: streamId, kind: 'shape', at_sec: Math.max(0, Math.round((shape.atMs - start) / 100) / 10),++ vod_id: recordingVod(streamId), detail: { width: shape.width, height: shape.height } });++}++++/** The broadcaster's picture is now width×height. Returns false for a size that is not one. */++function shape(streamId, width, height) {++ width = Number(width); height = Number(height);++ if (!streamId || !Number.isInteger(width) || !Number.isInteger(height) || width < 16 || height < 16 || width > MAX_SIDE || height > MAX_SIDE) return false;++ const e = entry(streamId);++ const s = e.shape || {};++ if (s.pending) s.pending = null;++ if (s.width === width && s.height === height) return true; // back where the last row says: nothing new++ const next = { width, height, atMs: Date.now() };++ const wait = (s.writtenAt || 0) + SHAPE_MIN_MS - Date.now();++ if (wait <= 0) { writeShape(streamId, next).catch(() => {}); return true; }++ e.shape = { ...s, pending: next };++ if (!s.timer) {++ const timer = setTimeout(() => {++ const cur = _streams.get(streamId)?.shape;++ if (!cur) return;++ cur.timer = null;++ const p = cur.pending;++ cur.pending = null;++ if (p && !(p.width === cur.width && p.height === cur.height)) writeShape(streamId, p).catch(() => {});++ }, wait);++ if (timer.unref) timer.unref();++ e.shape.timer = timer;++ }++ return true;++}++++function broadcasterLeft(streamId) { entry(streamId).leftAt = Date.now(); }++++async function broadcasterBack(streamId) {++ const e = _streams.get(streamId);++ if (!e || !e.leftAt) return false;++ const left = e.leftAt;++ e.leftAt = null;++ const start = await startOf(streamId);++ if (start == null) return false;++ return add({ stream_id: streamId, kind: 'reconnect', at_sec: Math.max(0, Math.round((Date.now() - start) / 100) / 10),++ detail: { gap_s: Math.round((Date.now() - left) / 100) / 10 } });++}++++/** The stream ended: forget its in-memory state (rows stay). */++function forget(streamId) {++ const e = _streams.get(streamId);++ if (e && e.shape && e.shape.timer) clearTimeout(e.shape.timer);++ _streams.delete(streamId);++}++++const parse = (detail) => { try { return detail ? JSON.parse(detail) : {}; } catch { return {}; } };++++async function forStream(streamId) {++ let rows = [];++ try {++ rows = await db.all(`SELECT kind, at_sec, end_sec, vod_id, detail FROM stream_history WHERE stream_id = ? ORDER BY at_sec ASC, id ASC LIMIT 5000`, [streamId]);++ } catch { /* none */ }++ const out = { parts: [], shapes: [], reconnects: [] };++ for (const r of rows) {++ const d = parse(r.detail);++ if (r.kind === 'part') out.parts.push({ part: d.part || out.parts.length + 1, vod_id: Number(r.vod_id) || null, at: Number(r.at_sec), end: r.end_sec == null ? null : Number(r.end_sec), ...(d.why ? { why: d.why } : {}) });++ else if (r.kind === 'shape') out.shapes.push({ at: Number(r.at_sec), width: d.width, height: d.height, vod_id: Number(r.vod_id) || null });++ else if (r.kind === 'reconnect') out.reconnects.push({ at: Number(r.at_sec), gap_s: d.gap_s == null ? null : Number(d.gap_s) });++ }++ return out;++}++++async function partAt(streamId, sec) {++ const { parts } = await forStream(streamId);++ let best = null;++ for (const p of parts) if (p.at <= sec) best = p;++ return best;++}++++async function partForVod(vodId) {++ if (!vodId) return null;++ try {++ const r = await db.get(`SELECT stream_id, at_sec, end_sec, detail FROM stream_history WHERE kind = 'part' AND vod_id = ? ORDER BY id ASC LIMIT 1`, [vodId]);++ if (!r) return null;++ const d = parse(r.detail);++ return { stream_id: Number(r.stream_id), vod_id: Number(vodId), part: d.part || 1, at: Number(r.at_sec), end: r.end_sec == null ? null : Number(r.end_sec) };++ } catch { return null; }++}++++/**++ * Where each of these VODs starts in its stream's time → Map(vodId → seconds). Stream time minus that is the VOD's own++ * time (a timeline row's start_sec, a Rundown segment's). A VOD with no part row (recorded before the history) is++ * left out: its rows are read as they always were.++ */++async function vodOffsets(vodIds) {++ const ids = [...new Set((vodIds || []).map(Number).filter((n) => Number.isSafeInteger(n) && n > 0))].slice(0, 500);++ const out = new Map();++ if (!ids.length) return out;++ try {++ const rows = await db.all(`SELECT vod_id, MIN(at_sec) AS at FROM stream_history WHERE kind = 'part' AND vod_id IN (${ids.map(() => '?').join(',')}) GROUP BY vod_id`, ids);++ for (const r of rows) out.set(Number(r.vod_id), Number(r.at));++ } catch { /* none */ }++ return out;++}++++/** A time in stream seconds as seconds into the VOD that starts at `offset` (never before its start). */++const vodTime = (sec, offset) => Math.max(0, Math.round(((Number(sec) || 0) - (Number(offset) || 0)) * 100) / 100);++++module.exports = {++ partStarted, partEnded, shape, broadcasterLeft, broadcasterBack, forget,++ forStream, partAt, partForVod, vodOffsets, vodTime, streamSeconds, epochMs, SHAPE_MIN_MS,++};nnew file mode 100644@@@ -0,0 +1,83 @@++/**++ * The live rewind spans every recording part of the stream (public/js/stream-player.js _dvrSpan, _dvrBarTime,++ * _dvrSeekBar, _dvrNudge). Stream 2795 (2026-10-26) was recorded in two parts after its broadcaster reconnected, and the++ * rewind only knew the part being recorded. With the stream's history (GET /api/streams/:id/history) the bar runs from++ * the first part's start to the live edge, in stream time:++ * - a point in the recording part seeks the live recording at that part's time;++ * - a point in an earlier part plays that part's VOD at its own time (never past its end);++ * - with no history the bar is the recording part alone, as before;++ * - nudging by 5 s moves within what is playing, and into the next part across a boundary.++ *++ * node test/player-dvr-parts.test.js++ */++'use strict';++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const vm = require('vm');++++const src = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'stream-player.js'), 'utf8');++function extract(name) {++ const m = new RegExp(`(?:async )?function ${name}\\(`).exec(src);++ assert.ok(m, `${name}() must exist`);++ let i = m.index + m[0].length;++ for (let parens = 1; parens; i++) { if (src[i] === '(') parens++; else if (src[i] === ')') parens--; }++ i = src.indexOf('{', i);++ for (let depth = 0; i < src.length; i++) {++ if (src[i] === '{') depth++;++ else if (src[i] === '}' && --depth === 0) return src.slice(m.index, i + 1);++ }++ throw new Error(`unbalanced ${name}`);++}++++const calls = [];++const video = { currentTime: 0 };++const ctx = { Math, Number, calls, video, updateDVRUI: () => {}, switchToDVR: (t, part = null) => calls.push([Math.round(t * 10) / 10, part ? part.vodId : 'rec']) };++vm.createContext(ctx);++vm.runInContext(['_dvrSpan', '_dvrBarTime', '_dvrSeekBar', '_dvrNudge'].map(extract).join('\n'), ctx);++++// Stream 2795's shape: part 1 (VOD 6025) from 28 s to 706 s, part 2 recording since 709 s and 900 s long so far.++const part1 = { vodId: 6025, at: 28, end: 706, src: 'https://media.test/o/med_1/master.m3u8', hls: true };++const set = (state) => vm.runInContext(`var dvrState = ${JSON.stringify(state)};`, ctx);++set({ duration: 900, seekable: true, hlsUrl: 'https://media.test/v/6026/dvr.m3u8', vodFile: null, earlier: [part1], activeAt: 709, playingPart: null });++++const span = vm.runInContext('_dvrSpan()', ctx);++assert.deepStrictEqual({ ...span }, { start: 28, total: 709 - 28 + 900 }, 'the bar runs from part 1\'s start to the live edge');++++vm.runInContext('_dvrSeekBar(1500)', ctx); // stream 1528 s: part 2 at 819 s++assert.deepStrictEqual(calls.pop(), [819, 'rec']);++vm.runInContext('_dvrSeekBar(100)', ctx); // stream 128 s: part 1 at 100 s++assert.deepStrictEqual(calls.pop(), [100, 6025]);++vm.runInContext('_dvrSeekBar(679.9)', ctx); // stream 707.9 s, the reconnect's gap: part 1, just before its end++assert.deepStrictEqual(calls.pop(), [677.5, 6025]);++++// The bar time of what plays: part 1 at 650 s is 650 s in; the recording at 10 s is (709 - 28) + 10.++vm.runInContext('dvrState.playingPart = dvrState.earlier[0];', ctx);++video.currentTime = 650;++assert.strictEqual(vm.runInContext('_dvrBarTime(video)', ctx), 650);++vm.runInContext('dvrState.playingPart = null;', ctx);++video.currentTime = 10;++assert.strictEqual(vm.runInContext('_dvrBarTime(video)', ctx), 691);++++// Nudging: within the recording part it moves the video; back across the boundary it plays part 1 near its end.++vm.runInContext('_dvrNudge(video, 5)', ctx);++assert.strictEqual(video.currentTime, 15);++assert.strictEqual(calls.length, 0, 'no new source for a move within it');++video.currentTime = 2;++vm.runInContext('_dvrNudge(video, -5)', ctx); // bar 683 - 5 = 678 → stream 706: part 1 at 677.5 (half a second before its end)++assert.deepStrictEqual(calls.pop(), [677.5, 6025]);++++// No history: the bar is the recording part alone, as before.++set({ duration: 300, seekable: true, hlsUrl: 'https://media.test/v/7/dvr.m3u8', vodFile: null, earlier: [], activeAt: null, playingPart: null });++assert.deepStrictEqual({ ...vm.runInContext('_dvrSpan()', ctx) }, { start: 0, total: 300 });++vm.runInContext('_dvrSeekBar(120)', ctx);++assert.deepStrictEqual(calls.pop(), [120, 'rec']);++++// History for a single part (no earlier ones): seconds into the recording, wherever it starts in the stream.++set({ duration: 300, seekable: true, hlsUrl: 'https://media.test/v/7/dvr.m3u8', vodFile: null, earlier: [], activeAt: 40, playingPart: null });++vm.runInContext('_dvrSeekBar(120)', ctx);++assert.deepStrictEqual(calls.pop(), [120, 'rec']);++video.currentTime = 50;++assert.strictEqual(vm.runInContext('_dvrBarTime(video)', ctx), 50);++++console.log('player dvr parts: ok');@@@ -38,10 +38,10 @@ const els = {};const ctx = { document: { getElementById: (id) => els[id] || null }, Date, Math, isNaN, isFinite, String };vm.createContext(ctx);--vm.runInContext(`${extract('_dvrFmtTime')}\n${extract('updateDVRUI')}\nthis.run = updateDVRUI;`, ctx);++vm.runInContext(`${extract('_dvrFmtTime')}\n${extract('_dvrSpan')}\n${extract('_dvrBarTime')}\n${extract('updateDVRUI')}\nthis.run = updateDVRUI;`, ctx);// At the live edge: no time of its own (the buffer is 1:30:00 while the stream is 1:30:17 old).--ctx.dvrState = { active: true, isLive: true, duration: 5400, streamStartTime: Date.now() - 5417000, seeking: false };++ctx.dvrState = { active: true, isLive: true, duration: 5400, streamStartTime: Date.now() - 5417000, seeking: false, earlier: [], activeAt: null, playingPart: null };vm.runInContext('var dvrState = this.dvrState;', ctx);ctx.run();const time = els['dvr-time-display'];@@@ -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 _destroyDvrHls(', 'function showStreamEnded('].map((h) => extract(player, h)).join('\n');++const code = ['let dvrState = {', 'async function initDVR(', 'async function pollDVR(', 'async function _pollDvrParts(', 'function destroyDVR(', 'function _destroyDvrHls(', 'function _dvrPartEnded(', '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();@@@ -37,7 +37,7 @@ const setTimeout = (fn, ms) => { timers.set(nextId, { fn, ms }); return nextId++const clearTimeout = (id) => { timers.delete(id); };const asked = [];const api = async (p) => { asked.push(p); const e = new Error('Not found'); e.status = 404; throw e; };--const el = () => ({ style: {} });++const el = () => ({ style: {}, removeEventListener() {} });const document = { getElementById: el };const window = { dispatchEvent() {} };class CustomEvent { constructor(type, init) { this.type = type; this.detail = init && init.detail; } }nnew file mode 100644@@@ -0,0 +1,234 @@++/**++ * What happened during a live stream (server/streaming/stream-history.js, migration 0020_stream_history), and what uses it.++ *++ * Stream 2795 (Maticus, 2026-10-26): its broadcaster reconnected 11 minutes in, so it was recorded in two parts, VODs++ * 6025 and 6026, and nothing remembered where part 2 began. The studio's clip panel sent stream times to part 1 ("Start++ * time exceeds video duration"), the rewind could not reach part 1, and part 2's transcript pointed 11 minutes late.++ *++ * - Parts, shapes and reconnects are kept in stream time; a burst of shape changes is written at most every 2 s.++ * - A stream-time clip (the studio's) goes to the part that recorded its end, in that part's time; across a reconnect it++ * keeps the part's side; before any recording it is refused.++ * - GET /api/streams/:id/history lists the parts (a finished one with its source), shapes and reconnects.++ * - Transcript times (stream time) are moved into a part's own time; the stream transcript lists the parts.++ * - The speech capture's segment offsets are stream time across a capture restart.++ *++ * node test/stream-history.test.js++ */++'use strict';++const assert = require('assert');++const http = require('http');++++process.env.NODE_ENV = 'test';++const quiet = console.log;++console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };++console.warn = () => {};++++let failures = 0;++async function check(name, fn) {++ try { await fn(); quiet(' ✓', name); }++ catch (e) { failures++; quiet(' ✗', name, '\n ', e.stack || e.message); }++}++const wait = (ms) => new Promise((r) => setTimeout(r, ms));++++(async () => {++ const db = require('../server/db/database');++ await db.initDb();++ const raw = db.getDb();++++ const auth = require('../server/auth/auth');++ const signIn = async (req) => {++ const id = Number(req.headers['x-test-user'] || 0);++ const u = id ? await db.getUserById(id) : null;++ if (u) { req.user = u; req.authSource = 'network'; }++ return u;++ };++ auth.requireAuth = async (req, res, next) => ((await signIn(req)) ? next() : res.status(401).json({ error: 'Authentication required' }));++ auth.optionalAuth = async (req, res, next) => { await signIn(req); next(); };++++ await raw.prepare(`INSERT INTO users (id, username, display_name, email, password_hash, role, created_at) OVERRIDING SYSTEM VALUE++ VALUES (80, 'maticus', 'Maticus', 'm@x', 'x', 'streamer', '2025-01-01 00:00:00')`).run();++ await db.ensureChannel(80);++ const chan = await db.getChannelByUserId(80);++ const sid = Number((await db.createStream({ user_id: 80, channel_id: chan.id, title: 'BIRD DRAMA', protocol: 'webrtc' })).lastInsertRowid);++ const startMs = Math.floor(Date.now() / 1000) * 1000 - 1600 * 1000; // whole seconds: started_at keeps no fraction++ const stamp = (ms) => new Date(ms).toISOString().slice(0, 19).replace('T', ' ');++ await raw.prepare('UPDATE streams SET is_live = 1, started_at = ? WHERE id = ?').run(stamp(startMs), sid);++ const stream = await db.getStreamById(sid);++++ const history = require('../server/streaming/stream-history');++ const recorder = require('../server/streaming/recorder');++ recorder.getActiveRecording = (s) => (Number(s) === sid ? { vodId: 6026, part: 2, startedAt: startMs + 707000, mediaStartedAt: startMs + 709000 } : null);++++ // Part 1 from 28 s to 706 s, part 2 from 709 s (the broadcaster re-produced its video), still recording.++ await history.partStarted(stream, { vodId: 6025, part: 1, atMs: startMs + 28000 });++ await history.partEnded(sid, 6025, startMs + 706000);++ await history.partStarted(stream, { vodId: 6026, part: 2, why: 'the broadcaster re-produced video on a new transport', atMs: startMs + 709000 });++++ await check('parts are kept in stream time', async () => {++ const h = await history.forStream(sid);++ assert.deepStrictEqual(h.parts.map((p) => [p.part, p.vod_id, p.at, p.end]), [[1, 6025, 28, 706], [2, 6026, 709, null]]);++ assert.strictEqual(h.parts[1].why, 'the broadcaster re-produced video on a new transport');++ assert.strictEqual((await history.partAt(sid, 800)).vod_id, 6026);++ assert.strictEqual((await history.partAt(sid, 100)).vod_id, 6025);++ assert.strictEqual(await history.partAt(sid, 10), null, 'nothing before the first part');++ const off = await history.vodOffsets([6025, 6026, 1]);++ assert.deepStrictEqual([...off.entries()], [[6025, 28], [6026, 709]]);++ assert.deepStrictEqual(await history.partForVod(6026), { stream_id: sid, vod_id: 6026, part: 2, at: 709, end: null });++ assert.strictEqual(history.vodTime(700, 709), 0, 'never before the VOD starts');++ });++++ await check('a burst of shape changes is written at most every 2 s, the last one kept; the same shape adds nothing', async () => {++ assert.strictEqual(history.shape(sid, 720, 1280), true);++ await wait(50);++ assert.strictEqual(history.shape(sid, 1280, 720), true);++ assert.strictEqual(history.shape(sid, 960, 540), true);++ assert.strictEqual(history.shape(sid, 'big', 5), false, 'not a size');++ await wait(50);++ assert.strictEqual((await history.forStream(sid)).shapes.length, 1, 'only the first so far');++ await wait(history.SHAPE_MIN_MS + 200);++ history.shape(sid, 960, 540);++ await wait(50);++ const shapes = (await history.forStream(sid)).shapes;++ assert.deepStrictEqual(shapes.map((s) => [s.width, s.height, s.vod_id]), [[720, 1280, 6026], [960, 540, 6026]]);++ assert.ok(shapes.every((s) => s.at >= 1599 && s.at <= 1603), `in stream time (${shapes.map((s) => s.at)})`);++ });++++ await check('a reconnect is kept with its gap', async () => {++ history.broadcasterLeft(sid);++ await wait(120);++ assert.strictEqual(await history.broadcasterBack(sid), true);++ assert.strictEqual(await history.broadcasterBack(sid), false, 'once');++ const r = (await history.forStream(sid)).reconnects;++ assert.strictEqual(r.length, 1);++ assert.ok(r[0].gap_s >= 0.1 && r[0].gap_s < 2, `gap ${r[0].gap_s}`);++ });++++ // ── Routes ──++ const media = require('../server/media-client');++ const VODS = {++ 6025: { id: 6025, user_id: 80, stream_id: sid, title: 'BIRD DRAMA', visibility: 'public', is_public: 1, status: 'ready', hls_url: '/o/med_1/master.m3u8' },++ 6026: { id: 6026, user_id: 80, stream_id: sid, title: 'BIRD DRAMA (Part 2)', visibility: 'public', is_public: 1, status: 'recording', is_recording: 1 },++ };++ media.getVod = async (id) => { const v = VODS[Number(id)]; if (!v) throw new media.MediaApiError('VOD not found', 404, { error: 'VOD not found' }); return { ...v }; };++ const made = [];++ media.createClip = async (body) => { made.push(body); return { id: 900 + made.length, status: 'processing' }; };++ media.listVods = async () => ({ vods: [{ id: 6026 }] });++++ const express = require('express');++ const app = express();++ app.use(express.json());++ app.use('/api/vods', require('../server/media-proxy/vods'));++ app.use('/api/streams', require('../server/streaming/routes'));++ app.use('/api/chat-ai', require('../server/ai/chat-ai-routes'));++ const server = http.createServer(app).listen(0);++ if (!server.listening) await new Promise((r) => server.once('listening', r));++ let ip = 0;++ const call = (method, p, user, body) => new Promise((resolve, reject) => {++ const data = body ? JSON.stringify(body) : null;++ const headers = { 'content-type': 'application/json', 'cf-connecting-ip': `10.1.0.${++ip}` };++ if (user) headers['x-test-user'] = String(user);++ const req = http.request({ port: server.address().port, path: p, method, headers }, (res) => {++ let text = '';++ res.on('data', (c) => { text += c; });++ res.on('end', () => { let json = null; try { json = JSON.parse(text); } catch { /* */ } resolve({ status: res.statusCode, json, text }); });++ });++ req.on('error', reject);++ if (data) req.write(data);++ req.end();++ });++ const clipAt = (start, end) => call('POST', '/api/vods/clips', 80, { stream_id: sid, stream_time: true, start_time: start, end_time: end, title: '' });++++ await check('a studio clip in stream time goes to the part recording it, in that part\'s time', async () => {++ const r = await clipAt(1570, 1600);++ assert.strictEqual(r.status, 201, r.text);++ assert.deepStrictEqual([r.json.vod_id, r.json.part], [6026, 2]);++ const c = made[made.length - 1];++ assert.deepStrictEqual([c.vod_id, c.start_s, c.end_s], [6026, 861, 891]);++ });++++ await check('across the reconnect: the part that recorded the end, from its start', async () => {++ await wait(2600); // the per-user live clip cooldown (2.5 s)++ const r = await clipAt(690, 715);++ assert.strictEqual(r.status, 201, r.text);++ const c = made[made.length - 1];++ assert.deepStrictEqual([c.vod_id, c.start_s, c.end_s], [6026, 0, 6]);++ });++++ await check('ending in the reconnect\'s gap (after part 1, before part 2): part 1 up to its end, as long as asked', async () => {++ await wait(2600);++ const r = await clipAt(692, 707.5);++ assert.strictEqual(r.status, 201, r.text);++ const c = made[made.length - 1];++ assert.deepStrictEqual([c.vod_id, c.start_s, c.end_s], [6025, 662.5, 678]);++ });++++ await check('before anything was recorded: refused, nothing cut', async () => {++ await wait(2600);++ const n = made.length;++ const r = await clipAt(0, 10);++ assert.strictEqual(r.status, 409, r.text);++ assert.strictEqual(r.json.no_recording, true);++ assert.strictEqual(made.length, n);++ });++++ await check('GET /api/streams/:id/history: parts with their sources, shapes, reconnects', async () => {++ const r = await call('GET', `/api/streams/${sid}/history`);++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.json.is_live, true);++ assert.ok(r.json.now_sec >= 1600 && r.json.now_sec < 1700, `now ${r.json.now_sec}`);++ const [p1, p2] = r.json.parts;++ assert.deepStrictEqual([p1.vod_id, p1.at, p1.end, p1.recording, p1.hls], [6025, 28, 706, false, true]);++ assert.ok(/^https:\/\/.+\/o\/med_1\/master\.m3u8$/.test(p1.src), p1.src);++ assert.deepStrictEqual([p2.vod_id, p2.recording, p2.src], [6026, true, null], 'the recording part plays from the live DVR, not a source here');++ assert.strictEqual(r.json.shapes.length, 2);++ assert.strictEqual(r.json.reconnects.length, 1);++ VODS[6025].visibility = 'private'; VODS[6025].is_public = 0;++ const anon = await call('GET', `/api/streams/${sid}/history`);++ assert.deepStrictEqual([anon.json.parts[0].vod_id, anon.json.parts[0].src], [null, null], 'a private part is listed without its VOD');++ VODS[6025].visibility = 'public'; VODS[6025].is_public = 1;++ assert.strictEqual((await call('GET', '/api/streams/999999/history')).status, 404);++ });++++ await check('transcripts: stream-time lines in a part\'s own time; the stream transcript lists the parts', async () => {++ await db.addTimelineEvents([++ { stream_id: sid, vod_id: 6025, kind: 'speech', start_sec: 40, end_sec: 42, text: 'what is up guys' },++ { stream_id: sid, vod_id: 6026, kind: 'speech', start_sec: 720, end_sec: 723.5, text: 'test successful' },++ ]);++ const r = await call('GET', '/api/chat-ai/vod-transcripts?ids=6025,6026');++ assert.strictEqual(r.status, 200, r.text);++ assert.deepStrictEqual(r.json.transcripts['6025'].segments.map((s) => [s.start, s.end]), [[12, 14]]);++ assert.deepStrictEqual(r.json.transcripts['6026'].segments.map((s) => [s.start, s.end]), [[11, 14.5]]);++ const t = await call('GET', `/api/chat-ai/transcript/${sid}`);++ assert.deepStrictEqual(t.json.parts.map((p) => [p.vod_id, p.at]), [[6025, 28], [6026, 709]]);++ });++++ await check('speech capture offsets are stream time across a restart', async () => {++ const fs = require('fs');++ const path = require('path');++ const audio = require('../server/ai/stream-audio');++ const dir = audio.spoolDir(sid);++ fs.mkdirSync(dir, { recursive: true });++ for (const i of [0, 1, 2, 69, 70]) fs.writeFileSync(path.join(dir, `seg-${String(i).padStart(6, '0')}.wav`), '');++ audio._noteRun(sid, 0, 5); // first capture 5 s into the stream++ audio._noteRun(sid, 69, 709.4); // restarted after the reconnect, numbering on from 69++ const segs = audio.pendingSegments(sid, { includeLast: true });++ assert.deepStrictEqual(segs.map((s) => [s.index, s.offsetSec]), [[0, 5], [1, 15], [2, 25], [69, 709.4], [70, 719.4]]);++ audio.purgeSpool(sid);++ fs.mkdirSync(dir, { recursive: true });++ fs.writeFileSync(path.join(dir, 'seg-000003.wav'), '');++ assert.deepStrictEqual(audio.pendingSegments(sid, { includeLast: true }).map((s) => s.offsetSec), [3 * audio.SEGMENT_SEC], 'no run on record: the index alone, as before');++ audio.purgeSpool(sid);++ });++++ await check('the studio sends stream times and reports the shape it sends', async () => {++ const js = require('fs').readFileSync(require('path').join(__dirname, '..', 'public', 'js', 'broadcast.js'), 'utf8');++ const body = js.slice(js.indexOf('async function createBroadcastClip'), js.indexOf('function _offerClipName'));++ assert.ok(/stream_time: true/.test(body) && !/vod_id:/.test(body), 'the clip panel sends stream time, no VOD id');++ assert.ok(/type: 'shape', width:/.test(js), 'the studio reports its picture size');++ });++++ server.close();++ if (failures) { quiet(`stream history: ${failures} failed`); process.exit(1); }++ quiet('stream history: ok');++ process.exit(0);++})().catch((err) => { quiet(err); process.exit(1); });
@@ -77,6 +77,34 @@ Viewers can seek backwards in a live stream using the DVR controls:
@@ -77,6 +77,34 @@ Viewers can seek backwards in a live stream using the DVR controls:
DVR availability appears after ~30 seconds of recording.
DVR availability appears after ~30 seconds of recording.
+The bar spans the whole stream, not only the part being recorded. A broadcaster who reconnects (a phone dropping its
+connection, a browser tab reloading) is recorded in parts, each its own VOD ("BIRD DRAMA (Part 2)"); the player reads
+the stream's history and plays an earlier part from its own VOD when you seek into it, then carries on into the next.
+Rewinding across a phone turn shows each stretch at its own shape: Media's DVR playlist starts a new period, with its
+own init segment, at each change of shape.
+
+## A stream's history
+
+While a stream is live, Live keeps what happened to it, in stream time (seconds since it started), in
+`stream_history` (`server/streaming/stream-history.js`):
+
+| Kind | What | Kept from |
+|------|------|-----------|
+| `part` | a recording part: its VOD, where it starts and stops, why it rolled | the recorder |
+| `shape` | the picture the broadcaster sends changed size (`WxH`) | the studio's outbound video stats |
+| `reconnect` | the broadcaster came back, and how long it was gone | the broadcast socket |
+
+`GET /api/streams/:id/history` answers `{ started_at, is_live, now_sec, parts, shapes, reconnects }`; a part whose
+VOD the viewer cannot see is listed without it. Stream time minus a part's start is that part's own time, and that is
+how the history is used:
+
+- **Clips from the go-live studio**: the clip panel's marks are stream time; the server cuts the part that recorded the
+ moment, in that part's time (`POST /api/vods/clips` with `stream_id`, `stream_time: true`, `start_time`, `end_time`).
+ Stream 2795's studio sent those times to part 1 and Media refused them ("Start time exceeds video duration").
+- **Transcripts, the Rundown, Vibe's voice clips and AI moments** move stream-time lines into the VOD's own time. The
+ speech capture's offsets are stream time across a capture restart (they used to fall behind by every gap).
+- **The rewind** above.
+
## Clips
## Clips
Viewers can create clips from live streams:
Viewers can create clips from live streams:
new file mode 100644
new file mode 100644
@@ -0,0 +1,24 @@
@@ -0,0 +1,24 @@
+-- phase: expand
+-- What happened during a live stream, kept so a seek, a clip or a transcript can use it later
+-- (server/streaming/stream-history.js; owner, 2026-10-26: "save the historical events/changes that happen during a
+-- livestream so that we can use that data when the user seeks"). One row per event, at_sec in stream time (seconds since
+-- streams.started_at):
+-- part a recording part: at_sec when it started, end_sec when it stopped, vod_id its VOD, detail
+-- {"part":2,"why":"..."}. A stream whose broadcaster reconnects is recorded in parts, each its own VOD, so
+-- stream time minus a part's at_sec is that VOD's time.
+-- shape the broadcaster's picture changed size: detail {"width":1280,"height":720}, vod_id the part recording it.
+-- reconnect the broadcaster came back after dropping: detail {"gap_s":4.2}.
+-- A new table, so the N-1 release neither reads nor writes it.
+CREATE TABLE IF NOT EXISTS stream_history (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ stream_id bigint NOT NULL,
+ kind text COLLATE "C" NOT NULL,
+ at_sec double precision NOT NULL,
+ end_sec double precision,
+ vod_id bigint,
+ detail text COLLATE "C",
+ created_at text COLLATE "C" DEFAULT ov_now(),
+ FOREIGN KEY (stream_id) REFERENCES streams(id) ON DELETE CASCADE
+);
+CREATE INDEX IF NOT EXISTS idx_stream_history_stream ON stream_history (stream_id, at_sec);
+CREATE INDEX IF NOT EXISTS idx_stream_history_vod ON stream_history (vod_id) WHERE vod_id IS NOT NULL;
@@ -2096,7 +2096,19 @@ async function _aiTlToggleTranscript(btn) {
@@ -2096,7 +2096,19 @@ async function _aiTlToggleTranscript(btn) {
}
}
const nSpeech = blocks.filter(b => b.kind === 'speech').length, nSound = blocks.filter(b => b.kind === 'sounds').length;
const nSpeech = blocks.filter(b => b.kind === 'speech').length, nSound = blocks.filter(b => b.kind === 'sounds').length;
const cov = data.coverageSec ? `${_aiTimeFmt(data.coverageSec)} of speech transcribed` : '';
const cov = data.coverageSec ? `${_aiTimeFmt(data.coverageSec)} of speech transcribed` : '';
- const jumpFor = (t, cls = 'ai-tl-ts') => vod ? `<a class="${cls}" href="${videoHref('vod', vod, Math.floor(t))}" onclick="return handleLinkClick(event, '${videoHref('vod', vod, Math.floor(t))}')" title="Watch from here">${_aiTimeFmt(t)}</a>` : `<span class="${cls}">${_aiTimeFmt(t)}</span>`;
+ // A stream recorded in parts links each moment into the part that recorded it, at that part's own time.
+ const parts = Array.isArray(data.parts) ? data.parts.filter(p => p && p.vod_id && Number.isFinite(p.at)) : [];
+ const where = (t) => {
+ let p = null;
+ for (const x of parts) if (x.at <= t + 0.5) p = x;
+ return p ? { vod: p.vod_id, at: Math.max(0, t - p.at) } : vod ? { vod, at: t } : null;
+ };
+ const jumpFor = (t, cls = 'ai-tl-ts') => {
+ const w = where(t);
+ if (!w) return `<span class="${cls}">${_aiTimeFmt(t)}</span>`;
+ const href = videoHref('vod', w.vod, Math.floor(w.at));
+ return `<a class="${cls}" href="${href}" onclick="return handleLinkClick(event, '${href}')" title="Watch from here">${_aiTimeFmt(t)}</a>`;
+ };
const toolbar = `<div class="ai-tl-tr-bar">
const toolbar = `<div class="ai-tl-tr-bar">
<div class="ai-tl-tr-filter" role="tablist">
<div class="ai-tl-tr-filter" role="tablist">
<button type="button" class="active" data-f="all">All</button>
<button type="button" class="active" data-f="all">All</button>
@@ -2212,6 +2212,19 @@ function setNavLiveIndicator(isLive) {
@@ -2212,6 +2212,19 @@ function setNavLiveIndicator(isLive) {
/* ── Global Display Timers (Stats + Uptime) ──────────────────── */
/* ── Global Display Timers (Stats + Uptime) ──────────────────── */
+/**
+ * The picture the encoder sends changed size (the phone turned, WebRTC lowered the resolution): tell the server, which
+ * keeps it in the stream's history (server/streaming/stream-history.js) for seeks and the record. Only what the encoder
+ * really sends counts (its outbound stats), and only a change.
+ */
+function _reportBroadcastShape(ss, resolution) {
+ const m = /^(\d+)x(\d+)$/.exec(resolution || '');
+ if (!m || ss._reportedShape === resolution) return;
+ const ws = ss.signalingWs;
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
+ try { ws.send(JSON.stringify({ type: 'shape', width: Number(m[1]), height: Number(m[2]) })); ss._reportedShape = resolution; } catch { /* next tick */ }
+}
+
function startGlobalDisplayTimers() {
function startGlobalDisplayTimers() {
// Stats polling — reads from active stream
// Stats polling — reads from active stream
clearInterval(_globalStatsInterval);
clearInterval(_globalStatsInterval);
@@ -2288,6 +2301,7 @@ function startGlobalDisplayTimers() {
@@ -2288,6 +2301,7 @@ function startGlobalDisplayTimers() {
setT('bc-stat-bitrate', bitrateText);
setT('bc-stat-bitrate', bitrateText);
setT('bc-stat-fps', `${Math.round(frameRate)} fps`);
setT('bc-stat-fps', `${Math.round(frameRate)} fps`);
setT('bc-stat-resolution', resolution || '--');
setT('bc-stat-resolution', resolution || '--');
+ _reportBroadcastShape(ss, hasStats ? resolution : '');
setT('bc-stat-status', connState);
setT('bc-stat-status', connState);
setT('bc-stat-codec', codec || '--');
setT('bc-stat-codec', codec || '--');
@@ -3864,6 +3878,7 @@ function connectSignaling(streamId) {
@@ -3864,6 +3878,7 @@ function connectSignaling(streamId) {
const wsUrl = `${protocol}://${host}${portSuffix}/ws/broadcast?token=${token}&streamId=${ss.streamData.id}&role=broadcaster`;
const wsUrl = `${protocol}://${host}${portSuffix}/ws/broadcast?token=${token}&streamId=${ss.streamData.id}&role=broadcaster`;
const ws = new WebSocket(wsUrl);
const ws = new WebSocket(wsUrl);
ss.signalingWs = ws;
ss.signalingWs = ws;
+ ss._reportedShape = null; // a new socket (or a restarted server) hears the current shape again
ss.signalingIntentionalClose = false;
ss.signalingIntentionalClose = false;
ws.onopen = () => {
ws.onopen = () => {
@@ -5513,18 +5528,7 @@ async function createBroadcastClip() {
@@ -5513,18 +5528,7 @@ async function createBroadcastClip() {
const ss = getActiveStreamState();
const ss = getActiveStreamState();
if (!ss) { toast('No active stream', 'error'); return null; }
if (!ss) { toast('No active stream', 'error'); return null; }
- // Auto-retry if VOD not ready yet (up to 3 attempts, 2s apart)
- if (!ss.vodId) {
- const btn = document.getElementById('bc-clip-create-btn');
- if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Waiting for VOD…'; }
- for (let attempt = 0; attempt < 3; attempt++) {
- await new Promise(r => setTimeout(r, 2000));
- const retry = getActiveStreamState();
- if (retry?.vodId) { ss.vodId = retry.vodId; break; }
- }
- if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fa-solid fa-scissors"></i> Create Clip'; }
- if (!ss.vodId) { toast('VOD recording not ready yet — try again in a few seconds', 'error'); return null; }
- }
+ if (!ss.streamData?.id) { toast('The stream is still starting — try again in a few seconds', 'error'); return null; }
const duration = _bcClipEnd - _bcClipStart;
const duration = _bcClipEnd - _bcClipStart;
if (duration <= 0) { toast('End time must be after start time', 'error'); return null; }
if (duration <= 0) { toast('End time must be after start time', 'error'); return null; }
@@ -5541,9 +5545,12 @@ async function createBroadcastClip() {
@@ -5541,9 +5545,12 @@ async function createBroadcastClip() {
const res = await fetch('/api/vods/clips', {
const res = await fetch('/api/vods/clips', {
method: 'POST',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json' },
+ // The marks are seconds since the stream started. A broadcaster who reconnects is recorded in parts, each
+ // its own VOD, so the server picks the part that recorded the moment and turns the marks into its time
+ // (stream 2795: these times sent to part 1 were past its end).
body: JSON.stringify({
body: JSON.stringify({
- vod_id: ss.vodId,
- stream_id: ss.streamData?.id,
+ stream_id: ss.streamData.id,
+ stream_time: true,
start_time: _bcClipStart,
start_time: _bcClipStart,
end_time: _bcClipEnd,
end_time: _bcClipEnd,
title: typed,
title: typed,
@@ -628,6 +628,12 @@ let dvrState = {
@@ -628,6 +628,12 @@ let dvrState = {
hlsUrl: null, // an MP4 recording's DVR playlist (Media /v/<id>/dvr.m3u8): seeking with no snapshot copy
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
dvrHls: null, // the hls.js instance playing it while rewound
dvrNative: false, // or the browser's own HLS (Safari) playing it
dvrNative: false, // or the browser's own HLS (Safari) playing it
+ dvrSrc: null, // the source the rewound video is playing (the recording's playlist or file, or an earlier part's)
+ // A broadcaster who reconnects is recorded in parts, each its own VOD (the stream's history: GET /streams/:id/history).
+ // The bar spans them all, in stream time: an earlier part plays from its own VOD.
+ earlier: [], // finished parts before the recording one: [{ vodId, at, end, src, hls }] (at/end: stream seconds)
+ activeAt: null, // where the recording part starts, in stream seconds (null: no history, the bar is that part)
+ playingPart: null, // the earlier part playing while rewound, or null for the recording part
};
};
function loadExternalScriptOnce(src) {
function loadExternalScriptOnce(src) {
@@ -2297,9 +2303,11 @@ async function initDVR(stream) {
@@ -2297,9 +2303,11 @@ async function initDVR(stream) {
* Poll the server for live VOD info and update DVR state.
* Poll the server for live VOD info and update DVR state.
*/
*/
async function pollDVR(streamId) {
async function pollDVR(streamId) {
+ let recording = false;
try {
try {
const data = await api(`/vods/stream/${streamId}/live`);
const data = await api(`/vods/stream/${streamId}/live`);
if (data && data.vod) {
if (data && data.vod) {
+ recording = true;
const vod = data.vod;
const vod = data.vod;
dvrState.vodId = vod.id;
dvrState.vodId = vod.id;
if (vod.file_path) {
if (vod.file_path) {
@@ -2314,20 +2322,75 @@ async function pollDVR(streamId) {
@@ -2314,20 +2322,75 @@ async function pollDVR(streamId) {
dvrState.hlsUrl = typeof info.dvr_hls === 'string' && /^https:\/\//.test(info.dvr_hls) ? info.dvr_hls : null;
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 (or the recording's own playlist)
- if (dvrState.seekable && (dvrState.vodFile || dvrState.hlsUrl)) {
- dvrState.active = true;
- showDVRControls();
- }
}
}
} catch {
} catch {
// No live VOD yet — normal for first ~30-60s
// No live VOD yet — normal for first ~30-60s
}
}
+ if (recording) await _pollDvrParts(streamId); // an ended stream (its live recording a 404) asks nothing more
+ // Show DVR controls when we have a seekable copy (or the recording's own playlist), or earlier parts to rewind into
+ if ((dvrState.seekable && (dvrState.vodFile || dvrState.hlsUrl)) || dvrState.earlier.length) {
+ dvrState.active = true;
+ showDVRControls();
+ }
// Continue polling every 20s
// Continue polling every 20s
dvrState.pollTimer = setTimeout(() => pollDVR(streamId), 20000);
dvrState.pollTimer = setTimeout(() => pollDVR(streamId), 20000);
}
}
+/** The stream's recording parts (its history): the earlier, finished ones to rewind into, and where the recording one starts. */
+async function _pollDvrParts(streamId) {
+ let h = null;
+ try { h = await api(`/streams/${streamId}/history`); } catch { return; }
+ const parts = Array.isArray(h && h.parts) ? h.parts.filter((p) => Number.isFinite(p.at)) : [];
+ const rec = parts.find((p) => p.recording) || null;
+ dvrState.activeAt = rec ? rec.at : null;
+ dvrState.earlier = rec ? parts
+ .filter((p) => !p.recording && p.vod_id && typeof p.src === 'string' && /^https:\/\//.test(p.src) && Number.isFinite(p.end) && p.end > p.at + 1 && p.at < rec.at)
+ .map((p) => ({ vodId: p.vod_id, at: p.at, end: Math.min(p.end, rec.at), src: p.src, hls: !!p.hls })) : [];
+}
+
+/**
+ * The bar's span in seconds: from the first earlier part's start to the live edge of the recording part, in stream time;
+ * without history, the recording part alone. → { start, total } (start in stream seconds).
+ */
+function _dvrSpan() {
+ const first = dvrState.earlier[0];
+ if (!first || dvrState.activeAt == null) return { start: dvrState.activeAt || 0, total: dvrState.duration };
+ return { start: first.at, total: dvrState.activeAt - first.at + dvrState.duration };
+}
+
+/** Where the rewound video is on the bar, in seconds from its start. */
+function _dvrBarTime(video) {
+ const span = _dvrSpan();
+ const p = dvrState.playingPart;
+ const base = p ? p.at : (dvrState.activeAt != null && dvrState.earlier.length ? dvrState.activeAt : span.start);
+ return (base - span.start) + (video ? video.currentTime : 0);
+}
+
+/** Seek to a point of the bar (seconds from its start): the recording part, or the earlier part that holds it. */
+function _dvrSeekBar(barTime) {
+ const span = _dvrSpan();
+ const t = span.start + Math.max(0, Math.min(barTime, span.total));
+ if (!dvrState.earlier.length || dvrState.activeAt == null || t >= dvrState.activeAt) {
+ if (dvrState.seekable && (dvrState.vodFile || dvrState.hlsUrl)) return switchToDVR(t - (dvrState.earlier.length ? dvrState.activeAt : span.start));
+ if (!dvrState.earlier.length) return;
+ }
+ let part = dvrState.earlier[0];
+ for (const p of dvrState.earlier) if (p.at <= t) part = p;
+ switchToDVR(Math.max(0, Math.min(t - part.at, part.end - part.at - 0.5)), part);
+}
+
+/** Move the rewound video by `delta` seconds: within what it is playing, or on to the part that holds the new time. */
+function _dvrNudge(video, delta) {
+ const span = _dvrSpan();
+ const target = Math.max(0, Math.min(_dvrBarTime(video) + delta, span.total));
+ const p = dvrState.playingPart;
+ const local = target - ((p ? p.at : (dvrState.earlier.length ? dvrState.activeAt : span.start)) - span.start);
+ const length = p ? p.end - p.at : dvrState.duration;
+ if (local >= 0 && local <= length) { video.currentTime = local; updateDVRUI(); return; }
+ _dvrSeekBar(target);
+}
+
/**
/**
* Show the DVR progress bar and related controls.
* Show the DVR progress bar and related controls.
*/
*/
@@ -2379,13 +2442,15 @@ function updateDVRUI() {
@@ -2379,13 +2442,15 @@ function updateDVRUI() {
} else {
} else {
// Rewound: how far behind live, with the position in the buffer as its title
// Rewound: how far behind live, with the position in the buffer as its title
const video = document.getElementById('video-element');
const video = document.getElementById('video-element');
- if (video && dvrState.duration > 0 && !dvrState.seeking) {
- const pct = (video.currentTime / dvrState.duration) * 100;
+ const span = _dvrSpan();
+ if (video && span.total > 0 && !dvrState.seeking) {
+ const at = _dvrBarTime(video);
+ const pct = (at / span.total) * 100;
if (fill) fill.style.width = Math.min(pct, 100) + '%';
if (fill) fill.style.width = Math.min(pct, 100) + '%';
if (timeEl) {
if (timeEl) {
timeEl.hidden = false;
timeEl.hidden = false;
- timeEl.textContent = `\u2212${_dvrFmtTime(dvrState.duration - video.currentTime)}`;
- timeEl.title = `${_dvrFmtTime(video.currentTime)} of ${_dvrFmtTime(dvrState.duration)} recorded; behind live`;
+ timeEl.textContent = `\u2212${_dvrFmtTime(span.total - at)}`;
+ timeEl.title = `${_dvrFmtTime(at)} of ${_dvrFmtTime(span.total)} recorded; behind live`;
}
}
}
}
if (container) container.classList.add('dvr-rewound');
if (container) container.classList.add('dvr-rewound');
@@ -2406,13 +2471,13 @@ function setupDVRSeek() {
@@ -2406,13 +2471,13 @@ function setupDVRSeek() {
wrap._dvrListenersAttached = true;
wrap._dvrListenersAttached = true;
const seekToPercent = (pct) => {
const seekToPercent = (pct) => {
- if (!dvrState.seekable || dvrState.duration <= 0) return;
- const targetTime = pct * dvrState.duration;
- switchToDVR(targetTime);
+ const span = _dvrSpan();
+ if (!(dvrState.seekable || dvrState.earlier.length) || span.total <= 0) return;
+ _dvrSeekBar(pct * span.total);
};
};
wrap.addEventListener('click', (e) => {
wrap.addEventListener('click', (e) => {
- if (!dvrState.active || !dvrState.seekable) return;
+ if (!dvrState.active || !(dvrState.seekable || dvrState.earlier.length)) return;
const rect = wrap.getBoundingClientRect();
const rect = wrap.getBoundingClientRect();
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
seekToPercent(pct);
seekToPercent(pct);
@@ -2420,7 +2485,7 @@ function setupDVRSeek() {
@@ -2420,7 +2485,7 @@ function setupDVRSeek() {
// Drag to seek
// Drag to seek
wrap.addEventListener('mousedown', (e) => {
wrap.addEventListener('mousedown', (e) => {
- if (!dvrState.active || !dvrState.seekable) return;
+ if (!dvrState.active || !(dvrState.seekable || dvrState.earlier.length)) return;
dvrState.seeking = true;
dvrState.seeking = true;
const rect = wrap.getBoundingClientRect();
const rect = wrap.getBoundingClientRect();
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
@@ -2445,7 +2510,7 @@ function setupDVRSeek() {
@@ -2445,7 +2510,7 @@ function setupDVRSeek() {
// Touch seek (scoped to the bar so it works on mobile without a hover).
// Touch seek (scoped to the bar so it works on mobile without a hover).
wrap.addEventListener('touchstart', (e) => {
wrap.addEventListener('touchstart', (e) => {
- if (!dvrState.active || !dvrState.seekable || !e.touches[0]) return;
+ if (!dvrState.active || !(dvrState.seekable || dvrState.earlier.length) || !e.touches[0]) return;
dvrState.seeking = true;
dvrState.seeking = true;
const rect = wrap.getBoundingClientRect();
const rect = wrap.getBoundingClientRect();
const pct = Math.max(0, Math.min(1, (e.touches[0].clientX - rect.left) / rect.width));
const pct = Math.max(0, Math.min(1, (e.touches[0].clientX - rect.left) / rect.width));
@@ -2480,8 +2545,8 @@ function setupDVRSeek() {
@@ -2480,8 +2545,8 @@ function setupDVRSeek() {
* and HLS/RTMP (detach live player).
* and HLS/RTMP (detach live player).
* @param {number} targetTime - Seek position in seconds
* @param {number} targetTime - Seek position in seconds
*/
*/
-function switchToDVR(targetTime) {
- if ((!dvrState.vodFile && !dvrState.hlsUrl) || !dvrState.seekable) return;
+function switchToDVR(targetTime, part = null) {
+ if (!part && ((!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');
@@ -2520,16 +2585,31 @@ function switchToDVR(targetTime) {
@@ -2520,16 +2585,31 @@ function switchToDVR(targetTime) {
video.style.display = 'block';
video.style.display = 'block';
video.srcObject = null;
video.srcObject = null;
- if (dvrState.hlsUrl) _playDvrHls(video, targetTime);
+ dvrState.playingPart = part;
+ if (part) {
+ if (part.hls) _playDvrHls(video, targetTime, part.src);
+ else _playDvrFile(video, targetTime, part.src);
+ } else if (dvrState.hlsUrl) _playDvrHls(video, targetTime);
else _playDvrFile(video, targetTime);
else _playDvrFile(video, targetTime);
- console.log(`[DVR] Switched to VOD seeking at ${Math.round(targetTime)}s`);
+ console.log(`[DVR] Switched to ${part ? `part from ${Math.round(part.at)}s` : 'the recording'} at ${Math.round(targetTime)}s`);
+}
+
+/** An earlier part played to its end: carry on into the next part (or the recording one) without a gap of choice. */
+function _dvrPartEnded() {
+ const p = dvrState.playingPart;
+ if (!p || dvrState.isLive) return;
+ _dvrSeekBar(p.end - _dvrSpan().start + 0.01);
}
}
/** The recording's HTTP file (Media serves a seekable copy of it while it records). */
/** 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()}`;
+function _playDvrFile(video, targetTime, src = null) {
+ const file = src || dvrState.vodFile;
+ if (!file) return;
+ _destroyDvrHls();
+ dvrState.dvrSrc = file;
+ video.src = src ? src : `${file}?t=${Date.now()}`;
+ if (src) video.addEventListener('ended', _dvrPartEnded, { once: true });
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
@@ -2543,15 +2623,27 @@ function _playDvrFile(video, targetTime) {
@@ -2543,15 +2623,27 @@ function _playDvrFile(video, targetTime) {
* An MP4 recording's own playlist (Media /v/<id>/dvr.m3u8: slices of the file as it grows), with hls.js, or the
* 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.
* 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); };
+function _playDvrHls(video, targetTime, src = null) {
+ const url = src || dvrState.hlsUrl;
+ if ((dvrState.dvrHls || dvrState.dvrNative) && dvrState.dvrSrc === url) { video.currentTime = targetTime; video.play().catch(() => {}); return; }
+ _destroyDvrHls();
+ dvrState.dvrSrc = url;
+ // The recording's playlist falls back to its file; an earlier part has no other source.
+ // A recording that rolled to a new part meanwhile has a new playlist and file: its old one is gone, so back to live.
+ const fallback = () => {
+ _destroyDvrHls();
+ if (src) return;
+ if (url !== dvrState.hlsUrl) { if (!dvrState.isLive) jumpToLive(); return; }
+ dvrState.hlsUrl = null;
+ if (!dvrState.isLive) _playDvrFile(video, targetTime);
+ };
loadExternalScriptOnce('https://cdn.jsdelivr.net/npm/hls.js@latest').then(() => {
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 (dvrState.isLive || dvrState.dvrSrc !== url) return; // back to live, or another source, meanwhile
+ if (src) video.addEventListener('ended', _dvrPartEnded, { once: true });
if (!window.Hls || !Hls.isSupported()) {
if (!window.Hls || !Hls.isSupported()) {
if (!video.canPlayType('application/vnd.apple.mpegurl')) return fallback();
if (!video.canPlayType('application/vnd.apple.mpegurl')) return fallback();
dvrState.dvrNative = true;
dvrState.dvrNative = true;
- video.src = dvrState.hlsUrl;
+ video.src = url;
video.addEventListener('loadedmetadata', () => { video.currentTime = targetTime; video.play().catch(() => {}); }, { once: true });
video.addEventListener('loadedmetadata', () => { video.currentTime = targetTime; video.play().catch(() => {}); }, { once: true });
return;
return;
}
}
@@ -2560,10 +2652,10 @@ function _playDvrHls(video, targetTime) {
@@ -2560,10 +2652,10 @@ function _playDvrHls(video, targetTime) {
hls.on(Hls.Events.MANIFEST_PARSED, () => { video.play().catch(() => {}); });
hls.on(Hls.Events.MANIFEST_PARSED, () => { video.play().catch(() => {}); });
hls.on(Hls.Events.ERROR, (_e, data) => {
hls.on(Hls.Events.ERROR, (_e, data) => {
if (!data || !data.fatal) return;
if (!data || !data.fatal) return;
- console.warn('[DVR] the recording\'s playlist failed, using its file:', data.details);
+ console.warn(`[DVR] ${src ? 'the earlier part\'s' : 'the recording\'s'} playlist failed${src ? '' : ', using its file'}:`, data.details);
fallback();
fallback();
});
});
- hls.loadSource(dvrState.hlsUrl);
+ hls.loadSource(url);
hls.attachMedia(video);
hls.attachMedia(video);
}).catch(fallback);
}).catch(fallback);
}
}
@@ -2571,6 +2663,9 @@ function _playDvrHls(video, targetTime) {
@@ -2571,6 +2663,9 @@ function _playDvrHls(video, targetTime) {
function _destroyDvrHls() {
function _destroyDvrHls() {
if (dvrState.dvrHls) { try { dvrState.dvrHls.destroy(); } catch {} dvrState.dvrHls = null; }
if (dvrState.dvrHls) { try { dvrState.dvrHls.destroy(); } catch {} dvrState.dvrHls = null; }
dvrState.dvrNative = false;
dvrState.dvrNative = false;
+ dvrState.dvrSrc = null;
+ const video = document.getElementById('video-element');
+ if (video) video.removeEventListener('ended', _dvrPartEnded);
}
}
/**
/**
@@ -2633,6 +2728,7 @@ function jumpToLive() {
@@ -2633,6 +2728,7 @@ function jumpToLive() {
}
}
dvrState.isLive = true;
dvrState.isLive = true;
+ dvrState.playingPart = null;
// Reset live button
// Reset live button
if (liveBtn) {
if (liveBtn) {
@@ -2652,6 +2748,9 @@ function destroyDVR() {
@@ -2652,6 +2748,9 @@ function destroyDVR() {
if (dvrState.updateTimer) { clearInterval(dvrState.updateTimer); dvrState.updateTimer = null; }
if (dvrState.updateTimer) { clearInterval(dvrState.updateTimer); dvrState.updateTimer = null; }
_destroyDvrHls();
_destroyDvrHls();
dvrState.hlsUrl = null;
dvrState.hlsUrl = null;
+ dvrState.earlier = [];
+ dvrState.activeAt = null;
+ dvrState.playingPart = null;
dvrState.active = false;
dvrState.active = false;
dvrState.isLive = true;
dvrState.isLive = true;
dvrState.vodId = null;
dvrState.vodId = null;
@@ -2833,34 +2932,23 @@ function setupVideoControls() {
@@ -2833,34 +2932,23 @@ function setupVideoControls() {
if (container) {
if (container) {
container.tabIndex = 0;
container.tabIndex = 0;
container.addEventListener('keydown', (e) => {
container.addEventListener('keydown', (e) => {
- if (!dvrState.active || !dvrState.seekable) return;
+ if (!dvrState.active || !(dvrState.seekable || dvrState.earlier.length)) return;
const video = document.getElementById('video-element');
const video = document.getElementById('video-element');
if (!video) return;
if (!video) return;
+ const span = _dvrSpan();
switch (e.key) {
switch (e.key) {
case 'ArrowLeft':
case 'ArrowLeft':
e.preventDefault();
e.preventDefault();
- if (dvrState.isLive) {
- // First rewind: jump to 10s before live edge
- const pos = Math.max(0, dvrState.duration - 10);
- switchToDVR(pos);
- } else {
- // Already in DVR — rewind 5 more seconds
- const newTime = Math.max(0, video.currentTime - 5);
- video.currentTime = newTime;
- updateDVRUI();
- }
+ // First rewind: 10 s before the live edge; then 5 s more each time, into an earlier part when it gets there
+ if (dvrState.isLive) _dvrSeekBar(Math.max(0, span.total - 10));
+ else _dvrNudge(video, -5);
break;
break;
case 'ArrowRight':
case 'ArrowRight':
e.preventDefault();
e.preventDefault();
if (!dvrState.isLive) {
if (!dvrState.isLive) {
- const newTime = video.currentTime + 5;
- if (newTime >= dvrState.duration - 2) {
- jumpToLive();
- } else {
- video.currentTime = newTime;
- updateDVRUI();
- }
+ if (_dvrBarTime(video) + 5 >= span.total - 2) jumpToLive();
+ else _dvrNudge(video, 5);
}
}
break;
break;
}
}
@@ -167,7 +167,28 @@ async function _momentContext(streamId, vodId) {
@@ -167,7 +167,28 @@ async function _momentContext(streamId, vodId) {
sounds = _sample((await db.getTimeline(streamId, { kind: 'sound', limit: 400 }) || [])
sounds = _sample((await db.getTimeline(streamId, { kind: 'sound', limit: 400 }) || [])
.filter(e => Number(e.confidence || 0) >= 0.45), 30);
.filter(e => Number(e.confidence || 0) >= 0.45), 30);
} catch { /* timeline optional */ }
} catch { /* timeline optional */ }
- return { memories, transcript, clipTimes, spikes, sounds };
+ return _inPart({ memories, transcript, clipTimes, spikes, sounds }, vodId);
+}
+
+/**
+ * The signals above are in stream time. A VOD that is one part of a stream (its broadcaster reconnected) starts later
+ * (streaming/stream-history.js): its moments are picked from the signals inside it, moved to its own time. Clip times are
+ * the VOD's own already. A VOD with no part on record keeps the stream's times, as before.
+ */
+async function _inPart(ctx, vodId) {
+ const history = require('../streaming/stream-history');
+ const part = await history.partForVod(vodId);
+ if (!part) return ctx;
+ const len = part.end != null ? part.end - part.at : Infinity;
+ const inside = (t) => Number.isFinite(t) && t >= part.at && t - part.at <= len;
+ const move = (t) => Math.round((t - part.at) * 10) / 10;
+ return {
+ ...ctx,
+ memories: ctx.memories.filter(m => inside(Number(m.offset_seconds) || 0)).map(m => ({ ...m, offset_seconds: move(Number(m.offset_seconds) || 0) })),
+ transcript: ctx.transcript.filter(t => inside(Number(t.start) || 0)).map(t => ({ ...t, start: move(Number(t.start) || 0), end: t.end == null ? null : move(Number(t.end)) })),
+ spikes: ctx.spikes.filter(sp => inside(Number(sp.offset))).map(sp => ({ ...sp, offset: move(Number(sp.offset)) })),
+ sounds: ctx.sounds.filter(e => inside(Number(e.start_sec))).map(e => ({ ...e, start_sec: move(Number(e.start_sec)), end_sec: e.end_sec == null ? null : move(Number(e.end_sec)) })),
+ };
}
}
function _nearestMemory(memories, offset) {
function _nearestMemory(memories, offset) {
let best = null, bestD = Infinity;
let best = null, bestD = Infinity;
@@ -248,15 +248,20 @@ router.get('/vod-transcripts', async (req, res) => {
@@ -248,15 +248,20 @@ router.get('/vod-transcripts', async (req, res) => {
const shown = await Promise.all(ids.map((id) => media.getVod(id).then((v) => (v && !isPrivate(v) ? id : null), () => null)));
const shown = await Promise.all(ids.map((id) => media.getVod(id).then((v) => (v && !isPrivate(v) ? id : null), () => null)));
const out = {};
const out = {};
+ // Timeline rows are in stream time; a VOD that is one part of a stream (the broadcaster reconnected) starts
+ // later than the stream did, so its rows are moved to its own time (streaming/stream-history.js).
+ const history = require('../streaming/stream-history');
+ const offsets = await history.vodOffsets(shown.filter((id) => id != null));
for (const vodId of shown.filter((id) => id != null)) {
for (const vodId of shown.filter((id) => id != null)) {
let segments = [];
let segments = [];
let events = [];
let events = [];
+ const at = (sec) => (sec == null ? sec : offsets.has(vodId) ? history.vodTime(sec, offsets.get(vodId)) : sec);
// Prefer the timeline: it covers the whole stream and carries sound events.
// Prefer the timeline: it covers the whole stream and carries sound events.
try {
try {
const rows = await db.getTimelineByVod(vodId);
const rows = await db.getTimelineByVod(vodId);
for (const r of rows) {
for (const r of rows) {
- if (r.kind === 'speech') segments.push({ start: r.start_sec, end: r.end_sec, text: r.text });
- else events.push({ start: r.start_sec, end: r.end_sec, label: r.label, confidence: r.confidence });
+ if (r.kind === 'speech') segments.push({ start: at(r.start_sec), end: at(r.end_sec), text: r.text });
+ else events.push({ start: at(r.start_sec), end: at(r.end_sec), label: r.label, confidence: r.confidence });
}
}
} catch { /* */ }
} catch { /* */ }
// The timeline and the batch-transcribed blob are two views of the same audio
// The timeline and the batch-transcribed blob are two views of the same audio
@@ -316,7 +321,17 @@ router.get('/transcript/:streamId', async (req, res) => {
@@ -316,7 +321,17 @@ router.get('/transcript/:streamId', async (req, res) => {
try { events = await db.getTimeline(sid, { kind: 'sound', limit: 2000 }) || []; } catch { /* */ }
try { events = await db.getTimeline(sid, { kind: 'sound', limit: 2000 }) || []; } catch { /* */ }
let coverageSec = 0;
let coverageSec = 0;
try { coverageSec = await db.getTimelineCoverage(sid) || 0; } catch { /* */ }
try { coverageSec = await db.getTimelineCoverage(sid) || 0; } catch { /* */ }
- res.json({ streamId: sid, vodId, segments, events, coverageSec });
+ // A stream recorded in parts (its broadcaster reconnected): each part's VOD and where it starts in stream time, so
+ // a line links into the part that recorded it. Public parts only, like vodId.
+ let parts = [];
+ try {
+ const media = require('../media-client');
+ const { isPrivate } = require('../media-proxy/access');
+ const h = await require('../streaming/stream-history').forStream(sid);
+ const shown = await Promise.all(h.parts.map((p) => (p.vod_id ? media.getVod(p.vod_id).then((v) => (v && !isPrivate(v) ? p : null), () => null) : null)));
+ parts = shown.filter(Boolean).map((p) => ({ vod_id: p.vod_id, at: p.at, end: p.end }));
+ } catch { /* no history: vodId alone */ }
+ res.json({ streamId: sid, vodId, parts, segments, events, coverageSec });
} catch (err) {
} catch (err) {
res.status(500).json({ error: 'Failed to load transcript' });
res.status(500).json({ error: 'Failed to load transcript' });
}
}
@@ -236,6 +236,18 @@ const _capturing = new Map(); // streamId -> { ff, dir, cleanup, startedAt, pr
@@ -236,6 +236,18 @@ const _capturing = new Map(); // streamId -> { ff, dir, cleanup, startedAt, pr
// Streams whose spool this process has started: only the first start drops leftover segments (a previous process's,
// Streams whose spool this process has started: only the first start drops leftover segments (a previous process's,
// numbered from 0 again); a restart in this process continues the numbering and keeps what was captured.
// numbered from 0 again); a restart in this process continues the numbering and keeps what was captured.
const _startedHere = new Set();
const _startedHere = new Set();
+// streamId → [{ firstIndex, baseSec }]: each capture run's first segment number and when it began in stream time
+// (seconds since streams.started_at). A capture that restarts (the broadcaster reconnected) numbers on from the last
+// segment, but the seconds it missed are not in any segment: offsets counted from the index alone fell behind the
+// stream by every gap, and started a few seconds after it went live. With each run's start, a segment's offset is
+// stream time, the time the stream's history measures recording parts in (streaming/stream-history.js).
+const _runs = new Map();
+function noteRun(streamId, firstIndex, baseSec) {
+ if (baseSec == null) return;
+ const runs = (_runs.get(streamId) || []).filter((r) => r.firstIndex < firstIndex);
+ runs.push({ firstIndex, baseSec });
+ _runs.set(streamId, runs);
+}
// ffmpeg that ends soon after it started (no RTP from a dead producer, an input it cannot probe) is retried after a
// ffmpeg that ends soon after it started (no RTP from a dead producer, an input it cannot probe) is retried after a
// growing pause instead of every timeline tick, and logged once per streak with its last error line.
// growing pause instead of every timeline tick, and logged once per streak with its last error line.
const _retry = new Map(); // streamId -> { at, delayMs, fails, reason }
const _retry = new Map(); // streamId -> { at, delayMs, fails, reason }
@@ -293,6 +305,7 @@ async function startContinuousCapture(stream) {
@@ -293,6 +305,7 @@ async function startContinuousCapture(stream) {
// every 22 s dropped its own segment each time).
// every 22 s dropped its own segment each time).
let firstIndex = 0;
let firstIndex = 0;
if (!_startedHere.has(stream.id)) {
if (!_startedHere.has(stream.id)) {
+ _runs.delete(stream.id);
try {
try {
const stale = fs.readdirSync(dir).filter(n => /^seg-\d{6}\.wav$/.test(n));
const stale = fs.readdirSync(dir).filter(n => /^seg-\d{6}\.wav$/.test(n));
for (const n of stale) { try { fs.unlinkSync(path.join(dir, n)); } catch { /* */ } }
for (const n of stale) { try { fs.unlinkSync(path.join(dir, n)); } catch { /* */ } }
@@ -337,6 +350,7 @@ async function startContinuousCapture(stream) {
@@ -337,6 +350,7 @@ async function startContinuousCapture(stream) {
const entry = { ff, dir, cleanup, startedAt: Date.now(), producerId, firstIndex };
const entry = { ff, dir, cleanup, startedAt: Date.now(), producerId, firstIndex };
_capturing.set(stream.id, entry);
_capturing.set(stream.id, entry);
_startedHere.add(stream.id);
_startedHere.add(stream.id);
+ noteRun(stream.id, firstIndex, require('../streaming/stream-history').streamSeconds(stream, entry.startedAt));
let lastErr = '';
let lastErr = '';
ff.stderr.on('data', (d) => { const lines = String(d).trim().split('\n'); lastErr = (lines[lines.length - 1] || lastErr).slice(0, 200); });
ff.stderr.on('data', (d) => { const lines = String(d).trim().split('\n'); lastErr = (lines[lines.length - 1] || lastErr).slice(0, 200); });
let exited = false;
let exited = false;
@@ -404,13 +418,16 @@ function pendingSegments(streamId, { includeLast = false } = {}) {
@@ -404,13 +418,16 @@ function pendingSegments(streamId, { includeLast = false } = {}) {
const cur = _capturing.get(streamId) || {};
const cur = _capturing.get(streamId) || {};
const startedAt = cur.startedAt || null;
const startedAt = cur.startedAt || null;
const firstIndex = cur.firstIndex || 0;
const firstIndex = cur.firstIndex || 0;
+ const runs = _runs.get(streamId) || [];
return ready.slice(overflow).map(name => {
return ready.slice(overflow).map(name => {
const index = parseInt(name.slice(4, 10), 10);
const index = parseInt(name.slice(4, 10), 10);
+ let run = null;
+ for (const r of runs) if (r.firstIndex <= index) run = r;
return {
return {
name,
name,
path: path.join(dir, name),
path: path.join(dir, name),
index,
index,
- offsetSec: index * SEGMENT_SEC,
+ offsetSec: run ? Math.round((run.baseSec + (index - run.firstIndex) * SEGMENT_SEC) * 10) / 10 : index * SEGMENT_SEC,
// Wall-clock instant this segment's audio ENDED, so the job can report how far
// Wall-clock instant this segment's audio ENDED, so the job can report how far
// behind live the transcript actually is.
// behind live the transcript actually is.
endedAtMs: startedAt && index >= firstIndex ? startedAt + (index - firstIndex + 1) * SEGMENT_SEC * 1000 : null,
endedAtMs: startedAt && index >= firstIndex ? startedAt + (index - firstIndex + 1) * SEGMENT_SEC * 1000 : null,
@@ -434,6 +451,7 @@ function spooledStreamIds() {
@@ -434,6 +451,7 @@ function spooledStreamIds() {
/** Remove a stream's whole spool directory (call when the stream ends). */
/** Remove a stream's whole spool directory (call when the stream ends). */
function purgeSpool(streamId) {
function purgeSpool(streamId) {
_startedHere.delete(streamId);
_startedHere.delete(streamId);
+ _runs.delete(streamId);
_retry.delete(streamId);
_retry.delete(streamId);
try { fs.rmSync(spoolDir(streamId), { recursive: true, force: true }); return true; }
try { fs.rmSync(spoolDir(streamId), { recursive: true, force: true }); return true; }
catch { return false; }
catch { return false; }
@@ -443,5 +461,5 @@ module.exports = {
@@ -443,5 +461,5 @@ module.exports = {
captureAudioChunk,
captureAudioChunk,
startContinuousCapture, stopContinuousCapture, stopAllCaptures, isCapturing,
startContinuousCapture, stopContinuousCapture, stopAllCaptures, isCapturing,
pendingSegments, discardSegment, purgeSpool, spoolDir, spooledStreamIds,
pendingSegments, discardSegment, purgeSpool, spoolDir, spooledStreamIds,
- SEGMENT_SEC,
+ SEGMENT_SEC, _noteRun: noteRun,
};
};
@@ -112,8 +112,12 @@ async function clipMoment(stream, ev, settings, deps) {
@@ -112,8 +112,12 @@ async function clipMoment(stream, ev, settings, deps) {
const media = deps.media || require('../media-client');
const media = deps.media || require('../media-client');
const rec = recorder.getActiveRecording(stream.id);
const rec = recorder.getActiveRecording(stream.id);
if (!rec || !rec.vodId) return say(stream, 'I can\'t clip right now: this stream isn\'t being recorded.', { speak: settings.speak, deps });
if (!rec || !rec.vodId) return say(stream, 'I can\'t clip right now: this stream isn\'t being recorded.', { speak: settings.speak, deps });
- const edge = Math.max(0, ((deps.now || Date.now)() - rec.startedAt) / 1000);
- const end = Math.min(Number(ev.start_sec) + 1, edge - 0.5);
+ const t0 = rec.mediaStartedAt || rec.startedAt;
+ const edge = Math.max(0, ((deps.now || Date.now)() - t0) / 1000);
+ // The phrase's time is stream time; the recording (a later part, when the broadcaster reconnected) starts at t0.
+ const partAt = require('../streaming/stream-history').streamSeconds(stream, t0);
+ const said = Number(ev.start_sec) - (partAt != null && rec.mediaStartedAt ? partAt : 0);
+ const end = Math.min(said + 1, edge - 0.5);
const start = Math.max(0, end - CLIP_SEC);
const start = Math.max(0, end - CLIP_SEC);
if (end - start < 5) return say(stream, 'Give me a few seconds of the stream first, then ask again.', { speak: settings.speak, deps });
if (end - start < 5) return say(stream, 'Give me a few seconds of the stream first, then ask again.', { speak: settings.speak, deps });
const title = `${String(stream.title || 'Stream moment').slice(0, 70)} (clipped by voice)`;
const title = `${String(stream.title || 'Stream moment').slice(0, 70)} (clipped by voice)`;
@@ -692,7 +692,8 @@ router.post('/clips', requireAuth, memUpload.single('video'), async (req, res) =
@@ -692,7 +692,8 @@ router.post('/clips', requireAuth, memUpload.single('video'), async (req, res) =
const parsedVodId = vod_id ? parseInt(vod_id, 10) : null;
const parsedVodId = vod_id ? parseInt(vod_id, 10) : null;
const startTime = Number.parseFloat(req.body?.start_time);
const startTime = Number.parseFloat(req.body?.start_time);
const endTime = Number.parseFloat(req.body?.end_time);
const endTime = Number.parseFloat(req.body?.end_time);
- const isLiveClip = !!req.file || ['true', '1', true].includes(req.body.live);
+ const streamTime = ['true', '1', true].includes(req.body?.stream_time); // the studio's clip panel: a live stream's moment
+ const isLiveClip = !!req.file || ['true', '1', true].includes(req.body.live) || streamTime;
const sanitizedTitle = sanitizeClipTitle(title);
const sanitizedTitle = sanitizeClipTitle(title);
if (accountTooNew(req.user)) return res.status(403).json({ error: 'Your account is too new to create clips. Please wait a minute.' });
if (accountTooNew(req.user)) return res.status(403).json({ error: 'Your account is too new to create clips. Please wait a minute.' });
@@ -730,6 +731,39 @@ router.post('/clips', requireAuth, memUpload.single('video'), async (req, res) =
@@ -730,6 +731,39 @@ router.post('/clips', requireAuth, memUpload.single('video'), async (req, res) =
return res.status(201).json({ clip });
return res.status(201).json({ clip });
}
}
+ // ── Stream-time clip (the go-live studio's clip panel): start/end in seconds since the stream started ──
+ // A stream whose broadcaster reconnected is recorded in parts, each its own VOD (stream 2795: VODs 6025 and
+ // 6026). The panel measures from the stream's start, and used to send those times to the VOD it knew first:
+ // "Start time exceeds video duration". The stream's history (streaming/stream-history.js) knows where each
+ // part began, so the window goes to the part recording its end, in that part's own time.
+ if (!req.file && parsedStreamId && !parsedVodId && streamTime) {
+ if (!Number.isFinite(startTime) || !Number.isFinite(endTime)) return res.status(400).json({ error: 'start_time and end_time required' });
+ if (startTime < 0 || endTime < 0) return res.status(400).json({ error: 'Time values cannot be negative' });
+ const maxClipDuration = await db.getSetting('max_clip_duration') || 60;
+ if (endTime - startTime < 1) return res.status(400).json({ error: 'Clip must be at least 1 second' });
+ if (endTime - startTime > maxClipDuration) return res.status(400).json({ error: `Clips are limited to ${maxClipDuration} seconds` });
+ const stream = await db.getStreamById(parsedStreamId);
+ if (!stream) return res.status(404).json({ error: 'Stream not found' });
+ if (!await db.isStreamClipRecordingEnabled(stream)) return res.status(403).json({ error: 'Clipping is disabled for this stream.' });
+ const place = await clipPlace(stream, startTime, endTime);
+ if (!place) return res.status(409).json({ error: 'This stream was not being recorded at that moment, so it cannot be clipped.', no_recording: true });
+ let vod;
+ try { vod = await media.getVod(place.vodId); } catch (err) { return mediaErr(res, err, 'VOD not found'); }
+ if (!vod || !await access.canView(req.user, vod)) return vodNotFound(res);
+ const clip = await media.createClip({
+ vod_id: place.vodId,
+ start_s: place.start,
+ end_s: place.end,
+ title: defaultClipTitle(sanitizedTitle, stream.title),
+ user_id: req.user.id,
+ stream_id: stream.id,
+ visibility: await db.resolveStreamClipVisibility(stream),
+ });
+ noteClip(req.user.id);
+ await _notifyStreamerOfClip(stream.id, req.user, sanitizedTitle, clip?.id);
+ return res.status(201).json({ clip, vod_id: place.vodId, part: place.part });
+ }
+
// ── LIVE clip — cut server-side from the stream's active Media recording ──
// ── LIVE clip — cut server-side from the stream's active Media recording ──
if (isLiveClip && parsedStreamId && !parsedVodId) {
if (isLiveClip && parsedStreamId && !parsedVodId) {
const stream = await db.getStreamById(parsedStreamId);
const stream = await db.getStreamById(parsedStreamId);
@@ -753,7 +787,7 @@ router.post('/clips', requireAuth, memUpload.single('video'), async (req, res) =
@@ -753,7 +787,7 @@ router.post('/clips', requireAuth, memUpload.single('video'), async (req, res) =
let dur = parseFloat(req.body.duration);
let dur = parseFloat(req.body.duration);
if (!Number.isFinite(dur) || dur < 1) dur = 30;
if (!Number.isFinite(dur) || dur < 1) dur = 30;
dur = Math.min(dur, maxDur);
dur = Math.min(dur, maxDur);
- const recElapsed = Math.max(0, (Date.now() - rec.startedAt) / 1000);
+ const recElapsed = Math.max(0, (Date.now() - (rec.mediaStartedAt || rec.startedAt)) / 1000);
const safeEdge = Math.max(0, recElapsed - 1.5); // stay inside flushed footage
const safeEdge = Math.max(0, recElapsed - 1.5); // stay inside flushed footage
let clipEnd = parseFloat(req.body.at);
let clipEnd = parseFloat(req.body.at);
if (!Number.isFinite(clipEnd) || clipEnd <= 0) clipEnd = safeEdge;
if (!Number.isFinite(clipEnd) || clipEnd <= 0) clipEnd = safeEdge;
@@ -811,6 +845,29 @@ router.post('/clips', requireAuth, memUpload.single('video'), async (req, res) =
@@ -811,6 +845,29 @@ router.post('/clips', requireAuth, memUpload.single('video'), async (req, res) =
}
}
});
});
+/**
+ * Where a window of stream time (seconds since the stream started) was recorded: the part recording its end, and the
+ * window in that part's own seconds → { vodId, part, start, end }, or null when nothing recorded it. Without a history
+ * row (a part started before the history existed) the active recording is measured from its start.
+ */
+async function clipPlace(stream, startTime, endTime) {
+ const history = require('../streaming/stream-history');
+ let part = await history.partAt(stream.id, endTime);
+ if (!part) {
+ const rec = recorder.getActiveRecording(stream.id);
+ const at = rec && rec.vodId ? history.streamSeconds(stream, rec.mediaStartedAt || rec.startedAt) : null;
+ if (at == null || at > endTime) return null;
+ part = { vod_id: rec.vodId, part: rec.part || 1, at, end: null };
+ }
+ if (!part.vod_id) return null;
+ let end = endTime - part.at;
+ if (part.end != null) end = Math.min(end, part.end - part.at);
+ // A window that begins before its part did (the reconnect's gap): the part's own seconds only, at most as long as asked.
+ const start = Math.max(0, end - (endTime - startTime));
+ if (end - start < 1) return null;
+ return { vodId: part.vod_id, part: part.part, start: Math.round(start * 10) / 10, end: Math.round(end * 10) / 10 };
+}
+
async function _notifyStreamerOfClip(streamId, clipper, title, clipId) {
async function _notifyStreamerOfClip(streamId, clipper, title, clipId) {
try {
try {
if (!streamId) return;
if (!streamId) return;
@@ -53,12 +53,17 @@ async function segmentsOut(rows, voter) {
@@ -53,12 +53,17 @@ async function segmentsOut(rows, voter) {
const ppl = await people(rows.flatMap((r) => [r.user_id, ...(r.mentions || [])]));
const ppl = await people(rows.flatMap((r) => [r.user_id, ...(r.mentions || [])]));
const tps = await topicsById(rows.map((r) => r.topic_id));
const tps = await topicsById(rows.map((r) => r.topic_id));
const rx = await reactionsFor('segment', rows.map((r) => r.id), voter);
const rx = await reactionsFor('segment', rows.map((r) => r.id), voter);
+ // Segment and quote times are stream time; `sec` links into vod_id, which for a stream recorded in parts (its
+ // broadcaster reconnected) starts later than the stream (streaming/stream-history.js).
+ const history = require('../streaming/stream-history');
+ const offsets = await history.vodOffsets(rows.map((r) => r.vod_id));
+ const inVod = (r, sec) => Math.max(0, Math.floor(history.vodTime(sec, r.vod_id ? offsets.get(Number(r.vod_id)) : 0)));
return rows.map((r) => ({
return rows.map((r) => ({
type: 'segment', id: Number(r.id), title: r.title, what: r.what, take: r.take || null, quip: r.quip || null, score: Number(r.score),
type: 'segment', id: Number(r.id), title: r.title, what: r.what, take: r.take || null, quip: r.quip || null, score: Number(r.score),
- quotes: parseJson(r.quotes_json, []).map((q) => ({ text: q.text, en: q.en || null, sec: Math.max(0, Math.floor(Number(q.sec) || 0)) })),
+ quotes: parseJson(r.quotes_json, []).map((q) => ({ text: q.text, en: q.en || null, sec: inVod(r, q.sec) })),
topic: tps.get(Number(r.topic_id)) || null, user: ppl.get(Number(r.user_id)) || null,
topic: tps.get(Number(r.topic_id)) || null, user: ppl.get(Number(r.user_id)) || null,
mentions: (r.mentions || []).map((id) => ppl.get(Number(id))).filter(Boolean),
mentions: (r.mentions || []).map((id) => ppl.get(Number(id))).filter(Boolean),
- stream_id: Number(r.stream_id), vod_id: r.vod_id ? Number(r.vod_id) : null, sec: Math.max(0, Math.floor(Number(r.start_sec) || 0)),
+ stream_id: Number(r.stream_id), vod_id: r.vod_id ? Number(r.vod_id) : null, sec: inVod(r, r.start_sec),
said_at: r.said_at, reactions: rx.counts.get(Number(r.id)) || {}, my_reaction: rx.mine.get(Number(r.id)) || null,
said_at: r.said_at, reactions: rx.counts.get(Number(r.id)) || {}, my_reaction: rx.mine.get(Number(r.id)) || null,
}));
}));
}
}
@@ -294,6 +294,7 @@ class BroadcastServer extends EventEmitter {
@@ -294,6 +294,7 @@ class BroadcastServer extends EventEmitter {
}
}
room.broadcaster = ws;
room.broadcaster = ws;
console.log(`[Broadcast] Broadcaster connected: stream ${streamId} (${user.username})`);
console.log(`[Broadcast] Broadcaster connected: stream ${streamId} (${user.username})`);
+ require('./stream-history').broadcasterBack(streamId).catch(() => {});
this.emit('broadcaster-connected', { streamId, userId: user.id });
this.emit('broadcaster-connected', { streamId, userId: user.id });
// Notify existing viewers to re-negotiate
// Notify existing viewers to re-negotiate
@@ -411,6 +412,12 @@ class BroadcastServer extends EventEmitter {
@@ -411,6 +412,12 @@ class BroadcastServer extends EventEmitter {
}
}
break;
break;
+ case 'shape':
+ // The broadcaster's studio: the picture it sends changed size (a phone turned, WebRTC lowered the
+ // resolution). Kept in the stream's history (stream-history.js) for seeks and the record.
+ if (client.role === 'broadcaster') require('./stream-history').shape(client.streamId, msg.width, msg.height);
+ break;
+
case 'chat-tts':
case 'chat-tts':
// TTS message from chat to broadcaster
// TTS message from chat to broadcaster
if (room.broadcaster && room.broadcaster.readyState === WebSocket.OPEN) {
if (room.broadcaster && room.broadcaster.readyState === WebSocket.OPEN) {
@@ -504,6 +511,7 @@ class BroadcastServer extends EventEmitter {
@@ -504,6 +511,7 @@ class BroadcastServer extends EventEmitter {
room.broadcaster = null;
room.broadcaster = null;
room._sfuProduceRequestedAt = 0;
room._sfuProduceRequestedAt = 0;
console.log(`[Broadcast] Broadcaster disconnected: stream ${client.streamId}`);
console.log(`[Broadcast] Broadcaster disconnected: stream ${client.streamId}`);
+ require('./stream-history').broadcasterLeft(client.streamId);
// Start a grace timer — if broadcaster doesn't reconnect, end the stream cleanly
// Start a grace timer — if broadcaster doesn't reconnect, end the stream cleanly
if (room._disconnectTimer) clearTimeout(room._disconnectTimer);
if (room._disconnectTimer) clearTimeout(room._disconnectTimer);
@@ -99,7 +99,7 @@ class StreamRecorder {
@@ -99,7 +99,7 @@ class StreamRecorder {
/** Stop a WebRTC recording and start its next part a moment later, on the producers there are then. */
/** Stop a WebRTC recording and start its next part a moment later, on the producers there are then. */
_rollRecording(streamId, rec, why) {
_rollRecording(streamId, rec, why) {
rec._rolling = true;
rec._rolling = true;
- const next = { mode: rec.mode, part: (rec.part || 1) + 1, baseTitle: rec.baseTitle };
+ const next = { mode: rec.mode, part: (rec.part || 1) + 1, baseTitle: rec.baseTitle, why };
console.log(`[VOD] Stream ${streamId}: ${why} — rolling recording to part ${next.part}`);
console.log(`[VOD] Stream ${streamId}: ${why} — rolling recording to part ${next.part}`);
this.stopRecording(streamId);
this.stopRecording(streamId);
const timer = setTimeout(async () => {
const timer = setTimeout(async () => {
@@ -139,6 +139,7 @@ class StreamRecorder {
@@ -139,6 +139,7 @@ class StreamRecorder {
clipsOnly: mode === 'clips',
clipsOnly: mode === 'clips',
startedAt: Date.now(),
startedAt: Date.now(),
part: opts.part || 1,
part: opts.part || 1,
+ why: opts.why || null,
baseTitle: opts.baseTitle || stream.title || 'Stream Recording',
baseTitle: opts.baseTitle || stream.title || 'Stream Recording',
type: protocol === 'rtmp' ? 'rtmp' : 'rtp',
type: protocol === 'rtmp' ? 'rtmp' : 'rtp',
webrtcState: null,
webrtcState: null,
@@ -224,6 +225,7 @@ class StreamRecorder {
@@ -224,6 +225,7 @@ class StreamRecorder {
if (rec._cancel) return this._abort(streamId, rec);
if (rec._cancel) return this._abort(streamId, rec);
const rtmpUrl = `rtmp://127.0.0.1:${config.rtmp.port}/live/${endpoint.streamKey}`;
const rtmpUrl = `rtmp://127.0.0.1:${config.rtmp.port}/live/${endpoint.streamKey}`;
await media.ingestRtmp(rec.vodId, rtmpUrl);
await media.ingestRtmp(rec.vodId, rtmpUrl);
+ this._partStarted(stream, rec);
console.log(`[VOD] Media RTMP ingest started: stream ${streamId} → vod ${rec.vodId} (${rec.mode})`);
console.log(`[VOD] Media RTMP ingest started: stream ${streamId} → vod ${rec.vodId} (${rec.mode})`);
}
}
@@ -314,9 +316,21 @@ class StreamRecorder {
@@ -314,9 +316,21 @@ class StreamRecorder {
}
}
rec.webrtcState = webrtcState;
rec.webrtcState = webrtcState;
+ this._partStarted(stream, rec);
console.log(`[VOD] Media RTP ingest started: stream ${streamId} → vod ${rec.vodId} (v:${videoPort}${audioPort ? ` a:${audioPort}` : ''}, ${rec.mode})`);
console.log(`[VOD] Media RTP ingest started: stream ${streamId} → vod ${rec.vodId} (v:${videoPort}${audioPort ? ` a:${audioPort}` : ''}, ${rec.mode})`);
}
}
+ /**
+ * The recording's footage starts now (Media is ingesting): mediaStartedAt is its time 0, which a live clip measures
+ * from (startedAt is earlier by the VOD creation and the wait for the microphone), and the stream's history notes the
+ * part (stream-history.js), so stream time can be turned into this VOD's time later.
+ */
+ _partStarted(stream, rec) {
+ if (!rec.mediaStartedAt) rec.mediaStartedAt = Date.now();
+ if (rec.clipsOnly) return;
+ require('./stream-history').partStarted(stream, { vodId: rec.vodId, part: rec.part, why: rec.why, atMs: rec.mediaStartedAt }).catch(() => {});
+ }
+
_closePlainConsumers(webrtcState) {
_closePlainConsumers(webrtcState) {
if (!webrtcState) return;
if (!webrtcState) return;
try {
try {
@@ -345,6 +359,8 @@ class StreamRecorder {
@@ -345,6 +359,8 @@ class StreamRecorder {
startedAt: Date.now(), part: 1, baseTitle: null, type: 'chunks', webrtcState: null,
startedAt: Date.now(), part: 1, baseTitle: null, type: 'chunks', webrtcState: null,
};
};
this.activeRecordings.set(streamId, rec);
this.activeRecordings.set(streamId, rec);
+ rec.mediaStartedAt = rec.startedAt;
+ db.getStreamById(streamId).then((stream) => { if (stream) this._partStarted(stream, rec); }).catch(() => {});
return rec;
return rec;
}
}
@@ -367,9 +383,11 @@ class StreamRecorder {
@@ -367,9 +383,11 @@ class StreamRecorder {
this.activeRecordings.delete(streamId);
this.activeRecordings.delete(streamId);
this._finalizing.add(streamId);
this._finalizing.add(streamId);
+ const stoppedAt = Date.now();
const finish = (async () => {
const finish = (async () => {
this._closePlainConsumers(rec.webrtcState);
this._closePlainConsumers(rec.webrtcState);
if (!rec.vodId) return;
if (!rec.vodId) return;
+ if (rec.mediaStartedAt && !rec.clipsOnly) require('./stream-history').partEnded(streamId, rec.vodId, stoppedAt).catch(() => {});
if (rec.type === 'rtp') {
if (rec.type === 'rtp') {
await media.ingestRtpStop(rec.vodId).catch((e) => console.warn(`[VOD] rtp stop failed (vod ${rec.vodId}):`, e.message));
await media.ingestRtpStop(rec.vodId).catch((e) => console.warn(`[VOD] rtp stop failed (vod ${rec.vodId}):`, e.message));
} else if (rec.type === 'chunks') {
} else if (rec.type === 'chunks') {
@@ -1800,6 +1800,46 @@ router.post('/managed/:id/regenerate-key', requireAuth, async (req, res) => {
@@ -1800,6 +1800,46 @@ router.post('/managed/:id/regenerate-key', requireAuth, async (req, res) => {
}
}
});
});
+// ── What happened during a stream (stream-history.js) ───────
+// Its recording parts (each its own VOD, with where it starts in stream time), the broadcaster's picture sizes and
+// reconnects, in seconds since the stream started. The player rewinds across parts with it; a part whose VOD the
+// viewer cannot see is listed without its VOD.
+router.get('/:id/history', optionalAuth, async (req, res) => {
+ try {
+ const stream = await db.getStreamById(req.params.id);
+ if (!stream) return res.status(404).json({ error: 'Stream not found' });
+ const history = require('./stream-history');
+ const media = require('../media-client');
+ const access = require('../media-proxy/access');
+ const h = await history.forStream(stream.id);
+ let active = null;
+ try { active = require('./recorder').getActiveRecording(stream.id)?.vodId || null; } catch { /* */ }
+ const parts = await Promise.all(h.parts.map(async (p) => {
+ let vod = null;
+ try { vod = p.vod_id ? await media.getVod(p.vod_id) : null; } catch { /* Media unreachable: listed without it */ }
+ const seen = vod && await access.canView(req.user, vod).catch(() => false);
+ const recording = !!(active && p.vod_id === active && stream.is_live);
+ // A finished part plays from Media's timeline playlist when it has one (one period per shape), else its file.
+ if (seen) media.setPlaybackUrls(vod, 'vod');
+ const src = seen && !recording ? (vod.hls_url || vod.file_url || null) : null;
+ return { part: p.part, vod_id: seen ? p.vod_id : null, at: p.at, end: p.end, recording, src, hls: !!(src && /\.m3u8(\?|$)/.test(src)) };
+ }));
+ res.set('Cache-Control', 'private, max-age=0');
+ res.json({
+ stream_id: stream.id,
+ started_at: stream.started_at || null,
+ is_live: !!stream.is_live,
+ now_sec: stream.is_live ? history.streamSeconds(stream) : null,
+ parts,
+ shapes: h.shapes.map(({ at, width, height }) => ({ at, width, height })),
+ reconnects: h.reconnects,
+ });
+ } catch (err) {
+ console.error('[Streams] history error:', err.message);
+ res.status(500).json({ error: 'Failed to get the stream history' });
+ }
+});
+
// ── Get Stream Details ───────────────────────────────────────
// ── Get Stream Details ───────────────────────────────────────
router.get('/:id', optionalAuth, async (req, res) => {
router.get('/:id', optionalAuth, async (req, res) => {
try {
try {
new file mode 100644
new file mode 100644
@@ -0,0 +1,210 @@
@@ -0,0 +1,210 @@
+'use strict';
+/**
+ * What happened during a live stream, in stream time (seconds since streams.started_at), kept so a seek, a clip or a
+ * transcript can use it later (migration 0020_stream_history; owner, 2026-10-26).
+ *
+ * Stream 2795 showed why: its broadcaster reconnected 11 minutes in, so it was recorded in two parts (VODs 6025 and
+ * 6026), and nothing remembered where part 2 began. The studio's clip panel measures from the stream's start and sent
+ * those times to part 1 ("Start time exceeds video duration"); a viewer's rewind could not reach part 1; part 2's
+ * transcript lines pointed 11 minutes late. With each part's start on record, stream time minus that start is the
+ * part's own time.
+ *
+ * partStarted(stream, { vodId, part, why }) a recording part began (recorder.js, once Media made its VOD)
+ * partEnded(streamId, vodId) it stopped
+ * shape(streamId, width, height) the broadcaster's picture changed size (its studio reports it); a burst
+ * of changes is written at most every SHAPE_MIN_MS, the last one kept
+ * broadcasterLeft(streamId) / broadcasterBack(streamId) a reconnect, with its gap
+ * forStream(streamId) { parts, shapes, reconnects } in time order
+ * partAt(streamId, sec) the part recording stream time `sec` (or the nearest one before it)
+ * partForVod(vodId) a VOD's part row, or null
+ *
+ * Writes never throw: the history is a record, not a gate on going live or recording.
+ */
+const db = require('../db/database');
+
+const SHAPE_MIN_MS = 2000;
+const MAX_SIDE = 16384;
+
+/** Epoch ms of a stored timestamp ('YYYY-MM-DD HH:MM:SS' is UTC), or null. */
+function epochMs(v) {
+ if (!v) return null;
+ if (v instanceof Date) return isNaN(v) ? null : v.getTime();
+ const t = String(v);
+ const d = new Date(/^\d{4}-\d{2}-\d{2} /.test(t) ? `${t.replace(' ', 'T')}Z` : t);
+ return isNaN(d) ? null : d.getTime();
+}
+
+/** Seconds since the stream started at `atMs` (now by default); null when it has no start. */
+function streamSeconds(stream, atMs = Date.now()) {
+ const start = epochMs(stream && stream.started_at);
+ return start == null ? null : Math.max(0, Math.round((atMs - start) / 100) / 10);
+}
+
+const _streams = new Map(); // streamId → { started: epoch ms, shape: { width, height, writtenAt, pending, timer }, leftAt }
+
+async function startOf(streamId) {
+ const cur = _streams.get(streamId);
+ if (cur && cur.started != null) return cur.started;
+ let started = null;
+ try { started = epochMs((await db.getStreamById(streamId))?.started_at); } catch { /* */ }
+ if (started != null) entry(streamId).started = started;
+ return started;
+}
+const MAX_STREAMS = 500; // in-memory state of streams seen lately; the oldest goes first (rows stay in the table)
+function entry(streamId) {
+ let e = _streams.get(streamId);
+ if (!e) {
+ _streams.set(streamId, e = {});
+ for (const id of _streams.keys()) { if (_streams.size <= MAX_STREAMS) break; forget(id); }
+ }
+ return e;
+}
+
+async function add(row) {
+ try {
+ await db.run(`INSERT INTO stream_history (stream_id, kind, at_sec, end_sec, vod_id, detail) VALUES (?, ?, ?, ?, ?, ?)`,
+ [row.stream_id, row.kind, row.at_sec, row.end_sec == null ? null : row.end_sec, row.vod_id || null, row.detail ? JSON.stringify(row.detail) : null]);
+ return true;
+ } catch (err) {
+ console.warn(`[History] stream ${row.stream_id} ${row.kind} not recorded:`, err.message);
+ return false;
+ }
+}
+
+/** The part recording now, for a shape row: the recorder's active VOD. */
+function recordingVod(streamId) {
+ try { return require('./recorder').getActiveRecording(streamId)?.vodId || null; } catch { return null; }
+}
+
+async function partStarted(stream, { vodId, part = 1, why = null, atMs = Date.now() } = {}) {
+ if (!stream || !stream.id || !vodId) return false;
+ const at = streamSeconds(stream, atMs);
+ if (at == null) return false;
+ entry(stream.id).started = epochMs(stream.started_at);
+ return add({ stream_id: stream.id, kind: 'part', at_sec: at, vod_id: vodId, detail: { part, ...(why ? { why } : {}) } });
+}
+
+async function partEnded(streamId, vodId, atMs = Date.now()) {
+ if (!streamId || !vodId) return false;
+ const start = await startOf(streamId);
+ if (start == null) return false;
+ try {
+ await db.run(`UPDATE stream_history SET end_sec = ? WHERE stream_id = ? AND kind = 'part' AND vod_id = ? AND end_sec IS NULL`,
+ [Math.max(0, Math.round((atMs - start) / 100) / 10), streamId, vodId]);
+ return true;
+ } catch { return false; }
+}
+
+async function writeShape(streamId, shape) {
+ const start = await startOf(streamId);
+ if (start == null) return;
+ const e = entry(streamId);
+ e.shape = { ...(e.shape || {}), width: shape.width, height: shape.height, writtenAt: Date.now() };
+ await add({ stream_id: streamId, kind: 'shape', at_sec: Math.max(0, Math.round((shape.atMs - start) / 100) / 10),
+ vod_id: recordingVod(streamId), detail: { width: shape.width, height: shape.height } });
+}
+
+/** The broadcaster's picture is now width×height. Returns false for a size that is not one. */
+function shape(streamId, width, height) {
+ width = Number(width); height = Number(height);
+ if (!streamId || !Number.isInteger(width) || !Number.isInteger(height) || width < 16 || height < 16 || width > MAX_SIDE || height > MAX_SIDE) return false;
+ const e = entry(streamId);
+ const s = e.shape || {};
+ if (s.pending) s.pending = null;
+ if (s.width === width && s.height === height) return true; // back where the last row says: nothing new
+ const next = { width, height, atMs: Date.now() };
+ const wait = (s.writtenAt || 0) + SHAPE_MIN_MS - Date.now();
+ if (wait <= 0) { writeShape(streamId, next).catch(() => {}); return true; }
+ e.shape = { ...s, pending: next };
+ if (!s.timer) {
+ const timer = setTimeout(() => {
+ const cur = _streams.get(streamId)?.shape;
+ if (!cur) return;
+ cur.timer = null;
+ const p = cur.pending;
+ cur.pending = null;
+ if (p && !(p.width === cur.width && p.height === cur.height)) writeShape(streamId, p).catch(() => {});
+ }, wait);
+ if (timer.unref) timer.unref();
+ e.shape.timer = timer;
+ }
+ return true;
+}
+
+function broadcasterLeft(streamId) { entry(streamId).leftAt = Date.now(); }
+
+async function broadcasterBack(streamId) {
+ const e = _streams.get(streamId);
+ if (!e || !e.leftAt) return false;
+ const left = e.leftAt;
+ e.leftAt = null;
+ const start = await startOf(streamId);
+ if (start == null) return false;
+ return add({ stream_id: streamId, kind: 'reconnect', at_sec: Math.max(0, Math.round((Date.now() - start) / 100) / 10),
+ detail: { gap_s: Math.round((Date.now() - left) / 100) / 10 } });
+}
+
+/** The stream ended: forget its in-memory state (rows stay). */
+function forget(streamId) {
+ const e = _streams.get(streamId);
+ if (e && e.shape && e.shape.timer) clearTimeout(e.shape.timer);
+ _streams.delete(streamId);
+}
+
+const parse = (detail) => { try { return detail ? JSON.parse(detail) : {}; } catch { return {}; } };
+
+async function forStream(streamId) {
+ let rows = [];
+ try {
+ rows = await db.all(`SELECT kind, at_sec, end_sec, vod_id, detail FROM stream_history WHERE stream_id = ? ORDER BY at_sec ASC, id ASC LIMIT 5000`, [streamId]);
+ } catch { /* none */ }
+ const out = { parts: [], shapes: [], reconnects: [] };
+ for (const r of rows) {
+ const d = parse(r.detail);
+ if (r.kind === 'part') out.parts.push({ part: d.part || out.parts.length + 1, vod_id: Number(r.vod_id) || null, at: Number(r.at_sec), end: r.end_sec == null ? null : Number(r.end_sec), ...(d.why ? { why: d.why } : {}) });
+ else if (r.kind === 'shape') out.shapes.push({ at: Number(r.at_sec), width: d.width, height: d.height, vod_id: Number(r.vod_id) || null });
+ else if (r.kind === 'reconnect') out.reconnects.push({ at: Number(r.at_sec), gap_s: d.gap_s == null ? null : Number(d.gap_s) });
+ }
+ return out;
+}
+
+async function partAt(streamId, sec) {
+ const { parts } = await forStream(streamId);
+ let best = null;
+ for (const p of parts) if (p.at <= sec) best = p;
+ return best;
+}
+
+async function partForVod(vodId) {
+ if (!vodId) return null;
+ try {
+ const r = await db.get(`SELECT stream_id, at_sec, end_sec, detail FROM stream_history WHERE kind = 'part' AND vod_id = ? ORDER BY id ASC LIMIT 1`, [vodId]);
+ if (!r) return null;
+ const d = parse(r.detail);
+ return { stream_id: Number(r.stream_id), vod_id: Number(vodId), part: d.part || 1, at: Number(r.at_sec), end: r.end_sec == null ? null : Number(r.end_sec) };
+ } catch { return null; }
+}
+
+/**
+ * Where each of these VODs starts in its stream's time → Map(vodId → seconds). Stream time minus that is the VOD's own
+ * time (a timeline row's start_sec, a Rundown segment's). A VOD with no part row (recorded before the history) is
+ * left out: its rows are read as they always were.
+ */
+async function vodOffsets(vodIds) {
+ const ids = [...new Set((vodIds || []).map(Number).filter((n) => Number.isSafeInteger(n) && n > 0))].slice(0, 500);
+ const out = new Map();
+ if (!ids.length) return out;
+ try {
+ const rows = await db.all(`SELECT vod_id, MIN(at_sec) AS at FROM stream_history WHERE kind = 'part' AND vod_id IN (${ids.map(() => '?').join(',')}) GROUP BY vod_id`, ids);
+ for (const r of rows) out.set(Number(r.vod_id), Number(r.at));
+ } catch { /* none */ }
+ return out;
+}
+
+/** A time in stream seconds as seconds into the VOD that starts at `offset` (never before its start). */
+const vodTime = (sec, offset) => Math.max(0, Math.round(((Number(sec) || 0) - (Number(offset) || 0)) * 100) / 100);
+
+module.exports = {
+ partStarted, partEnded, shape, broadcasterLeft, broadcasterBack, forget,
+ forStream, partAt, partForVod, vodOffsets, vodTime, streamSeconds, epochMs, SHAPE_MIN_MS,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,83 @@
@@ -0,0 +1,83 @@
+/**
+ * The live rewind spans every recording part of the stream (public/js/stream-player.js _dvrSpan, _dvrBarTime,
+ * _dvrSeekBar, _dvrNudge). Stream 2795 (2026-10-26) was recorded in two parts after its broadcaster reconnected, and the
+ * rewind only knew the part being recorded. With the stream's history (GET /api/streams/:id/history) the bar runs from
+ * the first part's start to the live edge, in stream time:
+ * - a point in the recording part seeks the live recording at that part's time;
+ * - a point in an earlier part plays that part's VOD at its own time (never past its end);
+ * - with no history the bar is the recording part alone, as before;
+ * - nudging by 5 s moves within what is playing, and into the next part across a boundary.
+ *
+ * node test/player-dvr-parts.test.js
+ */
+'use strict';
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const vm = require('vm');
+
+const src = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'stream-player.js'), 'utf8');
+function extract(name) {
+ const m = new RegExp(`(?:async )?function ${name}\\(`).exec(src);
+ assert.ok(m, `${name}() must exist`);
+ let i = m.index + m[0].length;
+ for (let parens = 1; parens; i++) { if (src[i] === '(') parens++; else if (src[i] === ')') parens--; }
+ i = src.indexOf('{', i);
+ for (let depth = 0; i < src.length; i++) {
+ if (src[i] === '{') depth++;
+ else if (src[i] === '}' && --depth === 0) return src.slice(m.index, i + 1);
+ }
+ throw new Error(`unbalanced ${name}`);
+}
+
+const calls = [];
+const video = { currentTime: 0 };
+const ctx = { Math, Number, calls, video, updateDVRUI: () => {}, switchToDVR: (t, part = null) => calls.push([Math.round(t * 10) / 10, part ? part.vodId : 'rec']) };
+vm.createContext(ctx);
+vm.runInContext(['_dvrSpan', '_dvrBarTime', '_dvrSeekBar', '_dvrNudge'].map(extract).join('\n'), ctx);
+
+// Stream 2795's shape: part 1 (VOD 6025) from 28 s to 706 s, part 2 recording since 709 s and 900 s long so far.
+const part1 = { vodId: 6025, at: 28, end: 706, src: 'https://media.test/o/med_1/master.m3u8', hls: true };
+const set = (state) => vm.runInContext(`var dvrState = ${JSON.stringify(state)};`, ctx);
+set({ duration: 900, seekable: true, hlsUrl: 'https://media.test/v/6026/dvr.m3u8', vodFile: null, earlier: [part1], activeAt: 709, playingPart: null });
+
+const span = vm.runInContext('_dvrSpan()', ctx);
+assert.deepStrictEqual({ ...span }, { start: 28, total: 709 - 28 + 900 }, 'the bar runs from part 1\'s start to the live edge');
+
+vm.runInContext('_dvrSeekBar(1500)', ctx); // stream 1528 s: part 2 at 819 s
+assert.deepStrictEqual(calls.pop(), [819, 'rec']);
+vm.runInContext('_dvrSeekBar(100)', ctx); // stream 128 s: part 1 at 100 s
+assert.deepStrictEqual(calls.pop(), [100, 6025]);
+vm.runInContext('_dvrSeekBar(679.9)', ctx); // stream 707.9 s, the reconnect's gap: part 1, just before its end
+assert.deepStrictEqual(calls.pop(), [677.5, 6025]);
+
+// The bar time of what plays: part 1 at 650 s is 650 s in; the recording at 10 s is (709 - 28) + 10.
+vm.runInContext('dvrState.playingPart = dvrState.earlier[0];', ctx);
+video.currentTime = 650;
+assert.strictEqual(vm.runInContext('_dvrBarTime(video)', ctx), 650);
+vm.runInContext('dvrState.playingPart = null;', ctx);
+video.currentTime = 10;
+assert.strictEqual(vm.runInContext('_dvrBarTime(video)', ctx), 691);
+
+// Nudging: within the recording part it moves the video; back across the boundary it plays part 1 near its end.
+vm.runInContext('_dvrNudge(video, 5)', ctx);
+assert.strictEqual(video.currentTime, 15);
+assert.strictEqual(calls.length, 0, 'no new source for a move within it');
+video.currentTime = 2;
+vm.runInContext('_dvrNudge(video, -5)', ctx); // bar 683 - 5 = 678 → stream 706: part 1 at 677.5 (half a second before its end)
+assert.deepStrictEqual(calls.pop(), [677.5, 6025]);
+
+// No history: the bar is the recording part alone, as before.
+set({ duration: 300, seekable: true, hlsUrl: 'https://media.test/v/7/dvr.m3u8', vodFile: null, earlier: [], activeAt: null, playingPart: null });
+assert.deepStrictEqual({ ...vm.runInContext('_dvrSpan()', ctx) }, { start: 0, total: 300 });
+vm.runInContext('_dvrSeekBar(120)', ctx);
+assert.deepStrictEqual(calls.pop(), [120, 'rec']);
+
+// History for a single part (no earlier ones): seconds into the recording, wherever it starts in the stream.
+set({ duration: 300, seekable: true, hlsUrl: 'https://media.test/v/7/dvr.m3u8', vodFile: null, earlier: [], activeAt: 40, playingPart: null });
+vm.runInContext('_dvrSeekBar(120)', ctx);
+assert.deepStrictEqual(calls.pop(), [120, 'rec']);
+video.currentTime = 50;
+assert.strictEqual(vm.runInContext('_dvrBarTime(video)', ctx), 50);
+
+console.log('player dvr parts: ok');
@@ -38,10 +38,10 @@ const els = {
@@ -38,10 +38,10 @@ const els = {
};
};
const ctx = { document: { getElementById: (id) => els[id] || null }, Date, Math, isNaN, isFinite, String };
const ctx = { document: { getElementById: (id) => els[id] || null }, Date, Math, isNaN, isFinite, String };
vm.createContext(ctx);
vm.createContext(ctx);
-vm.runInContext(`${extract('_dvrFmtTime')}\n${extract('updateDVRUI')}\nthis.run = updateDVRUI;`, ctx);
+vm.runInContext(`${extract('_dvrFmtTime')}\n${extract('_dvrSpan')}\n${extract('_dvrBarTime')}\n${extract('updateDVRUI')}\nthis.run = updateDVRUI;`, ctx);
// At the live edge: no time of its own (the buffer is 1:30:00 while the stream is 1:30:17 old).
// At the live edge: no time of its own (the buffer is 1:30:00 while the stream is 1:30:17 old).
-ctx.dvrState = { active: true, isLive: true, duration: 5400, streamStartTime: Date.now() - 5417000, seeking: false };
+ctx.dvrState = { active: true, isLive: true, duration: 5400, streamStartTime: Date.now() - 5417000, seeking: false, earlier: [], activeAt: null, playingPart: null };
vm.runInContext('var dvrState = this.dvrState;', ctx);
vm.runInContext('var dvrState = this.dvrState;', ctx);
ctx.run();
ctx.run();
const time = els['dvr-time-display'];
const time = els['dvr-time-display'];
@@ -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 _destroyDvrHls(', 'function showStreamEnded('].map((h) => extract(player, h)).join('\n');
+const code = ['let dvrState = {', 'async function initDVR(', 'async function pollDVR(', 'async function _pollDvrParts(', 'function destroyDVR(', 'function _destroyDvrHls(', 'function _dvrPartEnded(', '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();
@@ -37,7 +37,7 @@ const setTimeout = (fn, ms) => { timers.set(nextId, { fn, ms }); return nextId++
@@ -37,7 +37,7 @@ const setTimeout = (fn, ms) => { timers.set(nextId, { fn, ms }); return nextId++
const clearTimeout = (id) => { timers.delete(id); };
const clearTimeout = (id) => { timers.delete(id); };
const asked = [];
const asked = [];
const api = async (p) => { asked.push(p); const e = new Error('Not found'); e.status = 404; throw e; };
const api = async (p) => { asked.push(p); const e = new Error('Not found'); e.status = 404; throw e; };
-const el = () => ({ style: {} });
+const el = () => ({ style: {}, removeEventListener() {} });
const document = { getElementById: el };
const document = { getElementById: el };
const window = { dispatchEvent() {} };
const window = { dispatchEvent() {} };
class CustomEvent { constructor(type, init) { this.type = type; this.detail = init && init.detail; } }
class CustomEvent { constructor(type, init) { this.type = type; this.detail = init && init.detail; } }
new file mode 100644
new file mode 100644
@@ -0,0 +1,234 @@
@@ -0,0 +1,234 @@
+/**
+ * What happened during a live stream (server/streaming/stream-history.js, migration 0020_stream_history), and what uses it.
+ *
+ * Stream 2795 (Maticus, 2026-10-26): its broadcaster reconnected 11 minutes in, so it was recorded in two parts, VODs
+ * 6025 and 6026, and nothing remembered where part 2 began. The studio's clip panel sent stream times to part 1 ("Start
+ * time exceeds video duration"), the rewind could not reach part 1, and part 2's transcript pointed 11 minutes late.
+ *
+ * - Parts, shapes and reconnects are kept in stream time; a burst of shape changes is written at most every 2 s.
+ * - A stream-time clip (the studio's) goes to the part that recorded its end, in that part's time; across a reconnect it
+ * keeps the part's side; before any recording it is refused.
+ * - GET /api/streams/:id/history lists the parts (a finished one with its source), shapes and reconnects.
+ * - Transcript times (stream time) are moved into a part's own time; the stream transcript lists the parts.
+ * - The speech capture's segment offsets are stream time across a capture restart.
+ *
+ * node test/stream-history.test.js
+ */
+'use strict';
+const assert = require('assert');
+const http = require('http');
+
+process.env.NODE_ENV = 'test';
+const quiet = console.log;
+console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
+console.warn = () => {};
+
+let failures = 0;
+async function check(name, fn) {
+ try { await fn(); quiet(' ✓', name); }
+ catch (e) { failures++; quiet(' ✗', name, '\n ', e.stack || e.message); }
+}
+const wait = (ms) => new Promise((r) => setTimeout(r, ms));
+
+(async () => {
+ const db = require('../server/db/database');
+ await db.initDb();
+ const raw = db.getDb();
+
+ const auth = require('../server/auth/auth');
+ const signIn = async (req) => {
+ const id = Number(req.headers['x-test-user'] || 0);
+ const u = id ? await db.getUserById(id) : null;
+ if (u) { req.user = u; req.authSource = 'network'; }
+ return u;
+ };
+ auth.requireAuth = async (req, res, next) => ((await signIn(req)) ? next() : res.status(401).json({ error: 'Authentication required' }));
+ auth.optionalAuth = async (req, res, next) => { await signIn(req); next(); };
+
+ await raw.prepare(`INSERT INTO users (id, username, display_name, email, password_hash, role, created_at) OVERRIDING SYSTEM VALUE
+ VALUES (80, 'maticus', 'Maticus', 'm@x', 'x', 'streamer', '2025-01-01 00:00:00')`).run();
+ await db.ensureChannel(80);
+ const chan = await db.getChannelByUserId(80);
+ const sid = Number((await db.createStream({ user_id: 80, channel_id: chan.id, title: 'BIRD DRAMA', protocol: 'webrtc' })).lastInsertRowid);
+ const startMs = Math.floor(Date.now() / 1000) * 1000 - 1600 * 1000; // whole seconds: started_at keeps no fraction
+ const stamp = (ms) => new Date(ms).toISOString().slice(0, 19).replace('T', ' ');
+ await raw.prepare('UPDATE streams SET is_live = 1, started_at = ? WHERE id = ?').run(stamp(startMs), sid);
+ const stream = await db.getStreamById(sid);
+
+ const history = require('../server/streaming/stream-history');
+ const recorder = require('../server/streaming/recorder');
+ recorder.getActiveRecording = (s) => (Number(s) === sid ? { vodId: 6026, part: 2, startedAt: startMs + 707000, mediaStartedAt: startMs + 709000 } : null);
+
+ // Part 1 from 28 s to 706 s, part 2 from 709 s (the broadcaster re-produced its video), still recording.
+ await history.partStarted(stream, { vodId: 6025, part: 1, atMs: startMs + 28000 });
+ await history.partEnded(sid, 6025, startMs + 706000);
+ await history.partStarted(stream, { vodId: 6026, part: 2, why: 'the broadcaster re-produced video on a new transport', atMs: startMs + 709000 });
+
+ await check('parts are kept in stream time', async () => {
+ const h = await history.forStream(sid);
+ assert.deepStrictEqual(h.parts.map((p) => [p.part, p.vod_id, p.at, p.end]), [[1, 6025, 28, 706], [2, 6026, 709, null]]);
+ assert.strictEqual(h.parts[1].why, 'the broadcaster re-produced video on a new transport');
+ assert.strictEqual((await history.partAt(sid, 800)).vod_id, 6026);
+ assert.strictEqual((await history.partAt(sid, 100)).vod_id, 6025);
+ assert.strictEqual(await history.partAt(sid, 10), null, 'nothing before the first part');
+ const off = await history.vodOffsets([6025, 6026, 1]);
+ assert.deepStrictEqual([...off.entries()], [[6025, 28], [6026, 709]]);
+ assert.deepStrictEqual(await history.partForVod(6026), { stream_id: sid, vod_id: 6026, part: 2, at: 709, end: null });
+ assert.strictEqual(history.vodTime(700, 709), 0, 'never before the VOD starts');
+ });
+
+ await check('a burst of shape changes is written at most every 2 s, the last one kept; the same shape adds nothing', async () => {
+ assert.strictEqual(history.shape(sid, 720, 1280), true);
+ await wait(50);
+ assert.strictEqual(history.shape(sid, 1280, 720), true);
+ assert.strictEqual(history.shape(sid, 960, 540), true);
+ assert.strictEqual(history.shape(sid, 'big', 5), false, 'not a size');
+ await wait(50);
+ assert.strictEqual((await history.forStream(sid)).shapes.length, 1, 'only the first so far');
+ await wait(history.SHAPE_MIN_MS + 200);
+ history.shape(sid, 960, 540);
+ await wait(50);
+ const shapes = (await history.forStream(sid)).shapes;
+ assert.deepStrictEqual(shapes.map((s) => [s.width, s.height, s.vod_id]), [[720, 1280, 6026], [960, 540, 6026]]);
+ assert.ok(shapes.every((s) => s.at >= 1599 && s.at <= 1603), `in stream time (${shapes.map((s) => s.at)})`);
+ });
+
+ await check('a reconnect is kept with its gap', async () => {
+ history.broadcasterLeft(sid);
+ await wait(120);
+ assert.strictEqual(await history.broadcasterBack(sid), true);
+ assert.strictEqual(await history.broadcasterBack(sid), false, 'once');
+ const r = (await history.forStream(sid)).reconnects;
+ assert.strictEqual(r.length, 1);
+ assert.ok(r[0].gap_s >= 0.1 && r[0].gap_s < 2, `gap ${r[0].gap_s}`);
+ });
+
+ // ── Routes ──
+ const media = require('../server/media-client');
+ const VODS = {
+ 6025: { id: 6025, user_id: 80, stream_id: sid, title: 'BIRD DRAMA', visibility: 'public', is_public: 1, status: 'ready', hls_url: '/o/med_1/master.m3u8' },
+ 6026: { id: 6026, user_id: 80, stream_id: sid, title: 'BIRD DRAMA (Part 2)', visibility: 'public', is_public: 1, status: 'recording', is_recording: 1 },
+ };
+ media.getVod = async (id) => { const v = VODS[Number(id)]; if (!v) throw new media.MediaApiError('VOD not found', 404, { error: 'VOD not found' }); return { ...v }; };
+ const made = [];
+ media.createClip = async (body) => { made.push(body); return { id: 900 + made.length, status: 'processing' }; };
+ media.listVods = async () => ({ vods: [{ id: 6026 }] });
+
+ const express = require('express');
+ const app = express();
+ app.use(express.json());
+ app.use('/api/vods', require('../server/media-proxy/vods'));
+ app.use('/api/streams', require('../server/streaming/routes'));
+ app.use('/api/chat-ai', require('../server/ai/chat-ai-routes'));
+ const server = http.createServer(app).listen(0);
+ if (!server.listening) await new Promise((r) => server.once('listening', r));
+ let ip = 0;
+ const call = (method, p, user, body) => new Promise((resolve, reject) => {
+ const data = body ? JSON.stringify(body) : null;
+ const headers = { 'content-type': 'application/json', 'cf-connecting-ip': `10.1.0.${++ip}` };
+ if (user) headers['x-test-user'] = String(user);
+ const req = http.request({ port: server.address().port, path: p, method, headers }, (res) => {
+ let text = '';
+ res.on('data', (c) => { text += c; });
+ res.on('end', () => { let json = null; try { json = JSON.parse(text); } catch { /* */ } resolve({ status: res.statusCode, json, text }); });
+ });
+ req.on('error', reject);
+ if (data) req.write(data);
+ req.end();
+ });
+ const clipAt = (start, end) => call('POST', '/api/vods/clips', 80, { stream_id: sid, stream_time: true, start_time: start, end_time: end, title: '' });
+
+ await check('a studio clip in stream time goes to the part recording it, in that part\'s time', async () => {
+ const r = await clipAt(1570, 1600);
+ assert.strictEqual(r.status, 201, r.text);
+ assert.deepStrictEqual([r.json.vod_id, r.json.part], [6026, 2]);
+ const c = made[made.length - 1];
+ assert.deepStrictEqual([c.vod_id, c.start_s, c.end_s], [6026, 861, 891]);
+ });
+
+ await check('across the reconnect: the part that recorded the end, from its start', async () => {
+ await wait(2600); // the per-user live clip cooldown (2.5 s)
+ const r = await clipAt(690, 715);
+ assert.strictEqual(r.status, 201, r.text);
+ const c = made[made.length - 1];
+ assert.deepStrictEqual([c.vod_id, c.start_s, c.end_s], [6026, 0, 6]);
+ });
+
+ await check('ending in the reconnect\'s gap (after part 1, before part 2): part 1 up to its end, as long as asked', async () => {
+ await wait(2600);
+ const r = await clipAt(692, 707.5);
+ assert.strictEqual(r.status, 201, r.text);
+ const c = made[made.length - 1];
+ assert.deepStrictEqual([c.vod_id, c.start_s, c.end_s], [6025, 662.5, 678]);
+ });
+
+ await check('before anything was recorded: refused, nothing cut', async () => {
+ await wait(2600);
+ const n = made.length;
+ const r = await clipAt(0, 10);
+ assert.strictEqual(r.status, 409, r.text);
+ assert.strictEqual(r.json.no_recording, true);
+ assert.strictEqual(made.length, n);
+ });
+
+ await check('GET /api/streams/:id/history: parts with their sources, shapes, reconnects', async () => {
+ const r = await call('GET', `/api/streams/${sid}/history`);
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.json.is_live, true);
+ assert.ok(r.json.now_sec >= 1600 && r.json.now_sec < 1700, `now ${r.json.now_sec}`);
+ const [p1, p2] = r.json.parts;
+ assert.deepStrictEqual([p1.vod_id, p1.at, p1.end, p1.recording, p1.hls], [6025, 28, 706, false, true]);
+ assert.ok(/^https:\/\/.+\/o\/med_1\/master\.m3u8$/.test(p1.src), p1.src);
+ assert.deepStrictEqual([p2.vod_id, p2.recording, p2.src], [6026, true, null], 'the recording part plays from the live DVR, not a source here');
+ assert.strictEqual(r.json.shapes.length, 2);
+ assert.strictEqual(r.json.reconnects.length, 1);
+ VODS[6025].visibility = 'private'; VODS[6025].is_public = 0;
+ const anon = await call('GET', `/api/streams/${sid}/history`);
+ assert.deepStrictEqual([anon.json.parts[0].vod_id, anon.json.parts[0].src], [null, null], 'a private part is listed without its VOD');
+ VODS[6025].visibility = 'public'; VODS[6025].is_public = 1;
+ assert.strictEqual((await call('GET', '/api/streams/999999/history')).status, 404);
+ });
+
+ await check('transcripts: stream-time lines in a part\'s own time; the stream transcript lists the parts', async () => {
+ await db.addTimelineEvents([
+ { stream_id: sid, vod_id: 6025, kind: 'speech', start_sec: 40, end_sec: 42, text: 'what is up guys' },
+ { stream_id: sid, vod_id: 6026, kind: 'speech', start_sec: 720, end_sec: 723.5, text: 'test successful' },
+ ]);
+ const r = await call('GET', '/api/chat-ai/vod-transcripts?ids=6025,6026');
+ assert.strictEqual(r.status, 200, r.text);
+ assert.deepStrictEqual(r.json.transcripts['6025'].segments.map((s) => [s.start, s.end]), [[12, 14]]);
+ assert.deepStrictEqual(r.json.transcripts['6026'].segments.map((s) => [s.start, s.end]), [[11, 14.5]]);
+ const t = await call('GET', `/api/chat-ai/transcript/${sid}`);
+ assert.deepStrictEqual(t.json.parts.map((p) => [p.vod_id, p.at]), [[6025, 28], [6026, 709]]);
+ });
+
+ await check('speech capture offsets are stream time across a restart', async () => {
+ const fs = require('fs');
+ const path = require('path');
+ const audio = require('../server/ai/stream-audio');
+ const dir = audio.spoolDir(sid);
+ fs.mkdirSync(dir, { recursive: true });
+ for (const i of [0, 1, 2, 69, 70]) fs.writeFileSync(path.join(dir, `seg-${String(i).padStart(6, '0')}.wav`), '');
+ audio._noteRun(sid, 0, 5); // first capture 5 s into the stream
+ audio._noteRun(sid, 69, 709.4); // restarted after the reconnect, numbering on from 69
+ const segs = audio.pendingSegments(sid, { includeLast: true });
+ assert.deepStrictEqual(segs.map((s) => [s.index, s.offsetSec]), [[0, 5], [1, 15], [2, 25], [69, 709.4], [70, 719.4]]);
+ audio.purgeSpool(sid);
+ fs.mkdirSync(dir, { recursive: true });
+ fs.writeFileSync(path.join(dir, 'seg-000003.wav'), '');
+ assert.deepStrictEqual(audio.pendingSegments(sid, { includeLast: true }).map((s) => s.offsetSec), [3 * audio.SEGMENT_SEC], 'no run on record: the index alone, as before');
+ audio.purgeSpool(sid);
+ });
+
+ await check('the studio sends stream times and reports the shape it sends', async () => {
+ const js = require('fs').readFileSync(require('path').join(__dirname, '..', 'public', 'js', 'broadcast.js'), 'utf8');
+ const body = js.slice(js.indexOf('async function createBroadcastClip'), js.indexOf('function _offerClipName'));
+ assert.ok(/stream_time: true/.test(body) && !/vod_id:/.test(body), 'the clip panel sends stream time, no VOD id');
+ assert.ok(/type: 'shape', width:/.test(js), 'the studio reports its picture size');
+ });
+
+ server.close();
+ if (failures) { quiet(`stream history: ${failures} failed`); process.exit(1); }
+ quiet('stream history: ok');
+ process.exit(0);
+})().catch((err) => { quiet(err); process.exit(1); });