From 073c1863fe024e420c2ba11559b07d143fbcfc7c Mon Sep 17 00:00:00 2001 From: xRangerDE Date: Mon, 11 May 2026 00:29:54 +0200 Subject: [PATCH] feat: auto-merge resumed live-recording parts via ffmpeg concat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the loop on 4.6.13 auto-resume. A streamlink restart between two parts produces N separate .mp4 files for what is logically a single recording, which is fine for reliability but inconvenient for watching back. Opt-in flag flips that into a single stitched file post-recording. concatVideoFiles(inputs, output) writes a temp concat list and runs ffmpeg with the concat demuxer in copy mode — no re-encode, the parts get container-stitched in seconds even for multi-hour recordings. The merged output is named "{base}_merged.mp4" so it sits next to the parts without colliding. Two independent toggles: - auto_merge_resumed_parts (off by default) — runs the merge. - delete_parts_after_merge (off by default) — drops the originals ONLY if the merge produced a non-zero output file. Default-off means even if ffmpeg silently produced garbage, the parts stay around as the source of truth. If concat fails for any reason (corrupt segment header, codec mismatch from a stream that changed quality mid-recording, missing ffmpeg) the failure is non-fatal: we delete the half-written merged file and keep the parts. The user always has the original recordings. Settings card adds the two checkboxes nested under the existing auto-resume toggle so the relationship is visually obvious. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/index.html | 8 +++ src/main.ts | 111 ++++++++++++++++++++++++++++++++++++-- src/renderer-globals.d.ts | 2 + src/renderer-locale-de.ts | 2 + src/renderer-locale-en.ts | 2 + src/renderer-settings.ts | 6 +++ src/renderer-texts.ts | 2 + 7 files changed, 128 insertions(+), 5 deletions(-) diff --git a/src/index.html b/src/index.html index 6b5dd36..c5af5b1 100644 --- a/src/index.html +++ b/src/index.html @@ -639,6 +639,14 @@ Live-Aufnahme automatisch fortsetzen wenn Streamlink abbricht (max. 5 Versuche) + +
diff --git a/src/main.ts b/src/main.ts index 8c9e7a2..40fd21c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -231,6 +231,8 @@ interface Config { auto_vod_download_poll_minutes: number; auto_vod_max_age_hours: number; auto_resume_live_recording: boolean; + auto_merge_resumed_parts: boolean; + delete_parts_after_merge: boolean; } interface RuntimeMetrics { @@ -365,7 +367,9 @@ const defaultConfig: Config = { auto_vod_download_streamers: [], auto_vod_download_poll_minutes: 15, auto_vod_max_age_hours: 24, - auto_resume_live_recording: true + auto_resume_live_recording: true, + auto_merge_resumed_parts: false, + delete_parts_after_merge: false }; const AUTO_RECORD_POLL_MIN_SECONDS = 30; @@ -493,7 +497,9 @@ function normalizeConfigTemplates(input: Config): Config { 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 + auto_resume_live_recording: input.auto_resume_live_recording !== false, + auto_merge_resumed_parts: input.auto_merge_resumed_parts === true, + delete_parts_after_merge: input.delete_parts_after_merge === true }; } @@ -2434,6 +2440,71 @@ async function extractFrame(filePath: string, timeSeconds: number): Promise { + if (inputFiles.length < 2) return false; + const ffmpegReady = await ensureFfmpegInstalled(); + if (!ffmpegReady) return false; + + for (const f of inputFiles) { + if (!fs.existsSync(f)) { + appendDebugLog('concat-missing-part', { missing: f }); + return false; + } + } + + const listFile = path.join(path.dirname(outputFile), `.concat-${Date.now()}.txt`); + try { + // ffmpeg concat demuxer escaping: paths go in single quotes, embedded + // single quotes need '\''. Backslashes are fine on Windows. + const lines = inputFiles + .map((f) => `file '${f.replace(/'/g, "'\\''")}'`) + .join('\n'); + fs.writeFileSync(listFile, lines, 'utf8'); + } catch (e) { + appendDebugLog('concat-listfile-write-failed', String(e)); + return false; + } + + const ffmpeg = getFFmpegPath(); + const args = [ + '-f', 'concat', + '-safe', '0', + '-i', listFile, + '-c', 'copy', + '-y', + outputFile + ]; + + return await new Promise((resolve) => { + const proc = spawn(ffmpeg, args, { windowsHide: true }); + let stderrBuf = ''; + proc.stderr?.on('data', (chunk: Buffer) => { stderrBuf += chunk.toString(); }); + proc.on('close', (code) => { + try { fs.unlinkSync(listFile); } catch { /* ignore */ } + if (code === 0 && fs.existsSync(outputFile) && fs.statSync(outputFile).size > 0) { + appendDebugLog('concat-ok', { output: outputFile, parts: inputFiles.length }); + resolve(true); + } else { + appendDebugLog('concat-failed', { code, stderrTail: stderrBuf.slice(-400) }); + try { + if (fs.existsSync(outputFile)) fs.unlinkSync(outputFile); + } catch { /* ignore */ } + resolve(false); + } + }); + proc.on('error', (err) => { + try { fs.unlinkSync(listFile); } catch { /* ignore */ } + appendDebugLog('concat-spawn-error', String(err)); + resolve(false); + }); + }); +} + async function cutVideo( inputFile: string, outputFile: string, @@ -4648,13 +4719,43 @@ async function downloadLiveStream( } if (outputs.length === 0) return lastPartResult; + + // Auto-merge resumed parts. Only attempt when (a) the user opted in, + // (b) there's actually something to merge, and (c) the parts are all + // present on disk. Failure is non-fatal — we keep the parts so the + // user still has working files even if ffmpeg trips on a corrupted + // segment header. + let finalRecordings = outputs.slice(); + if (config.auto_merge_resumed_parts && outputs.length > 1) { + const mergedOutput = ensureUniqueFilename( + baseFilename.replace(/\.mp4$/i, '_merged.mp4'), + item.id + ); + const mergeOk = await concatVideoFiles(outputs, mergedOutput); + if (mergeOk) { + if (config.delete_parts_after_merge) { + for (const partPath of outputs) { + try { fs.unlinkSync(partPath); } catch (e) { + appendDebugLog('merge-part-delete-failed', { path: partPath, error: String(e) }); + } + } + finalRecordings = [mergedOutput]; + } else { + finalRecordings = [mergedOutput, ...outputs]; + } + appendDebugLog('merge-resumed-parts-ok', { merged: mergedOutput, partsKept: !config.delete_parts_after_merge }); + } else { + appendDebugLog('merge-resumed-parts-failed-keeping-parts'); + } + } + if (chatSession && fs.existsSync(chatSession.outputPath)) { - outputs.push(chatSession.outputPath); + finalRecordings.push(chatSession.outputPath); } if (eventsTracker && fs.existsSync(eventsTracker.eventsPath)) { - outputs.push(eventsTracker.eventsPath); + finalRecordings.push(eventsTracker.eventsPath); } - return { success: true, outputFiles: outputs }; + return { success: true, outputFiles: finalRecordings }; } async function downloadVOD( diff --git a/src/renderer-globals.d.ts b/src/renderer-globals.d.ts index 10ac2a2..fe7b92c 100644 --- a/src/renderer-globals.d.ts +++ b/src/renderer-globals.d.ts @@ -39,6 +39,8 @@ interface AppConfig { auto_vod_download_poll_minutes?: number; auto_vod_max_age_hours?: number; auto_resume_live_recording?: boolean; + auto_merge_resumed_parts?: boolean; + delete_parts_after_merge?: boolean; [key: string]: unknown; } diff --git a/src/renderer-locale-de.ts b/src/renderer-locale-de.ts index b3b24c2..3bc7e60 100644 --- a/src/renderer-locale-de.ts +++ b/src/renderer-locale-de.ts @@ -90,6 +90,8 @@ const UI_TEXT_DE = { discordNotifyLiveEndLabel: 'Bei Live-Aufnahme-Ende benachrichtigen', discordNotifyVodAutoQueuedLabel: 'Bei automatisch eingereihten VODs benachrichtigen', autoResumeLiveRecordingLabel: 'Live-Aufnahme automatisch fortsetzen wenn Streamlink abbricht (max. 5 Versuche)', + autoMergeResumedPartsLabel: 'Fortgesetzte Aufnahme-Parts automatisch zu einer Datei zusammenfuegen (ffmpeg concat, kein Re-Encode)', + deletePartsAfterMergeLabel: 'Einzelne Parts nach erfolgreichem Merge loeschen', 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)', diff --git a/src/renderer-locale-en.ts b/src/renderer-locale-en.ts index fba6d55..15cea9c 100644 --- a/src/renderer-locale-en.ts +++ b/src/renderer-locale-en.ts @@ -90,6 +90,8 @@ const UI_TEXT_EN = { discordNotifyLiveEndLabel: 'Notify on live recording end', discordNotifyVodCompleteLabel: 'Notify on completed VOD download', autoResumeLiveRecordingLabel: 'Auto-resume live recording if streamlink crashes (max 5 retries)', + autoMergeResumedPartsLabel: 'Auto-merge resumed-recording parts into one file (ffmpeg concat, no re-encode)', + deletePartsAfterMergeLabel: 'Delete individual parts after successful merge', 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.', diff --git a/src/renderer-settings.ts b/src/renderer-settings.ts index 90155b8..d19be50 100644 --- a/src/renderer-settings.ts +++ b/src/renderer-settings.ts @@ -557,6 +557,8 @@ function collectDownloadSettingsPayload(): Partial { capture_live_chat: byId('captureLiveChatToggle').checked, log_stream_events: byId('logStreamEventsToggle').checked, auto_resume_live_recording: byId('autoResumeLiveRecordingToggle').checked, + auto_merge_resumed_parts: byId('autoMergeResumedPartsToggle').checked, + delete_parts_after_merge: byId('deletePartsAfterMergeToggle').checked, discord_webhook_url: byId('discordWebhookUrl').value.trim(), discord_notify_live_start: byId('discordNotifyLiveStartToggle').checked, discord_notify_live_end: byId('discordNotifyLiveEndToggle').checked, @@ -618,6 +620,8 @@ function getSettingsFingerprint(payload: Partial): string { effective.capture_live_chat === true, effective.log_stream_events !== false, effective.auto_resume_live_recording !== false, + effective.auto_merge_resumed_parts === true, + effective.delete_parts_after_merge === true, effective.discord_webhook_url ?? '', effective.discord_notify_live_start === true, effective.discord_notify_live_end === true, @@ -654,6 +658,8 @@ function syncSettingsFormFromConfig(): void { 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('autoMergeResumedPartsToggle').checked = (config.auto_merge_resumed_parts as boolean) === true; + byId('deletePartsAfterMergeToggle').checked = (config.delete_parts_after_merge as boolean) === true; 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 039e91a..a7e48e6 100644 --- a/src/renderer-texts.ts +++ b/src/renderer-texts.ts @@ -227,6 +227,8 @@ function applyLanguageToStaticUI(): void { setText('discordNotifyLiveEndLabel', UI_TEXT.static.discordNotifyLiveEndLabel); setText('discordNotifyVodCompleteLabel', UI_TEXT.static.discordNotifyVodCompleteLabel); setText('autoResumeLiveRecordingLabel', UI_TEXT.static.autoResumeLiveRecordingLabel); + setText('autoMergeResumedPartsLabel', UI_TEXT.static.autoMergeResumedPartsLabel); + setText('deletePartsAfterMergeLabel', UI_TEXT.static.deletePartsAfterMergeLabel); setText('discordNotifyVodAutoQueuedLabel', UI_TEXT.static.discordNotifyVodAutoQueuedLabel); setText('autoVodCardTitle', UI_TEXT.static.autoVodCardTitle); setText('autoVodCardIntro', UI_TEXT.static.autoVodCardIntro);