refactor: extract tool discovery functions to src/tools.ts
Move streamlink/ffmpeg path discovery, bundled tool management, auto-install logic, and related caches (~430 lines) into a dedicated tools module. main.ts uses dependency injection for debug logging and directory paths to keep the module decoupled. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
54d04d4f73
commit
66afaba0ea
460
src/main.ts
460
src/main.ts
@ -1,11 +1,20 @@
|
|||||||
import { app, BrowserWindow, ipcMain, dialog, shell, nativeTheme, Notification } from 'electron';
|
import { app, BrowserWindow, ipcMain, dialog, shell, nativeTheme, Notification } from 'electron';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as fs from 'fs';
|
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 axios from 'axios';
|
||||||
import { autoUpdater } from 'electron-updater';
|
import { autoUpdater } from 'electron-updater';
|
||||||
import { compareUpdateVersions, isNewerUpdateVersion, normalizeUpdateVersion } from './update-version-utils';
|
import { compareUpdateVersions, isNewerUpdateVersion, normalizeUpdateVersion } from './update-version-utils';
|
||||||
import { CustomClip, MergeGroupItem, MergeGroup, QueueItem, DownloadProgress, DownloadResult } from './types';
|
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
|
// CONFIG & CONSTANTS
|
||||||
@ -34,7 +43,6 @@ const DEFAULT_METADATA_CACHE_MINUTES = 10;
|
|||||||
const DEFAULT_PERFORMANCE_MODE: PerformanceMode = 'balanced';
|
const DEFAULT_PERFORMANCE_MODE: PerformanceMode = 'balanced';
|
||||||
const QUEUE_SAVE_DEBOUNCE_MS = 250;
|
const QUEUE_SAVE_DEBOUNCE_MS = 250;
|
||||||
const MIN_FREE_DISK_BYTES = 128 * 1024 * 1024;
|
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_FLUSH_INTERVAL_MS = 1000;
|
||||||
const DEBUG_LOG_BUFFER_FLUSH_LINES = 48;
|
const DEBUG_LOG_BUFFER_FLUSH_LINES = 48;
|
||||||
const DEBUG_LOG_READ_TAIL_BYTES = 512 * 1024;
|
const DEBUG_LOG_READ_TAIL_BYTES = 512 * 1024;
|
||||||
@ -410,17 +418,6 @@ const runtimeMetrics: RuntimeMetrics = {
|
|||||||
lastErrorClass: null,
|
lastErrorClass: null,
|
||||||
lastRetryDelaySeconds: 0
|
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 debugLogFlushTimer: NodeJS.Timeout | null = null;
|
||||||
let pendingDebugLogLines: string[] = [];
|
let pendingDebugLogLines: string[] = [];
|
||||||
let autoUpdaterInitialized = false;
|
let autoUpdaterInitialized = false;
|
||||||
@ -435,54 +432,6 @@ let downloadedUpdateVersion: string | null = null;
|
|||||||
let latestReleaseUpdateInfo: ReleaseUpdateInfo | null = null;
|
let latestReleaseUpdateInfo: ReleaseUpdateInfo | null = null;
|
||||||
let twitchLoginInFlight: Promise<boolean> | null = null;
|
let twitchLoginInFlight: Promise<boolean> | 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<void> {
|
function sleep(ms: number): Promise<void> {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
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<boolean> {
|
|
||||||
try {
|
|
||||||
const response = await axios.get(url, { responseType: 'stream', timeout: 120000 });
|
|
||||||
|
|
||||||
await new Promise<void>((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<boolean> {
|
|
||||||
try {
|
|
||||||
fs.mkdirSync(destinationDir, { recursive: true });
|
|
||||||
|
|
||||||
const command = `Expand-Archive -Path '${zipPath.replace(/'/g, "''")}' -DestinationPath '${destinationDir.replace(/'/g, "''")}' -Force`;
|
|
||||||
|
|
||||||
await new Promise<void>((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<boolean> {
|
|
||||||
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<boolean> {
|
|
||||||
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 {
|
function appendDebugLog(message: string, details?: unknown): void {
|
||||||
try {
|
try {
|
||||||
const ts = new Date().toISOString();
|
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
|
// DURATION HELPERS
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|||||||
494
src/tools.ts
Normal file
494
src/tools.ts
Normal file
@ -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<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(url, { responseType: 'stream', timeout: 120000 });
|
||||||
|
|
||||||
|
await new Promise<void>((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<boolean> {
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(destinationDir, { recursive: true });
|
||||||
|
|
||||||
|
const command = `Expand-Archive -Path '${zipPath.replace(/'/g, "''")}' -DestinationPath '${destinationDir.replace(/'/g, "''")}' -Force`;
|
||||||
|
|
||||||
|
await new Promise<void>((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<boolean> {
|
||||||
|
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<boolean> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user