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)

OP OpenVibers committed 18 days ago · ca5173f1ef · 19 files, +951 −79

Stream 2795 was recorded in two parts after its broadcaster reconnected (VODs 6025, 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 lines pointed 11 minutes late. stream_history (migration 0020)
keeps each part, shape change and reconnect; GET /api/streams/:id/history serves it. Studio clips go to the part that
recorded them; the player's bar spans every part; transcripts, Rundown links, Vibe voice clips and AI moments move
stream time into the part's own time; speech capture offsets are stream time across restarts.

Changes

ddiff --git a/docs/vods-and-clips.md b/docs/vods-and-clips.md
iindex f8d1e8c1..4b1d6fb9 100644
---- a/docs/vods-and-clips.md
++++ b/docs/vods-and-clips.md
@@@ -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.
++
## Clips
Viewers can create clips from live streams:
ddiff --git a/migrations/0020_stream_history.sql b/migrations/0020_stream_history.sql
nnew file mode 100644
iindex 00000000..2f843896
---- /dev/null
++++ b/migrations/0020_stream_history.sql
@@@ -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;
ddiff --git a/public/js/app-channel.js b/public/js/app-channel.js
iindex d5d34c33..0fb61d51 100644
---- a/public/js/app-channel.js
++++ b/public/js/app-channel.js
@@@ -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>
ddiff --git a/public/js/broadcast.js b/public/js/broadcast.js
iindex d47a4ebc..c1c0c844 100644
---- a/public/js/broadcast.js
++++ b/public/js/broadcast.js
@@@ -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 stream
clearInterval(_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 again
ss.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,
ddiff --git a/public/js/stream-player.js b/public/js/stream-player.js
iindex a5626848..71f515ac 100644
---- a/public/js/stream-player.js
++++ b/public/js/stream-player.js
@@@ -628,6 +628,12 @@ let dvrState = {
hlsUrl: null, // an MP4 recording's DVR playlist (Media /v/<id>/dvr.m3u8): seeking with no snapshot copy
dvrHls: null, // the hls.js instance playing it while rewound
dvrNative: false, // or the browser's own HLS (Safari) playing it
++ 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 20s
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.
*/
@@@ -2379,13 +2442,15 @@ function updateDVRUI() {
} else {
// Rewound: how far behind live, with the position in the buffer as its title
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 (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 seek
wrap.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 button
if (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;
}
ddiff --git a/server/ai/ai-moments-job.js b/server/ai/ai-moments-job.js
iindex 6c1a8e13..d74d3d35 100644
---- a/server/ai/ai-moments-job.js
++++ b/server/ai/ai-moments-job.js
@@@ -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;
ddiff --git a/server/ai/chat-ai-routes.js b/server/ai/chat-ai-routes.js
iindex 18178043..b9e706f1 100644
---- a/server/ai/chat-ai-routes.js
++++ b/server/ai/chat-ai-routes.js
@@@ -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' });
}
ddiff --git a/server/ai/stream-audio.js b/server/ai/stream-audio.js
iindex 2f9ea25c..e5a91d92 100644
---- a/server/ai/stream-audio.js
++++ b/server/ai/stream-audio.js
@@@ -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,
};
ddiff --git a/server/ai/vibe-voice.js b/server/ai/vibe-voice.js
iindex c7307d77..0b7cc72f 100644
---- a/server/ai/vibe-voice.js
++++ b/server/ai/vibe-voice.js
@@@ -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)`;
ddiff --git a/server/media-proxy/vods.js b/server/media-proxy/vods.js
iindex cbd4e6aa..2eddc83e 100644
---- a/server/media-proxy/vods.js
++++ b/server/media-proxy/vods.js
@@@ -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 footage
let 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;
ddiff --git a/server/rundown/read.js b/server/rundown/read.js
iindex ec42a4d0..a25af1c3 100644
---- a/server/rundown/read.js
++++ b/server/rundown/read.js
@@@ -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,
}));
}
ddiff --git a/server/streaming/broadcast-server.js b/server/streaming/broadcast-server.js
iindex b746370a..b54e7120 100644
---- a/server/streaming/broadcast-server.js
++++ b/server/streaming/broadcast-server.js
@@@ -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 broadcaster
if (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 cleanly
if (room._disconnectTimer) clearTimeout(room._disconnectTimer);
ddiff --git a/server/streaming/recorder.js b/server/streaming/recorder.js
iindex 7364ec0e..fc51692f 100644
---- a/server/streaming/recorder.js
++++ b/server/streaming/recorder.js
@@@ -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') {
ddiff --git a/server/streaming/routes.js b/server/streaming/routes.js
iindex d6d48838..6e98b78a 100644
---- a/server/streaming/routes.js
++++ b/server/streaming/routes.js
@@@ -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 {
ddiff --git a/server/streaming/stream-history.js b/server/streaming/stream-history.js
nnew file mode 100644
iindex 00000000..57c20066
---- /dev/null
++++ b/server/streaming/stream-history.js
@@@ -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,
++};
ddiff --git a/test/player-dvr-parts.test.js b/test/player-dvr-parts.test.js
nnew file mode 100644
iindex 00000000..187ee253
---- /dev/null
++++ b/test/player-dvr-parts.test.js
@@@ -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');
ddiff --git a/test/player-live-clock.test.js b/test/player-live-clock.test.js
iindex 8ba211e1..2cb22d84 100644
---- a/test/player-live-clock.test.js
++++ b/test/player-live-clock.test.js
@@@ -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'];
ddiff --git a/test/stream-ended-teardown.test.js b/test/stream-ended-teardown.test.js
iindex aa3f0a95..b2144eec 100644
---- a/test/stream-ended-teardown.test.js
++++ b/test/stream-ended-teardown.test.js
@@@ -28,7 +28,7 @@ function extract(src, head) {
}
const player = read('public/js/stream-player.js');
--const code = ['let dvrState = {', 'async function initDVR(', 'async function pollDVR(', 'function destroyDVR(', 'function _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; } }
ddiff --git a/test/stream-history.test.js b/test/stream-history.test.js
nnew file mode 100644
iindex 00000000..6aeeea35
---- /dev/null
++++ b/test/stream-history.test.js
@@@ -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); });