diff --git a/src/main.ts b/src/main.ts index 487ec9f..db754cb 100644 --- a/src/main.ts +++ b/src/main.ts @@ -9,6 +9,18 @@ import { compareUpdateVersions, isNewerUpdateVersion, normalizeUpdateVersion } f 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 { + 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 { setDebugLogFn, initToolDirs, @@ -68,7 +80,6 @@ const DEFAULT_RETRY_DELAY_SECONDS = 5; const MIN_FILE_BYTES = 256 * 1024; const TWITCH_WEB_CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; -type PerformanceMode = 'stability' | 'balanced' | 'speed'; type RetryErrorClass = 'network' | 'rate_limit' | 'auth' | 'tooling' | 'integrity' | 'io' | 'validation' | 'unknown'; type UpdateCheckSource = 'startup' | 'interval' | 'manual'; type UpdateDownloadSource = 'auto' | 'manual'; @@ -284,72 +295,15 @@ const defaultConfig: Config = { delete_parts_after_merge: false }; -const AUTO_RECORD_POLL_MIN_SECONDS = 30; -const AUTO_RECORD_POLL_MAX_SECONDS = 1800; -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))); -} - -function normalizeAutoRecordList(value: unknown): string[] { - if (!Array.isArray(value)) return []; - const seen = new Set(); - 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'; -} - +// normalize* helpers + VALID_STREAMLINK_QUALITIES + isPlainObject + normalizeLogin +// kommen aus ./main/domain/config-normalize. getStreamlinkStreamArg bleibt +// hier, da es config liest. function getStreamlinkStreamArg(): string { const choice = normalizeStreamlinkQuality(config.streamlink_quality); 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`; } -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 { // downloaded_vod_ids is bounded so a long-running app doesn't accumulate // an unbounded list across years of downloads. Latest entries kept. @@ -430,10 +384,6 @@ function recordDownloadedVodId(vodId: string): void { saveConfig(config); } -function isPlainObject(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - function loadConfig(): Config { try { if (fs.existsSync(CONFIG_FILE)) { @@ -1763,10 +1713,6 @@ async function ensureTwitchAuth(forceRefresh = false): Promise { return await requestTwitchLogin(); } -function normalizeLogin(input: string): string { - return input.trim().replace(/^@+/, '').toLowerCase(); -} - function formatTwitchDurationFromSeconds(totalSeconds: number): string { const seconds = Math.max(0, Math.floor(totalSeconds)); const h = Math.floor(seconds / 3600); diff --git a/src/main/domain/config-normalize.test.ts b/src/main/domain/config-normalize.test.ts new file mode 100644 index 0000000..7cbc4e1 --- /dev/null +++ b/src/main/domain/config-normalize.test.ts @@ -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); + }); +}); diff --git a/src/main/domain/config-normalize.ts b/src/main/domain/config-normalize.ts new file mode 100644 index 0000000..df5f9e1 --- /dev/null +++ b/src/main/domain/config-normalize.ts @@ -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(); + 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 { + return typeof value === 'object' && value !== null && !Array.isArray(value); +}