Compare commits

...

11 Commits

Author SHA1 Message Date
xRangerDE
b649cf36f2 release: 5.0.0-alpha.0 — foundation: vitest + 5 pure modules extracted
Plan 01 abgeschlossen. main.ts: 7485 → 7287 LoC (-198).
5 neue Module + 91 Unit-Tests:
  src/main/infra/fs-atomic.ts
  src/main/infra/duration.ts
  src/main/domain/update-version-utils.ts
  src/main/domain/i18n-backend.ts
  src/main/domain/config-normalize.ts

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 21:50:25 +02:00
xRangerDE
fb1392bc4b 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>
2026-05-11 21:48:58 +02:00
xRangerDE
89b30d33b9 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>
2026-05-11 21:46:12 +02:00
xRangerDE
aee2914397 refactor: extract duration helpers to src/main/infra/duration + 18 tests
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 21:44:15 +02:00
xRangerDE
995e4b62dd refactor: extract writeFileAtomicSync to src/main/infra/fs-atomic + 6 tests
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 21:43:12 +02:00
xRangerDE
640807778c refactor: relocate update-version-utils to src/main/domain/ + vitest
16 unit tests covering normalize/compare/isNewer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 21:42:05 +02:00
xRangerDE
1b4dac5709 scaffold: src/main/{infra,domain} directory tree for v5 split
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 21:40:52 +02:00
xRangerDE
84aa4c5eca build: add test:unit + chain into test:e2e:release
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 21:40:24 +02:00
xRangerDE
cf859e70db build: vitest config (node env, src/**/*.test.ts)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 21:39:46 +02:00
xRangerDE
d97f75d0f7 build: add vitest devDep
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 21:39:19 +02:00
xRangerDE
956ffc30bc docs: v5.0.0 goal + roadmap + foundation plan
- tasks/v5.0.0-goal.md: 7-Pillar Vision, Breaking Changes, Release-Phasen
- tasks/v5.0.0-roadmap.md: Reality-Check vs Goal, 11-Plan Execution-Order
- tasks/v5.0.0-plan-01-foundation.md: Vitest + 5 Pure-Module-Extraktionen

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 21:37:54 +02:00
21 changed files with 3306 additions and 235 deletions

1235
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "twitch-vod-manager",
"version": "4.6.155",
"version": "5.0.0-alpha.0",
"description": "Twitch VOD Manager - Download Twitch VODs easily",
"main": "dist/main.js",
"author": "xRangerDE",
@ -8,11 +8,13 @@
"scripts": {
"build": "tsc",
"start": "npm run build && electron .",
"test:unit": "vitest run --passWithNoTests",
"test:unit:watch": "vitest",
"test:e2e:update-logic": "node scripts/smoke-test-update-version-logic.js",
"test:e2e": "node scripts/smoke-test.js",
"test:e2e:guide": "node scripts/smoke-test-template-guide.js",
"test:e2e:full": "node scripts/smoke-test-full.js",
"test:e2e:release": "npm run build && npm run test:e2e:update-logic && npm run test:e2e && npm run test:e2e:guide && npm run test:e2e:full",
"test:e2e:release": "npm run build && npm run test:unit && npm run test:e2e:update-logic && npm run test:e2e && npm run test:e2e:guide && npm run test:e2e:full",
"test:e2e:stress": "npm run test:e2e:release && npm run test:e2e:release && npm run test:e2e:release",
"pack": "npm run build && electron-builder --dir",
"dist": "npm run build && electron-builder",
@ -33,7 +35,8 @@
"eslint-plugin-security": "^4.0.0",
"playwright": "^1.59.1",
"typescript": "^5.3.0",
"typescript-eslint": "^8.57.1"
"typescript-eslint": "^8.57.1",
"vitest": "^4.1.6"
},
"build": {
"appId": "de.24-music.twitch-vod-manager",

View File

@ -4,7 +4,7 @@ const {
normalizeUpdateVersion,
compareUpdateVersions,
isNewerUpdateVersion
} = require(path.join(process.cwd(), 'dist', 'update-version-utils.js'));
} = require(path.join(process.cwd(), 'dist', 'main', 'domain', 'update-version-utils.js'));
function run() {
const failures = [];

View File

@ -5,7 +5,22 @@ import { spawn, ChildProcess, execSync, spawnSync } from 'child_process';
import { connect as tlsConnect, TLSSocket } from 'node:tls';
import axios from 'axios';
import { autoUpdater } from 'electron-updater';
import { compareUpdateVersions, isNewerUpdateVersion, normalizeUpdateVersion } from './update-version-utils';
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 {
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,
@ -65,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';
@ -84,102 +98,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
@ -372,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<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';
}
// 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.
@ -518,10 +384,6 @@ function recordDownloadedVodId(vodId: string): void {
saveConfig(config);
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function loadConfig(): Config {
try {
if (fs.existsSync(CONFIG_FILE)) {
@ -539,30 +401,6 @@ function loadConfig(): Config {
return normalizeConfigTemplates(defaultConfig);
}
function writeFileAtomicSync(targetPath: string, payload: string | Buffer): void {
const buffer = Buffer.isBuffer(payload) ? payload : Buffer.from(payload, 'utf-8');
const tmpPath = targetPath + '.tmp';
let fd: number | null = null;
try {
fd = fs.openSync(tmpPath, 'w');
fs.writeSync(fd, buffer, 0, buffer.length, 0);
try { fs.fsyncSync(fd); } catch { /* fsync may fail on some FS; rename is still safer than nothing */ }
} finally {
if (fd !== null) {
try { fs.closeSync(fd); } catch { }
}
}
try {
fs.renameSync(tmpPath, targetPath);
} catch {
// On Windows, rename can fail if target exists or is locked. Fall back to copy.
fs.copyFileSync(tmpPath, targetPath);
try { fs.unlinkSync(tmpPath); } catch { }
}
}
function saveConfig(config: Config): void {
try {
writeFileAtomicSync(CONFIG_FILE, JSON.stringify(config, null, 2));
@ -1096,38 +934,6 @@ function appendDebugLog(message: string, details?: unknown): void {
setDebugLogFn(appendDebugLog);
initToolDirs(TOOLS_STREAMLINK_DIR, TOOLS_FFMPEG_DIR, () => app.getPath('temp'));
// ==========================================
// DURATION HELPERS
// ==========================================
function parseDuration(duration: string): number {
let seconds = 0;
const hours = duration.match(/(\d+)h/);
const minutes = duration.match(/(\d+)m/);
const secs = duration.match(/(\d+)s/);
if (hours) seconds += parseInt(hours[1]) * 3600;
if (minutes) seconds += parseInt(minutes[1]) * 60;
if (secs) seconds += parseInt(secs[1]);
return seconds;
}
function formatDuration(seconds: number): string {
if (!isFinite(seconds) || seconds < 0) return '00:00:00';
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
}
function formatDurationDashed(seconds: number): string {
if (!isFinite(seconds) || seconds < 0) return '00-00-00';
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
return `${h.toString().padStart(2, '0')}-${m.toString().padStart(2, '0')}-${s.toString().padStart(2, '0')}`;
}
const claimedFilenames = new Set<string>();
const itemClaimedFilenames = new Map<string, Set<string>>();
@ -1907,10 +1713,6 @@ async function ensureTwitchAuth(forceRefresh = false): Promise<boolean> {
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);

0
src/main/domain/.gitkeep Normal file
View File

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);
}

View 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');
});
});

View 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;
}

View File

@ -0,0 +1,66 @@
import { test, expect, describe } from 'vitest';
import {
normalizeUpdateVersion,
compareUpdateVersions,
isNewerUpdateVersion,
} from './update-version-utils';
describe('normalizeUpdateVersion', () => {
test('strips v-prefix lowercase', () => {
expect(normalizeUpdateVersion('v1.2.3')).toBe('1.2.3');
});
test('strips V-prefix uppercase', () => {
expect(normalizeUpdateVersion('V1.2.3')).toBe('1.2.3');
});
test('trims whitespace', () => {
expect(normalizeUpdateVersion(' 1.2.3 ')).toBe('1.2.3');
});
test('handles null and undefined as empty string', () => {
expect(normalizeUpdateVersion(null)).toBe('');
expect(normalizeUpdateVersion(undefined)).toBe('');
});
test('passes plain version unchanged', () => {
expect(normalizeUpdateVersion('4.6.155')).toBe('4.6.155');
});
});
describe('compareUpdateVersions', () => {
test('older < newer in same minor', () => {
expect(compareUpdateVersions('4.1.10', '4.1.18')).toBeLessThan(0);
});
test('newer > older in same minor', () => {
expect(compareUpdateVersions('4.1.18', '4.1.10')).toBeGreaterThan(0);
});
test('equal versions return 0', () => {
expect(compareUpdateVersions('4.6.155', '4.6.155')).toBe(0);
});
test('v-prefix is normalized away', () => {
expect(compareUpdateVersions('v4.1.12', '4.1.12')).toBe(0);
});
test('extra trailing part is newer', () => {
expect(compareUpdateVersions('4.1.12', '4.1.12.1')).toBeLessThan(0);
});
test('major bump wins', () => {
expect(compareUpdateVersions('5.0.0', '4.99.99')).toBeGreaterThan(0);
});
test('null versions sort lowest', () => {
expect(compareUpdateVersions(null, '1.0.0')).toBeLessThan(0);
expect(compareUpdateVersions('1.0.0', null)).toBeGreaterThan(0);
});
test('both null returns 0', () => {
expect(compareUpdateVersions(null, null)).toBe(0);
expect(compareUpdateVersions('', '')).toBe(0);
});
});
describe('isNewerUpdateVersion', () => {
test('strictly newer returns true', () => {
expect(isNewerUpdateVersion('4.1.18', '4.1.17')).toBe(true);
});
test('equal returns false', () => {
expect(isNewerUpdateVersion('4.1.18', '4.1.18')).toBe(false);
});
test('older returns false', () => {
expect(isNewerUpdateVersion('4.1.17', '4.1.18')).toBe(false);
});
});

3
src/main/index.ts Normal file
View File

@ -0,0 +1,3 @@
// Stammverzeichnis fuer das v5-Architektur-Refactoring.
// Plan 04 macht daraus den Entry-Point statt src/main.ts.
export {};

0
src/main/infra/.gitkeep Normal file
View File

View File

@ -0,0 +1,65 @@
import { test, expect, describe } from 'vitest';
import { parseDuration, formatDuration, formatDurationDashed } from './duration';
describe('parseDuration', () => {
test('1h2m3s = 3723', () => {
expect(parseDuration('1h2m3s')).toBe(3723);
});
test('45m = 2700', () => {
expect(parseDuration('45m')).toBe(2700);
});
test('10s = 10', () => {
expect(parseDuration('10s')).toBe(10);
});
test('empty string = 0', () => {
expect(parseDuration('')).toBe(0);
});
test('unknown format = 0', () => {
expect(parseDuration('abcdef')).toBe(0);
});
test('partial 2h = 7200', () => {
expect(parseDuration('2h')).toBe(7200);
});
test('h and s without m = 3601', () => {
expect(parseDuration('1h1s')).toBe(3601);
});
});
describe('formatDuration', () => {
test('3723 = 01:02:03', () => {
expect(formatDuration(3723)).toBe('01:02:03');
});
test('0 = 00:00:00', () => {
expect(formatDuration(0)).toBe('00:00:00');
});
test('negative = 00:00:00', () => {
expect(formatDuration(-1)).toBe('00:00:00');
});
test('Infinity = 00:00:00', () => {
expect(formatDuration(Infinity)).toBe('00:00:00');
});
test('NaN = 00:00:00', () => {
expect(formatDuration(NaN)).toBe('00:00:00');
});
test('3600 = 01:00:00', () => {
expect(formatDuration(3600)).toBe('01:00:00');
});
test('86399 = 23:59:59', () => {
expect(formatDuration(86399)).toBe('23:59:59');
});
test('fractional seconds floored', () => {
expect(formatDuration(3723.9)).toBe('01:02:03');
});
});
describe('formatDurationDashed', () => {
test('3723 = 01-02-03', () => {
expect(formatDurationDashed(3723)).toBe('01-02-03');
});
test('negative = 00-00-00', () => {
expect(formatDurationDashed(-1)).toBe('00-00-00');
});
test('NaN = 00-00-00', () => {
expect(formatDurationDashed(NaN)).toBe('00-00-00');
});
});

View File

@ -0,0 +1,28 @@
export function parseDuration(duration: string): number {
let seconds = 0;
const hours = duration.match(/(\d+)h/);
const minutes = duration.match(/(\d+)m/);
const secs = duration.match(/(\d+)s/);
if (hours) seconds += parseInt(hours[1]) * 3600;
if (minutes) seconds += parseInt(minutes[1]) * 60;
if (secs) seconds += parseInt(secs[1]);
return seconds;
}
export function formatDuration(seconds: number): string {
if (!isFinite(seconds) || seconds < 0) return '00:00:00';
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
}
export function formatDurationDashed(seconds: number): string {
if (!isFinite(seconds) || seconds < 0) return '00-00-00';
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
return `${h.toString().padStart(2, '0')}-${m.toString().padStart(2, '0')}-${s.toString().padStart(2, '0')}`;
}

View File

@ -0,0 +1,53 @@
import { test, expect, describe, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { writeFileAtomicSync } from './fs-atomic';
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fsatomic-'));
});
afterEach(() => {
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
});
describe('writeFileAtomicSync', () => {
test('writes a string payload', () => {
const target = path.join(tmpDir, 'a.txt');
writeFileAtomicSync(target, 'hello');
expect(fs.readFileSync(target, 'utf-8')).toBe('hello');
});
test('writes a buffer payload', () => {
const target = path.join(tmpDir, 'b.bin');
writeFileAtomicSync(target, Buffer.from([1, 2, 3, 4]));
expect(fs.readFileSync(target)).toEqual(Buffer.from([1, 2, 3, 4]));
});
test('overwrites existing file', () => {
const target = path.join(tmpDir, 'c.txt');
fs.writeFileSync(target, 'old');
writeFileAtomicSync(target, 'new');
expect(fs.readFileSync(target, 'utf-8')).toBe('new');
});
test('cleans up tmp file after success', () => {
const target = path.join(tmpDir, 'd.txt');
writeFileAtomicSync(target, 'x');
expect(fs.existsSync(target + '.tmp')).toBe(false);
});
test('utf-8 multibyte chars roundtrip', () => {
const target = path.join(tmpDir, 'e.txt');
writeFileAtomicSync(target, 'aeoeue-aeoeue');
expect(fs.readFileSync(target, 'utf-8')).toBe('aeoeue-aeoeue');
});
test('empty payload writes empty file', () => {
const target = path.join(tmpDir, 'f.txt');
writeFileAtomicSync(target, '');
expect(fs.readFileSync(target, 'utf-8')).toBe('');
expect(fs.statSync(target).size).toBe(0);
});
});

View File

@ -0,0 +1,29 @@
import * as fs from 'fs';
/**
* Atomic write via tmp + rename. Survives crash mid-write either old or
* new content, never partial. Windows fallback: copy + unlink if rename
* fails (e.g. target locked by reader). fsync best-effort.
*/
export function writeFileAtomicSync(targetPath: string, payload: string | Buffer): void {
const buffer = Buffer.isBuffer(payload) ? payload : Buffer.from(payload, 'utf-8');
const tmpPath = targetPath + '.tmp';
let fd: number | null = null;
try {
fd = fs.openSync(tmpPath, 'w');
fs.writeSync(fd, buffer, 0, buffer.length, 0);
try { fs.fsyncSync(fd); } catch { /* fsync may fail on some FS; rename is still safer than nothing */ }
} finally {
if (fd !== null) {
try { fs.closeSync(fd); } catch { /* ignore */ }
}
}
try {
fs.renameSync(tmpPath, targetPath);
} catch {
fs.copyFileSync(tmpPath, targetPath);
try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
}
}

275
tasks/v5.0.0-goal.md Normal file
View File

@ -0,0 +1,275 @@
# Twitch VOD Manager — v5.0.0 Goal
**Baseline:** v4.6.155 (Stand 2026-05-11)
**Target:** v5.0.0
**Status:** Draft / nicht freigegeben
**Sprache:** Deutsch (Stack-Begriffe englisch)
---
## 1. Executive Summary
Nach 155 Patches im 4.6er-Zyklus ist das Produkt poliert, aber im Kern unverändert seit 4.0: monolithische `main.ts` (~3700 Zeilen), JSON-File-Persistenz, Vanilla-DOM-Renderer, kein Live-Recording, keine sub-only Unterstützung, keine Tests jenseits Playwright-Smoke.
**v5.0.0 ist der Bruch:** Aufnahme statt nur Download, SQLite statt JSON, modulare Domain-Architektur, Twitch-OAuth, optionales Web-Worker-Offloading für grosse Listen. Sieben Feature-Pillars, drei harte Breaking Changes, klarer Migrationspfad von 4.6.x.
**Was 5.0.0 NICHT ist:** kein React/Vue-Rewrite, kein Plattform-Hop (bleibt Electron + Win), kein neues Bezahlmodell. Es bleibt der gleiche Single-User-Desktop-Client — nur deutlich erwachsener.
---
## 2. Why Major Bump?
Ein Major-Sprung ist gerechtfertigt, wenn **mindestens zwei** Kriterien erfuellt sind:
| Kriterium | 5.0.0 trifft zu? | Belege |
|---|---|---|
| Breaking Change im Config-/Datenformat | Ja | JSON → SQLite Migration, Schema v4 → v5 |
| Neues Kern-Feature (nicht Inkrement) | Ja | Live-Stream-Recording (sub-only via OAuth), Scheduled-Recording |
| Architektur-Bruch | Ja | main.ts Split in 6+ Domain-Module |
| Min-OS-/Runtime-Bump | Ja | Electron 28 → 32, Node 18 → 22, Drop Win < 10 1809 |
| User-spuerbare UI-Verschiebung | Ja | Virtual-List, Mini-Player, Theme-Engine |
---
## 3. Themes & Vision
Drei Leitthemen ueber allen Features:
### 3.1 Vom Downloader zum Recorder
v4 reagiert: VOD existiert auf Twitch → wir laden runter. v5 agiert proaktiv: Stream geht live → wir nehmen direkt mit. Damit fallen Sub-Only-VODs (oft 60 Tage Retention), 7-Tage-Standard-Retention und geloeschte VODs als Verlust-Szenarien weg.
### 3.2 Vom Tool zur Platform
Domain-Module mit klaren Schnittstellen statt einem 3700-Zeilen-Monolith. Optionaler Plugin-Hook fuer Post-Download-Actions (z. B. Auto-Upload-Skript, Custom-Rename, Notify-Discord). Spaeter wartbar, leichter testbar.
### 3.3 Vom Hobby zum stabilen Daily-Driver
SQLite + Crash-Recovery + Chunk-Hashing + echte Unit-Tests. Wer 500 VODs queued, soll nicht in JSON-Lock-Konflikte laufen oder beim Crash 4 Stunden Download verlieren.
---
## 4. Feature-Pillars
### Pillar 1 — Live Stream Recording (P0)
**Was:** App kann laufende Streams in Echtzeit mitschneiden, nicht nur VODs post-hoc downloaden.
**Warum:** Sub-only Streams haben oft keine VOD-Retention oder Sub-only-VODs werden nach 60 Tagen geloescht. Recording ist der einzige Weg, sie dauerhaft zu sichern.
**Scope:**
- Streamlink-basierte Aufnahme aktiver Live-Streams (Quality-Selector wie bei VODs)
- Auto-Start: "wenn Streamer X live geht, Recording starten" (Polling-Intervall pro Streamer konfigurierbar, default 60s)
- Auto-Stop bei Stream-Ende + Final-Mux mit ffmpeg
- Resume bei Mid-Stream-Disconnect (neuer HLS-Segment-Pointer, append-Mode)
- Sub-only Streams: nutzt OAuth-Token aus Pillar 2
**Acceptance:**
- 4-Stunden-Test-Stream wird ohne Datenverlust aufgenommen
- Disconnect-Wiederherstellung in < 15s
- File-Output ist mux-fertig MP4 (kein .ts-Reste, kein orphan-Index)
- UI zeigt "LIVE REC" Badge + Recording-Dauer im Streamer-Eintrag
### Pillar 2 — Twitch-OAuth (Device Code Flow) (P0)
**Was:** Native Anmeldung mit Twitch-Account, OAuth-Token wird verschluesselt in OS-Keychain (Win Credential Manager) gespeichert.
**Warum:** Sub-only Inhalte, hoehere Rate-Limits (Helix mit User-Token > App-Token in vielen Endpoints), private Follow-Liste fuer Auto-Discovery, korrekte Mature-Content-Gating-Behandlung.
**Scope:**
- Device Code Flow (kein Browser-Embed, kein localhost-Callback noetig — sauberer fuer Desktop)
- Multi-Account: bis zu 3 parallele Accounts (Haupt, Alt, Auto-Sub)
- Token-Refresh automatisch, Fail-Soft auf App-Token
- Settings-Tab "Accounts" mit Liste, Login/Logout, "Default fuer Recording"
**Acceptance:**
- Login dauert < 60s vom Klick bis Token in Keychain
- Sub-only Test-Channel laesst sich aufnehmen mit eingeloggtem Sub-Account
- Logout entfernt Token sauber aus Keychain (audit per Tool: kein orphan-Entry)
### Pillar 3 — SQLite-Migration (P0, Breaking)
**Was:** Alle App-Daten (Queue, Stats, Archiv-Index, Streamer-Liste, Recording-History) wandern von JSON-Files nach `app.db` (better-sqlite3 oder besser-im-Bundle-Alternative).
**Warum:** JSON wird ab ~1000 VODs spuerbar lahm (Full-File-Rewrite bei jedem Save). SQLite gibt indexed lookups, atomic transactions, kein Lock-Konflikt bei parallelen Writes, einfache Migrationen.
**Scope:**
- Schema v5 mit Tabellen: `queue`, `archive`, `streamers`, `recordings`, `stats_daily`, `clip_index`, `config_blob`
- Migrator-Script: liest alle `C:\ProgramData\Twitch_VOD_Manager\*.json` und schreibt in `app.db`
- Backup vor Migration: `.json``.json.v4-backup`
- Read-Path bleibt API-kompatibel fuer Renderer (IPC-Surface unveraendert)
- WAL-Mode + busy-timeout 5000ms
**Acceptance:**
- Migrator laeuft idempotent (zweimal aufrufen = gleicher Endzustand, kein Daten-Duplikat)
- Failure-Rollback: bei Migrator-Crash bleibt App auf JSON, kein Daten-Verlust
- Query "alle VODs eines Streamers" < 50ms bei 10k Archiv-Eintraegen (gemessen)
### Pillar 4 — Architektur-Refactor: main.ts Split (P1)
**Was:** `main.ts` (3700 LoC) wird in Domain-Module zerlegt.
**Ziel-Struktur:**
```
src/main/
index.ts Entry, app lifecycle, BrowserWindow
domain/
twitch-api.ts Helix + GraphQL Calls, Rate-Limit-Handling
downloader.ts Streamlink/ffmpeg Orchestration (VOD + Live)
recorder.ts Live-Stream-Recording (NEU)
queue.ts Queue-State-Machine, persistence-call
config.ts Settings-Schema, defaults, validation
auth.ts OAuth, Token-Storage, Keychain (NEU)
cache.ts In-Memory-TTL-Caches
updater.ts electron-updater Wrapper
infra/
db.ts SQLite-Wrapper, prepared statements
logger.ts Strukturiertes Logging (JSON-Lines)
ipc.ts Channel-Registry, type-safe Handler
ipc-bridge.ts exportiert nur die API-Surface fuer preload.ts
```
**Constraint:** Renderer-IPC-Surface bleibt rueckwaertskompatibel — kein Renderer-Rewrite noetig.
**Acceptance:**
- Kein Modul groesser als 800 LoC
- Zyklische Imports = 0 (`madge --circular` oder aequivalent)
- E2E-Tests gruen ohne Anpassung
### Pillar 5 — UI Power-Features (P1)
**Was:** Renderer-seitige Spruenge, die User direkt sehen.
**Scope:**
- **Virtualized List** fuer Queue/Archive (react-window-aequivalent vanilla, oder `@tanstack/virtual-core` vanilla-Adapter) — 10k Eintraege ohne Lag
- **Mini-Player Panel**: HLS-Stream in App-eigenem `<video>` (hls.js), kollabierbar, fuer Recording-Live-Preview + VOD-Vorschau
- **Theme-Engine**: drei eingebaute Themes (Default, Dark High-Contrast, Light) + Custom-CSS-Slot mit Live-Reload
- **Template-Preview live**: Title-Template aendert sich, Beispiel-Output rendert in Echtzeit unter dem Input
- **Drag-Reorder** in Queue (HTML5-DnD, persistiert Position-Index in DB)
- **Command-Palette** (Ctrl+K): Schnellaktionen, Streamer-Suche, Settings-Jump
**Acceptance:**
- Scroll-Performance bei 5000 Eintraegen: 60fps (Chrome DevTools Performance-Trace)
- Theme-Switch ohne Reload
- Command-Palette < 100ms First-Result-Latency
### Pillar 6 — Smart-Resume & Integrity (P1)
**Was:** Downloads/Recordings ueberleben Crash, Disconnect, Reboot.
**Scope:**
- Jeder HLS-Segment-Chunk bekommt sha1, geschrieben in DB-Tabelle `chunk_index`
- Bei Resume: vergleicht local-chunk-hash mit Manifest, ueberspringt valide Chunks, faengt am ersten Mismatch an
- Crash-Recovery beim Startup: scannt `recordings`-Tabelle nach `state=running` und bietet Resume-Dialog an
- Final-Integrity-Check: ffprobe-Aufruf auf fertige Datei, schreibt `verified=1` in DB
**Acceptance:**
- App-Kill mid-download → Neustart → Resume genau ab Abbruch-Chunk (manueller Test)
- Korrupte Chunk-Datei wird erkannt und neu geladen (per Mutation-Test: zufaelliges Byte flippen)
### Pillar 7 — Scheduled Recording & Auto-Discovery (P2)
**Was:** Automatisierungs-Layer ueber Pillar 1+2.
**Scope:**
- Pro Streamer: "Auto-Record on Live: ja/nein" + Quality-Preset
- Zeit-Plan: "nur zwischen 18-02 Uhr aufnehmen" (vermeidet Reruns/Marathons)
- **Top-Clip-Crawler**: pro Streamer alle X Tage die N besten Clips (Helix `/clips`) downloaden
- **Follow-Sync**: importiert eingeloggte Follow-Liste als Streamer-Eintraege (opt-in)
**Acceptance:**
- 3-Streamer-Auto-Record-Test ueber 24h ohne Eingriff, kein Doppelaufnahme bei Stream-Reconnect
---
## 5. Breaking Changes
| # | Was bricht | Migration | User-Impact |
|---|---|---|---|
| BC-1 | Config-Speicherort | Migrator on first 5.0 start, `.v4-backup` der JSONs bleibt liegen | Einmalig 5-30s Migrations-Dialog |
| BC-2 | Min-Windows: Win10 1809 (Oktober 2018) | Installer-Check, blockiert Setup auf alten OS | Sehr wenige User betroffen |
| BC-3 | Node-API-Version: Plugins die auf Node 18 N-API gebaut sind muessen rebuild (relevant fuer better-sqlite3, evtl. fsevents) | Pre-built Binaries im Bundle | Transparent fuer User |
| BC-4 | Streamer-Eintrag-Format aendert sich (neue Felder: `oauth_account_id`, `auto_record`, `record_quality`) | Auto-Default bei Migration | Transparent |
| BC-5 | Auto-Updater Channel: alte 4.x-Clients sehen 5.0 nicht im stable-Channel, sondern in `next` (opt-in) | User muss bewusst Channel wechseln; nach 4 Wochen Beta: 5.0 → stable | Bewusste User-Aktion |
---
## 6. Tech-Stack Upgrades
| Komponente | 4.6 | 5.0 | Grund |
|---|---|---|---|
| Electron | 28.x | 32.x (oder LTS aktuell) | Security-Patches, Chromium-Upgrade |
| Node | 18 | 22 | LTS, V8 perf |
| TypeScript | 5.3 | 5.6+ | satisfies / decorators stable |
| ESLint | 10 | aktueller flat-config | passt |
| Neu: better-sqlite3 | — | latest | SQLite-Persistenz |
| Neu: hls.js | — | latest | Mini-Player |
| Neu: keytar (oder Electron safeStorage) | — | latest | OAuth-Token-Storage |
| Playwright | 1.59 | aktuell | E2E |
| Neu: vitest | — | latest | Unit-Tests |
---
## 7. Quality & DevX
- **Unit-Tests (Vitest):** mind. 60% Coverage in `src/main/domain/*` zum 5.0.0-GA. Reine Logik-Tests, kein Electron-Boot.
- **E2E (Playwright):** bestehende Suite plus drei neue Szenarien (Login, Live-Record-Start, SQLite-Migration).
- **CI:** weiterhin lokal (Solo-Projekt), aber `npm run check:all` = build + lint + vitest + e2e in einem Skript.
- **Logging:** strukturiert JSONL in `logs/app-YYYY-MM-DD.jsonl`, 14-Tage-Retention, mit Level (debug/info/warn/error) und Trace-IDs pro Download/Recording.
- **Crash-Reports:** opt-in Sentry (oder einfacher: lokaler Crash-Log-Export + Copy-to-Clipboard-Button). Default: aus, kein Telemetrie-Surprise.
---
## 8. Release-Phasen
| Phase | Version | Was rein | Wann (relativ) |
|---|---|---|---|
| Alpha-1 | 5.0.0-alpha.1 | SQLite-Migrator + Architektur-Split | T+0 |
| Alpha-2 | 5.0.0-alpha.2 | OAuth + Live-Recording (Baseline) | T+2 Wo |
| Beta-1 | 5.0.0-beta.1 | UI Power-Features, Smart-Resume | T+4 Wo |
| Beta-2 | 5.0.0-beta.2 | Scheduled/Auto-Discovery, Bug-Fixes | T+6 Wo |
| RC | 5.0.0-rc.1 | nur noch Bugfixes, kein Feature-Add | T+8 Wo |
| GA | 5.0.0 | Public, `next`-Channel → `stable` | T+10 Wo |
Konkret Daten setze ich nicht — die haengen davon ab, wie eng du das fahren willst.
---
## 9. Out-of-Scope (bewusst NICHT in 5.0.0)
- React/Vue/Svelte-Rewrite des Renderers (Risiko zu gross, Vanilla bleibt)
- macOS/Linux-Builds (Electron-Builder kann, aber kein Test-Setup → erst 5.1+)
- Cloud-Sync (Queue/Settings ueber Geraete) — eigener Major-Bump waert
- Chat-Recording / Chat-Renderer (TwitchDownloaderCLI Integration) — verlockend, aber Scope-Creep → 5.1
- Mobile-Companion-App
- Plugin-API fuer Dritte (Hooks koennen wir intern bauen, aber kein public-API-Versprechen in 5.0)
- AI-Highlight-Detection (lokales LLM) — Forschung, kein Produkt-Feature 2026
---
## 10. Definition of Done (5.0.0 GA)
Alle Punkte erfuellt:
1. [ ] Pillars 1-6 implementiert und in E2E-Tests abgedeckt (Pillar 7 darf teilweise sein, mind. Auto-Record-on-Live)
2. [ ] SQLite-Migrator getestet auf realer 4.6-Installation mit 5k+ Archive-Eintraegen, idempotent, mit Rollback-Path
3. [ ] OAuth-Token landet in Keychain, audit zeigt sauberen Logout
4. [ ] Vitest-Coverage `src/main/domain` >= 60%
5. [ ] Playwright-Suite gruen, inkl. drei neue Szenarien
6. [ ] Mini-Player + Theme-Engine + Virtual-List manuell verifiziert (Demo-Video)
7. [ ] Auto-Updater: bestehende 4.6-Installation kriegt 5.0 ueber `next`-Channel, Migration laeuft, App startet
8. [ ] README + CLAUDE.md aktualisiert (neue Architektur, neue Commands, neue Settings)
9. [ ] CHANGELOG-Eintrag mit allen Breaking Changes prominent oben
10. [ ] Frische Installation aus `Twitch-VOD-Manager-Setup-5.0.0.exe` auf clean Win11-VM: erstes Recording lauft binnen 5 Minuten User-Klicks
---
## 11. Naechste Schritte (wenn du dieses Goal annimmst)
1. Goal mit eigenem Realitaetscheck lesen — was streichen, was zuerst
2. Pillar 3 (SQLite) zuerst, weil es Voraussetzung fuer 1, 6, 7 ist
3. Branch `feat/v5-foundation` aufmachen, dort Architektur-Split + SQLite parallel
4. Nach Alpha-1 entscheiden, ob OAuth vor UI-Refactor oder umgekehrt
5. Eigenes `tasks/v5.0.0-checklist.md` aus Pillar-Acceptance-Criteria generieren, abhakbar
---
**Autor:** Claude (Opus 4.7, 1M context) — 2026-05-11
**Review-Status:** unreviewed, Erstentwurf

File diff suppressed because it is too large Load Diff

84
tasks/v5.0.0-roadmap.md Normal file
View File

@ -0,0 +1,84 @@
# v5.0.0 Roadmap (Reality-Aligned)
**Baseline:** v4.6.155
**Spec:** `tasks/v5.0.0-goal.md`
**Last verified:** 2026-05-11
---
## Reality Check vs. Goal
Beim Vergleich der echten Codebase (`src/main.ts` 7485 LoC mit 30+ Sektionen) gegen die Goal-Annahmen zeigt sich: einige "neu zu bauenden" Features existieren teilweise schon.
| Pillar | Goal sagt | Reality | Restarbeit % |
|---|---|---|---|
| P1 — Live Stream Recording | "neu" | Auto-Record-Poller, Live-Recording-Events-Log, Live-Chat-Capture existieren | ~30% (Polish + Sub-only-Pfad nach OAuth) |
| P2 — Twitch OAuth | "neu" | App nutzt nur public Web-Client-ID, keine User-Auth | 100% |
| P3 — SQLite-Migration | "Breaking" | Config + Queue + Stats sind JSON-Files | 100% |
| P4 — main.ts Split | "neu" | 7485 LoC Monolith, 30 Sektionen, schon klar getrennt | 100% (mechanische Arbeit) |
| P5 — UI Power-Features | "neu" | Vanilla DOM, keine Virtualisierung, kein Mini-Player, ein Theme | 90% |
| P6 — Smart-Resume | "neu" | `auto_resume_live_recording` Flag existiert, `auto_merge_resumed_parts`, Crash-Recovery rudimentaer | ~60% (Chunk-Hash + Integrity-Check fehlen) |
| P7 — Scheduled/Auto-Discovery | "neu" | Auto-VOD-Download-Poller + Live-Status-Batch-Poller schon implementiert | ~30% (Top-Clip-Crawler, Follow-Sync fehlen) |
**Konsequenz:** Reihenfolge der Plane orientiert sich am Restarbeits-Volumen und Dependencies.
---
## Execution Order
```
Plan 01: Foundation — Vitest + Pure-Utility-Extraction [START HIER]
└─> Plan 02: Domain Modules (twitch-api, config, queue split)
└─> Plan 03: Domain Modules (download, record, cutter, stats)
└─> Plan 04: Final main.ts Split (ipc + index.ts + delete old main.ts)
└─> Plan 05: SQLite Migration (P3)
└─> Plan 06: OAuth (P2)
├─> Plan 07: Live-Recording Polish + Sub-only (P1)
├─> Plan 08: Auto-Discovery Erweiterung (P7)
└─> Plan 09: UI Power-Features (P5)
└─> Plan 10: Smart-Resume Finalisierung (P6)
└─> Plan 11: v5.0.0-rc.1 → GA
```
Plan 01-04 = Pillar 4 (Architektur-Split, monolithische Aufgabe in 4 Teile gesplittet).
Plan 05 = Pillar 3.
Plan 06 = Pillar 2.
Plan 07-09 = Pillars 1, 7, 5 parallelisierbar (verschiedene Subsysteme).
Plan 10 = Pillar 6.
Plan 11 = Release-Phase.
---
## Plan Status
| # | Plan | Datei | Status |
|---|---|---|---|
| 01 | Foundation: Vitest + Pure-Utility-Extraction | `tasks/v5.0.0-plan-01-foundation.md` | bereit, wird ausgefuehrt |
| 02 | Domain Split Pt. 1 | `tasks/v5.0.0-plan-02-domain-pt1.md` | erstellt nach Plan 01 |
| 03 | Domain Split Pt. 2 | `tasks/v5.0.0-plan-03-domain-pt2.md` | nach Plan 02 |
| 04 | Final Architektur-Split | `tasks/v5.0.0-plan-04-final-split.md` | nach Plan 03 |
| 05 | SQLite-Migration | `tasks/v5.0.0-plan-05-sqlite.md` | nach Plan 04 |
| 06 | OAuth | `tasks/v5.0.0-plan-06-oauth.md` | nach Plan 05 |
| 07 | Live-Rec Polish + Sub-only | `tasks/v5.0.0-plan-07-live-rec.md` | nach Plan 06 |
| 08 | Auto-Discovery Erweiterung | `tasks/v5.0.0-plan-08-auto-disc.md` | nach Plan 06 |
| 09 | UI Power | `tasks/v5.0.0-plan-09-ui-power.md` | nach Plan 06 |
| 10 | Smart-Resume Final | `tasks/v5.0.0-plan-10-smart-resume.md` | nach Plan 09 |
| 11 | Release 5.0.0 GA | `tasks/v5.0.0-plan-11-release.md` | nach Plan 10 |
---
## Versionsstrategie
| Plan abgeschlossen | Version |
|---|---|
| Plan 01 | 5.0.0-alpha.0 (Foundation, opt-in via `next` channel) |
| Plan 02 | 5.0.0-alpha.1 |
| Plan 03 | 5.0.0-alpha.2 |
| Plan 04 | 5.0.0-alpha.3 |
| Plan 05 | 5.0.0-beta.0 (SQLite Migrator live, Breaking) |
| Plan 06 | 5.0.0-beta.1 (OAuth live) |
| Plan 07 + 08 + 09 | 5.0.0-beta.2, .3, .4 |
| Plan 10 | 5.0.0-rc.1 |
| Plan 11 | 5.0.0 (stable) |
Pro Plan: ein Release-Tag, Auto-Updater Channel `next`. 4.6-User bleiben auf `stable` und kriegen nur Bugfix-Patches der 4.6 Linie (sofern bestellt).

11
vitest.config.ts Normal file
View File

@ -0,0 +1,11 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
include: ['src/**/*.test.ts'],
globals: false,
reporters: ['default'],
clearMocks: true,
}
});