From 3d404d75e1ba9a2ac2a50ea90d89ae2e1302ec47 Mon Sep 17 00:00:00 2001 From: xRangerDE Date: Fri, 13 Feb 2026 12:20:36 +0100 Subject: [PATCH] Auto-install missing runtime tools (v3.8.0) Download and extract streamlink/ffmpeg dependencies into ProgramData when unavailable so fresh server installs can start downloads without manual tool setup, while preserving detailed debug logging for failures. --- docs/src/pages/getting-started.mdx | 8 +- typescript-version/package-lock.json | 4 +- typescript-version/package.json | 2 +- typescript-version/src/index.html | 4 +- typescript-version/src/main.ts | 225 ++++++++++++++++++++++++++- 5 files changed, 234 insertions(+), 9 deletions(-) diff --git a/docs/src/pages/getting-started.mdx b/docs/src/pages/getting-started.mdx index dfaf074..1b5dbf4 100644 --- a/docs/src/pages/getting-started.mdx +++ b/docs/src/pages/getting-started.mdx @@ -9,8 +9,12 @@ description: Install and configure Twitch VOD Manager quickly. ## Requirements - Windows 10/11 (installer and paths are currently Windows-first) -- `streamlink` available in `PATH` -- `ffmpeg` + `ffprobe` available in `PATH` + +The app can auto-install missing runtime tools (`streamlink`, `ffmpeg`, `ffprobe`) into: + +`C:\ProgramData\Twitch_VOD_Manager\tools` + +Manual installation is still supported. Optional but recommended: diff --git a/typescript-version/package-lock.json b/typescript-version/package-lock.json index c37fb63..d5c4242 100644 --- a/typescript-version/package-lock.json +++ b/typescript-version/package-lock.json @@ -1,12 +1,12 @@ { "name": "twitch-vod-manager", - "version": "3.7.9", + "version": "3.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "twitch-vod-manager", - "version": "3.7.9", + "version": "3.8.0", "license": "MIT", "dependencies": { "axios": "^1.6.0", diff --git a/typescript-version/package.json b/typescript-version/package.json index 2fb00ae..0b5304d 100644 --- a/typescript-version/package.json +++ b/typescript-version/package.json @@ -1,6 +1,6 @@ { "name": "twitch-vod-manager", - "version": "3.7.9", + "version": "3.8.0", "description": "Twitch VOD Manager - Download Twitch VODs easily", "main": "dist/main.js", "author": "xRangerDE", diff --git a/typescript-version/src/index.html b/typescript-version/src/index.html index 65fb60c..2d01a93 100644 --- a/typescript-version/src/index.html +++ b/typescript-version/src/index.html @@ -335,7 +335,7 @@

Updates

-

Version: v3.7.9

+

Version: v3.8.0

@@ -346,7 +346,7 @@
Nicht verbunden - v3.7.9 + v3.8.0 diff --git a/typescript-version/src/main.ts b/typescript-version/src/main.ts index 6936eaf..d41c133 100644 --- a/typescript-version/src/main.ts +++ b/typescript-version/src/main.ts @@ -1,14 +1,14 @@ import { app, BrowserWindow, ipcMain, dialog, shell } from 'electron'; import * as path from 'path'; import * as fs from 'fs'; -import { spawn, ChildProcess, execSync, exec } from 'child_process'; +import { spawn, ChildProcess, execSync, exec, execFileSync, spawnSync } from 'child_process'; import axios from 'axios'; import { autoUpdater } from 'electron-updater'; // ========================================== // CONFIG & CONSTANTS // ========================================== -const APP_VERSION = '3.7.9'; +const APP_VERSION = '3.8.0'; const UPDATE_CHECK_URL = 'http://24-music.de/version.json'; // Paths @@ -16,6 +16,9 @@ const APPDATA_DIR = path.join(process.env.PROGRAMDATA || 'C:\\ProgramData', 'Twi 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'); // Timeouts @@ -173,11 +176,18 @@ let downloadStartTime = 0; let downloadedBytes = 0; const userIdLoginCache = new Map(); let streamlinkCommandCache: { command: string; prefixArgs: string[] } | null = null; +let bundledStreamlinkPath: string | null = null; +let bundledFFmpegPath: string | null = null; +let bundledFFprobePath: string | null = null; // ========================================== // TOOL PATHS // ========================================== function getStreamlinkPath(): string { + if (bundledStreamlinkPath && fs.existsSync(bundledStreamlinkPath)) { + return bundledStreamlinkPath; + } + try { if (process.platform === 'win32') { const result = execSync('where streamlink', { encoding: 'utf-8' }); @@ -211,6 +221,170 @@ function canExecute(cmd: string): boolean { } } +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 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 refreshBundledToolPaths(): void { + bundledStreamlinkPath = findFileRecursive(TOOLS_STREAMLINK_DIR, process.platform === 'win32' ? 'streamlink.exe' : 'streamlink'); + bundledFFmpegPath = findFileRecursive(TOOLS_FFMPEG_DIR, process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'); + bundledFFprobePath = findFileRecursive(TOOLS_FFMPEG_DIR, process.platform === 'win32' ? 'ffprobe.exe' : 'ffprobe'); +} + +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; + } +} + +function extractZip(zipPath: string, destinationDir: string): boolean { + try { + fs.mkdirSync(destinationDir, { recursive: true }); + execFileSync('powershell', [ + '-NoProfile', + '-ExecutionPolicy', 'Bypass', + '-Command', + `Expand-Archive -Path '${zipPath.replace(/'/g, "''")}' -DestinationPath '${destinationDir.replace(/'/g, "''")}' -Force` + ], { windowsHide: true, stdio: 'ignore' }); + return true; + } catch (e) { + appendDebugLog('extract-zip-failed', { zipPath, destinationDir, error: String(e) }); + return false; + } +} + +async function ensureStreamlinkInstalled(): Promise { + refreshBundledToolPaths(); + + const current = getStreamlinkCommand(); + if (canExecuteCommand(current.command, [...current.prefixArgs, '--version'])) { + 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 = extractZip(zipPath, TOOLS_STREAMLINK_DIR); + try { fs.unlinkSync(zipPath); } catch { } + if (!extractOk) return false; + + refreshBundledToolPaths(); + streamlinkCommandCache = null; + + const cmd = getStreamlinkCommand(); + const works = canExecuteCommand(cmd.command, [...cmd.prefixArgs, '--version']); + 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 (canExecuteCommand(ffmpegPath, ['-version']) && canExecuteCommand(ffprobePath, ['-version'])) { + 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 = extractZip(zipPath, TOOLS_FFMPEG_DIR); + try { fs.unlinkSync(zipPath); } catch { } + if (!extractOk) return false; + + refreshBundledToolPaths(); + + const newFfmpegPath = getFFmpegPath(); + const newFfprobePath = getFFprobePath(); + const works = canExecuteCommand(newFfmpegPath, ['-version']) && canExecuteCommand(newFfprobePath, ['-version']); + 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; @@ -249,6 +423,10 @@ function getStreamlinkCommand(): { command: string; prefixArgs: string[] } { } function getFFmpegPath(): string { + if (bundledFFmpegPath && fs.existsSync(bundledFFmpegPath)) { + return bundledFFmpegPath; + } + try { if (process.platform === 'win32') { const result = execSync('where ffmpeg', { encoding: 'utf-8' }); @@ -274,6 +452,10 @@ function getFFmpegPath(): string { } function getFFprobePath(): string { + if (bundledFFprobePath && fs.existsSync(bundledFFprobePath)) { + return bundledFFprobePath; + } + const ffmpegPath = getFFmpegPath(); const ffprobeExe = process.platform === 'win32' ? 'ffprobe.exe' : 'ffprobe'; return path.join(path.dirname(ffmpegPath), ffprobeExe); @@ -619,6 +801,12 @@ async function getClipInfo(clipId: string): Promise { // 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 = [ @@ -665,6 +853,12 @@ async function getVideoInfo(filePath: string): Promise { // 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`); @@ -702,6 +896,12 @@ async function cutVideo( endTime: number, onProgress: (percent: number) => void ): Promise { + const ffmpegReady = await ensureFfmpegInstalled(); + if (!ffmpegReady) { + appendDebugLog('cut-video-missing-ffmpeg'); + return false; + } + return new Promise((resolve) => { const ffmpeg = getFFmpegPath(); const duration = endTime - startTime; @@ -749,6 +949,12 @@ async function mergeVideos( outputFile: string, onProgress: (percent: number) => void ): Promise { + const ffmpegReady = await ensureFfmpegInstalled(); + if (!ffmpegReady) { + appendDebugLog('merge-videos-missing-ffmpeg'); + return false; + } + return new Promise((resolve) => { const ffmpeg = getFFmpegPath(); @@ -951,6 +1157,14 @@ async function downloadVOD( item: QueueItem, onProgress: (progress: DownloadProgress) => void ): Promise { + const streamlinkReady = await ensureStreamlinkInstalled(); + if (!streamlinkReady) { + return { + success: false, + error: 'Streamlink fehlt und konnte nicht automatisch installiert werden. Siehe debug.log.' + }; + } + 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()}`; @@ -1437,8 +1651,15 @@ ipcMain.handle('save-video-dialog', async (_, defaultName: string) => { // APP LIFECYCLE // ========================================== app.whenReady().then(() => { + refreshBundledToolPaths(); createWindow(); + void (async () => { + const streamlinkOk = await ensureStreamlinkInstalled(); + const ffmpegOk = await ensureFfmpegInstalled(); + appendDebugLog('startup-tools-check', { streamlinkOk, ffmpegOk }); + })(); + app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow();