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);