refactor: extract config normalizers to src/main/domain/config-normalize + 47 tests

8 pure helpers (normalizeAutoRecordPollSeconds, normalizeAutoRecordList,
normalizeStreamlinkQuality, normalizeFilenameTemplate,
normalizeMetadataCacheMinutes, normalizePerformanceMode, isPlainObject,
normalizeLogin) plus VALID_STREAMLINK_QUALITIES + PerformanceMode type.
getStreamlinkStreamArg and normalizeConfigTemplates stay in main.ts
because they read globals (config / DEFAULT_*).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
xRangerDE 2026-05-11 21:48:58 +02:00
parent 89b30d33b9
commit fb1392bc4b
3 changed files with 265 additions and 69 deletions

View File

@ -9,6 +9,18 @@ import { compareUpdateVersions, isNewerUpdateVersion, normalizeUpdateVersion } f
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 { tBackend as tBackendCore, type BackendMessageKey } from './main/domain/i18n-backend';
import {
normalizeLogin,
normalizeAutoRecordPollSeconds,
normalizeAutoRecordList,
normalizeStreamlinkQuality,
normalizeFilenameTemplate,
normalizeMetadataCacheMinutes,
normalizePerformanceMode,
isPlainObject,
VALID_STREAMLINK_QUALITIES,
type PerformanceMode,
} from './main/domain/config-normalize';
import { CustomClip, MergeGroupItem, MergeGroup, QueueItem, DownloadProgress, DownloadResult } from './types'; import { CustomClip, MergeGroupItem, MergeGroup, QueueItem, DownloadProgress, DownloadResult } from './types';
import { import {
setDebugLogFn, initToolDirs, setDebugLogFn, initToolDirs,
@ -68,7 +80,6 @@ const DEFAULT_RETRY_DELAY_SECONDS = 5;
const MIN_FILE_BYTES = 256 * 1024; const MIN_FILE_BYTES = 256 * 1024;
const TWITCH_WEB_CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; const TWITCH_WEB_CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko';
type PerformanceMode = 'stability' | 'balanced' | 'speed';
type RetryErrorClass = 'network' | 'rate_limit' | 'auth' | 'tooling' | 'integrity' | 'io' | 'validation' | 'unknown'; type RetryErrorClass = 'network' | 'rate_limit' | 'auth' | 'tooling' | 'integrity' | 'io' | 'validation' | 'unknown';
type UpdateCheckSource = 'startup' | 'interval' | 'manual'; type UpdateCheckSource = 'startup' | 'interval' | 'manual';
type UpdateDownloadSource = 'auto' | 'manual'; type UpdateDownloadSource = 'auto' | 'manual';
@ -284,72 +295,15 @@ const defaultConfig: Config = {
delete_parts_after_merge: false delete_parts_after_merge: false
}; };
const AUTO_RECORD_POLL_MIN_SECONDS = 30; // normalize* helpers + VALID_STREAMLINK_QUALITIES + isPlainObject + normalizeLogin
const AUTO_RECORD_POLL_MAX_SECONDS = 1800; // kommen aus ./main/domain/config-normalize. getStreamlinkStreamArg bleibt
function normalizeAutoRecordPollSeconds(value: unknown): number { // hier, da es config liest.
const parsed = Number(value);
if (!Number.isFinite(parsed)) return 90;
return Math.max(AUTO_RECORD_POLL_MIN_SECONDS, Math.min(AUTO_RECORD_POLL_MAX_SECONDS, Math.floor(parsed)));
}
function normalizeAutoRecordList(value: unknown): string[] {
if (!Array.isArray(value)) return [];
const seen = new Set<string>();
const out: string[] = [];
for (const v of value) {
if (typeof v !== 'string') continue;
const cleaned = normalizeLogin(v);
if (cleaned && !seen.has(cleaned)) {
seen.add(cleaned);
out.push(cleaned);
}
}
return out;
}
// Whitelist of streamlink stream specifiers we surface in Settings. The
// user's choice is passed to streamlink with "best" appended as a fallback
// (streamlink supports comma-separated stream lists, picks the first match)
// so a missing quality on the source stream still produces a download.
const VALID_STREAMLINK_QUALITIES = ['best', 'source', '1080p60', '720p60', '720p', '480p', 'audio_only'] as const;
function normalizeStreamlinkQuality(value: unknown): string {
if (typeof value === 'string' && (VALID_STREAMLINK_QUALITIES as readonly string[]).includes(value)) {
return value;
}
return 'best';
}
function getStreamlinkStreamArg(): string { function getStreamlinkStreamArg(): string {
const choice = normalizeStreamlinkQuality(config.streamlink_quality); const choice = normalizeStreamlinkQuality(config.streamlink_quality);
if (choice === 'best') return 'best'; if (choice === 'best') return 'best';
// Fall back to "best" if the chosen rendition isn't offered (e.g. an
// older stream archived before that resolution existed).
return `${choice},best`; return `${choice},best`;
} }
function normalizeFilenameTemplate(template: string | undefined, fallback: string): string {
const value = (template || '').trim();
return value || fallback;
}
function normalizeMetadataCacheMinutes(value: unknown): number {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return DEFAULT_METADATA_CACHE_MINUTES;
}
return Math.max(1, Math.min(120, Math.floor(parsed)));
}
function normalizePerformanceMode(mode: unknown): PerformanceMode {
if (mode === 'stability' || mode === 'balanced' || mode === 'speed') {
return mode;
}
return DEFAULT_PERFORMANCE_MODE;
}
function normalizeConfigTemplates(input: Config): Config { function normalizeConfigTemplates(input: Config): Config {
// downloaded_vod_ids is bounded so a long-running app doesn't accumulate // downloaded_vod_ids is bounded so a long-running app doesn't accumulate
// an unbounded list across years of downloads. Latest entries kept. // an unbounded list across years of downloads. Latest entries kept.
@ -430,10 +384,6 @@ function recordDownloadedVodId(vodId: string): void {
saveConfig(config); saveConfig(config);
} }
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function loadConfig(): Config { function loadConfig(): Config {
try { try {
if (fs.existsSync(CONFIG_FILE)) { if (fs.existsSync(CONFIG_FILE)) {
@ -1763,10 +1713,6 @@ async function ensureTwitchAuth(forceRefresh = false): Promise<boolean> {
return await requestTwitchLogin(); return await requestTwitchLogin();
} }
function normalizeLogin(input: string): string {
return input.trim().replace(/^@+/, '').toLowerCase();
}
function formatTwitchDurationFromSeconds(totalSeconds: number): string { function formatTwitchDurationFromSeconds(totalSeconds: number): string {
const seconds = Math.max(0, Math.floor(totalSeconds)); const seconds = Math.max(0, Math.floor(totalSeconds));
const h = Math.floor(seconds / 3600); const h = Math.floor(seconds / 3600);

View File

@ -0,0 +1,183 @@
import { test, expect, describe } from 'vitest';
import {
normalizeLogin,
normalizeAutoRecordPollSeconds,
normalizeAutoRecordList,
normalizeStreamlinkQuality,
normalizeFilenameTemplate,
normalizeMetadataCacheMinutes,
normalizePerformanceMode,
isPlainObject,
VALID_STREAMLINK_QUALITIES,
} from './config-normalize';
describe('normalizeLogin', () => {
test('trim + lowercase', () => {
expect(normalizeLogin(' Foo ')).toBe('foo');
});
test('strips single leading @', () => {
expect(normalizeLogin('@foo')).toBe('foo');
});
test('strips multiple leading @', () => {
expect(normalizeLogin('@@@foo')).toBe('foo');
});
test('preserves @ in middle of string', () => {
expect(normalizeLogin('foo@bar')).toBe('foo@bar');
});
test('empty stays empty', () => {
expect(normalizeLogin('')).toBe('');
});
});
describe('normalizeAutoRecordPollSeconds', () => {
test('default 90 for non-numeric (NaN producer)', () => {
// Number('x') === NaN, Number(undefined) === NaN → default 90.
// Number(null) === 0 (finite) → clamp to 30, see boundary test below.
expect(normalizeAutoRecordPollSeconds('x')).toBe(90);
expect(normalizeAutoRecordPollSeconds(undefined)).toBe(90);
expect(normalizeAutoRecordPollSeconds({})).toBe(90);
});
test('null becomes 0 then clamps to 30', () => {
expect(normalizeAutoRecordPollSeconds(null)).toBe(30);
});
test('clamps low to 30', () => {
expect(normalizeAutoRecordPollSeconds(5)).toBe(30);
});
test('clamps high to 1800', () => {
expect(normalizeAutoRecordPollSeconds(99999)).toBe(1800);
});
test('passes valid mid-range', () => {
expect(normalizeAutoRecordPollSeconds(120)).toBe(120);
});
test('floors fractional', () => {
expect(normalizeAutoRecordPollSeconds(120.9)).toBe(120);
});
test('boundary 30 stays', () => {
expect(normalizeAutoRecordPollSeconds(30)).toBe(30);
});
test('boundary 1800 stays', () => {
expect(normalizeAutoRecordPollSeconds(1800)).toBe(1800);
});
});
describe('normalizeAutoRecordList', () => {
test('empty for non-array', () => {
expect(normalizeAutoRecordList(null)).toEqual([]);
expect(normalizeAutoRecordList('x')).toEqual([]);
expect(normalizeAutoRecordList(undefined)).toEqual([]);
});
test('empty array stays empty', () => {
expect(normalizeAutoRecordList([])).toEqual([]);
});
test('lowercases + trims + dedupes', () => {
expect(normalizeAutoRecordList(['Foo', 'foo', ' BAR '])).toEqual(['foo', 'bar']);
});
test('strips leading @ (twitch username paste-form)', () => {
expect(normalizeAutoRecordList(['@foo', 'foo', '@@bar'])).toEqual(['foo', 'bar']);
});
test('drops non-string entries', () => {
expect(normalizeAutoRecordList(['foo', 123, null, 'bar'])).toEqual(['foo', 'bar']);
});
test('drops empty strings after normalize', () => {
expect(normalizeAutoRecordList(['', '@', ' ', 'foo'])).toEqual(['foo']);
});
});
describe('normalizeStreamlinkQuality', () => {
test('all valid values pass through', () => {
for (const q of VALID_STREAMLINK_QUALITIES) {
expect(normalizeStreamlinkQuality(q)).toBe(q);
}
});
test('invalid string falls back to best', () => {
expect(normalizeStreamlinkQuality('foo')).toBe('best');
});
test('null/undefined/number fall back to best', () => {
expect(normalizeStreamlinkQuality(null)).toBe('best');
expect(normalizeStreamlinkQuality(undefined)).toBe('best');
expect(normalizeStreamlinkQuality(42)).toBe('best');
});
});
describe('normalizeFilenameTemplate', () => {
test('valid string used as-is', () => {
expect(normalizeFilenameTemplate('{title}.mp4', 'FB')).toBe('{title}.mp4');
});
test('trims whitespace', () => {
expect(normalizeFilenameTemplate(' hi ', 'FB')).toBe('hi');
});
test('empty string falls back', () => {
expect(normalizeFilenameTemplate('', 'FB')).toBe('FB');
});
test('whitespace-only falls back', () => {
expect(normalizeFilenameTemplate(' ', 'FB')).toBe('FB');
});
test('undefined falls back', () => {
expect(normalizeFilenameTemplate(undefined, 'FB')).toBe('FB');
});
});
describe('normalizeMetadataCacheMinutes', () => {
test('default 10 for NaN-producer', () => {
expect(normalizeMetadataCacheMinutes('x')).toBe(10);
expect(normalizeMetadataCacheMinutes(undefined)).toBe(10);
expect(normalizeMetadataCacheMinutes({})).toBe(10);
});
test('null becomes 0 then clamps to 1', () => {
expect(normalizeMetadataCacheMinutes(null)).toBe(1);
});
test('clamps low to 1', () => {
expect(normalizeMetadataCacheMinutes(0)).toBe(1);
expect(normalizeMetadataCacheMinutes(-5)).toBe(1);
});
test('clamps high to 120', () => {
expect(normalizeMetadataCacheMinutes(999)).toBe(120);
});
test('passes valid mid-range', () => {
expect(normalizeMetadataCacheMinutes(15)).toBe(15);
});
test('floors fractional', () => {
expect(normalizeMetadataCacheMinutes(15.9)).toBe(15);
});
});
describe('normalizePerformanceMode', () => {
test('stability passes', () => {
expect(normalizePerformanceMode('stability')).toBe('stability');
});
test('balanced passes', () => {
expect(normalizePerformanceMode('balanced')).toBe('balanced');
});
test('speed passes', () => {
expect(normalizePerformanceMode('speed')).toBe('speed');
});
test('invalid string falls back to balanced', () => {
expect(normalizePerformanceMode('foo')).toBe('balanced');
});
test('null/undefined fall back to balanced', () => {
expect(normalizePerformanceMode(null)).toBe('balanced');
expect(normalizePerformanceMode(undefined)).toBe('balanced');
});
});
describe('isPlainObject', () => {
test('true for object literal', () => {
expect(isPlainObject({})).toBe(true);
expect(isPlainObject({ a: 1 })).toBe(true);
});
test('false for array', () => {
expect(isPlainObject([])).toBe(false);
expect(isPlainObject([1, 2, 3])).toBe(false);
});
test('false for null', () => {
expect(isPlainObject(null)).toBe(false);
});
test('false for undefined', () => {
expect(isPlainObject(undefined)).toBe(false);
});
test('false for primitives', () => {
expect(isPlainObject('x')).toBe(false);
expect(isPlainObject(42)).toBe(false);
expect(isPlainObject(true)).toBe(false);
});
});

View File

@ -0,0 +1,67 @@
// Pure normalizer-Helpers fuer Config-Felder. Keine Side-Effects, keine Globals.
export type PerformanceMode = 'stability' | 'balanced' | 'speed';
export const VALID_STREAMLINK_QUALITIES = ['best', 'source', '1080p60', '720p60', '720p', '480p', 'audio_only'] as const;
const AUTO_RECORD_POLL_MIN_SECONDS = 30;
const AUTO_RECORD_POLL_MAX_SECONDS = 1800;
const DEFAULT_METADATA_CACHE_MINUTES = 10;
const DEFAULT_PERFORMANCE_MODE: PerformanceMode = 'balanced';
/** trim + strip leading @ + lowercase. Verbatim aus altem main.ts. */
export function normalizeLogin(input: string): string {
return input.trim().replace(/^@+/, '').toLowerCase();
}
export function normalizeAutoRecordPollSeconds(value: unknown): number {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return 90;
return Math.max(AUTO_RECORD_POLL_MIN_SECONDS, Math.min(AUTO_RECORD_POLL_MAX_SECONDS, Math.floor(parsed)));
}
export function normalizeAutoRecordList(value: unknown): string[] {
if (!Array.isArray(value)) return [];
const seen = new Set<string>();
const out: string[] = [];
for (const v of value) {
if (typeof v !== 'string') continue;
const cleaned = normalizeLogin(v);
if (cleaned && !seen.has(cleaned)) {
seen.add(cleaned);
out.push(cleaned);
}
}
return out;
}
export function normalizeStreamlinkQuality(value: unknown): string {
if (typeof value === 'string' && (VALID_STREAMLINK_QUALITIES as readonly string[]).includes(value)) {
return value;
}
return 'best';
}
export function normalizeFilenameTemplate(template: string | undefined, fallback: string): string {
const value = (template || '').trim();
return value || fallback;
}
export function normalizeMetadataCacheMinutes(value: unknown): number {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return DEFAULT_METADATA_CACHE_MINUTES;
}
return Math.max(1, Math.min(120, Math.floor(parsed)));
}
export function normalizePerformanceMode(mode: unknown): PerformanceMode {
if (mode === 'stability' || mode === 'balanced' || mode === 'speed') {
return mode;
}
return DEFAULT_PERFORMANCE_MODE;
}
export function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}