Compare commits
8 Commits
6a32387add
...
6b97039471
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b97039471 | ||
|
|
66afaba0ea | ||
|
|
54d04d4f73 | ||
|
|
63aafae85d | ||
|
|
424b312551 | ||
|
|
fbcf3935d0 | ||
|
|
2481230983 | ||
|
|
c96fd13aff |
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "twitch-vod-manager",
|
||||
"version": "4.4.0",
|
||||
"version": "4.5.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "twitch-vod-manager",
|
||||
"version": "4.4.0",
|
||||
"version": "4.5.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.6.0",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "twitch-vod-manager",
|
||||
"version": "4.4.0",
|
||||
"version": "4.5.0",
|
||||
"description": "Twitch VOD Manager - Download Twitch VODs easily",
|
||||
"main": "dist/main.js",
|
||||
"author": "xRangerDE",
|
||||
|
||||
@ -218,6 +218,7 @@
|
||||
<button class="btn btn-clear" id="btnClear" onclick="clearCompleted()">Leeren</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats-bar" id="statsBar"></div>
|
||||
</aside>
|
||||
|
||||
<main class="main">
|
||||
@ -379,6 +380,7 @@
|
||||
<option value="discord">Discord</option>
|
||||
<option value="youtube">YouTube</option>
|
||||
<option value="apple">Apple</option>
|
||||
<option value="light" id="themeLightOption">Light</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
@ -434,6 +436,13 @@
|
||||
<label id="partMinutesLabel">Teil-Lange (Minuten)</label>
|
||||
<input type="number" id="partMinutes" value="120" min="10" max="480">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label id="parallelDownloadsLabel">Parallele Downloads</label>
|
||||
<select id="parallelDownloads">
|
||||
<option value="1" id="parallelDownloads1">1 (Standard)</option>
|
||||
<option value="2" id="parallelDownloads2">2 (Parallel)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label id="performanceModeLabel">Performance-Profil</label>
|
||||
<select id="performanceMode">
|
||||
|
||||
698
src/main.ts
698
src/main.ts
@ -1,10 +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
|
||||
@ -33,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;
|
||||
@ -96,6 +105,7 @@ interface Config {
|
||||
prevent_duplicate_downloads: boolean;
|
||||
persist_queue_on_restart: boolean;
|
||||
metadata_cache_minutes: number;
|
||||
parallel_downloads: number;
|
||||
}
|
||||
|
||||
interface RuntimeMetrics {
|
||||
@ -156,57 +166,6 @@ interface VOD {
|
||||
stream_id: string;
|
||||
}
|
||||
|
||||
interface CustomClip {
|
||||
startSec: number;
|
||||
durationSec: number;
|
||||
startPart: number;
|
||||
filenameFormat: 'simple' | 'timestamp' | 'template';
|
||||
filenameTemplate?: string;
|
||||
}
|
||||
|
||||
interface MergeGroupItem {
|
||||
url: string;
|
||||
title: string;
|
||||
date: string;
|
||||
streamer: string;
|
||||
duration_str: string;
|
||||
}
|
||||
|
||||
interface MergeGroup {
|
||||
items: MergeGroupItem[];
|
||||
mergePhase: 'downloading' | 'merging' | 'splitting' | 'cleanup' | 'done';
|
||||
currentItemIndex: number;
|
||||
downloadedFiles: Record<number, string>;
|
||||
mergedFile?: string;
|
||||
splitFiles?: string[];
|
||||
totalDurationSec?: number;
|
||||
}
|
||||
|
||||
interface QueueItem {
|
||||
id: string;
|
||||
title: string;
|
||||
url: string;
|
||||
date: string;
|
||||
streamer: string;
|
||||
duration_str: string;
|
||||
status: 'pending' | 'downloading' | 'paused' | 'completed' | 'error';
|
||||
progress: number;
|
||||
currentPart?: number;
|
||||
totalParts?: number;
|
||||
speed?: string;
|
||||
eta?: string;
|
||||
downloadedBytes?: number;
|
||||
totalBytes?: number;
|
||||
last_error?: string;
|
||||
customClip?: CustomClip;
|
||||
mergeGroup?: MergeGroup;
|
||||
}
|
||||
|
||||
interface DownloadResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface PreflightChecks {
|
||||
internet: boolean;
|
||||
streamlink: boolean;
|
||||
@ -223,19 +182,6 @@ interface PreflightResult {
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
interface DownloadProgress {
|
||||
id: string;
|
||||
progress: number;
|
||||
speed: string;
|
||||
speedBytesPerSec?: number;
|
||||
eta: string;
|
||||
status: string;
|
||||
currentPart?: number;
|
||||
totalParts?: number;
|
||||
downloadedBytes?: number;
|
||||
totalBytes?: number;
|
||||
}
|
||||
|
||||
interface VideoInfo {
|
||||
duration: number;
|
||||
width: number;
|
||||
@ -270,7 +216,8 @@ const defaultConfig: Config = {
|
||||
performance_mode: DEFAULT_PERFORMANCE_MODE,
|
||||
prevent_duplicate_downloads: true,
|
||||
persist_queue_on_restart: true,
|
||||
metadata_cache_minutes: DEFAULT_METADATA_CACHE_MINUTES
|
||||
metadata_cache_minutes: DEFAULT_METADATA_CACHE_MINUTES,
|
||||
parallel_downloads: 1
|
||||
};
|
||||
|
||||
function normalizeFilenameTemplate(template: string | undefined, fallback: string): string {
|
||||
@ -442,6 +389,9 @@ let pauseRequested = false;
|
||||
let activeQueueItemId: string | null = null;
|
||||
let downloadStartTime = 0;
|
||||
let downloadedBytes = 0;
|
||||
// Per-item tracking for parallel downloads
|
||||
const activeDownloads = new Map<string, { process: ChildProcess | null; cancelled: boolean; startTime: number; bytes: number }>();
|
||||
const cancelledItemIds = new Set<string>();
|
||||
const userIdLoginCache = new Map<string, string>();
|
||||
const loginToUserIdCache = new Map<string, CacheEntry<string>>();
|
||||
const vodListCache = new Map<string, CacheEntry<VOD[]>>();
|
||||
@ -468,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;
|
||||
@ -493,54 +432,6 @@ let downloadedUpdateVersion: string | null = null;
|
||||
let latestReleaseUpdateInfo: ReleaseUpdateInfo | 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> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
@ -741,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 {
|
||||
try {
|
||||
const ts = new Date().toISOString();
|
||||
@ -1145,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
|
||||
// ==========================================
|
||||
@ -2658,7 +2168,11 @@ function downloadVODPart(
|
||||
const proc = spawn(streamlinkCmd.command, args, { windowsHide: true });
|
||||
currentProcess = proc;
|
||||
|
||||
downloadStartTime = Date.now();
|
||||
// Register in per-item tracking map for parallel downloads
|
||||
const itemTracking = { process: proc, cancelled: false, startTime: Date.now(), bytes: 0 };
|
||||
activeDownloads.set(itemId, itemTracking);
|
||||
|
||||
downloadStartTime = itemTracking.startTime;
|
||||
downloadedBytes = 0;
|
||||
let lastBytes = 0;
|
||||
let lastTime = Date.now();
|
||||
@ -2669,6 +2183,7 @@ function downloadVODPart(
|
||||
try {
|
||||
const stats = fs.statSync(filename);
|
||||
downloadedBytes = stats.size;
|
||||
itemTracking.bytes = stats.size;
|
||||
|
||||
const now = Date.now();
|
||||
const timeDiff = (now - lastTime) / 1000;
|
||||
@ -2685,11 +2200,27 @@ function downloadVODPart(
|
||||
lastBytes = downloadedBytes;
|
||||
lastTime = now;
|
||||
|
||||
let etaStr = '';
|
||||
if (speed > 0 && downloadedBytes > 0) {
|
||||
const itemStartTime = itemTracking.startTime;
|
||||
const elapsedSec = (Date.now() - itemStartTime) / 1000;
|
||||
if (elapsedSec > 3) {
|
||||
const avgSpeed = downloadedBytes / elapsedSec;
|
||||
if (expectedDurationSeconds && expectedDurationSeconds > 0) {
|
||||
const estimatedTotalBytes = avgSpeed * expectedDurationSeconds;
|
||||
if (estimatedTotalBytes > downloadedBytes) {
|
||||
const remainingSec = (estimatedTotalBytes - downloadedBytes) / avgSpeed;
|
||||
etaStr = formatETA(remainingSec);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onProgress({
|
||||
id: itemId,
|
||||
progress: -1, // Unknown total
|
||||
speed: formatSpeed(speed),
|
||||
eta: '',
|
||||
eta: etaStr,
|
||||
status: `${formatBytes(downloadedBytes)} heruntergeladen`,
|
||||
currentPart: partNum,
|
||||
totalParts: totalParts,
|
||||
@ -2732,8 +2263,10 @@ function downloadVODPart(
|
||||
proc.on('close', async (code) => {
|
||||
clearInterval(progressInterval);
|
||||
currentProcess = null;
|
||||
activeDownloads.delete(itemId);
|
||||
|
||||
if (currentDownloadCancelled) {
|
||||
if (currentDownloadCancelled || cancelledItemIds.has(itemId)) {
|
||||
cancelledItemIds.delete(itemId);
|
||||
appendDebugLog('download-part-cancelled', { itemId, filename });
|
||||
resolve({ success: false, error: 'Download wurde abgebrochen.' });
|
||||
return;
|
||||
@ -2775,6 +2308,7 @@ function downloadVODPart(
|
||||
clearInterval(progressInterval);
|
||||
console.error('Process error:', err);
|
||||
currentProcess = null;
|
||||
activeDownloads.delete(itemId);
|
||||
const rawError = String(err);
|
||||
const errorMessage = rawError.includes('ENOENT')
|
||||
? 'Streamlink nicht gefunden. Installiere Streamlink oder Python+streamlink (py -3 -m pip install streamlink).'
|
||||
@ -2904,7 +2438,7 @@ async function downloadVOD(
|
||||
const downloadedFiles: string[] = [];
|
||||
|
||||
for (let i = 0; i < numParts; i++) {
|
||||
if (currentDownloadCancelled) break;
|
||||
if (currentDownloadCancelled || cancelledItemIds.has(item.id)) break;
|
||||
|
||||
const partNum = clip.startPart + i;
|
||||
const startOffset = clip.startSec + (i * partDuration);
|
||||
@ -2966,7 +2500,7 @@ async function downloadVOD(
|
||||
const downloadedFiles: string[] = [];
|
||||
|
||||
for (let i = 0; i < numParts; i++) {
|
||||
if (currentDownloadCancelled) break;
|
||||
if (currentDownloadCancelled || cancelledItemIds.has(item.id)) break;
|
||||
|
||||
const startSec = i * partDuration;
|
||||
const endSec = Math.min((i + 1) * partDuration, totalDuration);
|
||||
@ -3046,7 +2580,7 @@ async function processDownloadMergeGroup(
|
||||
}
|
||||
|
||||
for (let i = 0; i < mg.items.length; i++) {
|
||||
if (currentDownloadCancelled) {
|
||||
if (currentDownloadCancelled || cancelledItemIds.has(item.id)) {
|
||||
return { success: false, error: 'Download wurde abgebrochen.' };
|
||||
}
|
||||
|
||||
@ -3056,7 +2590,8 @@ async function processDownloadMergeGroup(
|
||||
continue;
|
||||
}
|
||||
|
||||
currentDownloadCancelled = false; // Reset stale cancel state
|
||||
// Reset stale per-item cancel state (global cancel already checked above)
|
||||
cancelledItemIds.delete(item.id);
|
||||
mg.currentItemIndex = i;
|
||||
mg.mergePhase = 'downloading';
|
||||
saveQueue(downloadQueue);
|
||||
@ -3157,7 +2692,7 @@ async function processDownloadMergeGroup(
|
||||
saveQueue(downloadQueue);
|
||||
emitQueueUpdated();
|
||||
|
||||
if (currentDownloadCancelled) {
|
||||
if (currentDownloadCancelled || cancelledItemIds.has(item.id)) {
|
||||
return { success: false, error: 'Download wurde abgebrochen.' };
|
||||
}
|
||||
|
||||
@ -3243,26 +2778,7 @@ async function processDownloadMergeGroup(
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async function processQueue(): Promise<void> {
|
||||
if (isDownloading || !downloadQueue.some((item) => item.status === 'pending')) return;
|
||||
|
||||
appendDebugLog('queue-start', {
|
||||
items: downloadQueue.length,
|
||||
smartScheduler: config.smart_queue_scheduler,
|
||||
performanceMode: config.performance_mode
|
||||
});
|
||||
|
||||
isDownloading = true;
|
||||
pauseRequested = false;
|
||||
mainWindow?.webContents.send('download-started');
|
||||
emitQueueUpdated();
|
||||
|
||||
while (isDownloading && !pauseRequested) {
|
||||
const item = pickNextPendingQueueItem();
|
||||
if (!item) {
|
||||
break;
|
||||
}
|
||||
|
||||
async function processOneQueueItem(item: QueueItem): Promise<void> {
|
||||
appendDebugLog('queue-item-start', {
|
||||
itemId: item.id,
|
||||
title: item.title,
|
||||
@ -3275,7 +2791,7 @@ async function processQueue(): Promise<void> {
|
||||
runtimeMetrics.activeItemTitle = item.title;
|
||||
activeQueueItemId = item.id;
|
||||
|
||||
currentDownloadCancelled = false;
|
||||
cancelledItemIds.delete(item.id);
|
||||
item.status = 'downloading';
|
||||
saveQueue(downloadQueue);
|
||||
emitQueueUpdated();
|
||||
@ -3303,7 +2819,7 @@ async function processQueue(): Promise<void> {
|
||||
|
||||
finalResult = result;
|
||||
|
||||
if (!isDownloading || currentDownloadCancelled || pauseRequested) {
|
||||
if (!isDownloading || currentDownloadCancelled || cancelledItemIds.has(item.id) || pauseRequested) {
|
||||
finalResult = { success: false, error: pauseRequested ? 'Download wurde pausiert.' : 'Download wurde abgebrochen.' };
|
||||
break;
|
||||
}
|
||||
@ -3345,10 +2861,9 @@ async function processQueue(): Promise<void> {
|
||||
|
||||
if (!hasQueueItemId(item.id)) {
|
||||
appendDebugLog('queue-item-finished-removed', { itemId: item.id });
|
||||
runtimeMetrics.activeItemId = null;
|
||||
runtimeMetrics.activeItemTitle = null;
|
||||
activeQueueItemId = null;
|
||||
continue;
|
||||
activeDownloads.delete(item.id);
|
||||
cancelledItemIds.delete(item.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const wasPaused = pauseRequested || (finalResult.error || '').includes('pausiert');
|
||||
@ -3362,9 +2877,8 @@ async function processQueue(): Promise<void> {
|
||||
runtimeMetrics.downloadsFailed += 1;
|
||||
}
|
||||
|
||||
runtimeMetrics.activeItemId = null;
|
||||
runtimeMetrics.activeItemTitle = null;
|
||||
activeQueueItemId = null;
|
||||
activeDownloads.delete(item.id);
|
||||
cancelledItemIds.delete(item.id);
|
||||
|
||||
appendDebugLog('queue-item-finished', {
|
||||
itemId: item.id,
|
||||
@ -3376,11 +2890,62 @@ async function processQueue(): Promise<void> {
|
||||
emitQueueUpdated();
|
||||
}
|
||||
|
||||
async function processQueue(): Promise<void> {
|
||||
if (isDownloading || !downloadQueue.some((item) => item.status === 'pending')) return;
|
||||
|
||||
appendDebugLog('queue-start', {
|
||||
items: downloadQueue.length,
|
||||
smartScheduler: config.smart_queue_scheduler,
|
||||
performanceMode: config.performance_mode,
|
||||
parallelDownloads: config.parallel_downloads || 1
|
||||
});
|
||||
|
||||
isDownloading = true;
|
||||
pauseRequested = false;
|
||||
currentDownloadCancelled = false;
|
||||
cancelledItemIds.clear();
|
||||
mainWindow?.webContents.send('download-started');
|
||||
emitQueueUpdated();
|
||||
|
||||
const maxSlots = Math.min(Math.max(1, config.parallel_downloads || 1), 2);
|
||||
const activePromises = new Map<string, Promise<void>>();
|
||||
|
||||
while (isDownloading && !pauseRequested) {
|
||||
// Clean up finished promises
|
||||
for (const [id] of activePromises) {
|
||||
const queueItem = downloadQueue.find(i => i.id === id);
|
||||
if (!queueItem || queueItem.status !== 'downloading') {
|
||||
activePromises.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Fill available slots
|
||||
while (activePromises.size < maxSlots && !pauseRequested) {
|
||||
const item = pickNextPendingQueueItem();
|
||||
if (!item) break;
|
||||
|
||||
const itemPromise = processOneQueueItem(item);
|
||||
activePromises.set(item.id, itemPromise);
|
||||
}
|
||||
|
||||
if (activePromises.size === 0) break;
|
||||
|
||||
// Wait for any one download to finish before re-checking
|
||||
await Promise.race([...activePromises.values()]);
|
||||
}
|
||||
|
||||
// Wait for all remaining active downloads to complete
|
||||
if (activePromises.size > 0) {
|
||||
await Promise.allSettled([...activePromises.values()]);
|
||||
}
|
||||
|
||||
isDownloading = false;
|
||||
pauseRequested = false;
|
||||
runtimeMetrics.activeItemId = null;
|
||||
runtimeMetrics.activeItemTitle = null;
|
||||
activeQueueItemId = null;
|
||||
activeDownloads.clear();
|
||||
cancelledItemIds.clear();
|
||||
|
||||
saveQueue(downloadQueue);
|
||||
emitQueueUpdated();
|
||||
@ -3404,7 +2969,7 @@ async function processQueue(): Promise<void> {
|
||||
// WINDOW CREATION
|
||||
// ==========================================
|
||||
function createWindow(): void {
|
||||
nativeTheme.themeSource = 'dark';
|
||||
nativeTheme.themeSource = config.theme === 'light' ? 'light' : 'dark';
|
||||
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1400,
|
||||
@ -3746,6 +3311,7 @@ ipcMain.handle('save-config', (_, newConfig: Partial<Config>) => {
|
||||
const previousClientSecret = config.client_secret;
|
||||
const previousCacheMinutes = config.metadata_cache_minutes;
|
||||
const previousPersistQueueOnRestart = config.persist_queue_on_restart;
|
||||
const previousTheme = config.theme;
|
||||
|
||||
config = normalizeConfigTemplates({ ...config, ...newConfig });
|
||||
|
||||
@ -3758,6 +3324,10 @@ ipcMain.handle('save-config', (_, newConfig: Partial<Config>) => {
|
||||
clearMetadataCaches();
|
||||
}
|
||||
|
||||
if (config.theme !== previousTheme) {
|
||||
nativeTheme.themeSource = config.theme === 'light' ? 'light' : 'dark';
|
||||
}
|
||||
|
||||
saveConfig(config);
|
||||
|
||||
if (config.persist_queue_on_restart === false) {
|
||||
@ -3817,13 +3387,21 @@ ipcMain.handle('add-to-queue', (_, item: Omit<QueueItem, 'id' | 'status' | 'prog
|
||||
});
|
||||
|
||||
ipcMain.handle('remove-from-queue', (_, id: string) => {
|
||||
const wasActiveItem = activeQueueItemId === id;
|
||||
const wasActiveItem = activeQueueItemId === id || activeDownloads.has(id);
|
||||
|
||||
if (wasActiveItem) {
|
||||
// Cancel via per-item tracking
|
||||
cancelledItemIds.add(id);
|
||||
const tracking = activeDownloads.get(id);
|
||||
if (tracking?.process) {
|
||||
tracking.process.kill();
|
||||
}
|
||||
// Also set global for backwards compat
|
||||
currentDownloadCancelled = true;
|
||||
if (currentProcess) {
|
||||
currentProcess.kill();
|
||||
}
|
||||
activeDownloads.delete(id);
|
||||
activeQueueItemId = null;
|
||||
runtimeMetrics.activeItemId = null;
|
||||
runtimeMetrics.activeItemTitle = null;
|
||||
@ -3991,6 +3569,13 @@ ipcMain.handle('pause-download', () => {
|
||||
|
||||
pauseRequested = true;
|
||||
currentDownloadCancelled = true;
|
||||
// Kill all active download processes
|
||||
for (const [id, tracking] of activeDownloads) {
|
||||
cancelledItemIds.add(id);
|
||||
if (tracking.process) {
|
||||
tracking.process.kill();
|
||||
}
|
||||
}
|
||||
if (currentProcess) {
|
||||
currentProcess.kill();
|
||||
}
|
||||
@ -4001,6 +3586,13 @@ ipcMain.handle('cancel-download', () => {
|
||||
isDownloading = false;
|
||||
pauseRequested = false;
|
||||
currentDownloadCancelled = true;
|
||||
// Kill all active download processes
|
||||
for (const [id, tracking] of activeDownloads) {
|
||||
cancelledItemIds.add(id);
|
||||
if (tracking.process) {
|
||||
tracking.process.kill();
|
||||
}
|
||||
}
|
||||
if (currentProcess) {
|
||||
currentProcess.kill();
|
||||
}
|
||||
@ -4233,6 +3825,12 @@ app.on('window-all-closed', () => {
|
||||
stopDebugLogFlushTimer(true);
|
||||
stopAutoUpdatePolling();
|
||||
|
||||
// Kill all active download processes
|
||||
for (const [, tracking] of activeDownloads) {
|
||||
if (tracking.process) {
|
||||
tracking.process.kill();
|
||||
}
|
||||
}
|
||||
if (currentProcess) {
|
||||
currentProcess.kill();
|
||||
}
|
||||
|
||||
@ -1,62 +1,7 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
import { CustomClip, MergeGroupItem, MergeGroup, QueueItem, DownloadProgress } from './types';
|
||||
|
||||
// Types
|
||||
interface CustomClip {
|
||||
startSec: number;
|
||||
durationSec: number;
|
||||
startPart: number;
|
||||
filenameFormat: 'simple' | 'timestamp' | 'template';
|
||||
filenameTemplate?: string;
|
||||
}
|
||||
|
||||
interface MergeGroupItem {
|
||||
url: string;
|
||||
title: string;
|
||||
date: string;
|
||||
streamer: string;
|
||||
duration_str: string;
|
||||
}
|
||||
|
||||
interface MergeGroup {
|
||||
items: MergeGroupItem[];
|
||||
mergePhase: 'downloading' | 'merging' | 'splitting' | 'cleanup' | 'done';
|
||||
currentItemIndex: number;
|
||||
downloadedFiles: Record<number, string>;
|
||||
mergedFile?: string;
|
||||
splitFiles?: string[];
|
||||
totalDurationSec?: number;
|
||||
}
|
||||
|
||||
interface QueueItem {
|
||||
id: string;
|
||||
title: string;
|
||||
url: string;
|
||||
date: string;
|
||||
streamer: string;
|
||||
duration_str: string;
|
||||
status: 'pending' | 'downloading' | 'paused' | 'completed' | 'error';
|
||||
progress: number;
|
||||
currentPart?: number;
|
||||
totalParts?: number;
|
||||
speed?: string;
|
||||
eta?: string;
|
||||
customClip?: CustomClip;
|
||||
mergeGroup?: MergeGroup;
|
||||
}
|
||||
|
||||
interface DownloadProgress {
|
||||
id: string;
|
||||
progress: number;
|
||||
speed: string;
|
||||
speedBytesPerSec?: number;
|
||||
eta: string;
|
||||
status: string;
|
||||
currentPart?: number;
|
||||
totalParts?: number;
|
||||
downloadedBytes?: number;
|
||||
totalBytes?: number;
|
||||
}
|
||||
|
||||
interface RuntimeMetricsSnapshot {
|
||||
cacheHits: number;
|
||||
cacheMisses: number;
|
||||
|
||||
1
src/renderer-globals.d.ts
vendored
1
src/renderer-globals.d.ts
vendored
@ -15,6 +15,7 @@ interface AppConfig {
|
||||
prevent_duplicate_downloads?: boolean;
|
||||
persist_queue_on_restart?: boolean;
|
||||
metadata_cache_minutes?: number;
|
||||
parallel_downloads?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
|
||||
@ -26,6 +26,7 @@ const UI_TEXT_DE = {
|
||||
mergeAdd: '+ Videos hinzufugen',
|
||||
designTitle: 'Design',
|
||||
themeLabel: 'Theme',
|
||||
themeLight: 'Hell',
|
||||
languageLabel: 'Sprache',
|
||||
languageDe: 'Deutsch',
|
||||
languageEn: 'Englisch',
|
||||
@ -40,6 +41,9 @@ const UI_TEXT_DE = {
|
||||
modeFull: 'Ganzes VOD',
|
||||
modeParts: 'In Teile splitten',
|
||||
partMinutesLabel: 'Teil-Lange (Minuten)',
|
||||
parallelDownloadsLabel: 'Parallele Downloads',
|
||||
parallelDownloads1: '1 (Standard)',
|
||||
parallelDownloads2: '2 (Parallel)',
|
||||
performanceModeLabel: 'Performance-Profil',
|
||||
performanceModeStability: 'Max Stabilitat',
|
||||
performanceModeBalanced: 'Ausgewogen',
|
||||
|
||||
@ -26,6 +26,7 @@ const UI_TEXT_EN = {
|
||||
mergeAdd: '+ Add videos',
|
||||
designTitle: 'Design',
|
||||
themeLabel: 'Theme',
|
||||
themeLight: 'Light',
|
||||
languageLabel: 'Language',
|
||||
languageDe: 'German',
|
||||
languageEn: 'English',
|
||||
@ -40,6 +41,9 @@ const UI_TEXT_EN = {
|
||||
modeFull: 'Full VOD',
|
||||
modeParts: 'Split into parts',
|
||||
partMinutesLabel: 'Part Length (Minutes)',
|
||||
parallelDownloadsLabel: 'Parallel Downloads',
|
||||
parallelDownloads1: '1 (Default)',
|
||||
parallelDownloads2: '2 (Parallel)',
|
||||
performanceModeLabel: 'Performance Profile',
|
||||
performanceModeStability: 'Max Stability',
|
||||
performanceModeBalanced: 'Balanced',
|
||||
|
||||
@ -198,6 +198,49 @@ function updateQueueItemProgress(progress: DownloadProgress): void {
|
||||
if (meta) meta.textContent = getQueueMetaText(queue[idx]);
|
||||
}
|
||||
|
||||
function toggleQueueDetails(id: string): void {
|
||||
const el = document.getElementById(`details-${id}`);
|
||||
if (el) el.style.display = el.style.display === 'none' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
function initQueueDragDrop(): void {
|
||||
const list = byId('queueList');
|
||||
|
||||
list.addEventListener('dragstart', (e: DragEvent) => {
|
||||
const el = (e.target as HTMLElement).closest('.queue-item') as HTMLElement;
|
||||
if (!el) return;
|
||||
draggedQueueItemId = el.dataset.id || null;
|
||||
el.classList.add('dragging');
|
||||
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move';
|
||||
});
|
||||
|
||||
list.addEventListener('dragover', (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
|
||||
});
|
||||
|
||||
list.addEventListener('drop', (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
const target = (e.target as HTMLElement).closest('.queue-item') as HTMLElement;
|
||||
if (!target || !draggedQueueItemId) return;
|
||||
const targetId = target.dataset.id;
|
||||
if (!targetId || targetId === draggedQueueItemId) return;
|
||||
|
||||
const fromIdx = queue.findIndex(i => i.id === draggedQueueItemId);
|
||||
const toIdx = queue.findIndex(i => i.id === targetId);
|
||||
if (fromIdx < 0 || toIdx < 0) return;
|
||||
const [moved] = queue.splice(fromIdx, 1);
|
||||
queue.splice(toIdx, 0, moved);
|
||||
window.api.reorderQueue(queue.map(i => i.id));
|
||||
renderQueue();
|
||||
});
|
||||
|
||||
list.addEventListener('dragend', () => {
|
||||
draggedQueueItemId = null;
|
||||
document.querySelectorAll('.queue-item.dragging').forEach(el => el.classList.remove('dragging'));
|
||||
});
|
||||
}
|
||||
|
||||
function renderQueue(): void {
|
||||
if (!Array.isArray(queue)) {
|
||||
queue = [];
|
||||
@ -244,7 +287,7 @@ function renderQueue(): void {
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="queue-item${isMergeGroup ? ' merge-group' : ''}">
|
||||
<div class="queue-item${isMergeGroup ? ' merge-group' : ''}" draggable="${item.status === 'pending' ? 'true' : 'false'}" data-id="${item.id}">
|
||||
${showSelector
|
||||
? `<div class="queue-selector${isSelected ? ' selected' : ''}" onclick="toggleQueueSelection('${item.id}')">${isSelected ? selectionIndex + 1 : ''}</div>`
|
||||
: ''
|
||||
@ -252,7 +295,7 @@ function renderQueue(): void {
|
||||
<div class="status ${item.status}"></div>
|
||||
<div class="queue-main">
|
||||
<div class="queue-title-row">
|
||||
<div class="title" title="${safeTitle}">${mergeIcon}${isClip}${safeTitle}</div>
|
||||
<div class="title" title="${safeTitle}" onclick="toggleQueueDetails('${item.id}')" style="cursor:pointer">${mergeIcon}${isClip}${safeTitle}</div>
|
||||
<div class="queue-status-label">${safeStatusLabel}</div>
|
||||
</div>
|
||||
<div class="queue-meta">${safeMeta}${mergeMetaExtra}</div>
|
||||
@ -260,6 +303,12 @@ function renderQueue(): void {
|
||||
<div class="queue-progress-bar${progressClass}" style="width: ${progressValue}%;"></div>
|
||||
</div>
|
||||
<div class="queue-progress-text">${safeProgressText}</div>
|
||||
<div class="queue-details" id="details-${item.id}" style="display:none">
|
||||
<div>URL: ${escapeHtml(item.url)}</div>
|
||||
<div>Streamer: ${escapeHtml(item.streamer)}</div>
|
||||
<div>Dauer: ${escapeHtml(item.duration_str)}</div>
|
||||
<div>Datum: ${escapeHtml(new Date(item.date).toLocaleString())}</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="remove" onclick="removeFromQueue('${item.id}')">x</span>
|
||||
</div>
|
||||
|
||||
@ -315,6 +315,7 @@ function collectDownloadSettingsPayload(): Partial<AppConfig> {
|
||||
return {
|
||||
download_mode: byId<HTMLSelectElement>('downloadMode').value as 'parts' | 'full',
|
||||
part_minutes: parseInt(byId<HTMLInputElement>('partMinutes').value, 10) || 120,
|
||||
parallel_downloads: parseInt(byId<HTMLSelectElement>('parallelDownloads').value, 10) || 1,
|
||||
performance_mode: byId<HTMLSelectElement>('performanceMode').value as 'stability' | 'balanced' | 'speed',
|
||||
smart_queue_scheduler: byId<HTMLInputElement>('smartSchedulerToggle').checked,
|
||||
prevent_duplicate_downloads: byId<HTMLInputElement>('duplicatePreventionToggle').checked,
|
||||
@ -356,6 +357,7 @@ function getSettingsFingerprint(payload: Partial<AppConfig>): string {
|
||||
effective.client_secret ?? '',
|
||||
effective.download_mode ?? 'full',
|
||||
effective.part_minutes ?? 120,
|
||||
effective.parallel_downloads ?? 1,
|
||||
effective.performance_mode ?? 'balanced',
|
||||
effective.smart_queue_scheduler !== false,
|
||||
effective.prevent_duplicate_downloads !== false,
|
||||
@ -372,6 +374,7 @@ function syncSettingsFormFromConfig(): void {
|
||||
byId<HTMLInputElement>('clientSecret').value = config.client_secret ?? '';
|
||||
byId<HTMLSelectElement>('downloadMode').value = (config.download_mode as 'parts' | 'full') ?? 'full';
|
||||
byId<HTMLInputElement>('partMinutes').value = String((config.part_minutes as number) || 120);
|
||||
byId<HTMLSelectElement>('parallelDownloads').value = String((config.parallel_downloads as number) || 1);
|
||||
byId<HTMLSelectElement>('performanceMode').value = (config.performance_mode as string) || 'balanced';
|
||||
byId<HTMLInputElement>('smartSchedulerToggle').checked = (config.smart_queue_scheduler as boolean) !== false;
|
||||
byId<HTMLInputElement>('duplicatePreventionToggle').checked = (config.prevent_duplicate_downloads as boolean) !== false;
|
||||
@ -482,6 +485,7 @@ function initSettingsAutoSave(): void {
|
||||
|
||||
const immediateSaveIds = [
|
||||
'downloadMode',
|
||||
'parallelDownloads',
|
||||
'performanceMode',
|
||||
'smartSchedulerToggle',
|
||||
'duplicatePreventionToggle',
|
||||
@ -582,5 +586,6 @@ function openFolder(): void {
|
||||
|
||||
function changeTheme(theme: string): void {
|
||||
document.body.className = `theme-${theme}`;
|
||||
config.theme = theme;
|
||||
void window.api.saveConfig({ theme });
|
||||
}
|
||||
|
||||
@ -68,6 +68,7 @@ function applyLanguageToStaticUI(): void {
|
||||
setText('mergeAddBtn', UI_TEXT.static.mergeAdd);
|
||||
setText('designTitle', UI_TEXT.static.designTitle);
|
||||
setText('themeLabel', UI_TEXT.static.themeLabel);
|
||||
setText('themeLightOption', UI_TEXT.static.themeLight);
|
||||
setText('languageLabel', UI_TEXT.static.languageLabel);
|
||||
setText('languageDeText', UI_TEXT.static.languageDe);
|
||||
setText('languageEnText', UI_TEXT.static.languageEn);
|
||||
@ -82,6 +83,9 @@ function applyLanguageToStaticUI(): void {
|
||||
setText('modeFullText', UI_TEXT.static.modeFull);
|
||||
setText('modePartsText', UI_TEXT.static.modeParts);
|
||||
setText('partMinutesLabel', UI_TEXT.static.partMinutesLabel);
|
||||
setText('parallelDownloadsLabel', UI_TEXT.static.parallelDownloadsLabel);
|
||||
setText('parallelDownloads1', UI_TEXT.static.parallelDownloads1);
|
||||
setText('parallelDownloads2', UI_TEXT.static.parallelDownloads2);
|
||||
setText('performanceModeLabel', UI_TEXT.static.performanceModeLabel);
|
||||
setText('performanceModeStability', UI_TEXT.static.performanceModeStability);
|
||||
setText('performanceModeBalanced', UI_TEXT.static.performanceModeBalanced);
|
||||
|
||||
@ -42,6 +42,7 @@ async function init(): Promise<void> {
|
||||
changeTheme(config.theme ?? 'twitch');
|
||||
renderStreamers();
|
||||
renderQueue();
|
||||
initQueueDragDrop();
|
||||
updateDownloadButtonState();
|
||||
|
||||
window.api.onQueueUpdated((q: QueueItem[]) => {
|
||||
@ -96,6 +97,10 @@ async function init(): Promise<void> {
|
||||
byId('mergeProgressText').textContent = Math.round(percent) + '%';
|
||||
});
|
||||
|
||||
// Update stats bar
|
||||
updateStatsBar();
|
||||
setInterval(updateStatsBar, 5000);
|
||||
|
||||
if (config.client_id && config.client_secret) {
|
||||
await connect();
|
||||
} else {
|
||||
@ -119,9 +124,56 @@ async function init(): Promise<void> {
|
||||
scheduleQueueSync(document.hidden ? 600 : 150);
|
||||
});
|
||||
|
||||
// Keyboard shortcuts
|
||||
document.addEventListener('keydown', (e) => {
|
||||
// Skip if user is typing in an input field
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement) return;
|
||||
|
||||
if (e.key === 'Delete' && selectedQueueIds.length > 0) {
|
||||
// Delete selected queue items
|
||||
const idsToRemove = [...selectedQueueIds];
|
||||
selectedQueueIds = [];
|
||||
(async () => {
|
||||
for (const id of idsToRemove) {
|
||||
queue = await window.api.removeFromQueue(id);
|
||||
}
|
||||
renderQueue();
|
||||
})();
|
||||
}
|
||||
|
||||
if ((e.key === 's' || e.key === 'S') && !e.ctrlKey && !e.altKey && !e.metaKey) {
|
||||
e.preventDefault();
|
||||
toggleDownload();
|
||||
}
|
||||
});
|
||||
|
||||
scheduleQueueSync(QUEUE_SYNC_DEFAULT_MS);
|
||||
}
|
||||
|
||||
function formatBytesRenderer(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
function formatSpeedRenderer(bytesPerSec: number): string {
|
||||
if (bytesPerSec < 1024) return `${bytesPerSec.toFixed(0)} B/s`;
|
||||
if (bytesPerSec < 1024 * 1024) return `${(bytesPerSec / 1024).toFixed(1)} KB/s`;
|
||||
return `${(bytesPerSec / (1024 * 1024)).toFixed(1)} MB/s`;
|
||||
}
|
||||
|
||||
async function updateStatsBar(): Promise<void> {
|
||||
try {
|
||||
const metrics = await window.api.getRuntimeMetrics();
|
||||
const bar = byId('statsBar');
|
||||
if (!bar) return;
|
||||
const totalDL = formatBytesRenderer(metrics.downloadedBytesTotal);
|
||||
const avgSpeed = metrics.avgSpeedBytesPerSec > 0 ? formatSpeedRenderer(metrics.avgSpeedBytesPerSec) : '-';
|
||||
bar.textContent = `${totalDL} | ${avgSpeed} avg | ${metrics.downloadsCompleted} done | ${metrics.downloadsFailed} failed`;
|
||||
} catch { }
|
||||
}
|
||||
|
||||
let toastHideTimer: number | null = null;
|
||||
let queueSyncTimer: number | null = null;
|
||||
let queueSyncInFlight = false;
|
||||
|
||||
171
src/styles.css
171
src/styles.css
@ -334,6 +334,29 @@ body {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.queue-item[draggable="true"] {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.queue-item[draggable="true"]:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.queue-item.dragging {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.queue-details {
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
padding: 4px 0;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.queue-details div {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.queue-selector {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
@ -386,6 +409,14 @@ body {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stats-bar {
|
||||
padding: 6px 15px;
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
border-top: 1px solid rgba(255,255,255,0.1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
@ -1315,6 +1346,146 @@ body.theme-apple {
|
||||
--accent-hover: #0071e3;
|
||||
}
|
||||
|
||||
body.theme-light {
|
||||
--bg-main: #f0f2f5;
|
||||
--bg-sidebar: #ffffff;
|
||||
--bg-card: #e4e6ea;
|
||||
--text: #1a1a2e;
|
||||
--text-secondary: #65676b;
|
||||
--accent: #9146ff;
|
||||
--accent-hover: #772ce8;
|
||||
--success: #00c853;
|
||||
--error: #e41e3f;
|
||||
--warning: #e68a00;
|
||||
}
|
||||
|
||||
/* Light theme: swap white-alpha borders/backgrounds to black-alpha */
|
||||
body.theme-light .sidebar,
|
||||
body.theme-light .queue-section,
|
||||
body.theme-light .logo,
|
||||
body.theme-light .stats-bar,
|
||||
body.theme-light .header,
|
||||
body.theme-light .status-bar {
|
||||
border-color: rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
body.theme-light .add-streamer input,
|
||||
body.theme-light .form-group input:not([type="checkbox"]):not([type="radio"]),
|
||||
body.theme-light .form-group select,
|
||||
body.theme-light .clip-input input,
|
||||
body.theme-light .time-input-group input,
|
||||
body.theme-light .part-number-group input,
|
||||
body.theme-light .btn-secondary,
|
||||
body.theme-light .lang-option,
|
||||
body.theme-light .log-panel,
|
||||
body.theme-light .template-guide-table-wrap,
|
||||
body.theme-light .template-guide-preview-box {
|
||||
border-color: rgba(0,0,0,0.12);
|
||||
}
|
||||
|
||||
body.theme-light .lang-option:hover {
|
||||
border-color: rgba(0,0,0,0.26);
|
||||
}
|
||||
|
||||
body.theme-light .streamer-item:hover {
|
||||
background: rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
body.theme-light .vod-btn.secondary {
|
||||
background: rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
body.theme-light .vod-btn.secondary:hover {
|
||||
background: rgba(0,0,0,0.12);
|
||||
}
|
||||
|
||||
body.theme-light .nav-item:hover {
|
||||
background: rgba(145, 71, 255, 0.1);
|
||||
}
|
||||
|
||||
body.theme-light ::-webkit-scrollbar-thumb {
|
||||
background: rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
body.theme-light ::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0,0,0,0.25);
|
||||
}
|
||||
|
||||
body.theme-light .update-modal {
|
||||
border-color: rgba(0,0,0,0.1);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(145, 70, 255, 0.12) 0%, rgba(145, 70, 255, 0.03) 24%, rgba(240, 242, 245, 0.98) 100%),
|
||||
var(--bg-card);
|
||||
}
|
||||
|
||||
body.theme-light .update-modal-eyebrow {
|
||||
color: #4a2a8a;
|
||||
}
|
||||
|
||||
body.theme-light .update-changelog-card {
|
||||
border-color: rgba(0,0,0,0.08);
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
body.theme-light .update-changelog-header {
|
||||
border-bottom-color: rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
body.theme-light .update-changelog-toggle {
|
||||
color: #4a2a8a;
|
||||
}
|
||||
|
||||
body.theme-light .update-changelog-toggle:hover {
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
body.theme-light .update-changelog-heading,
|
||||
body.theme-light .update-changelog-content strong {
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
body.theme-light .template-guide-preview-box {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
body.theme-light .template-guide-output {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
body.theme-light .template-guide-table th,
|
||||
body.theme-light .template-guide-table td {
|
||||
border-bottom-color: rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
body.theme-light .log-panel {
|
||||
background: #f8f9fb;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
body.theme-light .app-toast {
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
color: #1a1a2e;
|
||||
border-color: rgba(0,0,0,0.12);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
body.theme-light .btn-retry {
|
||||
background: #dce4f0;
|
||||
color: #2a3344;
|
||||
}
|
||||
|
||||
body.theme-light .btn-retry:hover {
|
||||
background: #c8d4e8;
|
||||
}
|
||||
|
||||
body.theme-light .btn-clear:hover {
|
||||
background: #d0d2d6;
|
||||
}
|
||||
|
||||
body.theme-light .modal {
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* Modal Styles */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
63
src/types.ts
Normal file
63
src/types.ts
Normal file
@ -0,0 +1,63 @@
|
||||
export interface CustomClip {
|
||||
startSec: number;
|
||||
durationSec: number;
|
||||
startPart: number;
|
||||
filenameFormat: 'simple' | 'timestamp' | 'template';
|
||||
filenameTemplate?: string;
|
||||
}
|
||||
|
||||
export interface MergeGroupItem {
|
||||
url: string;
|
||||
title: string;
|
||||
date: string;
|
||||
streamer: string;
|
||||
duration_str: string;
|
||||
}
|
||||
|
||||
export interface MergeGroup {
|
||||
items: MergeGroupItem[];
|
||||
mergePhase: 'downloading' | 'merging' | 'splitting' | 'cleanup' | 'done';
|
||||
currentItemIndex: number;
|
||||
downloadedFiles: Record<number, string>;
|
||||
mergedFile?: string;
|
||||
splitFiles?: string[];
|
||||
totalDurationSec?: number;
|
||||
}
|
||||
|
||||
export interface QueueItem {
|
||||
id: string;
|
||||
title: string;
|
||||
url: string;
|
||||
date: string;
|
||||
streamer: string;
|
||||
duration_str: string;
|
||||
status: 'pending' | 'downloading' | 'paused' | 'completed' | 'error';
|
||||
progress: number;
|
||||
currentPart?: number;
|
||||
totalParts?: number;
|
||||
speed?: string;
|
||||
eta?: string;
|
||||
downloadedBytes?: number;
|
||||
totalBytes?: number;
|
||||
last_error?: string;
|
||||
customClip?: CustomClip;
|
||||
mergeGroup?: MergeGroup;
|
||||
}
|
||||
|
||||
export interface DownloadProgress {
|
||||
id: string;
|
||||
progress: number;
|
||||
speed: string;
|
||||
speedBytesPerSec?: number;
|
||||
eta: string;
|
||||
status: string;
|
||||
currentPart?: number;
|
||||
totalParts?: number;
|
||||
downloadedBytes?: number;
|
||||
totalBytes?: number;
|
||||
}
|
||||
|
||||
export interface DownloadResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user