diff --git a/src/main.ts b/src/main.ts index ab74b44..70d8b1d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,11 +1,20 @@ import { app, BrowserWindow, ipcMain, dialog, shell, nativeTheme, Notification } from 'electron'; import * as path from 'path'; import * as fs from 'fs'; -import { spawn, ChildProcess, execSync, exec, spawnSync } from 'child_process'; +import { spawn, ChildProcess, execSync, spawnSync } from 'child_process'; import axios from 'axios'; import { autoUpdater } from 'electron-updater'; import { compareUpdateVersions, isNewerUpdateVersion, normalizeUpdateVersion } from './update-version-utils'; import { CustomClip, MergeGroupItem, MergeGroup, QueueItem, DownloadProgress, DownloadResult } from './types'; +import { + setDebugLogFn, initToolDirs, + getStreamlinkPath, getStreamlinkCommand, getFFmpegPath, getFFprobePath, + refreshBundledToolPaths, ensureStreamlinkInstalled, ensureFfmpegInstalled, + canExecute, canExecuteCommand, + cacheVerifiedStreamlinkCommand, isVerifiedStreamlinkCommand, + cacheVerifiedFfmpegCommands, isVerifiedFfmpegCommands, + invalidateVerifiedToolCaches +} from './tools'; // ========================================== // CONFIG & CONSTANTS @@ -34,7 +43,6 @@ const DEFAULT_METADATA_CACHE_MINUTES = 10; const DEFAULT_PERFORMANCE_MODE: PerformanceMode = 'balanced'; const QUEUE_SAVE_DEBOUNCE_MS = 250; const MIN_FREE_DISK_BYTES = 128 * 1024 * 1024; -const TOOL_PATH_REFRESH_TTL_MS = 10 * 1000; const DEBUG_LOG_FLUSH_INTERVAL_MS = 1000; const DEBUG_LOG_BUFFER_FLUSH_LINES = 48; const DEBUG_LOG_READ_TAIL_BYTES = 512 * 1024; @@ -410,17 +418,6 @@ const runtimeMetrics: RuntimeMetrics = { lastErrorClass: null, lastRetryDelaySeconds: 0 }; -let streamlinkCommandCache: { command: string; prefixArgs: string[] } | null = null; -let bundledStreamlinkPath: string | null = null; -let bundledFFmpegPath: string | null = null; -let bundledFFprobePath: string | null = null; -let streamlinkPathCache: string | null = null; -let ffmpegPathCache: string | null = null; -let ffprobePathCache: string | null = null; -let verifiedStreamlinkCommandKey: string | null = null; -let verifiedFfmpegCommandKey: string | null = null; -let bundledToolPathSignature = ''; -let bundledToolPathRefreshedAt = 0; let debugLogFlushTimer: NodeJS.Timeout | null = null; let pendingDebugLogLines: string[] = []; let autoUpdaterInitialized = false; @@ -435,54 +432,6 @@ let downloadedUpdateVersion: string | null = null; let latestReleaseUpdateInfo: ReleaseUpdateInfo | null = null; let twitchLoginInFlight: Promise | null = null; -// ========================================== -// TOOL PATHS -// ========================================== -function getStreamlinkPath(): string { - if (streamlinkPathCache) { - if (streamlinkPathCache === 'streamlink' || fs.existsSync(streamlinkPathCache)) { - return streamlinkPathCache; - } - streamlinkPathCache = null; - } - - if (bundledStreamlinkPath && fs.existsSync(bundledStreamlinkPath)) { - streamlinkPathCache = bundledStreamlinkPath; - return streamlinkPathCache; - } - - try { - if (process.platform === 'win32') { - const result = execSync('where streamlink', { encoding: 'utf-8' }); - const paths = result.trim().split('\n'); - if (paths.length > 0) { - streamlinkPathCache = paths[0].trim(); - return streamlinkPathCache; - } - } else { - const result = execSync('which streamlink', { encoding: 'utf-8' }); - streamlinkPathCache = result.trim(); - return streamlinkPathCache; - } - } catch { } - - const commonPaths = [ - 'C:\\Program Files\\Streamlink\\bin\\streamlink.exe', - 'C:\\Program Files (x86)\\Streamlink\\bin\\streamlink.exe', - path.join(process.env.LOCALAPPDATA || '', 'Programs', 'Streamlink', 'bin', 'streamlink.exe') - ]; - - for (const p of commonPaths) { - if (fs.existsSync(p)) { - streamlinkPathCache = p; - return streamlinkPathCache; - } - } - - streamlinkPathCache = 'streamlink'; - return streamlinkPathCache; -} - function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -683,391 +632,6 @@ function readDebugLog(lines = 200): string { } } -function canExecute(cmd: string): boolean { - try { - execSync(cmd, { stdio: 'ignore', windowsHide: true }); - return true; - } catch { - return false; - } -} - -function canExecuteCommand(command: string, args: string[]): boolean { - try { - const result = spawnSync(command, args, { stdio: 'ignore', windowsHide: true }); - return result.status === 0; - } catch { - return false; - } -} - -function getCommandCacheKey(command: string, args: string[]): string { - return [command, ...args].join('\u0000'); -} - -function cacheVerifiedStreamlinkCommand(command: string, args: string[]): void { - verifiedStreamlinkCommandKey = getCommandCacheKey(command, args); -} - -function isVerifiedStreamlinkCommand(command: string, args: string[]): boolean { - return verifiedStreamlinkCommandKey === getCommandCacheKey(command, args); -} - -function cacheVerifiedFfmpegCommands(ffmpegCommand: string, ffprobeCommand: string): void { - verifiedFfmpegCommandKey = getCommandCacheKey(ffmpegCommand, [ffprobeCommand]); -} - -function isVerifiedFfmpegCommands(ffmpegCommand: string, ffprobeCommand: string): boolean { - return verifiedFfmpegCommandKey === getCommandCacheKey(ffmpegCommand, [ffprobeCommand]); -} - -function invalidateVerifiedToolCaches(): void { - verifiedStreamlinkCommandKey = null; - verifiedFfmpegCommandKey = null; -} - -function findFileRecursive(rootDir: string, fileName: string): string | null { - if (!fs.existsSync(rootDir)) return null; - - const entries = fs.readdirSync(rootDir, { withFileTypes: true }); - for (const entry of entries) { - const fullPath = path.join(rootDir, entry.name); - if (entry.isFile() && entry.name.toLowerCase() === fileName.toLowerCase()) { - return fullPath; - } - - if (entry.isDirectory()) { - const nested = findFileRecursive(fullPath, fileName); - if (nested) return nested; - } - } - - return null; -} - -function getDirectoryMtimeMs(directoryPath: string): number { - try { - return fs.statSync(directoryPath).mtimeMs; - } catch { - return 0; - } -} - -function refreshBundledToolPaths(force = false): void { - const now = Date.now(); - const signature = `${getDirectoryMtimeMs(TOOLS_STREAMLINK_DIR)}|${getDirectoryMtimeMs(TOOLS_FFMPEG_DIR)}`; - - if (!force && signature === bundledToolPathSignature && (now - bundledToolPathRefreshedAt) < TOOL_PATH_REFRESH_TTL_MS) { - return; - } - - bundledToolPathSignature = signature; - bundledToolPathRefreshedAt = now; - - const nextBundledStreamlinkPath = findFileRecursive(TOOLS_STREAMLINK_DIR, process.platform === 'win32' ? 'streamlink.exe' : 'streamlink'); - const nextBundledFFmpegPath = findFileRecursive(TOOLS_FFMPEG_DIR, process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'); - const nextBundledFFprobePath = findFileRecursive(TOOLS_FFMPEG_DIR, process.platform === 'win32' ? 'ffprobe.exe' : 'ffprobe'); - - const changed = - nextBundledStreamlinkPath !== bundledStreamlinkPath || - nextBundledFFmpegPath !== bundledFFmpegPath || - nextBundledFFprobePath !== bundledFFprobePath; - - bundledStreamlinkPath = nextBundledStreamlinkPath; - bundledFFmpegPath = nextBundledFFmpegPath; - bundledFFprobePath = nextBundledFFprobePath; - - if (changed) { - streamlinkPathCache = null; - ffmpegPathCache = null; - ffprobePathCache = null; - streamlinkCommandCache = null; - invalidateVerifiedToolCaches(); - } -} - -async function downloadFile(url: string, destinationPath: string): Promise { - try { - const response = await axios.get(url, { responseType: 'stream', timeout: 120000 }); - - await new Promise((resolve, reject) => { - const writer = fs.createWriteStream(destinationPath); - response.data.pipe(writer); - writer.on('finish', () => resolve()); - writer.on('error', (err) => reject(err)); - }); - - return true; - } catch (e) { - appendDebugLog('download-file-failed', { url, destinationPath, error: String(e) }); - return false; - } -} - -async function extractZip(zipPath: string, destinationDir: string): Promise { - try { - fs.mkdirSync(destinationDir, { recursive: true }); - - const command = `Expand-Archive -Path '${zipPath.replace(/'/g, "''")}' -DestinationPath '${destinationDir.replace(/'/g, "''")}' -Force`; - - await new Promise((resolve, reject) => { - const proc = spawn('powershell', [ - '-NoProfile', - '-ExecutionPolicy', 'Bypass', - '-Command', - command - ], { windowsHide: true }); - - let stderr = ''; - proc.stderr?.on('data', (data) => { - stderr += data.toString(); - }); - - proc.on('close', (code) => { - if (code === 0) { - resolve(); - } else { - reject(new Error(`Expand-Archive exit code ${code}: ${stderr.trim()}`)); - } - }); - - proc.on('error', (err) => reject(err)); - }); - - return true; - } catch (e) { - appendDebugLog('extract-zip-failed', { zipPath, destinationDir, error: String(e) }); - return false; - } -} - -async function ensureStreamlinkInstalled(): Promise { - refreshBundledToolPaths(); - - const current = getStreamlinkCommand(); - const versionArgs = [...current.prefixArgs, '--version']; - if (isVerifiedStreamlinkCommand(current.command, versionArgs)) { - return true; - } - - if (canExecuteCommand(current.command, versionArgs)) { - cacheVerifiedStreamlinkCommand(current.command, versionArgs); - return true; - } - - if (process.platform !== 'win32') { - return false; - } - - appendDebugLog('streamlink-install-start'); - try { - fs.mkdirSync(TOOLS_STREAMLINK_DIR, { recursive: true }); - - const release = await axios.get('https://api.github.com/repos/streamlink/windows-builds/releases/latest', { - timeout: 120000, - headers: { - 'Accept': 'application/vnd.github+json', - 'User-Agent': 'Twitch-VOD-Manager' - } - }); - - const assets = release.data?.assets || []; - const zipAsset = assets.find((a: any) => typeof a?.name === 'string' && /x86_64\.zip$/i.test(a.name)); - if (!zipAsset?.browser_download_url) { - appendDebugLog('streamlink-install-no-asset-found'); - return false; - } - - const zipPath = path.join(app.getPath('temp'), `streamlink_portable_${Date.now()}.zip`); - const downloadOk = await downloadFile(zipAsset.browser_download_url, zipPath); - if (!downloadOk) return false; - - fs.rmSync(TOOLS_STREAMLINK_DIR, { recursive: true, force: true }); - fs.mkdirSync(TOOLS_STREAMLINK_DIR, { recursive: true }); - - const extractOk = await extractZip(zipPath, TOOLS_STREAMLINK_DIR); - try { fs.unlinkSync(zipPath); } catch { } - if (!extractOk) return false; - - refreshBundledToolPaths(true); - streamlinkCommandCache = null; - - const cmd = getStreamlinkCommand(); - const installedVersionArgs = [...cmd.prefixArgs, '--version']; - const works = canExecuteCommand(cmd.command, installedVersionArgs); - if (works) { - cacheVerifiedStreamlinkCommand(cmd.command, installedVersionArgs); - } - appendDebugLog('streamlink-install-finished', { works, command: cmd.command, prefixArgs: cmd.prefixArgs }); - return works; - } catch (e) { - appendDebugLog('streamlink-install-failed', String(e)); - return false; - } -} - -async function ensureFfmpegInstalled(): Promise { - refreshBundledToolPaths(); - - const ffmpegPath = getFFmpegPath(); - const ffprobePath = getFFprobePath(); - if (isVerifiedFfmpegCommands(ffmpegPath, ffprobePath)) { - return true; - } - - if (canExecuteCommand(ffmpegPath, ['-version']) && canExecuteCommand(ffprobePath, ['-version'])) { - cacheVerifiedFfmpegCommands(ffmpegPath, ffprobePath); - return true; - } - - if (process.platform !== 'win32') { - return false; - } - - appendDebugLog('ffmpeg-install-start'); - try { - fs.mkdirSync(TOOLS_FFMPEG_DIR, { recursive: true }); - - const zipPath = path.join(app.getPath('temp'), `ffmpeg_essentials_${Date.now()}.zip`); - const downloadOk = await downloadFile('https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip', zipPath); - if (!downloadOk) return false; - - fs.rmSync(TOOLS_FFMPEG_DIR, { recursive: true, force: true }); - fs.mkdirSync(TOOLS_FFMPEG_DIR, { recursive: true }); - - const extractOk = await extractZip(zipPath, TOOLS_FFMPEG_DIR); - try { fs.unlinkSync(zipPath); } catch { } - if (!extractOk) return false; - - refreshBundledToolPaths(true); - - const newFfmpegPath = getFFmpegPath(); - const newFfprobePath = getFFprobePath(); - const works = canExecuteCommand(newFfmpegPath, ['-version']) && canExecuteCommand(newFfprobePath, ['-version']); - if (works) { - cacheVerifiedFfmpegCommands(newFfmpegPath, newFfprobePath); - } - appendDebugLog('ffmpeg-install-finished', { works, ffmpeg: newFfmpegPath, ffprobe: newFfprobePath }); - return works; - } catch (e) { - appendDebugLog('ffmpeg-install-failed', String(e)); - return false; - } -} - -function getStreamlinkCommand(): { command: string; prefixArgs: string[] } { - if (streamlinkCommandCache) { - return streamlinkCommandCache; - } - - const directPath = getStreamlinkPath(); - if (directPath !== 'streamlink' || canExecute('streamlink --version')) { - streamlinkCommandCache = { command: directPath, prefixArgs: [] }; - return streamlinkCommandCache; - } - - if (process.platform === 'win32') { - if (canExecute('py -3 -m streamlink --version')) { - streamlinkCommandCache = { command: 'py', prefixArgs: ['-3', '-m', 'streamlink'] }; - return streamlinkCommandCache; - } - - if (canExecute('python -m streamlink --version')) { - streamlinkCommandCache = { command: 'python', prefixArgs: ['-m', 'streamlink'] }; - return streamlinkCommandCache; - } - } else { - if (canExecute('python3 -m streamlink --version')) { - streamlinkCommandCache = { command: 'python3', prefixArgs: ['-m', 'streamlink'] }; - return streamlinkCommandCache; - } - - if (canExecute('python -m streamlink --version')) { - streamlinkCommandCache = { command: 'python', prefixArgs: ['-m', 'streamlink'] }; - return streamlinkCommandCache; - } - } - - streamlinkCommandCache = { command: directPath, prefixArgs: [] }; - return streamlinkCommandCache; -} - -function getFFmpegPath(): string { - if (ffmpegPathCache) { - if (ffmpegPathCache === 'ffmpeg' || fs.existsSync(ffmpegPathCache)) { - return ffmpegPathCache; - } - ffmpegPathCache = null; - } - - if (bundledFFmpegPath && fs.existsSync(bundledFFmpegPath)) { - ffmpegPathCache = bundledFFmpegPath; - return ffmpegPathCache; - } - - try { - if (process.platform === 'win32') { - const result = execSync('where ffmpeg', { encoding: 'utf-8' }); - const paths = result.trim().split('\n'); - if (paths.length > 0) { - ffmpegPathCache = paths[0].trim(); - return ffmpegPathCache; - } - } else { - const result = execSync('which ffmpeg', { encoding: 'utf-8' }); - ffmpegPathCache = result.trim(); - return ffmpegPathCache; - } - } catch { } - - const commonPaths = [ - 'C:\\ffmpeg\\bin\\ffmpeg.exe', - 'C:\\Program Files\\ffmpeg\\bin\\ffmpeg.exe', - path.join(process.env.LOCALAPPDATA || '', 'Programs', 'ffmpeg', 'bin', 'ffmpeg.exe') - ]; - - for (const p of commonPaths) { - if (fs.existsSync(p)) { - ffmpegPathCache = p; - return ffmpegPathCache; - } - } - - ffmpegPathCache = 'ffmpeg'; - return ffmpegPathCache; -} - -function getFFprobePath(): string { - if (ffprobePathCache) { - if (ffprobePathCache === 'ffprobe' || ffprobePathCache === 'ffprobe.exe' || fs.existsSync(ffprobePathCache)) { - return ffprobePathCache; - } - ffprobePathCache = null; - } - - if (bundledFFprobePath && fs.existsSync(bundledFFprobePath)) { - ffprobePathCache = bundledFFprobePath; - return ffprobePathCache; - } - - const ffmpegPath = getFFmpegPath(); - const ffprobeExe = process.platform === 'win32' ? 'ffprobe.exe' : 'ffprobe'; - - if (ffmpegPath === 'ffmpeg') { - ffprobePathCache = ffprobeExe; - return ffprobePathCache; - } - - const derivedFfprobePath = path.join(path.dirname(ffmpegPath), ffprobeExe); - if (fs.existsSync(derivedFfprobePath)) { - ffprobePathCache = derivedFfprobePath; - return ffprobePathCache; - } - - ffprobePathCache = ffprobeExe; - return ffprobePathCache; -} - function appendDebugLog(message: string, details?: unknown): void { try { const ts = new Date().toISOString(); @@ -1087,6 +651,10 @@ function appendDebugLog(message: string, details?: unknown): void { } } +// Wire up tools module with debug logging and directory paths +setDebugLogFn(appendDebugLog); +initToolDirs(TOOLS_STREAMLINK_DIR, TOOLS_FFMPEG_DIR, () => app.getPath('temp')); + // ========================================== // DURATION HELPERS // ========================================== diff --git a/src/tools.ts b/src/tools.ts new file mode 100644 index 0000000..7012162 --- /dev/null +++ b/src/tools.ts @@ -0,0 +1,494 @@ +import * as path from 'path'; +import * as fs from 'fs'; +import { spawn, execSync, spawnSync } from 'child_process'; +import axios from 'axios'; + +// ========================================== +// CONSTANTS +// ========================================== +const TOOL_PATH_REFRESH_TTL_MS = 10 * 1000; + +// ========================================== +// DEBUG LOG CALLBACK +// ========================================== +let _appendDebugLog: (message: string, details?: unknown) => void = () => {}; + +export function setDebugLogFn(fn: (message: string, details?: unknown) => void): void { + _appendDebugLog = fn; +} + +// ========================================== +// TOOL DIRECTORIES (set once from main) +// ========================================== +let TOOLS_STREAMLINK_DIR = ''; +let TOOLS_FFMPEG_DIR = ''; +let _getTempPath: () => string = () => ''; + +export function initToolDirs(streamlinkDir: string, ffmpegDir: string, getTempPath: () => string): void { + TOOLS_STREAMLINK_DIR = streamlinkDir; + TOOLS_FFMPEG_DIR = ffmpegDir; + _getTempPath = getTempPath; +} + +// ========================================== +// CACHE STATE +// ========================================== +let streamlinkPathCache: string | null = null; +let streamlinkCommandCache: { command: string; prefixArgs: string[] } | null = null; +let ffmpegPathCache: string | null = null; +let ffprobePathCache: string | null = null; +let bundledStreamlinkPath: string | null = null; +let bundledFFmpegPath: string | null = null; +let bundledFFprobePath: string | null = null; +let verifiedStreamlinkCommandKey: string | null = null; +let verifiedFfmpegCommandKey: string | null = null; +let bundledToolPathSignature = ''; +let bundledToolPathRefreshedAt = 0; + +// ========================================== +// INTERNAL HELPERS +// ========================================== +function findFileRecursive(rootDir: string, fileName: string): string | null { + if (!fs.existsSync(rootDir)) return null; + + const entries = fs.readdirSync(rootDir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(rootDir, entry.name); + if (entry.isFile() && entry.name.toLowerCase() === fileName.toLowerCase()) { + return fullPath; + } + + if (entry.isDirectory()) { + const nested = findFileRecursive(fullPath, fileName); + if (nested) return nested; + } + } + + return null; +} + +function getDirectoryMtimeMs(directoryPath: string): number { + try { + return fs.statSync(directoryPath).mtimeMs; + } catch { + return 0; + } +} + +function getCommandCacheKey(command: string, args: string[]): string { + return [command, ...args].join('\u0000'); +} + +export function canExecute(cmd: string): boolean { + try { + execSync(cmd, { stdio: 'ignore', windowsHide: true }); + return true; + } catch { + return false; + } +} + +export function canExecuteCommand(command: string, args: string[]): boolean { + try { + const result = spawnSync(command, args, { stdio: 'ignore', windowsHide: true }); + return result.status === 0; + } catch { + return false; + } +} + +// ========================================== +// VERIFIED COMMAND CACHES +// ========================================== +export function cacheVerifiedStreamlinkCommand(command: string, args: string[]): void { + verifiedStreamlinkCommandKey = getCommandCacheKey(command, args); +} + +export function isVerifiedStreamlinkCommand(command: string, args: string[]): boolean { + return verifiedStreamlinkCommandKey === getCommandCacheKey(command, args); +} + +export function cacheVerifiedFfmpegCommands(ffmpegCommand: string, ffprobeCommand: string): void { + verifiedFfmpegCommandKey = getCommandCacheKey(ffmpegCommand, [ffprobeCommand]); +} + +export function isVerifiedFfmpegCommands(ffmpegCommand: string, ffprobeCommand: string): boolean { + return verifiedFfmpegCommandKey === getCommandCacheKey(ffmpegCommand, [ffprobeCommand]); +} + +export function invalidateVerifiedToolCaches(): void { + verifiedStreamlinkCommandKey = null; + verifiedFfmpegCommandKey = null; +} + +// ========================================== +// TOOL PATH DISCOVERY +// ========================================== +export function getStreamlinkPath(): string { + if (streamlinkPathCache) { + if (streamlinkPathCache === 'streamlink' || fs.existsSync(streamlinkPathCache)) { + return streamlinkPathCache; + } + streamlinkPathCache = null; + } + + if (bundledStreamlinkPath && fs.existsSync(bundledStreamlinkPath)) { + streamlinkPathCache = bundledStreamlinkPath; + return streamlinkPathCache; + } + + try { + if (process.platform === 'win32') { + const result = execSync('where streamlink', { encoding: 'utf-8' }); + const paths = result.trim().split('\n'); + if (paths.length > 0) { + streamlinkPathCache = paths[0].trim(); + return streamlinkPathCache; + } + } else { + const result = execSync('which streamlink', { encoding: 'utf-8' }); + streamlinkPathCache = result.trim(); + return streamlinkPathCache; + } + } catch { } + + const commonPaths = [ + 'C:\\Program Files\\Streamlink\\bin\\streamlink.exe', + 'C:\\Program Files (x86)\\Streamlink\\bin\\streamlink.exe', + path.join(process.env.LOCALAPPDATA || '', 'Programs', 'Streamlink', 'bin', 'streamlink.exe') + ]; + + for (const p of commonPaths) { + if (fs.existsSync(p)) { + streamlinkPathCache = p; + return streamlinkPathCache; + } + } + + streamlinkPathCache = 'streamlink'; + return streamlinkPathCache; +} + +export function getStreamlinkCommand(): { command: string; prefixArgs: string[] } { + if (streamlinkCommandCache) { + return streamlinkCommandCache; + } + + const directPath = getStreamlinkPath(); + if (directPath !== 'streamlink' || canExecute('streamlink --version')) { + streamlinkCommandCache = { command: directPath, prefixArgs: [] }; + return streamlinkCommandCache; + } + + if (process.platform === 'win32') { + if (canExecute('py -3 -m streamlink --version')) { + streamlinkCommandCache = { command: 'py', prefixArgs: ['-3', '-m', 'streamlink'] }; + return streamlinkCommandCache; + } + + if (canExecute('python -m streamlink --version')) { + streamlinkCommandCache = { command: 'python', prefixArgs: ['-m', 'streamlink'] }; + return streamlinkCommandCache; + } + } else { + if (canExecute('python3 -m streamlink --version')) { + streamlinkCommandCache = { command: 'python3', prefixArgs: ['-m', 'streamlink'] }; + return streamlinkCommandCache; + } + + if (canExecute('python -m streamlink --version')) { + streamlinkCommandCache = { command: 'python', prefixArgs: ['-m', 'streamlink'] }; + return streamlinkCommandCache; + } + } + + streamlinkCommandCache = { command: directPath, prefixArgs: [] }; + return streamlinkCommandCache; +} + +export function getFFmpegPath(): string { + if (ffmpegPathCache) { + if (ffmpegPathCache === 'ffmpeg' || fs.existsSync(ffmpegPathCache)) { + return ffmpegPathCache; + } + ffmpegPathCache = null; + } + + if (bundledFFmpegPath && fs.existsSync(bundledFFmpegPath)) { + ffmpegPathCache = bundledFFmpegPath; + return ffmpegPathCache; + } + + try { + if (process.platform === 'win32') { + const result = execSync('where ffmpeg', { encoding: 'utf-8' }); + const paths = result.trim().split('\n'); + if (paths.length > 0) { + ffmpegPathCache = paths[0].trim(); + return ffmpegPathCache; + } + } else { + const result = execSync('which ffmpeg', { encoding: 'utf-8' }); + ffmpegPathCache = result.trim(); + return ffmpegPathCache; + } + } catch { } + + const commonPaths = [ + 'C:\\ffmpeg\\bin\\ffmpeg.exe', + 'C:\\Program Files\\ffmpeg\\bin\\ffmpeg.exe', + path.join(process.env.LOCALAPPDATA || '', 'Programs', 'ffmpeg', 'bin', 'ffmpeg.exe') + ]; + + for (const p of commonPaths) { + if (fs.existsSync(p)) { + ffmpegPathCache = p; + return ffmpegPathCache; + } + } + + ffmpegPathCache = 'ffmpeg'; + return ffmpegPathCache; +} + +export function getFFprobePath(): string { + if (ffprobePathCache) { + if (ffprobePathCache === 'ffprobe' || ffprobePathCache === 'ffprobe.exe' || fs.existsSync(ffprobePathCache)) { + return ffprobePathCache; + } + ffprobePathCache = null; + } + + if (bundledFFprobePath && fs.existsSync(bundledFFprobePath)) { + ffprobePathCache = bundledFFprobePath; + return ffprobePathCache; + } + + const ffmpegPath = getFFmpegPath(); + const ffprobeExe = process.platform === 'win32' ? 'ffprobe.exe' : 'ffprobe'; + + if (ffmpegPath === 'ffmpeg') { + ffprobePathCache = ffprobeExe; + return ffprobePathCache; + } + + const derivedFfprobePath = path.join(path.dirname(ffmpegPath), ffprobeExe); + if (fs.existsSync(derivedFfprobePath)) { + ffprobePathCache = derivedFfprobePath; + return ffprobePathCache; + } + + ffprobePathCache = ffprobeExe; + return ffprobePathCache; +} + +// ========================================== +// BUNDLED TOOL PATH REFRESH +// ========================================== +export function refreshBundledToolPaths(force = false): void { + const now = Date.now(); + const signature = `${getDirectoryMtimeMs(TOOLS_STREAMLINK_DIR)}|${getDirectoryMtimeMs(TOOLS_FFMPEG_DIR)}`; + + if (!force && signature === bundledToolPathSignature && (now - bundledToolPathRefreshedAt) < TOOL_PATH_REFRESH_TTL_MS) { + return; + } + + bundledToolPathSignature = signature; + bundledToolPathRefreshedAt = now; + + const nextBundledStreamlinkPath = findFileRecursive(TOOLS_STREAMLINK_DIR, process.platform === 'win32' ? 'streamlink.exe' : 'streamlink'); + const nextBundledFFmpegPath = findFileRecursive(TOOLS_FFMPEG_DIR, process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'); + const nextBundledFFprobePath = findFileRecursive(TOOLS_FFMPEG_DIR, process.platform === 'win32' ? 'ffprobe.exe' : 'ffprobe'); + + const changed = + nextBundledStreamlinkPath !== bundledStreamlinkPath || + nextBundledFFmpegPath !== bundledFFmpegPath || + nextBundledFFprobePath !== bundledFFprobePath; + + bundledStreamlinkPath = nextBundledStreamlinkPath; + bundledFFmpegPath = nextBundledFFmpegPath; + bundledFFprobePath = nextBundledFFprobePath; + + if (changed) { + streamlinkPathCache = null; + ffmpegPathCache = null; + ffprobePathCache = null; + streamlinkCommandCache = null; + invalidateVerifiedToolCaches(); + } +} + +// ========================================== +// DOWNLOAD & EXTRACT HELPERS +// ========================================== +async function downloadFile(url: string, destinationPath: string): Promise { + try { + const response = await axios.get(url, { responseType: 'stream', timeout: 120000 }); + + await new Promise((resolve, reject) => { + const writer = fs.createWriteStream(destinationPath); + response.data.pipe(writer); + writer.on('finish', () => resolve()); + writer.on('error', (err) => reject(err)); + }); + + return true; + } catch (e) { + _appendDebugLog('download-file-failed', { url, destinationPath, error: String(e) }); + return false; + } +} + +async function extractZip(zipPath: string, destinationDir: string): Promise { + try { + fs.mkdirSync(destinationDir, { recursive: true }); + + const command = `Expand-Archive -Path '${zipPath.replace(/'/g, "''")}' -DestinationPath '${destinationDir.replace(/'/g, "''")}' -Force`; + + await new Promise((resolve, reject) => { + const proc = spawn('powershell', [ + '-NoProfile', + '-ExecutionPolicy', 'Bypass', + '-Command', + command + ], { windowsHide: true }); + + let stderr = ''; + proc.stderr?.on('data', (data) => { + stderr += data.toString(); + }); + + proc.on('close', (code) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`Expand-Archive exit code ${code}: ${stderr.trim()}`)); + } + }); + + proc.on('error', (err) => reject(err)); + }); + + return true; + } catch (e) { + _appendDebugLog('extract-zip-failed', { zipPath, destinationDir, error: String(e) }); + return false; + } +} + +// ========================================== +// AUTO-INSTALL TOOLS +// ========================================== +export async function ensureStreamlinkInstalled(): Promise { + refreshBundledToolPaths(); + + const current = getStreamlinkCommand(); + const versionArgs = [...current.prefixArgs, '--version']; + if (isVerifiedStreamlinkCommand(current.command, versionArgs)) { + return true; + } + + if (canExecuteCommand(current.command, versionArgs)) { + cacheVerifiedStreamlinkCommand(current.command, versionArgs); + return true; + } + + if (process.platform !== 'win32') { + return false; + } + + _appendDebugLog('streamlink-install-start'); + try { + fs.mkdirSync(TOOLS_STREAMLINK_DIR, { recursive: true }); + + const release = await axios.get('https://api.github.com/repos/streamlink/windows-builds/releases/latest', { + timeout: 120000, + headers: { + 'Accept': 'application/vnd.github+json', + 'User-Agent': 'Twitch-VOD-Manager' + } + }); + + const assets = release.data?.assets || []; + const zipAsset = assets.find((a: any) => typeof a?.name === 'string' && /x86_64\.zip$/i.test(a.name)); + if (!zipAsset?.browser_download_url) { + _appendDebugLog('streamlink-install-no-asset-found'); + return false; + } + + const zipPath = path.join(_getTempPath(), `streamlink_portable_${Date.now()}.zip`); + const downloadOk = await downloadFile(zipAsset.browser_download_url, zipPath); + if (!downloadOk) return false; + + fs.rmSync(TOOLS_STREAMLINK_DIR, { recursive: true, force: true }); + fs.mkdirSync(TOOLS_STREAMLINK_DIR, { recursive: true }); + + const extractOk = await extractZip(zipPath, TOOLS_STREAMLINK_DIR); + try { fs.unlinkSync(zipPath); } catch { } + if (!extractOk) return false; + + refreshBundledToolPaths(true); + streamlinkCommandCache = null; + + const cmd = getStreamlinkCommand(); + const installedVersionArgs = [...cmd.prefixArgs, '--version']; + const works = canExecuteCommand(cmd.command, installedVersionArgs); + if (works) { + cacheVerifiedStreamlinkCommand(cmd.command, installedVersionArgs); + } + _appendDebugLog('streamlink-install-finished', { works, command: cmd.command, prefixArgs: cmd.prefixArgs }); + return works; + } catch (e) { + _appendDebugLog('streamlink-install-failed', String(e)); + return false; + } +} + +export async function ensureFfmpegInstalled(): Promise { + refreshBundledToolPaths(); + + const ffmpegPath = getFFmpegPath(); + const ffprobePath = getFFprobePath(); + if (isVerifiedFfmpegCommands(ffmpegPath, ffprobePath)) { + return true; + } + + if (canExecuteCommand(ffmpegPath, ['-version']) && canExecuteCommand(ffprobePath, ['-version'])) { + cacheVerifiedFfmpegCommands(ffmpegPath, ffprobePath); + return true; + } + + if (process.platform !== 'win32') { + return false; + } + + _appendDebugLog('ffmpeg-install-start'); + try { + fs.mkdirSync(TOOLS_FFMPEG_DIR, { recursive: true }); + + const zipPath = path.join(_getTempPath(), `ffmpeg_essentials_${Date.now()}.zip`); + const downloadOk = await downloadFile('https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip', zipPath); + if (!downloadOk) return false; + + fs.rmSync(TOOLS_FFMPEG_DIR, { recursive: true, force: true }); + fs.mkdirSync(TOOLS_FFMPEG_DIR, { recursive: true }); + + const extractOk = await extractZip(zipPath, TOOLS_FFMPEG_DIR); + try { fs.unlinkSync(zipPath); } catch { } + if (!extractOk) return false; + + refreshBundledToolPaths(true); + + const newFfmpegPath = getFFmpegPath(); + const newFfprobePath = getFFprobePath(); + const works = canExecuteCommand(newFfmpegPath, ['-version']) && canExecuteCommand(newFfprobePath, ['-version']); + if (works) { + cacheVerifiedFfmpegCommands(newFfmpegPath, newFfprobePath); + } + _appendDebugLog('ffmpeg-install-finished', { works, ffmpeg: newFfmpegPath, ffprobe: newFfprobePath }); + return works; + } catch (e) { + _appendDebugLog('ffmpeg-install-failed', String(e)); + return false; + } +}