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) <noreply@anthropic.com>
This commit is contained in:
parent
aee2914397
commit
89b30d33b9
100
src/main.ts
100
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, 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;
|
||||
return tBackendCore(key, params, config?.language ?? 'de');
|
||||
}
|
||||
|
||||
// Ensure directories exist
|
||||
|
||||
49
src/main/domain/i18n-backend.test.ts
Normal file
49
src/main/domain/i18n-backend.test.ts
Normal file
@ -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');
|
||||
});
|
||||
});
|
||||
101
src/main/domain/i18n-backend.ts
Normal file
101
src/main/domain/i18n-backend.ts
Normal file
@ -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<string, string | number> | 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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user