Compare commits

..

No commits in common. "a43fc6689cd604e26a88cf9e471360ef08f0bbf5" and "7d4ee9eb40efb0688cf3daaed70f52029aecb1ee" have entirely different histories.

9 changed files with 8 additions and 131 deletions

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "twitch-vod-manager",
"version": "4.6.16",
"version": "4.6.15",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "twitch-vod-manager",
"version": "4.6.16",
"version": "4.6.15",
"license": "MIT",
"dependencies": {
"axios": "^1.6.0",

View File

@ -1,6 +1,6 @@
{
"name": "twitch-vod-manager",
"version": "4.6.16",
"version": "4.6.15",
"description": "Twitch VOD Manager - Download Twitch VODs easily",
"main": "dist/main.js",
"author": "xRangerDE",

View File

@ -639,14 +639,6 @@
<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,8 +231,6 @@ 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 {
@ -367,9 +365,7 @@ 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_merge_resumed_parts: false,
delete_parts_after_merge: false
auto_resume_live_recording: true
};
const AUTO_RECORD_POLL_MIN_SECONDS = 30;
@ -497,9 +493,7 @@ 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_merge_resumed_parts: input.auto_merge_resumed_parts === true,
delete_parts_after_merge: input.delete_parts_after_merge === true
auto_resume_live_recording: input.auto_resume_live_recording !== false
};
}
@ -2440,71 +2434,6 @@ 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,
@ -4719,43 +4648,13 @@ 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)) {
finalRecordings.push(chatSession.outputPath);
outputs.push(chatSession.outputPath);
}
if (eventsTracker && fs.existsSync(eventsTracker.eventsPath)) {
finalRecordings.push(eventsTracker.eventsPath);
outputs.push(eventsTracker.eventsPath);
}
return { success: true, outputFiles: finalRecordings };
return { success: true, outputFiles: outputs };
}
async function downloadVOD(

View File

@ -39,8 +39,6 @@ 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,8 +90,6 @@ 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,8 +90,6 @@ 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,8 +557,6 @@ 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,
@ -620,8 +618,6 @@ 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,
@ -658,8 +654,6 @@ 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,8 +227,6 @@ 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);