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') {