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:
xRangerDE 2026-05-10 12:33:18 +02:00
parent 7308a52a3e
commit 44c9173f10
4 changed files with 155 additions and 63 deletions

View File

@ -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">

View File

@ -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') });
});
});
});

View File

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

View File

@ -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 */