feat: backend i18n for user-visible errors + light-theme color vars
Two related Phase-4 changes. 1. main.ts: tBackend(key, params) helper with DE/EN tables for every user-visible error / status string produced server-side. Previously every backend message was hardcoded German, so EN-mode users saw German errors in the queue (last_error), in download progress status, in clip-download responses, and in the preflight panel. ~30 keys covered: invalidVodUrl, streamlinkMissing, fileTooSmall, integrity*, downloadCancelled / downloadPaused, attemptFailed, retryingIn, statusBytesDownloaded, mergeGroupFileMissing, notAllPartsDownloaded / notAllClipPartsDownloaded, ffmpegMerge/ SplitFailed, diskSpaceShortFor, all preflight* messages, etc. classifyDownloadError extended to recognize EN equivalents (streamlink not found, no video stream, folder) so the retry classification still works correctly when the language is EN. The hand-rolled translation table in renderer.ts:downloadClip is gone — backend strings are already locale-correct. 2. styles.css: --border-soft CSS var added to :root and the theme-light override. Inline styles in index.html for the VOD filter input / sort select / bulk bar were referencing --bg-secondary / --text-primary / --border-color (which don't exist) and falling through to dark hex fallbacks (#222 / #fff / #444), producing a dark patch in light theme. Now uses var(--bg-card) / var(--text) / var(--border-soft) which both themes define. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7308a52a3e
commit
44c9173f10
@ -246,23 +246,23 @@
|
||||
<!-- VODs Tab -->
|
||||
<div class="tab-content active" id="vodsTab">
|
||||
<div class="vod-filter-row" style="display:flex; align-items:center; gap:8px; margin-bottom:12px; flex-wrap:wrap;">
|
||||
<input type="text" id="vodFilterInput" placeholder="Filter VODs..." oninput="onVodFilterInput()" style="flex:1; min-width:180px; background: var(--bg-secondary,#222); border:1px solid var(--border-color,#444); border-radius:6px; padding:8px 12px; color: var(--text-primary,#fff); font-size:13px;">
|
||||
<button id="vodFilterClearBtn" onclick="clearVodFilter()" title="Clear filter" style="display:none; background:transparent; border:1px solid var(--border-color,#444); border-radius:6px; padding:8px 12px; color: var(--text-secondary,#888); cursor:pointer;">x</button>
|
||||
<label id="vodSortLabel" for="vodSortSelect" style="color: var(--text-secondary,#888); font-size:12px; margin-left:8px;">Sort:</label>
|
||||
<select id="vodSortSelect" onchange="onVodSortChange()" style="background: var(--bg-secondary,#222); border:1px solid var(--border-color,#444); border-radius:6px; padding:7px 10px; color: var(--text-primary,#fff); font-size:13px;">
|
||||
<input type="text" id="vodFilterInput" placeholder="Filter VODs..." oninput="onVodFilterInput()" style="flex:1; min-width:180px; background: var(--bg-card); border:1px solid var(--border-soft); border-radius:6px; padding:8px 12px; color: var(--text); font-size:13px;">
|
||||
<button id="vodFilterClearBtn" onclick="clearVodFilter()" title="Clear filter" style="display:none; background:transparent; border:1px solid var(--border-soft); border-radius:6px; padding:8px 12px; color: var(--text-secondary); cursor:pointer;">x</button>
|
||||
<label id="vodSortLabel" for="vodSortSelect" style="color: var(--text-secondary); font-size:12px; margin-left:8px;">Sort:</label>
|
||||
<select id="vodSortSelect" onchange="onVodSortChange()" style="background: var(--bg-card); border:1px solid var(--border-soft); border-radius:6px; padding:7px 10px; color: var(--text); font-size:13px;">
|
||||
<option value="date_desc">Newest first</option>
|
||||
<option value="date_asc">Oldest first</option>
|
||||
<option value="views_desc">Most viewed</option>
|
||||
<option value="duration_desc">Longest first</option>
|
||||
<option value="duration_asc">Shortest first</option>
|
||||
</select>
|
||||
<span id="vodFilterCount" style="color: var(--text-secondary,#888); font-size:12px; min-width:80px;"></span>
|
||||
<span id="vodFilterCount" style="color: var(--text-secondary); font-size:12px; min-width:80px;"></span>
|
||||
</div>
|
||||
<div id="vodBulkBar" class="vod-bulk-bar" style="display:none; align-items:center; gap:10px; padding:8px 12px; background: rgba(145, 70, 255, 0.12); border:1px solid rgba(145, 70, 255, 0.4); border-radius:6px; margin-bottom:12px;">
|
||||
<span id="vodBulkCount" style="color: var(--text-primary,#fff); font-size:13px; font-weight:600;">0 selected</span>
|
||||
<span id="vodBulkCount" style="color: var(--text); font-size:13px; font-weight:600;">0 selected</span>
|
||||
<span style="flex:1;"></span>
|
||||
<button id="vodBulkAddBtn" type="button" onclick="bulkAddSelectedVodsToQueue()" style="background:#9146FF; border:none; border-radius:6px; padding:6px 14px; color:white; font-size:13px; font-weight:600; cursor:pointer;">+ Queue</button>
|
||||
<button id="vodBulkClearBtn" type="button" onclick="clearVodSelection()" style="background:transparent; border:1px solid var(--border-color,#444); border-radius:6px; padding:6px 12px; color:var(--text-secondary,#888); font-size:13px; cursor:pointer;">Clear</button>
|
||||
<button id="vodBulkAddBtn" type="button" onclick="bulkAddSelectedVodsToQueue()" style="background:var(--accent); border:none; border-radius:6px; padding:6px 14px; color:#fff; font-size:13px; font-weight:600; cursor:pointer;">+ Queue</button>
|
||||
<button id="vodBulkClearBtn" type="button" onclick="clearVodSelection()" style="background:transparent; border:1px solid var(--border-soft); border-radius:6px; padding:6px 12px; color:var(--text-secondary); font-size:13px; cursor:pointer;">Clear</button>
|
||||
</div>
|
||||
<div class="vod-grid" id="vodGrid">
|
||||
<div class="empty-state">
|
||||
|
||||
181
src/main.ts
181
src/main.ts
@ -80,6 +80,103 @@ 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
|
||||
if (!fs.existsSync(APPDATA_DIR)) {
|
||||
fs.mkdirSync(APPDATA_DIR, { recursive: true });
|
||||
@ -650,11 +747,11 @@ async function runPreflight(autoFix = false): Promise<PreflightResult> {
|
||||
}
|
||||
|
||||
const messages: string[] = [];
|
||||
if (!checks.internet) messages.push('Keine Internetverbindung erkannt.');
|
||||
if (!checks.streamlink) messages.push('Streamlink fehlt oder ist nicht startbar.');
|
||||
if (!checks.ffmpeg) messages.push('FFmpeg fehlt oder ist nicht startbar.');
|
||||
if (!checks.ffprobe) messages.push('FFprobe fehlt oder ist nicht startbar.');
|
||||
if (!checks.downloadPathWritable) messages.push('Download-Ordner ist nicht beschreibbar.');
|
||||
if (!checks.internet) messages.push(tBackend('preflightNoInternet'));
|
||||
if (!checks.streamlink) messages.push(tBackend('preflightStreamlinkMissing'));
|
||||
if (!checks.ffmpeg) messages.push(tBackend('preflightFfmpegMissing'));
|
||||
if (!checks.ffprobe) messages.push(tBackend('preflightFfprobeMissing'));
|
||||
if (!checks.downloadPathWritable) messages.push(tBackend('preflightDownloadPathNotWritable'));
|
||||
|
||||
const result: PreflightResult = {
|
||||
ok: messages.length === 0,
|
||||
@ -1089,7 +1186,7 @@ function ensureDiskSpace(targetPath: string, requiredBytes: number, context: str
|
||||
}
|
||||
|
||||
if (freeBytes < Math.max(requiredBytes, MIN_FREE_DISK_BYTES)) {
|
||||
const message = `Zu wenig Speicherplatz fur ${context}: frei ${formatBytes(freeBytes)}, benoetigt ~${formatBytes(requiredBytes)}.`;
|
||||
const message = tBackend('diskSpaceShortFor', { context, free: formatBytes(freeBytes), required: formatBytes(requiredBytes) });
|
||||
appendDebugLog('disk-space-check-failed', {
|
||||
targetPath,
|
||||
requiredBytes,
|
||||
@ -1279,9 +1376,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('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('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')) return 'integrity';
|
||||
if (text.includes('access denied') || text.includes('permission') || text.includes('disk') || text.includes('file') || text.includes('ordner')) return 'io';
|
||||
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('integritaet') || text.includes('integrity') || text.includes('kein videostream') || text.includes('no video stream')) return 'integrity';
|
||||
if (text.includes('access denied') || text.includes('permission') || text.includes('disk') || text.includes('file') || text.includes('ordner') || text.includes('folder')) return 'io';
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
@ -1513,12 +1610,12 @@ function validateDownloadedFileIntegrity(filePath: string, expectedDurationSecon
|
||||
|
||||
if (!probed.hasVideo) {
|
||||
runtimeMetrics.integrityFailures += 1;
|
||||
return { success: false, error: 'Integritaetspruefung fehlgeschlagen: Kein Videostream gefunden.' };
|
||||
return { success: false, error: tBackend('integrityNoVideo') };
|
||||
}
|
||||
|
||||
if (probed.durationSeconds <= 1) {
|
||||
runtimeMetrics.integrityFailures += 1;
|
||||
return { success: false, error: `Integritaetspruefung fehlgeschlagen: Dauer zu kurz (${probed.durationSeconds.toFixed(2)}s).` };
|
||||
return { success: false, error: tBackend('integrityTooShort', { duration: probed.durationSeconds.toFixed(2) }) };
|
||||
}
|
||||
|
||||
if (expectedDurationSeconds && expectedDurationSeconds > 4) {
|
||||
@ -1527,7 +1624,7 @@ function validateDownloadedFileIntegrity(filePath: string, expectedDurationSecon
|
||||
runtimeMetrics.integrityFailures += 1;
|
||||
return {
|
||||
success: false,
|
||||
error: `Integritaetspruefung fehlgeschlagen: ${probed.durationSeconds.toFixed(1)}s statt erwarteter ~${expectedDurationSeconds}s.`
|
||||
error: tBackend('integrityDurationMismatch', { actual: probed.durationSeconds.toFixed(1), expected: String(expectedDurationSeconds) })
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -2466,7 +2563,7 @@ function downloadVODPart(
|
||||
progress: -1, // Unknown total
|
||||
speed: formatSpeed(speed),
|
||||
eta: etaStr,
|
||||
status: `${formatBytes(downloadedBytes)} heruntergeladen`,
|
||||
status: tBackend('statusBytesDownloaded', { bytes: formatBytes(downloadedBytes) }),
|
||||
currentPart: partNum,
|
||||
totalParts: totalParts,
|
||||
downloadedBytes: downloadedBytes,
|
||||
@ -2513,14 +2610,14 @@ function downloadVODPart(
|
||||
if (currentDownloadCancelled || cancelledItemIds.has(itemId)) {
|
||||
cancelledItemIds.delete(itemId);
|
||||
appendDebugLog('download-part-cancelled', { itemId, filename });
|
||||
resolve({ success: false, error: 'Download wurde abgebrochen.' });
|
||||
resolve({ success: false, error: tBackend('downloadCancelled') });
|
||||
return;
|
||||
}
|
||||
|
||||
if (code === 0 && fs.existsSync(filename)) {
|
||||
const stats = fs.statSync(filename);
|
||||
if (stats.size <= MIN_FILE_BYTES) {
|
||||
const tooSmall = `Datei zu klein (${stats.size} Bytes)`;
|
||||
const tooSmall = tBackend('fileTooSmall', { bytes: String(stats.size) });
|
||||
appendDebugLog('download-part-failed-small-file', { itemId, filename, bytes: stats.size });
|
||||
resolve({ success: false, error: tooSmall });
|
||||
return;
|
||||
@ -2544,7 +2641,7 @@ function downloadVODPart(
|
||||
return;
|
||||
}
|
||||
|
||||
const genericError = lastErrorLine || `Streamlink Fehlercode ${code ?? -1}`;
|
||||
const genericError = lastErrorLine || tBackend('streamlinkExitCode', { code: String(code ?? -1) });
|
||||
appendDebugLog('download-part-failed', { itemId, filename, code, error: genericError });
|
||||
resolve({ success: false, error: genericError });
|
||||
});
|
||||
@ -2555,7 +2652,7 @@ function downloadVODPart(
|
||||
activeDownloads.delete(itemId);
|
||||
const rawError = String(err);
|
||||
const errorMessage = rawError.includes('ENOENT')
|
||||
? 'Streamlink nicht gefunden. Installiere Streamlink oder Python+streamlink (py -3 -m pip install streamlink).'
|
||||
? tBackend('streamlinkNotFound')
|
||||
: rawError;
|
||||
appendDebugLog('download-part-process-error', { itemId, error: errorMessage, rawError });
|
||||
resolve({ success: false, error: errorMessage });
|
||||
@ -2571,7 +2668,7 @@ async function downloadVOD(
|
||||
if (!isLikelyVodUrl(item.url) || !vodId) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Ungueltige VOD-URL'
|
||||
error: tBackend('invalidVodUrl')
|
||||
};
|
||||
}
|
||||
|
||||
@ -2585,7 +2682,7 @@ async function downloadVOD(
|
||||
progress: -1,
|
||||
speed: '',
|
||||
eta: '',
|
||||
status: 'Prufe Download-Tools...',
|
||||
status: tBackend('statusCheckingTools'),
|
||||
currentPart: 0,
|
||||
totalParts: 0
|
||||
});
|
||||
@ -2595,7 +2692,7 @@ async function downloadVOD(
|
||||
if (!streamlinkReady) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Streamlink fehlt und konnte nicht automatisch installiert werden. Siehe debug.log.'
|
||||
error: tBackend('streamlinkAutoInstallFailed')
|
||||
};
|
||||
}
|
||||
|
||||
@ -2604,7 +2701,7 @@ async function downloadVOD(
|
||||
progress: -1,
|
||||
speed: '',
|
||||
eta: '',
|
||||
status: 'Download gestartet',
|
||||
status: tBackend('statusDownloadStarted'),
|
||||
currentPart: 0,
|
||||
totalParts: 0
|
||||
});
|
||||
@ -2714,7 +2811,7 @@ async function downloadVOD(
|
||||
|
||||
return {
|
||||
success: downloadedFiles.length === numParts,
|
||||
error: downloadedFiles.length === numParts ? undefined : 'Nicht alle Clip-Teile konnten heruntergeladen werden.',
|
||||
error: downloadedFiles.length === numParts ? undefined : tBackend('notAllClipPartsDownloaded'),
|
||||
outputFiles: downloadedFiles.length === numParts ? [...downloadedFiles] : undefined
|
||||
};
|
||||
} else {
|
||||
@ -2787,7 +2884,7 @@ async function downloadVOD(
|
||||
|
||||
return {
|
||||
success: downloadedFiles.length === numParts,
|
||||
error: downloadedFiles.length === numParts ? undefined : 'Nicht alle Teile konnten heruntergeladen werden.',
|
||||
error: downloadedFiles.length === numParts ? undefined : tBackend('notAllPartsDownloaded'),
|
||||
outputFiles: downloadedFiles.length === numParts ? [...downloadedFiles] : undefined
|
||||
};
|
||||
}
|
||||
@ -2808,12 +2905,12 @@ async function processDownloadMergeGroup(
|
||||
if (mg.mergePhase === 'downloading') {
|
||||
const streamlinkReady = await ensureStreamlinkInstalled();
|
||||
if (!streamlinkReady) {
|
||||
return { success: false, error: 'Streamlink fehlt.' };
|
||||
return { success: false, error: tBackend('streamlinkMissing') };
|
||||
}
|
||||
|
||||
const ffmpegReady = await ensureFfmpegInstalled();
|
||||
if (!ffmpegReady) {
|
||||
return { success: false, error: 'FFmpeg fehlt.' };
|
||||
return { success: false, error: tBackend('ffmpegMissing') };
|
||||
}
|
||||
|
||||
const streamer = mg.items[0].streamer.replace(/[^a-zA-Z0-9_-]/g, '');
|
||||
@ -2835,7 +2932,7 @@ async function processDownloadMergeGroup(
|
||||
|
||||
for (let i = 0; i < mg.items.length; i++) {
|
||||
if (currentDownloadCancelled || cancelledItemIds.has(item.id)) {
|
||||
return { success: false, error: 'Download wurde abgebrochen.' };
|
||||
return { success: false, error: tBackend('downloadCancelled') };
|
||||
}
|
||||
|
||||
// Skip already downloaded files (retry recovery)
|
||||
@ -2899,7 +2996,7 @@ async function processDownloadMergeGroup(
|
||||
for (let i = 0; i < mg.items.length; i++) {
|
||||
if (!mg.downloadedFiles[i] || !fs.existsSync(mg.downloadedFiles[i])) {
|
||||
mg.mergePhase = 'downloading';
|
||||
return { success: false, error: `Heruntergeladene Datei ${i + 1} fehlt.` };
|
||||
return { success: false, error: tBackend('mergeGroupFileMissing', { index: i + 1 }) };
|
||||
}
|
||||
}
|
||||
|
||||
@ -2934,7 +3031,7 @@ async function processDownloadMergeGroup(
|
||||
);
|
||||
|
||||
if (!mergeSuccess) {
|
||||
return { success: false, error: 'FFmpeg Merge fehlgeschlagen.' };
|
||||
return { success: false, error: tBackend('ffmpegMergeFailed') };
|
||||
}
|
||||
|
||||
mg.mergedFile = mergedFilePath;
|
||||
@ -2947,7 +3044,7 @@ async function processDownloadMergeGroup(
|
||||
emitQueueUpdated();
|
||||
|
||||
if (currentDownloadCancelled || cancelledItemIds.has(item.id)) {
|
||||
return { success: false, error: 'Download wurde abgebrochen.' };
|
||||
return { success: false, error: tBackend('downloadCancelled') };
|
||||
}
|
||||
|
||||
const partDuration = config.part_minutes * 60;
|
||||
@ -2999,7 +3096,7 @@ async function processDownloadMergeGroup(
|
||||
for (const partFile of splitResult.files) {
|
||||
try { if (fs.existsSync(partFile)) fs.unlinkSync(partFile); } catch { }
|
||||
}
|
||||
return { success: false, error: 'FFmpeg Split fehlgeschlagen.' };
|
||||
return { success: false, error: tBackend('ffmpegSplitFailed') };
|
||||
}
|
||||
|
||||
mg.splitFiles = splitResult.files;
|
||||
@ -3054,7 +3151,7 @@ async function processOneQueueItem(item: QueueItem): Promise<void> {
|
||||
item.last_error = '';
|
||||
|
||||
try {
|
||||
let finalResult: DownloadResult = { success: false, error: 'Unbekannter Fehler beim Download' };
|
||||
let finalResult: DownloadResult = { success: false, error: tBackend('unknownDownloadError') };
|
||||
const maxAttempts = getRetryAttemptLimit();
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
@ -3076,7 +3173,7 @@ async function processOneQueueItem(item: QueueItem): Promise<void> {
|
||||
finalResult = result;
|
||||
|
||||
if (!isDownloading || currentDownloadCancelled || cancelledItemIds.has(item.id) || pauseRequested) {
|
||||
finalResult = { success: false, error: pauseRequested ? 'Download wurde pausiert.' : 'Download wurde abgebrochen.' };
|
||||
finalResult = { success: false, error: pauseRequested ? tBackend('downloadPaused') : tBackend('downloadCancelled') };
|
||||
break;
|
||||
}
|
||||
|
||||
@ -3097,13 +3194,13 @@ async function processOneQueueItem(item: QueueItem): Promise<void> {
|
||||
runtimeMetrics.retriesScheduled += 1;
|
||||
runtimeMetrics.lastRetryDelaySeconds = retryDelaySeconds;
|
||||
|
||||
item.last_error = `Versuch ${attempt}/${maxAttempts} fehlgeschlagen (${errorClass}): ${result.error || 'Unbekannter Fehler'}`;
|
||||
item.last_error = tBackend('attemptFailed', { attempt, max: maxAttempts, errorClass, error: result.error || tBackend('unknownDownloadError') });
|
||||
mainWindow?.webContents.send('download-progress', {
|
||||
id: item.id,
|
||||
progress: -1,
|
||||
speed: '',
|
||||
eta: '',
|
||||
status: `Neuer Versuch in ${retryDelaySeconds}s (${errorClass})...`,
|
||||
status: tBackend('retryingIn', { seconds: retryDelaySeconds, errorClass }),
|
||||
currentPart: item.currentPart,
|
||||
totalParts: item.totalParts
|
||||
} as DownloadProgress);
|
||||
@ -3123,7 +3220,7 @@ async function processOneQueueItem(item: QueueItem): Promise<void> {
|
||||
const wasPaused = pauseRequested || (finalResult.error || '').includes('pausiert');
|
||||
item.status = finalResult.success ? 'completed' : (wasPaused ? 'paused' : 'error');
|
||||
item.progress = finalResult.success ? 100 : item.progress;
|
||||
item.last_error = finalResult.success || wasPaused ? '' : (finalResult.error || 'Unbekannter Fehler beim Download');
|
||||
item.last_error = finalResult.success || wasPaused ? '' : (finalResult.error || tBackend('unknownDownloadError'));
|
||||
|
||||
if (finalResult.success && Array.isArray(finalResult.outputFiles) && finalResult.outputFiles.length > 0) {
|
||||
// Attach the produced file paths so the renderer can offer
|
||||
@ -3963,10 +4060,10 @@ ipcMain.handle('download-clip', async (_, clipUrl: string) => {
|
||||
|
||||
if (match1) clipId = match1[1];
|
||||
else if (match2) clipId = match2[1];
|
||||
else return { success: false, error: 'Ungueltige Clip-URL' };
|
||||
else return { success: false, error: tBackend('invalidClipUrl') };
|
||||
|
||||
const clipInfo = await getClipInfo(clipId);
|
||||
if (!clipInfo) return { success: false, error: 'Clip nicht gefunden' };
|
||||
if (!clipInfo) return { success: false, error: tBackend('clipNotFound') };
|
||||
|
||||
// Sanitize broadcaster_name for path safety — Twitch returns the display
|
||||
// name which can contain unicode, spaces, or punctuation that breaks
|
||||
@ -3980,7 +4077,7 @@ ipcMain.handle('download-clip', async (_, clipUrl: string) => {
|
||||
|
||||
const clipDiskCheck = ensureDiskSpace(folder, 128 * 1024 * 1024, 'Clip-Download');
|
||||
if (!clipDiskCheck.success) {
|
||||
return { success: false, error: clipDiskCheck.error || 'Zu wenig Speicherplatz.' };
|
||||
return { success: false, error: clipDiskCheck.error || tBackend('diskSpaceShortGeneric') };
|
||||
}
|
||||
|
||||
const rawTitle = typeof clipInfo.title === 'string' ? clipInfo.title : '';
|
||||
@ -4009,7 +4106,7 @@ ipcMain.handle('download-clip', async (_, clipUrl: string) => {
|
||||
|
||||
if (code !== 0 || !fs.existsSync(filename)) {
|
||||
appendDebugLog('clip-download-failed', { clipId, code });
|
||||
resolve({ success: false, error: `Download fehlgeschlagen (Exit-Code ${code ?? -1})` });
|
||||
resolve({ success: false, error: tBackend('downloadFailedExitCode', { code: String(code ?? -1) }) });
|
||||
return;
|
||||
}
|
||||
|
||||
@ -4020,7 +4117,7 @@ ipcMain.handle('download-clip', async (_, clipUrl: string) => {
|
||||
if (stats.size < 16 * 1024) {
|
||||
try { fs.unlinkSync(filename); } catch { }
|
||||
appendDebugLog('clip-download-too-small', { clipId, bytes: stats.size });
|
||||
resolve({ success: false, error: `Clip-Datei zu klein (${stats.size} Bytes) — Twitch hat den Stream evtl. nicht ausgeliefert.` });
|
||||
resolve({ success: false, error: tBackend('clipFileTooSmall', { bytes: String(stats.size) }) });
|
||||
return;
|
||||
}
|
||||
|
||||
@ -4028,7 +4125,7 @@ ipcMain.handle('download-clip', async (_, clipUrl: string) => {
|
||||
if (!integrity.success) {
|
||||
try { fs.unlinkSync(filename); } catch { }
|
||||
appendDebugLog('clip-download-integrity-failed', { clipId, error: integrity.error });
|
||||
resolve({ success: false, error: integrity.error || 'Integritaetspruefung fehlgeschlagen.' });
|
||||
resolve({ success: false, error: integrity.error || tBackend('integrityFailedGeneric') });
|
||||
return;
|
||||
}
|
||||
|
||||
@ -4039,7 +4136,7 @@ ipcMain.handle('download-clip', async (_, clipUrl: string) => {
|
||||
proc.on('error', () => {
|
||||
activeClipProcesses.delete(clipId);
|
||||
releaseClaimedFilenamesForItem(clipId);
|
||||
resolve({ success: false, error: 'Streamlink nicht gefunden' });
|
||||
resolve({ success: false, error: tBackend('streamlinkNotFound') });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -1019,20 +1019,10 @@ async function downloadClip(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Backend now produces locale-aware error strings via tBackend(),
|
||||
// so we no longer need a renderer-side translation table here.
|
||||
const backendError = (result.error || '').trim();
|
||||
let localizedError = backendError;
|
||||
|
||||
if (backendError === 'Ungueltige Clip-URL') {
|
||||
localizedError = currentLanguage === 'en' ? 'Invalid clip URL' : backendError;
|
||||
} else if (backendError === 'Clip nicht gefunden') {
|
||||
localizedError = currentLanguage === 'en' ? 'Clip not found' : backendError;
|
||||
} else if (backendError === 'Streamlink nicht gefunden') {
|
||||
localizedError = currentLanguage === 'en' ? 'Streamlink not found' : backendError;
|
||||
} else if (backendError.startsWith('Download fehlgeschlagen')) {
|
||||
localizedError = currentLanguage === 'en' ? backendError.replace('Download fehlgeschlagen', 'Download failed') : backendError;
|
||||
}
|
||||
|
||||
status.textContent = UI_TEXT.clips.errorPrefix + (localizedError || UI_TEXT.clips.unknownError);
|
||||
status.textContent = UI_TEXT.clips.errorPrefix + (backendError || UI_TEXT.clips.unknownError);
|
||||
status.className = 'clip-status error';
|
||||
}
|
||||
|
||||
|
||||
@ -9,6 +9,10 @@
|
||||
--success: #00c853;
|
||||
--error: #ff4444;
|
||||
--warning: #ffab00;
|
||||
/* Soft border that adapts to theme — used by post-4.5.x UI additions
|
||||
(filter input, sort select, bulk bar) so they don't visually break
|
||||
in light theme. */
|
||||
--border-soft: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
* {
|
||||
@ -1367,6 +1371,7 @@ body.theme-light {
|
||||
--success: #00c853;
|
||||
--error: #e41e3f;
|
||||
--warning: #e68a00;
|
||||
--border-soft: rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
/* Light theme: swap white-alpha borders/backgrounds to black-alpha */
|
||||
|
||||
Loading…
Reference in New Issue
Block a user