import { app, BrowserWindow, ipcMain, dialog, shell, nativeTheme, Notification } from 'electron'; import * as path from 'path'; import * as fs from 'fs'; import { spawn, ChildProcess, execSync, spawnSync } from 'child_process'; import axios from 'axios'; import { autoUpdater } from 'electron-updater'; import { compareUpdateVersions, isNewerUpdateVersion, normalizeUpdateVersion } from './update-version-utils'; import { CustomClip, MergeGroupItem, MergeGroup, QueueItem, DownloadProgress, DownloadResult } from './types'; import { setDebugLogFn, initToolDirs, getStreamlinkPath, getStreamlinkCommand, getFFmpegPath, getFFprobePath, refreshBundledToolPaths, ensureStreamlinkInstalled, ensureFfmpegInstalled, canExecute, canExecuteCommand, cacheVerifiedStreamlinkCommand, isVerifiedStreamlinkCommand, cacheVerifiedFfmpegCommands, isVerifiedFfmpegCommands, invalidateVerifiedToolCaches } from './tools'; // ========================================== // 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 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'; function getMergeGroupPhaseText(phase: string): string { const isEnglish = config.language === 'en'; switch (phase) { case 'downloading': return isEnglish ? 'Downloading VOD' : 'VOD wird heruntergeladen'; case 'merging': return isEnglish ? 'Merging...' : 'Zusammenfugen...'; case 'splitting': return isEnglish ? 'Splitting Part' : 'Part wird erstellt'; case 'cleanup': return isEnglish ? 'Cleaning up...' : 'Aufraumen...'; default: return phase; } } // ========================================== // BACKEND I18N // ========================================== // User-visible messages produced in main.ts. Keep keys stable — the renderer // no longer translates these (renderer.ts:downloadClip used to translate a // hardcoded set, which was brittle as the strings drifted). Internal // debug log messages stay English-only since they're developer-facing. const BACKEND_MESSAGES = { de: { invalidVodUrl: 'Ungueltige VOD-URL', invalidClipUrl: 'Ungueltige Clip-URL', clipNotFound: 'Clip nicht gefunden', streamlinkAutoInstallFailed: 'Streamlink fehlt und konnte nicht automatisch installiert werden. Siehe debug.log.', streamlinkMissing: 'Streamlink fehlt.', streamlinkNotFound: 'Streamlink nicht gefunden. Installiere Streamlink oder Python+streamlink (py -3 -m pip install streamlink).', streamlinkExitCode: 'Streamlink Fehlercode {code}', ffmpegMissing: 'FFmpeg fehlt.', ffmpegMergeFailed: 'FFmpeg Merge fehlgeschlagen.', ffmpegSplitFailed: 'FFmpeg Split fehlgeschlagen.', fileTooSmall: 'Datei zu klein ({bytes} Bytes)', clipFileTooSmall: 'Clip-Datei zu klein ({bytes} Bytes) - Twitch hat den Stream evtl. nicht ausgeliefert.', integrityNoVideo: 'Integritaetspruefung fehlgeschlagen: Kein Videostream gefunden.', integrityTooShort: 'Integritaetspruefung fehlgeschlagen: Dauer zu kurz ({duration}s).', integrityDurationMismatch: 'Integritaetspruefung fehlgeschlagen: {actual}s statt erwarteter ~{expected}s.', integrityFailedGeneric: 'Integritaetspruefung fehlgeschlagen.', downloadCancelled: 'Download wurde abgebrochen.', downloadPaused: 'Download wurde pausiert.', downloadFailedExitCode: 'Download fehlgeschlagen (Exit-Code {code})', unknownDownloadError: 'Unbekannter Fehler beim Download', notAllClipPartsDownloaded: 'Nicht alle Clip-Teile konnten heruntergeladen werden.', notAllPartsDownloaded: 'Nicht alle Teile konnten heruntergeladen werden.', mergeGroupFileMissing: 'Heruntergeladene Datei {index} fehlt.', diskSpaceShortFor: 'Zu wenig Speicherplatz fur {context}: frei {free}, benoetigt ~{required}.', diskSpaceShortGeneric: 'Zu wenig Speicherplatz.', attemptFailed: 'Versuch {attempt}/{max} fehlgeschlagen ({errorClass}): {error}', retryingIn: 'Neuer Versuch in {seconds}s ({errorClass})...', statusCheckingTools: 'Prufe Download-Tools...', statusDownloadStarted: 'Download gestartet', statusBytesDownloaded: '{bytes} heruntergeladen', preflightNoInternet: 'Keine Internetverbindung erkannt.', preflightStreamlinkMissing: 'Streamlink fehlt oder ist nicht startbar.', preflightFfmpegMissing: 'FFmpeg fehlt oder ist nicht startbar.', preflightFfprobeMissing: 'FFprobe fehlt oder ist nicht startbar.', preflightDownloadPathNotWritable: 'Download-Ordner ist nicht beschreibbar.' }, en: { invalidVodUrl: 'Invalid VOD URL', invalidClipUrl: 'Invalid clip URL', clipNotFound: 'Clip not found', streamlinkAutoInstallFailed: 'Streamlink is missing and could not be auto-installed. See debug.log.', streamlinkMissing: 'Streamlink is missing.', streamlinkNotFound: 'Streamlink not found. Install streamlink or Python+streamlink (py -3 -m pip install streamlink).', streamlinkExitCode: 'Streamlink exit code {code}', ffmpegMissing: 'FFmpeg is missing.', ffmpegMergeFailed: 'FFmpeg merge failed.', ffmpegSplitFailed: 'FFmpeg split failed.', fileTooSmall: 'File too small ({bytes} bytes)', clipFileTooSmall: 'Clip file too small ({bytes} bytes) - Twitch may not have served the stream.', integrityNoVideo: 'Integrity check failed: no video stream found.', integrityTooShort: 'Integrity check failed: duration too short ({duration}s).', integrityDurationMismatch: 'Integrity check failed: {actual}s instead of expected ~{expected}s.', integrityFailedGeneric: 'Integrity check failed.', downloadCancelled: 'Download was cancelled.', downloadPaused: 'Download was paused.', downloadFailedExitCode: 'Download failed (exit code {code})', unknownDownloadError: 'Unknown download error', notAllClipPartsDownloaded: 'Not all clip parts could be downloaded.', notAllPartsDownloaded: 'Not all parts could be downloaded.', mergeGroupFileMissing: 'Downloaded file {index} is missing.', diskSpaceShortFor: 'Not enough disk space for {context}: free {free}, need ~{required}.', diskSpaceShortGeneric: 'Not enough disk space.', attemptFailed: 'Attempt {attempt}/{max} failed ({errorClass}): {error}', retryingIn: 'Retrying in {seconds}s ({errorClass})...', statusCheckingTools: 'Checking download tools...', statusDownloadStarted: 'Download started', statusBytesDownloaded: '{bytes} downloaded', preflightNoInternet: 'No internet connection detected.', preflightStreamlinkMissing: 'Streamlink is missing or not runnable.', preflightFfmpegMissing: 'FFmpeg is missing or not runnable.', preflightFfprobeMissing: 'FFprobe is missing or not runnable.', preflightDownloadPathNotWritable: 'Download folder is not writable.' } } as const; type BackendMessageKey = keyof typeof BACKEND_MESSAGES.de; function tBackend(key: BackendMessageKey, params?: Record): string { const lang: 'de' | 'en' = config.language === 'en' ? 'en' : 'de'; let template: string = BACKEND_MESSAGES[lang][key]; if (params) { for (const [k, v] of Object.entries(params)) { template = template.replace(`{${k}}`, String(v)); } } return template; } // 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; parallel_downloads: number; auto_resume_queue_on_startup: boolean; downloaded_vod_ids: string[]; } 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 PreflightChecks { internet: boolean; streamlink: boolean; ffmpeg: boolean; ffprobe: boolean; downloadPathWritable: boolean; } interface PreflightResult { ok: boolean; autoFixApplied: boolean; checks: PreflightChecks; messages: string[]; timestamp: string; } 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, parallel_downloads: 1, auto_resume_queue_on_startup: false, downloaded_vod_ids: [] }; function normalizeFilenameTemplate(template: string | undefined, fallback: string): string { const value = (template || '').trim(); return value || fallback; } function normalizeMetadataCacheMinutes(value: unknown): number { const parsed = Number(value); if (!Number.isFinite(parsed)) { return DEFAULT_METADATA_CACHE_MINUTES; } return Math.max(1, Math.min(120, Math.floor(parsed))); } function normalizePerformanceMode(mode: unknown): PerformanceMode { if (mode === 'stability' || mode === 'balanced' || mode === 'speed') { return mode; } return DEFAULT_PERFORMANCE_MODE; } function normalizeConfigTemplates(input: Config): Config { // downloaded_vod_ids is bounded so a long-running app doesn't accumulate // an unbounded list across years of downloads. Latest entries kept. const DOWNLOADED_IDS_MAX = 4096; const rawIds = Array.isArray(input.downloaded_vod_ids) ? input.downloaded_vod_ids : []; const cleanIds = rawIds.filter((id): id is string => typeof id === 'string' && id.length > 0); const trimmedIds = cleanIds.length > DOWNLOADED_IDS_MAX ? cleanIds.slice(cleanIds.length - DOWNLOADED_IDS_MAX) : cleanIds; 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), auto_resume_queue_on_startup: input.auto_resume_queue_on_startup === true, downloaded_vod_ids: trimmedIds }; } function recordDownloadedVodId(vodId: string): void { if (!vodId) return; if (!Array.isArray(config.downloaded_vod_ids)) config.downloaded_vod_ids = []; if (config.downloaded_vod_ids.includes(vodId)) return; config.downloaded_vod_ids.push(vodId); // Cap to keep config size bounded — drop oldest first. const DOWNLOADED_IDS_MAX = 4096; if (config.downloaded_vod_ids.length > DOWNLOADED_IDS_MAX) { config.downloaded_vod_ids = config.downloaded_vod_ids.slice( config.downloaded_vod_ids.length - DOWNLOADED_IDS_MAX ); } saveConfig(config); } function isPlainObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } function loadConfig(): Config { try { if (fs.existsSync(CONFIG_FILE)) { const data = fs.readFileSync(CONFIG_FILE, 'utf-8'); const parsed = JSON.parse(data); if (!isPlainObject(parsed)) { console.error('Config file is not a JSON object — using defaults'); return normalizeConfigTemplates(defaultConfig); } return normalizeConfigTemplates({ ...defaultConfig, ...parsed }); } } catch (e) { console.error('Error loading config:', e); } return normalizeConfigTemplates(defaultConfig); } function writeFileAtomicSync(targetPath: string, payload: string | Buffer): void { const buffer = Buffer.isBuffer(payload) ? payload : Buffer.from(payload, 'utf-8'); const tmpPath = targetPath + '.tmp'; let fd: number | null = null; try { fd = fs.openSync(tmpPath, 'w'); fs.writeSync(fd, buffer, 0, buffer.length, 0); try { fs.fsyncSync(fd); } catch { /* fsync may fail on some FS; rename is still safer than nothing */ } } finally { if (fd !== null) { try { fs.closeSync(fd); } catch { } } } try { fs.renameSync(tmpPath, targetPath); } catch { // On Windows, rename can fail if target exists or is locked. Fall back to copy. fs.copyFileSync(tmpPath, targetPath); try { fs.unlinkSync(tmpPath); } catch { } } } function saveConfig(config: Config): void { try { writeFileAtomicSync(CONFIG_FILE, JSON.stringify(config, null, 2)); } catch (e) { console.error('Error saving config:', e); } } // ========================================== // QUEUE MANAGEMENT // ========================================== const VALID_QUEUE_STATUSES: ReadonlyArray = ['pending', 'downloading', 'paused', 'completed', 'error']; const VALID_MERGE_PHASES: ReadonlyArray = ['downloading', 'merging', 'splitting', 'cleanup', 'done']; function isValidQueueStatus(status: unknown): status is QueueItem['status'] { return typeof status === 'string' && (VALID_QUEUE_STATUSES as readonly string[]).includes(status); } function sanitizeMergeGroup(raw: unknown): MergeGroup | undefined { if (!isPlainObject(raw)) return undefined; if (!Array.isArray(raw.items) || raw.items.length < 2) return undefined; const items: MergeGroupItem[] = []; for (const mi of raw.items) { if (!isPlainObject(mi)) continue; if (typeof mi.url !== 'string' || typeof mi.title !== 'string' || typeof mi.date !== 'string' || typeof mi.streamer !== 'string' || typeof mi.duration_str !== 'string') continue; items.push({ url: mi.url, title: mi.title, date: mi.date, streamer: mi.streamer, duration_str: mi.duration_str }); } if (items.length < 2) return undefined; const phase: MergeGroup['mergePhase'] = (VALID_MERGE_PHASES as readonly string[]).includes(String(raw.mergePhase)) ? raw.mergePhase as MergeGroup['mergePhase'] : 'downloading'; const downloadedFiles: Record = {}; if (isPlainObject(raw.downloadedFiles)) { for (const [k, v] of Object.entries(raw.downloadedFiles)) { const idx = Number(k); if (Number.isFinite(idx) && typeof v === 'string') downloadedFiles[idx] = v; } } return { items, mergePhase: phase, currentItemIndex: typeof raw.currentItemIndex === 'number' && Number.isFinite(raw.currentItemIndex) ? raw.currentItemIndex : 0, downloadedFiles, mergedFile: typeof raw.mergedFile === 'string' ? raw.mergedFile : undefined, splitFiles: Array.isArray(raw.splitFiles) ? raw.splitFiles.filter((f): f is string => typeof f === 'string') : undefined, totalDurationSec: typeof raw.totalDurationSec === 'number' && Number.isFinite(raw.totalDurationSec) ? raw.totalDurationSec : undefined }; } function sanitizeCustomClip(raw: unknown): CustomClip | undefined { if (!isPlainObject(raw)) return undefined; const startSec = Number(raw.startSec); const durationSec = Number(raw.durationSec); const startPart = Number(raw.startPart); if (!Number.isFinite(startSec) || !Number.isFinite(durationSec) || durationSec <= 0 || !Number.isFinite(startPart)) return undefined; const filenameFormat = raw.filenameFormat; if (filenameFormat !== 'simple' && filenameFormat !== 'timestamp' && filenameFormat !== 'template' && filenameFormat !== 'parts') return undefined; return { startSec: Math.max(0, startSec), durationSec: Math.max(1, durationSec), startPart: Math.max(1, Math.floor(startPart)), filenameFormat, filenameTemplate: typeof raw.filenameTemplate === 'string' ? raw.filenameTemplate : undefined }; } function sanitizeQueueItem(raw: unknown): QueueItem | null { if (!isPlainObject(raw)) return null; if (typeof raw.id !== 'string' || !raw.id) return null; if (typeof raw.url !== 'string' || !raw.url) return null; if (!isValidQueueStatus(raw.status)) return null; // 'downloading' on cold start is stale — no download is actually running // and the user expects to resume from start, so map it back to 'pending' const isStaleDownloading = raw.status === 'downloading'; const finalStatus: QueueItem['status'] = isStaleDownloading ? 'pending' : raw.status; const progressNum = Number(raw.progress); const safeProgress = Number.isFinite(progressNum) ? Math.max(0, Math.min(100, progressNum)) : 0; const item: QueueItem = { id: raw.id, url: raw.url, title: typeof raw.title === 'string' ? raw.title : '', date: typeof raw.date === 'string' ? raw.date : '', streamer: typeof raw.streamer === 'string' ? raw.streamer : '', duration_str: typeof raw.duration_str === 'string' ? raw.duration_str : '0s', status: finalStatus, progress: isStaleDownloading ? 0 : safeProgress }; if (typeof raw.currentPart === 'number' && Number.isFinite(raw.currentPart)) item.currentPart = raw.currentPart; if (typeof raw.totalParts === 'number' && Number.isFinite(raw.totalParts)) item.totalParts = raw.totalParts; if (typeof raw.speed === 'string') item.speed = raw.speed; if (typeof raw.eta === 'string') item.eta = raw.eta; if (typeof raw.last_error === 'string') item.last_error = raw.last_error; if (typeof raw.downloadedBytes === 'number' && Number.isFinite(raw.downloadedBytes)) item.downloadedBytes = raw.downloadedBytes; if (typeof raw.totalBytes === 'number' && Number.isFinite(raw.totalBytes)) item.totalBytes = raw.totalBytes; if (Array.isArray(raw.outputFiles)) { const files = raw.outputFiles.filter((f): f is string => typeof f === 'string' && f.length > 0); if (files.length > 0) item.outputFiles = files; } const customClip = sanitizeCustomClip(raw.customClip); if (customClip) item.customClip = customClip; const mergeGroup = sanitizeMergeGroup(raw.mergeGroup); if (mergeGroup) item.mergeGroup = mergeGroup; return item; } 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'); const parsed = JSON.parse(data); if (!Array.isArray(parsed)) { console.error('Queue file is not a JSON array — ignoring'); return []; } const items: QueueItem[] = []; let droppedCount = 0; for (const raw of parsed) { const sanitized = sanitizeQueueItem(raw); if (sanitized) items.push(sanitized); else droppedCount++; } if (droppedCount > 0) { console.error(`loadQueue: dropped ${droppedCount} invalid queue item(s)`); } return items; } } 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 { writeFileAtomicSync(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; // Process handle for the standalone video editor pipeline (cutter / merger / // splitter). Queue downloads track their own children via activeDownloads, // and clip downloads via activeClipProcesses. Keeping these separate // prevents cancel-download from killing an unrelated cutter ffmpeg. let currentEditorProcess: ChildProcess | null = null; let currentDownloadCancelled = false; let pauseRequested = false; let activeQueueItemId: string | null = null; let downloadStartTime = 0; let downloadedBytes = 0; // Per-item tracking for parallel downloads const activeDownloads = new Map(); const cancelledItemIds = new Set(); 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 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; 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(tBackend('preflightNoInternet')); if (!checks.streamlink) messages.push(tBackend('preflightStreamlinkMissing')); if (!checks.ffmpeg) messages.push(tBackend('preflightFfmpegMissing')); if (!checks.ffprobe) messages.push(tBackend('preflightFfprobeMissing')); if (!checks.downloadPathWritable) messages.push(tBackend('preflightDownloadPathNotWritable')); 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 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 } } // Wire up tools module with debug logging and directory paths setDebugLogFn(appendDebugLog); initToolDirs(TOOLS_STREAMLINK_DIR, TOOLS_FFMPEG_DIR, () => app.getPath('temp')); // ========================================== // DURATION HELPERS // ========================================== function parseDuration(duration: string): number { let seconds = 0; const hours = duration.match(/(\d+)h/); const minutes = duration.match(/(\d+)m/); const secs = duration.match(/(\d+)s/); if (hours) seconds += parseInt(hours[1]) * 3600; if (minutes) seconds += parseInt(minutes[1]) * 60; if (secs) seconds += parseInt(secs[1]); return seconds; } function formatDuration(seconds: number): string { if (!isFinite(seconds) || seconds < 0) return '00:00:00'; const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); const s = Math.floor(seconds % 60); return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; } function formatDurationDashed(seconds: number): string { if (!isFinite(seconds) || seconds < 0) return '00-00-00'; const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); const s = Math.floor(seconds % 60); return `${h.toString().padStart(2, '0')}-${m.toString().padStart(2, '0')}-${s.toString().padStart(2, '0')}`; } const claimedFilenames = new Set(); const itemClaimedFilenames = new Map>(); function ensureUniqueFilename(filePath: string, itemId: string | null = null): string { const dir = path.dirname(filePath); const ext = path.extname(filePath); const base = path.basename(filePath, ext); let candidate = filePath; let counter = 0; while (fs.existsSync(candidate) || claimedFilenames.has(candidate)) { counter++; candidate = path.join(dir, `${base}_${counter}${ext}`); } claimedFilenames.add(candidate); if (itemId) { let perItem = itemClaimedFilenames.get(itemId); if (!perItem) { perItem = new Set(); itemClaimedFilenames.set(itemId, perItem); } perItem.add(candidate); } return candidate; } function releaseClaimedFilenamesForItem(itemId: string): void { const perItem = itemClaimedFilenames.get(itemId); if (!perItem) return; for (const f of perItem) claimedFilenames.delete(f); itemClaimedFilenames.delete(itemId); } 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 = tBackend('diskSpaceShortFor', { context, free: formatBytes(freeBytes), required: 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; } const requestPromise: Promise = 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('streamlink not found') || text.includes('streamlink is missing') || text.includes('ffmpeg') || text.includes('ffprobe') || text.includes('enoent')) return 'tooling'; if (text.includes('integritaet') || text.includes('integrity') || text.includes('kein videostream') || text.includes('no video stream')) return 'integrity'; if (text.includes('access denied') || text.includes('permission') || text.includes('disk') || text.includes('file') || text.includes('ordner') || text.includes('folder')) 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: tBackend('integrityNoVideo') }; } if (probed.durationSeconds <= 1) { runtimeMetrics.integrityFailures += 1; return { success: false, error: tBackend('integrityTooShort', { duration: probed.durationSeconds.toFixed(2) }) }; } if (expectedDurationSeconds && expectedDurationSeconds > 4) { const minExpected = Math.max(2, expectedDurationSeconds * 0.45); if (probed.durationSeconds < minExpected) { runtimeMetrics.integrityFailures += 1; return { success: false, error: tBackend('integrityDurationMismatch', { actual: probed.durationSeconds.toFixed(1), expected: String(expectedDurationSeconds) }) }; } } 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; } const loginPromise: Promise = 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`; } // Transient HTTP errors that warrant a retry (5xx, 408 timeout, 429 rate limit). // 4xx (other than 408/429) are application errors and not retried. function isTransientAxiosError(err: unknown): boolean { if (!axios.isAxiosError(err)) { // Non-axios errors thrown from axios.post are typically network-layer // failures (DNS, ECONNRESET, socket hangup) — retry those too. return true; } const status = err.response?.status; if (status === undefined) { // No response means the request never reached / never returned — // treat as transient (network blip, timeout). return true; } return status === 408 || status === 429 || (status >= 500 && status < 600); } const TWITCH_GQL_RETRY_ATTEMPTS = 3; const TWITCH_GQL_RETRY_BASE_DELAY_MS = 400; async function fetchPublicTwitchGql(query: string, variables: Record): Promise { let lastError: unknown = null; for (let attempt = 1; attempt <= TWITCH_GQL_RETRY_ATTEMPTS; attempt++) { 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 } ); // GraphQL errors (in `errors[]`) are application-level and not // retried — the query itself is rejected. if (response.data.errors?.length) { const messages = response.data.errors.map((err) => err.message).join('; '); appendDebugLog('public-gql-errors', { messages, attempt }); console.error('Public Twitch GQL errors:', messages); return null; } if (attempt > 1) { appendDebugLog('public-gql-recovered', { attempt }); } return response.data.data || null; } catch (e) { lastError = e; const transient = isTransientAxiosError(e); const willRetry = transient && attempt < TWITCH_GQL_RETRY_ATTEMPTS; appendDebugLog('public-gql-failed', { attempt, maxAttempts: TWITCH_GQL_RETRY_ATTEMPTS, transient, willRetry, error: String(e) }); if (!willRetry) { break; } // Exponential backoff with jitter const delay = TWITCH_GQL_RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1) + Math.floor(Math.random() * 250); await sleep(delay); } } console.error('Public Twitch GQL request failed:', lastError); 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 MAX_VOD_PAGES = 50; // 50 pages x 100 per page = 5000 VODs max const fetchVodsPage = async (cursor?: string) => { const params: Record = { user_id: userId, type: 'archive', first: 100 }; if (cursor) params.after = cursor; return await axios.get('https://api.twitch.tv/helix/videos', { params, headers: { 'Client-ID': config.client_id, 'Authorization': `Bearer ${accessToken}` }, timeout: API_TIMEOUT }); }; const fetchAllVodPages = async (): Promise => { const allVods: VOD[] = []; let cursor: string | undefined; let pageCount = 0; do { const response = await fetchVodsPage(cursor); const pageVods = response.data.data || []; allVods.push(...pageVods); if (pageCount === 0) { const login = pageVods[0]?.user_login; if (login) { userIdLoginCache.set(userId, normalizeLogin(login)); } } cursor = response.data.pagination?.cursor; pageCount++; } while (cursor && pageCount < MAX_VOD_PAGES); return allVods; }; try { const vods = await fetchAllVodPages(); 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 vods = await fetchAllVodPages(); 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 { } 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 }); currentEditorProcess = 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) => { currentEditorProcess = null; if (code === 0 && fs.existsSync(outputFile)) { const stats = fs.statSync(outputFile); if (stats.size <= 256) { appendDebugLog('cut-video-empty-output', { outputFile, bytes: stats.size }); resolve(false); return; } resolve(true); } else { resolve(false); } }); proc.on('error', () => { currentEditorProcess = 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, totalDurationSec?: number ): 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) => { const normalized = filePath.replace(/\\/g, '/'); return `file '${normalized.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; } // Determine total duration for accurate progress let mergeTotalDurationUs = 0; if (totalDurationSec && totalDurationSec > 0) { mergeTotalDurationUs = totalDurationSec * 1_000_000; } else { // Fallback: use ffprobe to get total duration of all input files const ffprobe = getFFprobePath(); for (const filePath of inputFiles) { try { const result = execSync( `"${ffprobe}" -v quiet -show_entries format=duration -of csv=p=0 "${filePath}"`, { timeout: 10000, windowsHide: true } ).toString().trim(); const dur = parseFloat(result); if (!isNaN(dur)) { mergeTotalDurationUs += dur * 1_000_000; } } catch { // If ffprobe fails, fall back to old behavior } } } 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 }); currentEditorProcess = 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); if (mergeTotalDurationUs > 0) { onProgress(Math.min(99, (currentUs / mergeTotalDurationUs) * 100)); } else { onProgress(Math.min(99, currentUs / 10000000)); } } }); proc.on('close', (code) => { currentEditorProcess = null; const success = code === 0 && fs.existsSync(outputFile); if (success) { onProgress(100); } resolve(success); }); proc.on('error', () => { currentEditorProcess = 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 { } } } // ========================================== // SPLIT MERGED FILE // ========================================== async function splitMergedFile( inputFile: string, outputFolder: string, partDurationSec: number, totalDurationSec: number, filenameGenerator: (partNum: number) => string, onProgress: (currentPart: number, totalParts: number) => void, itemId: string | null = null ): Promise<{ success: boolean; files: string[] }> { const ffmpegReady = await ensureFfmpegInstalled(); if (!ffmpegReady) { appendDebugLog('split-merged-missing-ffmpeg'); return { success: false, files: [] }; } const ffmpeg = getFFmpegPath(); const numParts = Math.ceil(totalDurationSec / partDurationSec); const splitFiles: string[] = []; for (let i = 0; i < numParts; i++) { if (currentDownloadCancelled) { return { success: false, files: splitFiles }; } const startSec = i * partDurationSec; const thisDuration = Math.min(partDurationSec, totalDurationSec - startSec); const outputFile = ensureUniqueFilename(path.join(outputFolder, filenameGenerator(i + 1)), itemId); onProgress(i + 1, numParts); const args = [ '-ss', formatDuration(startSec), '-i', inputFile, '-t', formatDuration(thisDuration), '-c', 'copy', '-y', outputFile ]; appendDebugLog('split-merged-part', { part: i + 1, total: numParts, startSec, duration: thisDuration }); const success = await new Promise((resolve) => { const proc = spawn(ffmpeg, args, { windowsHide: true }); currentEditorProcess = proc; proc.on('close', (code) => { currentEditorProcess = null; resolve(code === 0 && fs.existsSync(outputFile)); }); proc.on('error', () => { currentEditorProcess = null; resolve(false); }); }); if (!success) { appendDebugLog('split-merged-part-failed', { part: i + 1, outputFile }); return { success: false, files: splitFiles }; } splitFiles.push(outputFile); } return { success: true, files: splitFiles }; } // ========================================== // 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); let lastStreamlinkPercent = 0; 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 }); // Register in per-item tracking map for parallel downloads // (no longer mirrored on a global — currentEditorProcess is editor-only) const itemTracking = { process: proc, cancelled: false, startTime: Date.now(), bytes: 0 }; activeDownloads.set(itemId, itemTracking); downloadStartTime = itemTracking.startTime; 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; itemTracking.bytes = 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; let etaStr = ''; if (downloadedBytes > 0) { const elapsedSec = (Date.now() - (itemTracking?.startTime || Date.now())) / 1000; if (elapsedSec > 5 && lastStreamlinkPercent > 1) { // Use streamlink's reported progress for accurate ETA const remainingSec = (elapsedSec / lastStreamlinkPercent) * (100 - lastStreamlinkPercent); if (remainingSec > 0 && remainingSec < 86400) { etaStr = formatETA(remainingSec); } } } onProgress({ id: itemId, progress: -1, // Unknown total speed: formatSpeed(speed), eta: etaStr, status: tBackend('statusBytesDownloaded', { bytes: formatBytes(downloadedBytes) }), 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]); lastStreamlinkPercent = percent; 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); activeDownloads.delete(itemId); if (currentDownloadCancelled || cancelledItemIds.has(itemId)) { cancelledItemIds.delete(itemId); appendDebugLog('download-part-cancelled', { itemId, filename }); resolve({ success: false, error: tBackend('downloadCancelled') }); return; } if (code === 0 && fs.existsSync(filename)) { const stats = fs.statSync(filename); if (stats.size <= MIN_FILE_BYTES) { const tooSmall = tBackend('fileTooSmall', { bytes: String(stats.size) }); 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 || tBackend('streamlinkExitCode', { code: String(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); activeDownloads.delete(itemId); const rawError = String(err); const errorMessage = rawError.includes('ENOENT') ? tBackend('streamlinkNotFound') : 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: tBackend('invalidVodUrl') }; } 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: tBackend('statusCheckingTools'), currentPart: 0, totalParts: 0 }); } const streamlinkReady = await ensureStreamlinkInstalled(); if (!streamlinkReady) { return { success: false, error: tBackend('streamlinkAutoInstallFailed') }; } onProgress({ id: item.id, progress: -1, speed: '', eta: '', status: tBackend('statusDownloadStarted'), 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`); } if (clip.filenameFormat === 'parts') { // Mirrors the global filename_template_parts default: // `{date}_Part{part_padded}.mp4` -> e.g. 08.05.2026_Part07.mp4 return path.join(folder, `${dateStr}_Part${partNum.toString().padStart(2, '0')}.mp4`); } 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 || cancelledItemIds.has(item.id)) 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 = ensureUniqueFilename(makeClipFilename(partNum, startOffset, thisDuration), item.id); 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 : tBackend('notAllClipPartsDownloaded'), outputFiles: downloadedFiles.length === numParts ? [...downloadedFiles] : undefined }; } else { // Single clip file const filename = ensureUniqueFilename(makeClipFilename(clip.startPart, clip.startSec, clip.durationSec), item.id); const result = await downloadVODPart( item.url, filename, formatDuration(clip.startSec), formatDuration(clip.durationSec), onProgress, item.id, 1, 1 ); return result.success ? { ...result, outputFiles: [filename] } : result; } } // Check download mode if (config.download_mode === 'full' || totalDuration <= config.part_minutes * 60) { // Full download const filename = ensureUniqueFilename(makeTemplateFilename( config.filename_template_vod, DEFAULT_FILENAME_TEMPLATE_VOD, 1, 0, totalDuration ), item.id); const result = await downloadVODPart(item.url, filename, null, null, onProgress, item.id, 1, 1); return result.success ? { ...result, outputFiles: [filename] } : result; } 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 || cancelledItemIds.has(item.id)) break; const startSec = i * partDuration; const endSec = Math.min((i + 1) * partDuration, totalDuration); const duration = endSec - startSec; const partFilename = ensureUniqueFilename(makeTemplateFilename( config.filename_template_parts, DEFAULT_FILENAME_TEMPLATE_PARTS, i + 1, startSec, duration ), item.id); 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 : tBackend('notAllPartsDownloaded'), outputFiles: downloadedFiles.length === numParts ? [...downloadedFiles] : undefined }; } } // ========================================== // MERGE GROUP DOWNLOAD PIPELINE // ========================================== async function processDownloadMergeGroup( item: QueueItem, onProgress: (progress: DownloadProgress) => void ): Promise { const mg = item.mergeGroup!; const totalDurationSec = mg.totalDurationSec || mg.items.reduce((sum, i) => sum + parseDuration(i.duration_str), 0); mg.totalDurationSec = totalDurationSec; // ---- PHASE 1: DOWNLOADING ---- if (mg.mergePhase === 'downloading') { const streamlinkReady = await ensureStreamlinkInstalled(); if (!streamlinkReady) { return { success: false, error: tBackend('streamlinkMissing') }; } const ffmpegReady = await ensureFfmpegInstalled(); if (!ffmpegReady) { return { success: false, error: tBackend('ffmpegMissing') }; } const streamer = mg.items[0].streamer.replace(/[^a-zA-Z0-9_-]/g, ''); const date = new Date(mg.items[0].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 }); // Disk space pre-check: 3x total estimated size const estimatedBytes = mg.items.reduce((sum, i) => { const dur = parseDuration(i.duration_str); return sum + Math.ceil(dur * 500_000); // ~500KB/s estimate }, 0); const requiredBytes = Math.max(256 * 1024 * 1024, estimatedBytes * 3); const diskCheck = ensureDiskSpace(folder, requiredBytes, 'Merge-Group-Download'); if (!diskCheck.success) { return diskCheck; } for (let i = 0; i < mg.items.length; i++) { if (currentDownloadCancelled || cancelledItemIds.has(item.id)) { return { success: false, error: tBackend('downloadCancelled') }; } // Skip already downloaded files (retry recovery) if (mg.downloadedFiles[i] && fs.existsSync(mg.downloadedFiles[i])) { appendDebugLog('merge-group-skip-existing', { index: i, file: mg.downloadedFiles[i] }); continue; } // Reset stale per-item cancel state (global cancel already checked above) cancelledItemIds.delete(item.id); mg.currentItemIndex = i; mg.mergePhase = 'downloading'; saveQueue(downloadQueue); const vodItem = mg.items[i]; const tmpFilename = ensureUniqueFilename(path.join(folder, `merge_tmp_${i}_${Date.now()}.mp4`), item.id); // Calculate progress weighting per VOD const vodDuration = parseDuration(vodItem.duration_str); const vodWeight = vodDuration / totalDurationSec; const priorWeight = mg.items.slice(0, i).reduce((s, v) => s + parseDuration(v.duration_str), 0) / totalDurationSec; const result = await downloadVODPart( vodItem.url, tmpFilename, null, // startTime: null = full VOD null, // endTime: null = full VOD (progress) => { // Weighted progress: download phase = 0-70% const vodProgress = progress.progress > 0 ? progress.progress : 0; const overallProgress = (priorWeight + vodWeight * (vodProgress / 100)) * 70; onProgress({ ...progress, id: item.id, progress: overallProgress, status: `${getMergeGroupPhaseText('downloading')} ${i + 1}/${mg.items.length} — ${progress.status}`, currentPart: i + 1, totalParts: mg.items.length }); }, item.id, i + 1, mg.items.length ); if (!result.success) { return result; } mg.downloadedFiles[i] = tmpFilename; saveQueue(downloadQueue); } } // ---- PHASE 2: MERGING ---- mg.mergePhase = 'merging'; saveQueue(downloadQueue); emitQueueUpdated(); // Check all downloaded files exist (retry recovery) for (let i = 0; i < mg.items.length; i++) { if (!mg.downloadedFiles[i] || !fs.existsSync(mg.downloadedFiles[i])) { mg.mergePhase = 'downloading'; return { success: false, error: tBackend('mergeGroupFileMissing', { index: i + 1 }) }; } } if (!mg.mergedFile || !fs.existsSync(mg.mergedFile)) { const streamer = mg.items[0].streamer.replace(/[^a-zA-Z0-9_-]/g, ''); const date = new Date(mg.items[0].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); const mergedFilePath = path.join(folder, `merged_${Date.now()}.mp4`); // Get files in correct order (explicit sort by index — do NOT rely on Object.values ordering) const sortedFiles = Object.keys(mg.downloadedFiles) .sort((a, b) => Number(a) - Number(b)) .map(k => mg.downloadedFiles[Number(k)]); const mergeSuccess = await mergeVideos( sortedFiles, mergedFilePath, (percent) => { const overallProgress = 70 + (percent / 100) * 20; // merge = 70-90% onProgress({ id: item.id, progress: overallProgress, speed: '', eta: '', status: getMergeGroupPhaseText('merging'), currentPart: 0, totalParts: 0 }); }, totalDurationSec ); if (!mergeSuccess) { return { success: false, error: tBackend('ffmpegMergeFailed') }; } mg.mergedFile = mergedFilePath; saveQueue(downloadQueue); } // ---- PHASE 3: SPLITTING ---- mg.mergePhase = 'splitting'; saveQueue(downloadQueue); emitQueueUpdated(); if (currentDownloadCancelled || cancelledItemIds.has(item.id)) { return { success: false, error: tBackend('downloadCancelled') }; } const partDuration = config.part_minutes * 60; const streamer = mg.items[0].streamer.replace(/[^a-zA-Z0-9_-]/g, ''); const date = new Date(mg.items[0].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); const vodId = parseVodId(mg.items[0].url) || 'merged'; const splitResult = await splitMergedFile( mg.mergedFile!, folder, partDuration, totalDurationSec, (partNum: number) => { const startSec = (partNum - 1) * partDuration; const thisDuration = Math.min(partDuration, totalDurationSec - startSec); return renderClipFilenameTemplate({ template: normalizeFilenameTemplate(config.filename_template_parts, DEFAULT_FILENAME_TEMPLATE_PARTS), title: mg.items[0].title, vodId, channel: mg.items[0].streamer, date, part: partNum, partPadded: partNum.toString().padStart(2, '0'), trimStartSec: startSec, trimEndSec: startSec + thisDuration, trimLengthSec: thisDuration, fullLengthSec: totalDurationSec }); }, (currentPart, totalParts) => { const overallProgress = 90 + ((currentPart - 1) / totalParts) * 10; // split = 90-100% onProgress({ id: item.id, progress: overallProgress, speed: '', eta: '', status: `${getMergeGroupPhaseText('splitting')} ${currentPart}/${totalParts}...`, currentPart, totalParts }); }, item.id ); if (!splitResult.success) { // Clean up any partial split files for (const partFile of splitResult.files) { try { if (fs.existsSync(partFile)) fs.unlinkSync(partFile); } catch { } } return { success: false, error: tBackend('ffmpegSplitFailed') }; } mg.splitFiles = splitResult.files; // ---- PHASE 4: CLEANUP ---- mg.mergePhase = 'cleanup'; saveQueue(downloadQueue); // Delete individual downloads for (const key of Object.keys(mg.downloadedFiles)) { const filePath = mg.downloadedFiles[Number(key)]; try { if (fs.existsSync(filePath)) fs.unlinkSync(filePath); } catch { } } // Delete merged file if (mg.mergedFile) { try { if (fs.existsSync(mg.mergedFile)) fs.unlinkSync(mg.mergedFile); } catch { } } mg.mergePhase = 'done'; appendDebugLog('merge-group-complete', { itemId: item.id, parts: splitResult.files.length, totalDurationSec }); return { success: true, outputFiles: [...splitResult.files] }; } async function processOneQueueItem(item: QueueItem): Promise { 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; cancelledItemIds.delete(item.id); item.status = 'downloading'; saveQueue(downloadQueue); emitQueueUpdated(); item.last_error = ''; try { let finalResult: DownloadResult = { success: false, error: tBackend('unknownDownloadError') }; const maxAttempts = getRetryAttemptLimit(); for (let attempt = 1; attempt <= maxAttempts; attempt++) { appendDebugLog('queue-item-attempt', { itemId: item.id, attempt, max: maxAttempts }); const result = item.mergeGroup ? await processDownloadMergeGroup(item, (progress) => { mainWindow?.webContents.send('download-progress', progress); }) : await downloadVOD(item, (progress) => { mainWindow?.webContents.send('download-progress', progress); }); if (result.success) { finalResult = result; break; } finalResult = result; if (!isDownloading || currentDownloadCancelled || cancelledItemIds.has(item.id) || pauseRequested) { finalResult = { success: false, error: pauseRequested ? tBackend('downloadPaused') : tBackend('downloadCancelled') }; 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 = tBackend('attemptFailed', { attempt, max: maxAttempts, errorClass, error: result.error || tBackend('unknownDownloadError') }); mainWindow?.webContents.send('download-progress', { id: item.id, progress: -1, speed: '', eta: '', status: tBackend('retryingIn', { seconds: retryDelaySeconds, 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 }); return; } 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 || tBackend('unknownDownloadError')); if (finalResult.success && Array.isArray(finalResult.outputFiles) && finalResult.outputFiles.length > 0) { // Attach the produced file paths so the renderer can offer // "Open file" / "Show in folder" actions on completed items, // surviving a queue persistence round-trip. item.outputFiles = [...finalResult.outputFiles]; } if (finalResult.success) { // Record the VOD ID so the renderer can mark this VOD as // already-downloaded the next time the user browses the // streamer's archive. Merge groups don't have a single VOD // ID — record each component instead. if (item.mergeGroup?.items?.length) { for (const m of item.mergeGroup.items) { const id = parseVodId(m.url); if (id) recordDownloadedVodId(id); } } else { const id = parseVodId(item.url); if (id) recordDownloadedVodId(id); } } if (finalResult.success) { runtimeMetrics.downloadsCompleted += 1; } else if (!wasPaused) { runtimeMetrics.downloadsFailed += 1; } appendDebugLog('queue-item-finished', { itemId: item.id, status: item.status, error: item.last_error }); saveQueue(downloadQueue); emitQueueUpdated(); } finally { activeDownloads.delete(item.id); cancelledItemIds.delete(item.id); // Release only THIS item's claimed filenames (other parallel downloads keep their claims) releaseClaimedFilenamesForItem(item.id); } } 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, parallelDownloads: config.parallel_downloads || 1 }); isDownloading = true; pauseRequested = false; currentDownloadCancelled = false; cancelledItemIds.clear(); mainWindow?.webContents.send('download-started'); emitQueueUpdated(); const maxSlots = Math.min(Math.max(1, config.parallel_downloads || 1), 2); const activePromises = new Map>(); while (isDownloading && !pauseRequested) { // Clean up finished promises for (const [id] of activePromises) { const queueItem = downloadQueue.find(i => i.id === id); if (!queueItem || queueItem.status !== 'downloading') { activePromises.delete(id); } } // Fill available slots while (activePromises.size < maxSlots && !pauseRequested) { const item = pickNextPendingQueueItem(); if (!item) break; const itemPromise = processOneQueueItem(item); activePromises.set(item.id, itemPromise); } if (activePromises.size === 0) break; // Wait for any one download to finish before re-checking await Promise.race([...activePromises.values()]); } // Wait for all remaining active downloads to complete if (activePromises.size > 0) { await Promise.allSettled([...activePromises.values()]); } isDownloading = false; pauseRequested = false; runtimeMetrics.activeItemId = null; runtimeMetrics.activeItemTitle = null; activeQueueItemId = null; activeDownloads.clear(); cancelledItemIds.clear(); saveQueue(downloadQueue); emitQueueUpdated(); mainWindow?.webContents.send('download-finished'); try { if (Notification.isSupported()) { const completed = downloadQueue.filter(i => i.status === 'completed').length; const failed = downloadQueue.filter(i => i.status === 'error').length; const notification = new Notification({ title: 'Twitch VOD Manager', body: failed > 0 ? `${completed} Downloads fertig, ${failed} fehlgeschlagen` : `${completed} Downloads abgeschlossen` }); // Click brings the app to the foreground AND opens the download // folder so the user can immediately see the output files. notification.on('click', () => { try { if (mainWindow) { if (mainWindow.isMinimized()) mainWindow.restore(); mainWindow.focus(); } if (config.download_path && fs.existsSync(config.download_path)) { void shell.openPath(config.download_path); } } catch (e) { appendDebugLog('notification-click-failed', String(e)); } }); notification.show(); } } catch { } appendDebugLog('queue-finished', { items: downloadQueue.length }); } // ========================================== // WINDOW CREATION // ========================================== function createWindow(): void { nativeTheme.themeSource = config.theme === 'light' ? 'light' : '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)); } // Auto-resume: if the user opted in AND the persisted queue has // pending entries, kick off processing after a short delay so the // UI has time to render and the user can still pause if they want. if (config.auto_resume_queue_on_startup && !isDownloading) { const hasPending = downloadQueue.some((it) => it.status === 'pending'); if (hasPending) { appendDebugLog('auto-resume-queue-scheduled', { pending: downloadQueue.filter((it) => it.status === 'pending').length }); setTimeout(() => { if (config.auto_resume_queue_on_startup && !isDownloading && downloadQueue.some((it) => it.status === 'pending')) { void processQueue(); } }, 5000); } } }); 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; const previousTheme = config.theme; config = normalizeConfigTemplates({ ...config, ...newConfig }); if (config.client_id !== previousClientId || config.client_secret !== previousClientSecret) { accessToken = null; twitchLoginInFlight = null; } if (config.metadata_cache_minutes !== previousCacheMinutes) { clearMetadataCaches(); } if (config.theme !== previousTheme) { nativeTheme.themeSource = config.theme === 'light' ? 'light' : 'dark'; } 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 || activeDownloads.has(id); if (wasActiveItem) { cancelledItemIds.add(id); const tracking = activeDownloads.get(id); if (tracking?.process) { tracking.process.kill(); } currentDownloadCancelled = true; activeDownloads.delete(id); activeQueueItemId = null; runtimeMetrics.activeItemId = null; runtimeMetrics.activeItemTitle = null; appendDebugLog('queue-item-removed-active-cancelled', { id }); } // Clean up merge-group temp files (must run for any merge group, not just active) const removedItem = downloadQueue.find(item => item.id === id); if (removedItem?.mergeGroup) { const mg = removedItem.mergeGroup; for (const key of Object.keys(mg.downloadedFiles)) { try { if (fs.existsSync(mg.downloadedFiles[Number(key)])) fs.unlinkSync(mg.downloadedFiles[Number(key)]); } catch { } } if (mg.mergedFile) { try { if (fs.existsSync(mg.mergedFile)) fs.unlinkSync(mg.mergedFile); } catch { } } } 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('retry-queue-item', (_, id: string) => { if (typeof id !== 'string' || !id) return downloadQueue; const idx = downloadQueue.findIndex((it) => it.id === id); if (idx < 0) return downloadQueue; const item = downloadQueue[idx]; if (item.status !== 'error') return downloadQueue; downloadQueue[idx] = { ...item, status: 'pending', progress: 0, last_error: '' }; saveQueue(downloadQueue); emitQueueUpdated(); appendDebugLog('queue-item-retry-single', { id, title: item.title }); if (!isDownloading) { void processQueue(); } return downloadQueue; }); ipcMain.handle('create-merge-group', (_, itemIds: string[]) => { const selectedItems = downloadQueue.filter(item => itemIds.includes(item.id)); if (selectedItems.length < 2) { return downloadQueue; } // Validate all are pending if (selectedItems.some(item => item.status !== 'pending')) { return downloadQueue; } // Preserve user-defined order from renderer (itemIds array order) const sorted = itemIds .map(id => selectedItems.find(item => item.id === id)) .filter((item): item is QueueItem => item !== undefined); // Calculate total duration const totalDurationSec = sorted.reduce((sum, item) => sum + parseDuration(item.duration_str), 0); const totalDurationStr = (() => { const h = Math.floor(totalDurationSec / 3600); const m = Math.floor((totalDurationSec % 3600) / 60); const s = totalDurationSec % 60; const parts: string[] = []; if (h > 0) parts.push(`${h}h`); if (m > 0) parts.push(`${m}m`); if (s > 0 || parts.length === 0) parts.push(`${s}s`); return parts.join(''); })(); // Generate title (language-aware) const first = sorted[0]; const isEnglish = config.language === 'en'; const title = sorted.length === 2 ? `Merge: ${first.title} + ${sorted[1].title}` : `Merge: ${first.title} + ${sorted.length - 1} ${isEnglish ? 'more' : 'weitere'}`; // Build merge group const mergeGroup: MergeGroup = { items: sorted.map(item => ({ url: item.url, title: item.title, date: item.date, streamer: item.streamer, duration_str: item.duration_str })), mergePhase: 'downloading', currentItemIndex: 0, downloadedFiles: {}, totalDurationSec }; // Create merged queue item const mergedItem: QueueItem = { id: generateQueueItemId(), title, url: first.url, date: first.date, streamer: first.streamer, duration_str: totalDurationStr, status: 'pending', progress: 0, mergeGroup }; // Find position of first selected item const firstIndex = downloadQueue.findIndex(item => itemIds.includes(item.id)); // Remove selected items and insert merged item at first position downloadQueue = downloadQueue.filter(item => !itemIds.includes(item.id)); downloadQueue.splice(firstIndex >= 0 ? Math.min(firstIndex, downloadQueue.length) : downloadQueue.length, 0, mergedItem); saveQueue(downloadQueue); emitQueueUpdated(); 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(); if (!isDownloading) { void processQueue(); } return true; }); ipcMain.handle('pause-download', () => { if (!isDownloading) return false; pauseRequested = true; currentDownloadCancelled = true; // Kill queue downloads only — cutter/merger/splitter use currentEditorProcess // and aren't affected by pause-download. for (const [id, tracking] of activeDownloads) { cancelledItemIds.add(id); if (tracking.process) { tracking.process.kill(); } } return true; }); ipcMain.handle('cancel-download', () => { isDownloading = false; pauseRequested = false; currentDownloadCancelled = true; // Kill queue downloads only — see pause-download note above. for (const [id, tracking] of activeDownloads) { cancelledItemIds.add(id); if (tracking.process) { tracking.process.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('open-file', async (_, filePath: string): Promise => { if (typeof filePath !== 'string' || !filePath) return false; if (!fs.existsSync(filePath)) return false; const result = await shell.openPath(filePath); // shell.openPath returns '' on success, an error string on failure. return result === ''; }); ipcMain.handle('show-in-folder', (_, filePath: string): boolean => { if (typeof filePath !== 'string' || !filePath) return false; if (!fs.existsSync(filePath)) return false; shell.showItemInFolder(filePath); return true; }); 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); }); // Tracks active standalone clip downloads so cancel-download / window-all-closed // can kill them. Separate from activeDownloads (queue) because clip downloads // don't go through the queue scheduler. const activeClipProcesses = new Map(); 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: tBackend('invalidClipUrl') }; const clipInfo = await getClipInfo(clipId); if (!clipInfo) return { success: false, error: tBackend('clipNotFound') }; // Sanitize broadcaster_name for path safety — Twitch returns the display // name which can contain unicode, spaces, or punctuation that breaks // path joining on some Windows configurations. const safeBroadcaster = sanitizeFilenamePart( typeof clipInfo.broadcaster_name === 'string' ? clipInfo.broadcaster_name : '', 'unknown' ); const folder = path.join(config.download_path, 'Clips', safeBroadcaster); fs.mkdirSync(folder, { recursive: true }); const clipDiskCheck = ensureDiskSpace(folder, 128 * 1024 * 1024, 'Clip-Download'); if (!clipDiskCheck.success) { return { success: false, error: clipDiskCheck.error || tBackend('diskSpaceShortGeneric') }; } const rawTitle = typeof clipInfo.title === 'string' ? clipInfo.title : ''; const safeTitle = (rawTitle.replace(/[^a-zA-Z0-9_\- ]/g, '').trim().substring(0, 50)) || 'clip'; // Use ensureUniqueFilename so retrying a clip with the same title doesn't // overwrite the previous download. itemId is the clipId — if the user // cancels via cancel-download, that's the handle. const filename = ensureUniqueFilename(path.join(folder, `${safeTitle}.mp4`), clipId); return new Promise<{ success: boolean; error?: string; filename?: string }>((resolve) => { const streamlinkCmd = getStreamlinkCommand(); const proc = spawn(streamlinkCmd.command, [ ...streamlinkCmd.prefixArgs, `https://clips.twitch.tv/${clipId}`, 'best', '-o', filename, '--force' ], { windowsHide: true }); activeClipProcesses.set(clipId, proc); appendDebugLog('clip-download-start', { clipId, broadcaster: safeBroadcaster, filename }); proc.on('close', (code) => { activeClipProcesses.delete(clipId); releaseClaimedFilenamesForItem(clipId); if (code !== 0 || !fs.existsSync(filename)) { appendDebugLog('clip-download-failed', { clipId, code }); resolve({ success: false, error: tBackend('downloadFailedExitCode', { code: String(code ?? -1) }) }); return; } // Integrity: clips are short but should still be at least a few KB // and parse as a video stream via ffprobe. Empty/zero-byte files // were previously reported as "success" because exit code was 0. const stats = fs.statSync(filename); if (stats.size < 16 * 1024) { try { fs.unlinkSync(filename); } catch { } appendDebugLog('clip-download-too-small', { clipId, bytes: stats.size }); resolve({ success: false, error: tBackend('clipFileTooSmall', { bytes: String(stats.size) }) }); return; } const integrity = validateDownloadedFileIntegrity(filename, null); if (!integrity.success) { try { fs.unlinkSync(filename); } catch { } appendDebugLog('clip-download-integrity-failed', { clipId, error: integrity.error }); resolve({ success: false, error: integrity.error || tBackend('integrityFailedGeneric') }); return; } appendDebugLog('clip-download-success', { clipId, bytes: stats.size, filename }); resolve({ success: true, filename }); }); proc.on('error', () => { activeClipProcesses.delete(clipId); releaseClaimedFilenamesForItem(clipId); resolve({ success: false, error: tBackend('streamlinkNotFound') }); }); }); }); ipcMain.handle('run-preflight', async (_, autoFix: boolean = false) => { return await runPreflight(autoFix); }); ipcMain.handle('get-debug-log', async (_, lines: number = 200) => { // Cap so a misbehaving renderer (or future feature) cannot ask the // main process to slice millions of lines from a multi-MB log. const safeLines = Number.isFinite(lines) ? Math.max(1, Math.min(5000, Math.floor(lines))) : 200; return readDebugLog(safeLines); }); ipcMain.handle('open-debug-log-file', (): boolean => { if (!fs.existsSync(DEBUG_LOG_FILE)) return false; shell.showItemInFolder(DEBUG_LOG_FILE); return true; }); 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(); // Atomic write: same fsync+rename pattern used for config/queue // (cycle 1) so a power loss mid-export can't leave a half-written // metrics file at the user's chosen path. writeFileAtomicSync(dialogResult.filePath, JSON.stringify(snapshot, null, 2)); 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(() => { app.setAppUserModelId('com.twitch.vodmanager'); refreshBundledToolPaths(true); startMetadataCacheCleanup(); startDebugLogFlushTimer(); createWindow(); appendDebugLog('startup-tools-check-skipped', 'Deferred to first use'); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow(); } }); }); // Both window-all-closed and before-quit ran nearly identical cleanup blocks // before, with slight drift (only window-all-closed killed children, only // window-all-closed did anything platform-specific). Consolidating them into // a single idempotent helper means any future tweak (e.g. flushing a new // debug stream) lands once and applies on every quit path. let shutdownCleanupDone = false; function shutdownCleanup(reason: 'window-all-closed' | 'before-quit'): void { if (shutdownCleanupDone) return; shutdownCleanupDone = true; appendDebugLog('shutdown-cleanup', { reason }); stopMetadataCacheCleanup(); cleanupMetadataCaches('shutdown'); stopAutoUpdatePolling(); // Kill all active children: queue downloads, standalone clip downloads, // and any in-flight cutter/merger/splitter ffmpeg. before-quit used to // skip this entirely; window-all-closed did it but only via direct // kill() (no try/catch around the queue process kill). for (const [, tracking] of activeDownloads) { if (tracking.process) { try { tracking.process.kill(); } catch { /* already exited */ } } } activeDownloads.clear(); for (const [, proc] of activeClipProcesses) { try { proc.kill(); } catch { /* already exited */ } } activeClipProcesses.clear(); if (currentEditorProcess) { try { currentEditorProcess.kill(); } catch { /* already exited */ } currentEditorProcess = null; } saveConfig(config); flushQueueSave(); // Flush debug log AFTER persisting state so any errors saving config / // queue land in the log before the timer is gone. stopDebugLogFlushTimer(true); } app.on('window-all-closed', () => { shutdownCleanup('window-all-closed'); if (process.platform !== 'darwin') { app.quit(); } }); app.on('before-quit', () => { shutdownCleanup('before-quit'); });