import { app, BrowserWindow, ipcMain, dialog, shell, nativeTheme } from 'electron'; import * as path from 'path'; import * as fs from 'fs'; import { spawn, ChildProcess, execSync, exec, spawnSync } from 'child_process'; import axios from 'axios'; import { autoUpdater } from 'electron-updater'; import { compareUpdateVersions, isNewerUpdateVersion, normalizeUpdateVersion } from './update-version-utils'; // ========================================== // CONFIG & CONSTANTS // ========================================== const APP_VERSION = app.getVersion(); const UPDATE_CHECK_URL = 'http://24-music.de/version.json'; const GITEA_BASE_URL = (process.env.GITEA_BASE_URL || 'https://git.24-music.de').replace(/\/+$/, ''); const GITEA_REPO_OWNER = process.env.GITEA_REPO_OWNER || 'Administrator'; const GITEA_REPO_NAME = process.env.GITEA_REPO_NAME || 'Twitch-VOD-Manager'; const GITEA_RELEASES_API_LATEST_URL = `${GITEA_BASE_URL}/api/v1/repos/${GITEA_REPO_OWNER}/${GITEA_REPO_NAME}/releases/latest`; const GITEA_RELEASES_DOWNLOAD_BASE_URL = `${GITEA_BASE_URL}/${GITEA_REPO_OWNER}/${GITEA_REPO_NAME}/releases/download`; // Paths const APPDATA_DIR = path.join(process.env.PROGRAMDATA || 'C:\\ProgramData', 'Twitch_VOD_Manager'); const CONFIG_FILE = path.join(APPDATA_DIR, 'config.json'); const QUEUE_FILE = path.join(APPDATA_DIR, 'download_queue.json'); const DEBUG_LOG_FILE = path.join(APPDATA_DIR, 'debug.log'); const TOOLS_DIR = path.join(APPDATA_DIR, 'tools'); const TOOLS_STREAMLINK_DIR = path.join(TOOLS_DIR, 'streamlink'); const TOOLS_FFMPEG_DIR = path.join(TOOLS_DIR, 'ffmpeg'); const DEFAULT_DOWNLOAD_PATH = path.join(app.getPath('desktop'), 'Twitch_VODs'); const DEFAULT_FILENAME_TEMPLATE_VOD = '{title}.mp4'; const DEFAULT_FILENAME_TEMPLATE_PARTS = '{date}_Part{part_padded}.mp4'; const DEFAULT_FILENAME_TEMPLATE_CLIP = '{date}_{part}.mp4'; const DEFAULT_METADATA_CACHE_MINUTES = 10; const DEFAULT_PERFORMANCE_MODE: PerformanceMode = 'balanced'; const QUEUE_SAVE_DEBOUNCE_MS = 250; const MIN_FREE_DISK_BYTES = 128 * 1024 * 1024; const TOOL_PATH_REFRESH_TTL_MS = 10 * 1000; const DEBUG_LOG_FLUSH_INTERVAL_MS = 1000; const DEBUG_LOG_BUFFER_FLUSH_LINES = 48; const DEBUG_LOG_READ_TAIL_BYTES = 512 * 1024; const DEBUG_LOG_MAX_BYTES = 8 * 1024 * 1024; const DEBUG_LOG_TRIM_TO_BYTES = 4 * 1024 * 1024; const AUTO_UPDATE_CHECK_INTERVAL_MS = 10 * 60 * 1000; const AUTO_UPDATE_STARTUP_CHECK_DELAY_MS = 5000; const AUTO_UPDATE_MIN_CHECK_GAP_MS = 45 * 1000; const AUTO_UPDATE_AUTO_DOWNLOAD = false; const AUTO_UPDATE_CHECK_TIMEOUT_MS = 30 * 1000; const CACHE_CLEANUP_INTERVAL_MS = 60 * 1000; const MAX_LOGIN_TO_USER_ID_CACHE_ENTRIES = 4096; const MAX_VOD_LIST_CACHE_ENTRIES = 512; const MAX_CLIP_INFO_CACHE_ENTRIES = 4096; // Timeouts const API_TIMEOUT = 10000; 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'; // Ensure directories exist if (!fs.existsSync(APPDATA_DIR)) { fs.mkdirSync(APPDATA_DIR, { recursive: true }); } // ========================================== // INTERFACES // ========================================== interface Config { client_id: string; client_secret: string; download_path: string; streamers: string[]; theme: string; download_mode: 'parts' | 'full'; part_minutes: number; language: 'de' | 'en'; filename_template_vod: string; filename_template_parts: string; filename_template_clip: string; smart_queue_scheduler: boolean; performance_mode: PerformanceMode; prevent_duplicate_downloads: boolean; persist_queue_on_restart: boolean; metadata_cache_minutes: number; } interface RuntimeMetrics { cacheHits: number; cacheMisses: number; duplicateSkips: number; retriesScheduled: number; retriesExhausted: number; integrityFailures: number; downloadsStarted: number; downloadsCompleted: number; downloadsFailed: number; downloadedBytesTotal: number; lastSpeedBytesPerSec: number; avgSpeedBytesPerSec: number; activeItemId: string | null; activeItemTitle: string | null; lastErrorClass: RetryErrorClass | null; lastRetryDelaySeconds: number; } interface RuntimeMetricsSnapshot extends RuntimeMetrics { timestamp: string; queue: { pending: number; downloading: number; paused: number; completed: number; error: number; total: number; }; caches: { loginToUserId: number; vodList: number; clipInfo: number; }; config: { performanceMode: PerformanceMode; smartScheduler: boolean; metadataCacheMinutes: number; duplicatePrevention: boolean; }; } interface CacheEntry { value: T; expiresAt: number; } interface VOD { id: string; title: string; created_at: string; duration: string; thumbnail_url: string; url: string; view_count: number; stream_id: string; } interface CustomClip { startSec: number; durationSec: number; startPart: number; filenameFormat: 'simple' | 'timestamp' | 'template'; filenameTemplate?: string; } interface QueueItem { id: string; title: string; url: string; date: string; streamer: string; duration_str: string; status: 'pending' | 'downloading' | 'paused' | 'completed' | 'error'; progress: number; currentPart?: number; totalParts?: number; speed?: string; eta?: string; downloadedBytes?: number; totalBytes?: number; last_error?: string; customClip?: CustomClip; } interface DownloadResult { success: boolean; error?: string; } interface PreflightChecks { internet: boolean; streamlink: boolean; ffmpeg: boolean; ffprobe: boolean; downloadPathWritable: boolean; } interface PreflightResult { ok: boolean; autoFixApplied: boolean; checks: PreflightChecks; messages: string[]; timestamp: string; } interface DownloadProgress { id: string; progress: number; speed: string; speedBytesPerSec?: number; eta: string; status: string; currentPart?: number; totalParts?: number; downloadedBytes?: number; totalBytes?: number; } interface VideoInfo { duration: number; width: number; height: number; fps: number; } interface ReleaseUpdateInfo { tagName?: string; version?: string; releaseDate?: string; releaseName?: string; releaseNotes?: string; } // ========================================== // CONFIG MANAGEMENT // ========================================== const defaultConfig: Config = { client_id: '', client_secret: '', download_path: DEFAULT_DOWNLOAD_PATH, streamers: [], theme: 'twitch', download_mode: 'full', part_minutes: 120, language: 'en', filename_template_vod: DEFAULT_FILENAME_TEMPLATE_VOD, filename_template_parts: DEFAULT_FILENAME_TEMPLATE_PARTS, filename_template_clip: DEFAULT_FILENAME_TEMPLATE_CLIP, smart_queue_scheduler: true, performance_mode: DEFAULT_PERFORMANCE_MODE, prevent_duplicate_downloads: true, persist_queue_on_restart: true, metadata_cache_minutes: DEFAULT_METADATA_CACHE_MINUTES }; 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 { return { ...input, filename_template_vod: normalizeFilenameTemplate(input.filename_template_vod, DEFAULT_FILENAME_TEMPLATE_VOD), filename_template_parts: normalizeFilenameTemplate(input.filename_template_parts, DEFAULT_FILENAME_TEMPLATE_PARTS), filename_template_clip: normalizeFilenameTemplate(input.filename_template_clip, DEFAULT_FILENAME_TEMPLATE_CLIP), smart_queue_scheduler: input.smart_queue_scheduler !== false, performance_mode: normalizePerformanceMode(input.performance_mode), prevent_duplicate_downloads: input.prevent_duplicate_downloads !== false, persist_queue_on_restart: input.persist_queue_on_restart !== false, metadata_cache_minutes: normalizeMetadataCacheMinutes(input.metadata_cache_minutes) }; } function loadConfig(): Config { try { if (fs.existsSync(CONFIG_FILE)) { const data = fs.readFileSync(CONFIG_FILE, 'utf-8'); return normalizeConfigTemplates({ ...defaultConfig, ...JSON.parse(data) }); } } catch (e) { console.error('Error loading config:', e); } return normalizeConfigTemplates(defaultConfig); } function saveConfig(config: Config): void { try { fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2)); } catch (e) { console.error('Error saving config:', e); } } // ========================================== // QUEUE MANAGEMENT // ========================================== function loadQueue(): QueueItem[] { if (config.persist_queue_on_restart === false) { return []; } try { if (fs.existsSync(QUEUE_FILE)) { const data = fs.readFileSync(QUEUE_FILE, 'utf-8'); return JSON.parse(data); } } catch (e) { console.error('Error loading queue:', e); } return []; } let queueSaveTimer: NodeJS.Timeout | null = null; let pendingQueueSnapshot: QueueItem[] | null = null; function clearQueueFileFromDisk(): void { try { if (fs.existsSync(QUEUE_FILE)) { fs.unlinkSync(QUEUE_FILE); } } catch (e) { console.error('Error clearing queue file:', e); } } function writeQueueToDisk(queue: QueueItem[]): void { if (config.persist_queue_on_restart === false) { clearQueueFileFromDisk(); return; } try { fs.writeFileSync(QUEUE_FILE, JSON.stringify(queue, null, 2)); } catch (e) { console.error('Error saving queue:', e); } } function saveQueue(queue: QueueItem[], force = false): void { if (config.persist_queue_on_restart === false) { pendingQueueSnapshot = null; if (queueSaveTimer) { clearTimeout(queueSaveTimer); queueSaveTimer = null; } clearQueueFileFromDisk(); return; } pendingQueueSnapshot = queue; if (force) { if (queueSaveTimer) { clearTimeout(queueSaveTimer); queueSaveTimer = null; } writeQueueToDisk(pendingQueueSnapshot); pendingQueueSnapshot = null; return; } if (queueSaveTimer) { return; } queueSaveTimer = setTimeout(() => { queueSaveTimer = null; if (pendingQueueSnapshot) { writeQueueToDisk(pendingQueueSnapshot); pendingQueueSnapshot = null; } }, QUEUE_SAVE_DEBOUNCE_MS); } function flushQueueSave(): void { if (pendingQueueSnapshot) { saveQueue(pendingQueueSnapshot, true); } else { saveQueue(downloadQueue, true); } } // ========================================== // GLOBAL STATE // ========================================== let mainWindow: BrowserWindow | null = null; let config = loadConfig(); let accessToken: string | null = null; let downloadQueue: QueueItem[] = loadQueue(); let queueIdCounter = 0; let lastQueueBroadcastFingerprint = ''; let isDownloading = false; let currentProcess: ChildProcess | null = null; let currentDownloadCancelled = false; let pauseRequested = false; let activeQueueItemId: string | null = null; let downloadStartTime = 0; let downloadedBytes = 0; const userIdLoginCache = new Map(); const loginToUserIdCache = new Map>(); const vodListCache = new Map>(); const clipInfoCache = new Map>(); const inFlightUserIdRequests = new Map>(); const inFlightVodRequests = new Map>(); const inFlightClipRequests = new Map>(); let cacheCleanupTimer: NodeJS.Timeout | null = null; const runtimeMetrics: RuntimeMetrics = { cacheHits: 0, cacheMisses: 0, duplicateSkips: 0, retriesScheduled: 0, retriesExhausted: 0, integrityFailures: 0, downloadsStarted: 0, downloadsCompleted: 0, downloadsFailed: 0, downloadedBytesTotal: 0, lastSpeedBytesPerSec: 0, avgSpeedBytesPerSec: 0, activeItemId: null, activeItemTitle: null, lastErrorClass: null, lastRetryDelaySeconds: 0 }; let streamlinkCommandCache: { command: string; prefixArgs: string[] } | null = null; let bundledStreamlinkPath: string | null = null; let bundledFFmpegPath: string | null = null; let bundledFFprobePath: string | null = null; let streamlinkPathCache: string | null = null; let ffmpegPathCache: string | null = null; let ffprobePathCache: string | null = null; let verifiedStreamlinkCommandKey: string | null = null; let verifiedFfmpegCommandKey: string | null = null; let bundledToolPathSignature = ''; let bundledToolPathRefreshedAt = 0; let debugLogFlushTimer: NodeJS.Timeout | null = null; let pendingDebugLogLines: string[] = []; let autoUpdaterInitialized = false; let autoUpdateCheckTimer: NodeJS.Timeout | null = null; let autoUpdateStartupTimer: NodeJS.Timeout | null = null; let autoUpdateCheckInProgress = false; let autoUpdateReadyToInstall = false; let autoUpdateDownloadInProgress = false; let lastAutoUpdateCheckAt = 0; let latestKnownUpdateVersion: string | null = null; let downloadedUpdateVersion: string | null = null; let latestReleaseUpdateInfo: ReleaseUpdateInfo | null = null; let twitchLoginInFlight: Promise | null = null; // ========================================== // TOOL PATHS // ========================================== function getStreamlinkPath(): string { if (streamlinkPathCache) { if (streamlinkPathCache === 'streamlink' || fs.existsSync(streamlinkPathCache)) { return streamlinkPathCache; } streamlinkPathCache = null; } if (bundledStreamlinkPath && fs.existsSync(bundledStreamlinkPath)) { streamlinkPathCache = bundledStreamlinkPath; return streamlinkPathCache; } try { if (process.platform === 'win32') { const result = execSync('where streamlink', { encoding: 'utf-8' }); const paths = result.trim().split('\n'); if (paths.length > 0) { streamlinkPathCache = paths[0].trim(); return streamlinkPathCache; } } else { const result = execSync('which streamlink', { encoding: 'utf-8' }); streamlinkPathCache = result.trim(); return streamlinkPathCache; } } catch { } const commonPaths = [ 'C:\\Program Files\\Streamlink\\bin\\streamlink.exe', 'C:\\Program Files (x86)\\Streamlink\\bin\\streamlink.exe', path.join(process.env.LOCALAPPDATA || '', 'Programs', 'Streamlink', 'bin', 'streamlink.exe') ]; for (const p of commonPaths) { if (fs.existsSync(p)) { streamlinkPathCache = p; return streamlinkPathCache; } } streamlinkPathCache = 'streamlink'; return streamlinkPathCache; } function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } function isDownloadPathWritable(targetPath: string): boolean { try { fs.mkdirSync(targetPath, { recursive: true }); const probeFile = path.join(targetPath, `.write_test_${Date.now()}.tmp`); fs.writeFileSync(probeFile, 'ok'); fs.unlinkSync(probeFile); return true; } catch { return false; } } async function hasInternetConnection(): Promise { try { const res = await axios.get('https://id.twitch.tv/oauth2/validate', { timeout: 5000, validateStatus: () => true }); return res.status > 0; } catch { return false; } } async function runPreflight(autoFix = false): Promise { appendDebugLog('preflight-start', { autoFix }); refreshBundledToolPaths(); const checks: PreflightChecks = { internet: await hasInternetConnection(), streamlink: false, ffmpeg: false, ffprobe: false, downloadPathWritable: isDownloadPathWritable(config.download_path) }; if (autoFix) { await ensureStreamlinkInstalled(); await ensureFfmpegInstalled(); refreshBundledToolPaths(true); } const streamlinkCmd = getStreamlinkCommand(); checks.streamlink = canExecuteCommand(streamlinkCmd.command, [...streamlinkCmd.prefixArgs, '--version']); if (checks.streamlink) { cacheVerifiedStreamlinkCommand(streamlinkCmd.command, [...streamlinkCmd.prefixArgs, '--version']); } const ffmpegPath = getFFmpegPath(); const ffprobePath = getFFprobePath(); checks.ffmpeg = canExecuteCommand(ffmpegPath, ['-version']); checks.ffprobe = canExecuteCommand(ffprobePath, ['-version']); if (checks.ffmpeg && checks.ffprobe) { cacheVerifiedFfmpegCommands(ffmpegPath, ffprobePath); } const messages: string[] = []; if (!checks.internet) messages.push('Keine Internetverbindung erkannt.'); if (!checks.streamlink) messages.push('Streamlink fehlt oder ist nicht startbar.'); if (!checks.ffmpeg) messages.push('FFmpeg fehlt oder ist nicht startbar.'); if (!checks.ffprobe) messages.push('FFprobe fehlt oder ist nicht startbar.'); if (!checks.downloadPathWritable) messages.push('Download-Ordner ist nicht beschreibbar.'); const result: PreflightResult = { ok: messages.length === 0, autoFixApplied: autoFix, checks, messages, timestamp: new Date().toISOString() }; appendDebugLog('preflight-finished', result); return result; } function flushPendingDebugLogLines(): void { if (!pendingDebugLogLines.length) { return; } try { const payload = pendingDebugLogLines.join(''); pendingDebugLogLines = []; fs.appendFileSync(DEBUG_LOG_FILE, payload); trimDebugLogFileIfNeeded(); } catch { // ignore debug log errors } } function trimDebugLogFileIfNeeded(): void { try { if (!fs.existsSync(DEBUG_LOG_FILE)) { return; } const stats = fs.statSync(DEBUG_LOG_FILE); if (stats.size <= DEBUG_LOG_MAX_BYTES) { return; } const bytesToKeep = Math.min(DEBUG_LOG_TRIM_TO_BYTES, stats.size); const startOffset = Math.max(0, stats.size - bytesToKeep); const buffer = Buffer.allocUnsafe(bytesToKeep); let fileHandle: number | null = null; try { fileHandle = fs.openSync(DEBUG_LOG_FILE, 'r'); fs.readSync(fileHandle, buffer, 0, bytesToKeep, startOffset); } finally { if (fileHandle !== null) { fs.closeSync(fileHandle); } } const firstLineBreak = buffer.indexOf(0x0a); const trimmed = firstLineBreak >= 0 && firstLineBreak + 1 < buffer.length ? buffer.subarray(firstLineBreak + 1) : buffer; fs.writeFileSync(DEBUG_LOG_FILE, trimmed); } catch { // ignore debug log errors } } function readDebugLogTailFromDisk(): string { const stats = fs.statSync(DEBUG_LOG_FILE); if (stats.size <= 0) { return ''; } const bytesToRead = Math.min(stats.size, DEBUG_LOG_READ_TAIL_BYTES); if (bytesToRead === stats.size) { return fs.readFileSync(DEBUG_LOG_FILE, 'utf-8'); } const buffer = Buffer.allocUnsafe(bytesToRead); let fileHandle: number | null = null; try { fileHandle = fs.openSync(DEBUG_LOG_FILE, 'r'); fs.readSync(fileHandle, buffer, 0, bytesToRead, stats.size - bytesToRead); } finally { if (fileHandle !== null) { fs.closeSync(fileHandle); } } const firstLineBreak = buffer.indexOf(0x0a); const slice = firstLineBreak >= 0 && firstLineBreak + 1 < buffer.length ? buffer.subarray(firstLineBreak + 1) : buffer; return slice.toString('utf-8'); } function startDebugLogFlushTimer(): void { if (debugLogFlushTimer) { return; } debugLogFlushTimer = setInterval(() => { flushPendingDebugLogLines(); }, DEBUG_LOG_FLUSH_INTERVAL_MS); debugLogFlushTimer.unref?.(); } function stopDebugLogFlushTimer(flush = true): void { if (debugLogFlushTimer) { clearInterval(debugLogFlushTimer); debugLogFlushTimer = null; } if (flush) { flushPendingDebugLogLines(); } } function readDebugLog(lines = 200): string { try { flushPendingDebugLogLines(); if (!fs.existsSync(DEBUG_LOG_FILE)) { return 'Debug-Log ist leer.'; } const text = readDebugLogTailFromDisk(); const rows = text.split(/\r?\n/).filter(Boolean); return rows.slice(-lines).join('\n') || 'Debug-Log ist leer.'; } catch (e) { return `Debug-Log konnte nicht gelesen werden: ${String(e)}`; } } function canExecute(cmd: string): boolean { try { execSync(cmd, { stdio: 'ignore', windowsHide: true }); return true; } catch { return false; } } function canExecuteCommand(command: string, args: string[]): boolean { try { const result = spawnSync(command, args, { stdio: 'ignore', windowsHide: true }); return result.status === 0; } catch { return false; } } function getCommandCacheKey(command: string, args: string[]): string { return [command, ...args].join('\u0000'); } function cacheVerifiedStreamlinkCommand(command: string, args: string[]): void { verifiedStreamlinkCommandKey = getCommandCacheKey(command, args); } function isVerifiedStreamlinkCommand(command: string, args: string[]): boolean { return verifiedStreamlinkCommandKey === getCommandCacheKey(command, args); } function cacheVerifiedFfmpegCommands(ffmpegCommand: string, ffprobeCommand: string): void { verifiedFfmpegCommandKey = getCommandCacheKey(ffmpegCommand, [ffprobeCommand]); } function isVerifiedFfmpegCommands(ffmpegCommand: string, ffprobeCommand: string): boolean { return verifiedFfmpegCommandKey === getCommandCacheKey(ffmpegCommand, [ffprobeCommand]); } function invalidateVerifiedToolCaches(): void { verifiedStreamlinkCommandKey = null; verifiedFfmpegCommandKey = null; } function findFileRecursive(rootDir: string, fileName: string): string | null { if (!fs.existsSync(rootDir)) return null; const entries = fs.readdirSync(rootDir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(rootDir, entry.name); if (entry.isFile() && entry.name.toLowerCase() === fileName.toLowerCase()) { return fullPath; } if (entry.isDirectory()) { const nested = findFileRecursive(fullPath, fileName); if (nested) return nested; } } return null; } function getDirectoryMtimeMs(directoryPath: string): number { try { return fs.statSync(directoryPath).mtimeMs; } catch { return 0; } } function refreshBundledToolPaths(force = false): void { const now = Date.now(); const signature = `${getDirectoryMtimeMs(TOOLS_STREAMLINK_DIR)}|${getDirectoryMtimeMs(TOOLS_FFMPEG_DIR)}`; if (!force && signature === bundledToolPathSignature && (now - bundledToolPathRefreshedAt) < TOOL_PATH_REFRESH_TTL_MS) { return; } bundledToolPathSignature = signature; bundledToolPathRefreshedAt = now; const nextBundledStreamlinkPath = findFileRecursive(TOOLS_STREAMLINK_DIR, process.platform === 'win32' ? 'streamlink.exe' : 'streamlink'); const nextBundledFFmpegPath = findFileRecursive(TOOLS_FFMPEG_DIR, process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'); const nextBundledFFprobePath = findFileRecursive(TOOLS_FFMPEG_DIR, process.platform === 'win32' ? 'ffprobe.exe' : 'ffprobe'); const changed = nextBundledStreamlinkPath !== bundledStreamlinkPath || nextBundledFFmpegPath !== bundledFFmpegPath || nextBundledFFprobePath !== bundledFFprobePath; bundledStreamlinkPath = nextBundledStreamlinkPath; bundledFFmpegPath = nextBundledFFmpegPath; bundledFFprobePath = nextBundledFFprobePath; if (changed) { streamlinkPathCache = null; ffmpegPathCache = null; ffprobePathCache = null; streamlinkCommandCache = null; invalidateVerifiedToolCaches(); } } async function downloadFile(url: string, destinationPath: string): Promise { try { const response = await axios.get(url, { responseType: 'stream', timeout: 120000 }); await new Promise((resolve, reject) => { const writer = fs.createWriteStream(destinationPath); response.data.pipe(writer); writer.on('finish', () => resolve()); writer.on('error', (err) => reject(err)); }); return true; } catch (e) { appendDebugLog('download-file-failed', { url, destinationPath, error: String(e) }); return false; } } async function extractZip(zipPath: string, destinationDir: string): Promise { try { fs.mkdirSync(destinationDir, { recursive: true }); const command = `Expand-Archive -Path '${zipPath.replace(/'/g, "''")}' -DestinationPath '${destinationDir.replace(/'/g, "''")}' -Force`; await new Promise((resolve, reject) => { const proc = spawn('powershell', [ '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', command ], { windowsHide: true }); let stderr = ''; proc.stderr?.on('data', (data) => { stderr += data.toString(); }); proc.on('close', (code) => { if (code === 0) { resolve(); } else { reject(new Error(`Expand-Archive exit code ${code}: ${stderr.trim()}`)); } }); proc.on('error', (err) => reject(err)); }); return true; } catch (e) { appendDebugLog('extract-zip-failed', { zipPath, destinationDir, error: String(e) }); return false; } } async function ensureStreamlinkInstalled(): Promise { refreshBundledToolPaths(); const current = getStreamlinkCommand(); const versionArgs = [...current.prefixArgs, '--version']; if (isVerifiedStreamlinkCommand(current.command, versionArgs)) { return true; } if (canExecuteCommand(current.command, versionArgs)) { cacheVerifiedStreamlinkCommand(current.command, versionArgs); return true; } if (process.platform !== 'win32') { return false; } appendDebugLog('streamlink-install-start'); try { fs.mkdirSync(TOOLS_STREAMLINK_DIR, { recursive: true }); const release = await axios.get('https://api.github.com/repos/streamlink/windows-builds/releases/latest', { timeout: 120000, headers: { 'Accept': 'application/vnd.github+json', 'User-Agent': 'Twitch-VOD-Manager' } }); const assets = release.data?.assets || []; const zipAsset = assets.find((a: any) => typeof a?.name === 'string' && /x86_64\.zip$/i.test(a.name)); if (!zipAsset?.browser_download_url) { appendDebugLog('streamlink-install-no-asset-found'); return false; } const zipPath = path.join(app.getPath('temp'), `streamlink_portable_${Date.now()}.zip`); const downloadOk = await downloadFile(zipAsset.browser_download_url, zipPath); if (!downloadOk) return false; fs.rmSync(TOOLS_STREAMLINK_DIR, { recursive: true, force: true }); fs.mkdirSync(TOOLS_STREAMLINK_DIR, { recursive: true }); const extractOk = await extractZip(zipPath, TOOLS_STREAMLINK_DIR); try { fs.unlinkSync(zipPath); } catch { } if (!extractOk) return false; refreshBundledToolPaths(true); streamlinkCommandCache = null; const cmd = getStreamlinkCommand(); const installedVersionArgs = [...cmd.prefixArgs, '--version']; const works = canExecuteCommand(cmd.command, installedVersionArgs); if (works) { cacheVerifiedStreamlinkCommand(cmd.command, installedVersionArgs); } appendDebugLog('streamlink-install-finished', { works, command: cmd.command, prefixArgs: cmd.prefixArgs }); return works; } catch (e) { appendDebugLog('streamlink-install-failed', String(e)); return false; } } async function ensureFfmpegInstalled(): Promise { refreshBundledToolPaths(); const ffmpegPath = getFFmpegPath(); const ffprobePath = getFFprobePath(); if (isVerifiedFfmpegCommands(ffmpegPath, ffprobePath)) { return true; } if (canExecuteCommand(ffmpegPath, ['-version']) && canExecuteCommand(ffprobePath, ['-version'])) { cacheVerifiedFfmpegCommands(ffmpegPath, ffprobePath); return true; } if (process.platform !== 'win32') { return false; } appendDebugLog('ffmpeg-install-start'); try { fs.mkdirSync(TOOLS_FFMPEG_DIR, { recursive: true }); const zipPath = path.join(app.getPath('temp'), `ffmpeg_essentials_${Date.now()}.zip`); const downloadOk = await downloadFile('https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip', zipPath); if (!downloadOk) return false; fs.rmSync(TOOLS_FFMPEG_DIR, { recursive: true, force: true }); fs.mkdirSync(TOOLS_FFMPEG_DIR, { recursive: true }); const extractOk = await extractZip(zipPath, TOOLS_FFMPEG_DIR); try { fs.unlinkSync(zipPath); } catch { } if (!extractOk) return false; refreshBundledToolPaths(true); const newFfmpegPath = getFFmpegPath(); const newFfprobePath = getFFprobePath(); const works = canExecuteCommand(newFfmpegPath, ['-version']) && canExecuteCommand(newFfprobePath, ['-version']); if (works) { cacheVerifiedFfmpegCommands(newFfmpegPath, newFfprobePath); } appendDebugLog('ffmpeg-install-finished', { works, ffmpeg: newFfmpegPath, ffprobe: newFfprobePath }); return works; } catch (e) { appendDebugLog('ffmpeg-install-failed', String(e)); return false; } } function getStreamlinkCommand(): { command: string; prefixArgs: string[] } { if (streamlinkCommandCache) { return streamlinkCommandCache; } const directPath = getStreamlinkPath(); if (directPath !== 'streamlink' || canExecute('streamlink --version')) { streamlinkCommandCache = { command: directPath, prefixArgs: [] }; return streamlinkCommandCache; } if (process.platform === 'win32') { if (canExecute('py -3 -m streamlink --version')) { streamlinkCommandCache = { command: 'py', prefixArgs: ['-3', '-m', 'streamlink'] }; return streamlinkCommandCache; } if (canExecute('python -m streamlink --version')) { streamlinkCommandCache = { command: 'python', prefixArgs: ['-m', 'streamlink'] }; return streamlinkCommandCache; } } else { if (canExecute('python3 -m streamlink --version')) { streamlinkCommandCache = { command: 'python3', prefixArgs: ['-m', 'streamlink'] }; return streamlinkCommandCache; } if (canExecute('python -m streamlink --version')) { streamlinkCommandCache = { command: 'python', prefixArgs: ['-m', 'streamlink'] }; return streamlinkCommandCache; } } streamlinkCommandCache = { command: directPath, prefixArgs: [] }; return streamlinkCommandCache; } function getFFmpegPath(): string { if (ffmpegPathCache) { if (ffmpegPathCache === 'ffmpeg' || fs.existsSync(ffmpegPathCache)) { return ffmpegPathCache; } ffmpegPathCache = null; } if (bundledFFmpegPath && fs.existsSync(bundledFFmpegPath)) { ffmpegPathCache = bundledFFmpegPath; return ffmpegPathCache; } try { if (process.platform === 'win32') { const result = execSync('where ffmpeg', { encoding: 'utf-8' }); const paths = result.trim().split('\n'); if (paths.length > 0) { ffmpegPathCache = paths[0].trim(); return ffmpegPathCache; } } else { const result = execSync('which ffmpeg', { encoding: 'utf-8' }); ffmpegPathCache = result.trim(); return ffmpegPathCache; } } catch { } const commonPaths = [ 'C:\\ffmpeg\\bin\\ffmpeg.exe', 'C:\\Program Files\\ffmpeg\\bin\\ffmpeg.exe', path.join(process.env.LOCALAPPDATA || '', 'Programs', 'ffmpeg', 'bin', 'ffmpeg.exe') ]; for (const p of commonPaths) { if (fs.existsSync(p)) { ffmpegPathCache = p; return ffmpegPathCache; } } ffmpegPathCache = 'ffmpeg'; return ffmpegPathCache; } function getFFprobePath(): string { if (ffprobePathCache) { if (ffprobePathCache === 'ffprobe' || ffprobePathCache === 'ffprobe.exe' || fs.existsSync(ffprobePathCache)) { return ffprobePathCache; } ffprobePathCache = null; } if (bundledFFprobePath && fs.existsSync(bundledFFprobePath)) { ffprobePathCache = bundledFFprobePath; return ffprobePathCache; } const ffmpegPath = getFFmpegPath(); const ffprobeExe = process.platform === 'win32' ? 'ffprobe.exe' : 'ffprobe'; if (ffmpegPath === 'ffmpeg') { ffprobePathCache = ffprobeExe; return ffprobePathCache; } const derivedFfprobePath = path.join(path.dirname(ffmpegPath), ffprobeExe); if (fs.existsSync(derivedFfprobePath)) { ffprobePathCache = derivedFfprobePath; return ffprobePathCache; } ffprobePathCache = ffprobeExe; return ffprobePathCache; } function appendDebugLog(message: string, details?: unknown): void { try { const ts = new Date().toISOString(); const payload = details === undefined ? '' : ` | ${typeof details === 'string' ? details : JSON.stringify(details)}`; pendingDebugLogLines.push(`[${ts}] ${message}${payload}\n`); if (pendingDebugLogLines.length >= DEBUG_LOG_BUFFER_FLUSH_LINES) { flushPendingDebugLogLines(); } else { startDebugLogFlushTimer(); } } catch { // ignore debug log errors } } // ========================================== // 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 { 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 { 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 sanitizeFilenamePart(input: string, fallback = 'unnamed'): string { const cleaned = (input || '') .replace(/[<>:"|?*\x00-\x1f]/g, '_') .replace(/[\\/]/g, '_') .trim(); return cleaned || fallback; } function formatDateWithPattern(date: Date, pattern: string): string { const tokenMap: Record = { yyyy: date.getFullYear().toString(), yy: date.getFullYear().toString().slice(-2), MM: (date.getMonth() + 1).toString().padStart(2, '0'), M: (date.getMonth() + 1).toString(), dd: date.getDate().toString().padStart(2, '0'), d: date.getDate().toString(), HH: date.getHours().toString().padStart(2, '0'), H: date.getHours().toString(), hh: date.getHours().toString().padStart(2, '0'), h: date.getHours().toString(), mm: date.getMinutes().toString().padStart(2, '0'), m: date.getMinutes().toString(), ss: date.getSeconds().toString().padStart(2, '0'), s: date.getSeconds().toString() }; return pattern .replace(/yyyy|yy|MM|M|dd|d|HH|H|hh|h|mm|m|ss|s/g, (token) => tokenMap[token] ?? token) .replace(/\\(.)/g, '$1'); } function formatSecondsWithPattern(totalSeconds: number, pattern: string): string { const safe = Math.max(0, Math.floor(totalSeconds)); const hours = Math.floor(safe / 3600); const minutes = Math.floor((safe % 3600) / 60); const seconds = safe % 60; const tokenMap: Record = { HH: hours.toString().padStart(2, '0'), H: hours.toString(), hh: hours.toString().padStart(2, '0'), h: hours.toString(), mm: minutes.toString().padStart(2, '0'), m: minutes.toString(), ss: seconds.toString().padStart(2, '0'), s: seconds.toString() }; return pattern .replace(/HH|H|hh|h|mm|m|ss|s/g, (token) => tokenMap[token] ?? token) .replace(/\\(.)/g, '$1'); } function parseVodId(url: string): string { const match = url.match(/videos\/(\d+)/i); return match?.[1] || ''; } function isLikelyVodUrl(url: string): boolean { return /twitch\.tv\/videos\/\d+/i.test(url || ''); } function parseFrameRate(rawFrameRate: string | undefined): number { const fallback = 30; const value = (rawFrameRate || '').trim(); if (!value) return fallback; if (/^\d+(\.\d+)?$/.test(value)) { const numeric = Number(value); return Number.isFinite(numeric) && numeric > 0 ? numeric : fallback; } const ratio = value.match(/^(\d+(?:\.\d+)?)\/(\d+(?:\.\d+)?)$/); if (!ratio) return fallback; const numerator = Number(ratio[1]); const denominator = Number(ratio[2]); if (!Number.isFinite(numerator) || !Number.isFinite(denominator) || denominator <= 0) { return fallback; } const fps = numerator / denominator; return Number.isFinite(fps) && fps > 0 ? fps : fallback; } interface ClipTemplateContext { template: string; title: string; vodId: string; channel: string; date: Date; part: number; partPadded: string; trimStartSec: number; trimEndSec: number; trimLengthSec: number; fullLengthSec: number; } function renderClipFilenameTemplate(context: ClipTemplateContext): string { const baseDate = `${context.date.getDate().toString().padStart(2, '0')}.${(context.date.getMonth() + 1).toString().padStart(2, '0')}.${context.date.getFullYear()}`; let rendered = context.template .replace(/\{title\}/g, sanitizeFilenamePart(context.title, 'untitled')) .replace(/\{id\}/g, sanitizeFilenamePart(context.vodId, 'unknown')) .replace(/\{channel\}/g, sanitizeFilenamePart(context.channel, 'unknown')) .replace(/\{channel_id\}/g, '') .replace(/\{date\}/g, baseDate) .replace(/\{part\}/g, String(context.part)) .replace(/\{part_padded\}/g, context.partPadded) .replace(/\{trim_start\}/g, formatDurationDashed(context.trimStartSec)) .replace(/\{trim_end\}/g, formatDurationDashed(context.trimEndSec)) .replace(/\{trim_length\}/g, formatDurationDashed(context.trimLengthSec)) .replace(/\{length\}/g, formatDurationDashed(context.fullLengthSec)) .replace(/\{ext\}/g, 'mp4') .replace(/\{random_string\}/g, Math.random().toString(36).slice(2, 10)); rendered = rendered.replace(/\{date_custom="(.*?)"\}/g, (_, pattern: string) => { return sanitizeFilenamePart(formatDateWithPattern(context.date, pattern), 'date'); }); rendered = rendered.replace(/\{trim_start_custom="(.*?)"\}/g, (_, pattern: string) => { return sanitizeFilenamePart(formatSecondsWithPattern(context.trimStartSec, pattern), '00-00-00'); }); rendered = rendered.replace(/\{trim_end_custom="(.*?)"\}/g, (_, pattern: string) => { return sanitizeFilenamePart(formatSecondsWithPattern(context.trimEndSec, pattern), '00-00-00'); }); rendered = rendered.replace(/\{trim_length_custom="(.*?)"\}/g, (_, pattern: string) => { return sanitizeFilenamePart(formatSecondsWithPattern(context.trimLengthSec, pattern), '00-00-00'); }); rendered = rendered.replace(/\{length_custom="(.*?)"\}/g, (_, pattern: string) => { return sanitizeFilenamePart(formatSecondsWithPattern(context.fullLengthSec, pattern), '00-00-00'); }); const parts = rendered .split(/[\\/]+/) .map((segment) => sanitizeFilenamePart(segment, 'unnamed')) .filter((segment) => segment !== '.' && segment !== '..'); if (parts.length === 0) { return 'clip.mp4'; } const lastIdx = parts.length - 1; if (!/\.[A-Za-z0-9]{1,8}$/.test(parts[lastIdx])) { parts[lastIdx] = `${parts[lastIdx]}.mp4`; } return path.join(...parts); } function formatBytes(bytes: number): string { if (bytes < 1024) return bytes + ' B'; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB'; } function formatSpeed(bytesPerSec: number): string { if (bytesPerSec < 1024) return bytesPerSec.toFixed(0) + ' B/s'; if (bytesPerSec < 1024 * 1024) return (bytesPerSec / 1024).toFixed(1) + ' KB/s'; return (bytesPerSec / (1024 * 1024)).toFixed(1) + ' MB/s'; } function formatETA(seconds: number): string { if (seconds < 60) return `${Math.floor(seconds)}s`; if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${Math.floor(seconds % 60)}s`; const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); return `${h}h ${m}m`; } function getFreeDiskBytes(targetPath: string): number | null { try { const statfsSync = (fs as unknown as { statfsSync?: (path: string) => { bsize?: number; frsize?: number; bavail?: number } }).statfsSync; if (!statfsSync) { return null; } const info = statfsSync(targetPath); const blockSize = Number(info?.bsize || info?.frsize || 0); const availableBlocks = Number(info?.bavail || 0); if (!Number.isFinite(blockSize) || !Number.isFinite(availableBlocks) || blockSize <= 0 || availableBlocks < 0) { return null; } return Math.floor(blockSize * availableBlocks); } catch { return null; } } function estimateRequiredDownloadBytes(item: QueueItem): number { const durationSeconds = Math.max(1, item.customClip?.durationSec || parseDuration(item.duration_str || '0s')); const bytesPerSecondByMode: Record = { stability: 900 * 1024, balanced: 700 * 1024, speed: 550 * 1024 }; const mode = normalizePerformanceMode(config.performance_mode); const baseEstimate = durationSeconds * bytesPerSecondByMode[mode]; const withHeadroom = Math.ceil(baseEstimate * (item.customClip ? 1.2 : 1.35)); return Math.max(64 * 1024 * 1024, Math.min(withHeadroom, 40 * 1024 * 1024 * 1024)); } function ensureDiskSpace(targetPath: string, requiredBytes: number, context: string): DownloadResult { const freeBytes = getFreeDiskBytes(targetPath); if (freeBytes === null) { appendDebugLog('disk-space-check-skipped', { targetPath, requiredBytes, context }); return { success: true }; } if (freeBytes < Math.max(requiredBytes, MIN_FREE_DISK_BYTES)) { const message = `Zu wenig Speicherplatz fur ${context}: frei ${formatBytes(freeBytes)}, benoetigt ~${formatBytes(requiredBytes)}.`; appendDebugLog('disk-space-check-failed', { targetPath, requiredBytes, freeBytes, context }); return { success: false, error: message }; } return { success: true }; } function getMetadataCacheTtlMs(): number { return normalizeMetadataCacheMinutes(config.metadata_cache_minutes) * 60 * 1000; } function getCachedValue(cache: Map>, key: string): T | undefined { const cached = cache.get(key); if (!cached) { return undefined; } if (cached.expiresAt <= Date.now()) { cache.delete(key); return undefined; } cache.delete(key); cache.set(key, cached); return cached.value; } function pruneExpiredCacheEntries(cache: Map>): number { const now = Date.now(); let removed = 0; for (const [key, entry] of cache.entries()) { if (entry.expiresAt <= now) { cache.delete(key); removed += 1; } } return removed; } function enforceCacheEntryLimit(cache: Map>, maxEntries: number): number { if (maxEntries <= 0) { const removed = cache.size; cache.clear(); return removed; } let removed = 0; while (cache.size > maxEntries) { const oldest = cache.keys().next().value as string | undefined; if (!oldest) { break; } cache.delete(oldest); removed += 1; } return removed; } function setCachedValue( cache: Map>, key: string, value: T, maxEntries: number ): void { cache.set(key, { value, expiresAt: Date.now() + getMetadataCacheTtlMs() }); if (cache.size > maxEntries) { pruneExpiredCacheEntries(cache); enforceCacheEntryLimit(cache, maxEntries); } } function cleanupMetadataCaches(reason: 'interval' | 'manual' | 'shutdown'): void { const before = { loginToUserId: loginToUserIdCache.size, vodList: vodListCache.size, clipInfo: clipInfoCache.size }; const expired = { loginToUserId: pruneExpiredCacheEntries(loginToUserIdCache), vodList: pruneExpiredCacheEntries(vodListCache), clipInfo: pruneExpiredCacheEntries(clipInfoCache) }; const evicted = { loginToUserId: enforceCacheEntryLimit(loginToUserIdCache, MAX_LOGIN_TO_USER_ID_CACHE_ENTRIES), vodList: enforceCacheEntryLimit(vodListCache, MAX_VOD_LIST_CACHE_ENTRIES), clipInfo: enforceCacheEntryLimit(clipInfoCache, MAX_CLIP_INFO_CACHE_ENTRIES) }; const removedTotal = expired.loginToUserId + expired.vodList + expired.clipInfo + evicted.loginToUserId + evicted.vodList + evicted.clipInfo; if (removedTotal > 0) { appendDebugLog('metadata-cache-cleanup', { reason, before, after: { loginToUserId: loginToUserIdCache.size, vodList: vodListCache.size, clipInfo: clipInfoCache.size }, expired, evicted, removedTotal }); } } function clearMetadataCaches(): void { loginToUserIdCache.clear(); vodListCache.clear(); clipInfoCache.clear(); } function startMetadataCacheCleanup(): void { if (cacheCleanupTimer) { return; } cacheCleanupTimer = setInterval(() => { cleanupMetadataCaches('interval'); }, CACHE_CLEANUP_INTERVAL_MS); cacheCleanupTimer.unref?.(); } function stopMetadataCacheCleanup(): void { if (!cacheCleanupTimer) { return; } clearInterval(cacheCleanupTimer); cacheCleanupTimer = null; } function withInFlightDedup( store: Map>, key: string, factory: () => Promise ): Promise { const existing = store.get(key); if (existing) { return existing; } let requestPromise: Promise; requestPromise = factory().finally(() => { if (store.get(key) === requestPromise) { store.delete(key); } }); store.set(key, requestPromise); return requestPromise; } function getRetryAttemptLimit(): number { switch (normalizePerformanceMode(config.performance_mode)) { case 'stability': return 5; case 'speed': return 2; case 'balanced': default: return 3; } } function classifyDownloadError(errorMessage: string): RetryErrorClass { const text = (errorMessage || '').toLowerCase(); if (!text) return 'unknown'; if (text.includes('ungueltige vod-url') || text.includes('invalid vod url')) return 'validation'; if (text.includes('429') || text.includes('rate limit') || text.includes('too many requests')) return 'rate_limit'; if (text.includes('401') || text.includes('403') || text.includes('unauthorized') || text.includes('forbidden')) return 'auth'; if (text.includes('timed out') || text.includes('timeout') || text.includes('network') || text.includes('connection') || text.includes('dns')) return 'network'; if (text.includes('streamlink nicht gefunden') || text.includes('ffmpeg') || text.includes('ffprobe') || text.includes('enoent')) return 'tooling'; if (text.includes('integritaet') || text.includes('integrity') || text.includes('kein videostream')) return 'integrity'; if (text.includes('access denied') || text.includes('permission') || text.includes('disk') || text.includes('file') || text.includes('ordner')) return 'io'; return 'unknown'; } function getRetryDelaySeconds(errorClass: RetryErrorClass, attempt: number): number { const jitter = Math.floor(Math.random() * 3); switch (errorClass) { case 'rate_limit': return Math.min(45, 10 + attempt * 6 + jitter); case 'network': return Math.min(30, 4 * attempt + jitter); case 'auth': return Math.min(40, 8 + attempt * 5 + jitter); case 'integrity': return Math.min(20, 3 + attempt * 2 + jitter); case 'io': return Math.min(25, 5 + attempt * 3 + jitter); case 'tooling': return DEFAULT_RETRY_DELAY_SECONDS; case 'validation': return 0; case 'unknown': default: return Math.min(25, DEFAULT_RETRY_DELAY_SECONDS + attempt * 2 + jitter); } } function getQueueCounts(queueData: QueueItem[] = downloadQueue): RuntimeMetricsSnapshot['queue'] { const counts = { pending: 0, downloading: 0, paused: 0, completed: 0, error: 0, total: queueData.length }; for (const item of queueData) { if (item.status === 'pending') counts.pending += 1; else if (item.status === 'downloading') counts.downloading += 1; else if (item.status === 'paused') counts.paused += 1; else if (item.status === 'completed') counts.completed += 1; else if (item.status === 'error') counts.error += 1; } return counts; } function generateQueueItemId(): string { queueIdCounter = (queueIdCounter + 1) % 1000; return `${Date.now()}-${queueIdCounter}`; } function getQueueBroadcastFingerprint(queueData: QueueItem[] = downloadQueue): string { return queueData.map((item) => [ item.id, item.status, Math.round((Number(item.progress) || 0) * 10), item.currentPart || 0, item.totalParts || 0, item.speed || '', item.eta || '', item.last_error || '' ].join(':')).join('|'); } function emitQueueUpdated(force = false): void { const nextFingerprint = getQueueBroadcastFingerprint(downloadQueue); if (!force && nextFingerprint === lastQueueBroadcastFingerprint) { return; } lastQueueBroadcastFingerprint = nextFingerprint; mainWindow?.webContents.send('queue-updated', downloadQueue); } function hasQueueItemId(id: string): boolean { return downloadQueue.some((item) => item.id === id); } function getRuntimeMetricsSnapshot(): RuntimeMetricsSnapshot { return { ...runtimeMetrics, timestamp: new Date().toISOString(), queue: getQueueCounts(downloadQueue), caches: { loginToUserId: loginToUserIdCache.size, vodList: vodListCache.size, clipInfo: clipInfoCache.size }, config: { performanceMode: normalizePerformanceMode(config.performance_mode), smartScheduler: config.smart_queue_scheduler !== false, metadataCacheMinutes: normalizeMetadataCacheMinutes(config.metadata_cache_minutes), duplicatePrevention: config.prevent_duplicate_downloads !== false } }; } function normalizeQueueUrlForFingerprint(url: string): string { return (url || '').trim().toLowerCase().replace(/^https?:\/\/(www\.)?/, ''); } function getQueueItemFingerprint(item: Pick): string { const clip = item.customClip; const clipFingerprint = clip ? [ 'clip', clip.startSec, clip.durationSec, clip.startPart, clip.filenameFormat, (clip.filenameTemplate || '').trim().toLowerCase() ].join(':') : 'vod'; return [ normalizeQueueUrlForFingerprint(item.url), (item.streamer || '').trim().toLowerCase(), (item.date || '').trim(), clipFingerprint ].join('|'); } function isQueueItemActive(item: QueueItem): boolean { return item.status === 'pending' || item.status === 'downloading' || item.status === 'paused'; } function hasActiveDuplicate(candidate: Pick): boolean { const candidateFingerprint = getQueueItemFingerprint(candidate); return downloadQueue.some((existing) => { if (!isQueueItemActive(existing)) return false; return getQueueItemFingerprint(existing) === candidateFingerprint; }); } function getQueuePriorityScore(item: QueueItem): number { const now = Date.now(); const createdMs = Number(item.id) || now; const waitSeconds = Math.max(0, Math.floor((now - createdMs) / 1000)); const durationSeconds = Math.max(0, parseDuration(item.duration_str || '0s')); const clipBoost = item.customClip ? 1500 : 0; const shortJobBoost = Math.max(0, 7200 - Math.min(7200, durationSeconds)) / 5; const ageBoost = Math.min(waitSeconds, 1800) / 2; return clipBoost + shortJobBoost + ageBoost; } function pickNextPendingQueueItem(): QueueItem | null { const pendingItems = downloadQueue.filter((item) => item.status === 'pending'); if (!pendingItems.length) return null; if (!config.smart_queue_scheduler) { return pendingItems[0]; } let best = pendingItems[0]; let bestScore = getQueuePriorityScore(best); for (let i = 1; i < pendingItems.length; i += 1) { const candidate = pendingItems[i]; const score = getQueuePriorityScore(candidate); if (score > bestScore) { best = candidate; bestScore = score; } } return best; } function parseClockDurationSeconds(duration: string | null): number | null { if (!duration) return null; const parts = duration.split(':').map((part) => Number(part)); if (parts.length !== 3 || parts.some((part) => !Number.isFinite(part))) { return null; } return Math.max(0, Math.floor(parts[0] * 3600 + parts[1] * 60 + parts[2])); } function probeMediaFile(filePath: string): { durationSeconds: number; hasVideo: boolean } | null { try { const ffprobePath = getFFprobePath(); if (!canExecuteCommand(ffprobePath, ['-version'])) { return null; } const res = spawnSync(ffprobePath, [ '-v', 'error', '-print_format', 'json', '-show_format', '-show_streams', filePath ], { windowsHide: true, encoding: 'utf-8' }); if (res.status !== 0 || !res.stdout) { return null; } const parsed = JSON.parse(res.stdout) as { format?: { duration?: string }; streams?: Array<{ codec_type?: string }>; }; const durationSeconds = Number(parsed?.format?.duration || 0); const hasVideo = Boolean(parsed?.streams?.some((stream) => stream.codec_type === 'video')); return { durationSeconds: Number.isFinite(durationSeconds) ? durationSeconds : 0, hasVideo }; } catch { return null; } } function validateDownloadedFileIntegrity(filePath: string, expectedDurationSeconds: number | null): DownloadResult { const probed = probeMediaFile(filePath); if (!probed) { appendDebugLog('integrity-probe-skipped', { filePath }); return { success: true }; } if (!probed.hasVideo) { runtimeMetrics.integrityFailures += 1; return { success: false, error: 'Integritaetspruefung fehlgeschlagen: Kein Videostream gefunden.' }; } if (probed.durationSeconds <= 1) { runtimeMetrics.integrityFailures += 1; return { success: false, error: `Integritaetspruefung fehlgeschlagen: Dauer zu kurz (${probed.durationSeconds.toFixed(2)}s).` }; } if (expectedDurationSeconds && expectedDurationSeconds > 4) { const minExpected = Math.max(2, expectedDurationSeconds * 0.45); if (probed.durationSeconds < minExpected) { runtimeMetrics.integrityFailures += 1; return { success: false, error: `Integritaetspruefung fehlgeschlagen: ${probed.durationSeconds.toFixed(1)}s statt erwarteter ~${expectedDurationSeconds}s.` }; } } return { success: true }; } // ========================================== // TWITCH API // ========================================== async function twitchLogin(): Promise { if (!config.client_id || !config.client_secret) { return false; } try { const response = await axios.post('https://id.twitch.tv/oauth2/token', null, { params: { client_id: config.client_id, client_secret: config.client_secret, grant_type: 'client_credentials' }, timeout: API_TIMEOUT }); accessToken = response.data.access_token; return true; } catch (e) { console.error('Login error:', e); return false; } } function requestTwitchLogin(): Promise { if (twitchLoginInFlight) { return twitchLoginInFlight; } let loginPromise: Promise; loginPromise = twitchLogin().finally(() => { if (twitchLoginInFlight === loginPromise) { twitchLoginInFlight = null; } }); twitchLoginInFlight = loginPromise; return loginPromise; } async function ensureTwitchAuth(forceRefresh = false): Promise { if (!config.client_id || !config.client_secret) { accessToken = null; return false; } if (!forceRefresh && accessToken) { return true; } 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); const m = Math.floor((seconds % 3600) / 60); const s = seconds % 60; if (h > 0) return `${h}h${m}m${s}s`; if (m > 0) return `${m}m${s}s`; return `${s}s`; } async function fetchPublicTwitchGql(query: string, variables: Record): Promise { try { const response = await axios.post<{ data?: T; errors?: Array<{ message: string }> }>( 'https://gql.twitch.tv/gql', { query, variables }, { headers: { 'Client-ID': TWITCH_WEB_CLIENT_ID, 'Content-Type': 'application/json' }, timeout: API_TIMEOUT } ); if (response.data.errors?.length) { console.error('Public Twitch GQL errors:', response.data.errors.map((err) => err.message).join('; ')); return null; } return response.data.data || null; } catch (e) { console.error('Public Twitch GQL request failed:', e); return null; } } async function getPublicUserId(username: string): Promise { const login = normalizeLogin(username); if (!login) return null; const cachedUserId = getCachedValue(loginToUserIdCache, login); if (cachedUserId !== undefined) { runtimeMetrics.cacheHits += 1; return cachedUserId; } runtimeMetrics.cacheMisses += 1; type UserQueryResult = { user: { id: string; login: string } | null }; const data = await fetchPublicTwitchGql( 'query($login:String!){ user(login:$login){ id login } }', { login } ); const user = data?.user; if (!user?.id) return null; setCachedValue(loginToUserIdCache, login, user.id, MAX_LOGIN_TO_USER_ID_CACHE_ENTRIES); userIdLoginCache.set(user.id, user.login || login); return user.id; } async function getPublicVODsByLogin(loginName: string): Promise { const login = normalizeLogin(loginName); if (!login) return []; type VideoNode = { id: string; title: string; publishedAt: string; lengthSeconds: number; viewCount: number; previewThumbnailURL: string; }; type VodsQueryResult = { user: { videos: { edges: Array<{ node: VideoNode }>; }; } | null; }; const data = await fetchPublicTwitchGql( 'query($login:String!,$first:Int!){ user(login:$login){ videos(first:$first, type:ARCHIVE, sort:TIME){ edges{ node{ id title publishedAt lengthSeconds viewCount previewThumbnailURL(width:320,height:180) } } } } }', { login, first: 100 } ); const edges = data?.user?.videos?.edges || []; return edges .map(({ node }) => { const id = node?.id; if (!id) return null; return { id, title: node.title || 'Untitled VOD', created_at: node.publishedAt || new Date(0).toISOString(), duration: formatTwitchDurationFromSeconds(node.lengthSeconds || 0), thumbnail_url: node.previewThumbnailURL || '', url: `https://www.twitch.tv/videos/${id}`, view_count: node.viewCount || 0, stream_id: '' } as VOD; }) .filter((vod): vod is VOD => Boolean(vod)); } async function getUserId(username: string): Promise { const login = normalizeLogin(username); if (!login) return null; const cachedUserId = getCachedValue(loginToUserIdCache, login); if (cachedUserId !== undefined) { runtimeMetrics.cacheHits += 1; return cachedUserId; } return await withInFlightDedup(inFlightUserIdRequests, login, async () => { const refreshedCachedUserId = getCachedValue(loginToUserIdCache, login); if (refreshedCachedUserId !== undefined) { runtimeMetrics.cacheHits += 1; return refreshedCachedUserId; } runtimeMetrics.cacheMisses += 1; const getUserViaPublicApi = async () => { return await getPublicUserId(login); }; if (!(await ensureTwitchAuth())) return await getUserViaPublicApi(); const fetchUser = async () => { return await axios.get('https://api.twitch.tv/helix/users', { params: { login }, headers: { 'Client-ID': config.client_id, 'Authorization': `Bearer ${accessToken}` }, timeout: API_TIMEOUT }); }; try { const response = await fetchUser(); const user = response.data.data[0]; if (!user?.id) return await getUserViaPublicApi(); setCachedValue(loginToUserIdCache, login, user.id, MAX_LOGIN_TO_USER_ID_CACHE_ENTRIES); userIdLoginCache.set(user.id, user.login || login); return user.id; } catch (e) { if (axios.isAxiosError(e) && e.response?.status === 401 && (await ensureTwitchAuth(true))) { try { const retryResponse = await fetchUser(); const user = retryResponse.data.data[0]; if (!user?.id) return await getUserViaPublicApi(); setCachedValue(loginToUserIdCache, login, user.id, MAX_LOGIN_TO_USER_ID_CACHE_ENTRIES); userIdLoginCache.set(user.id, user.login || login); return user.id; } catch (retryError) { console.error('Error getting user after relogin:', retryError); return await getUserViaPublicApi(); } } console.error('Error getting user:', e); return await getUserViaPublicApi(); } }); } async function getVODs(userId: string, forceRefresh = false): Promise { const cacheKey = `user:${userId}`; if (!forceRefresh) { const cachedVods = getCachedValue(vodListCache, cacheKey); if (cachedVods !== undefined) { runtimeMetrics.cacheHits += 1; return cachedVods; } } const requestKey = `${cacheKey}|${forceRefresh ? 'force' : 'default'}`; return await withInFlightDedup(inFlightVodRequests, requestKey, async () => { if (!forceRefresh) { const refreshedCachedVods = getCachedValue(vodListCache, cacheKey); if (refreshedCachedVods !== undefined) { runtimeMetrics.cacheHits += 1; return refreshedCachedVods; } } runtimeMetrics.cacheMisses += 1; const getVodsViaPublicApi = async () => { const login = userIdLoginCache.get(userId); if (!login) return []; const vods = await getPublicVODsByLogin(login); setCachedValue(vodListCache, cacheKey, vods, MAX_VOD_LIST_CACHE_ENTRIES); return vods; }; if (!(await ensureTwitchAuth())) return await getVodsViaPublicApi(); const fetchVods = async () => { return await axios.get('https://api.twitch.tv/helix/videos', { params: { user_id: userId, type: 'archive', first: 100 }, headers: { 'Client-ID': config.client_id, 'Authorization': `Bearer ${accessToken}` }, timeout: API_TIMEOUT }); }; try { const response = await fetchVods(); const vods = response.data.data || []; const login = vods[0]?.user_login; if (login) { userIdLoginCache.set(userId, normalizeLogin(login)); } setCachedValue(vodListCache, cacheKey, vods, MAX_VOD_LIST_CACHE_ENTRIES); return vods; } catch (e) { if (axios.isAxiosError(e) && e.response?.status === 401 && (await ensureTwitchAuth(true))) { try { const retryResponse = await fetchVods(); const vods = retryResponse.data.data || []; const login = vods[0]?.user_login; if (login) { userIdLoginCache.set(userId, normalizeLogin(login)); } setCachedValue(vodListCache, cacheKey, vods, MAX_VOD_LIST_CACHE_ENTRIES); return vods; } catch (retryError) { console.error('Error getting VODs after relogin:', retryError); return await getVodsViaPublicApi(); } } console.error('Error getting VODs:', e); return await getVodsViaPublicApi(); } }); } async function getClipInfo(clipId: string): Promise { const cachedClip = getCachedValue(clipInfoCache, clipId); if (cachedClip !== undefined) { runtimeMetrics.cacheHits += 1; return cachedClip; } return await withInFlightDedup(inFlightClipRequests, clipId, async () => { const refreshedCachedClip = getCachedValue(clipInfoCache, clipId); if (refreshedCachedClip !== undefined) { runtimeMetrics.cacheHits += 1; return refreshedCachedClip; } runtimeMetrics.cacheMisses += 1; if (!(await ensureTwitchAuth())) return null; const fetchClip = async () => { return await axios.get('https://api.twitch.tv/helix/clips', { params: { id: clipId }, headers: { 'Client-ID': config.client_id, 'Authorization': `Bearer ${accessToken}` }, timeout: API_TIMEOUT }); }; try { const response = await fetchClip(); const clip = response.data.data[0] || null; if (clip) { setCachedValue(clipInfoCache, clipId, clip, MAX_CLIP_INFO_CACHE_ENTRIES); } return clip; } catch (e) { if (axios.isAxiosError(e) && e.response?.status === 401 && (await ensureTwitchAuth(true))) { try { const retryResponse = await fetchClip(); const clip = retryResponse.data.data[0] || null; if (clip) { setCachedValue(clipInfoCache, clipId, clip, MAX_CLIP_INFO_CACHE_ENTRIES); } return clip; } catch (retryError) { console.error('Error getting clip after relogin:', retryError); return null; } } console.error('Error getting clip:', e); return null; } }); } // ========================================== // VIDEO INFO (for cutter) // ========================================== async function getVideoInfo(filePath: string): Promise { const ffmpegReady = await ensureFfmpegInstalled(); if (!ffmpegReady) { appendDebugLog('get-video-info-missing-ffmpeg'); return null; } return new Promise((resolve) => { const ffprobe = getFFprobePath(); const args = [ '-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', filePath ]; const proc = spawn(ffprobe, args, { windowsHide: true }); let output = ''; proc.stdout?.on('data', (data) => { output += data.toString(); }); proc.on('close', (code) => { if (code !== 0) { resolve(null); return; } try { const info = JSON.parse(output); const videoStream = info.streams?.find((s: any) => s.codec_type === 'video'); resolve({ duration: parseFloat(info.format?.duration || '0'), width: videoStream?.width || 0, height: videoStream?.height || 0, fps: parseFrameRate(videoStream?.r_frame_rate) }); } catch { resolve(null); } }); proc.on('error', () => resolve(null)); }); } // ========================================== // VIDEO CUTTER // ========================================== async function extractFrame(filePath: string, timeSeconds: number): Promise { const ffmpegReady = await ensureFfmpegInstalled(); if (!ffmpegReady) { appendDebugLog('extract-frame-missing-ffmpeg'); return null; } return new Promise((resolve) => { const ffmpeg = getFFmpegPath(); const tempFile = path.join(app.getPath('temp'), `frame_${Date.now()}.jpg`); const args = [ '-ss', timeSeconds.toString(), '-i', filePath, '-vframes', '1', '-q:v', '2', '-y', tempFile ]; const proc = spawn(ffmpeg, args, { windowsHide: true }); proc.on('close', (code) => { if (code === 0 && fs.existsSync(tempFile)) { const imageData = fs.readFileSync(tempFile); const base64 = `data:image/jpeg;base64,${imageData.toString('base64')}`; fs.unlinkSync(tempFile); resolve(base64); } else { resolve(null); } }); proc.on('error', () => resolve(null)); }); } async function cutVideo( inputFile: string, outputFile: string, startTime: number, endTime: number, onProgress: (percent: number) => void ): Promise { const ffmpegReady = await ensureFfmpegInstalled(); if (!ffmpegReady) { appendDebugLog('cut-video-missing-ffmpeg'); return false; } const ffmpeg = getFFmpegPath(); const duration = Math.max(0.1, endTime - startTime); let inputBytes = 0; try { inputBytes = fs.statSync(inputFile).size; } catch { inputBytes = 0; } const cutRequiredBytes = Math.max(96 * 1024 * 1024, Math.ceil(inputBytes * 0.75)); const cutDiskCheck = ensureDiskSpace(path.dirname(outputFile), cutRequiredBytes, 'Video-Cut'); if (!cutDiskCheck.success) { appendDebugLog('cut-video-no-disk-space', { inputFile, outputFile, requiredBytes: cutRequiredBytes, error: cutDiskCheck.error }); return false; } const runCutAttempt = async (copyMode: boolean): Promise => { const args = [ '-ss', formatDuration(startTime), '-i', inputFile, '-t', formatDuration(duration) ]; if (copyMode) { args.push('-c', 'copy'); } else { args.push( '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-c:a', 'aac', '-b:a', '160k', '-movflags', '+faststart' ); } args.push('-progress', 'pipe:1', '-y', outputFile); appendDebugLog('cut-video-attempt', { copyMode, args }); return await new Promise((resolve) => { const proc = spawn(ffmpeg, args, { windowsHide: true }); currentProcess = proc; proc.stdout?.on('data', (data) => { const line = data.toString(); const match = line.match(/out_time_us=(\d+)/); if (match) { const currentUs = parseInt(match[1], 10); const percent = Math.min(100, (currentUs / 1000000) / duration * 100); onProgress(percent); } }); proc.on('close', (code) => { currentProcess = null; resolve(code === 0 && fs.existsSync(outputFile)); }); proc.on('error', () => { currentProcess = null; resolve(false); }); }); }; const copySuccess = await runCutAttempt(true); if (copySuccess) { return true; } appendDebugLog('cut-video-copy-failed-fallback-reencode', { inputFile, outputFile }); try { if (fs.existsSync(outputFile)) fs.unlinkSync(outputFile); } catch { } return await runCutAttempt(false); } // ========================================== // MERGE VIDEOS // ========================================== async function mergeVideos( inputFiles: string[], outputFile: string, onProgress: (percent: number) => void ): Promise { const ffmpegReady = await ensureFfmpegInstalled(); if (!ffmpegReady) { appendDebugLog('merge-videos-missing-ffmpeg'); return false; } const ffmpeg = getFFmpegPath(); const concatFile = path.join(app.getPath('temp'), `concat_${Date.now()}.txt`); const concatContent = inputFiles.map((filePath) => `file '${filePath.replace(/'/g, "'\\''")}'`).join('\n'); fs.writeFileSync(concatFile, concatContent); let mergeInputBytes = 0; for (const filePath of inputFiles) { try { mergeInputBytes += fs.statSync(filePath).size; } catch { // ignore missing file in estimation } } const mergeRequiredBytes = Math.max(128 * 1024 * 1024, Math.ceil(mergeInputBytes * 1.1)); const mergeDiskCheck = ensureDiskSpace(path.dirname(outputFile), mergeRequiredBytes, 'Video-Merge'); if (!mergeDiskCheck.success) { appendDebugLog('merge-video-no-disk-space', { outputFile, files: inputFiles.length, requiredBytes: mergeRequiredBytes, error: mergeDiskCheck.error }); try { fs.unlinkSync(concatFile); } catch { } return false; } const runMergeAttempt = async (copyMode: boolean): Promise => { const args = [ '-f', 'concat', '-safe', '0', '-i', concatFile ]; if (copyMode) { args.push('-c', 'copy'); } else { args.push( '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-c:a', 'aac', '-b:a', '160k', '-movflags', '+faststart' ); } args.push('-progress', 'pipe:1', '-y', outputFile); appendDebugLog('merge-video-attempt', { copyMode, argsCount: args.length }); return await new Promise((resolve) => { const proc = spawn(ffmpeg, args, { windowsHide: true }); currentProcess = proc; proc.stdout?.on('data', (data) => { const line = data.toString(); const match = line.match(/out_time_us=(\d+)/); if (match) { const currentUs = parseInt(match[1], 10); onProgress(Math.min(99, currentUs / 10000000)); } }); proc.on('close', (code) => { currentProcess = null; const success = code === 0 && fs.existsSync(outputFile); if (success) { onProgress(100); } resolve(success); }); proc.on('error', () => { currentProcess = null; resolve(false); }); }); }; try { const copySuccess = await runMergeAttempt(true); if (copySuccess) { return true; } appendDebugLog('merge-video-copy-failed-fallback-reencode', { outputFile, files: inputFiles.length }); try { if (fs.existsSync(outputFile)) fs.unlinkSync(outputFile); } catch { } return await runMergeAttempt(false); } finally { try { fs.unlinkSync(concatFile); } catch { } } } // ========================================== // DOWNLOAD FUNCTIONS // ========================================== function downloadVODPart( url: string, filename: string, startTime: string | null, endTime: string | null, onProgress: (progress: DownloadProgress) => void, itemId: string, partNum: number, totalParts: number ): Promise { return new Promise((resolve) => { const streamlinkCmd = getStreamlinkCommand(); const args = [...streamlinkCmd.prefixArgs, url, 'best', '-o', filename, '--force']; let lastErrorLine = ''; const expectedDurationSeconds = parseClockDurationSeconds(endTime); if (startTime) { args.push('--hls-start-offset', startTime); } if (endTime) { args.push('--hls-duration', endTime); } console.log('Starting download:', streamlinkCmd.command, args); appendDebugLog('download-part-start', { itemId, command: streamlinkCmd.command, filename, args }); const proc = spawn(streamlinkCmd.command, args, { windowsHide: true }); currentProcess = proc; downloadStartTime = Date.now(); downloadedBytes = 0; let lastBytes = 0; let lastTime = Date.now(); // Monitor file size for progress const progressInterval = setInterval(() => { if (fs.existsSync(filename)) { try { const stats = fs.statSync(filename); downloadedBytes = stats.size; const now = Date.now(); const timeDiff = (now - lastTime) / 1000; const bytesDiff = downloadedBytes - lastBytes; const speed = timeDiff > 0 ? bytesDiff / timeDiff : 0; runtimeMetrics.lastSpeedBytesPerSec = speed; if (speed > 0) { runtimeMetrics.avgSpeedBytesPerSec = runtimeMetrics.avgSpeedBytesPerSec <= 0 ? speed : (runtimeMetrics.avgSpeedBytesPerSec * 0.8) + (speed * 0.2); } lastBytes = downloadedBytes; lastTime = now; onProgress({ id: itemId, progress: -1, // Unknown total speed: formatSpeed(speed), eta: '', status: `${formatBytes(downloadedBytes)} heruntergeladen`, currentPart: partNum, totalParts: totalParts, downloadedBytes: downloadedBytes, speedBytesPerSec: speed }); } catch { } } }, 1000); proc.stdout?.on('data', (data: Buffer) => { const line = data.toString(); console.log('Streamlink:', line); // Parse progress const match = line.match(/(\d+\.\d+)%/); if (match) { const percent = parseFloat(match[1]); onProgress({ id: itemId, progress: percent, speed: '', eta: '', status: `${percent.toFixed(1)}%`, currentPart: partNum, totalParts: totalParts }); } }); proc.stderr?.on('data', (data: Buffer) => { const message = data.toString().trim(); if (message) { lastErrorLine = message.split('\n').pop() || message; appendDebugLog('download-part-stderr', { itemId, message: lastErrorLine }); console.error('Streamlink error:', message); } }); proc.on('close', async (code) => { clearInterval(progressInterval); currentProcess = null; if (currentDownloadCancelled) { appendDebugLog('download-part-cancelled', { itemId, filename }); resolve({ success: false, error: 'Download wurde abgebrochen.' }); return; } if (code === 0 && fs.existsSync(filename)) { const stats = fs.statSync(filename); if (stats.size <= MIN_FILE_BYTES) { const tooSmall = `Datei zu klein (${stats.size} Bytes)`; appendDebugLog('download-part-failed-small-file', { itemId, filename, bytes: stats.size }); resolve({ success: false, error: tooSmall }); return; } const integrityResult = validateDownloadedFileIntegrity(filename, expectedDurationSeconds); if (!integrityResult.success) { appendDebugLog('download-part-failed-integrity', { itemId, filename, bytes: stats.size, error: integrityResult.error }); resolve(integrityResult); return; } runtimeMetrics.downloadedBytesTotal += stats.size; appendDebugLog('download-part-success', { itemId, filename, bytes: stats.size }); resolve({ success: true }); return; } const genericError = lastErrorLine || `Streamlink Fehlercode ${code ?? -1}`; appendDebugLog('download-part-failed', { itemId, filename, code, error: genericError }); resolve({ success: false, error: genericError }); }); proc.on('error', (err) => { clearInterval(progressInterval); console.error('Process error:', err); currentProcess = null; const rawError = String(err); const errorMessage = rawError.includes('ENOENT') ? 'Streamlink nicht gefunden. Installiere Streamlink oder Python+streamlink (py -3 -m pip install streamlink).' : rawError; appendDebugLog('download-part-process-error', { itemId, error: errorMessage, rawError }); resolve({ success: false, error: errorMessage }); }); }); } async function downloadVOD( item: QueueItem, onProgress: (progress: DownloadProgress) => void ): Promise { const vodId = parseVodId(item.url); if (!isLikelyVodUrl(item.url) || !vodId) { return { success: false, error: 'Ungueltige VOD-URL' }; } const streamlinkCmd = getStreamlinkCommand(); const streamlinkVersionArgs = [...streamlinkCmd.prefixArgs, '--version']; const streamlinkAlreadyVerified = isVerifiedStreamlinkCommand(streamlinkCmd.command, streamlinkVersionArgs); if (!streamlinkAlreadyVerified) { onProgress({ id: item.id, progress: -1, speed: '', eta: '', status: 'Prufe Download-Tools...', currentPart: 0, totalParts: 0 }); } const streamlinkReady = await ensureStreamlinkInstalled(); if (!streamlinkReady) { return { success: false, error: 'Streamlink fehlt und konnte nicht automatisch installiert werden. Siehe debug.log.' }; } onProgress({ id: item.id, progress: -1, speed: '', eta: '', status: 'Download gestartet', currentPart: 0, totalParts: 0 }); const streamer = item.streamer.replace(/[^a-zA-Z0-9_-]/g, ''); const date = new Date(item.date); const dateStr = `${date.getDate().toString().padStart(2, '0')}.${(date.getMonth() + 1).toString().padStart(2, '0')}.${date.getFullYear()}`; const folder = path.join(config.download_path, streamer, dateStr); fs.mkdirSync(folder, { recursive: true }); const totalDuration = parseDuration(item.duration_str); const requiredBytesEstimate = estimateRequiredDownloadBytes(item); const diskSpaceCheck = ensureDiskSpace(folder, requiredBytesEstimate, 'Download'); if (!diskSpaceCheck.success) { return diskSpaceCheck; } const makeTemplateFilename = ( template: string, templateFallback: string, partNum: number, trimStartSec: number, trimLengthSec: number ): string => { const relativeName = renderClipFilenameTemplate({ template: normalizeFilenameTemplate(template, templateFallback), title: item.title, vodId, channel: item.streamer, date, part: partNum, partPadded: partNum.toString().padStart(2, '0'), trimStartSec, trimEndSec: trimStartSec + trimLengthSec, trimLengthSec, fullLengthSec: totalDuration }); return path.join(folder, relativeName); }; // Custom Clip - download specific time range if (item.customClip) { const clip = item.customClip; const partDuration = config.part_minutes * 60; // Helper to generate filename based on format const makeClipFilename = (partNum: number, startOffset: number, clipLengthSec: number): string => { if (clip.filenameFormat === 'template') { return makeTemplateFilename( clip.filenameTemplate || config.filename_template_clip, DEFAULT_FILENAME_TEMPLATE_CLIP, partNum, startOffset, clipLengthSec ); } if (clip.filenameFormat === 'timestamp') { const h = Math.floor(startOffset / 3600); const m = Math.floor((startOffset % 3600) / 60); const s = Math.floor(startOffset % 60); const timeStr = `${h.toString().padStart(2, '0')}-${m.toString().padStart(2, '0')}-${s.toString().padStart(2, '0')}`; return path.join(folder, `${dateStr}_CLIP_${timeStr}_${partNum}.mp4`); } else { return path.join(folder, `${dateStr}_${partNum}.mp4`); } }; // If clip is longer than part duration, split into parts if (clip.durationSec > partDuration) { const numParts = Math.ceil(clip.durationSec / partDuration); const downloadedFiles: string[] = []; for (let i = 0; i < numParts; i++) { if (currentDownloadCancelled) break; const partNum = clip.startPart + i; const startOffset = clip.startSec + (i * partDuration); const remainingDuration = clip.durationSec - (i * partDuration); const thisDuration = Math.min(partDuration, remainingDuration); const partFilename = makeClipFilename(partNum, startOffset, thisDuration); const result = await downloadVODPart( item.url, partFilename, formatDuration(startOffset), formatDuration(thisDuration), onProgress, item.id, i + 1, numParts ); if (!result.success) return result; downloadedFiles.push(partFilename); } return { success: downloadedFiles.length === numParts, error: downloadedFiles.length === numParts ? undefined : 'Nicht alle Clip-Teile konnten heruntergeladen werden.' }; } else { // Single clip file const filename = makeClipFilename(clip.startPart, clip.startSec, clip.durationSec); return await downloadVODPart( item.url, filename, formatDuration(clip.startSec), formatDuration(clip.durationSec), onProgress, item.id, 1, 1 ); } } // Check download mode if (config.download_mode === 'full' || totalDuration <= config.part_minutes * 60) { // Full download const filename = makeTemplateFilename( config.filename_template_vod, DEFAULT_FILENAME_TEMPLATE_VOD, 1, 0, totalDuration ); return await downloadVODPart(item.url, filename, null, null, onProgress, item.id, 1, 1); } else { // Part-based download const partDuration = config.part_minutes * 60; const numParts = Math.ceil(totalDuration / partDuration); const downloadedFiles: string[] = []; for (let i = 0; i < numParts; i++) { if (currentDownloadCancelled) break; const startSec = i * partDuration; const endSec = Math.min((i + 1) * partDuration, totalDuration); const duration = endSec - startSec; const partFilename = makeTemplateFilename( config.filename_template_parts, DEFAULT_FILENAME_TEMPLATE_PARTS, i + 1, startSec, duration ); const result = await downloadVODPart( item.url, partFilename, formatDuration(startSec), formatDuration(duration), onProgress, item.id, i + 1, numParts ); if (!result.success) { return result; } downloadedFiles.push(partFilename); } return { success: downloadedFiles.length === numParts, error: downloadedFiles.length === numParts ? undefined : 'Nicht alle Teile konnten heruntergeladen werden.' }; } } async function processQueue(): Promise { if (isDownloading || !downloadQueue.some((item) => item.status === 'pending')) return; appendDebugLog('queue-start', { items: downloadQueue.length, smartScheduler: config.smart_queue_scheduler, performanceMode: config.performance_mode }); isDownloading = true; pauseRequested = false; mainWindow?.webContents.send('download-started'); emitQueueUpdated(); while (isDownloading && !pauseRequested) { const item = pickNextPendingQueueItem(); if (!item) { break; } appendDebugLog('queue-item-start', { itemId: item.id, title: item.title, url: item.url, smartScore: config.smart_queue_scheduler ? getQueuePriorityScore(item) : 0 }); runtimeMetrics.downloadsStarted += 1; runtimeMetrics.activeItemId = item.id; runtimeMetrics.activeItemTitle = item.title; activeQueueItemId = item.id; currentDownloadCancelled = false; item.status = 'downloading'; saveQueue(downloadQueue); emitQueueUpdated(); item.last_error = ''; let finalResult: DownloadResult = { success: false, error: 'Unbekannter Fehler beim Download' }; const maxAttempts = getRetryAttemptLimit(); for (let attempt = 1; attempt <= maxAttempts; attempt++) { appendDebugLog('queue-item-attempt', { itemId: item.id, attempt, max: maxAttempts }); const result = await downloadVOD(item, (progress) => { mainWindow?.webContents.send('download-progress', progress); }); if (result.success) { finalResult = result; break; } finalResult = result; if (!isDownloading || currentDownloadCancelled || pauseRequested) { finalResult = { success: false, error: pauseRequested ? 'Download wurde pausiert.' : 'Download wurde abgebrochen.' }; break; } const errorClass = classifyDownloadError(result.error || ''); runtimeMetrics.lastErrorClass = errorClass; if (errorClass === 'tooling' || errorClass === 'validation') { appendDebugLog('queue-item-no-retry', { itemId: item.id, errorClass, error: result.error || 'unknown' }); break; } if (attempt < maxAttempts) { const retryDelaySeconds = getRetryDelaySeconds(errorClass, attempt); runtimeMetrics.retriesScheduled += 1; runtimeMetrics.lastRetryDelaySeconds = retryDelaySeconds; item.last_error = `Versuch ${attempt}/${maxAttempts} fehlgeschlagen (${errorClass}): ${result.error || 'Unbekannter Fehler'}`; mainWindow?.webContents.send('download-progress', { id: item.id, progress: -1, speed: '', eta: '', status: `Neuer Versuch in ${retryDelaySeconds}s (${errorClass})...`, currentPart: item.currentPart, totalParts: item.totalParts } as DownloadProgress); saveQueue(downloadQueue); emitQueueUpdated(); await sleep(retryDelaySeconds * 1000); } else { runtimeMetrics.retriesExhausted += 1; } } if (!hasQueueItemId(item.id)) { appendDebugLog('queue-item-finished-removed', { itemId: item.id }); runtimeMetrics.activeItemId = null; runtimeMetrics.activeItemTitle = null; activeQueueItemId = null; continue; } const wasPaused = pauseRequested || (finalResult.error || '').includes('pausiert'); item.status = finalResult.success ? 'completed' : (wasPaused ? 'paused' : 'error'); item.progress = finalResult.success ? 100 : item.progress; item.last_error = finalResult.success || wasPaused ? '' : (finalResult.error || 'Unbekannter Fehler beim Download'); if (finalResult.success) { runtimeMetrics.downloadsCompleted += 1; } else if (!wasPaused) { runtimeMetrics.downloadsFailed += 1; } runtimeMetrics.activeItemId = null; runtimeMetrics.activeItemTitle = null; activeQueueItemId = null; appendDebugLog('queue-item-finished', { itemId: item.id, status: item.status, error: item.last_error }); saveQueue(downloadQueue); emitQueueUpdated(); } isDownloading = false; pauseRequested = false; runtimeMetrics.activeItemId = null; runtimeMetrics.activeItemTitle = null; activeQueueItemId = null; saveQueue(downloadQueue); emitQueueUpdated(); mainWindow?.webContents.send('download-finished'); appendDebugLog('queue-finished', { items: downloadQueue.length }); } // ========================================== // WINDOW CREATION // ========================================== function createWindow(): void { nativeTheme.themeSource = 'dark'; mainWindow = new BrowserWindow({ width: 1400, height: 900, minWidth: 1200, minHeight: 700, title: `Twitch VOD Manager [v${APP_VERSION}]`, backgroundColor: '#0e0e10', autoHideMenuBar: true, webPreferences: { nodeIntegration: false, contextIsolation: true, preload: path.join(__dirname, 'preload.js') } }); if (process.platform !== 'darwin') { mainWindow.removeMenu(); } mainWindow.loadFile(path.join(__dirname, '../src/index.html')); mainWindow.webContents.on('did-finish-load', () => { emitQueueUpdated(true); if (isDownloading) { mainWindow?.webContents.send('download-started'); } if (autoUpdateReadyToInstall && downloadedUpdateVersion) { mainWindow?.webContents.send('update-downloaded', buildUpdateInfoPayload(downloadedUpdateVersion)); } }); mainWindow.on('closed', () => { mainWindow = null; }); // Setup auto-updater after window is ready setTimeout(() => { setupAutoUpdater(); }, 3000); } // ========================================== // AUTO-UPDATER (electron-updater) // ========================================== function hasNewerKnownUpdateThanDownloaded(): boolean { if (!latestKnownUpdateVersion || !downloadedUpdateVersion) { return false; } return isNewerUpdateVersion(latestKnownUpdateVersion, downloadedUpdateVersion); } function normalizeReleaseVersionCandidate(value: unknown): string | undefined { if (typeof value !== 'string') { return undefined; } const trimmed = value.trim(); if (!trimmed) { return undefined; } return normalizeUpdateVersion(trimmed) || trimmed.replace(/^v/i, ''); } function cacheLatestReleaseUpdateInfo(releaseData: any): void { if (!releaseData || typeof releaseData !== 'object') { return; } const tagName = typeof releaseData.tag_name === 'string' ? releaseData.tag_name.trim() : ''; const version = normalizeReleaseVersionCandidate(tagName) || normalizeReleaseVersionCandidate(releaseData.name); const releaseName = typeof releaseData.name === 'string' ? releaseData.name.trim() : ''; const releaseNotes = typeof releaseData.body === 'string' ? releaseData.body : ''; const releaseDate = typeof releaseData.published_at === 'string' ? releaseData.published_at : (typeof releaseData.created_at === 'string' ? releaseData.created_at : undefined); latestReleaseUpdateInfo = { tagName: tagName || undefined, version, releaseDate, releaseName: releaseName || undefined, releaseNotes: releaseNotes.trim() ? releaseNotes : undefined }; } function buildUpdateInfoPayload(version: string, releaseDate?: string): { version: string; releaseDate?: string; releaseName?: string; releaseNotes?: string; } { const normalizedVersion = normalizeReleaseVersionCandidate(version) || version; const cachedVersion = latestReleaseUpdateInfo?.version ? (normalizeReleaseVersionCandidate(latestReleaseUpdateInfo.version) || latestReleaseUpdateInfo.version) : undefined; const hasMatchingReleaseInfo = !cachedVersion || cachedVersion === normalizedVersion; return { version: normalizedVersion, releaseDate: releaseDate || (hasMatchingReleaseInfo ? latestReleaseUpdateInfo?.releaseDate : undefined), releaseName: hasMatchingReleaseInfo ? latestReleaseUpdateInfo?.releaseName : undefined, releaseNotes: hasMatchingReleaseInfo ? latestReleaseUpdateInfo?.releaseNotes : undefined }; } async function requestUpdateCheck(source: UpdateCheckSource, force = false): Promise<{ started: boolean; reason?: string }> { if (autoUpdateCheckInProgress) { return { started: false, reason: 'in-progress' }; } const now = Date.now(); if (!force && lastAutoUpdateCheckAt > 0 && (now - lastAutoUpdateCheckAt) < AUTO_UPDATE_MIN_CHECK_GAP_MS) { return { started: false, reason: 'throttled' }; } autoUpdateCheckInProgress = true; lastAutoUpdateCheckAt = now; appendDebugLog('update-check-start', { source }); try { try { const giteaRes = await axios.get(GITEA_RELEASES_API_LATEST_URL, { timeout: 5000, headers: { 'Accept': 'application/json', 'User-Agent': 'Twitch-VOD-Manager' } }); cacheLatestReleaseUpdateInfo(giteaRes.data); const tagName = latestReleaseUpdateInfo?.tagName || giteaRes.data?.tag_name; if (tagName) { autoUpdater.setFeedURL({ provider: 'generic', url: `${GITEA_RELEASES_DOWNLOAD_BASE_URL}/${tagName}` }); appendDebugLog('gitea-feed-url-set', { tagName, owner: GITEA_REPO_OWNER, repo: GITEA_REPO_NAME }); } } catch (apiErr) { appendDebugLog('gitea-api-failed', String(apiErr)); } let timeoutHandle: NodeJS.Timeout | null = null; try { await Promise.race([ autoUpdater.checkForUpdates(), new Promise((_, reject) => { timeoutHandle = setTimeout(() => { reject(new Error(`Update check timed out after ${AUTO_UPDATE_CHECK_TIMEOUT_MS}ms`)); }, AUTO_UPDATE_CHECK_TIMEOUT_MS); }) ]); } finally { if (timeoutHandle) { clearTimeout(timeoutHandle); timeoutHandle = null; } } return { started: true }; } catch (err) { appendDebugLog('update-check-failed', { source, error: String(err) }); console.error('Update check failed:', err); return { started: false, reason: 'error' }; } finally { autoUpdateCheckInProgress = false; } } async function requestUpdateDownload(source: UpdateDownloadSource): Promise<{ started: boolean; reason?: string }> { if (autoUpdateReadyToInstall && !hasNewerKnownUpdateThanDownloaded()) { return { started: false, reason: 'ready-to-install' }; } if (autoUpdateDownloadInProgress) { return { started: false, reason: 'in-progress' }; } autoUpdateDownloadInProgress = true; appendDebugLog('update-download-start', { source }); try { await autoUpdater.downloadUpdate(); return { started: true }; } catch (err) { appendDebugLog('update-download-failed', { source, error: String(err) }); console.error('Download failed:', err); return { started: false, reason: 'error' }; } finally { autoUpdateDownloadInProgress = false; } } function stopAutoUpdatePolling(): void { if (autoUpdateCheckTimer) { clearInterval(autoUpdateCheckTimer); autoUpdateCheckTimer = null; } if (autoUpdateStartupTimer) { clearTimeout(autoUpdateStartupTimer); autoUpdateStartupTimer = null; } } function startAutoUpdatePolling(): void { if (!autoUpdateCheckTimer) { autoUpdateCheckTimer = setInterval(() => { void requestUpdateCheck('interval'); }, AUTO_UPDATE_CHECK_INTERVAL_MS); autoUpdateCheckTimer.unref?.(); } if (autoUpdateStartupTimer) { clearTimeout(autoUpdateStartupTimer); autoUpdateStartupTimer = null; } autoUpdateStartupTimer = setTimeout(() => { autoUpdateStartupTimer = null; void requestUpdateCheck('startup', true); }, AUTO_UPDATE_STARTUP_CHECK_DELAY_MS); } function setupAutoUpdater() { if (autoUpdaterInitialized) { startAutoUpdatePolling(); return; } autoUpdaterInitialized = true; autoUpdater.autoDownload = false; autoUpdater.autoInstallOnAppQuit = true; autoUpdater.autoRunAppAfterInstall = true; autoUpdater.on('checking-for-update', () => { console.log('Checking for updates...'); mainWindow?.webContents.send('update-checking'); }); autoUpdater.on('update-available', (info) => { const incomingVersion = normalizeUpdateVersion(info.version); const displayVersion = incomingVersion || info.version; if (latestKnownUpdateVersion && compareUpdateVersions(incomingVersion, latestKnownUpdateVersion) < 0) { appendDebugLog('update-available-ignored-older', { incomingVersion: displayVersion, knownVersion: latestKnownUpdateVersion }); return; } latestKnownUpdateVersion = incomingVersion || latestKnownUpdateVersion; const hasAlreadyDownloadedThisVersion = Boolean( autoUpdateReadyToInstall && downloadedUpdateVersion && compareUpdateVersions(downloadedUpdateVersion, incomingVersion) === 0 ); console.log('Update available:', displayVersion); if (!hasAlreadyDownloadedThisVersion) { autoUpdateReadyToInstall = false; } autoUpdateDownloadInProgress = false; if (hasAlreadyDownloadedThisVersion) { if (mainWindow) { mainWindow.webContents.send('update-downloaded', buildUpdateInfoPayload(displayVersion, info.releaseDate)); } return; } if (mainWindow) { mainWindow.webContents.send('update-available', buildUpdateInfoPayload(displayVersion, info.releaseDate)); } if (AUTO_UPDATE_AUTO_DOWNLOAD) { void requestUpdateDownload('auto'); } }); autoUpdater.on('update-not-available', () => { console.log('No updates available'); mainWindow?.webContents.send('update-not-available'); }); autoUpdater.on('download-progress', (progress) => { console.log(`Download progress: ${progress.percent.toFixed(1)}%`); if (mainWindow) { mainWindow.webContents.send('update-download-progress', { percent: progress.percent, bytesPerSecond: progress.bytesPerSecond, transferred: progress.transferred, total: progress.total }); } }); autoUpdater.on('update-downloaded', (info) => { const downloadedVersion = normalizeUpdateVersion(info.version) || info.version; console.log('Update downloaded:', downloadedVersion); autoUpdateReadyToInstall = true; autoUpdateDownloadInProgress = false; downloadedUpdateVersion = downloadedVersion; if (!latestKnownUpdateVersion || compareUpdateVersions(downloadedVersion, latestKnownUpdateVersion) > 0) { latestKnownUpdateVersion = downloadedVersion; } if (mainWindow) { mainWindow.webContents.send('update-downloaded', buildUpdateInfoPayload(downloadedVersion, info.releaseDate)); } }); autoUpdater.on('error', (err) => { autoUpdateCheckInProgress = false; autoUpdateDownloadInProgress = false; const message = String(err); appendDebugLog('auto-updater-error', message); mainWindow?.webContents.send('update-error', { message }); console.error('Auto-updater error:', err); }); startAutoUpdatePolling(); } // ========================================== // IPC HANDLERS // ========================================== ipcMain.handle('get-config', () => config); ipcMain.handle('save-config', (_, newConfig: Partial) => { const previousClientId = config.client_id; const previousClientSecret = config.client_secret; const previousCacheMinutes = config.metadata_cache_minutes; const previousPersistQueueOnRestart = config.persist_queue_on_restart; config = normalizeConfigTemplates({ ...config, ...newConfig }); if (config.client_id !== previousClientId || config.client_secret !== previousClientSecret) { accessToken = null; twitchLoginInFlight = null; } if (config.metadata_cache_minutes !== previousCacheMinutes) { clearMetadataCaches(); } saveConfig(config); if (config.persist_queue_on_restart === false) { pendingQueueSnapshot = null; if (queueSaveTimer) { clearTimeout(queueSaveTimer); queueSaveTimer = null; } clearQueueFileFromDisk(); } else if (previousPersistQueueOnRestart === false) { saveQueue(downloadQueue, true); } return config; }); ipcMain.handle('login', async () => { return await twitchLogin(); }); ipcMain.handle('get-user-id', async (_, username: string) => { return await getUserId(username); }); ipcMain.handle('get-vods', async (_, userId: string, forceRefresh: boolean = false) => { return await getVODs(userId, forceRefresh); }); ipcMain.handle('get-queue', () => downloadQueue); ipcMain.handle('add-to-queue', (_, item: Omit) => { if (config.prevent_duplicate_downloads && hasActiveDuplicate(item)) { runtimeMetrics.duplicateSkips += 1; mainWindow?.webContents.send('queue-duplicate-skipped', { title: item.title, streamer: item.streamer, url: item.url }); appendDebugLog('queue-item-duplicate-skipped', { title: item.title, url: item.url, streamer: item.streamer }); return downloadQueue; } const queueItem: QueueItem = { ...item, id: generateQueueItemId(), status: 'pending', progress: 0 }; downloadQueue.push(queueItem); saveQueue(downloadQueue); emitQueueUpdated(); return downloadQueue; }); ipcMain.handle('remove-from-queue', (_, id: string) => { const wasActiveItem = activeQueueItemId === id; if (wasActiveItem) { currentDownloadCancelled = true; if (currentProcess) { currentProcess.kill(); } activeQueueItemId = null; runtimeMetrics.activeItemId = null; runtimeMetrics.activeItemTitle = null; appendDebugLog('queue-item-removed-active-cancelled', { id }); } downloadQueue = downloadQueue.filter(item => item.id !== id); saveQueue(downloadQueue); emitQueueUpdated(); return downloadQueue; }); ipcMain.handle('clear-completed', () => { downloadQueue = downloadQueue.filter(item => item.status !== 'completed'); saveQueue(downloadQueue); emitQueueUpdated(); return downloadQueue; }); ipcMain.handle('reorder-queue', (_, orderIds: string[]) => { const order = new Map(orderIds.map((id, idx) => [id, idx])); const withOrder = [...downloadQueue].sort((a, b) => { const ai = order.has(a.id) ? (order.get(a.id) as number) : Number.MAX_SAFE_INTEGER; const bi = order.has(b.id) ? (order.get(b.id) as number) : Number.MAX_SAFE_INTEGER; return ai - bi; }); downloadQueue = withOrder; saveQueue(downloadQueue); emitQueueUpdated(); return downloadQueue; }); ipcMain.handle('retry-failed-downloads', () => { downloadQueue = downloadQueue.map((item) => { if (item.status !== 'error') return item; return { ...item, status: 'pending', progress: 0, last_error: '' }; }); saveQueue(downloadQueue); emitQueueUpdated(); if (!isDownloading) { void processQueue(); } return downloadQueue; }); ipcMain.handle('start-download', async () => { downloadQueue = downloadQueue.map((item) => item.status === 'paused' ? { ...item, status: 'pending' } : item); const hasPendingItems = downloadQueue.some(item => item.status === 'pending'); if (!hasPendingItems) { emitQueueUpdated(); return false; } saveQueue(downloadQueue); emitQueueUpdated(); processQueue(); return true; }); ipcMain.handle('pause-download', () => { if (!isDownloading) return false; pauseRequested = true; currentDownloadCancelled = true; if (currentProcess) { currentProcess.kill(); } return true; }); ipcMain.handle('cancel-download', () => { isDownloading = false; pauseRequested = false; currentDownloadCancelled = true; if (currentProcess) { currentProcess.kill(); } return true; }); ipcMain.handle('select-folder', async () => { const result = await dialog.showOpenDialog(mainWindow!, { properties: ['openDirectory'] }); return result.filePaths[0] || null; }); ipcMain.handle('select-video-file', async () => { const result = await dialog.showOpenDialog(mainWindow!, { properties: ['openFile'], filters: [ { name: 'Video Files', extensions: ['mp4', 'mkv', 'ts', 'mov', 'avi'] } ] }); return result.filePaths[0] || null; }); ipcMain.handle('open-folder', (_, folderPath: string) => { if (fs.existsSync(folderPath)) { shell.openPath(folderPath); } }); ipcMain.handle('get-version', () => APP_VERSION); ipcMain.handle('check-update', async () => { try { setupAutoUpdater(); const result = await requestUpdateCheck('manual', true); if (result.reason === 'error') { return { error: true }; } return result.started ? { checking: true } : { checking: true, skipped: result.reason }; } catch (err) { console.error('Update check failed:', err); return { error: true }; } }); ipcMain.handle('download-update', async () => { try { setupAutoUpdater(); const result = await requestUpdateDownload('manual'); if (result.reason === 'error') { return { error: true }; } return result.started ? { downloading: true } : { downloading: true, skipped: result.reason }; } catch (err) { console.error('Download failed:', err); return { error: true }; } }); ipcMain.handle('install-update', () => { autoUpdater.quitAndInstall(true, true); }); ipcMain.handle('open-external', async (_, url: string) => { await shell.openExternal(url); }); ipcMain.handle('download-clip', async (_, clipUrl: string) => { let clipId = ''; const match1 = clipUrl.match(/clips\.twitch\.tv\/([A-Za-z0-9_-]+)/); const match2 = clipUrl.match(/twitch\.tv\/[^/]+\/clip\/([A-Za-z0-9_-]+)/); if (match1) clipId = match1[1]; else if (match2) clipId = match2[1]; else return { success: false, error: 'Ungueltige Clip-URL' }; const clipInfo = await getClipInfo(clipId); if (!clipInfo) return { success: false, error: 'Clip nicht gefunden' }; const folder = path.join(config.download_path, 'Clips', clipInfo.broadcaster_name); fs.mkdirSync(folder, { recursive: true }); const clipDiskCheck = ensureDiskSpace(folder, 128 * 1024 * 1024, 'Clip-Download'); if (!clipDiskCheck.success) { return { success: false, error: clipDiskCheck.error || 'Zu wenig Speicherplatz.' }; } const safeTitle = clipInfo.title.replace(/[^a-zA-Z0-9_\- ]/g, '').substring(0, 50); const filename = path.join(folder, `${safeTitle}.mp4`); return new Promise((resolve) => { const streamlinkCmd = getStreamlinkCommand(); const proc = spawn(streamlinkCmd.command, [ ...streamlinkCmd.prefixArgs, `https://clips.twitch.tv/${clipId}`, 'best', '-o', filename, '--force' ], { windowsHide: true }); proc.on('close', (code) => { if (code === 0 && fs.existsSync(filename)) { resolve({ success: true, filename }); } else { resolve({ success: false, error: `Download fehlgeschlagen (Exit-Code ${code ?? -1})` }); } }); proc.on('error', () => { resolve({ success: false, error: 'Streamlink nicht gefunden' }); }); }); }); ipcMain.handle('run-preflight', async (_, autoFix: boolean = false) => { return await runPreflight(autoFix); }); ipcMain.handle('get-debug-log', async (_, lines: number = 200) => { return readDebugLog(lines); }); ipcMain.handle('is-downloading', () => isDownloading); ipcMain.handle('get-runtime-metrics', () => getRuntimeMetricsSnapshot()); ipcMain.handle('export-runtime-metrics', async () => { try { const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const defaultName = `runtime-metrics-${timestamp}.json`; const preferredDir = fs.existsSync(config.download_path) ? config.download_path : app.getPath('desktop'); const dialogResult = await dialog.showSaveDialog(mainWindow!, { defaultPath: path.join(preferredDir, defaultName), filters: [{ name: 'JSON', extensions: ['json'] }] }); if (dialogResult.canceled || !dialogResult.filePath) { return { success: false, cancelled: true }; } const snapshot = getRuntimeMetricsSnapshot(); fs.writeFileSync(dialogResult.filePath, JSON.stringify(snapshot, null, 2), 'utf-8'); return { success: true, filePath: dialogResult.filePath }; } catch (e) { appendDebugLog('runtime-metrics-export-failed', String(e)); return { success: false, error: String(e) }; } }); // Video Cutter IPC ipcMain.handle('get-video-info', async (_, filePath: string) => { return await getVideoInfo(filePath); }); ipcMain.handle('extract-frame', async (_, filePath: string, timeSeconds: number) => { return await extractFrame(filePath, timeSeconds); }); ipcMain.handle('cut-video', async (_, inputFile: string, startTime: number, endTime: number) => { const dir = path.dirname(inputFile); const baseName = path.basename(inputFile, path.extname(inputFile)); const timestamp = new Date().toISOString().replace(/[:.]/g, '-').substring(11, 19); const outputFile = path.join(dir, `${baseName}_cut_${timestamp}.mp4`); let lastProgress = 0; const success = await cutVideo(inputFile, outputFile, startTime, endTime, (percent) => { lastProgress = percent; mainWindow?.webContents.send('cut-progress', percent); }); return { success, outputFile: success ? outputFile : null }; }); // Merge IPC ipcMain.handle('merge-videos', async (_, inputFiles: string[], outputFile: string) => { const success = await mergeVideos(inputFiles, outputFile, (percent) => { mainWindow?.webContents.send('merge-progress', percent); }); return { success, outputFile: success ? outputFile : null }; }); ipcMain.handle('select-multiple-videos', async () => { const result = await dialog.showOpenDialog(mainWindow!, { properties: ['openFile', 'multiSelections'], filters: [ { name: 'Video Files', extensions: ['mp4', 'mkv', 'ts', 'mov', 'avi'] } ] }); return result.filePaths; }); ipcMain.handle('save-video-dialog', async (_, defaultName: string) => { const result = await dialog.showSaveDialog(mainWindow!, { defaultPath: defaultName, filters: [ { name: 'MP4 Video', extensions: ['mp4'] } ] }); return result.filePath || null; }); // ========================================== // APP LIFECYCLE // ========================================== app.whenReady().then(() => { refreshBundledToolPaths(true); startMetadataCacheCleanup(); startDebugLogFlushTimer(); createWindow(); appendDebugLog('startup-tools-check-skipped', 'Deferred to first use'); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow(); } }); }); app.on('window-all-closed', () => { stopMetadataCacheCleanup(); cleanupMetadataCaches('shutdown'); stopDebugLogFlushTimer(true); stopAutoUpdatePolling(); if (currentProcess) { currentProcess.kill(); } flushQueueSave(); if (process.platform !== 'darwin') { app.quit(); } }); app.on('before-quit', () => { stopMetadataCacheCleanup(); cleanupMetadataCaches('shutdown'); stopDebugLogFlushTimer(true); stopAutoUpdatePolling(); flushQueueSave(); });