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