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 { connect as tlsConnect, TLSSocket } from 'node:tls'; import axios from 'axios'; import { autoUpdater } from 'electron-updater'; import { compareUpdateVersions, isNewerUpdateVersion, normalizeUpdateVersion } from './main/domain/update-version-utils'; import { writeFileAtomicSync } from './main/infra/fs-atomic'; import { parseDuration, formatDuration, formatDurationDashed } from './main/infra/duration'; import { sanitizeFilenamePart, formatTwitchDurationFromSeconds, formatDateWithPattern, getMergeGroupPhaseText as getMergeGroupPhaseTextCore, } from './main/infra/format-helpers'; import { tBackend as tBackendCore, type BackendMessageKey } from './main/domain/i18n-backend'; import type { DbHandle } from './main/infra/db'; import { normalizeLogin, normalizeAutoRecordPollSeconds, normalizeAutoRecordList, normalizeStreamlinkQuality, normalizeFilenameTemplate, normalizeMetadataCacheMinutes, normalizePerformanceMode, isPlainObject, VALID_STREAMLINK_QUALITIES, type PerformanceMode, } from './main/domain/config-normalize'; import { CustomClip, MergeGroupItem, MergeGroup, QueueItem, DownloadProgress, DownloadResult } from './types'; import { setDebugLogFn, initToolDirs, 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 RetryErrorClass = 'network' | 'rate_limit' | 'auth' | 'tooling' | 'integrity' | 'io' | 'validation' | 'unknown'; type UpdateCheckSource = 'startup' | 'interval' | 'manual'; type UpdateDownloadSource = 'auto' | 'manual'; function getMergeGroupPhaseText(phase: string): string { return getMergeGroupPhaseTextCore(phase, config?.language ?? 'de'); } // ========================================== // BACKEND I18N // ========================================== // Backend-Messages sind in src/main/domain/i18n-backend.ts. // tBackend bleibt als 2-Arg-Adapter hier — pure Variante uebernimmt language // als 3. Parameter, der hier aus config.language injected wird. function tBackend(key: BackendMessageKey, params?: Record): string { return tBackendCore(key, params, config?.language ?? 'de'); } // 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[]; streamlink_quality: string; notify_on_each_completion: boolean; streamlink_disable_ads: boolean; auto_record_streamers: string[]; auto_record_poll_seconds: number; download_chat_replay: boolean; capture_live_chat: boolean; discord_webhook_url: string; discord_notify_live_start: boolean; discord_notify_live_end: boolean; discord_notify_vod_complete: boolean; discord_notify_vod_auto_queued: boolean; auto_cleanup_enabled: boolean; auto_cleanup_days: number; auto_cleanup_target: 'live_only' | 'all'; auto_cleanup_action: 'delete' | 'archive'; log_stream_events: boolean; auto_vod_download_streamers: string[]; auto_vod_download_poll_minutes: number; auto_vod_max_age_hours: number; auto_resume_live_recording: boolean; auto_merge_resumed_parts: boolean; delete_parts_after_merge: boolean; } 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: [], streamlink_quality: 'best', notify_on_each_completion: false, streamlink_disable_ads: true, auto_record_streamers: [], auto_record_poll_seconds: 90, download_chat_replay: false, capture_live_chat: false, discord_webhook_url: '', discord_notify_live_start: false, discord_notify_live_end: false, discord_notify_vod_complete: false, discord_notify_vod_auto_queued: false, auto_cleanup_enabled: false, auto_cleanup_days: 30, auto_cleanup_target: 'live_only', auto_cleanup_action: 'archive', log_stream_events: true, auto_vod_download_streamers: [], auto_vod_download_poll_minutes: 15, auto_vod_max_age_hours: 24, auto_resume_live_recording: true, auto_merge_resumed_parts: false, delete_parts_after_merge: false }; // normalize* helpers + VALID_STREAMLINK_QUALITIES + isPlainObject + normalizeLogin // kommen aus ./main/domain/config-normalize. getStreamlinkStreamArg bleibt // hier, da es config liest. function getStreamlinkStreamArg(): string { const choice = normalizeStreamlinkQuality(config.streamlink_quality); if (choice === 'best') return 'best'; return `${choice},best`; } 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, streamlink_quality: normalizeStreamlinkQuality(input.streamlink_quality), notify_on_each_completion: input.notify_on_each_completion === true, // Default-true on first launch (most users hit this), but respect // an explicit `false` from the loaded config. streamlink_disable_ads: input.streamlink_disable_ads !== false, auto_record_streamers: normalizeAutoRecordList(input.auto_record_streamers), auto_record_poll_seconds: normalizeAutoRecordPollSeconds(input.auto_record_poll_seconds), download_chat_replay: input.download_chat_replay === true, capture_live_chat: input.capture_live_chat === true, // Webhook URL is stored but never validated server-side — invalid // URLs just cause the post to fail (logged, non-fatal). Users with // accidental whitespace are saved by the .trim(). discord_webhook_url: typeof input.discord_webhook_url === 'string' ? input.discord_webhook_url.trim() : '', discord_notify_live_start: input.discord_notify_live_start === true, discord_notify_live_end: input.discord_notify_live_end === true, discord_notify_vod_complete: input.discord_notify_vod_complete === true, discord_notify_vod_auto_queued: input.discord_notify_vod_auto_queued === true, auto_cleanup_enabled: input.auto_cleanup_enabled === true, auto_cleanup_days: (() => { const n = Number(input.auto_cleanup_days); if (!Number.isFinite(n) || n < 1) return 30; return Math.min(3650, Math.floor(n)); })(), auto_cleanup_target: input.auto_cleanup_target === 'all' ? 'all' : 'live_only', auto_cleanup_action: input.auto_cleanup_action === 'delete' ? 'delete' : 'archive', log_stream_events: input.log_stream_events !== false, auto_vod_download_streamers: normalizeAutoRecordList(input.auto_vod_download_streamers), auto_vod_download_poll_minutes: (() => { const n = Number(input.auto_vod_download_poll_minutes); if (!Number.isFinite(n)) return 15; return Math.max(5, Math.min(360, Math.floor(n))); })(), auto_vod_max_age_hours: (() => { const n = Number(input.auto_vod_max_age_hours); if (!Number.isFinite(n)) return 24; return Math.max(1, Math.min(720, Math.floor(n))); })(), auto_resume_live_recording: input.auto_resume_live_recording !== false, auto_merge_resumed_parts: input.auto_merge_resumed_parts === true, delete_parts_after_merge: input.delete_parts_after_merge === true }; } 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 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 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; } if (raw.isLive === true) { item.isLive = true; } 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; // Per-item cancellation lives in `cancelledItemIds`. The previous global // `currentDownloadCancelled` flag was redundant once pause/cancel/remove // started iterating activeDownloads and adding each item to that Set; it // was removed in the 4.5.27 cleanup. 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(); // userId -> login reverse map. Bounded via Map insertion-order eviction so // a long-running session doesn't grow it unbounded across thousands of // streamer lookups. Values are short (~20 char each) but accumulate. const USER_ID_LOGIN_CACHE_MAX = 4096; const userIdLoginCache = new Map(); function setUserIdLogin(userId: string, login: string): void { if (!userId || !login) return; if (userIdLoginCache.has(userId)) { userIdLoginCache.delete(userId); } userIdLoginCache.set(userId, login); while (userIdLoginCache.size > USER_ID_LOGIN_CACHE_MAX) { const oldest = userIdLoginCache.keys().next().value as string | undefined; if (!oldest) break; userIdLoginCache.delete(oldest); } } 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')); 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 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); updateTaskbarProgress(); } // Per-item taskbar progress is tracked here because main's downloadQueue // items don't update their .progress field mid-download (only the renderer // gets a stream of progress events). Map is cleared in processOneQueueItem.finally. const activeDownloadProgress = new Map(); function recordDownloadProgress(progress: DownloadProgress): void { const p = Number(progress.progress); const fraction = Number.isFinite(p) && p > 0 && p <= 100 ? p / 100 : 0.3; activeDownloadProgress.set(progress.id, fraction); updateTaskbarProgress(); } function clearDownloadProgress(itemId: string): void { activeDownloadProgress.delete(itemId); updateTaskbarProgress(); } // Aggregate progress across all currently-downloading items, mapped to the // Windows taskbar progress indicator (-1 = no progress, 0..1 = fraction). // Visible whenever the user has minimised / collapsed the window. Indeterminate // downloads (no percentage yet) report a 30% bar so the taskbar still shows // activity instead of going cold. function updateTaskbarProgress(): void { if (!mainWindow || mainWindow.isDestroyed()) return; const entries = Array.from(activeDownloadProgress.values()); if (entries.length === 0) { try { mainWindow.setProgressBar(-1); } catch { /* unsupported on some platforms */ } return; } const avg = entries.reduce((s, v) => s + v, 0) / entries.length; try { mainWindow.setProgressBar(Math.max(0, Math.min(1, avg))); } catch { /* ignore */ } } 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(); } // 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); setUserIdLogin(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); setUserIdLogin(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); setUserIdLogin(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) { setUserIdLogin(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(); } }); } interface LiveStreamInfo { isLive: boolean; title?: string; gameName?: string; } // Returns whether the streamer is currently live + a little metadata if // available. Tries Helix first (better data), falls back to public GQL when // the user has no client_id/secret configured. A `null` return means we // couldn't determine — caller should treat as "best-effort". async function getLiveStreamInfo(login: string): Promise { const normalized = normalizeLogin(login); if (!normalized) return null; if (await ensureTwitchAuth()) { try { const response = await axios.get('https://api.twitch.tv/helix/streams', { params: { user_login: normalized, first: 1 }, headers: { 'Client-ID': config.client_id, 'Authorization': `Bearer ${accessToken}` }, timeout: API_TIMEOUT }); const entries = response.data?.data || []; if (entries.length === 0) return { isLive: false }; const e = entries[0]; return { isLive: e.type === 'live', title: typeof e.title === 'string' ? e.title : undefined, gameName: typeof e.game_name === 'string' ? e.game_name : undefined }; } catch (e) { appendDebugLog('helix-streams-failed', { login: normalized, error: String(e) }); // fall through to public GQL } } type StreamQueryResult = { user: { stream: { id: string; type: string; title?: string; game?: { name?: string } } | null; } | null; }; const data = await fetchPublicTwitchGql( 'query($login:String!){ user(login:$login){ stream{ id type title game{ name } } } }', { login: normalized } ); if (!data) return null; const stream = data.user?.stream; if (!stream) return { isLive: false }; return { isLive: stream.type === 'live', title: stream.title, gameName: stream.game?.name }; } // ========================================== // STREAMER PROFILE — display-name, avatar, follower count, etc. // ========================================== // User-facing channel header data. Combines Helix /users (display name, // avatar, bio, broadcaster type), public GQL (follower total — Helix // requires moderator scope we don't have), the already-cached VOD list // (vodCount + lastStreamAt come for free), and the live-status cache // (isLive + currentTitle + currentGame). Cached for 30 min per login. interface StreamerProfile { login: string; displayName: string; avatarUrl: string; bannerUrl: string; description: string; broadcasterType: '' | 'partner' | 'affiliate'; followerCount: number | null; vodCount: number; lastStreamAt: string | null; isLive: boolean; currentTitle: string | null; currentGame: string | null; currentStreamPreviewUrl: string; currentStreamViewers: number | null; twitchUrl: string; fetchedAt: number; } const MAX_STREAMER_PROFILE_CACHE_ENTRIES = 512; const streamerProfileCache = new Map>(); const inFlightProfileRequests = new Map>(); // Avatar bytes get embedded as data URLs in the profile so the renderer // doesn't have to do its own HTTPS fetch (Electron's renderer img loader // has a habit of failing silently against the Twitch CDN — undocumented, // but reproducibly: the same URL works in DevTools but not in the live // page). Cached by source URL so a single avatar change across multiple // streamer entries only downloads once. const avatarDataUrlCache = new Map(); const MAX_AVATAR_DATA_URL_CACHE = 256; async function fetchAvatarAsDataUrl(url: string): Promise { if (!url) return ''; const cached = avatarDataUrlCache.get(url); if (cached !== undefined) return cached; try { const response = await axios.get(url, { responseType: 'arraybuffer', timeout: 8000, headers: { 'User-Agent': 'TwitchVODManager/1.0' } }); const buf = Buffer.from(response.data); // Twitch CDN almost always serves PNG or JPEG. Detect from the // response content-type when available, fall back to PNG which is // the default for profile_image_url. const contentType = (response.headers['content-type'] as string | undefined)?.split(';')[0]?.trim() || 'image/png'; const dataUrl = `data:${contentType};base64,${buf.toString('base64')}`; avatarDataUrlCache.set(url, dataUrl); if (avatarDataUrlCache.size > MAX_AVATAR_DATA_URL_CACHE) { // FIFO eviction — Map preserves insertion order. const firstKey = avatarDataUrlCache.keys().next().value as string | undefined; if (firstKey) avatarDataUrlCache.delete(firstKey); } return dataUrl; } catch (e) { appendDebugLog('avatar-fetch-failed', { url, error: String(e) }); return ''; } } interface HelixUser { id: string; login: string; display_name: string; description: string; profile_image_url: string; broadcaster_type: string; } async function fetchHelixUserInfo(login: string): Promise { if (!(await ensureTwitchAuth())) return null; try { const response = await axios.get('https://api.twitch.tv/helix/users', { params: { login }, headers: { 'Client-ID': config.client_id, 'Authorization': `Bearer ${accessToken}` }, timeout: API_TIMEOUT }); const u = response.data?.data?.[0]; if (!u?.id) return null; return u as HelixUser; } catch (e) { appendDebugLog('helix-user-info-failed', { login, error: String(e) }); return null; } } interface PublicProfileQueryResult { user: { id: string; login: string; displayName: string; description: string | null; profileImageURL: string | null; bannerImageURL: string | null; roles?: { isPartner: boolean; isAffiliate: boolean } | null; followers?: { totalCount: number } | null; stream?: { id: string; type: string; title: string | null; viewersCount: number | null; previewImageURL: string | null; game: { name: string } | null; } | null; } | null; } interface PublicStreamerProfileResult { displayName: string; avatarUrl: string; bannerUrl: string; description: string; broadcasterType: '' | 'partner' | 'affiliate'; followerCount: number | null; stream: PublicStreamInfo | null; } interface PublicStreamInfo { previewUrl: string; viewers: number | null; title: string | null; game: string | null; } async function fetchPublicStreamerProfile(login: string): Promise { // Same query also pulls bannerImageURL and the current stream's // preview + viewer count when live — saves a separate roundtrip. const data = await fetchPublicTwitchGql( `query($login: String!) { user(login: $login) { id login displayName description profileImageURL(width: 150) bannerImageURL roles { isPartner isAffiliate } followers { totalCount } stream { id type title viewersCount previewImageURL(width: 640, height: 360) game { name } } } }`, { login } ); if (!data?.user) return null; const roles = data.user.roles; const broadcasterType: '' | 'partner' | 'affiliate' = roles?.isPartner ? 'partner' : (roles?.isAffiliate ? 'affiliate' : ''); const s = data.user.stream; const stream = (s && s.type === 'live') ? { previewUrl: s.previewImageURL || '', viewers: typeof s.viewersCount === 'number' ? s.viewersCount : null, title: s.title || null, game: s.game?.name || null } : null; return { displayName: data.user.displayName || login, avatarUrl: data.user.profileImageURL || '', bannerUrl: data.user.bannerImageURL || '', description: data.user.description || '', broadcasterType, followerCount: typeof data.user.followers?.totalCount === 'number' ? data.user.followers.totalCount : null, stream }; } async function getStreamerProfile(login: string, forceRefresh = false): Promise { const normalized = normalizeLogin(login); if (!normalized) return null; if (!forceRefresh) { const cached = getCachedValue(streamerProfileCache, normalized); if (cached !== undefined) { runtimeMetrics.cacheHits += 1; return cached; } } return await withInFlightDedup(inFlightProfileRequests, normalized, async () => { runtimeMetrics.cacheMisses += 1; // Public GQL is now the SOURCE for everything except some of the // core text fields when Helix is authenticated — because public // GQL is the only route that gives us the banner image + current // stream preview in one shot, and skipping it would mean two // extra roundtrips. Helix takes precedence for displayName / // description (those fields are sometimes richer there). let displayName = normalized; let avatarUrl = ''; let bannerUrl = ''; let description = ''; let broadcasterType: '' | 'partner' | 'affiliate' = ''; let streamFromPublic: PublicStreamInfo | null = null; let followerCountFromPublic: number | null = null; const publicProfile = await fetchPublicStreamerProfile(normalized); if (publicProfile) { displayName = publicProfile.displayName; avatarUrl = publicProfile.avatarUrl; bannerUrl = publicProfile.bannerUrl; description = publicProfile.description; broadcasterType = publicProfile.broadcasterType; followerCountFromPublic = publicProfile.followerCount; streamFromPublic = publicProfile.stream; } const helixUser = await fetchHelixUserInfo(normalized); if (helixUser) { displayName = helixUser.display_name || displayName; if (helixUser.profile_image_url) avatarUrl = helixUser.profile_image_url; if (helixUser.description) description = helixUser.description; const bt = (helixUser.broadcaster_type || '').toLowerCase(); if (bt === 'partner' || bt === 'affiliate') broadcasterType = bt; } // followerCountFromPublic comes from the public profile query // above — no separate follower roundtrip needed. const followerCount = followerCountFromPublic; // Derive vod count + last stream from the already-cached VOD list // when we have an id. No extra network hit. let vodCount = 0; let lastStreamAt: string | null = null; const userId = await getUserId(normalized); if (userId) { try { const vods = await getVODs(userId); vodCount = vods.length; // VOD list is sorted by Twitch newest-first; pick element 0. const newest = vods[0]; if (newest?.created_at) lastStreamAt = newest.created_at; } catch (e) { appendDebugLog('profile-vod-derive-failed', { login: normalized, error: String(e) }); } } let isLive = false; let currentTitle: string | null = null; let currentGame: string | null = null; let currentStreamPreviewRemoteUrl = ''; let currentStreamViewers: number | null = null; if (streamFromPublic) { // Public-GQL already told us this user is live and gave us a // preview frame URL + viewer count + game/title. Don't double- // call getLiveStreamInfo when we already have a fresh answer. isLive = true; currentTitle = streamFromPublic.title; currentGame = streamFromPublic.game; currentStreamPreviewRemoteUrl = streamFromPublic.previewUrl; currentStreamViewers = streamFromPublic.viewers; } else { try { const live = await getLiveStreamInfo(normalized); if (live) { isLive = live.isLive; currentTitle = live.title || null; currentGame = live.gameName || null; } } catch (_) { /* best-effort */ } } // Embed the avatar AND banner bytes as data URLs in parallel. // Renderer can't reliably fetch Twitch CDN images directly from // an Electron renderer process, plus the data URL approach skips // any CSP/referer/CORS quirks. Live preview also goes through // this path — adds a cache-busting query string so a returning // user gets a fresh frame each time the profile refreshes. const livePreviewUrlForFetch = currentStreamPreviewRemoteUrl ? `${currentStreamPreviewRemoteUrl}${currentStreamPreviewRemoteUrl.includes('?') ? '&' : '?'}_=${Date.now()}` : ''; const [avatarDataUrl, bannerDataUrl, livePreviewDataUrl] = await Promise.all([ avatarUrl ? fetchAvatarAsDataUrl(avatarUrl) : Promise.resolve(''), bannerUrl ? fetchAvatarAsDataUrl(bannerUrl) : Promise.resolve(''), livePreviewUrlForFetch ? fetchAvatarAsDataUrl(livePreviewUrlForFetch) : Promise.resolve('') ]); const profile: StreamerProfile = { login: normalized, displayName, avatarUrl: avatarDataUrl || avatarUrl, bannerUrl: bannerDataUrl || bannerUrl, description, broadcasterType, followerCount, vodCount, lastStreamAt, isLive, currentTitle, currentGame, currentStreamPreviewUrl: livePreviewDataUrl || currentStreamPreviewRemoteUrl, currentStreamViewers, twitchUrl: `https://www.twitch.tv/${normalized}`, fetchedAt: Date.now() }; setCachedValue(streamerProfileCache, normalized, profile, MAX_STREAMER_PROFILE_CACHE_ENTRIES); return profile; }); } // ========================================== // VOD STORYBOARD — animated hover preview // ========================================== // Twitch publishes a "storyboard" JSON per VOD with sprite-sheet URLs // containing N preview thumbnails covering the full length of the // recording. We pull the JSON via public GQL (seekPreviewsURL), then // hand the renderer the first high-quality sprite as a data URL plus // the grid metadata. The renderer animates background-position across // 4 cells to produce a scrub-preview effect on hover, twitch.tv-style. interface VodStoryboard { vodId: string; spriteDataUrl: string; cols: number; rows: number; cellWidth: number; cellHeight: number; framesInSprite: number; } const MAX_VOD_STORYBOARD_CACHE_ENTRIES = 1024; const vodStoryboardCache = new Map>(); const inFlightStoryboardRequests = new Map>(); interface StoryboardManifestEntry { count: number; width: number; height: number; cols: number; rows: number; images: string[]; quality: string; interval: number; } async function getVodStoryboard(vodId: string): Promise { if (!vodId) return null; const cached = getCachedValue(vodStoryboardCache, vodId); if (cached !== undefined) { runtimeMetrics.cacheHits += 1; return cached; } return await withInFlightDedup(inFlightStoryboardRequests, vodId, async () => { runtimeMetrics.cacheMisses += 1; // Step 1: GQL gives us the seekPreviewsURL pointing at a JSON // manifest. The manifest lists sprite images at multiple quality // levels; we pick the high-quality first sprite (covers the // beginning of the VOD with the most detail). const data = await fetchPublicTwitchGql<{ video: { seekPreviewsURL: string | null } | null }>( `query($id: ID!) { video(id: $id) { seekPreviewsURL } }`, { id: vodId } ); const manifestUrl = data?.video?.seekPreviewsURL; if (!manifestUrl) { // Cache the negative result so a VOD without a storyboard // (private/unlisted/expired) doesn't get re-queried on every // subsequent hover. setCachedValue(vodStoryboardCache, vodId, null, MAX_VOD_STORYBOARD_CACHE_ENTRIES); return null; } let manifest: StoryboardManifestEntry[] | null = null; try { const manifestResp = await axios.get(manifestUrl, { timeout: 6000, responseType: 'json', headers: { 'User-Agent': 'TwitchVODManager/1.0' } }); manifest = manifestResp.data; } catch (e) { appendDebugLog('storyboard-manifest-failed', { vodId, error: String(e) }); setCachedValue(vodStoryboardCache, vodId, null, MAX_VOD_STORYBOARD_CACHE_ENTRIES); return null; } if (!Array.isArray(manifest) || manifest.length === 0) { setCachedValue(vodStoryboardCache, vodId, null, MAX_VOD_STORYBOARD_CACHE_ENTRIES); return null; } // Prefer the "high" quality entry — Twitch ships both "low" and // "high" alongside each other. Falls back to whichever is present. const entry = manifest.find((m) => m.quality === 'high') || manifest[0]; if (!entry?.images?.length) { setCachedValue(vodStoryboardCache, vodId, null, MAX_VOD_STORYBOARD_CACHE_ENTRIES); return null; } // The manifest URL points at e.g. .../storyboards/2767872722-info.json // and sprite filenames are relative (e.g. "2767872722-high-0.jpg"). // Strip the JSON filename to get the base, then append the sprite. const baseUrl = manifestUrl.replace(/\/[^/]+$/, '/'); const firstSpriteUrl = baseUrl + entry.images[0]; const spriteDataUrl = await fetchAvatarAsDataUrl(firstSpriteUrl); if (!spriteDataUrl) { setCachedValue(vodStoryboardCache, vodId, null, MAX_VOD_STORYBOARD_CACHE_ENTRIES); return null; } const storyboard: VodStoryboard = { vodId, spriteDataUrl, cols: entry.cols, rows: entry.rows, cellWidth: entry.width, cellHeight: entry.height, framesInSprite: entry.cols * entry.rows }; setCachedValue(vodStoryboardCache, vodId, storyboard, MAX_VOD_STORYBOARD_CACHE_ENTRIES); return storyboard; }); } 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)); }); } // Concatenates same-codec mp4 files into a single output via ffmpeg's // concat demuxer. No re-encoding — purely a container stitch, which is // what we want for resumed-recording parts (same streamlink, same codec // settings, just split across files). Returns false on any error so the // caller can keep the original parts. async function concatVideoFiles(inputFiles: string[], outputFile: string): Promise { if (inputFiles.length < 2) return false; const ffmpegReady = await ensureFfmpegInstalled(); if (!ffmpegReady) return false; for (const f of inputFiles) { if (!fs.existsSync(f)) { appendDebugLog('concat-missing-part', { missing: f }); return false; } } const listFile = path.join(path.dirname(outputFile), `.concat-${Date.now()}.txt`); try { // ffmpeg concat demuxer escaping: paths go in single quotes, embedded // single quotes need '\''. Backslashes are fine on Windows. const lines = inputFiles .map((f) => `file '${f.replace(/'/g, "'\\''")}'`) .join('\n'); fs.writeFileSync(listFile, lines, 'utf8'); } catch (e) { appendDebugLog('concat-listfile-write-failed', String(e)); return false; } const ffmpeg = getFFmpegPath(); const args = [ '-f', 'concat', '-safe', '0', '-i', listFile, '-c', 'copy', '-y', outputFile ]; return await new Promise((resolve) => { const proc = spawn(ffmpeg, args, { windowsHide: true }); let stderrBuf = ''; proc.stderr?.on('data', (chunk: Buffer) => { stderrBuf += chunk.toString(); }); proc.on('close', (code) => { try { fs.unlinkSync(listFile); } catch { /* ignore */ } if (code === 0 && fs.existsSync(outputFile) && fs.statSync(outputFile).size > 0) { appendDebugLog('concat-ok', { output: outputFile, parts: inputFiles.length }); resolve(true); } else { appendDebugLog('concat-failed', { code, stderrTail: stderrBuf.slice(-400) }); try { if (fs.existsSync(outputFile)) fs.unlinkSync(outputFile); } catch { /* ignore */ } resolve(false); } }); proc.on('error', (err) => { try { fs.unlinkSync(listFile); } catch { /* ignore */ } appendDebugLog('concat-spawn-error', String(err)); resolve(false); }); }); } 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 (itemId && cancelledItemIds.has(itemId)) { 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, getStreamlinkStreamArg(), '-o', filename, '--force']; if (config.streamlink_disable_ads !== false) { // Skips Twitch mid-roll ads which would otherwise be embedded // in the VOD output. Off only if the user explicitly disabled it. args.push('--twitch-disable-ads'); } let lastErrorLine = ''; const expectedDurationSeconds = parseClockDurationSeconds(endTime); let lastStreamlinkPercent = 0; if (startTime) { args.push('--hls-start-offset', startTime); } if (endTime) { args.push('--hls-duration', endTime); } // download-part-start in the debug log captures the same info // for support / forensics — no need to flood stdout too. 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(); // No per-line stdout — streamlink emits 10-100 lines/sec during // an active download, which floods the terminal in dev and the // electron-launched console in prod. Progress + tag parsing // below extracts everything we need; failures get logged via // appendDebugLog from the consumer side. // 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 (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 }); }); }); } // ========================================== // AUTO-RECORD POLLER // ========================================== // Tracks the last-known live state of every streamer in // config.auto_record_streamers. When a streamer transitions from // offline -> live AND no live recording is already in flight for them, // we auto-queue a live recording. Polling stops when no streamer has // auto-record enabled. const autoRecordLastLiveState = new Map(); let autoRecordPollTimer: NodeJS.Timeout | null = null; let autoRecordPollInFlight = false; let autoRecordLastRunAt = 0; let autoRecordNextRunAt = 0; let autoRecordLastTriggerCount = 0; function stopAutoRecordPoller(): void { if (autoRecordPollTimer) { clearInterval(autoRecordPollTimer); autoRecordPollTimer = null; } } function restartAutoRecordPoller(): void { stopAutoRecordPoller(); const list = Array.isArray(config.auto_record_streamers) ? config.auto_record_streamers : []; if (list.length === 0) { appendDebugLog('auto-record-poller-idle', { reason: 'no streamers' }); return; } const seconds = normalizeAutoRecordPollSeconds(config.auto_record_poll_seconds); appendDebugLog('auto-record-poller-start', { streamers: list.length, seconds }); autoRecordPollTimer = setInterval(() => { void runAutoRecordPoll(); }, seconds * 1000); autoRecordPollTimer.unref?.(); autoRecordNextRunAt = Date.now() + seconds * 1000; // Kick off an immediate first poll so a freshly-enabled streamer that's // already live gets picked up without waiting a full interval. setTimeout(() => { void runAutoRecordPoll(); }, 1500); } async function runAutoRecordPoll(): Promise { if (autoRecordPollInFlight) return 0; autoRecordPollInFlight = true; let triggered = 0; try { const list = Array.isArray(config.auto_record_streamers) ? [...config.auto_record_streamers] : []; for (const streamer of list) { // Check if list still contains streamer (config may have changed // mid-iteration via save-config from the renderer). if (!config.auto_record_streamers.includes(streamer)) continue; const info = await getLiveStreamInfo(streamer); if (info === null) { // Couldn't determine live state — skip this streamer this // round. Don't update lastLiveState so a subsequent successful // poll can still detect an offline->live transition cleanly. continue; } const wasLive = autoRecordLastLiveState.get(streamer) === true; autoRecordLastLiveState.set(streamer, info.isLive); if (!info.isLive || wasLive) continue; // offline -> live transition. Don't double-record if a live item // already exists in the queue (e.g. user manually triggered it). const alreadyRecording = downloadQueue.some((it) => it.isLive && it.streamer === streamer && (it.status === 'pending' || it.status === 'downloading') ); if (alreadyRecording) { appendDebugLog('auto-record-skip-already', { streamer }); continue; } const liveItem: QueueItem = { id: generateQueueItemId(), title: info.title || `${streamer} (LIVE)`, url: `https://www.twitch.tv/${streamer}`, date: new Date().toISOString(), streamer, duration_str: '0s', status: 'pending', progress: 0, isLive: true }; downloadQueue.push(liveItem); saveQueue(downloadQueue); emitQueueUpdated(); triggered++; appendDebugLog('auto-record-triggered', { streamer, title: liveItem.title }); if (!isDownloading) { void processQueue(); } } } catch (e) { appendDebugLog('auto-record-poll-failed', String(e)); } finally { autoRecordPollInFlight = false; autoRecordLastRunAt = Date.now(); autoRecordLastTriggerCount = triggered; const seconds = normalizeAutoRecordPollSeconds(config.auto_record_poll_seconds); autoRecordNextRunAt = Date.now() + seconds * 1000; } return triggered; } // ========================================== // AUTO-VOD-DOWNLOAD POLLER // ========================================== // Periodically scans VOD listings of opted-in streamers and auto-queues // any VOD that's (a) recent enough to be in scope, (b) not already // downloaded, and (c) not already in the active queue. Cadence is // minutes, not seconds — a VOD-listing scan is much heavier than a // live-status check, and new VODs only appear after a stream ends, so // minute-level lag is fine. let autoVodPollTimer: NodeJS.Timeout | null = null; let autoVodPollInFlight = false; let autoVodLastRunAt = 0; let autoVodNextRunAt = 0; let autoVodLastQueuedCount = 0; function stopAutoVodPoller(): void { if (autoVodPollTimer) { clearInterval(autoVodPollTimer); autoVodPollTimer = null; } } function restartAutoVodPoller(): void { stopAutoVodPoller(); const list = Array.isArray(config.auto_vod_download_streamers) ? config.auto_vod_download_streamers : []; if (list.length === 0) { appendDebugLog('auto-vod-poller-idle', { reason: 'no streamers' }); return; } const minutes = (() => { const n = Number(config.auto_vod_download_poll_minutes); if (!Number.isFinite(n)) return 15; return Math.max(5, Math.min(360, Math.floor(n))); })(); appendDebugLog('auto-vod-poller-start', { streamers: list.length, minutes }); autoVodPollTimer = setInterval(() => { void runAutoVodPoll(); }, minutes * 60 * 1000); autoVodPollTimer.unref?.(); autoVodNextRunAt = Date.now() + minutes * 60 * 1000; setTimeout(() => { void runAutoVodPoll(); }, 5000); } async function runAutoVodPoll(): Promise { if (autoVodPollInFlight) return 0; autoVodPollInFlight = true; let queuedCount = 0; try { const list = Array.isArray(config.auto_vod_download_streamers) ? [...config.auto_vod_download_streamers] : []; if (list.length === 0) return 0; const maxAgeHours = (() => { const n = Number(config.auto_vod_max_age_hours); if (!Number.isFinite(n)) return 24; return Math.max(1, Math.min(720, Math.floor(n))); })(); const cutoffMs = Date.now() - maxAgeHours * 3600 * 1000; const downloadedSet = new Set(Array.isArray(config.downloaded_vod_ids) ? config.downloaded_vod_ids : []); const queuedUrls = new Set(downloadQueue.map((it) => it.url)); for (const streamer of list) { if (!config.auto_vod_download_streamers.includes(streamer)) continue; const userId = await getUserId(streamer); if (!userId) { appendDebugLog('auto-vod-skip-no-user', { streamer }); continue; } let vods: VOD[] = []; try { vods = await getVODs(userId, true); } catch (e) { appendDebugLog('auto-vod-list-failed', { streamer, error: String(e) }); continue; } if (!Array.isArray(vods) || vods.length === 0) continue; for (const vod of vods) { if (!vod || !vod.id || !vod.url) continue; if (downloadedSet.has(vod.id)) continue; if (queuedUrls.has(vod.url)) continue; const createdMs = Date.parse(vod.created_at || ''); if (!Number.isFinite(createdMs) || createdMs < cutoffMs) continue; const queueItem: QueueItem = { id: generateQueueItemId(), title: vod.title || `${streamer} VOD ${vod.id}`, url: vod.url, date: vod.created_at, streamer, duration_str: vod.duration || '', status: 'pending', progress: 0 }; downloadQueue.push(queueItem); queuedUrls.add(vod.url); queuedCount++; appendDebugLog('auto-vod-queued', { streamer, vodId: vod.id, title: queueItem.title }); if (config.discord_notify_vod_auto_queued) { try { await sendDiscordWebhook({ title: 'New VOD auto-queued', description: `\`${streamer}\` published a new VOD — queued for download.`, color: 'info', fields: [ { name: 'Title', value: queueItem.title, inline: false }, { name: 'VOD ID', value: String(vod.id), inline: true }, { name: 'URL', value: vod.url, inline: false } ] }); } catch (_) { /* ignore webhook errors */ } } } } saveQueue(downloadQueue); emitQueueUpdated(); if (!isDownloading && downloadQueue.some((it) => it.status === 'pending')) { void processQueue(); } } catch (e) { appendDebugLog('auto-vod-poll-failed', String(e)); } finally { autoVodPollInFlight = false; autoVodLastRunAt = Date.now(); autoVodLastQueuedCount = queuedCount; const minutes = (() => { const n = Number(config.auto_vod_download_poll_minutes); if (!Number.isFinite(n)) return 15; return Math.max(5, Math.min(360, Math.floor(n))); })(); autoVodNextRunAt = Date.now() + minutes * 60 * 1000; if (queuedCount > 0 && mainWindow) { mainWindow.webContents.send('auto-vod-scan-completed', { queuedCount }); } } return queuedCount; } // ========================================== // LIVE STATUS BATCH POLLER — for the sidebar live indicators // ========================================== // Background poller that asks "which of these streamers are live right // now?" for every streamer in the user's list, in a single GQL roundtrip // (per chunk of 50). Results are stamped into liveStatusByLogin and // pushed to the renderer so the sidebar gets a red pulsing dot next to // anyone currently broadcasting. Independent from the auto-record // poller — that one only watches a small subset and needs title/game, // this one just needs the boolean and covers everyone. const liveStatusByLogin = new Map(); let liveStatusPollTimer: NodeJS.Timeout | null = null; let liveStatusPollInFlight = false; const LIVE_STATUS_POLL_INTERVAL_MS = 60_000; const LIVE_STATUS_BATCH_CHUNK_SIZE = 50; async function fetchLiveStatusBatch(logins: string[]): Promise> { const result = new Map(); if (logins.length === 0) return result; for (let i = 0; i < logins.length; i += LIVE_STATUS_BATCH_CHUNK_SIZE) { const chunk = logins.slice(i, i + LIVE_STATUS_BATCH_CHUNK_SIZE); const vars: Record = {}; const varDecls: string[] = []; const aliases: string[] = []; chunk.forEach((login, idx) => { const varName = `l${idx}`; vars[varName] = login; varDecls.push(`$${varName}:String!`); aliases.push(`u${idx}:user(login:$${varName}){login stream{type}}`); }); const query = `query(${varDecls.join(',')}){${aliases.join(' ')}}`; try { const data = await fetchPublicTwitchGql>( query, vars ); if (!data) continue; for (const key of Object.keys(data)) { const user = data[key]; if (!user || !user.login) continue; result.set(normalizeLogin(user.login), user.stream?.type === 'live'); } } catch (e) { appendDebugLog('live-status-batch-failed', { chunkStart: i, error: String(e) }); } } return result; } async function runLiveStatusBatchPoll(): Promise { if (liveStatusPollInFlight) return; liveStatusPollInFlight = true; try { const logins = ((config.streamers as string[]) || []) .map((s) => normalizeLogin(s)) .filter((s): s is string => Boolean(s)); const changes: Array<{ login: string; isLive: boolean }> = []; const watchedSet = new Set(logins); // Always run the eviction pass FIRST — entries left over from a // streamer that's no longer in the watch list must go regardless // of whether we're about to fetch fresh data. Previously this // ran inside the fetch branch only, so removing the last // streamer left ghost entries in liveStatusByLogin until the // next add. for (const oldLogin of Array.from(liveStatusByLogin.keys())) { if (!watchedSet.has(oldLogin)) { liveStatusByLogin.delete(oldLogin); changes.push({ login: oldLogin, isLive: false }); } } if (logins.length > 0) { const fresh = await fetchLiveStatusBatch(logins); for (const [login, isLive] of fresh.entries()) { const prev = liveStatusByLogin.get(login); if (prev !== isLive) changes.push({ login, isLive }); liveStatusByLogin.set(login, isLive); } } if (mainWindow && changes.length > 0) { // Renderer only consumes `changes` — initial state comes via // the get-live-status-snapshot IPC at boot. Don't ship the // full map on every tick (was ~1.5KB JSON per 60s with zero // consumer-side use). Also skip the broadcast entirely when // nothing actually changed. mainWindow.webContents.send('live-status-batch-update', { changes }); } } catch (e) { appendDebugLog('live-status-poll-failed', String(e)); } finally { liveStatusPollInFlight = false; } } function stopLiveStatusPoller(): void { if (liveStatusPollTimer) { clearInterval(liveStatusPollTimer); liveStatusPollTimer = null; } } function restartLiveStatusPoller(): void { stopLiveStatusPoller(); liveStatusPollTimer = setInterval(() => { void runLiveStatusBatchPoll(); }, LIVE_STATUS_POLL_INTERVAL_MS); liveStatusPollTimer.unref?.(); setTimeout(() => { void runLiveStatusBatchPoll(); }, 1500); } // ========================================== // CHAT REPLAY DOWNLOAD // ========================================== // Twitch retains chat replay alongside the VOD itself — same 7-60 day TTL. // Anyone archiving the video usually wants the chat too. fetchVodChatReplay // pulls the entire chat for a VOD via the public GQL endpoint, paginated // via edge cursors (Twitch returns ~100 comments per page). interface ChatReplayMessage { id: string; offset: number; // contentOffsetSeconds — when in the VOD createdAt: string; // ISO timestamp user: string; // display name login: string; // login (lowercase) color: string; // user chat color text: string; // assembled message text } interface ChatReplayResult { messages: ChatReplayMessage[]; truncated: boolean; pages: number; } async function fetchVodChatReplay( videoId: string, onProgress?: (count: number) => void, cancelCheck?: () => boolean ): Promise { const messages: ChatReplayMessage[] = []; let cursor: string | null = null; let pages = 0; let truncated = false; // Hard cap to keep one runaway stream from filling memory. 200 pages = // ~20k messages which covers typical 6-hour streams. Above that we // stop and mark truncated. const MAX_PAGES = 500; type CommentNode = { id: string; contentOffsetSeconds: number; createdAt: string; message?: { fragments?: Array<{ text?: string }>; userColor?: string }; commenter?: { displayName?: string; login?: string }; }; type CommentEdge = { node: CommentNode; cursor: string }; type CommentsPage = { video: { comments: { edges: CommentEdge[]; pageInfo: { hasNextPage: boolean } } } | null; }; const query = 'query($videoID:ID!,$cursor:Cursor){video(id:$videoID){comments(contentOffsetSeconds:0,cursor:$cursor){edges{node{id contentOffsetSeconds createdAt message{fragments{text} userColor} commenter{displayName login}} cursor} pageInfo{hasNextPage}}}}'; while (pages < MAX_PAGES) { if (cancelCheck && cancelCheck()) { truncated = true; break; } const data: CommentsPage | null = await fetchPublicTwitchGql(query, { videoID: videoId, cursor }); if (!data || !data.video || !data.video.comments) break; const edges: CommentEdge[] = Array.isArray(data.video.comments.edges) ? data.video.comments.edges : []; for (const edge of edges) { const node = edge.node; const fragments = node.message?.fragments || []; const text = fragments.map((f: { text?: string }) => (typeof f.text === 'string' ? f.text : '')).join(''); messages.push({ id: node.id, offset: Number(node.contentOffsetSeconds) || 0, createdAt: node.createdAt || '', user: node.commenter?.displayName || '', login: node.commenter?.login || '', color: node.message?.userColor || '', text }); } pages += 1; if (onProgress) onProgress(messages.length); const last: CommentEdge | undefined = edges[edges.length - 1]; if (!data.video.comments.pageInfo.hasNextPage || !last) break; cursor = last.cursor; } if (pages >= MAX_PAGES) truncated = true; return { messages, truncated, pages }; } function chatReplayPathFor(vodFilePath: string): string { // Strip the final extension and append .chat.json so the chat file // lives next to the video and is easy to find. const ext = path.extname(vodFilePath); const base = ext ? vodFilePath.slice(0, -ext.length) : vodFilePath; return `${base}.chat.json`; } // ========================================== // AUTO-CLEANUP // ========================================== // Targets old recording artifacts (.mp4/.ts/.mkv plus their sibling // .chat.json/.chat.jsonl) older than auto_cleanup_days. Two scopes — // live_only (only files inside a streamer/live/ subfolder, set-and- // forget for auto-record users) or all (everything under the streamer // folders). Two actions — delete or archive (move to a parallel // archived/{streamer}/{YYYY-MM}/ tree). Archive is the safer default. // Sibling chat files travel with the video so we don't end up with // an orphan transcript. interface CleanupCandidate { videoPath: string; sidecarPaths: string[]; streamer: string; bytes: number; ageDays: number; } interface CleanupReport { enabled: boolean; dryRun: boolean; cutoffDays: number; target: 'live_only' | 'all'; action: 'delete' | 'archive'; scannedAt: string; candidates: number; processed: number; failed: number; bytesFreed: number; failures: Array<{ path: string; error: string }>; } const VIDEO_FILE_REGEX = /\.(mp4|ts|mkv|mov|avi)$/i; function findCleanupCandidates(cutoffDays: number, target: 'live_only' | 'all'): CleanupCandidate[] { const out: CleanupCandidate[] = []; const root = config.download_path; if (!root || !fs.existsSync(root)) return out; const cutoffMs = Date.now() - cutoffDays * 24 * 60 * 60 * 1000; const knownStreamers = new Set(((config.streamers as string[]) || []).map((s) => s.toLowerCase())); let topEntries: fs.Dirent[]; try { topEntries = fs.readdirSync(root, { withFileTypes: true }); } catch { return out; } const visit = (dir: string, streamer: string, mustBeUnderLive: boolean): void => { let entries: fs.Dirent[]; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } for (const entry of entries) { const full = path.join(dir, entry.name); if (entry.isDirectory()) { // Never walk back into the archived/ tree we own. if (entry.name === 'archived') continue; const enteringLive = entry.name === 'live'; visit(full, streamer, mustBeUnderLive && !enteringLive); continue; } if (!entry.isFile()) continue; if (!VIDEO_FILE_REGEX.test(entry.name)) continue; if (mustBeUnderLive) continue; // live_only mode + we're not under live/ let stat: fs.Stats; try { stat = fs.statSync(full); } catch { continue; } if (stat.mtimeMs > cutoffMs) continue; // Find sibling chat files (same basename, .chat.json / .chat.jsonl) const ext = path.extname(full); const base = ext ? full.slice(0, -ext.length) : full; const sidecars: string[] = []; for (const sidecarExt of ['.chat.json', '.chat.jsonl']) { const candidate = base + sidecarExt; if (fs.existsSync(candidate)) sidecars.push(candidate); } out.push({ videoPath: full, sidecarPaths: sidecars, streamer, bytes: stat.size, ageDays: Math.floor((Date.now() - stat.mtimeMs) / (24 * 60 * 60 * 1000)) }); } }; for (const top of topEntries) { if (!top.isDirectory()) continue; if (top.name === 'archived') continue; // never recurse into the archive tree const lowered = top.name.toLowerCase(); const isKnown = knownStreamers.has(lowered) || top.name === 'Clips'; if (!isKnown) continue; const folderPath = path.join(root, top.name); // For live_only mode, we descend with mustBeUnderLive=true; the // visit() call flips it to false the moment we enter a "live" // subfolder. For "all" mode, mustBeUnderLive is false from the // top so every video matches. visit(folderPath, top.name, target === 'live_only'); } return out; } function archivePathForCleanup(streamer: string, originalPath: string, mtimeMs: number): string { const root = config.download_path; const date = new Date(mtimeMs); const monthKey = `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}`; const dir = path.join(root, 'archived', streamer, monthKey); fs.mkdirSync(dir, { recursive: true }); return ensureUniqueFilename(path.join(dir, path.basename(originalPath)), null); } function runStorageCleanup(opts: { dryRun: boolean }): CleanupReport { const report: CleanupReport = { enabled: config.auto_cleanup_enabled === true, dryRun: opts.dryRun, cutoffDays: Number(config.auto_cleanup_days) || 30, target: config.auto_cleanup_target === 'all' ? 'all' : 'live_only', action: config.auto_cleanup_action === 'delete' ? 'delete' : 'archive', scannedAt: new Date().toISOString(), candidates: 0, processed: 0, failed: 0, bytesFreed: 0, failures: [] }; const candidates = findCleanupCandidates(report.cutoffDays, report.target); report.candidates = candidates.length; if (opts.dryRun) { for (const c of candidates) { report.bytesFreed += c.bytes; for (const sc of c.sidecarPaths) { try { report.bytesFreed += fs.statSync(sc).size; } catch { /* ignore */ } } } appendDebugLog('storage-cleanup-dry-run', { candidates: report.candidates, bytes: report.bytesFreed }); return report; } for (const c of candidates) { const allPaths = [c.videoPath, ...c.sidecarPaths]; try { if (report.action === 'delete') { for (const p of allPaths) { let bytes = 0; try { bytes = fs.statSync(p).size; } catch { /* ignore */ } fs.unlinkSync(p); report.bytesFreed += bytes; } } else { // Archive: keep the same basename, group by streamer + month. const stat = fs.statSync(c.videoPath); const archived = archivePathForCleanup(c.streamer, c.videoPath, stat.mtimeMs); fs.renameSync(c.videoPath, archived); report.bytesFreed += stat.size; // Move sidecars to the same archive folder. const archDir = path.dirname(archived); for (const sc of c.sidecarPaths) { try { const dest = ensureUniqueFilename(path.join(archDir, path.basename(sc)), null); fs.renameSync(sc, dest); } catch (err) { report.failures.push({ path: sc, error: String(err) }); } } } report.processed += 1; } catch (err) { report.failed += 1; report.failures.push({ path: c.videoPath, error: String(err) }); } } appendDebugLog('storage-cleanup-run', { candidates: report.candidates, processed: report.processed, failed: report.failed, bytes: report.bytesFreed, action: report.action, target: report.target }); return report; } let autoCleanupTimer: NodeJS.Timeout | null = null; let lastAutoCleanupAt = 0; function stopAutoCleanupTimer(): void { if (autoCleanupTimer) { clearInterval(autoCleanupTimer); autoCleanupTimer = null; } } function restartAutoCleanupTimer(): void { stopAutoCleanupTimer(); if (!config.auto_cleanup_enabled) return; // Run every 6 hours while the app is running. Skip the first cycle if // the previous run was less than 6h ago to avoid hammering on every // settings save. const SIX_HOURS_MS = 6 * 60 * 60 * 1000; autoCleanupTimer = setInterval(() => { if (Date.now() - lastAutoCleanupAt < SIX_HOURS_MS) return; lastAutoCleanupAt = Date.now(); try { runStorageCleanup({ dryRun: false }); } catch (e) { appendDebugLog('auto-cleanup-failed', String(e)); } }, SIX_HOURS_MS); autoCleanupTimer.unref?.(); // First run is delayed 60s so it doesn't compete with startup IO. setTimeout(() => { if (!config.auto_cleanup_enabled) return; if (Date.now() - lastAutoCleanupAt < 60 * 1000) return; lastAutoCleanupAt = Date.now(); try { runStorageCleanup({ dryRun: false }); } catch (e) { appendDebugLog('auto-cleanup-failed', String(e)); } }, 60 * 1000); } // ========================================== // STORAGE STATS // ========================================== // Walks the download folder once on demand and reports per-streamer disk // usage so the user can see which streamers are eating their archive // budget. Only enumerates direct subfolders that match a known streamer // name (from config.streamers) plus a special "Clips" bucket. Refusing // to recurse the entire filesystem means a user with a huge unrelated // download_path doesn't pay for it here. interface StreamerStorageEntry { name: string; fileCount: number; totalBytes: number; liveBytes: number; chatBytes: number; folderPath: string; } interface StorageStatsResult { downloadPath: string; rootExists: boolean; freeBytes: number | null; totalFiles: number; totalBytes: number; streamers: StreamerStorageEntry[]; extras: StreamerStorageEntry[]; scannedAt: string; } function walkFolderForStats(folderPath: string): { files: number; bytes: number; liveBytes: number; chatBytes: number } { const result = { files: 0, bytes: 0, liveBytes: 0, chatBytes: 0 }; let entries: fs.Dirent[]; try { entries = fs.readdirSync(folderPath, { withFileTypes: true }); } catch { return result; } for (const entry of entries) { const full = path.join(folderPath, entry.name); try { if (entry.isDirectory()) { const sub = walkFolderForStats(full); result.files += sub.files; result.bytes += sub.bytes; if (entry.name === 'live') { result.liveBytes += sub.bytes; } } else if (entry.isFile()) { const st = fs.statSync(full); result.files += 1; result.bytes += st.size; if (/\.chat\.json(l)?$/i.test(entry.name)) { result.chatBytes += st.size; } } } catch { // Symlink / permissions blip — skip the entry, continue. } } return result; } function computeStorageStats(): StorageStatsResult { const root = config.download_path; const result: StorageStatsResult = { downloadPath: root, rootExists: false, freeBytes: null, totalFiles: 0, totalBytes: 0, streamers: [], extras: [], scannedAt: new Date().toISOString() }; if (!root || !fs.existsSync(root)) return result; result.rootExists = true; result.freeBytes = getFreeDiskBytes(root); const knownStreamers = new Set( ((config.streamers as string[]) || []).map((s) => s.toLowerCase()) ); let topEntries: fs.Dirent[]; try { topEntries = fs.readdirSync(root, { withFileTypes: true }); } catch { return result; } for (const entry of topEntries) { if (!entry.isDirectory()) continue; const full = path.join(root, entry.name); const safeName = entry.name.replace(/[^a-zA-Z0-9_-]/g, ''); const isKnownStreamer = knownStreamers.has(safeName.toLowerCase()); // Treat Clips/ + anything that matches known streamers as a tracked // bucket; everything else (random user folders) lives in `extras`. const sub = walkFolderForStats(full); const stats: StreamerStorageEntry = { name: entry.name, fileCount: sub.files, totalBytes: sub.bytes, liveBytes: sub.liveBytes, chatBytes: sub.chatBytes, folderPath: full }; if (isKnownStreamer || entry.name === 'Clips') { result.streamers.push(stats); } else { result.extras.push(stats); } result.totalFiles += sub.files; result.totalBytes += sub.bytes; } // Largest first — that's what the user wants to see. result.streamers.sort((a, b) => b.totalBytes - a.totalBytes); result.extras.sort((a, b) => b.totalBytes - a.totalBytes); return result; } // ========================================== // ARCHIVE STATS — DASHBOARD AGGREGATION // ========================================== interface ArchiveStatsTopStreamer { streamer: string; bytes: number; fileCount: number; liveBytes: number; vodBytes: number; chatBytes: number; } interface ArchiveStatsDay { date: string; count: number; bytes: number } interface ArchiveStatsBucket { label: string; count: number; bytes: number } interface ArchiveStats { totalFiles: number; totalBytes: number; liveCount: number; liveBytes: number; vodCount: number; vodBytes: number; chatCount: number; chatBytes: number; eventsCount: number; streamerCount: number; avgRecordingSizeBytes: number; topStreamers: ArchiveStatsTopStreamer[]; dailyActivity: ArchiveStatsDay[]; sizeBuckets: ArchiveStatsBucket[]; scannedAt: string; downloadPath: string; rootExists: boolean; } const SIZE_BUCKETS: Array<{ label: string; min: number; max: number }> = [ { label: '< 100 MB', min: 0, max: 100 * 1024 * 1024 }, { label: '100 MB - 500 MB', min: 100 * 1024 * 1024, max: 500 * 1024 * 1024 }, { label: '500 MB - 1 GB', min: 500 * 1024 * 1024, max: 1024 * 1024 * 1024 }, { label: '1 GB - 5 GB', min: 1024 * 1024 * 1024, max: 5 * 1024 * 1024 * 1024 }, { label: '5 GB - 10 GB', min: 5 * 1024 * 1024 * 1024, max: 10 * 1024 * 1024 * 1024 }, { label: '> 10 GB', min: 10 * 1024 * 1024 * 1024, max: Number.POSITIVE_INFINITY } ]; type ArchiveFileType = 'live' | 'vod' | 'chat' | 'events' | 'other'; function classifyArchiveFile(relativePath: string): ArchiveFileType { if (/\.chat\.jsonl?$/i.test(relativePath)) return 'chat'; if (/\.events\.jsonl$/i.test(relativePath)) return 'events'; const norm = relativePath.replace(/\\/g, '/').toLowerCase(); if (norm.startsWith('live/')) return 'live'; if (/\.(mp4|mkv|ts|m4v)$/i.test(relativePath)) return 'vod'; return 'other'; } function extractFilenameDate(name: string): string | null { const m = /(\d{4})-(\d{2})-(\d{2})/.exec(name); if (!m) return null; return `${m[1]}-${m[2]}-${m[3]}`; } function bucketIndexForSize(bytes: number): number { for (let i = 0; i < SIZE_BUCKETS.length; i++) { if (bytes < SIZE_BUCKETS[i].max) return i; } return SIZE_BUCKETS.length - 1; } interface ArchiveFileRecord { size: number; mtimeMs: number; type: ArchiveFileType; date: string } function walkForArchiveStats( folderPath: string, relPrefix: string, accum: { files: ArchiveFileRecord[] } ): void { let entries: fs.Dirent[]; try { entries = fs.readdirSync(folderPath, { withFileTypes: true }); } catch { return; } for (const entry of entries) { const full = path.join(folderPath, entry.name); const rel = relPrefix ? `${relPrefix}/${entry.name}` : entry.name; try { if (entry.isDirectory()) { walkForArchiveStats(full, rel, accum); } else if (entry.isFile()) { const st = fs.statSync(full); const type = classifyArchiveFile(rel); const dateFromName = extractFilenameDate(entry.name); const date = dateFromName || new Date(st.mtimeMs).toISOString().slice(0, 10); accum.files.push({ size: st.size, mtimeMs: st.mtimeMs, type, date }); } } catch { /* permission blip — skip */ } } } // Search a single file matches the live query. Empty query matches all. // streamerFolder is the top-level directory under root (which we equate // with the channel name); relativePath is everything below that. interface ArchiveSearchFilter { query: string; type: 'all' | 'live' | 'vod' | 'chat' | 'events'; streamer: string; sinceMs: number | null; untilMs: number | null; sort: 'date_desc' | 'date_asc' | 'size_desc' | 'size_asc' | 'name_asc'; limit: number; } interface ArchiveSearchHit { fullPath: string; fileName: string; streamer: string; type: ArchiveFileType; size: number; mtimeMs: number; chatPath: string | null; eventsPath: string | null; } interface ArchiveSearchResult { totalScanned: number; matchCount: number; truncated: boolean; hits: ArchiveSearchHit[]; scannedAt: string; rootExists: boolean; } function matchSearchFilter( streamerFolder: string, relativePath: string, fileName: string, fileSize: number, mtimeMs: number, type: ArchiveFileType, filter: ArchiveSearchFilter ): boolean { if (filter.type !== 'all' && filter.type !== type) return false; if (filter.streamer && streamerFolder.toLowerCase() !== filter.streamer.toLowerCase()) return false; if (filter.sinceMs !== null && mtimeMs < filter.sinceMs) return false; if (filter.untilMs !== null && mtimeMs > filter.untilMs) return false; if (filter.query) { const q = filter.query.toLowerCase(); const hay = `${fileName} ${streamerFolder} ${relativePath}`.toLowerCase(); if (!hay.includes(q)) return false; } return true; } function searchArchive(filter: ArchiveSearchFilter): ArchiveSearchResult { const root = config.download_path; const result: ArchiveSearchResult = { totalScanned: 0, matchCount: 0, truncated: false, hits: [], scannedAt: new Date().toISOString(), rootExists: false }; if (!root || !fs.existsSync(root)) return result; result.rootExists = true; const maxHits = Math.max(10, Math.min(2000, Math.floor(filter.limit) || 200)); let topEntries: fs.Dirent[]; try { topEntries = fs.readdirSync(root, { withFileTypes: true }); } catch { return result; } // To attach chat/events sibling paths to a recording hit, we collect // every file in a streamer's tree first, then make a second pass to // pair up companions by stripping the .mp4 base. for (const entry of topEntries) { if (!entry.isDirectory()) continue; const streamerFolder = entry.name; const streamerRoot = path.join(root, streamerFolder); const filesInTree: Array<{ fullPath: string; rel: string; name: string; size: number; mtimeMs: number; type: ArchiveFileType }> = []; const accum: { files: ArchiveFileRecord[] } = { files: [] }; // We re-walk here instead of reusing walkForArchiveStats because // we need the full path + rel path on each file, not just the // type/size aggregates. The cost is one redundant tree walk per // search; acceptable for an interactive search. const walkWithPaths = (folderPath: string, relPrefix: string): void => { let entries2: fs.Dirent[]; try { entries2 = fs.readdirSync(folderPath, { withFileTypes: true }); } catch { return; } for (const e2 of entries2) { const full = path.join(folderPath, e2.name); const rel = relPrefix ? `${relPrefix}/${e2.name}` : e2.name; try { if (e2.isDirectory()) { walkWithPaths(full, rel); } else if (e2.isFile()) { const st = fs.statSync(full); const type = classifyArchiveFile(rel); filesInTree.push({ fullPath: full, rel, name: e2.name, size: st.size, mtimeMs: st.mtimeMs, type }); } } catch { /* skip */ } } }; walkWithPaths(streamerRoot, ''); if (filesInTree.length === 0) continue; result.totalScanned += filesInTree.length; // Build a quick lookup so a recording file can attach its sibling // .chat.* and .events.jsonl by stripping the .mp4/.mkv extension. const companionByBase = new Map(); for (const f of filesInTree) { if (f.type !== 'chat' && f.type !== 'events') continue; // Strip companion suffix to get the base name shared with the // recording: foo.mp4 + foo.chat.jsonl + foo.events.jsonl. const base = f.fullPath.replace(/\.chat\.jsonl?$/i, '').replace(/\.events\.jsonl$/i, ''); const existing = companionByBase.get(base) || { chat: null, events: null }; if (f.type === 'chat') existing.chat = f.fullPath; else if (f.type === 'events') existing.events = f.fullPath; companionByBase.set(base, existing); } for (const f of filesInTree) { // We only surface recordings (live/vod) as search hits — chat // and events files attach as companions and don't appear as // standalone rows. Users searching for chat usually want the // recording it belongs to anyway. if (f.type !== 'live' && f.type !== 'vod') continue; if (!matchSearchFilter(streamerFolder, f.rel, f.name, f.size, f.mtimeMs, f.type, filter)) continue; const recordingBase = f.fullPath.replace(/\.(mp4|mkv|ts|m4v)$/i, ''); const companions = companionByBase.get(recordingBase) || { chat: null, events: null }; result.hits.push({ fullPath: f.fullPath, fileName: f.name, streamer: streamerFolder, type: f.type, size: f.size, mtimeMs: f.mtimeMs, chatPath: companions.chat, eventsPath: companions.events }); result.matchCount++; } } // Sort then truncate. We sort the FULL match set (not the truncated // one) so the user gets the genuinely largest/newest results, not // arbitrary order. const cmp: Record number> = { date_desc: (a, b) => b.mtimeMs - a.mtimeMs, date_asc: (a, b) => a.mtimeMs - b.mtimeMs, size_desc: (a, b) => b.size - a.size, size_asc: (a, b) => a.size - b.size, name_asc: (a, b) => a.fileName.localeCompare(b.fileName) }; result.hits.sort(cmp[filter.sort] || cmp.date_desc); if (result.hits.length > maxHits) { result.truncated = true; result.hits = result.hits.slice(0, maxHits); } return result; } function computeArchiveStats(): ArchiveStats { const root = config.download_path; const stats: ArchiveStats = { totalFiles: 0, totalBytes: 0, liveCount: 0, liveBytes: 0, vodCount: 0, vodBytes: 0, chatCount: 0, chatBytes: 0, eventsCount: 0, streamerCount: 0, avgRecordingSizeBytes: 0, topStreamers: [], dailyActivity: [], sizeBuckets: SIZE_BUCKETS.map((b) => ({ label: b.label, count: 0, bytes: 0 })), scannedAt: new Date().toISOString(), downloadPath: root || '', rootExists: false }; if (!root || !fs.existsSync(root)) return stats; stats.rootExists = true; let topEntries: fs.Dirent[]; try { topEntries = fs.readdirSync(root, { withFileTypes: true }); } catch { return stats; } const perStreamer = new Map(); const dailyMap = new Map(); let recordingCount = 0; let recordingBytes = 0; for (const entry of topEntries) { if (!entry.isDirectory()) continue; const streamerFolder = entry.name; const full = path.join(root, streamerFolder); const accum: { files: ArchiveFileRecord[] } = { files: [] }; walkForArchiveStats(full, '', accum); if (accum.files.length === 0) continue; const ts: ArchiveStatsTopStreamer = { streamer: streamerFolder, bytes: 0, fileCount: 0, liveBytes: 0, vodBytes: 0, chatBytes: 0 }; for (const f of accum.files) { stats.totalFiles++; stats.totalBytes += f.size; ts.fileCount++; ts.bytes += f.size; if (f.type === 'live') { stats.liveCount++; stats.liveBytes += f.size; ts.liveBytes += f.size; recordingCount++; recordingBytes += f.size; stats.sizeBuckets[bucketIndexForSize(f.size)].count++; stats.sizeBuckets[bucketIndexForSize(f.size)].bytes += f.size; } else if (f.type === 'vod') { stats.vodCount++; stats.vodBytes += f.size; ts.vodBytes += f.size; recordingCount++; recordingBytes += f.size; stats.sizeBuckets[bucketIndexForSize(f.size)].count++; stats.sizeBuckets[bucketIndexForSize(f.size)].bytes += f.size; } else if (f.type === 'chat') { stats.chatCount++; stats.chatBytes += f.size; ts.chatBytes += f.size; } else if (f.type === 'events') { stats.eventsCount++; } if (f.type === 'live' || f.type === 'vod') { const cur = dailyMap.get(f.date) || { date: f.date, count: 0, bytes: 0 }; cur.count++; cur.bytes += f.size; dailyMap.set(f.date, cur); } } perStreamer.set(streamerFolder, ts); } stats.streamerCount = perStreamer.size; stats.avgRecordingSizeBytes = recordingCount > 0 ? Math.round(recordingBytes / recordingCount) : 0; stats.topStreamers = Array.from(perStreamer.values()) .sort((a, b) => b.bytes - a.bytes) .slice(0, 10); const today = new Date(); today.setHours(0, 0, 0, 0); const days: ArchiveStatsDay[] = []; for (let i = 29; i >= 0; i--) { const d = new Date(today); d.setDate(d.getDate() - i); const key = d.toISOString().slice(0, 10); days.push(dailyMap.get(key) || { date: key, count: 0, bytes: 0 }); } stats.dailyActivity = days; return stats; } // ========================================== // DISCORD WEBHOOK NOTIFICATIONS // ========================================== // Fire-and-forget webhook for "stream went live", "recording finished", // "VOD download complete". Useful when the user runs the app on a // dedicated archival machine and isn't checking it directly. type DiscordEmbedColor = 'live' | 'success' | 'info'; const DISCORD_EMBED_COLORS: Record = { live: 0xE91916, // red — recording started success: 0x00C853, // green — completed cleanly info: 0x9146FF // twitch purple — neutral }; function isAcceptableDiscordWebhook(url: string): boolean { const trimmed = (url || '').trim(); if (!trimmed) return false; return /^https:\/\/(?:[a-z]+\.)?discord(?:app)?\.com\/api\/webhooks\//i.test(trimmed); } async function sendDiscordWebhook(payload: { title: string; description: string; color: DiscordEmbedColor; fields?: Array<{ name: string; value: string; inline?: boolean }>; }): Promise { const url = (config.discord_webhook_url || '').trim(); if (!isAcceptableDiscordWebhook(url)) return; const body = { username: 'Twitch VOD Manager', embeds: [ { title: payload.title.slice(0, 256), description: payload.description.slice(0, 4096), color: DISCORD_EMBED_COLORS[payload.color], fields: (payload.fields || []).slice(0, 25).map((f) => ({ name: (f.name || '').slice(0, 256), value: (f.value || '').slice(0, 1024), inline: f.inline === true })), timestamp: new Date().toISOString() } ] }; try { await axios.post(url, body, { timeout: 8000, headers: { 'Content-Type': 'application/json' } }); appendDebugLog('discord-webhook-ok', { title: payload.title, color: payload.color }); } catch (e) { appendDebugLog('discord-webhook-failed', { title: payload.title, error: String(e) }); } } // ========================================== // LIVE RECORDING EVENTS LOG // ========================================== // Sibling .events.jsonl file alongside each live recording. Tracks // recording start/end + Twitch metadata changes (title / game) that // happen while the stream is being captured. Useful when seeking // inside a long archived stream — tells you "at minute 142 he switched // from Just Chatting to Counter-Strike". Independent of chat capture // (lives even if capture_live_chat is off) and uses JSON Lines for // the same crash-safety reason. interface LiveEventTracker { itemId: string; streamer: string; eventsPath: string; fileHandle: number | null; startedAt: number; // Date.now() when recording started lastTitle: string; lastGame: string; closing: boolean; } const liveEventTrackers = new Map(); let liveEventsPollTimer: NodeJS.Timeout | null = null; function eventsLogPathFor(videoPath: string): string { const ext = path.extname(videoPath); const base = ext ? videoPath.slice(0, -ext.length) : videoPath; return `${base}.events.jsonl`; } function appendEventLine(tracker: LiveEventTracker, payload: Record): void { if (tracker.fileHandle === null) return; const line = JSON.stringify({ t: new Date().toISOString(), ...payload }) + '\n'; try { fs.writeSync(tracker.fileHandle, line); } catch (e) { appendDebugLog('events-log-write-failed', { itemId: tracker.itemId, error: String(e) }); } } function startLiveEventsTracker(itemId: string, streamer: string, videoPath: string, initialTitle: string, initialGame: string): LiveEventTracker | null { const eventsPath = eventsLogPathFor(videoPath); let fd: number; try { fd = fs.openSync(eventsPath, 'w'); } catch (e) { appendDebugLog('events-log-open-failed', { itemId, eventsPath, error: String(e) }); return null; } const tracker: LiveEventTracker = { itemId, streamer, eventsPath, fileHandle: fd, startedAt: Date.now(), lastTitle: initialTitle, lastGame: initialGame, closing: false }; appendEventLine(tracker, { type: 'recording_start', streamer, title: initialTitle, game: initialGame }); liveEventTrackers.set(itemId, tracker); ensureLiveEventsPollTimer(); return tracker; } function stopLiveEventsTracker(itemId: string, finalNote?: { success: boolean; durationMs: number; error?: string }): void { const tracker = liveEventTrackers.get(itemId); if (!tracker || tracker.closing) return; tracker.closing = true; appendEventLine(tracker, { type: 'recording_end', durationSeconds: finalNote ? Math.floor(finalNote.durationMs / 1000) : Math.floor((Date.now() - tracker.startedAt) / 1000), success: finalNote?.success === true, error: finalNote?.error || '' }); if (tracker.fileHandle !== null) { try { fs.closeSync(tracker.fileHandle); } catch { /* ignore */ } tracker.fileHandle = null; } liveEventTrackers.delete(itemId); if (liveEventTrackers.size === 0 && liveEventsPollTimer) { clearInterval(liveEventsPollTimer); liveEventsPollTimer = null; } } function ensureLiveEventsPollTimer(): void { if (liveEventsPollTimer) return; // Same cadence as auto-record polling; metadata changes don't need // sub-minute resolution and we want to keep API load bounded. liveEventsPollTimer = setInterval(() => { void pollLiveEventsForChanges(); }, 60 * 1000); liveEventsPollTimer.unref?.(); } async function pollLiveEventsForChanges(): Promise { if (liveEventTrackers.size === 0) return; for (const tracker of liveEventTrackers.values()) { if (tracker.closing) continue; const info = await getLiveStreamInfo(tracker.streamer); if (!info || !info.isLive) continue; const currentTitle = info.title || ''; const currentGame = info.gameName || ''; if (currentTitle !== tracker.lastTitle) { appendEventLine(tracker, { type: 'title_change', from: tracker.lastTitle, to: currentTitle }); tracker.lastTitle = currentTitle; } if (currentGame !== tracker.lastGame) { appendEventLine(tracker, { type: 'game_change', from: tracker.lastGame, to: currentGame }); tracker.lastGame = currentGame; // Also fire a webhook ping if the user wants it. Game changes // matter more than title micro-tweaks, so we only ping for game. if (config.discord_notify_live_start) { void sendDiscordWebhook({ title: `Game change: ${tracker.streamer}`, description: `Now playing **${currentGame || 'unknown'}**`, color: 'info', fields: [ { name: 'Title', value: currentTitle || '-', inline: false } ] }); } } } } // ========================================== // LIVE CHAT CAPTURE (during live recording) // ========================================== // Companion to fetchVodChatReplay: while a stream is being recorded live, // open an anonymous IRC connection to Twitch chat and append every message // to a sibling .chat.jsonl file. Format is JSON Lines (one JSON object per // line) so a partial / killed write still parses correctly — important // because live recordings can run for many hours and we don't want to // keep the full chat in memory. interface LiveChatSession { streamer: string; outputPath: string; socket: TLSSocket; fileHandle: number | null; closing: boolean; messageCount: number; buffer: string; } const TWITCH_IRC_HOST = 'irc.chat.twitch.tv'; const TWITCH_IRC_PORT = 6697; function liveChatPathFor(videoPath: string): string { const ext = path.extname(videoPath); const base = ext ? videoPath.slice(0, -ext.length) : videoPath; return `${base}.chat.jsonl`; } function startLiveChatCapture(streamer: string, outputPath: string): LiveChatSession | null { const channelName = normalizeLogin(streamer); if (!channelName) return null; let fd: number; try { fd = fs.openSync(outputPath, 'w'); } catch (e) { appendDebugLog('chat-capture-open-failed', { streamer: channelName, outputPath, error: String(e) }); return null; } const session: LiveChatSession = { streamer: channelName, outputPath, socket: tlsConnect({ host: TWITCH_IRC_HOST, port: TWITCH_IRC_PORT, servername: TWITCH_IRC_HOST }), fileHandle: fd, closing: false, messageCount: 0, buffer: '' }; // Write a header line so the file is self-describing even if zero // messages arrive (e.g. silent stream, immediate disconnect). const header = { type: 'header', streamer: channelName, startedAt: new Date().toISOString(), format: 'twitch-vod-manager-chat-jsonl-v1' }; try { fs.writeSync(fd, JSON.stringify(header) + '\n'); } catch { /* ignore */ } session.socket.on('secureConnect', () => { // Anonymous Twitch IRC: any nick prefixed with "justinfan" is // accepted without a password. Random suffix avoids collisions. const nick = `justinfan${Math.floor(Math.random() * 100000)}`; try { session.socket.write('CAP REQ :twitch.tv/tags twitch.tv/commands\r\n'); session.socket.write(`NICK ${nick}\r\n`); session.socket.write(`JOIN #${channelName}\r\n`); } catch (e) { appendDebugLog('chat-capture-handshake-failed', { streamer: channelName, error: String(e) }); } appendDebugLog('chat-capture-connected', { streamer: channelName, nick }); }); session.socket.on('data', (chunk: Buffer) => { session.buffer += chunk.toString('utf-8'); const lines = session.buffer.split('\r\n'); session.buffer = lines.pop() || ''; for (const line of lines) { handleIrcLine(session, line); } }); session.socket.on('error', (err: Error) => { appendDebugLog('chat-capture-socket-error', { streamer: channelName, error: String(err) }); }); session.socket.on('close', () => { if (!session.closing) { appendDebugLog('chat-capture-disconnected', { streamer: channelName, messages: session.messageCount }); } if (session.fileHandle !== null) { try { fs.closeSync(session.fileHandle); } catch { /* ignore */ } session.fileHandle = null; } }); return session; } function handleIrcLine(session: LiveChatSession, line: string): void { if (!line) return; if (line.startsWith('PING')) { try { session.socket.write('PONG' + line.slice(4) + '\r\n'); } catch { /* ignore */ } return; } let rest = line; let tagsStr = ''; if (rest.startsWith('@')) { const sp = rest.indexOf(' '); if (sp < 0) return; tagsStr = rest.slice(1, sp); rest = rest.slice(sp + 1); } let prefix = ''; if (rest.startsWith(':')) { const sp = rest.indexOf(' '); if (sp < 0) return; prefix = rest.slice(1, sp); rest = rest.slice(sp + 1); } const cmdSp = rest.indexOf(' '); const command = cmdSp < 0 ? rest : rest.slice(0, cmdSp); const params = cmdSp < 0 ? '' : rest.slice(cmdSp + 1); if (command !== 'PRIVMSG' && command !== 'USERNOTICE' && command !== 'CLEARCHAT' && command !== 'CLEARMSG') return; const colonIdx = params.indexOf(' :'); const text = colonIdx >= 0 ? params.slice(colonIdx + 2) : ''; const tags: Record = {}; if (tagsStr) { for (const pair of tagsStr.split(';')) { const eq = pair.indexOf('='); if (eq < 0) continue; tags[pair.slice(0, eq)] = pair.slice(eq + 1); } } const login = (prefix.split('!')[0] || tags['login'] || '').toLowerCase(); const message = { t: new Date().toISOString(), type: command === 'PRIVMSG' ? 'msg' : (command === 'USERNOTICE' ? 'notice' : command.toLowerCase()), u: tags['display-name'] || login, login, color: tags['color'] || '', msg: text, badges: tags['badges'] || '', bits: tags['bits'] || '', msgId: tags['msg-id'] || '', systemMsg: (tags['system-msg'] || '').replace(/\\s/g, ' ') }; if (session.fileHandle === null) return; try { fs.writeSync(session.fileHandle, JSON.stringify(message) + '\n'); session.messageCount++; } catch (e) { appendDebugLog('chat-capture-write-failed', { error: String(e) }); } } function stopLiveChatCapture(session: LiveChatSession): void { if (session.closing) return; session.closing = true; appendDebugLog('chat-capture-stopping', { streamer: session.streamer, messages: session.messageCount }); try { session.socket.write(`PART #${session.streamer}\r\nQUIT\r\n`); } catch { /* ignore */ } try { session.socket.end(); } catch { /* ignore */ } setTimeout(() => { try { session.socket.destroy(); } catch { /* ignore */ } }, 500); } async function downloadLiveStream( item: QueueItem, onProgress: (progress: DownloadProgress) => void ): Promise { 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 safeStreamer = (item.streamer || 'live').replace(/[^a-zA-Z0-9_-]/g, ''); const now = new Date(); const dateStr = `${now.getFullYear()}-${(now.getMonth() + 1).toString().padStart(2, '0')}-${now.getDate().toString().padStart(2, '0')}`; const timeStr = `${now.getHours().toString().padStart(2, '0')}-${now.getMinutes().toString().padStart(2, '0')}-${now.getSeconds().toString().padStart(2, '0')}`; const folder = path.join(config.download_path, safeStreamer, 'live'); fs.mkdirSync(folder, { recursive: true }); const baseFilename = ensureUniqueFilename( path.join(folder, `${safeStreamer}_LIVE_${dateStr}_${timeStr}.mp4`), item.id ); // Optional: anonymous IRC chat capture for the duration of the // recording. Sibling .chat.jsonl file. We start it BEFORE streamlink // so the very first chat lines after JOIN aren't dropped, and stop it // AFTER streamlink exits so trailing messages (e.g. "stream offline" // user reactions) are still captured. Chat + events span the whole // multi-part recording (chat is an independent IRC connection, events // is an independent poller), so they stay alive across resume cycles. let chatSession: LiveChatSession | null = null; if (config.capture_live_chat) { const chatPath = liveChatPathFor(baseFilename); chatSession = startLiveChatCapture(item.streamer, chatPath); } let eventsTracker: LiveEventTracker | null = null; if (config.log_stream_events) { let initialTitle = ''; let initialGame = ''; try { const info = await getLiveStreamInfo(item.streamer); if (info) { initialTitle = info.title || ''; initialGame = info.gameName || ''; } } catch { /* ignore */ } eventsTracker = startLiveEventsTracker(item.id, item.streamer, baseFilename, initialTitle, initialGame); } if (config.discord_notify_live_start) { void sendDiscordWebhook({ title: `Recording started: ${item.streamer}`, description: item.title || `${item.streamer} is live`, color: 'live', fields: [ { name: 'URL', value: item.url, inline: false }, { name: 'Output', value: path.basename(baseFilename), inline: false } ] }); } const recordingStartedAt = Date.now(); const BYTES_FRESH_MS = 30_000; const MIN_HEALTHY_PART_MS = 30_000; const RESUME_WAIT_MS = 10_000; const MAX_RESUME_ATTEMPTS = 5; // Total-recording byte tracking. Each resumed part starts streamlink // fresh, so its byte counter resets to 0; we keep accumulatedBytes // across parts so the meta line shows the TOTAL recorded size, not // just the current part. Same for elapsed — recordingStartedAt is the // overall start, not per-part. let accumulatedBytes = 0; let currentPartBytes = 0; let lastBytesValue = 0; let lastBytesAdvancedAt = 0; let lastEmittedProgress: DownloadProgress | null = null; const computeHealth = (): 'ok' | 'stale' | 'unknown' => { if (lastBytesAdvancedAt === 0) return 'unknown'; return (Date.now() - lastBytesAdvancedAt) <= BYTES_FRESH_MS ? 'ok' : 'stale'; }; const wrappedProgress = (p: DownloadProgress): void => { const bytes = Number(p.downloadedBytes) || 0; if (bytes > lastBytesValue) { lastBytesValue = bytes; lastBytesAdvancedAt = Date.now(); } currentPartBytes = bytes; const totalBytes = accumulatedBytes + currentPartBytes; const elapsed = Math.max(1, Math.floor((Date.now() - recordingStartedAt) / 1000)); const avgBitrateMbps = (totalBytes * 8) / elapsed / 1_000_000; const parts: string[] = [formatDuration(elapsed)]; if (totalBytes > 0) parts.push(formatBytes(totalBytes)); if (avgBitrateMbps > 0) parts.push(`${avgBitrateMbps.toFixed(1)} Mbps`); const next = { ...p, speed: '', eta: '', status: parts.join(' · '), recordingHealth: computeHealth() }; lastEmittedProgress = next; onProgress(next); }; // Health-tick: re-emit the most recent progress every 10s so the // renderer's health badge updates even when streamlink is silent. // Without this, a streamlink hung on a buffer-stall would keep showing // 'ok' until the next real byte event. const healthTick = setInterval(() => { if (!lastEmittedProgress) return; const updated: DownloadProgress = { ...lastEmittedProgress, recordingHealth: computeHealth() }; lastEmittedProgress = updated; onProgress(updated); }, 10_000); healthTick.unref?.(); const outputs: string[] = []; let partNumber = 1; let resumeCount = 0; let lastPartResult: DownloadResult = { success: false, error: tBackend('unknownDownloadError') }; try { // Resume loop. Each iteration runs streamlink once. On clean exit, // we re-check whether the stream is still live on Twitch's side; // if yes, the exit was an interruption (network blip, segment // discontinuity, etc.) — start a new part and append. If the // stream really ended, break and finalize. while (true) { const partFilename = partNumber === 1 ? baseFilename : ensureUniqueFilename( baseFilename.replace(/\.mp4$/i, `_part${partNumber}.mp4`), item.id ); // Reset per-part counters — streamlink is fresh, byte counter // restarts at zero. lastBytesAdvancedAt stays at zero until // the first segment arrives, which correctly flips the health // dot to 'unknown' during the resume gap. lastBytesValue = 0; lastBytesAdvancedAt = 0; currentPartBytes = 0; const partStartedAt = Date.now(); appendDebugLog('recording-part-start', { itemId: item.id, partNumber, filename: path.basename(partFilename) }); lastPartResult = await downloadVODPart(item.url, partFilename, null, null, wrappedProgress, item.id, partNumber, partNumber); // Accumulate this part's final bytes into the running total so // the next part's meta line continues from the correct figure. let partFinalBytes = 0; if (fs.existsSync(partFilename)) { try { partFinalBytes = fs.statSync(partFilename).size || 0; } catch { /* ignore */ } } if (partFinalBytes > 0) { outputs.push(partFilename); accumulatedBytes += partFinalBytes; } else { // Streamlink produced no bytes — likely permission or auth // failure. Skip resume because retrying will hit the same // wall. The error from lastPartResult will surface upstream. appendDebugLog('recording-part-zero-bytes', { itemId: item.id, partNumber }); break; } // Resume decision tree. if (cancelledItemIds.has(item.id) || !isDownloading || pauseRequested) { appendDebugLog('recording-resume-cancelled', { itemId: item.id, partNumber, reason: pauseRequested ? 'pause' : 'cancel' }); break; } if (!config.auto_resume_live_recording) { appendDebugLog('recording-resume-disabled', { itemId: item.id }); break; } if (resumeCount >= MAX_RESUME_ATTEMPTS) { appendDebugLog('recording-resume-max-attempts', { itemId: item.id, max: MAX_RESUME_ATTEMPTS }); break; } // Don't resume on suspiciously short parts — that pattern points // at a config issue (bad URL, auth-required stream, streamlink // missing plugin) where retrying will just loop and burn API // quota. const partDurationMs = Date.now() - partStartedAt; if (partDurationMs < MIN_HEALTHY_PART_MS) { appendDebugLog('recording-resume-skip-short', { itemId: item.id, partNumber, durationMs: partDurationMs }); break; } // Only resume if Twitch still says the stream is live. If the // streamer actually ended their broadcast, we accept the part // we have and call the recording done. let stillLive = false; try { const info = await getLiveStreamInfo(item.streamer); stillLive = info?.isLive === true; } catch { // Unknown liveness — err on the side of NOT resuming to // avoid infinite-loop on network-out conditions where we // can't even reach Twitch to check. The user can always // restart manually. stillLive = false; } if (!stillLive) { appendDebugLog('recording-finished-stream-offline', { itemId: item.id, parts: partNumber }); break; } appendDebugLog('recording-resume-attempt', { itemId: item.id, previousPart: partNumber, attempt: resumeCount + 1 }); if (eventsTracker) { appendEventLine(eventsTracker, { type: 'recording_resume', part: partNumber + 1 }); } resumeCount++; partNumber++; await sleep(RESUME_WAIT_MS); } } finally { clearInterval(healthTick); } if (chatSession) { stopLiveChatCapture(chatSession); } if (eventsTracker) { stopLiveEventsTracker(item.id, { success: outputs.length > 0, durationMs: Date.now() - recordingStartedAt, error: outputs.length === 0 ? lastPartResult.error : undefined }); } if (config.discord_notify_live_end) { const durationSec = Math.max(0, Math.floor((Date.now() - recordingStartedAt) / 1000)); const sizeBytes = accumulatedBytes; const success = outputs.length > 0; void sendDiscordWebhook({ title: success ? `Recording finished: ${item.streamer}` : `Recording failed: ${item.streamer}`, description: item.title || `${item.streamer}`, color: success ? 'success' : 'info', fields: [ { name: 'Duration', value: formatDuration(durationSec), inline: true }, { name: 'Size', value: formatBytes(sizeBytes), inline: true }, { name: 'Parts', value: String(outputs.length || 1), inline: true }, { name: 'Chat captured', value: chatSession ? `${chatSession.messageCount} messages` : 'no', inline: true }, { name: 'Output', value: path.basename(baseFilename), inline: false } ] }); } if (outputs.length === 0) return lastPartResult; // Auto-merge resumed parts. Only attempt when (a) the user opted in, // (b) there's actually something to merge, and (c) the parts are all // present on disk. Failure is non-fatal — we keep the parts so the // user still has working files even if ffmpeg trips on a corrupted // segment header. let finalRecordings = outputs.slice(); if (config.auto_merge_resumed_parts && outputs.length > 1) { const mergedOutput = ensureUniqueFilename( baseFilename.replace(/\.mp4$/i, '_merged.mp4'), item.id ); const mergeOk = await concatVideoFiles(outputs, mergedOutput); if (mergeOk) { if (config.delete_parts_after_merge) { for (const partPath of outputs) { try { fs.unlinkSync(partPath); } catch (e) { appendDebugLog('merge-part-delete-failed', { path: partPath, error: String(e) }); } } finalRecordings = [mergedOutput]; } else { finalRecordings = [mergedOutput, ...outputs]; } appendDebugLog('merge-resumed-parts-ok', { merged: mergedOutput, partsKept: !config.delete_parts_after_merge }); } else { appendDebugLog('merge-resumed-parts-failed-keeping-parts'); } } if (chatSession && fs.existsSync(chatSession.outputPath)) { finalRecordings.push(chatSession.outputPath); } if (eventsTracker && fs.existsSync(eventsTracker.eventsPath)) { finalRecordings.push(eventsTracker.eventsPath); } return { success: true, outputFiles: finalRecordings }; } async function downloadVOD( item: QueueItem, onProgress: (progress: DownloadProgress) => void ): Promise { // Live-recording branch: URL is the channel page, no VOD id, no time // window. Streamlink runs until the stream ends, then we treat the // whole capture as a single output file. if (item.isLive) { return await downloadLiveStream(item, onProgress); } 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 (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 (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 (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 (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); recordDownloadProgress(progress); }) : await downloadVOD(item, (progress) => { mainWindow?.webContents.send('download-progress', progress); recordDownloadProgress(progress); }); if (result.success) { finalResult = result; break; } finalResult = result; if (!isDownloading || 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]; } // Discord webhook for non-live VOD completion. Live recordings // already get their own end-of-recording webhook in downloadLiveStream. if (finalResult.success && !item.isLive && config.discord_notify_vod_complete) { const totalBytes = (item.outputFiles || []).reduce((sum, f) => { try { return sum + (fs.statSync(f).size || 0); } catch { return sum; } }, 0); void sendDiscordWebhook({ title: `VOD download complete: ${item.streamer}`, description: item.title || item.url, color: 'success', fields: [ { name: 'Files', value: String((item.outputFiles || []).length), inline: true }, { name: 'Size', value: formatBytes(totalBytes), inline: true } ] }); } // Per-VOD completion notification (separate from the queue-end // notification fired at the end of processQueue). Off by default // because users with long queues would get spammed. if (finalResult.success && config.notify_on_each_completion) { try { if (Notification.isSupported()) { const itemNotification = new Notification({ title: 'Twitch VOD Manager', body: `${item.title || item.url}` }); const firstFile = item.outputFiles?.[0]; itemNotification.on('click', () => { try { if (mainWindow) { if (mainWindow.isMinimized()) mainWindow.restore(); mainWindow.focus(); } // Click on a per-item notification opens the // file directly when we know it; falls back to // the download folder otherwise. if (firstFile && fs.existsSync(firstFile)) { shell.showItemInFolder(firstFile); } else if (config.download_path && fs.existsSync(config.download_path)) { void shell.openPath(config.download_path); } } catch (e) { appendDebugLog('per-item-notification-click-failed', String(e)); } }); itemNotification.show(); } } catch { /* notifications optional */ } } 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); } // Optional chat-replay download. Only for non-live, non-merge // VODs that have a parseable VOD id and produced at least one // output file. Saved as {video_basename}.chat.json next to the // video. Truncation is logged but not fatal. if (config.download_chat_replay && !item.isLive && !item.mergeGroup) { const vodIdForChat = parseVodId(item.url); const firstOutput = item.outputFiles?.[0]; if (vodIdForChat && firstOutput) { try { mainWindow?.webContents.send('download-progress', { id: item.id, progress: 100, speed: '', eta: '', status: tBackend('statusFetchingChatReplay'), currentPart: 0, totalParts: 0 } as DownloadProgress); const replay = await fetchVodChatReplay(vodIdForChat, (count) => { mainWindow?.webContents.send('download-progress', { id: item.id, progress: 100, speed: '', eta: '', status: tBackend('statusChatMessagesFetched', { count: String(count) }), currentPart: 0, totalParts: 0 } as DownloadProgress); }, () => cancelledItemIds.has(item.id)); const chatPath = chatReplayPathFor(firstOutput); const payload = { videoId: vodIdForChat, videoUrl: item.url, streamer: item.streamer, title: item.title, fetchedAt: new Date().toISOString(), messageCount: replay.messages.length, truncated: replay.truncated, pages: replay.pages, messages: replay.messages }; writeFileAtomicSync(chatPath, JSON.stringify(payload, null, 2)); appendDebugLog('chat-replay-saved', { itemId: item.id, videoId: vodIdForChat, messages: replay.messages.length, pages: replay.pages, truncated: replay.truncated, path: chatPath }); if (Array.isArray(item.outputFiles)) { item.outputFiles = [...item.outputFiles, chatPath]; } } catch (e) { // Non-fatal: video download still succeeded. appendDebugLog('chat-replay-failed', { itemId: item.id, error: String(e) }); } } } } 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); clearDownloadProgress(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; 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', () => { appendDebugLog('auto-updater-checking'); 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 ); appendDebugLog('auto-updater-update-available', { version: 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', () => { appendDebugLog('auto-updater-update-not-available'); mainWindow?.webContents.send('update-not-available'); }); autoUpdater.on('download-progress', (progress) => { // No per-tick stdout — the autoUpdater fires this ~10x/sec during // an in-flight download. The renderer banner is the user-visible // surface; appendDebugLog already captures phase transitions. 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; appendDebugLog('auto-updater-update-downloaded', { version: 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('get-automation-status', () => ({ autoRecord: { watching: Array.isArray(config.auto_record_streamers) ? config.auto_record_streamers.length : 0, lastRunAt: autoRecordLastRunAt, nextRunAt: autoRecordNextRunAt, lastTriggeredCount: autoRecordLastTriggerCount, inFlight: autoRecordPollInFlight }, autoVod: { watching: Array.isArray(config.auto_vod_download_streamers) ? config.auto_vod_download_streamers.length : 0, lastRunAt: autoVodLastRunAt, nextRunAt: autoVodNextRunAt, lastQueuedCount: autoVodLastQueuedCount, inFlight: autoVodPollInFlight } })); ipcMain.handle('trigger-auto-record-scan', async () => { const triggered = await runAutoRecordPoll(); return { triggered }; }); ipcMain.handle('trigger-auto-vod-scan', async () => { const queuedCount = await runAutoVodPoll(); return { queuedCount }; }); 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; const previousAutoRecordList = JSON.stringify(config.auto_record_streamers || []); const previousAutoRecordSeconds = config.auto_record_poll_seconds; const previousAutoVodList = JSON.stringify(config.auto_vod_download_streamers || []); const previousAutoVodMinutes = config.auto_vod_download_poll_minutes; const previousStreamerList = JSON.stringify(config.streamers || []); 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); } // Restart auto-record poller if its inputs changed (added/removed // streamers or interval changed). Drop transition state for any // streamer no longer being watched so re-enabling them later doesn't // suppress an immediate first-poll trigger. const newAutoRecordList = JSON.stringify(config.auto_record_streamers || []); if (newAutoRecordList !== previousAutoRecordList || config.auto_record_poll_seconds !== previousAutoRecordSeconds) { const watched = new Set(config.auto_record_streamers || []); for (const k of Array.from(autoRecordLastLiveState.keys())) { if (!watched.has(k)) autoRecordLastLiveState.delete(k); } restartAutoRecordPoller(); } // Same dance for the auto-VOD poller — independent cadence from // auto-record because VOD listings are heavier to fetch. const newAutoVodList = JSON.stringify(config.auto_vod_download_streamers || []); if (newAutoVodList !== previousAutoVodList || config.auto_vod_download_poll_minutes !== previousAutoVodMinutes) { restartAutoVodPoller(); } // Live-status batch poller — fire an immediate refresh when the // streamer list itself changes (added/removed) so the sidebar dots // update instantly instead of waiting for the next 60s tick. const newStreamerList = JSON.stringify(config.streamers || []); if (newStreamerList !== previousStreamerList) { restartLiveStatusPoller(); } // Restart cleanup timer when the toggle flips; harmless to call when // unchanged because restartAutoCleanupTimer just resets the interval. restartAutoCleanupTimer(); 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('start-live-recording', async (_, streamerName: string) => { if (typeof streamerName !== 'string' || !streamerName) { return { success: false, error: 'Invalid streamer name' }; } const login = normalizeLogin(streamerName); if (!login) return { success: false, error: 'Invalid streamer name' }; const liveInfo = await getLiveStreamInfo(login); if (liveInfo === null) { return { success: false, error: 'Could not check live status. Try again.' }; } if (!liveInfo.isLive) { return { success: false, error: 'OFFLINE', streamer: login }; } const channelUrl = `https://www.twitch.tv/${login}`; const liveItem: QueueItem = { id: generateQueueItemId(), title: liveInfo.title || `${login} (LIVE)`, url: channelUrl, date: new Date().toISOString(), streamer: login, duration_str: '0s', // unknown — stream is in progress status: 'pending', progress: 0, isLive: true }; // Duplicate guard — refuse to start a second live recording of the // same channel while one is already active or pending. const dup = downloadQueue.some((it) => it.isLive && it.streamer === login && (it.status === 'pending' || it.status === 'downloading')); if (dup) { return { success: false, error: 'ALREADY_RECORDING', streamer: login }; } downloadQueue.push(liveItem); saveQueue(downloadQueue); emitQueueUpdated(); if (!isDownloading) void processQueue(); appendDebugLog('live-recording-queued', { streamer: login, title: liveItem.title }); return { success: true, streamer: login, title: liveInfo.title || login }; }); 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(); } 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; // Kill queue downloads only — cutter/merger/splitter use currentEditorProcess // and aren't affected by pause-download. Per-item cancel state lives in // cancelledItemIds — every active item gets added below. 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; // 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); } }); // Extensions that shell.openPath would happily execute via the system // default. Calc.exe via XSS smuggling is the canonical example; this // list blocks the obvious vectors. Media/text/image extensions are // still fine — shell.openPath opens them in the OS's default viewer. const OPEN_FILE_BLOCKED_EXTENSIONS = new Set([ '.exe', '.bat', '.cmd', '.com', '.ps1', '.vbs', '.vbe', '.js', '.jse', '.wsf', '.wsh', '.scr', '.msi', '.msp', '.lnk', '.cpl', '.reg', '.hta', '.jar', '.application' ]); ipcMain.handle('open-file', async (_, filePath: string): Promise => { if (typeof filePath !== 'string' || !filePath) return false; if (!fs.existsSync(filePath)) return false; const ext = path.extname(filePath).toLowerCase(); if (OPEN_FILE_BLOCKED_EXTENSIONS.has(ext)) { appendDebugLog('open-file-rejected-extension', { ext, path: filePath.slice(0, 200) }); 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) => { // Only allow https / http URLs — never let the renderer push a // file://, javascript:, or shell:-style URL through to the OS // shell.openExternal handler. The renderer is contextIsolated + // nodeIntegration: false, but an XSS through (e.g.) a streamer name // smuggling a payload into a template would otherwise hand the // attacker shell.openExternal which on Windows happily resolves // file:///C:/Windows/System32/calc.exe. if (typeof url !== 'string') return; const trimmed = url.trim(); if (!/^https?:\/\//i.test(trimmed)) { appendDebugLog('open-external-rejected', { url: trimmed.slice(0, 200) }); return; } await shell.openExternal(trimmed); }); // 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}`, getStreamlinkStreamArg(), '-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('get-archive-stats', (): ArchiveStats => { return computeArchiveStats(); }); ipcMain.handle('get-streamer-profile', async (_, login: string, forceRefresh?: boolean): Promise => { return await getStreamerProfile(login, forceRefresh === true); }); ipcMain.handle('get-vod-storyboard', async (_, vodId: string): Promise => { return await getVodStoryboard(vodId); }); ipcMain.handle('get-live-status-snapshot', (): Record => { const snap: Record = {}; for (const [k, v] of liveStatusByLogin.entries()) snap[k] = v; return snap; }); ipcMain.handle('search-archive', (_, filter: Partial): ArchiveSearchResult => { const normalized: ArchiveSearchFilter = { query: typeof filter?.query === 'string' ? filter.query.trim() : '', type: (['all', 'live', 'vod', 'chat', 'events'] as const).includes(filter?.type as 'all' | 'live' | 'vod' | 'chat' | 'events') ? filter!.type as 'all' | 'live' | 'vod' | 'chat' | 'events' : 'all', streamer: typeof filter?.streamer === 'string' ? filter.streamer.trim() : '', sinceMs: Number.isFinite(filter?.sinceMs as number) ? Number(filter?.sinceMs) : null, untilMs: Number.isFinite(filter?.untilMs as number) ? Number(filter?.untilMs) : null, sort: (['date_desc', 'date_asc', 'size_desc', 'size_asc', 'name_asc'] as const).includes(filter?.sort as 'date_desc') ? filter!.sort as 'date_desc' | 'date_asc' | 'size_desc' | 'size_asc' | 'name_asc' : 'date_desc', limit: Number.isFinite(filter?.limit as number) ? Number(filter?.limit) : 200 }; return searchArchive(normalized); }); ipcMain.handle('get-storage-stats', (): StorageStatsResult => { return computeStorageStats(); }); ipcMain.handle('run-storage-cleanup', (_, options?: { dryRun?: boolean }): CleanupReport => { return runStorageCleanup({ dryRun: options?.dryRun === true }); }); // Read a chat-replay (.chat.json) or live-chat (.chat.jsonl) file and // return a normalized message list the renderer can display directly. // Caps at 50k messages to stop a runaway file from killing the renderer. ipcMain.handle('read-chat-file', (_, filePath: string): { success: boolean; error?: string; format?: 'replay' | 'live'; messages?: Array>; truncated?: boolean; total?: number } => { if (typeof filePath !== 'string' || !filePath) return { success: false, error: 'No path' }; if (!fs.existsSync(filePath)) return { success: false, error: 'File not found' }; const MAX_MESSAGES = 50000; try { const raw = fs.readFileSync(filePath, 'utf-8'); if (filePath.toLowerCase().endsWith('.jsonl')) { // JSON Lines (live chat): one object per line, first line may be header const messages: Array> = []; let truncated = false; const lines = raw.split('\n'); let total = 0; for (const line of lines) { const trimmed = line.trim(); if (!trimmed) continue; try { const obj = JSON.parse(trimmed); if (obj && typeof obj === 'object' && obj.type !== 'header') { total++; if (messages.length < MAX_MESSAGES) messages.push(obj); else truncated = true; } } catch { /* skip bad lines */ } } return { success: true, format: 'live', messages, truncated, total }; } // .chat.json (VOD replay) — single object with messages array const parsed = JSON.parse(raw); if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.messages)) { return { success: false, error: 'Unsupported chat file format' }; } const total = parsed.messages.length; const messages = parsed.messages.length > MAX_MESSAGES ? parsed.messages.slice(0, MAX_MESSAGES) : parsed.messages; return { success: true, format: 'replay', messages, truncated: total > MAX_MESSAGES, total }; } catch (e) { return { success: false, error: String(e) }; } }); ipcMain.handle('check-folder-writable', (_, folderPath: string): boolean => { if (typeof folderPath !== 'string' || !folderPath) return false; return isDownloadPathWritable(folderPath); }); 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) }; } }); ipcMain.handle('mark-vod-downloaded', (_, vodId: string, mark: boolean): { success: boolean } => { if (typeof vodId !== 'string' || !vodId) return { success: false }; if (!Array.isArray(config.downloaded_vod_ids)) config.downloaded_vod_ids = []; const has = config.downloaded_vod_ids.includes(vodId); if (mark && !has) { config.downloaded_vod_ids.push(vodId); } else if (!mark && has) { config.downloaded_vod_ids = config.downloaded_vod_ids.filter((id) => id !== vodId); } else { return { success: true }; } saveConfig(config); appendDebugLog('mark-vod-downloaded', { vodId, mark }); return { success: true }; }); ipcMain.handle('reset-downloaded-vod-ids', () => { const count = Array.isArray(config.downloaded_vod_ids) ? config.downloaded_vod_ids.length : 0; config.downloaded_vod_ids = []; saveConfig(config); appendDebugLog('reset-downloaded-vod-ids', { previousCount: count }); return { success: true, removedCount: count }; }); ipcMain.handle('export-config', async () => { try { const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const defaultName = `twitch-vod-manager-config-${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 }; } // Strip the secrets from the export — Client Secret should not // travel as plain text across machines / cloud sync. The user // re-enters it on the new machine after import. const exportable = { ...config, client_secret: '', __exportVersion: 1, __exportedAt: new Date().toISOString() }; writeFileAtomicSync(dialogResult.filePath, JSON.stringify(exportable, null, 2)); return { success: true, filePath: dialogResult.filePath }; } catch (e) { appendDebugLog('config-export-failed', String(e)); return { success: false, error: String(e) }; } }); ipcMain.handle('import-config', async () => { try { const dialogResult = await dialog.showOpenDialog(mainWindow!, { properties: ['openFile'], filters: [{ name: 'JSON', extensions: ['json'] }] }); if (dialogResult.canceled || !dialogResult.filePaths[0]) { return { success: false, cancelled: true }; } const importPath = dialogResult.filePaths[0]; const raw = fs.readFileSync(importPath, 'utf-8'); const parsed = JSON.parse(raw); if (!isPlainObject(parsed)) { return { success: false, error: 'Imported file is not a JSON object.' }; } // Merge over current config so unknown / missing keys keep their // existing values. Then run normalizeConfigTemplates so any // out-of-range field falls back to defaults. const merged = normalizeConfigTemplates({ ...config, ...parsed } as Config); // Preserve the existing client_secret if the import stripped it // (export does this on purpose) — the user shouldn't lose creds. if (!merged.client_secret && config.client_secret) { merged.client_secret = config.client_secret; } config = merged; saveConfig(config); appendDebugLog('config-import-applied', { source: importPath }); return { success: true, filePath: importPath }; } catch (e) { appendDebugLog('config-import-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 // ========================================== // Long-lived SQLite-Handle (Plan 04b+ Voraussetzung). Wird in app.whenReady // geoeffnet, in shutdownCleanup geschlossen. getAppDb() returnt null wenn // Open fehlgeschlagen ist (Native-Build-Probleme) — Caller mussen das pruefen. let appDb: DbHandle | null = null; export function getAppDb(): DbHandle | null { return appDb; } app.whenReady().then(() => { app.setAppUserModelId('com.twitch.vodmanager'); refreshBundledToolPaths(true); startMetadataCacheCleanup(); startDebugLogFlushTimer(); // SQLite-Open + Shadow-Migration. Long-lived handle in appDb (siehe oben). // Lazy require, damit Native-Build-Fehler den App-Start nicht verhindern. try { const { openDatabase } = require('./main/infra/db'); const { migrateJsonToSqlite } = require('./main/domain/migrator'); const dbPath = path.join(APPDATA_DIR, 'app.db'); appDb = openDatabase(dbPath); const result = migrateJsonToSqlite({ db: appDb, appDataDir: APPDATA_DIR }); appendDebugLog('sqlite-migrator', result); } catch (e) { appendDebugLog('sqlite-open-failed', { error: e instanceof Error ? e.message : String(e), }); appDb = null; } restartAutoRecordPoller(); restartAutoVodPoller(); restartLiveStatusPoller(); restartAutoCleanupTimer(); 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(); stopAutoRecordPoller(); stopAutoVodPoller(); stopLiveStatusPoller(); stopAutoCleanupTimer(); // 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(); // SQLite-Handle schliessen, falls geoeffnet — WAL-Checkpoint passiert beim // close, sodass beim naechsten Start keine .wal/.shm orphans bleiben. if (appDb) { try { appDb.close(); } catch { /* already closed */ } appDb = null; } // 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'); });