feat: auto-merge resumed live-recording parts via ffmpeg concat

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) <noreply@anthropic.com>
This commit is contained in:
xRangerDE
2026-05-11 00:29:54 +02:00
co-authored by Claude Opus 4.7
parent 7d4ee9eb40
commit 073c1863fe
7 changed files with 128 additions and 5 deletions
+8
View File
@@ -639,6 +639,14 @@
<input type="checkbox" id="autoResumeLiveRecordingToggle" checked>
<span id="autoResumeLiveRecordingLabel">Live-Aufnahme automatisch fortsetzen wenn Streamlink abbricht (max. 5 Versuche)</span>
</label>
<label style="display:flex; align-items:center; gap:8px; margin-top: 8px;">
<input type="checkbox" id="autoMergeResumedPartsToggle">
<span id="autoMergeResumedPartsLabel">Fortgesetzte Aufnahme-Parts automatisch zu einer Datei zusammenfuegen (ffmpeg concat)</span>
</label>
<label style="display:flex; align-items:center; gap:8px; margin-top: 8px; margin-left: 22px;">
<input type="checkbox" id="deletePartsAfterMergeToggle">
<span id="deletePartsAfterMergeLabel">Einzelne Parts nach erfolgreichem Merge loeschen</span>
</label>
</div>
<div class="form-group">
<label id="metadataCacheMinutesLabel">Metadata-Cache (Minuten)</label>
+106 -5
View File
@@ -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<stri
});
}
// Concatenates same-codec mp4 files into a single output via ffmpeg's
// concat demuxer. No re-encoding — purely a container stitch, which is
// what we want for resumed-recording parts (same streamlink, same codec
// settings, just split across files). Returns false on any error so the
// caller can keep the original parts.
async function concatVideoFiles(inputFiles: string[], outputFile: string): Promise<boolean> {
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<boolean>((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(
+2
View File
@@ -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;
}
+2
View File
@@ -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)',
+2
View File
@@ -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.',
+6
View File
@@ -557,6 +557,8 @@ function collectDownloadSettingsPayload(): Partial<AppConfig> {
capture_live_chat: byId<HTMLInputElement>('captureLiveChatToggle').checked,
log_stream_events: byId<HTMLInputElement>('logStreamEventsToggle').checked,
auto_resume_live_recording: byId<HTMLInputElement>('autoResumeLiveRecordingToggle').checked,
auto_merge_resumed_parts: byId<HTMLInputElement>('autoMergeResumedPartsToggle').checked,
delete_parts_after_merge: byId<HTMLInputElement>('deletePartsAfterMergeToggle').checked,
discord_webhook_url: byId<HTMLInputElement>('discordWebhookUrl').value.trim(),
discord_notify_live_start: byId<HTMLInputElement>('discordNotifyLiveStartToggle').checked,
discord_notify_live_end: byId<HTMLInputElement>('discordNotifyLiveEndToggle').checked,
@@ -618,6 +620,8 @@ function getSettingsFingerprint(payload: Partial<AppConfig>): 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<HTMLInputElement>('captureLiveChatToggle').checked = (config.capture_live_chat as boolean) === true;
byId<HTMLInputElement>('logStreamEventsToggle').checked = (config.log_stream_events as boolean) !== false;
byId<HTMLInputElement>('autoResumeLiveRecordingToggle').checked = (config.auto_resume_live_recording as boolean) !== false;
byId<HTMLInputElement>('autoMergeResumedPartsToggle').checked = (config.auto_merge_resumed_parts as boolean) === true;
byId<HTMLInputElement>('deletePartsAfterMergeToggle').checked = (config.delete_parts_after_merge as boolean) === true;
byId<HTMLInputElement>('discordWebhookUrl').value = (config.discord_webhook_url as string) || '';
byId<HTMLInputElement>('discordNotifyLiveStartToggle').checked = (config.discord_notify_live_start as boolean) === true;
byId<HTMLInputElement>('discordNotifyLiveEndToggle').checked = (config.discord_notify_live_end as boolean) === true;
+2
View File
@@ -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);