From 89b30d33b9b6ee9645db75e8068f4725659d3566 Mon Sep 17 00:00:00 2001 From: xRangerDE Date: Mon, 11 May 2026 21:46:12 +0200 Subject: [PATCH] refactor: extract BACKEND_MESSAGES + tBackend to src/main/domain/i18n-backend + 8 tests Pure variant takes language as parameter. main.ts retains 2-arg adapter that injects config.language so call-sites are unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main.ts | 100 ++------------------------ src/main/domain/i18n-backend.test.ts | 49 +++++++++++++ src/main/domain/i18n-backend.ts | 101 +++++++++++++++++++++++++++ 3 files changed, 155 insertions(+), 95 deletions(-) create mode 100644 src/main/domain/i18n-backend.test.ts create mode 100644 src/main/domain/i18n-backend.ts diff --git a/src/main.ts b/src/main.ts index 2034e8a..487ec9f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -8,6 +8,7 @@ import { autoUpdater } from 'electron-updater'; import { compareUpdateVersions, isNewerUpdateVersion, normalizeUpdateVersion } from './main/domain/update-version-utils'; import { writeFileAtomicSync } from './main/infra/fs-atomic'; import { parseDuration, formatDuration, formatDurationDashed } from './main/infra/duration'; +import { tBackend as tBackendCore, type BackendMessageKey } from './main/domain/i18n-backend'; import { CustomClip, MergeGroupItem, MergeGroup, QueueItem, DownloadProgress, DownloadResult } from './types'; import { setDebugLogFn, initToolDirs, @@ -86,102 +87,11 @@ 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', - statusFetchingChatReplay: 'Chat-Replay wird heruntergeladen...', - statusChatMessagesFetched: 'Chat-Nachrichten geladen: {count}', - 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', - statusFetchingChatReplay: 'Fetching chat replay...', - statusChatMessagesFetched: 'Chat messages fetched: {count}', - 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; - +// Backend-Messages sind in src/main/domain/i18n-backend.ts. +// tBackend bleibt als 2-Arg-Adapter hier — pure Variante uebernimmt language +// als 3. Parameter, der hier aus config.language injected wird. function tBackend(key: BackendMessageKey, params?: Record): 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; + return tBackendCore(key, params, config?.language ?? 'de'); } // Ensure directories exist diff --git a/src/main/domain/i18n-backend.test.ts b/src/main/domain/i18n-backend.test.ts new file mode 100644 index 0000000..8a2717a --- /dev/null +++ b/src/main/domain/i18n-backend.test.ts @@ -0,0 +1,49 @@ +import { test, expect, describe } from 'vitest'; +import { tBackend, BACKEND_MESSAGES, type BackendMessageKey } from './i18n-backend'; + +describe('tBackend', () => { + test('returns DE message for known key (default language)', () => { + expect(tBackend('invalidVodUrl', undefined, 'de')).toBe(BACKEND_MESSAGES.de.invalidVodUrl); + }); + + test('returns EN message when language=en', () => { + expect(tBackend('invalidVodUrl', undefined, 'en')).toBe(BACKEND_MESSAGES.en.invalidVodUrl); + }); + + test('unknown language falls back to de', () => { + expect(tBackend('invalidVodUrl', undefined, 'fr')).toBe(BACKEND_MESSAGES.de.invalidVodUrl); + expect(tBackend('invalidVodUrl', undefined, '')).toBe(BACKEND_MESSAGES.de.invalidVodUrl); + }); + + test('substitutes single {param}', () => { + const result = tBackend('streamlinkExitCode', { code: 42 }, 'en'); + expect(result).toBe('Streamlink exit code 42'); + }); + + test('substitutes multiple {params}', () => { + const result = tBackend('integrityDurationMismatch', { actual: 100, expected: 120 }, 'de'); + expect(result).toContain('100'); + expect(result).toContain('120'); + expect(result).not.toContain('{actual}'); + expect(result).not.toContain('{expected}'); + }); + + test('numeric params stringify', () => { + const result = tBackend('fileTooSmall', { bytes: 256 }, 'en'); + expect(result).toBe('File too small (256 bytes)'); + }); + + test('every DE key has an EN counterpart', () => { + const deKeys = Object.keys(BACKEND_MESSAGES.de) as BackendMessageKey[]; + const enKeys = Object.keys(BACKEND_MESSAGES.en); + for (const k of deKeys) { + expect(enKeys).toContain(k); + } + }); + + test('no template literal left after substitution for typical params', () => { + // attemptFailed has {attempt}, {max}, {errorClass}, {error} + const result = tBackend('attemptFailed', { attempt: 1, max: 3, errorClass: 'network', error: 'ETIMEDOUT' }, 'en'); + expect(result).toBe('Attempt 1/3 failed (network): ETIMEDOUT'); + }); +}); diff --git a/src/main/domain/i18n-backend.ts b/src/main/domain/i18n-backend.ts new file mode 100644 index 0000000..146e8d2 --- /dev/null +++ b/src/main/domain/i18n-backend.ts @@ -0,0 +1,101 @@ +// Backend-Messages (User-visible aus main.ts produziert). Pure: Sprache wird +// als Parameter uebergeben statt aus globalem config geholt. + +export 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', + statusFetchingChatReplay: 'Chat-Replay wird heruntergeladen...', + statusChatMessagesFetched: 'Chat-Nachrichten geladen: {count}', + 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', + statusFetchingChatReplay: 'Fetching chat replay...', + statusChatMessagesFetched: 'Chat messages fetched: {count}', + 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; + +export type BackendMessageKey = keyof typeof BACKEND_MESSAGES.de; +export type BackendLanguage = 'de' | 'en'; + +export function tBackend( + key: BackendMessageKey, + params: Record | undefined, + language: BackendLanguage | string +): string { + const lang: BackendLanguage = (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; +}