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 { compareUpdateVersions, isNewerUpdateVersion, normalizeUpdateVersion } from './main/domain/update-version-utils';
|
||||||
import { writeFileAtomicSync } from './main/infra/fs-atomic';
|
import { writeFileAtomicSync } from './main/infra/fs-atomic';
|
||||||
import { parseDuration, formatDuration, formatDurationDashed } from './main/infra/duration';
|
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 { CustomClip, MergeGroupItem, MergeGroup, QueueItem, DownloadProgress, DownloadResult } from './types';
|
||||||
import {
|
import {
|
||||||
setDebugLogFn, initToolDirs,
|
setDebugLogFn, initToolDirs,
|
||||||
@ -86,102 +87,11 @@ function getMergeGroupPhaseText(phase: string): string {
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
// BACKEND I18N
|
// BACKEND I18N
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// User-visible messages produced in main.ts. Keep keys stable — the renderer
|
// Backend-Messages sind in src/main/domain/i18n-backend.ts.
|
||||||
// no longer translates these (renderer.ts:downloadClip used to translate a
|
// tBackend bleibt als 2-Arg-Adapter hier — pure Variante uebernimmt language
|
||||||
// hardcoded set, which was brittle as the strings drifted). Internal
|
// als 3. Parameter, der hier aus config.language injected wird.
|
||||||
// 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;
|
|
||||||
|
|
||||||
function tBackend(key: BackendMessageKey, params?: Record<string, string | number>): string {
|
function tBackend(key: BackendMessageKey, params?: Record<string, string | number>): string {
|
||||||
const lang: 'de' | 'en' = config.language === 'en' ? 'en' : 'de';
|
return tBackendCore(key, params, config?.language ?? '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
|
||||||
|
|||||||
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