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
parent 7d4ee9eb40
commit 073c1863fe
7 changed files with 128 additions and 5 deletions

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>

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(

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

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

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.',

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;

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