From 7d82f70ca31e5758a3a8d9a716ab87a2bc07f18a Mon Sep 17 00:00:00 2001 From: xRangerDE Date: Mon, 11 May 2026 00:10:44 +0200 Subject: [PATCH] feat: auto-resume live recording across streamlink crashes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a live recording gets cut short by a network blip or a streamlink subprocess that dies mid-stream, the recording would end with whatever it had captured up to that point. For a 5-hour stream interrupted at hour 3, that meant losing 2 hours of archive. downloadLiveStream now wraps the streamlink call in a resume loop. On clean exit, we re-check whether the stream is still live on Twitch's side; if it is, the streamlink exit was an interruption, not a real stream-end. The recording continues into a new file ("..._part2.mp4", "..._part3.mp4", ...) and both parts get attached to item.outputFiles so the user sees them as one logical recording. Guard rails to keep the loop from misbehaving: - Stream-still-live check before each resume. If the streamer actually ended their broadcast, we finalize. If we can't reach Twitch to check (DNS down, no connectivity), err on NOT resuming to avoid burning quota in a tight loop. - Skip resume on suspiciously short parts (<30s). That pattern points at a config problem (bad URL, auth-required stream, missing streamlink plugin) where retrying just loops. - Cap at 5 resume attempts per recording. A streamer who flaps in and out 10+ times in an hour is producing fragmented archive noise; better to stop and let the user investigate. - Skip resume on zero-byte parts. Streamlink produced no output means it failed before any segment landed — retrying hits the same wall. - Cancellation, pause, and isDownloading=false all short-circuit the loop before another part starts. Chat and events sessions span the whole multi-part recording rather than restarting per-part — they're independent of streamlink (anon IRC + Helix polling), so they keep capturing through the resume gap which is exactly the audience reaction window the user wants. A new "recording_resume" event type lands in .events.jsonl so the events viewer shows where each gap happened. The progress meta line was rewritten to accumulate bytes across parts. Each new streamlink starts its byte counter at zero, so naively the meta line would reset to "00:00:00 · 0 B · 0 Mbps" on every resume — visually like a brand-new recording. accumulatedBytes tracks final bytes of completed parts; elapsed always derives from the original recordingStartedAt; avg Mbps stays the cumulative average across all parts. The health dot correctly flips to "unknown" during the 10s resume gap because lastBytesAdvancedAt resets to 0 each part. Settings toggle (default on). When off, behavior is identical to 4.6.12 — single part, no resume. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/index.html | 4 + src/main.ts | 179 +++++++++++++++++++++++++++++--------- src/renderer-globals.d.ts | 1 + src/renderer-locale-de.ts | 4 +- src/renderer-locale-en.ts | 4 +- src/renderer-settings.ts | 3 + src/renderer-texts.ts | 1 + src/renderer.ts | 4 + 8 files changed, 157 insertions(+), 43 deletions(-) diff --git a/src/index.html b/src/index.html index 6f6d9e0..5e7bc85 100644 --- a/src/index.html +++ b/src/index.html @@ -562,6 +562,10 @@ Stream-Events bei Live-Aufnahmen mitloggen (.events.jsonl) +
diff --git a/src/main.ts b/src/main.ts index 7338e83..535405c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -230,6 +230,7 @@ interface Config { auto_vod_download_streamers: string[]; auto_vod_download_poll_minutes: number; auto_vod_max_age_hours: number; + auto_resume_live_recording: boolean; } interface RuntimeMetrics { @@ -363,7 +364,8 @@ const defaultConfig: Config = { log_stream_events: true, auto_vod_download_streamers: [], auto_vod_download_poll_minutes: 15, - auto_vod_max_age_hours: 24 + auto_vod_max_age_hours: 24, + auto_resume_live_recording: true }; const AUTO_RECORD_POLL_MIN_SECONDS = 30; @@ -490,7 +492,8 @@ function normalizeConfigTemplates(input: Config): Config { const n = Number(input.auto_vod_max_age_hours); if (!Number.isFinite(n)) return 24; return Math.max(1, Math.min(720, Math.floor(n))); - })() + })(), + auto_resume_live_recording: input.auto_resume_live_recording !== false }; } @@ -4027,7 +4030,7 @@ async function downloadLiveStream( const folder = path.join(config.download_path, safeStreamer, 'live'); fs.mkdirSync(folder, { recursive: true }); - const filename = ensureUniqueFilename( + const baseFilename = ensureUniqueFilename( path.join(folder, `${safeStreamer}_LIVE_${dateStr}_${timeStr}.mp4`), item.id ); @@ -4036,21 +4039,17 @@ async function downloadLiveStream( // recording. Sibling .chat.jsonl file. We start it BEFORE streamlink // so the very first chat lines after JOIN aren't dropped, and stop it // AFTER streamlink exits so trailing messages (e.g. "stream offline" - // user reactions) are still captured. + // user reactions) are still captured. Chat + events span the whole + // multi-part recording (chat is an independent IRC connection, events + // is an independent poller), so they stay alive across resume cycles. let chatSession: LiveChatSession | null = null; if (config.capture_live_chat) { - const chatPath = liveChatPathFor(filename); + const chatPath = liveChatPathFor(baseFilename); chatSession = startLiveChatCapture(item.streamer, chatPath); } - // Stream-events tracker — records title/game changes that happen - // while we're capturing. Cheap (one Helix/GQL hit per minute) and - // useful for long archives where the user later wants to seek. let eventsTracker: LiveEventTracker | null = null; if (config.log_stream_events) { - // Best-effort initial metadata snapshot. If the call fails the - // tracker still starts with empty title/game and the first - // successful poll shows them as a change. let initialTitle = ''; let initialGame = ''; try { @@ -4060,7 +4059,7 @@ async function downloadLiveStream( initialGame = info.gameName || ''; } } catch { /* ignore */ } - eventsTracker = startLiveEventsTracker(item.id, item.streamer, filename, initialTitle, initialGame); + eventsTracker = startLiveEventsTracker(item.id, item.streamer, baseFilename, initialTitle, initialGame); } if (config.discord_notify_live_start) { @@ -4070,18 +4069,24 @@ async function downloadLiveStream( color: 'live', fields: [ { name: 'URL', value: item.url, inline: false }, - { name: 'Output', value: path.basename(filename), inline: false } + { name: 'Output', value: path.basename(baseFilename), inline: false } ] }); } const recordingStartedAt = Date.now(); - // Health is derived from byte-progress liveness: each time the byte - // counter advances, we stamp lastBytesAdvancedAt; if we go BYTES_FRESH_MS - // without an advance we flip to 'stale'. Until the first byte arrives - // we report 'unknown' so the UI doesn't claim health prematurely on a - // streamlink that hasn't even hit a segment yet. const BYTES_FRESH_MS = 30_000; + const MIN_HEALTHY_PART_MS = 30_000; + const RESUME_WAIT_MS = 10_000; + const MAX_RESUME_ATTEMPTS = 5; + + // Total-recording byte tracking. Each resumed part starts streamlink + // fresh, so its byte counter resets to 0; we keep accumulatedBytes + // across parts so the meta line shows the TOTAL recorded size, not + // just the current part. Same for elapsed — recordingStartedAt is the + // overall start, not per-part. + let accumulatedBytes = 0; + let currentPartBytes = 0; let lastBytesValue = 0; let lastBytesAdvancedAt = 0; let lastEmittedProgress: DownloadProgress | null = null; @@ -4091,22 +4096,18 @@ async function downloadLiveStream( return (Date.now() - lastBytesAdvancedAt) <= BYTES_FRESH_MS ? 'ok' : 'stale'; }; - // Wrap onProgress so live recordings get a useful meta line. Without - // this the queue meta only shows raw bytes ("4.7 GB heruntergeladen") - // which doesn't tell the user how long the recording has been running - // or whether the bitrate is healthy. Substitutes: - // "{HH:MM:SS} · {size} · {avg Mbps}" - // and clears speed/eta so the renderer doesn't double-up on data. const wrappedProgress = (p: DownloadProgress): void => { const bytes = Number(p.downloadedBytes) || 0; if (bytes > lastBytesValue) { lastBytesValue = bytes; lastBytesAdvancedAt = Date.now(); } + currentPartBytes = bytes; + const totalBytes = accumulatedBytes + currentPartBytes; const elapsed = Math.max(1, Math.floor((Date.now() - recordingStartedAt) / 1000)); - const avgBitrateMbps = (bytes * 8) / elapsed / 1_000_000; + const avgBitrateMbps = (totalBytes * 8) / elapsed / 1_000_000; const parts: string[] = [formatDuration(elapsed)]; - if (bytes > 0) parts.push(formatBytes(bytes)); + if (totalBytes > 0) parts.push(formatBytes(totalBytes)); if (avgBitrateMbps > 0) parts.push(`${avgBitrateMbps.toFixed(1)} Mbps`); const next = { ...p, @@ -4122,7 +4123,7 @@ async function downloadLiveStream( // Health-tick: re-emit the most recent progress every 10s so the // renderer's health badge updates even when streamlink is silent. // Without this, a streamlink hung on a buffer-stall would keep showing - // 'ok' until the next real byte event — defeats the point of the badge. + // 'ok' until the next real byte event. const healthTick = setInterval(() => { if (!lastEmittedProgress) return; const updated: DownloadProgress = { ...lastEmittedProgress, recordingHealth: computeHealth() }; @@ -4131,12 +4132,107 @@ async function downloadLiveStream( }, 10_000); healthTick.unref?.(); - // No start/end times for live streams — streamlink records until the - // stream actually ends or we kill it. downloadVODPart already handles - // null start/end correctly. - let result: DownloadResult; + const outputs: string[] = []; + let partNumber = 1; + let resumeCount = 0; + let lastPartResult: DownloadResult = { success: false, error: tBackend('unknownDownloadError') }; + try { - result = await downloadVODPart(item.url, filename, null, null, wrappedProgress, item.id, 1, 1); + // Resume loop. Each iteration runs streamlink once. On clean exit, + // we re-check whether the stream is still live on Twitch's side; + // if yes, the exit was an interruption (network blip, segment + // discontinuity, etc.) — start a new part and append. If the + // stream really ended, break and finalize. + while (true) { + const partFilename = partNumber === 1 + ? baseFilename + : ensureUniqueFilename( + baseFilename.replace(/\.mp4$/i, `_part${partNumber}.mp4`), + item.id + ); + + // Reset per-part counters — streamlink is fresh, byte counter + // restarts at zero. lastBytesAdvancedAt stays at zero until + // the first segment arrives, which correctly flips the health + // dot to 'unknown' during the resume gap. + lastBytesValue = 0; + lastBytesAdvancedAt = 0; + currentPartBytes = 0; + + const partStartedAt = Date.now(); + appendDebugLog('recording-part-start', { itemId: item.id, partNumber, filename: path.basename(partFilename) }); + + lastPartResult = await downloadVODPart(item.url, partFilename, null, null, wrappedProgress, item.id, partNumber, partNumber); + + // Accumulate this part's final bytes into the running total so + // the next part's meta line continues from the correct figure. + let partFinalBytes = 0; + if (fs.existsSync(partFilename)) { + try { + partFinalBytes = fs.statSync(partFilename).size || 0; + } catch { /* ignore */ } + } + if (partFinalBytes > 0) { + outputs.push(partFilename); + accumulatedBytes += partFinalBytes; + } else { + // Streamlink produced no bytes — likely permission or auth + // failure. Skip resume because retrying will hit the same + // wall. The error from lastPartResult will surface upstream. + appendDebugLog('recording-part-zero-bytes', { itemId: item.id, partNumber }); + break; + } + + // Resume decision tree. + if (cancelledItemIds.has(item.id) || !isDownloading || pauseRequested) { + appendDebugLog('recording-resume-cancelled', { itemId: item.id, partNumber, reason: pauseRequested ? 'pause' : 'cancel' }); + break; + } + if (!config.auto_resume_live_recording) { + appendDebugLog('recording-resume-disabled', { itemId: item.id }); + break; + } + if (resumeCount >= MAX_RESUME_ATTEMPTS) { + appendDebugLog('recording-resume-max-attempts', { itemId: item.id, max: MAX_RESUME_ATTEMPTS }); + break; + } + // Don't resume on suspiciously short parts — that pattern points + // at a config issue (bad URL, auth-required stream, streamlink + // missing plugin) where retrying will just loop and burn API + // quota. + const partDurationMs = Date.now() - partStartedAt; + if (partDurationMs < MIN_HEALTHY_PART_MS) { + appendDebugLog('recording-resume-skip-short', { itemId: item.id, partNumber, durationMs: partDurationMs }); + break; + } + + // Only resume if Twitch still says the stream is live. If the + // streamer actually ended their broadcast, we accept the part + // we have and call the recording done. + let stillLive = false; + try { + const info = await getLiveStreamInfo(item.streamer); + stillLive = info?.isLive === true; + } catch { + // Unknown liveness — err on the side of NOT resuming to + // avoid infinite-loop on network-out conditions where we + // can't even reach Twitch to check. The user can always + // restart manually. + stillLive = false; + } + if (!stillLive) { + appendDebugLog('recording-finished-stream-offline', { itemId: item.id, parts: partNumber }); + break; + } + + appendDebugLog('recording-resume-attempt', { itemId: item.id, previousPart: partNumber, attempt: resumeCount + 1 }); + if (eventsTracker) { + appendEventLine(eventsTracker, { type: 'recording_resume', part: partNumber + 1 }); + } + resumeCount++; + partNumber++; + await sleep(RESUME_WAIT_MS); + } } finally { clearInterval(healthTick); } @@ -4146,37 +4242,38 @@ async function downloadLiveStream( } if (eventsTracker) { stopLiveEventsTracker(item.id, { - success: result.success, + success: outputs.length > 0, durationMs: Date.now() - recordingStartedAt, - error: result.error + error: outputs.length === 0 ? lastPartResult.error : undefined }); } if (config.discord_notify_live_end) { const durationSec = Math.max(0, Math.floor((Date.now() - recordingStartedAt) / 1000)); - const sizeBytes = fs.existsSync(filename) ? (fs.statSync(filename).size || 0) : 0; + const sizeBytes = accumulatedBytes; + const success = outputs.length > 0; void sendDiscordWebhook({ - title: result.success ? `Recording finished: ${item.streamer}` : `Recording failed: ${item.streamer}`, + title: success ? `Recording finished: ${item.streamer}` : `Recording failed: ${item.streamer}`, description: item.title || `${item.streamer}`, - color: result.success ? 'success' : 'info', + color: success ? 'success' : 'info', fields: [ { name: 'Duration', value: formatDuration(durationSec), inline: true }, { name: 'Size', value: formatBytes(sizeBytes), inline: true }, + { name: 'Parts', value: String(outputs.length || 1), inline: true }, { name: 'Chat captured', value: chatSession ? `${chatSession.messageCount} messages` : 'no', inline: true }, - { name: 'Output', value: path.basename(filename), inline: false } + { name: 'Output', value: path.basename(baseFilename), inline: false } ] }); } - if (!result.success) return result; - const outputs = [filename]; + if (outputs.length === 0) return lastPartResult; if (chatSession && fs.existsSync(chatSession.outputPath)) { outputs.push(chatSession.outputPath); } if (eventsTracker && fs.existsSync(eventsTracker.eventsPath)) { outputs.push(eventsTracker.eventsPath); } - return { ...result, outputFiles: outputs }; + return { success: true, outputFiles: outputs }; } async function downloadVOD( diff --git a/src/renderer-globals.d.ts b/src/renderer-globals.d.ts index 2a14c3c..8ef49fb 100644 --- a/src/renderer-globals.d.ts +++ b/src/renderer-globals.d.ts @@ -38,6 +38,7 @@ interface AppConfig { auto_vod_download_streamers?: string[]; auto_vod_download_poll_minutes?: number; auto_vod_max_age_hours?: number; + auto_resume_live_recording?: boolean; [key: string]: unknown; } diff --git a/src/renderer-locale-de.ts b/src/renderer-locale-de.ts index 522a292..39a1b6b 100644 --- a/src/renderer-locale-de.ts +++ b/src/renderer-locale-de.ts @@ -89,6 +89,7 @@ const UI_TEXT_DE = { discordNotifyLiveStartLabel: 'Bei Live-Aufnahme-Start benachrichtigen', discordNotifyLiveEndLabel: 'Bei Live-Aufnahme-Ende benachrichtigen', discordNotifyVodAutoQueuedLabel: 'Bei automatisch eingereihten VODs benachrichtigen', + autoResumeLiveRecordingLabel: 'Live-Aufnahme automatisch fortsetzen wenn Streamlink abbricht (max. 5 Versuche)', autoVodCardTitle: 'Auto-VOD-Download', autoVodCardIntro: 'Streamer mit aktiviertem VOD-Toggle werden in dem hier festgelegten Intervall auf neue Twitch-VODs geprueft. Neue VODs innerhalb des Alters-Fensters werden automatisch zur Download-Queue hinzugefuegt.', autoVodPollMinutesLabel: 'Poll-Intervall (Minuten)', @@ -263,7 +264,8 @@ const UI_TEXT_DE = { ok: 'Gesund - Bytes fliessen', stale: 'Stillstand - keine Bytes mehr (Netz-Hickser oder Stream endet)', unknown: 'Warte auf ersten Segment' - } + }, + eventRecordingResume: 'Aufnahme fortgesetzt - Part {part} startet' }, streamers: { recordLiveTitle: 'Diesen Streamer live aufnehmen (laeuft bis der Stream endet)', diff --git a/src/renderer-locale-en.ts b/src/renderer-locale-en.ts index dfaf82e..9a8d5aa 100644 --- a/src/renderer-locale-en.ts +++ b/src/renderer-locale-en.ts @@ -89,6 +89,7 @@ const UI_TEXT_EN = { discordNotifyLiveStartLabel: 'Notify on live recording start', discordNotifyLiveEndLabel: 'Notify on live recording end', discordNotifyVodCompleteLabel: 'Notify on completed VOD download', + autoResumeLiveRecordingLabel: 'Auto-resume live recording if streamlink crashes (max 5 retries)', discordNotifyVodAutoQueuedLabel: 'Notify when a VOD gets auto-queued', autoVodCardTitle: 'Auto-VOD download', autoVodCardIntro: 'Streamers with the VOD toggle on are scanned for new Twitch VODs at the interval set here. New VODs within the age window are added to the download queue automatically.', @@ -263,7 +264,8 @@ const UI_TEXT_EN = { ok: 'Healthy — bytes flowing', stale: 'Stalled — no bytes recently (network blip or stream ending)', unknown: 'Waiting for first segment' - } + }, + eventRecordingResume: 'Recording resumed — starting part {part}' }, streamers: { recordLiveTitle: 'Record this streamer live (captures until stream ends)', diff --git a/src/renderer-settings.ts b/src/renderer-settings.ts index 0dd8354..90155b8 100644 --- a/src/renderer-settings.ts +++ b/src/renderer-settings.ts @@ -556,6 +556,7 @@ function collectDownloadSettingsPayload(): Partial { download_chat_replay: byId('downloadChatReplayToggle').checked, capture_live_chat: byId('captureLiveChatToggle').checked, log_stream_events: byId('logStreamEventsToggle').checked, + auto_resume_live_recording: byId('autoResumeLiveRecordingToggle').checked, discord_webhook_url: byId('discordWebhookUrl').value.trim(), discord_notify_live_start: byId('discordNotifyLiveStartToggle').checked, discord_notify_live_end: byId('discordNotifyLiveEndToggle').checked, @@ -616,6 +617,7 @@ function getSettingsFingerprint(payload: Partial): string { effective.download_chat_replay === true, effective.capture_live_chat === true, effective.log_stream_events !== false, + effective.auto_resume_live_recording !== false, effective.discord_webhook_url ?? '', effective.discord_notify_live_start === true, effective.discord_notify_live_end === true, @@ -651,6 +653,7 @@ function syncSettingsFormFromConfig(): void { byId('downloadChatReplayToggle').checked = (config.download_chat_replay as boolean) === true; byId('captureLiveChatToggle').checked = (config.capture_live_chat as boolean) === true; byId('logStreamEventsToggle').checked = (config.log_stream_events as boolean) !== false; + byId('autoResumeLiveRecordingToggle').checked = (config.auto_resume_live_recording as boolean) !== false; byId('discordWebhookUrl').value = (config.discord_webhook_url as string) || ''; byId('discordNotifyLiveStartToggle').checked = (config.discord_notify_live_start as boolean) === true; byId('discordNotifyLiveEndToggle').checked = (config.discord_notify_live_end as boolean) === true; diff --git a/src/renderer-texts.ts b/src/renderer-texts.ts index 1772602..54d9dfc 100644 --- a/src/renderer-texts.ts +++ b/src/renderer-texts.ts @@ -197,6 +197,7 @@ function applyLanguageToStaticUI(): void { setText('discordNotifyLiveStartLabel', UI_TEXT.static.discordNotifyLiveStartLabel); setText('discordNotifyLiveEndLabel', UI_TEXT.static.discordNotifyLiveEndLabel); setText('discordNotifyVodCompleteLabel', UI_TEXT.static.discordNotifyVodCompleteLabel); + setText('autoResumeLiveRecordingLabel', UI_TEXT.static.autoResumeLiveRecordingLabel); setText('discordNotifyVodAutoQueuedLabel', UI_TEXT.static.discordNotifyVodAutoQueuedLabel); setText('autoVodCardTitle', UI_TEXT.static.autoVodCardTitle); setText('autoVodCardIntro', UI_TEXT.static.autoVodCardIntro); diff --git a/src/renderer.ts b/src/renderer.ts index 9340045..210faac 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -267,6 +267,7 @@ interface EventLogEntry { durationSeconds?: number; success?: boolean; error?: string; + part?: number; } async function openEventsViewer(filePath: string, title: string): Promise { @@ -331,6 +332,7 @@ function renderEventsList(events: EventLogEntry[]): void { const tagColors: Record = { recording_start: '#00c853', recording_end: '#9146ff', + recording_resume: '#2196f3', title_change: '#ffab00', game_change: '#ff4444' }; @@ -350,6 +352,8 @@ function renderEventsList(events: EventLogEntry[]): void { : '?'; const ok = ev.success ? '✓' : '✗'; detail.textContent = `${ok} ${UI_TEXT.queue.eventEndedAfter}: ${dur}${ev.error ? ` — ${ev.error}` : ''}`; + } else if (ev.type === 'recording_resume') { + detail.textContent = (UI_TEXT.queue.eventRecordingResume || 'Resume started — part {part}').replace('{part}', String(ev.part || '?')); } else if (ev.type === 'title_change') { detail.textContent = `${UI_TEXT.queue.eventTitleFromTo.replace('{from}', `"${ev.from || '-'}"`).replace('{to}', `"${ev.to || '-'}"`)}`; } else if (ev.type === 'game_change') {