|
|
|
@ -80,103 +80,6 @@ function getMergeGroupPhaseText(phase: string): string {
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ==========================================
|
|
|
|
|
|
|
|
// BACKEND I18N
|
|
|
|
|
|
|
|
// ==========================================
|
|
|
|
|
|
|
|
// User-visible messages produced in main.ts. Keep keys stable — the renderer
|
|
|
|
|
|
|
|
// no longer translates these (renderer.ts:downloadClip used to translate a
|
|
|
|
|
|
|
|
// hardcoded set, which was brittle as the strings drifted). Internal
|
|
|
|
|
|
|
|
// debug log messages stay English-only since they're developer-facing.
|
|
|
|
|
|
|
|
const BACKEND_MESSAGES = {
|
|
|
|
|
|
|
|
de: {
|
|
|
|
|
|
|
|
invalidVodUrl: 'Ungueltige VOD-URL',
|
|
|
|
|
|
|
|
invalidClipUrl: 'Ungueltige Clip-URL',
|
|
|
|
|
|
|
|
clipNotFound: 'Clip nicht gefunden',
|
|
|
|
|
|
|
|
streamlinkAutoInstallFailed: 'Streamlink fehlt und konnte nicht automatisch installiert werden. Siehe debug.log.',
|
|
|
|
|
|
|
|
streamlinkMissing: 'Streamlink fehlt.',
|
|
|
|
|
|
|
|
streamlinkNotFound: 'Streamlink nicht gefunden. Installiere Streamlink oder Python+streamlink (py -3 -m pip install streamlink).',
|
|
|
|
|
|
|
|
streamlinkExitCode: 'Streamlink Fehlercode {code}',
|
|
|
|
|
|
|
|
ffmpegMissing: 'FFmpeg fehlt.',
|
|
|
|
|
|
|
|
ffmpegMergeFailed: 'FFmpeg Merge fehlgeschlagen.',
|
|
|
|
|
|
|
|
ffmpegSplitFailed: 'FFmpeg Split fehlgeschlagen.',
|
|
|
|
|
|
|
|
fileTooSmall: 'Datei zu klein ({bytes} Bytes)',
|
|
|
|
|
|
|
|
clipFileTooSmall: 'Clip-Datei zu klein ({bytes} Bytes) - Twitch hat den Stream evtl. nicht ausgeliefert.',
|
|
|
|
|
|
|
|
integrityNoVideo: 'Integritaetspruefung fehlgeschlagen: Kein Videostream gefunden.',
|
|
|
|
|
|
|
|
integrityTooShort: 'Integritaetspruefung fehlgeschlagen: Dauer zu kurz ({duration}s).',
|
|
|
|
|
|
|
|
integrityDurationMismatch: 'Integritaetspruefung fehlgeschlagen: {actual}s statt erwarteter ~{expected}s.',
|
|
|
|
|
|
|
|
integrityFailedGeneric: 'Integritaetspruefung fehlgeschlagen.',
|
|
|
|
|
|
|
|
downloadCancelled: 'Download wurde abgebrochen.',
|
|
|
|
|
|
|
|
downloadPaused: 'Download wurde pausiert.',
|
|
|
|
|
|
|
|
downloadFailedExitCode: 'Download fehlgeschlagen (Exit-Code {code})',
|
|
|
|
|
|
|
|
unknownDownloadError: 'Unbekannter Fehler beim Download',
|
|
|
|
|
|
|
|
notAllClipPartsDownloaded: 'Nicht alle Clip-Teile konnten heruntergeladen werden.',
|
|
|
|
|
|
|
|
notAllPartsDownloaded: 'Nicht alle Teile konnten heruntergeladen werden.',
|
|
|
|
|
|
|
|
mergeGroupFileMissing: 'Heruntergeladene Datei {index} fehlt.',
|
|
|
|
|
|
|
|
diskSpaceShortFor: 'Zu wenig Speicherplatz fur {context}: frei {free}, benoetigt ~{required}.',
|
|
|
|
|
|
|
|
diskSpaceShortGeneric: 'Zu wenig Speicherplatz.',
|
|
|
|
|
|
|
|
attemptFailed: 'Versuch {attempt}/{max} fehlgeschlagen ({errorClass}): {error}',
|
|
|
|
|
|
|
|
retryingIn: 'Neuer Versuch in {seconds}s ({errorClass})...',
|
|
|
|
|
|
|
|
statusCheckingTools: 'Prufe Download-Tools...',
|
|
|
|
|
|
|
|
statusDownloadStarted: 'Download gestartet',
|
|
|
|
|
|
|
|
statusBytesDownloaded: '{bytes} heruntergeladen',
|
|
|
|
|
|
|
|
preflightNoInternet: 'Keine Internetverbindung erkannt.',
|
|
|
|
|
|
|
|
preflightStreamlinkMissing: 'Streamlink fehlt oder ist nicht startbar.',
|
|
|
|
|
|
|
|
preflightFfmpegMissing: 'FFmpeg fehlt oder ist nicht startbar.',
|
|
|
|
|
|
|
|
preflightFfprobeMissing: 'FFprobe fehlt oder ist nicht startbar.',
|
|
|
|
|
|
|
|
preflightDownloadPathNotWritable: 'Download-Ordner ist nicht beschreibbar.'
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
en: {
|
|
|
|
|
|
|
|
invalidVodUrl: 'Invalid VOD URL',
|
|
|
|
|
|
|
|
invalidClipUrl: 'Invalid clip URL',
|
|
|
|
|
|
|
|
clipNotFound: 'Clip not found',
|
|
|
|
|
|
|
|
streamlinkAutoInstallFailed: 'Streamlink is missing and could not be auto-installed. See debug.log.',
|
|
|
|
|
|
|
|
streamlinkMissing: 'Streamlink is missing.',
|
|
|
|
|
|
|
|
streamlinkNotFound: 'Streamlink not found. Install streamlink or Python+streamlink (py -3 -m pip install streamlink).',
|
|
|
|
|
|
|
|
streamlinkExitCode: 'Streamlink exit code {code}',
|
|
|
|
|
|
|
|
ffmpegMissing: 'FFmpeg is missing.',
|
|
|
|
|
|
|
|
ffmpegMergeFailed: 'FFmpeg merge failed.',
|
|
|
|
|
|
|
|
ffmpegSplitFailed: 'FFmpeg split failed.',
|
|
|
|
|
|
|
|
fileTooSmall: 'File too small ({bytes} bytes)',
|
|
|
|
|
|
|
|
clipFileTooSmall: 'Clip file too small ({bytes} bytes) - Twitch may not have served the stream.',
|
|
|
|
|
|
|
|
integrityNoVideo: 'Integrity check failed: no video stream found.',
|
|
|
|
|
|
|
|
integrityTooShort: 'Integrity check failed: duration too short ({duration}s).',
|
|
|
|
|
|
|
|
integrityDurationMismatch: 'Integrity check failed: {actual}s instead of expected ~{expected}s.',
|
|
|
|
|
|
|
|
integrityFailedGeneric: 'Integrity check failed.',
|
|
|
|
|
|
|
|
downloadCancelled: 'Download was cancelled.',
|
|
|
|
|
|
|
|
downloadPaused: 'Download was paused.',
|
|
|
|
|
|
|
|
downloadFailedExitCode: 'Download failed (exit code {code})',
|
|
|
|
|
|
|
|
unknownDownloadError: 'Unknown download error',
|
|
|
|
|
|
|
|
notAllClipPartsDownloaded: 'Not all clip parts could be downloaded.',
|
|
|
|
|
|
|
|
notAllPartsDownloaded: 'Not all parts could be downloaded.',
|
|
|
|
|
|
|
|
mergeGroupFileMissing: 'Downloaded file {index} is missing.',
|
|
|
|
|
|
|
|
diskSpaceShortFor: 'Not enough disk space for {context}: free {free}, need ~{required}.',
|
|
|
|
|
|
|
|
diskSpaceShortGeneric: 'Not enough disk space.',
|
|
|
|
|
|
|
|
attemptFailed: 'Attempt {attempt}/{max} failed ({errorClass}): {error}',
|
|
|
|
|
|
|
|
retryingIn: 'Retrying in {seconds}s ({errorClass})...',
|
|
|
|
|
|
|
|
statusCheckingTools: 'Checking download tools...',
|
|
|
|
|
|
|
|
statusDownloadStarted: 'Download started',
|
|
|
|
|
|
|
|
statusBytesDownloaded: '{bytes} downloaded',
|
|
|
|
|
|
|
|
preflightNoInternet: 'No internet connection detected.',
|
|
|
|
|
|
|
|
preflightStreamlinkMissing: 'Streamlink is missing or not runnable.',
|
|
|
|
|
|
|
|
preflightFfmpegMissing: 'FFmpeg is missing or not runnable.',
|
|
|
|
|
|
|
|
preflightFfprobeMissing: 'FFprobe is missing or not runnable.',
|
|
|
|
|
|
|
|
preflightDownloadPathNotWritable: 'Download folder is not writable.'
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
} as const;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
type BackendMessageKey = keyof typeof BACKEND_MESSAGES.de;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function tBackend(key: BackendMessageKey, params?: Record<string, string | number>): string {
|
|
|
|
|
|
|
|
const lang: 'de' | 'en' = config.language === 'en' ? 'en' : 'de';
|
|
|
|
|
|
|
|
let template: string = BACKEND_MESSAGES[lang][key];
|
|
|
|
|
|
|
|
if (params) {
|
|
|
|
|
|
|
|
for (const [k, v] of Object.entries(params)) {
|
|
|
|
|
|
|
|
template = template.replace(`{${k}}`, String(v));
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
return template;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Ensure directories exist
|
|
|
|
// Ensure directories exist
|
|
|
|
if (!fs.existsSync(APPDATA_DIR)) {
|
|
|
|
if (!fs.existsSync(APPDATA_DIR)) {
|
|
|
|
fs.mkdirSync(APPDATA_DIR, { recursive: true });
|
|
|
|
fs.mkdirSync(APPDATA_DIR, { recursive: true });
|
|
|
|
@ -747,11 +650,11 @@ async function runPreflight(autoFix = false): Promise<PreflightResult> {
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const messages: string[] = [];
|
|
|
|
const messages: string[] = [];
|
|
|
|
if (!checks.internet) messages.push(tBackend('preflightNoInternet'));
|
|
|
|
if (!checks.internet) messages.push('Keine Internetverbindung erkannt.');
|
|
|
|
if (!checks.streamlink) messages.push(tBackend('preflightStreamlinkMissing'));
|
|
|
|
if (!checks.streamlink) messages.push('Streamlink fehlt oder ist nicht startbar.');
|
|
|
|
if (!checks.ffmpeg) messages.push(tBackend('preflightFfmpegMissing'));
|
|
|
|
if (!checks.ffmpeg) messages.push('FFmpeg fehlt oder ist nicht startbar.');
|
|
|
|
if (!checks.ffprobe) messages.push(tBackend('preflightFfprobeMissing'));
|
|
|
|
if (!checks.ffprobe) messages.push('FFprobe fehlt oder ist nicht startbar.');
|
|
|
|
if (!checks.downloadPathWritable) messages.push(tBackend('preflightDownloadPathNotWritable'));
|
|
|
|
if (!checks.downloadPathWritable) messages.push('Download-Ordner ist nicht beschreibbar.');
|
|
|
|
|
|
|
|
|
|
|
|
const result: PreflightResult = {
|
|
|
|
const result: PreflightResult = {
|
|
|
|
ok: messages.length === 0,
|
|
|
|
ok: messages.length === 0,
|
|
|
|
@ -1186,7 +1089,7 @@ function ensureDiskSpace(targetPath: string, requiredBytes: number, context: str
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (freeBytes < Math.max(requiredBytes, MIN_FREE_DISK_BYTES)) {
|
|
|
|
if (freeBytes < Math.max(requiredBytes, MIN_FREE_DISK_BYTES)) {
|
|
|
|
const message = tBackend('diskSpaceShortFor', { context, free: formatBytes(freeBytes), required: formatBytes(requiredBytes) });
|
|
|
|
const message = `Zu wenig Speicherplatz fur ${context}: frei ${formatBytes(freeBytes)}, benoetigt ~${formatBytes(requiredBytes)}.`;
|
|
|
|
appendDebugLog('disk-space-check-failed', {
|
|
|
|
appendDebugLog('disk-space-check-failed', {
|
|
|
|
targetPath,
|
|
|
|
targetPath,
|
|
|
|
requiredBytes,
|
|
|
|
requiredBytes,
|
|
|
|
@ -1376,9 +1279,9 @@ function classifyDownloadError(errorMessage: string): RetryErrorClass {
|
|
|
|
if (text.includes('429') || text.includes('rate limit') || text.includes('too many requests')) return 'rate_limit';
|
|
|
|
if (text.includes('429') || text.includes('rate limit') || text.includes('too many requests')) return 'rate_limit';
|
|
|
|
if (text.includes('401') || text.includes('403') || text.includes('unauthorized') || text.includes('forbidden')) return 'auth';
|
|
|
|
if (text.includes('401') || text.includes('403') || text.includes('unauthorized') || text.includes('forbidden')) return 'auth';
|
|
|
|
if (text.includes('timed out') || text.includes('timeout') || text.includes('network') || text.includes('connection') || text.includes('dns')) return 'network';
|
|
|
|
if (text.includes('timed out') || text.includes('timeout') || text.includes('network') || text.includes('connection') || text.includes('dns')) return 'network';
|
|
|
|
if (text.includes('streamlink nicht gefunden') || text.includes('streamlink not found') || text.includes('streamlink is missing') || text.includes('ffmpeg') || text.includes('ffprobe') || text.includes('enoent')) return 'tooling';
|
|
|
|
if (text.includes('streamlink nicht gefunden') || text.includes('ffmpeg') || text.includes('ffprobe') || text.includes('enoent')) return 'tooling';
|
|
|
|
if (text.includes('integritaet') || text.includes('integrity') || text.includes('kein videostream') || text.includes('no video stream')) return 'integrity';
|
|
|
|
if (text.includes('integritaet') || text.includes('integrity') || text.includes('kein videostream')) return 'integrity';
|
|
|
|
if (text.includes('access denied') || text.includes('permission') || text.includes('disk') || text.includes('file') || text.includes('ordner') || text.includes('folder')) return 'io';
|
|
|
|
if (text.includes('access denied') || text.includes('permission') || text.includes('disk') || text.includes('file') || text.includes('ordner')) return 'io';
|
|
|
|
|
|
|
|
|
|
|
|
return 'unknown';
|
|
|
|
return 'unknown';
|
|
|
|
}
|
|
|
|
}
|
|
|
|
@ -1610,12 +1513,12 @@ function validateDownloadedFileIntegrity(filePath: string, expectedDurationSecon
|
|
|
|
|
|
|
|
|
|
|
|
if (!probed.hasVideo) {
|
|
|
|
if (!probed.hasVideo) {
|
|
|
|
runtimeMetrics.integrityFailures += 1;
|
|
|
|
runtimeMetrics.integrityFailures += 1;
|
|
|
|
return { success: false, error: tBackend('integrityNoVideo') };
|
|
|
|
return { success: false, error: 'Integritaetspruefung fehlgeschlagen: Kein Videostream gefunden.' };
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (probed.durationSeconds <= 1) {
|
|
|
|
if (probed.durationSeconds <= 1) {
|
|
|
|
runtimeMetrics.integrityFailures += 1;
|
|
|
|
runtimeMetrics.integrityFailures += 1;
|
|
|
|
return { success: false, error: tBackend('integrityTooShort', { duration: probed.durationSeconds.toFixed(2) }) };
|
|
|
|
return { success: false, error: `Integritaetspruefung fehlgeschlagen: Dauer zu kurz (${probed.durationSeconds.toFixed(2)}s).` };
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (expectedDurationSeconds && expectedDurationSeconds > 4) {
|
|
|
|
if (expectedDurationSeconds && expectedDurationSeconds > 4) {
|
|
|
|
@ -1624,7 +1527,7 @@ function validateDownloadedFileIntegrity(filePath: string, expectedDurationSecon
|
|
|
|
runtimeMetrics.integrityFailures += 1;
|
|
|
|
runtimeMetrics.integrityFailures += 1;
|
|
|
|
return {
|
|
|
|
return {
|
|
|
|
success: false,
|
|
|
|
success: false,
|
|
|
|
error: tBackend('integrityDurationMismatch', { actual: probed.durationSeconds.toFixed(1), expected: String(expectedDurationSeconds) })
|
|
|
|
error: `Integritaetspruefung fehlgeschlagen: ${probed.durationSeconds.toFixed(1)}s statt erwarteter ~${expectedDurationSeconds}s.`
|
|
|
|
};
|
|
|
|
};
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
@ -2563,7 +2466,7 @@ function downloadVODPart(
|
|
|
|
progress: -1, // Unknown total
|
|
|
|
progress: -1, // Unknown total
|
|
|
|
speed: formatSpeed(speed),
|
|
|
|
speed: formatSpeed(speed),
|
|
|
|
eta: etaStr,
|
|
|
|
eta: etaStr,
|
|
|
|
status: tBackend('statusBytesDownloaded', { bytes: formatBytes(downloadedBytes) }),
|
|
|
|
status: `${formatBytes(downloadedBytes)} heruntergeladen`,
|
|
|
|
currentPart: partNum,
|
|
|
|
currentPart: partNum,
|
|
|
|
totalParts: totalParts,
|
|
|
|
totalParts: totalParts,
|
|
|
|
downloadedBytes: downloadedBytes,
|
|
|
|
downloadedBytes: downloadedBytes,
|
|
|
|
@ -2610,14 +2513,14 @@ function downloadVODPart(
|
|
|
|
if (currentDownloadCancelled || cancelledItemIds.has(itemId)) {
|
|
|
|
if (currentDownloadCancelled || cancelledItemIds.has(itemId)) {
|
|
|
|
cancelledItemIds.delete(itemId);
|
|
|
|
cancelledItemIds.delete(itemId);
|
|
|
|
appendDebugLog('download-part-cancelled', { itemId, filename });
|
|
|
|
appendDebugLog('download-part-cancelled', { itemId, filename });
|
|
|
|
resolve({ success: false, error: tBackend('downloadCancelled') });
|
|
|
|
resolve({ success: false, error: 'Download wurde abgebrochen.' });
|
|
|
|
return;
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (code === 0 && fs.existsSync(filename)) {
|
|
|
|
if (code === 0 && fs.existsSync(filename)) {
|
|
|
|
const stats = fs.statSync(filename);
|
|
|
|
const stats = fs.statSync(filename);
|
|
|
|
if (stats.size <= MIN_FILE_BYTES) {
|
|
|
|
if (stats.size <= MIN_FILE_BYTES) {
|
|
|
|
const tooSmall = tBackend('fileTooSmall', { bytes: String(stats.size) });
|
|
|
|
const tooSmall = `Datei zu klein (${stats.size} Bytes)`;
|
|
|
|
appendDebugLog('download-part-failed-small-file', { itemId, filename, bytes: stats.size });
|
|
|
|
appendDebugLog('download-part-failed-small-file', { itemId, filename, bytes: stats.size });
|
|
|
|
resolve({ success: false, error: tooSmall });
|
|
|
|
resolve({ success: false, error: tooSmall });
|
|
|
|
return;
|
|
|
|
return;
|
|
|
|
@ -2641,7 +2544,7 @@ function downloadVODPart(
|
|
|
|
return;
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const genericError = lastErrorLine || tBackend('streamlinkExitCode', { code: String(code ?? -1) });
|
|
|
|
const genericError = lastErrorLine || `Streamlink Fehlercode ${code ?? -1}`;
|
|
|
|
appendDebugLog('download-part-failed', { itemId, filename, code, error: genericError });
|
|
|
|
appendDebugLog('download-part-failed', { itemId, filename, code, error: genericError });
|
|
|
|
resolve({ success: false, error: genericError });
|
|
|
|
resolve({ success: false, error: genericError });
|
|
|
|
});
|
|
|
|
});
|
|
|
|
@ -2652,7 +2555,7 @@ function downloadVODPart(
|
|
|
|
activeDownloads.delete(itemId);
|
|
|
|
activeDownloads.delete(itemId);
|
|
|
|
const rawError = String(err);
|
|
|
|
const rawError = String(err);
|
|
|
|
const errorMessage = rawError.includes('ENOENT')
|
|
|
|
const errorMessage = rawError.includes('ENOENT')
|
|
|
|
? tBackend('streamlinkNotFound')
|
|
|
|
? 'Streamlink nicht gefunden. Installiere Streamlink oder Python+streamlink (py -3 -m pip install streamlink).'
|
|
|
|
: rawError;
|
|
|
|
: rawError;
|
|
|
|
appendDebugLog('download-part-process-error', { itemId, error: errorMessage, rawError });
|
|
|
|
appendDebugLog('download-part-process-error', { itemId, error: errorMessage, rawError });
|
|
|
|
resolve({ success: false, error: errorMessage });
|
|
|
|
resolve({ success: false, error: errorMessage });
|
|
|
|
@ -2668,7 +2571,7 @@ async function downloadVOD(
|
|
|
|
if (!isLikelyVodUrl(item.url) || !vodId) {
|
|
|
|
if (!isLikelyVodUrl(item.url) || !vodId) {
|
|
|
|
return {
|
|
|
|
return {
|
|
|
|
success: false,
|
|
|
|
success: false,
|
|
|
|
error: tBackend('invalidVodUrl')
|
|
|
|
error: 'Ungueltige VOD-URL'
|
|
|
|
};
|
|
|
|
};
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@ -2682,7 +2585,7 @@ async function downloadVOD(
|
|
|
|
progress: -1,
|
|
|
|
progress: -1,
|
|
|
|
speed: '',
|
|
|
|
speed: '',
|
|
|
|
eta: '',
|
|
|
|
eta: '',
|
|
|
|
status: tBackend('statusCheckingTools'),
|
|
|
|
status: 'Prufe Download-Tools...',
|
|
|
|
currentPart: 0,
|
|
|
|
currentPart: 0,
|
|
|
|
totalParts: 0
|
|
|
|
totalParts: 0
|
|
|
|
});
|
|
|
|
});
|
|
|
|
@ -2692,7 +2595,7 @@ async function downloadVOD(
|
|
|
|
if (!streamlinkReady) {
|
|
|
|
if (!streamlinkReady) {
|
|
|
|
return {
|
|
|
|
return {
|
|
|
|
success: false,
|
|
|
|
success: false,
|
|
|
|
error: tBackend('streamlinkAutoInstallFailed')
|
|
|
|
error: 'Streamlink fehlt und konnte nicht automatisch installiert werden. Siehe debug.log.'
|
|
|
|
};
|
|
|
|
};
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@ -2701,7 +2604,7 @@ async function downloadVOD(
|
|
|
|
progress: -1,
|
|
|
|
progress: -1,
|
|
|
|
speed: '',
|
|
|
|
speed: '',
|
|
|
|
eta: '',
|
|
|
|
eta: '',
|
|
|
|
status: tBackend('statusDownloadStarted'),
|
|
|
|
status: 'Download gestartet',
|
|
|
|
currentPart: 0,
|
|
|
|
currentPart: 0,
|
|
|
|
totalParts: 0
|
|
|
|
totalParts: 0
|
|
|
|
});
|
|
|
|
});
|
|
|
|
@ -2811,7 +2714,7 @@ async function downloadVOD(
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
return {
|
|
|
|
success: downloadedFiles.length === numParts,
|
|
|
|
success: downloadedFiles.length === numParts,
|
|
|
|
error: downloadedFiles.length === numParts ? undefined : tBackend('notAllClipPartsDownloaded'),
|
|
|
|
error: downloadedFiles.length === numParts ? undefined : 'Nicht alle Clip-Teile konnten heruntergeladen werden.',
|
|
|
|
outputFiles: downloadedFiles.length === numParts ? [...downloadedFiles] : undefined
|
|
|
|
outputFiles: downloadedFiles.length === numParts ? [...downloadedFiles] : undefined
|
|
|
|
};
|
|
|
|
};
|
|
|
|
} else {
|
|
|
|
} else {
|
|
|
|
@ -2884,7 +2787,7 @@ async function downloadVOD(
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
return {
|
|
|
|
success: downloadedFiles.length === numParts,
|
|
|
|
success: downloadedFiles.length === numParts,
|
|
|
|
error: downloadedFiles.length === numParts ? undefined : tBackend('notAllPartsDownloaded'),
|
|
|
|
error: downloadedFiles.length === numParts ? undefined : 'Nicht alle Teile konnten heruntergeladen werden.',
|
|
|
|
outputFiles: downloadedFiles.length === numParts ? [...downloadedFiles] : undefined
|
|
|
|
outputFiles: downloadedFiles.length === numParts ? [...downloadedFiles] : undefined
|
|
|
|
};
|
|
|
|
};
|
|
|
|
}
|
|
|
|
}
|
|
|
|
@ -2905,12 +2808,12 @@ async function processDownloadMergeGroup(
|
|
|
|
if (mg.mergePhase === 'downloading') {
|
|
|
|
if (mg.mergePhase === 'downloading') {
|
|
|
|
const streamlinkReady = await ensureStreamlinkInstalled();
|
|
|
|
const streamlinkReady = await ensureStreamlinkInstalled();
|
|
|
|
if (!streamlinkReady) {
|
|
|
|
if (!streamlinkReady) {
|
|
|
|
return { success: false, error: tBackend('streamlinkMissing') };
|
|
|
|
return { success: false, error: 'Streamlink fehlt.' };
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const ffmpegReady = await ensureFfmpegInstalled();
|
|
|
|
const ffmpegReady = await ensureFfmpegInstalled();
|
|
|
|
if (!ffmpegReady) {
|
|
|
|
if (!ffmpegReady) {
|
|
|
|
return { success: false, error: tBackend('ffmpegMissing') };
|
|
|
|
return { success: false, error: 'FFmpeg fehlt.' };
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const streamer = mg.items[0].streamer.replace(/[^a-zA-Z0-9_-]/g, '');
|
|
|
|
const streamer = mg.items[0].streamer.replace(/[^a-zA-Z0-9_-]/g, '');
|
|
|
|
@ -2932,7 +2835,7 @@ async function processDownloadMergeGroup(
|
|
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < mg.items.length; i++) {
|
|
|
|
for (let i = 0; i < mg.items.length; i++) {
|
|
|
|
if (currentDownloadCancelled || cancelledItemIds.has(item.id)) {
|
|
|
|
if (currentDownloadCancelled || cancelledItemIds.has(item.id)) {
|
|
|
|
return { success: false, error: tBackend('downloadCancelled') };
|
|
|
|
return { success: false, error: 'Download wurde abgebrochen.' };
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Skip already downloaded files (retry recovery)
|
|
|
|
// Skip already downloaded files (retry recovery)
|
|
|
|
@ -2996,7 +2899,7 @@ async function processDownloadMergeGroup(
|
|
|
|
for (let i = 0; i < mg.items.length; i++) {
|
|
|
|
for (let i = 0; i < mg.items.length; i++) {
|
|
|
|
if (!mg.downloadedFiles[i] || !fs.existsSync(mg.downloadedFiles[i])) {
|
|
|
|
if (!mg.downloadedFiles[i] || !fs.existsSync(mg.downloadedFiles[i])) {
|
|
|
|
mg.mergePhase = 'downloading';
|
|
|
|
mg.mergePhase = 'downloading';
|
|
|
|
return { success: false, error: tBackend('mergeGroupFileMissing', { index: i + 1 }) };
|
|
|
|
return { success: false, error: `Heruntergeladene Datei ${i + 1} fehlt.` };
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@ -3031,7 +2934,7 @@ async function processDownloadMergeGroup(
|
|
|
|
);
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
if (!mergeSuccess) {
|
|
|
|
if (!mergeSuccess) {
|
|
|
|
return { success: false, error: tBackend('ffmpegMergeFailed') };
|
|
|
|
return { success: false, error: 'FFmpeg Merge fehlgeschlagen.' };
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
mg.mergedFile = mergedFilePath;
|
|
|
|
mg.mergedFile = mergedFilePath;
|
|
|
|
@ -3044,7 +2947,7 @@ async function processDownloadMergeGroup(
|
|
|
|
emitQueueUpdated();
|
|
|
|
emitQueueUpdated();
|
|
|
|
|
|
|
|
|
|
|
|
if (currentDownloadCancelled || cancelledItemIds.has(item.id)) {
|
|
|
|
if (currentDownloadCancelled || cancelledItemIds.has(item.id)) {
|
|
|
|
return { success: false, error: tBackend('downloadCancelled') };
|
|
|
|
return { success: false, error: 'Download wurde abgebrochen.' };
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const partDuration = config.part_minutes * 60;
|
|
|
|
const partDuration = config.part_minutes * 60;
|
|
|
|
@ -3096,7 +2999,7 @@ async function processDownloadMergeGroup(
|
|
|
|
for (const partFile of splitResult.files) {
|
|
|
|
for (const partFile of splitResult.files) {
|
|
|
|
try { if (fs.existsSync(partFile)) fs.unlinkSync(partFile); } catch { }
|
|
|
|
try { if (fs.existsSync(partFile)) fs.unlinkSync(partFile); } catch { }
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return { success: false, error: tBackend('ffmpegSplitFailed') };
|
|
|
|
return { success: false, error: 'FFmpeg Split fehlgeschlagen.' };
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
mg.splitFiles = splitResult.files;
|
|
|
|
mg.splitFiles = splitResult.files;
|
|
|
|
@ -3151,7 +3054,7 @@ async function processOneQueueItem(item: QueueItem): Promise<void> {
|
|
|
|
item.last_error = '';
|
|
|
|
item.last_error = '';
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
try {
|
|
|
|
let finalResult: DownloadResult = { success: false, error: tBackend('unknownDownloadError') };
|
|
|
|
let finalResult: DownloadResult = { success: false, error: 'Unbekannter Fehler beim Download' };
|
|
|
|
const maxAttempts = getRetryAttemptLimit();
|
|
|
|
const maxAttempts = getRetryAttemptLimit();
|
|
|
|
|
|
|
|
|
|
|
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
|
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
|
|
@ -3173,7 +3076,7 @@ async function processOneQueueItem(item: QueueItem): Promise<void> {
|
|
|
|
finalResult = result;
|
|
|
|
finalResult = result;
|
|
|
|
|
|
|
|
|
|
|
|
if (!isDownloading || currentDownloadCancelled || cancelledItemIds.has(item.id) || pauseRequested) {
|
|
|
|
if (!isDownloading || currentDownloadCancelled || cancelledItemIds.has(item.id) || pauseRequested) {
|
|
|
|
finalResult = { success: false, error: pauseRequested ? tBackend('downloadPaused') : tBackend('downloadCancelled') };
|
|
|
|
finalResult = { success: false, error: pauseRequested ? 'Download wurde pausiert.' : 'Download wurde abgebrochen.' };
|
|
|
|
break;
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@ -3194,13 +3097,13 @@ async function processOneQueueItem(item: QueueItem): Promise<void> {
|
|
|
|
runtimeMetrics.retriesScheduled += 1;
|
|
|
|
runtimeMetrics.retriesScheduled += 1;
|
|
|
|
runtimeMetrics.lastRetryDelaySeconds = retryDelaySeconds;
|
|
|
|
runtimeMetrics.lastRetryDelaySeconds = retryDelaySeconds;
|
|
|
|
|
|
|
|
|
|
|
|
item.last_error = tBackend('attemptFailed', { attempt, max: maxAttempts, errorClass, error: result.error || tBackend('unknownDownloadError') });
|
|
|
|
item.last_error = `Versuch ${attempt}/${maxAttempts} fehlgeschlagen (${errorClass}): ${result.error || 'Unbekannter Fehler'}`;
|
|
|
|
mainWindow?.webContents.send('download-progress', {
|
|
|
|
mainWindow?.webContents.send('download-progress', {
|
|
|
|
id: item.id,
|
|
|
|
id: item.id,
|
|
|
|
progress: -1,
|
|
|
|
progress: -1,
|
|
|
|
speed: '',
|
|
|
|
speed: '',
|
|
|
|
eta: '',
|
|
|
|
eta: '',
|
|
|
|
status: tBackend('retryingIn', { seconds: retryDelaySeconds, errorClass }),
|
|
|
|
status: `Neuer Versuch in ${retryDelaySeconds}s (${errorClass})...`,
|
|
|
|
currentPart: item.currentPart,
|
|
|
|
currentPart: item.currentPart,
|
|
|
|
totalParts: item.totalParts
|
|
|
|
totalParts: item.totalParts
|
|
|
|
} as DownloadProgress);
|
|
|
|
} as DownloadProgress);
|
|
|
|
@ -3220,7 +3123,7 @@ async function processOneQueueItem(item: QueueItem): Promise<void> {
|
|
|
|
const wasPaused = pauseRequested || (finalResult.error || '').includes('pausiert');
|
|
|
|
const wasPaused = pauseRequested || (finalResult.error || '').includes('pausiert');
|
|
|
|
item.status = finalResult.success ? 'completed' : (wasPaused ? 'paused' : 'error');
|
|
|
|
item.status = finalResult.success ? 'completed' : (wasPaused ? 'paused' : 'error');
|
|
|
|
item.progress = finalResult.success ? 100 : item.progress;
|
|
|
|
item.progress = finalResult.success ? 100 : item.progress;
|
|
|
|
item.last_error = finalResult.success || wasPaused ? '' : (finalResult.error || tBackend('unknownDownloadError'));
|
|
|
|
item.last_error = finalResult.success || wasPaused ? '' : (finalResult.error || 'Unbekannter Fehler beim Download');
|
|
|
|
|
|
|
|
|
|
|
|
if (finalResult.success && Array.isArray(finalResult.outputFiles) && finalResult.outputFiles.length > 0) {
|
|
|
|
if (finalResult.success && Array.isArray(finalResult.outputFiles) && finalResult.outputFiles.length > 0) {
|
|
|
|
// Attach the produced file paths so the renderer can offer
|
|
|
|
// Attach the produced file paths so the renderer can offer
|
|
|
|
@ -4060,10 +3963,10 @@ ipcMain.handle('download-clip', async (_, clipUrl: string) => {
|
|
|
|
|
|
|
|
|
|
|
|
if (match1) clipId = match1[1];
|
|
|
|
if (match1) clipId = match1[1];
|
|
|
|
else if (match2) clipId = match2[1];
|
|
|
|
else if (match2) clipId = match2[1];
|
|
|
|
else return { success: false, error: tBackend('invalidClipUrl') };
|
|
|
|
else return { success: false, error: 'Ungueltige Clip-URL' };
|
|
|
|
|
|
|
|
|
|
|
|
const clipInfo = await getClipInfo(clipId);
|
|
|
|
const clipInfo = await getClipInfo(clipId);
|
|
|
|
if (!clipInfo) return { success: false, error: tBackend('clipNotFound') };
|
|
|
|
if (!clipInfo) return { success: false, error: 'Clip nicht gefunden' };
|
|
|
|
|
|
|
|
|
|
|
|
// Sanitize broadcaster_name for path safety — Twitch returns the display
|
|
|
|
// Sanitize broadcaster_name for path safety — Twitch returns the display
|
|
|
|
// name which can contain unicode, spaces, or punctuation that breaks
|
|
|
|
// name which can contain unicode, spaces, or punctuation that breaks
|
|
|
|
@ -4077,7 +3980,7 @@ ipcMain.handle('download-clip', async (_, clipUrl: string) => {
|
|
|
|
|
|
|
|
|
|
|
|
const clipDiskCheck = ensureDiskSpace(folder, 128 * 1024 * 1024, 'Clip-Download');
|
|
|
|
const clipDiskCheck = ensureDiskSpace(folder, 128 * 1024 * 1024, 'Clip-Download');
|
|
|
|
if (!clipDiskCheck.success) {
|
|
|
|
if (!clipDiskCheck.success) {
|
|
|
|
return { success: false, error: clipDiskCheck.error || tBackend('diskSpaceShortGeneric') };
|
|
|
|
return { success: false, error: clipDiskCheck.error || 'Zu wenig Speicherplatz.' };
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const rawTitle = typeof clipInfo.title === 'string' ? clipInfo.title : '';
|
|
|
|
const rawTitle = typeof clipInfo.title === 'string' ? clipInfo.title : '';
|
|
|
|
@ -4106,7 +4009,7 @@ ipcMain.handle('download-clip', async (_, clipUrl: string) => {
|
|
|
|
|
|
|
|
|
|
|
|
if (code !== 0 || !fs.existsSync(filename)) {
|
|
|
|
if (code !== 0 || !fs.existsSync(filename)) {
|
|
|
|
appendDebugLog('clip-download-failed', { clipId, code });
|
|
|
|
appendDebugLog('clip-download-failed', { clipId, code });
|
|
|
|
resolve({ success: false, error: tBackend('downloadFailedExitCode', { code: String(code ?? -1) }) });
|
|
|
|
resolve({ success: false, error: `Download fehlgeschlagen (Exit-Code ${code ?? -1})` });
|
|
|
|
return;
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@ -4117,7 +4020,7 @@ ipcMain.handle('download-clip', async (_, clipUrl: string) => {
|
|
|
|
if (stats.size < 16 * 1024) {
|
|
|
|
if (stats.size < 16 * 1024) {
|
|
|
|
try { fs.unlinkSync(filename); } catch { }
|
|
|
|
try { fs.unlinkSync(filename); } catch { }
|
|
|
|
appendDebugLog('clip-download-too-small', { clipId, bytes: stats.size });
|
|
|
|
appendDebugLog('clip-download-too-small', { clipId, bytes: stats.size });
|
|
|
|
resolve({ success: false, error: tBackend('clipFileTooSmall', { bytes: String(stats.size) }) });
|
|
|
|
resolve({ success: false, error: `Clip-Datei zu klein (${stats.size} Bytes) — Twitch hat den Stream evtl. nicht ausgeliefert.` });
|
|
|
|
return;
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@ -4125,7 +4028,7 @@ ipcMain.handle('download-clip', async (_, clipUrl: string) => {
|
|
|
|
if (!integrity.success) {
|
|
|
|
if (!integrity.success) {
|
|
|
|
try { fs.unlinkSync(filename); } catch { }
|
|
|
|
try { fs.unlinkSync(filename); } catch { }
|
|
|
|
appendDebugLog('clip-download-integrity-failed', { clipId, error: integrity.error });
|
|
|
|
appendDebugLog('clip-download-integrity-failed', { clipId, error: integrity.error });
|
|
|
|
resolve({ success: false, error: integrity.error || tBackend('integrityFailedGeneric') });
|
|
|
|
resolve({ success: false, error: integrity.error || 'Integritaetspruefung fehlgeschlagen.' });
|
|
|
|
return;
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@ -4136,7 +4039,7 @@ ipcMain.handle('download-clip', async (_, clipUrl: string) => {
|
|
|
|
proc.on('error', () => {
|
|
|
|
proc.on('error', () => {
|
|
|
|
activeClipProcesses.delete(clipId);
|
|
|
|
activeClipProcesses.delete(clipId);
|
|
|
|
releaseClaimedFilenamesForItem(clipId);
|
|
|
|
releaseClaimedFilenamesForItem(clipId);
|
|
|
|
resolve({ success: false, error: tBackend('streamlinkNotFound') });
|
|
|
|
resolve({ success: false, error: 'Streamlink nicht gefunden' });
|
|
|
|
});
|
|
|
|
});
|
|
|
|
});
|
|
|
|
});
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|