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();