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:
parent
7d4ee9eb40
commit
073c1863fe
@ -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>
|
||||
|
||||
111
src/main.ts
111
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<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
src/renderer-globals.d.ts
vendored
2
src/renderer-globals.d.ts
vendored
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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)',
|
||||
|
||||
@ -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.',
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user