3758 lines
122 KiB
TypeScript
3758 lines
122 KiB
TypeScript
import { app, BrowserWindow, ipcMain, dialog, shell, nativeTheme } from 'electron';
|
|
import * as path from 'path';
|
|
import * as fs from 'fs';
|
|
import { spawn, ChildProcess, execSync, exec, spawnSync } from 'child_process';
|
|
import axios from 'axios';
|
|
import { autoUpdater } from 'electron-updater';
|
|
import { compareUpdateVersions, isNewerUpdateVersion, normalizeUpdateVersion } from './update-version-utils';
|
|
|
|
// ==========================================
|
|
// CONFIG & CONSTANTS
|
|
// ==========================================
|
|
const APP_VERSION = app.getVersion();
|
|
const UPDATE_CHECK_URL = 'http://24-music.de/version.json';
|
|
const GITEA_BASE_URL = (process.env.GITEA_BASE_URL || 'https://git.24-music.de').replace(/\/+$/, '');
|
|
const GITEA_REPO_OWNER = process.env.GITEA_REPO_OWNER || 'Administrator';
|
|
const GITEA_REPO_NAME = process.env.GITEA_REPO_NAME || 'Twitch-VOD-Manager';
|
|
const GITEA_RELEASES_API_LATEST_URL = `${GITEA_BASE_URL}/api/v1/repos/${GITEA_REPO_OWNER}/${GITEA_REPO_NAME}/releases/latest`;
|
|
const GITEA_RELEASES_DOWNLOAD_BASE_URL = `${GITEA_BASE_URL}/${GITEA_REPO_OWNER}/${GITEA_REPO_NAME}/releases/download`;
|
|
|
|
// Paths
|
|
const APPDATA_DIR = path.join(process.env.PROGRAMDATA || 'C:\\ProgramData', 'Twitch_VOD_Manager');
|
|
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');
|
|
const DEFAULT_FILENAME_TEMPLATE_VOD = '{title}.mp4';
|
|
const DEFAULT_FILENAME_TEMPLATE_PARTS = '{date}_Part{part_padded}.mp4';
|
|
const DEFAULT_FILENAME_TEMPLATE_CLIP = '{date}_{part}.mp4';
|
|
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;
|
|
const DEBUG_LOG_MAX_BYTES = 8 * 1024 * 1024;
|
|
const DEBUG_LOG_TRIM_TO_BYTES = 4 * 1024 * 1024;
|
|
const AUTO_UPDATE_CHECK_INTERVAL_MS = 10 * 60 * 1000;
|
|
const AUTO_UPDATE_STARTUP_CHECK_DELAY_MS = 5000;
|
|
const AUTO_UPDATE_MIN_CHECK_GAP_MS = 45 * 1000;
|
|
const AUTO_UPDATE_AUTO_DOWNLOAD = false;
|
|
const AUTO_UPDATE_CHECK_TIMEOUT_MS = 30 * 1000;
|
|
const CACHE_CLEANUP_INTERVAL_MS = 60 * 1000;
|
|
const MAX_LOGIN_TO_USER_ID_CACHE_ENTRIES = 4096;
|
|
const MAX_VOD_LIST_CACHE_ENTRIES = 512;
|
|
const MAX_CLIP_INFO_CACHE_ENTRIES = 4096;
|
|
|
|
// Timeouts
|
|
const API_TIMEOUT = 10000;
|
|
const DEFAULT_RETRY_DELAY_SECONDS = 5;
|
|
const MIN_FILE_BYTES = 256 * 1024;
|
|
const TWITCH_WEB_CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko';
|
|
|
|
type PerformanceMode = 'stability' | 'balanced' | 'speed';
|
|
type RetryErrorClass = 'network' | 'rate_limit' | 'auth' | 'tooling' | 'integrity' | 'io' | 'validation' | 'unknown';
|
|
type UpdateCheckSource = 'startup' | 'interval' | 'manual';
|
|
type UpdateDownloadSource = 'auto' | 'manual';
|
|
|
|
// Ensure directories exist
|
|
if (!fs.existsSync(APPDATA_DIR)) {
|
|
fs.mkdirSync(APPDATA_DIR, { recursive: true });
|
|
}
|
|
|
|
// ==========================================
|
|
// INTERFACES
|
|
// ==========================================
|
|
interface Config {
|
|
client_id: string;
|
|
client_secret: string;
|
|
download_path: string;
|
|
streamers: string[];
|
|
theme: string;
|
|
download_mode: 'parts' | 'full';
|
|
part_minutes: number;
|
|
language: 'de' | 'en';
|
|
filename_template_vod: string;
|
|
filename_template_parts: string;
|
|
filename_template_clip: string;
|
|
smart_queue_scheduler: boolean;
|
|
performance_mode: PerformanceMode;
|
|
prevent_duplicate_downloads: boolean;
|
|
persist_queue_on_restart: boolean;
|
|
metadata_cache_minutes: number;
|
|
}
|
|
|
|
interface RuntimeMetrics {
|
|
cacheHits: number;
|
|
cacheMisses: number;
|
|
duplicateSkips: number;
|
|
retriesScheduled: number;
|
|
retriesExhausted: number;
|
|
integrityFailures: number;
|
|
downloadsStarted: number;
|
|
downloadsCompleted: number;
|
|
downloadsFailed: number;
|
|
downloadedBytesTotal: number;
|
|
lastSpeedBytesPerSec: number;
|
|
avgSpeedBytesPerSec: number;
|
|
activeItemId: string | null;
|
|
activeItemTitle: string | null;
|
|
lastErrorClass: RetryErrorClass | null;
|
|
lastRetryDelaySeconds: number;
|
|
}
|
|
|
|
interface RuntimeMetricsSnapshot extends RuntimeMetrics {
|
|
timestamp: string;
|
|
queue: {
|
|
pending: number;
|
|
downloading: number;
|
|
paused: number;
|
|
completed: number;
|
|
error: number;
|
|
total: number;
|
|
};
|
|
caches: {
|
|
loginToUserId: number;
|
|
vodList: number;
|
|
clipInfo: number;
|
|
};
|
|
config: {
|
|
performanceMode: PerformanceMode;
|
|
smartScheduler: boolean;
|
|
metadataCacheMinutes: number;
|
|
duplicatePrevention: boolean;
|
|
};
|
|
}
|
|
|
|
interface CacheEntry<T> {
|
|
value: T;
|
|
expiresAt: number;
|
|
}
|
|
|
|
interface VOD {
|
|
id: string;
|
|
title: string;
|
|
created_at: string;
|
|
duration: string;
|
|
thumbnail_url: string;
|
|
url: string;
|
|
view_count: number;
|
|
stream_id: string;
|
|
}
|
|
|
|
interface CustomClip {
|
|
startSec: number;
|
|
durationSec: number;
|
|
startPart: number;
|
|
filenameFormat: 'simple' | 'timestamp' | 'template';
|
|
filenameTemplate?: string;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
interface DownloadResult {
|
|
success: boolean;
|
|
error?: string;
|
|
}
|
|
|
|
interface PreflightChecks {
|
|
internet: boolean;
|
|
streamlink: boolean;
|
|
ffmpeg: boolean;
|
|
ffprobe: boolean;
|
|
downloadPathWritable: boolean;
|
|
}
|
|
|
|
interface PreflightResult {
|
|
ok: boolean;
|
|
autoFixApplied: boolean;
|
|
checks: PreflightChecks;
|
|
messages: string[];
|
|
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;
|
|
height: number;
|
|
fps: number;
|
|
}
|
|
|
|
interface ReleaseUpdateInfo {
|
|
tagName?: string;
|
|
version?: string;
|
|
releaseDate?: string;
|
|
releaseName?: string;
|
|
releaseNotes?: string;
|
|
}
|
|
|
|
// ==========================================
|
|
// CONFIG MANAGEMENT
|
|
// ==========================================
|
|
const defaultConfig: Config = {
|
|
client_id: '',
|
|
client_secret: '',
|
|
download_path: DEFAULT_DOWNLOAD_PATH,
|
|
streamers: [],
|
|
theme: 'twitch',
|
|
download_mode: 'full',
|
|
part_minutes: 120,
|
|
language: 'en',
|
|
filename_template_vod: DEFAULT_FILENAME_TEMPLATE_VOD,
|
|
filename_template_parts: DEFAULT_FILENAME_TEMPLATE_PARTS,
|
|
filename_template_clip: DEFAULT_FILENAME_TEMPLATE_CLIP,
|
|
smart_queue_scheduler: true,
|
|
performance_mode: DEFAULT_PERFORMANCE_MODE,
|
|
prevent_duplicate_downloads: true,
|
|
persist_queue_on_restart: true,
|
|
metadata_cache_minutes: DEFAULT_METADATA_CACHE_MINUTES
|
|
};
|
|
|
|
function normalizeFilenameTemplate(template: string | undefined, fallback: string): string {
|
|
const value = (template || '').trim();
|
|
return value || fallback;
|
|
}
|
|
|
|
function normalizeMetadataCacheMinutes(value: unknown): number {
|
|
const parsed = Number(value);
|
|
if (!Number.isFinite(parsed)) {
|
|
return DEFAULT_METADATA_CACHE_MINUTES;
|
|
}
|
|
|
|
return Math.max(1, Math.min(120, Math.floor(parsed)));
|
|
}
|
|
|
|
function normalizePerformanceMode(mode: unknown): PerformanceMode {
|
|
if (mode === 'stability' || mode === 'balanced' || mode === 'speed') {
|
|
return mode;
|
|
}
|
|
|
|
return DEFAULT_PERFORMANCE_MODE;
|
|
}
|
|
|
|
function normalizeConfigTemplates(input: Config): Config {
|
|
return {
|
|
...input,
|
|
filename_template_vod: normalizeFilenameTemplate(input.filename_template_vod, DEFAULT_FILENAME_TEMPLATE_VOD),
|
|
filename_template_parts: normalizeFilenameTemplate(input.filename_template_parts, DEFAULT_FILENAME_TEMPLATE_PARTS),
|
|
filename_template_clip: normalizeFilenameTemplate(input.filename_template_clip, DEFAULT_FILENAME_TEMPLATE_CLIP),
|
|
smart_queue_scheduler: input.smart_queue_scheduler !== false,
|
|
performance_mode: normalizePerformanceMode(input.performance_mode),
|
|
prevent_duplicate_downloads: input.prevent_duplicate_downloads !== false,
|
|
persist_queue_on_restart: input.persist_queue_on_restart !== false,
|
|
metadata_cache_minutes: normalizeMetadataCacheMinutes(input.metadata_cache_minutes)
|
|
};
|
|
}
|
|
|
|
function loadConfig(): Config {
|
|
try {
|
|
if (fs.existsSync(CONFIG_FILE)) {
|
|
const data = fs.readFileSync(CONFIG_FILE, 'utf-8');
|
|
return normalizeConfigTemplates({ ...defaultConfig, ...JSON.parse(data) });
|
|
}
|
|
} catch (e) {
|
|
console.error('Error loading config:', e);
|
|
}
|
|
return normalizeConfigTemplates(defaultConfig);
|
|
}
|
|
|
|
function saveConfig(config: Config): void {
|
|
try {
|
|
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
} catch (e) {
|
|
console.error('Error saving config:', e);
|
|
}
|
|
}
|
|
|
|
// ==========================================
|
|
// QUEUE MANAGEMENT
|
|
// ==========================================
|
|
function loadQueue(): QueueItem[] {
|
|
if (config.persist_queue_on_restart === false) {
|
|
return [];
|
|
}
|
|
|
|
try {
|
|
if (fs.existsSync(QUEUE_FILE)) {
|
|
const data = fs.readFileSync(QUEUE_FILE, 'utf-8');
|
|
return JSON.parse(data);
|
|
}
|
|
} catch (e) {
|
|
console.error('Error loading queue:', e);
|
|
}
|
|
return [];
|
|
}
|
|
|
|
let queueSaveTimer: NodeJS.Timeout | null = null;
|
|
let pendingQueueSnapshot: QueueItem[] | null = null;
|
|
|
|
function clearQueueFileFromDisk(): void {
|
|
try {
|
|
if (fs.existsSync(QUEUE_FILE)) {
|
|
fs.unlinkSync(QUEUE_FILE);
|
|
}
|
|
} catch (e) {
|
|
console.error('Error clearing queue file:', e);
|
|
}
|
|
}
|
|
|
|
function writeQueueToDisk(queue: QueueItem[]): void {
|
|
if (config.persist_queue_on_restart === false) {
|
|
clearQueueFileFromDisk();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
fs.writeFileSync(QUEUE_FILE, JSON.stringify(queue, null, 2));
|
|
} catch (e) {
|
|
console.error('Error saving queue:', e);
|
|
}
|
|
}
|
|
|
|
function saveQueue(queue: QueueItem[], force = false): void {
|
|
if (config.persist_queue_on_restart === false) {
|
|
pendingQueueSnapshot = null;
|
|
if (queueSaveTimer) {
|
|
clearTimeout(queueSaveTimer);
|
|
queueSaveTimer = null;
|
|
}
|
|
clearQueueFileFromDisk();
|
|
return;
|
|
}
|
|
|
|
pendingQueueSnapshot = queue;
|
|
|
|
if (force) {
|
|
if (queueSaveTimer) {
|
|
clearTimeout(queueSaveTimer);
|
|
queueSaveTimer = null;
|
|
}
|
|
|
|
writeQueueToDisk(pendingQueueSnapshot);
|
|
pendingQueueSnapshot = null;
|
|
return;
|
|
}
|
|
|
|
if (queueSaveTimer) {
|
|
return;
|
|
}
|
|
|
|
queueSaveTimer = setTimeout(() => {
|
|
queueSaveTimer = null;
|
|
if (pendingQueueSnapshot) {
|
|
writeQueueToDisk(pendingQueueSnapshot);
|
|
pendingQueueSnapshot = null;
|
|
}
|
|
}, QUEUE_SAVE_DEBOUNCE_MS);
|
|
}
|
|
|
|
function flushQueueSave(): void {
|
|
if (pendingQueueSnapshot) {
|
|
saveQueue(pendingQueueSnapshot, true);
|
|
} else {
|
|
saveQueue(downloadQueue, true);
|
|
}
|
|
}
|
|
|
|
// ==========================================
|
|
// GLOBAL STATE
|
|
// ==========================================
|
|
let mainWindow: BrowserWindow | null = null;
|
|
let config = loadConfig();
|
|
let accessToken: string | null = null;
|
|
let downloadQueue: QueueItem[] = loadQueue();
|
|
let queueIdCounter = 0;
|
|
let lastQueueBroadcastFingerprint = '';
|
|
let isDownloading = false;
|
|
let currentProcess: ChildProcess | null = null;
|
|
let currentDownloadCancelled = false;
|
|
let pauseRequested = false;
|
|
let activeQueueItemId: string | null = null;
|
|
let downloadStartTime = 0;
|
|
let downloadedBytes = 0;
|
|
const userIdLoginCache = new Map<string, string>();
|
|
const loginToUserIdCache = new Map<string, CacheEntry<string>>();
|
|
const vodListCache = new Map<string, CacheEntry<VOD[]>>();
|
|
const clipInfoCache = new Map<string, CacheEntry<any>>();
|
|
const inFlightUserIdRequests = new Map<string, Promise<string | null>>();
|
|
const inFlightVodRequests = new Map<string, Promise<VOD[]>>();
|
|
const inFlightClipRequests = new Map<string, Promise<any | null>>();
|
|
let cacheCleanupTimer: NodeJS.Timeout | null = null;
|
|
const runtimeMetrics: RuntimeMetrics = {
|
|
cacheHits: 0,
|
|
cacheMisses: 0,
|
|
duplicateSkips: 0,
|
|
retriesScheduled: 0,
|
|
retriesExhausted: 0,
|
|
integrityFailures: 0,
|
|
downloadsStarted: 0,
|
|
downloadsCompleted: 0,
|
|
downloadsFailed: 0,
|
|
downloadedBytesTotal: 0,
|
|
lastSpeedBytesPerSec: 0,
|
|
avgSpeedBytesPerSec: 0,
|
|
activeItemId: null,
|
|
activeItemTitle: null,
|
|
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;
|
|
let autoUpdateCheckTimer: NodeJS.Timeout | null = null;
|
|
let autoUpdateStartupTimer: NodeJS.Timeout | null = null;
|
|
let autoUpdateCheckInProgress = false;
|
|
let autoUpdateReadyToInstall = false;
|
|
let autoUpdateDownloadInProgress = false;
|
|
let lastAutoUpdateCheckAt = 0;
|
|
let latestKnownUpdateVersion: string | null = null;
|
|
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));
|
|
}
|
|
|
|
function isDownloadPathWritable(targetPath: string): boolean {
|
|
try {
|
|
fs.mkdirSync(targetPath, { recursive: true });
|
|
const probeFile = path.join(targetPath, `.write_test_${Date.now()}.tmp`);
|
|
fs.writeFileSync(probeFile, 'ok');
|
|
fs.unlinkSync(probeFile);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function hasInternetConnection(): Promise<boolean> {
|
|
try {
|
|
const res = await axios.get('https://id.twitch.tv/oauth2/validate', {
|
|
timeout: 5000,
|
|
validateStatus: () => true
|
|
});
|
|
return res.status > 0;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function runPreflight(autoFix = false): Promise<PreflightResult> {
|
|
appendDebugLog('preflight-start', { autoFix });
|
|
|
|
refreshBundledToolPaths();
|
|
|
|
const checks: PreflightChecks = {
|
|
internet: await hasInternetConnection(),
|
|
streamlink: false,
|
|
ffmpeg: false,
|
|
ffprobe: false,
|
|
downloadPathWritable: isDownloadPathWritable(config.download_path)
|
|
};
|
|
|
|
if (autoFix) {
|
|
await ensureStreamlinkInstalled();
|
|
await ensureFfmpegInstalled();
|
|
refreshBundledToolPaths(true);
|
|
}
|
|
|
|
const streamlinkCmd = getStreamlinkCommand();
|
|
checks.streamlink = canExecuteCommand(streamlinkCmd.command, [...streamlinkCmd.prefixArgs, '--version']);
|
|
if (checks.streamlink) {
|
|
cacheVerifiedStreamlinkCommand(streamlinkCmd.command, [...streamlinkCmd.prefixArgs, '--version']);
|
|
}
|
|
|
|
const ffmpegPath = getFFmpegPath();
|
|
const ffprobePath = getFFprobePath();
|
|
checks.ffmpeg = canExecuteCommand(ffmpegPath, ['-version']);
|
|
checks.ffprobe = canExecuteCommand(ffprobePath, ['-version']);
|
|
if (checks.ffmpeg && checks.ffprobe) {
|
|
cacheVerifiedFfmpegCommands(ffmpegPath, ffprobePath);
|
|
}
|
|
|
|
const messages: string[] = [];
|
|
if (!checks.internet) messages.push('Keine Internetverbindung erkannt.');
|
|
if (!checks.streamlink) messages.push('Streamlink fehlt oder ist nicht startbar.');
|
|
if (!checks.ffmpeg) messages.push('FFmpeg fehlt oder ist nicht startbar.');
|
|
if (!checks.ffprobe) messages.push('FFprobe fehlt oder ist nicht startbar.');
|
|
if (!checks.downloadPathWritable) messages.push('Download-Ordner ist nicht beschreibbar.');
|
|
|
|
const result: PreflightResult = {
|
|
ok: messages.length === 0,
|
|
autoFixApplied: autoFix,
|
|
checks,
|
|
messages,
|
|
timestamp: new Date().toISOString()
|
|
};
|
|
|
|
appendDebugLog('preflight-finished', result);
|
|
return result;
|
|
}
|
|
|
|
function flushPendingDebugLogLines(): void {
|
|
if (!pendingDebugLogLines.length) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const payload = pendingDebugLogLines.join('');
|
|
pendingDebugLogLines = [];
|
|
fs.appendFileSync(DEBUG_LOG_FILE, payload);
|
|
trimDebugLogFileIfNeeded();
|
|
} catch {
|
|
// ignore debug log errors
|
|
}
|
|
}
|
|
|
|
function trimDebugLogFileIfNeeded(): void {
|
|
try {
|
|
if (!fs.existsSync(DEBUG_LOG_FILE)) {
|
|
return;
|
|
}
|
|
|
|
const stats = fs.statSync(DEBUG_LOG_FILE);
|
|
if (stats.size <= DEBUG_LOG_MAX_BYTES) {
|
|
return;
|
|
}
|
|
|
|
const bytesToKeep = Math.min(DEBUG_LOG_TRIM_TO_BYTES, stats.size);
|
|
const startOffset = Math.max(0, stats.size - bytesToKeep);
|
|
const buffer = Buffer.allocUnsafe(bytesToKeep);
|
|
|
|
let fileHandle: number | null = null;
|
|
try {
|
|
fileHandle = fs.openSync(DEBUG_LOG_FILE, 'r');
|
|
fs.readSync(fileHandle, buffer, 0, bytesToKeep, startOffset);
|
|
} finally {
|
|
if (fileHandle !== null) {
|
|
fs.closeSync(fileHandle);
|
|
}
|
|
}
|
|
|
|
const firstLineBreak = buffer.indexOf(0x0a);
|
|
const trimmed = firstLineBreak >= 0 && firstLineBreak + 1 < buffer.length
|
|
? buffer.subarray(firstLineBreak + 1)
|
|
: buffer;
|
|
|
|
fs.writeFileSync(DEBUG_LOG_FILE, trimmed);
|
|
} catch {
|
|
// ignore debug log errors
|
|
}
|
|
}
|
|
|
|
function readDebugLogTailFromDisk(): string {
|
|
const stats = fs.statSync(DEBUG_LOG_FILE);
|
|
if (stats.size <= 0) {
|
|
return '';
|
|
}
|
|
|
|
const bytesToRead = Math.min(stats.size, DEBUG_LOG_READ_TAIL_BYTES);
|
|
if (bytesToRead === stats.size) {
|
|
return fs.readFileSync(DEBUG_LOG_FILE, 'utf-8');
|
|
}
|
|
|
|
const buffer = Buffer.allocUnsafe(bytesToRead);
|
|
let fileHandle: number | null = null;
|
|
try {
|
|
fileHandle = fs.openSync(DEBUG_LOG_FILE, 'r');
|
|
fs.readSync(fileHandle, buffer, 0, bytesToRead, stats.size - bytesToRead);
|
|
} finally {
|
|
if (fileHandle !== null) {
|
|
fs.closeSync(fileHandle);
|
|
}
|
|
}
|
|
|
|
const firstLineBreak = buffer.indexOf(0x0a);
|
|
const slice = firstLineBreak >= 0 && firstLineBreak + 1 < buffer.length
|
|
? buffer.subarray(firstLineBreak + 1)
|
|
: buffer;
|
|
|
|
return slice.toString('utf-8');
|
|
}
|
|
|
|
function startDebugLogFlushTimer(): void {
|
|
if (debugLogFlushTimer) {
|
|
return;
|
|
}
|
|
|
|
debugLogFlushTimer = setInterval(() => {
|
|
flushPendingDebugLogLines();
|
|
}, DEBUG_LOG_FLUSH_INTERVAL_MS);
|
|
|
|
debugLogFlushTimer.unref?.();
|
|
}
|
|
|
|
function stopDebugLogFlushTimer(flush = true): void {
|
|
if (debugLogFlushTimer) {
|
|
clearInterval(debugLogFlushTimer);
|
|
debugLogFlushTimer = null;
|
|
}
|
|
|
|
if (flush) {
|
|
flushPendingDebugLogLines();
|
|
}
|
|
}
|
|
|
|
function readDebugLog(lines = 200): string {
|
|
try {
|
|
flushPendingDebugLogLines();
|
|
|
|
if (!fs.existsSync(DEBUG_LOG_FILE)) {
|
|
return 'Debug-Log ist leer.';
|
|
}
|
|
|
|
const text = readDebugLogTailFromDisk();
|
|
const rows = text.split(/\r?\n/).filter(Boolean);
|
|
return rows.slice(-lines).join('\n') || 'Debug-Log ist leer.';
|
|
} catch (e) {
|
|
return `Debug-Log konnte nicht gelesen werden: ${String(e)}`;
|
|
}
|
|
}
|
|
|
|
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();
|
|
const payload = details === undefined
|
|
? ''
|
|
: ` | ${typeof details === 'string' ? details : JSON.stringify(details)}`;
|
|
|
|
pendingDebugLogLines.push(`[${ts}] ${message}${payload}\n`);
|
|
|
|
if (pendingDebugLogLines.length >= DEBUG_LOG_BUFFER_FLUSH_LINES) {
|
|
flushPendingDebugLogLines();
|
|
} else {
|
|
startDebugLogFlushTimer();
|
|
}
|
|
} catch {
|
|
// ignore debug log errors
|
|
}
|
|
}
|
|
|
|
// ==========================================
|
|
// DURATION HELPERS
|
|
// ==========================================
|
|
function parseDuration(duration: string): number {
|
|
let seconds = 0;
|
|
const hours = duration.match(/(\d+)h/);
|
|
const minutes = duration.match(/(\d+)m/);
|
|
const secs = duration.match(/(\d+)s/);
|
|
|
|
if (hours) seconds += parseInt(hours[1]) * 3600;
|
|
if (minutes) seconds += parseInt(minutes[1]) * 60;
|
|
if (secs) seconds += parseInt(secs[1]);
|
|
|
|
return seconds;
|
|
}
|
|
|
|
function formatDuration(seconds: number): string {
|
|
const h = Math.floor(seconds / 3600);
|
|
const m = Math.floor((seconds % 3600) / 60);
|
|
const s = Math.floor(seconds % 60);
|
|
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
|
}
|
|
|
|
function formatDurationDashed(seconds: number): string {
|
|
const h = Math.floor(seconds / 3600);
|
|
const m = Math.floor((seconds % 3600) / 60);
|
|
const s = Math.floor(seconds % 60);
|
|
return `${h.toString().padStart(2, '0')}-${m.toString().padStart(2, '0')}-${s.toString().padStart(2, '0')}`;
|
|
}
|
|
|
|
function sanitizeFilenamePart(input: string, fallback = 'unnamed'): string {
|
|
const cleaned = (input || '')
|
|
.replace(/[<>:"|?*\x00-\x1f]/g, '_')
|
|
.replace(/[\\/]/g, '_')
|
|
.trim();
|
|
return cleaned || fallback;
|
|
}
|
|
|
|
function formatDateWithPattern(date: Date, pattern: string): string {
|
|
const tokenMap: Record<string, string> = {
|
|
yyyy: date.getFullYear().toString(),
|
|
yy: date.getFullYear().toString().slice(-2),
|
|
MM: (date.getMonth() + 1).toString().padStart(2, '0'),
|
|
M: (date.getMonth() + 1).toString(),
|
|
dd: date.getDate().toString().padStart(2, '0'),
|
|
d: date.getDate().toString(),
|
|
HH: date.getHours().toString().padStart(2, '0'),
|
|
H: date.getHours().toString(),
|
|
hh: date.getHours().toString().padStart(2, '0'),
|
|
h: date.getHours().toString(),
|
|
mm: date.getMinutes().toString().padStart(2, '0'),
|
|
m: date.getMinutes().toString(),
|
|
ss: date.getSeconds().toString().padStart(2, '0'),
|
|
s: date.getSeconds().toString()
|
|
};
|
|
|
|
return pattern
|
|
.replace(/yyyy|yy|MM|M|dd|d|HH|H|hh|h|mm|m|ss|s/g, (token) => tokenMap[token] ?? token)
|
|
.replace(/\\(.)/g, '$1');
|
|
}
|
|
|
|
function formatSecondsWithPattern(totalSeconds: number, pattern: string): string {
|
|
const safe = Math.max(0, Math.floor(totalSeconds));
|
|
const hours = Math.floor(safe / 3600);
|
|
const minutes = Math.floor((safe % 3600) / 60);
|
|
const seconds = safe % 60;
|
|
|
|
const tokenMap: Record<string, string> = {
|
|
HH: hours.toString().padStart(2, '0'),
|
|
H: hours.toString(),
|
|
hh: hours.toString().padStart(2, '0'),
|
|
h: hours.toString(),
|
|
mm: minutes.toString().padStart(2, '0'),
|
|
m: minutes.toString(),
|
|
ss: seconds.toString().padStart(2, '0'),
|
|
s: seconds.toString()
|
|
};
|
|
|
|
return pattern
|
|
.replace(/HH|H|hh|h|mm|m|ss|s/g, (token) => tokenMap[token] ?? token)
|
|
.replace(/\\(.)/g, '$1');
|
|
}
|
|
|
|
function parseVodId(url: string): string {
|
|
const match = url.match(/videos\/(\d+)/i);
|
|
return match?.[1] || '';
|
|
}
|
|
|
|
function isLikelyVodUrl(url: string): boolean {
|
|
return /twitch\.tv\/videos\/\d+/i.test(url || '');
|
|
}
|
|
|
|
function parseFrameRate(rawFrameRate: string | undefined): number {
|
|
const fallback = 30;
|
|
const value = (rawFrameRate || '').trim();
|
|
if (!value) return fallback;
|
|
|
|
if (/^\d+(\.\d+)?$/.test(value)) {
|
|
const numeric = Number(value);
|
|
return Number.isFinite(numeric) && numeric > 0 ? numeric : fallback;
|
|
}
|
|
|
|
const ratio = value.match(/^(\d+(?:\.\d+)?)\/(\d+(?:\.\d+)?)$/);
|
|
if (!ratio) return fallback;
|
|
|
|
const numerator = Number(ratio[1]);
|
|
const denominator = Number(ratio[2]);
|
|
if (!Number.isFinite(numerator) || !Number.isFinite(denominator) || denominator <= 0) {
|
|
return fallback;
|
|
}
|
|
|
|
const fps = numerator / denominator;
|
|
return Number.isFinite(fps) && fps > 0 ? fps : fallback;
|
|
}
|
|
|
|
interface ClipTemplateContext {
|
|
template: string;
|
|
title: string;
|
|
vodId: string;
|
|
channel: string;
|
|
date: Date;
|
|
part: number;
|
|
partPadded: string;
|
|
trimStartSec: number;
|
|
trimEndSec: number;
|
|
trimLengthSec: number;
|
|
fullLengthSec: number;
|
|
}
|
|
|
|
function renderClipFilenameTemplate(context: ClipTemplateContext): string {
|
|
const baseDate = `${context.date.getDate().toString().padStart(2, '0')}.${(context.date.getMonth() + 1).toString().padStart(2, '0')}.${context.date.getFullYear()}`;
|
|
let rendered = context.template
|
|
.replace(/\{title\}/g, sanitizeFilenamePart(context.title, 'untitled'))
|
|
.replace(/\{id\}/g, sanitizeFilenamePart(context.vodId, 'unknown'))
|
|
.replace(/\{channel\}/g, sanitizeFilenamePart(context.channel, 'unknown'))
|
|
.replace(/\{channel_id\}/g, '')
|
|
.replace(/\{date\}/g, baseDate)
|
|
.replace(/\{part\}/g, String(context.part))
|
|
.replace(/\{part_padded\}/g, context.partPadded)
|
|
.replace(/\{trim_start\}/g, formatDurationDashed(context.trimStartSec))
|
|
.replace(/\{trim_end\}/g, formatDurationDashed(context.trimEndSec))
|
|
.replace(/\{trim_length\}/g, formatDurationDashed(context.trimLengthSec))
|
|
.replace(/\{length\}/g, formatDurationDashed(context.fullLengthSec))
|
|
.replace(/\{ext\}/g, 'mp4')
|
|
.replace(/\{random_string\}/g, Math.random().toString(36).slice(2, 10));
|
|
|
|
rendered = rendered.replace(/\{date_custom="(.*?)"\}/g, (_, pattern: string) => {
|
|
return sanitizeFilenamePart(formatDateWithPattern(context.date, pattern), 'date');
|
|
});
|
|
rendered = rendered.replace(/\{trim_start_custom="(.*?)"\}/g, (_, pattern: string) => {
|
|
return sanitizeFilenamePart(formatSecondsWithPattern(context.trimStartSec, pattern), '00-00-00');
|
|
});
|
|
rendered = rendered.replace(/\{trim_end_custom="(.*?)"\}/g, (_, pattern: string) => {
|
|
return sanitizeFilenamePart(formatSecondsWithPattern(context.trimEndSec, pattern), '00-00-00');
|
|
});
|
|
rendered = rendered.replace(/\{trim_length_custom="(.*?)"\}/g, (_, pattern: string) => {
|
|
return sanitizeFilenamePart(formatSecondsWithPattern(context.trimLengthSec, pattern), '00-00-00');
|
|
});
|
|
rendered = rendered.replace(/\{length_custom="(.*?)"\}/g, (_, pattern: string) => {
|
|
return sanitizeFilenamePart(formatSecondsWithPattern(context.fullLengthSec, pattern), '00-00-00');
|
|
});
|
|
|
|
const parts = rendered
|
|
.split(/[\\/]+/)
|
|
.map((segment) => sanitizeFilenamePart(segment, 'unnamed'))
|
|
.filter((segment) => segment !== '.' && segment !== '..');
|
|
|
|
if (parts.length === 0) {
|
|
return 'clip.mp4';
|
|
}
|
|
|
|
const lastIdx = parts.length - 1;
|
|
if (!/\.[A-Za-z0-9]{1,8}$/.test(parts[lastIdx])) {
|
|
parts[lastIdx] = `${parts[lastIdx]}.mp4`;
|
|
}
|
|
|
|
return path.join(...parts);
|
|
}
|
|
|
|
function formatBytes(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 formatSpeed(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';
|
|
}
|
|
|
|
function formatETA(seconds: number): string {
|
|
if (seconds < 60) return `${Math.floor(seconds)}s`;
|
|
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${Math.floor(seconds % 60)}s`;
|
|
const h = Math.floor(seconds / 3600);
|
|
const m = Math.floor((seconds % 3600) / 60);
|
|
return `${h}h ${m}m`;
|
|
}
|
|
|
|
function getFreeDiskBytes(targetPath: string): number | null {
|
|
try {
|
|
const statfsSync = (fs as unknown as { statfsSync?: (path: string) => { bsize?: number; frsize?: number; bavail?: number } }).statfsSync;
|
|
if (!statfsSync) {
|
|
return null;
|
|
}
|
|
|
|
const info = statfsSync(targetPath);
|
|
const blockSize = Number(info?.bsize || info?.frsize || 0);
|
|
const availableBlocks = Number(info?.bavail || 0);
|
|
if (!Number.isFinite(blockSize) || !Number.isFinite(availableBlocks) || blockSize <= 0 || availableBlocks < 0) {
|
|
return null;
|
|
}
|
|
|
|
return Math.floor(blockSize * availableBlocks);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function estimateRequiredDownloadBytes(item: QueueItem): number {
|
|
const durationSeconds = Math.max(1, item.customClip?.durationSec || parseDuration(item.duration_str || '0s'));
|
|
|
|
const bytesPerSecondByMode: Record<PerformanceMode, number> = {
|
|
stability: 900 * 1024,
|
|
balanced: 700 * 1024,
|
|
speed: 550 * 1024
|
|
};
|
|
|
|
const mode = normalizePerformanceMode(config.performance_mode);
|
|
const baseEstimate = durationSeconds * bytesPerSecondByMode[mode];
|
|
const withHeadroom = Math.ceil(baseEstimate * (item.customClip ? 1.2 : 1.35));
|
|
|
|
return Math.max(64 * 1024 * 1024, Math.min(withHeadroom, 40 * 1024 * 1024 * 1024));
|
|
}
|
|
|
|
function ensureDiskSpace(targetPath: string, requiredBytes: number, context: string): DownloadResult {
|
|
const freeBytes = getFreeDiskBytes(targetPath);
|
|
if (freeBytes === null) {
|
|
appendDebugLog('disk-space-check-skipped', { targetPath, requiredBytes, context });
|
|
return { success: true };
|
|
}
|
|
|
|
if (freeBytes < Math.max(requiredBytes, MIN_FREE_DISK_BYTES)) {
|
|
const message = `Zu wenig Speicherplatz fur ${context}: frei ${formatBytes(freeBytes)}, benoetigt ~${formatBytes(requiredBytes)}.`;
|
|
appendDebugLog('disk-space-check-failed', {
|
|
targetPath,
|
|
requiredBytes,
|
|
freeBytes,
|
|
context
|
|
});
|
|
return { success: false, error: message };
|
|
}
|
|
|
|
return { success: true };
|
|
}
|
|
|
|
function getMetadataCacheTtlMs(): number {
|
|
return normalizeMetadataCacheMinutes(config.metadata_cache_minutes) * 60 * 1000;
|
|
}
|
|
|
|
function getCachedValue<T>(cache: Map<string, CacheEntry<T>>, key: string): T | undefined {
|
|
const cached = cache.get(key);
|
|
if (!cached) {
|
|
return undefined;
|
|
}
|
|
|
|
if (cached.expiresAt <= Date.now()) {
|
|
cache.delete(key);
|
|
return undefined;
|
|
}
|
|
|
|
cache.delete(key);
|
|
cache.set(key, cached);
|
|
return cached.value;
|
|
}
|
|
|
|
function pruneExpiredCacheEntries<T>(cache: Map<string, CacheEntry<T>>): number {
|
|
const now = Date.now();
|
|
let removed = 0;
|
|
|
|
for (const [key, entry] of cache.entries()) {
|
|
if (entry.expiresAt <= now) {
|
|
cache.delete(key);
|
|
removed += 1;
|
|
}
|
|
}
|
|
|
|
return removed;
|
|
}
|
|
|
|
function enforceCacheEntryLimit<T>(cache: Map<string, CacheEntry<T>>, maxEntries: number): number {
|
|
if (maxEntries <= 0) {
|
|
const removed = cache.size;
|
|
cache.clear();
|
|
return removed;
|
|
}
|
|
|
|
let removed = 0;
|
|
while (cache.size > maxEntries) {
|
|
const oldest = cache.keys().next().value as string | undefined;
|
|
if (!oldest) {
|
|
break;
|
|
}
|
|
cache.delete(oldest);
|
|
removed += 1;
|
|
}
|
|
|
|
return removed;
|
|
}
|
|
|
|
function setCachedValue<T>(
|
|
cache: Map<string, CacheEntry<T>>,
|
|
key: string,
|
|
value: T,
|
|
maxEntries: number
|
|
): void {
|
|
cache.set(key, {
|
|
value,
|
|
expiresAt: Date.now() + getMetadataCacheTtlMs()
|
|
});
|
|
|
|
if (cache.size > maxEntries) {
|
|
pruneExpiredCacheEntries(cache);
|
|
enforceCacheEntryLimit(cache, maxEntries);
|
|
}
|
|
}
|
|
|
|
function cleanupMetadataCaches(reason: 'interval' | 'manual' | 'shutdown'): void {
|
|
const before = {
|
|
loginToUserId: loginToUserIdCache.size,
|
|
vodList: vodListCache.size,
|
|
clipInfo: clipInfoCache.size
|
|
};
|
|
|
|
const expired = {
|
|
loginToUserId: pruneExpiredCacheEntries(loginToUserIdCache),
|
|
vodList: pruneExpiredCacheEntries(vodListCache),
|
|
clipInfo: pruneExpiredCacheEntries(clipInfoCache)
|
|
};
|
|
|
|
const evicted = {
|
|
loginToUserId: enforceCacheEntryLimit(loginToUserIdCache, MAX_LOGIN_TO_USER_ID_CACHE_ENTRIES),
|
|
vodList: enforceCacheEntryLimit(vodListCache, MAX_VOD_LIST_CACHE_ENTRIES),
|
|
clipInfo: enforceCacheEntryLimit(clipInfoCache, MAX_CLIP_INFO_CACHE_ENTRIES)
|
|
};
|
|
|
|
const removedTotal =
|
|
expired.loginToUserId + expired.vodList + expired.clipInfo +
|
|
evicted.loginToUserId + evicted.vodList + evicted.clipInfo;
|
|
|
|
if (removedTotal > 0) {
|
|
appendDebugLog('metadata-cache-cleanup', {
|
|
reason,
|
|
before,
|
|
after: {
|
|
loginToUserId: loginToUserIdCache.size,
|
|
vodList: vodListCache.size,
|
|
clipInfo: clipInfoCache.size
|
|
},
|
|
expired,
|
|
evicted,
|
|
removedTotal
|
|
});
|
|
}
|
|
}
|
|
|
|
function clearMetadataCaches(): void {
|
|
loginToUserIdCache.clear();
|
|
vodListCache.clear();
|
|
clipInfoCache.clear();
|
|
}
|
|
|
|
function startMetadataCacheCleanup(): void {
|
|
if (cacheCleanupTimer) {
|
|
return;
|
|
}
|
|
|
|
cacheCleanupTimer = setInterval(() => {
|
|
cleanupMetadataCaches('interval');
|
|
}, CACHE_CLEANUP_INTERVAL_MS);
|
|
|
|
cacheCleanupTimer.unref?.();
|
|
}
|
|
|
|
function stopMetadataCacheCleanup(): void {
|
|
if (!cacheCleanupTimer) {
|
|
return;
|
|
}
|
|
|
|
clearInterval(cacheCleanupTimer);
|
|
cacheCleanupTimer = null;
|
|
}
|
|
|
|
function withInFlightDedup<T>(
|
|
store: Map<string, Promise<T>>,
|
|
key: string,
|
|
factory: () => Promise<T>
|
|
): Promise<T> {
|
|
const existing = store.get(key);
|
|
if (existing) {
|
|
return existing;
|
|
}
|
|
|
|
let requestPromise: Promise<T>;
|
|
requestPromise = factory().finally(() => {
|
|
if (store.get(key) === requestPromise) {
|
|
store.delete(key);
|
|
}
|
|
});
|
|
|
|
store.set(key, requestPromise);
|
|
return requestPromise;
|
|
}
|
|
|
|
function getRetryAttemptLimit(): number {
|
|
switch (normalizePerformanceMode(config.performance_mode)) {
|
|
case 'stability':
|
|
return 5;
|
|
case 'speed':
|
|
return 2;
|
|
case 'balanced':
|
|
default:
|
|
return 3;
|
|
}
|
|
}
|
|
|
|
function classifyDownloadError(errorMessage: string): RetryErrorClass {
|
|
const text = (errorMessage || '').toLowerCase();
|
|
if (!text) return 'unknown';
|
|
|
|
if (text.includes('ungueltige vod-url') || text.includes('invalid vod url')) return 'validation';
|
|
if (text.includes('429') || text.includes('rate limit') || text.includes('too many requests')) return 'rate_limit';
|
|
if (text.includes('401') || text.includes('403') || text.includes('unauthorized') || text.includes('forbidden')) return 'auth';
|
|
if (text.includes('timed out') || text.includes('timeout') || text.includes('network') || text.includes('connection') || text.includes('dns')) return 'network';
|
|
if (text.includes('streamlink nicht gefunden') || text.includes('ffmpeg') || text.includes('ffprobe') || text.includes('enoent')) return 'tooling';
|
|
if (text.includes('integritaet') || text.includes('integrity') || text.includes('kein videostream')) return 'integrity';
|
|
if (text.includes('access denied') || text.includes('permission') || text.includes('disk') || text.includes('file') || text.includes('ordner')) return 'io';
|
|
|
|
return 'unknown';
|
|
}
|
|
|
|
function getRetryDelaySeconds(errorClass: RetryErrorClass, attempt: number): number {
|
|
const jitter = Math.floor(Math.random() * 3);
|
|
|
|
switch (errorClass) {
|
|
case 'rate_limit':
|
|
return Math.min(45, 10 + attempt * 6 + jitter);
|
|
case 'network':
|
|
return Math.min(30, 4 * attempt + jitter);
|
|
case 'auth':
|
|
return Math.min(40, 8 + attempt * 5 + jitter);
|
|
case 'integrity':
|
|
return Math.min(20, 3 + attempt * 2 + jitter);
|
|
case 'io':
|
|
return Math.min(25, 5 + attempt * 3 + jitter);
|
|
case 'tooling':
|
|
return DEFAULT_RETRY_DELAY_SECONDS;
|
|
case 'validation':
|
|
return 0;
|
|
case 'unknown':
|
|
default:
|
|
return Math.min(25, DEFAULT_RETRY_DELAY_SECONDS + attempt * 2 + jitter);
|
|
}
|
|
}
|
|
|
|
function getQueueCounts(queueData: QueueItem[] = downloadQueue): RuntimeMetricsSnapshot['queue'] {
|
|
const counts = {
|
|
pending: 0,
|
|
downloading: 0,
|
|
paused: 0,
|
|
completed: 0,
|
|
error: 0,
|
|
total: queueData.length
|
|
};
|
|
|
|
for (const item of queueData) {
|
|
if (item.status === 'pending') counts.pending += 1;
|
|
else if (item.status === 'downloading') counts.downloading += 1;
|
|
else if (item.status === 'paused') counts.paused += 1;
|
|
else if (item.status === 'completed') counts.completed += 1;
|
|
else if (item.status === 'error') counts.error += 1;
|
|
}
|
|
|
|
return counts;
|
|
}
|
|
|
|
function generateQueueItemId(): string {
|
|
queueIdCounter = (queueIdCounter + 1) % 1000;
|
|
return `${Date.now()}-${queueIdCounter}`;
|
|
}
|
|
|
|
function getQueueBroadcastFingerprint(queueData: QueueItem[] = downloadQueue): string {
|
|
return queueData.map((item) => [
|
|
item.id,
|
|
item.status,
|
|
Math.round((Number(item.progress) || 0) * 10),
|
|
item.currentPart || 0,
|
|
item.totalParts || 0,
|
|
item.speed || '',
|
|
item.eta || '',
|
|
item.last_error || ''
|
|
].join(':')).join('|');
|
|
}
|
|
|
|
function emitQueueUpdated(force = false): void {
|
|
const nextFingerprint = getQueueBroadcastFingerprint(downloadQueue);
|
|
if (!force && nextFingerprint === lastQueueBroadcastFingerprint) {
|
|
return;
|
|
}
|
|
|
|
lastQueueBroadcastFingerprint = nextFingerprint;
|
|
mainWindow?.webContents.send('queue-updated', downloadQueue);
|
|
}
|
|
|
|
function hasQueueItemId(id: string): boolean {
|
|
return downloadQueue.some((item) => item.id === id);
|
|
}
|
|
|
|
function getRuntimeMetricsSnapshot(): RuntimeMetricsSnapshot {
|
|
return {
|
|
...runtimeMetrics,
|
|
timestamp: new Date().toISOString(),
|
|
queue: getQueueCounts(downloadQueue),
|
|
caches: {
|
|
loginToUserId: loginToUserIdCache.size,
|
|
vodList: vodListCache.size,
|
|
clipInfo: clipInfoCache.size
|
|
},
|
|
config: {
|
|
performanceMode: normalizePerformanceMode(config.performance_mode),
|
|
smartScheduler: config.smart_queue_scheduler !== false,
|
|
metadataCacheMinutes: normalizeMetadataCacheMinutes(config.metadata_cache_minutes),
|
|
duplicatePrevention: config.prevent_duplicate_downloads !== false
|
|
}
|
|
};
|
|
}
|
|
|
|
function normalizeQueueUrlForFingerprint(url: string): string {
|
|
return (url || '').trim().toLowerCase().replace(/^https?:\/\/(www\.)?/, '');
|
|
}
|
|
|
|
function getQueueItemFingerprint(item: Pick<QueueItem, 'url' | 'streamer' | 'date' | 'customClip'>): string {
|
|
const clip = item.customClip;
|
|
const clipFingerprint = clip
|
|
? [
|
|
'clip',
|
|
clip.startSec,
|
|
clip.durationSec,
|
|
clip.startPart,
|
|
clip.filenameFormat,
|
|
(clip.filenameTemplate || '').trim().toLowerCase()
|
|
].join(':')
|
|
: 'vod';
|
|
|
|
return [
|
|
normalizeQueueUrlForFingerprint(item.url),
|
|
(item.streamer || '').trim().toLowerCase(),
|
|
(item.date || '').trim(),
|
|
clipFingerprint
|
|
].join('|');
|
|
}
|
|
|
|
function isQueueItemActive(item: QueueItem): boolean {
|
|
return item.status === 'pending' || item.status === 'downloading' || item.status === 'paused';
|
|
}
|
|
|
|
function hasActiveDuplicate(candidate: Pick<QueueItem, 'url' | 'streamer' | 'date' | 'customClip'>): boolean {
|
|
const candidateFingerprint = getQueueItemFingerprint(candidate);
|
|
|
|
return downloadQueue.some((existing) => {
|
|
if (!isQueueItemActive(existing)) return false;
|
|
return getQueueItemFingerprint(existing) === candidateFingerprint;
|
|
});
|
|
}
|
|
|
|
function getQueuePriorityScore(item: QueueItem): number {
|
|
const now = Date.now();
|
|
const createdMs = Number(item.id) || now;
|
|
const waitSeconds = Math.max(0, Math.floor((now - createdMs) / 1000));
|
|
const durationSeconds = Math.max(0, parseDuration(item.duration_str || '0s'));
|
|
const clipBoost = item.customClip ? 1500 : 0;
|
|
const shortJobBoost = Math.max(0, 7200 - Math.min(7200, durationSeconds)) / 5;
|
|
const ageBoost = Math.min(waitSeconds, 1800) / 2;
|
|
|
|
return clipBoost + shortJobBoost + ageBoost;
|
|
}
|
|
|
|
function pickNextPendingQueueItem(): QueueItem | null {
|
|
const pendingItems = downloadQueue.filter((item) => item.status === 'pending');
|
|
if (!pendingItems.length) return null;
|
|
|
|
if (!config.smart_queue_scheduler) {
|
|
return pendingItems[0];
|
|
}
|
|
|
|
let best = pendingItems[0];
|
|
let bestScore = getQueuePriorityScore(best);
|
|
|
|
for (let i = 1; i < pendingItems.length; i += 1) {
|
|
const candidate = pendingItems[i];
|
|
const score = getQueuePriorityScore(candidate);
|
|
if (score > bestScore) {
|
|
best = candidate;
|
|
bestScore = score;
|
|
}
|
|
}
|
|
|
|
return best;
|
|
}
|
|
|
|
function parseClockDurationSeconds(duration: string | null): number | null {
|
|
if (!duration) return null;
|
|
const parts = duration.split(':').map((part) => Number(part));
|
|
if (parts.length !== 3 || parts.some((part) => !Number.isFinite(part))) {
|
|
return null;
|
|
}
|
|
|
|
return Math.max(0, Math.floor(parts[0] * 3600 + parts[1] * 60 + parts[2]));
|
|
}
|
|
|
|
function probeMediaFile(filePath: string): { durationSeconds: number; hasVideo: boolean } | null {
|
|
try {
|
|
const ffprobePath = getFFprobePath();
|
|
if (!canExecuteCommand(ffprobePath, ['-version'])) {
|
|
return null;
|
|
}
|
|
|
|
const res = spawnSync(ffprobePath, [
|
|
'-v', 'error',
|
|
'-print_format', 'json',
|
|
'-show_format',
|
|
'-show_streams',
|
|
filePath
|
|
], {
|
|
windowsHide: true,
|
|
encoding: 'utf-8'
|
|
});
|
|
|
|
if (res.status !== 0 || !res.stdout) {
|
|
return null;
|
|
}
|
|
|
|
const parsed = JSON.parse(res.stdout) as {
|
|
format?: { duration?: string };
|
|
streams?: Array<{ codec_type?: string }>;
|
|
};
|
|
|
|
const durationSeconds = Number(parsed?.format?.duration || 0);
|
|
const hasVideo = Boolean(parsed?.streams?.some((stream) => stream.codec_type === 'video'));
|
|
|
|
return {
|
|
durationSeconds: Number.isFinite(durationSeconds) ? durationSeconds : 0,
|
|
hasVideo
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function validateDownloadedFileIntegrity(filePath: string, expectedDurationSeconds: number | null): DownloadResult {
|
|
const probed = probeMediaFile(filePath);
|
|
if (!probed) {
|
|
appendDebugLog('integrity-probe-skipped', { filePath });
|
|
return { success: true };
|
|
}
|
|
|
|
if (!probed.hasVideo) {
|
|
runtimeMetrics.integrityFailures += 1;
|
|
return { success: false, error: 'Integritaetspruefung fehlgeschlagen: Kein Videostream gefunden.' };
|
|
}
|
|
|
|
if (probed.durationSeconds <= 1) {
|
|
runtimeMetrics.integrityFailures += 1;
|
|
return { success: false, error: `Integritaetspruefung fehlgeschlagen: Dauer zu kurz (${probed.durationSeconds.toFixed(2)}s).` };
|
|
}
|
|
|
|
if (expectedDurationSeconds && expectedDurationSeconds > 4) {
|
|
const minExpected = Math.max(2, expectedDurationSeconds * 0.45);
|
|
if (probed.durationSeconds < minExpected) {
|
|
runtimeMetrics.integrityFailures += 1;
|
|
return {
|
|
success: false,
|
|
error: `Integritaetspruefung fehlgeschlagen: ${probed.durationSeconds.toFixed(1)}s statt erwarteter ~${expectedDurationSeconds}s.`
|
|
};
|
|
}
|
|
}
|
|
|
|
return { success: true };
|
|
}
|
|
|
|
// ==========================================
|
|
// TWITCH API
|
|
// ==========================================
|
|
async function twitchLogin(): Promise<boolean> {
|
|
if (!config.client_id || !config.client_secret) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
const response = await axios.post('https://id.twitch.tv/oauth2/token', null, {
|
|
params: {
|
|
client_id: config.client_id,
|
|
client_secret: config.client_secret,
|
|
grant_type: 'client_credentials'
|
|
},
|
|
timeout: API_TIMEOUT
|
|
});
|
|
accessToken = response.data.access_token;
|
|
return true;
|
|
} catch (e) {
|
|
console.error('Login error:', e);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function requestTwitchLogin(): Promise<boolean> {
|
|
if (twitchLoginInFlight) {
|
|
return twitchLoginInFlight;
|
|
}
|
|
|
|
let loginPromise: Promise<boolean>;
|
|
loginPromise = twitchLogin().finally(() => {
|
|
if (twitchLoginInFlight === loginPromise) {
|
|
twitchLoginInFlight = null;
|
|
}
|
|
});
|
|
|
|
twitchLoginInFlight = loginPromise;
|
|
return loginPromise;
|
|
}
|
|
|
|
async function ensureTwitchAuth(forceRefresh = false): Promise<boolean> {
|
|
if (!config.client_id || !config.client_secret) {
|
|
accessToken = null;
|
|
return false;
|
|
}
|
|
|
|
if (!forceRefresh && accessToken) {
|
|
return true;
|
|
}
|
|
|
|
return await requestTwitchLogin();
|
|
}
|
|
|
|
function normalizeLogin(input: string): string {
|
|
return input.trim().replace(/^@+/, '').toLowerCase();
|
|
}
|
|
|
|
function formatTwitchDurationFromSeconds(totalSeconds: number): string {
|
|
const seconds = Math.max(0, Math.floor(totalSeconds));
|
|
const h = Math.floor(seconds / 3600);
|
|
const m = Math.floor((seconds % 3600) / 60);
|
|
const s = seconds % 60;
|
|
|
|
if (h > 0) return `${h}h${m}m${s}s`;
|
|
if (m > 0) return `${m}m${s}s`;
|
|
return `${s}s`;
|
|
}
|
|
|
|
async function fetchPublicTwitchGql<T>(query: string, variables: Record<string, unknown>): Promise<T | null> {
|
|
try {
|
|
const response = await axios.post<{ data?: T; errors?: Array<{ message: string }> }>(
|
|
'https://gql.twitch.tv/gql',
|
|
{ query, variables },
|
|
{
|
|
headers: {
|
|
'Client-ID': TWITCH_WEB_CLIENT_ID,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
timeout: API_TIMEOUT
|
|
}
|
|
);
|
|
|
|
if (response.data.errors?.length) {
|
|
console.error('Public Twitch GQL errors:', response.data.errors.map((err) => err.message).join('; '));
|
|
return null;
|
|
}
|
|
|
|
return response.data.data || null;
|
|
} catch (e) {
|
|
console.error('Public Twitch GQL request failed:', e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function getPublicUserId(username: string): Promise<string | null> {
|
|
const login = normalizeLogin(username);
|
|
if (!login) return null;
|
|
|
|
const cachedUserId = getCachedValue(loginToUserIdCache, login);
|
|
if (cachedUserId !== undefined) {
|
|
runtimeMetrics.cacheHits += 1;
|
|
return cachedUserId;
|
|
}
|
|
|
|
runtimeMetrics.cacheMisses += 1;
|
|
|
|
type UserQueryResult = { user: { id: string; login: string } | null };
|
|
const data = await fetchPublicTwitchGql<UserQueryResult>(
|
|
'query($login:String!){ user(login:$login){ id login } }',
|
|
{ login }
|
|
);
|
|
|
|
const user = data?.user;
|
|
if (!user?.id) return null;
|
|
|
|
setCachedValue(loginToUserIdCache, login, user.id, MAX_LOGIN_TO_USER_ID_CACHE_ENTRIES);
|
|
userIdLoginCache.set(user.id, user.login || login);
|
|
return user.id;
|
|
}
|
|
|
|
async function getPublicVODsByLogin(loginName: string): Promise<VOD[]> {
|
|
const login = normalizeLogin(loginName);
|
|
if (!login) return [];
|
|
|
|
type VideoNode = {
|
|
id: string;
|
|
title: string;
|
|
publishedAt: string;
|
|
lengthSeconds: number;
|
|
viewCount: number;
|
|
previewThumbnailURL: string;
|
|
};
|
|
|
|
type VodsQueryResult = {
|
|
user: {
|
|
videos: {
|
|
edges: Array<{ node: VideoNode }>;
|
|
};
|
|
} | null;
|
|
};
|
|
|
|
const data = await fetchPublicTwitchGql<VodsQueryResult>(
|
|
'query($login:String!,$first:Int!){ user(login:$login){ videos(first:$first, type:ARCHIVE, sort:TIME){ edges{ node{ id title publishedAt lengthSeconds viewCount previewThumbnailURL(width:320,height:180) } } } } }',
|
|
{ login, first: 100 }
|
|
);
|
|
|
|
const edges = data?.user?.videos?.edges || [];
|
|
|
|
return edges
|
|
.map(({ node }) => {
|
|
const id = node?.id;
|
|
if (!id) return null;
|
|
|
|
return {
|
|
id,
|
|
title: node.title || 'Untitled VOD',
|
|
created_at: node.publishedAt || new Date(0).toISOString(),
|
|
duration: formatTwitchDurationFromSeconds(node.lengthSeconds || 0),
|
|
thumbnail_url: node.previewThumbnailURL || '',
|
|
url: `https://www.twitch.tv/videos/${id}`,
|
|
view_count: node.viewCount || 0,
|
|
stream_id: ''
|
|
} as VOD;
|
|
})
|
|
.filter((vod): vod is VOD => Boolean(vod));
|
|
}
|
|
|
|
async function getUserId(username: string): Promise<string | null> {
|
|
const login = normalizeLogin(username);
|
|
if (!login) return null;
|
|
|
|
const cachedUserId = getCachedValue(loginToUserIdCache, login);
|
|
if (cachedUserId !== undefined) {
|
|
runtimeMetrics.cacheHits += 1;
|
|
return cachedUserId;
|
|
}
|
|
|
|
return await withInFlightDedup(inFlightUserIdRequests, login, async () => {
|
|
const refreshedCachedUserId = getCachedValue(loginToUserIdCache, login);
|
|
if (refreshedCachedUserId !== undefined) {
|
|
runtimeMetrics.cacheHits += 1;
|
|
return refreshedCachedUserId;
|
|
}
|
|
|
|
runtimeMetrics.cacheMisses += 1;
|
|
|
|
const getUserViaPublicApi = async () => {
|
|
return await getPublicUserId(login);
|
|
};
|
|
|
|
if (!(await ensureTwitchAuth())) return await getUserViaPublicApi();
|
|
|
|
const fetchUser = async () => {
|
|
return await axios.get('https://api.twitch.tv/helix/users', {
|
|
params: { login },
|
|
headers: {
|
|
'Client-ID': config.client_id,
|
|
'Authorization': `Bearer ${accessToken}`
|
|
},
|
|
timeout: API_TIMEOUT
|
|
});
|
|
};
|
|
|
|
try {
|
|
const response = await fetchUser();
|
|
const user = response.data.data[0];
|
|
if (!user?.id) return await getUserViaPublicApi();
|
|
|
|
setCachedValue(loginToUserIdCache, login, user.id, MAX_LOGIN_TO_USER_ID_CACHE_ENTRIES);
|
|
userIdLoginCache.set(user.id, user.login || login);
|
|
return user.id;
|
|
} catch (e) {
|
|
if (axios.isAxiosError(e) && e.response?.status === 401 && (await ensureTwitchAuth(true))) {
|
|
try {
|
|
const retryResponse = await fetchUser();
|
|
const user = retryResponse.data.data[0];
|
|
if (!user?.id) return await getUserViaPublicApi();
|
|
|
|
setCachedValue(loginToUserIdCache, login, user.id, MAX_LOGIN_TO_USER_ID_CACHE_ENTRIES);
|
|
userIdLoginCache.set(user.id, user.login || login);
|
|
return user.id;
|
|
} catch (retryError) {
|
|
console.error('Error getting user after relogin:', retryError);
|
|
return await getUserViaPublicApi();
|
|
}
|
|
}
|
|
|
|
console.error('Error getting user:', e);
|
|
return await getUserViaPublicApi();
|
|
}
|
|
});
|
|
}
|
|
|
|
async function getVODs(userId: string, forceRefresh = false): Promise<VOD[]> {
|
|
const cacheKey = `user:${userId}`;
|
|
if (!forceRefresh) {
|
|
const cachedVods = getCachedValue(vodListCache, cacheKey);
|
|
if (cachedVods !== undefined) {
|
|
runtimeMetrics.cacheHits += 1;
|
|
return cachedVods;
|
|
}
|
|
}
|
|
|
|
const requestKey = `${cacheKey}|${forceRefresh ? 'force' : 'default'}`;
|
|
return await withInFlightDedup(inFlightVodRequests, requestKey, async () => {
|
|
if (!forceRefresh) {
|
|
const refreshedCachedVods = getCachedValue(vodListCache, cacheKey);
|
|
if (refreshedCachedVods !== undefined) {
|
|
runtimeMetrics.cacheHits += 1;
|
|
return refreshedCachedVods;
|
|
}
|
|
}
|
|
|
|
runtimeMetrics.cacheMisses += 1;
|
|
|
|
const getVodsViaPublicApi = async () => {
|
|
const login = userIdLoginCache.get(userId);
|
|
if (!login) return [];
|
|
|
|
const vods = await getPublicVODsByLogin(login);
|
|
setCachedValue(vodListCache, cacheKey, vods, MAX_VOD_LIST_CACHE_ENTRIES);
|
|
return vods;
|
|
};
|
|
|
|
if (!(await ensureTwitchAuth())) return await getVodsViaPublicApi();
|
|
|
|
const fetchVods = async () => {
|
|
return await axios.get('https://api.twitch.tv/helix/videos', {
|
|
params: {
|
|
user_id: userId,
|
|
type: 'archive',
|
|
first: 100
|
|
},
|
|
headers: {
|
|
'Client-ID': config.client_id,
|
|
'Authorization': `Bearer ${accessToken}`
|
|
},
|
|
timeout: API_TIMEOUT
|
|
});
|
|
};
|
|
|
|
try {
|
|
const response = await fetchVods();
|
|
const vods = response.data.data || [];
|
|
const login = vods[0]?.user_login;
|
|
if (login) {
|
|
userIdLoginCache.set(userId, normalizeLogin(login));
|
|
}
|
|
|
|
setCachedValue(vodListCache, cacheKey, vods, MAX_VOD_LIST_CACHE_ENTRIES);
|
|
return vods;
|
|
} catch (e) {
|
|
if (axios.isAxiosError(e) && e.response?.status === 401 && (await ensureTwitchAuth(true))) {
|
|
try {
|
|
const retryResponse = await fetchVods();
|
|
const vods = retryResponse.data.data || [];
|
|
const login = vods[0]?.user_login;
|
|
if (login) {
|
|
userIdLoginCache.set(userId, normalizeLogin(login));
|
|
}
|
|
|
|
setCachedValue(vodListCache, cacheKey, vods, MAX_VOD_LIST_CACHE_ENTRIES);
|
|
return vods;
|
|
} catch (retryError) {
|
|
console.error('Error getting VODs after relogin:', retryError);
|
|
return await getVodsViaPublicApi();
|
|
}
|
|
}
|
|
|
|
console.error('Error getting VODs:', e);
|
|
return await getVodsViaPublicApi();
|
|
}
|
|
});
|
|
}
|
|
|
|
async function getClipInfo(clipId: string): Promise<any | null> {
|
|
const cachedClip = getCachedValue(clipInfoCache, clipId);
|
|
if (cachedClip !== undefined) {
|
|
runtimeMetrics.cacheHits += 1;
|
|
return cachedClip;
|
|
}
|
|
|
|
return await withInFlightDedup(inFlightClipRequests, clipId, async () => {
|
|
const refreshedCachedClip = getCachedValue(clipInfoCache, clipId);
|
|
if (refreshedCachedClip !== undefined) {
|
|
runtimeMetrics.cacheHits += 1;
|
|
return refreshedCachedClip;
|
|
}
|
|
|
|
runtimeMetrics.cacheMisses += 1;
|
|
|
|
if (!(await ensureTwitchAuth())) return null;
|
|
|
|
const fetchClip = async () => {
|
|
return await axios.get('https://api.twitch.tv/helix/clips', {
|
|
params: { id: clipId },
|
|
headers: {
|
|
'Client-ID': config.client_id,
|
|
'Authorization': `Bearer ${accessToken}`
|
|
},
|
|
timeout: API_TIMEOUT
|
|
});
|
|
};
|
|
|
|
try {
|
|
const response = await fetchClip();
|
|
const clip = response.data.data[0] || null;
|
|
if (clip) {
|
|
setCachedValue(clipInfoCache, clipId, clip, MAX_CLIP_INFO_CACHE_ENTRIES);
|
|
}
|
|
return clip;
|
|
} catch (e) {
|
|
if (axios.isAxiosError(e) && e.response?.status === 401 && (await ensureTwitchAuth(true))) {
|
|
try {
|
|
const retryResponse = await fetchClip();
|
|
const clip = retryResponse.data.data[0] || null;
|
|
if (clip) {
|
|
setCachedValue(clipInfoCache, clipId, clip, MAX_CLIP_INFO_CACHE_ENTRIES);
|
|
}
|
|
return clip;
|
|
} catch (retryError) {
|
|
console.error('Error getting clip after relogin:', retryError);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
console.error('Error getting clip:', e);
|
|
return null;
|
|
}
|
|
});
|
|
}
|
|
|
|
// ==========================================
|
|
// VIDEO INFO (for cutter)
|
|
// ==========================================
|
|
async function getVideoInfo(filePath: string): Promise<VideoInfo | null> {
|
|
const ffmpegReady = await ensureFfmpegInstalled();
|
|
if (!ffmpegReady) {
|
|
appendDebugLog('get-video-info-missing-ffmpeg');
|
|
return null;
|
|
}
|
|
|
|
return new Promise((resolve) => {
|
|
const ffprobe = getFFprobePath();
|
|
const args = [
|
|
'-v', 'quiet',
|
|
'-print_format', 'json',
|
|
'-show_format',
|
|
'-show_streams',
|
|
filePath
|
|
];
|
|
|
|
const proc = spawn(ffprobe, args, { windowsHide: true });
|
|
let output = '';
|
|
|
|
proc.stdout?.on('data', (data) => {
|
|
output += data.toString();
|
|
});
|
|
|
|
proc.on('close', (code) => {
|
|
if (code !== 0) {
|
|
resolve(null);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const info = JSON.parse(output);
|
|
const videoStream = info.streams?.find((s: any) => s.codec_type === 'video');
|
|
|
|
resolve({
|
|
duration: parseFloat(info.format?.duration || '0'),
|
|
width: videoStream?.width || 0,
|
|
height: videoStream?.height || 0,
|
|
fps: parseFrameRate(videoStream?.r_frame_rate)
|
|
});
|
|
} catch {
|
|
resolve(null);
|
|
}
|
|
});
|
|
|
|
proc.on('error', () => resolve(null));
|
|
});
|
|
}
|
|
|
|
// ==========================================
|
|
// VIDEO CUTTER
|
|
// ==========================================
|
|
async function extractFrame(filePath: string, timeSeconds: number): Promise<string | null> {
|
|
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`);
|
|
|
|
const args = [
|
|
'-ss', timeSeconds.toString(),
|
|
'-i', filePath,
|
|
'-vframes', '1',
|
|
'-q:v', '2',
|
|
'-y',
|
|
tempFile
|
|
];
|
|
|
|
const proc = spawn(ffmpeg, args, { windowsHide: true });
|
|
|
|
proc.on('close', (code) => {
|
|
if (code === 0 && fs.existsSync(tempFile)) {
|
|
const imageData = fs.readFileSync(tempFile);
|
|
const base64 = `data:image/jpeg;base64,${imageData.toString('base64')}`;
|
|
fs.unlinkSync(tempFile);
|
|
resolve(base64);
|
|
} else {
|
|
resolve(null);
|
|
}
|
|
});
|
|
|
|
proc.on('error', () => resolve(null));
|
|
});
|
|
}
|
|
|
|
async function cutVideo(
|
|
inputFile: string,
|
|
outputFile: string,
|
|
startTime: number,
|
|
endTime: number,
|
|
onProgress: (percent: number) => void
|
|
): Promise<boolean> {
|
|
const ffmpegReady = await ensureFfmpegInstalled();
|
|
if (!ffmpegReady) {
|
|
appendDebugLog('cut-video-missing-ffmpeg');
|
|
return false;
|
|
}
|
|
|
|
const ffmpeg = getFFmpegPath();
|
|
const duration = Math.max(0.1, endTime - startTime);
|
|
|
|
let inputBytes = 0;
|
|
try {
|
|
inputBytes = fs.statSync(inputFile).size;
|
|
} catch {
|
|
inputBytes = 0;
|
|
}
|
|
|
|
const cutRequiredBytes = Math.max(96 * 1024 * 1024, Math.ceil(inputBytes * 0.75));
|
|
const cutDiskCheck = ensureDiskSpace(path.dirname(outputFile), cutRequiredBytes, 'Video-Cut');
|
|
if (!cutDiskCheck.success) {
|
|
appendDebugLog('cut-video-no-disk-space', {
|
|
inputFile,
|
|
outputFile,
|
|
requiredBytes: cutRequiredBytes,
|
|
error: cutDiskCheck.error
|
|
});
|
|
return false;
|
|
}
|
|
|
|
const runCutAttempt = async (copyMode: boolean): Promise<boolean> => {
|
|
const args = [
|
|
'-ss', formatDuration(startTime),
|
|
'-i', inputFile,
|
|
'-t', formatDuration(duration)
|
|
];
|
|
|
|
if (copyMode) {
|
|
args.push('-c', 'copy');
|
|
} else {
|
|
args.push(
|
|
'-c:v', 'libx264',
|
|
'-preset', 'veryfast',
|
|
'-crf', '20',
|
|
'-c:a', 'aac',
|
|
'-b:a', '160k',
|
|
'-movflags', '+faststart'
|
|
);
|
|
}
|
|
|
|
args.push('-progress', 'pipe:1', '-y', outputFile);
|
|
|
|
appendDebugLog('cut-video-attempt', { copyMode, args });
|
|
|
|
return await new Promise((resolve) => {
|
|
const proc = spawn(ffmpeg, args, { windowsHide: true });
|
|
currentProcess = proc;
|
|
|
|
proc.stdout?.on('data', (data) => {
|
|
const line = data.toString();
|
|
const match = line.match(/out_time_us=(\d+)/);
|
|
if (match) {
|
|
const currentUs = parseInt(match[1], 10);
|
|
const percent = Math.min(100, (currentUs / 1000000) / duration * 100);
|
|
onProgress(percent);
|
|
}
|
|
});
|
|
|
|
proc.on('close', (code) => {
|
|
currentProcess = null;
|
|
resolve(code === 0 && fs.existsSync(outputFile));
|
|
});
|
|
|
|
proc.on('error', () => {
|
|
currentProcess = null;
|
|
resolve(false);
|
|
});
|
|
});
|
|
};
|
|
|
|
const copySuccess = await runCutAttempt(true);
|
|
if (copySuccess) {
|
|
return true;
|
|
}
|
|
|
|
appendDebugLog('cut-video-copy-failed-fallback-reencode', { inputFile, outputFile });
|
|
try {
|
|
if (fs.existsSync(outputFile)) fs.unlinkSync(outputFile);
|
|
} catch { }
|
|
|
|
return await runCutAttempt(false);
|
|
}
|
|
|
|
// ==========================================
|
|
// MERGE VIDEOS
|
|
// ==========================================
|
|
async function mergeVideos(
|
|
inputFiles: string[],
|
|
outputFile: string,
|
|
onProgress: (percent: number) => void
|
|
): Promise<boolean> {
|
|
const ffmpegReady = await ensureFfmpegInstalled();
|
|
if (!ffmpegReady) {
|
|
appendDebugLog('merge-videos-missing-ffmpeg');
|
|
return false;
|
|
}
|
|
|
|
const ffmpeg = getFFmpegPath();
|
|
const concatFile = path.join(app.getPath('temp'), `concat_${Date.now()}.txt`);
|
|
const concatContent = inputFiles.map((filePath) => `file '${filePath.replace(/'/g, "'\\''")}'`).join('\n');
|
|
fs.writeFileSync(concatFile, concatContent);
|
|
|
|
let mergeInputBytes = 0;
|
|
for (const filePath of inputFiles) {
|
|
try {
|
|
mergeInputBytes += fs.statSync(filePath).size;
|
|
} catch {
|
|
// ignore missing file in estimation
|
|
}
|
|
}
|
|
|
|
const mergeRequiredBytes = Math.max(128 * 1024 * 1024, Math.ceil(mergeInputBytes * 1.1));
|
|
const mergeDiskCheck = ensureDiskSpace(path.dirname(outputFile), mergeRequiredBytes, 'Video-Merge');
|
|
if (!mergeDiskCheck.success) {
|
|
appendDebugLog('merge-video-no-disk-space', {
|
|
outputFile,
|
|
files: inputFiles.length,
|
|
requiredBytes: mergeRequiredBytes,
|
|
error: mergeDiskCheck.error
|
|
});
|
|
try {
|
|
fs.unlinkSync(concatFile);
|
|
} catch { }
|
|
return false;
|
|
}
|
|
|
|
const runMergeAttempt = async (copyMode: boolean): Promise<boolean> => {
|
|
const args = [
|
|
'-f', 'concat',
|
|
'-safe', '0',
|
|
'-i', concatFile
|
|
];
|
|
|
|
if (copyMode) {
|
|
args.push('-c', 'copy');
|
|
} else {
|
|
args.push(
|
|
'-c:v', 'libx264',
|
|
'-preset', 'veryfast',
|
|
'-crf', '20',
|
|
'-c:a', 'aac',
|
|
'-b:a', '160k',
|
|
'-movflags', '+faststart'
|
|
);
|
|
}
|
|
|
|
args.push('-progress', 'pipe:1', '-y', outputFile);
|
|
appendDebugLog('merge-video-attempt', { copyMode, argsCount: args.length });
|
|
|
|
return await new Promise((resolve) => {
|
|
const proc = spawn(ffmpeg, args, { windowsHide: true });
|
|
currentProcess = proc;
|
|
|
|
proc.stdout?.on('data', (data) => {
|
|
const line = data.toString();
|
|
const match = line.match(/out_time_us=(\d+)/);
|
|
if (match) {
|
|
const currentUs = parseInt(match[1], 10);
|
|
onProgress(Math.min(99, currentUs / 10000000));
|
|
}
|
|
});
|
|
|
|
proc.on('close', (code) => {
|
|
currentProcess = null;
|
|
const success = code === 0 && fs.existsSync(outputFile);
|
|
if (success) {
|
|
onProgress(100);
|
|
}
|
|
resolve(success);
|
|
});
|
|
|
|
proc.on('error', () => {
|
|
currentProcess = null;
|
|
resolve(false);
|
|
});
|
|
});
|
|
};
|
|
|
|
try {
|
|
const copySuccess = await runMergeAttempt(true);
|
|
if (copySuccess) {
|
|
return true;
|
|
}
|
|
|
|
appendDebugLog('merge-video-copy-failed-fallback-reencode', { outputFile, files: inputFiles.length });
|
|
try {
|
|
if (fs.existsSync(outputFile)) fs.unlinkSync(outputFile);
|
|
} catch { }
|
|
|
|
return await runMergeAttempt(false);
|
|
} finally {
|
|
try {
|
|
fs.unlinkSync(concatFile);
|
|
} catch { }
|
|
}
|
|
}
|
|
|
|
// ==========================================
|
|
// DOWNLOAD FUNCTIONS
|
|
// ==========================================
|
|
function downloadVODPart(
|
|
url: string,
|
|
filename: string,
|
|
startTime: string | null,
|
|
endTime: string | null,
|
|
onProgress: (progress: DownloadProgress) => void,
|
|
itemId: string,
|
|
partNum: number,
|
|
totalParts: number
|
|
): Promise<DownloadResult> {
|
|
return new Promise((resolve) => {
|
|
const streamlinkCmd = getStreamlinkCommand();
|
|
const args = [...streamlinkCmd.prefixArgs, url, 'best', '-o', filename, '--force'];
|
|
let lastErrorLine = '';
|
|
const expectedDurationSeconds = parseClockDurationSeconds(endTime);
|
|
|
|
if (startTime) {
|
|
args.push('--hls-start-offset', startTime);
|
|
}
|
|
if (endTime) {
|
|
args.push('--hls-duration', endTime);
|
|
}
|
|
|
|
console.log('Starting download:', streamlinkCmd.command, args);
|
|
appendDebugLog('download-part-start', { itemId, command: streamlinkCmd.command, filename, args });
|
|
|
|
const proc = spawn(streamlinkCmd.command, args, { windowsHide: true });
|
|
currentProcess = proc;
|
|
|
|
downloadStartTime = Date.now();
|
|
downloadedBytes = 0;
|
|
let lastBytes = 0;
|
|
let lastTime = Date.now();
|
|
|
|
// Monitor file size for progress
|
|
const progressInterval = setInterval(() => {
|
|
if (fs.existsSync(filename)) {
|
|
try {
|
|
const stats = fs.statSync(filename);
|
|
downloadedBytes = stats.size;
|
|
|
|
const now = Date.now();
|
|
const timeDiff = (now - lastTime) / 1000;
|
|
const bytesDiff = downloadedBytes - lastBytes;
|
|
const speed = timeDiff > 0 ? bytesDiff / timeDiff : 0;
|
|
|
|
runtimeMetrics.lastSpeedBytesPerSec = speed;
|
|
if (speed > 0) {
|
|
runtimeMetrics.avgSpeedBytesPerSec = runtimeMetrics.avgSpeedBytesPerSec <= 0
|
|
? speed
|
|
: (runtimeMetrics.avgSpeedBytesPerSec * 0.8) + (speed * 0.2);
|
|
}
|
|
|
|
lastBytes = downloadedBytes;
|
|
lastTime = now;
|
|
|
|
onProgress({
|
|
id: itemId,
|
|
progress: -1, // Unknown total
|
|
speed: formatSpeed(speed),
|
|
eta: '',
|
|
status: `${formatBytes(downloadedBytes)} heruntergeladen`,
|
|
currentPart: partNum,
|
|
totalParts: totalParts,
|
|
downloadedBytes: downloadedBytes,
|
|
speedBytesPerSec: speed
|
|
});
|
|
} catch { }
|
|
}
|
|
}, 1000);
|
|
|
|
proc.stdout?.on('data', (data: Buffer) => {
|
|
const line = data.toString();
|
|
console.log('Streamlink:', line);
|
|
|
|
// Parse progress
|
|
const match = line.match(/(\d+\.\d+)%/);
|
|
if (match) {
|
|
const percent = parseFloat(match[1]);
|
|
onProgress({
|
|
id: itemId,
|
|
progress: percent,
|
|
speed: '',
|
|
eta: '',
|
|
status: `${percent.toFixed(1)}%`,
|
|
currentPart: partNum,
|
|
totalParts: totalParts
|
|
});
|
|
}
|
|
});
|
|
|
|
proc.stderr?.on('data', (data: Buffer) => {
|
|
const message = data.toString().trim();
|
|
if (message) {
|
|
lastErrorLine = message.split('\n').pop() || message;
|
|
appendDebugLog('download-part-stderr', { itemId, message: lastErrorLine });
|
|
console.error('Streamlink error:', message);
|
|
}
|
|
});
|
|
|
|
proc.on('close', async (code) => {
|
|
clearInterval(progressInterval);
|
|
currentProcess = null;
|
|
|
|
if (currentDownloadCancelled) {
|
|
appendDebugLog('download-part-cancelled', { itemId, filename });
|
|
resolve({ success: false, error: 'Download wurde abgebrochen.' });
|
|
return;
|
|
}
|
|
|
|
if (code === 0 && fs.existsSync(filename)) {
|
|
const stats = fs.statSync(filename);
|
|
if (stats.size <= MIN_FILE_BYTES) {
|
|
const tooSmall = `Datei zu klein (${stats.size} Bytes)`;
|
|
appendDebugLog('download-part-failed-small-file', { itemId, filename, bytes: stats.size });
|
|
resolve({ success: false, error: tooSmall });
|
|
return;
|
|
}
|
|
|
|
const integrityResult = validateDownloadedFileIntegrity(filename, expectedDurationSeconds);
|
|
if (!integrityResult.success) {
|
|
appendDebugLog('download-part-failed-integrity', {
|
|
itemId,
|
|
filename,
|
|
bytes: stats.size,
|
|
error: integrityResult.error
|
|
});
|
|
resolve(integrityResult);
|
|
return;
|
|
}
|
|
|
|
runtimeMetrics.downloadedBytesTotal += stats.size;
|
|
appendDebugLog('download-part-success', { itemId, filename, bytes: stats.size });
|
|
resolve({ success: true });
|
|
return;
|
|
}
|
|
|
|
const genericError = lastErrorLine || `Streamlink Fehlercode ${code ?? -1}`;
|
|
appendDebugLog('download-part-failed', { itemId, filename, code, error: genericError });
|
|
resolve({ success: false, error: genericError });
|
|
});
|
|
|
|
proc.on('error', (err) => {
|
|
clearInterval(progressInterval);
|
|
console.error('Process error:', err);
|
|
currentProcess = null;
|
|
const rawError = String(err);
|
|
const errorMessage = rawError.includes('ENOENT')
|
|
? 'Streamlink nicht gefunden. Installiere Streamlink oder Python+streamlink (py -3 -m pip install streamlink).'
|
|
: rawError;
|
|
appendDebugLog('download-part-process-error', { itemId, error: errorMessage, rawError });
|
|
resolve({ success: false, error: errorMessage });
|
|
});
|
|
});
|
|
}
|
|
|
|
async function downloadVOD(
|
|
item: QueueItem,
|
|
onProgress: (progress: DownloadProgress) => void
|
|
): Promise<DownloadResult> {
|
|
const vodId = parseVodId(item.url);
|
|
if (!isLikelyVodUrl(item.url) || !vodId) {
|
|
return {
|
|
success: false,
|
|
error: 'Ungueltige VOD-URL'
|
|
};
|
|
}
|
|
|
|
const streamlinkCmd = getStreamlinkCommand();
|
|
const streamlinkVersionArgs = [...streamlinkCmd.prefixArgs, '--version'];
|
|
const streamlinkAlreadyVerified = isVerifiedStreamlinkCommand(streamlinkCmd.command, streamlinkVersionArgs);
|
|
|
|
if (!streamlinkAlreadyVerified) {
|
|
onProgress({
|
|
id: item.id,
|
|
progress: -1,
|
|
speed: '',
|
|
eta: '',
|
|
status: 'Prufe Download-Tools...',
|
|
currentPart: 0,
|
|
totalParts: 0
|
|
});
|
|
}
|
|
|
|
const streamlinkReady = await ensureStreamlinkInstalled();
|
|
if (!streamlinkReady) {
|
|
return {
|
|
success: false,
|
|
error: 'Streamlink fehlt und konnte nicht automatisch installiert werden. Siehe debug.log.'
|
|
};
|
|
}
|
|
|
|
onProgress({
|
|
id: item.id,
|
|
progress: -1,
|
|
speed: '',
|
|
eta: '',
|
|
status: 'Download gestartet',
|
|
currentPart: 0,
|
|
totalParts: 0
|
|
});
|
|
|
|
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()}`;
|
|
|
|
const folder = path.join(config.download_path, streamer, dateStr);
|
|
fs.mkdirSync(folder, { recursive: true });
|
|
|
|
const totalDuration = parseDuration(item.duration_str);
|
|
|
|
const requiredBytesEstimate = estimateRequiredDownloadBytes(item);
|
|
const diskSpaceCheck = ensureDiskSpace(folder, requiredBytesEstimate, 'Download');
|
|
if (!diskSpaceCheck.success) {
|
|
return diskSpaceCheck;
|
|
}
|
|
|
|
const makeTemplateFilename = (
|
|
template: string,
|
|
templateFallback: string,
|
|
partNum: number,
|
|
trimStartSec: number,
|
|
trimLengthSec: number
|
|
): string => {
|
|
const relativeName = renderClipFilenameTemplate({
|
|
template: normalizeFilenameTemplate(template, templateFallback),
|
|
title: item.title,
|
|
vodId,
|
|
channel: item.streamer,
|
|
date,
|
|
part: partNum,
|
|
partPadded: partNum.toString().padStart(2, '0'),
|
|
trimStartSec,
|
|
trimEndSec: trimStartSec + trimLengthSec,
|
|
trimLengthSec,
|
|
fullLengthSec: totalDuration
|
|
});
|
|
|
|
return path.join(folder, relativeName);
|
|
};
|
|
|
|
// Custom Clip - download specific time range
|
|
if (item.customClip) {
|
|
const clip = item.customClip;
|
|
const partDuration = config.part_minutes * 60;
|
|
|
|
// Helper to generate filename based on format
|
|
const makeClipFilename = (partNum: number, startOffset: number, clipLengthSec: number): string => {
|
|
if (clip.filenameFormat === 'template') {
|
|
return makeTemplateFilename(
|
|
clip.filenameTemplate || config.filename_template_clip,
|
|
DEFAULT_FILENAME_TEMPLATE_CLIP,
|
|
partNum,
|
|
startOffset,
|
|
clipLengthSec
|
|
);
|
|
}
|
|
|
|
if (clip.filenameFormat === 'timestamp') {
|
|
const h = Math.floor(startOffset / 3600);
|
|
const m = Math.floor((startOffset % 3600) / 60);
|
|
const s = Math.floor(startOffset % 60);
|
|
const timeStr = `${h.toString().padStart(2, '0')}-${m.toString().padStart(2, '0')}-${s.toString().padStart(2, '0')}`;
|
|
return path.join(folder, `${dateStr}_CLIP_${timeStr}_${partNum}.mp4`);
|
|
} else {
|
|
return path.join(folder, `${dateStr}_${partNum}.mp4`);
|
|
}
|
|
};
|
|
|
|
// If clip is longer than part duration, split into parts
|
|
if (clip.durationSec > partDuration) {
|
|
const numParts = Math.ceil(clip.durationSec / partDuration);
|
|
const downloadedFiles: string[] = [];
|
|
|
|
for (let i = 0; i < numParts; i++) {
|
|
if (currentDownloadCancelled) break;
|
|
|
|
const partNum = clip.startPart + i;
|
|
const startOffset = clip.startSec + (i * partDuration);
|
|
const remainingDuration = clip.durationSec - (i * partDuration);
|
|
const thisDuration = Math.min(partDuration, remainingDuration);
|
|
|
|
const partFilename = makeClipFilename(partNum, startOffset, thisDuration);
|
|
|
|
const result = await downloadVODPart(
|
|
item.url,
|
|
partFilename,
|
|
formatDuration(startOffset),
|
|
formatDuration(thisDuration),
|
|
onProgress,
|
|
item.id,
|
|
i + 1,
|
|
numParts
|
|
);
|
|
|
|
if (!result.success) return result;
|
|
downloadedFiles.push(partFilename);
|
|
}
|
|
|
|
return {
|
|
success: downloadedFiles.length === numParts,
|
|
error: downloadedFiles.length === numParts ? undefined : 'Nicht alle Clip-Teile konnten heruntergeladen werden.'
|
|
};
|
|
} else {
|
|
// Single clip file
|
|
const filename = makeClipFilename(clip.startPart, clip.startSec, clip.durationSec);
|
|
return await downloadVODPart(
|
|
item.url,
|
|
filename,
|
|
formatDuration(clip.startSec),
|
|
formatDuration(clip.durationSec),
|
|
onProgress,
|
|
item.id,
|
|
1,
|
|
1
|
|
);
|
|
}
|
|
}
|
|
|
|
// Check download mode
|
|
if (config.download_mode === 'full' || totalDuration <= config.part_minutes * 60) {
|
|
// Full download
|
|
const filename = makeTemplateFilename(
|
|
config.filename_template_vod,
|
|
DEFAULT_FILENAME_TEMPLATE_VOD,
|
|
1,
|
|
0,
|
|
totalDuration
|
|
);
|
|
return await downloadVODPart(item.url, filename, null, null, onProgress, item.id, 1, 1);
|
|
} else {
|
|
// Part-based download
|
|
const partDuration = config.part_minutes * 60;
|
|
const numParts = Math.ceil(totalDuration / partDuration);
|
|
const downloadedFiles: string[] = [];
|
|
|
|
for (let i = 0; i < numParts; i++) {
|
|
if (currentDownloadCancelled) break;
|
|
|
|
const startSec = i * partDuration;
|
|
const endSec = Math.min((i + 1) * partDuration, totalDuration);
|
|
const duration = endSec - startSec;
|
|
|
|
const partFilename = makeTemplateFilename(
|
|
config.filename_template_parts,
|
|
DEFAULT_FILENAME_TEMPLATE_PARTS,
|
|
i + 1,
|
|
startSec,
|
|
duration
|
|
);
|
|
|
|
const result = await downloadVODPart(
|
|
item.url,
|
|
partFilename,
|
|
formatDuration(startSec),
|
|
formatDuration(duration),
|
|
onProgress,
|
|
item.id,
|
|
i + 1,
|
|
numParts
|
|
);
|
|
|
|
if (!result.success) {
|
|
return result;
|
|
}
|
|
|
|
downloadedFiles.push(partFilename);
|
|
}
|
|
|
|
return {
|
|
success: downloadedFiles.length === numParts,
|
|
error: downloadedFiles.length === numParts ? undefined : 'Nicht alle Teile konnten heruntergeladen werden.'
|
|
};
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
appendDebugLog('queue-item-start', {
|
|
itemId: item.id,
|
|
title: item.title,
|
|
url: item.url,
|
|
smartScore: config.smart_queue_scheduler ? getQueuePriorityScore(item) : 0
|
|
});
|
|
|
|
runtimeMetrics.downloadsStarted += 1;
|
|
runtimeMetrics.activeItemId = item.id;
|
|
runtimeMetrics.activeItemTitle = item.title;
|
|
activeQueueItemId = item.id;
|
|
|
|
currentDownloadCancelled = false;
|
|
item.status = 'downloading';
|
|
saveQueue(downloadQueue);
|
|
emitQueueUpdated();
|
|
|
|
item.last_error = '';
|
|
|
|
let finalResult: DownloadResult = { success: false, error: 'Unbekannter Fehler beim Download' };
|
|
const maxAttempts = getRetryAttemptLimit();
|
|
|
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
appendDebugLog('queue-item-attempt', { itemId: item.id, attempt, max: maxAttempts });
|
|
|
|
const result = await downloadVOD(item, (progress) => {
|
|
mainWindow?.webContents.send('download-progress', progress);
|
|
});
|
|
|
|
if (result.success) {
|
|
finalResult = result;
|
|
break;
|
|
}
|
|
|
|
finalResult = result;
|
|
|
|
if (!isDownloading || currentDownloadCancelled || pauseRequested) {
|
|
finalResult = { success: false, error: pauseRequested ? 'Download wurde pausiert.' : 'Download wurde abgebrochen.' };
|
|
break;
|
|
}
|
|
|
|
const errorClass = classifyDownloadError(result.error || '');
|
|
runtimeMetrics.lastErrorClass = errorClass;
|
|
|
|
if (errorClass === 'tooling' || errorClass === 'validation') {
|
|
appendDebugLog('queue-item-no-retry', {
|
|
itemId: item.id,
|
|
errorClass,
|
|
error: result.error || 'unknown'
|
|
});
|
|
break;
|
|
}
|
|
|
|
if (attempt < maxAttempts) {
|
|
const retryDelaySeconds = getRetryDelaySeconds(errorClass, attempt);
|
|
runtimeMetrics.retriesScheduled += 1;
|
|
runtimeMetrics.lastRetryDelaySeconds = retryDelaySeconds;
|
|
|
|
item.last_error = `Versuch ${attempt}/${maxAttempts} fehlgeschlagen (${errorClass}): ${result.error || 'Unbekannter Fehler'}`;
|
|
mainWindow?.webContents.send('download-progress', {
|
|
id: item.id,
|
|
progress: -1,
|
|
speed: '',
|
|
eta: '',
|
|
status: `Neuer Versuch in ${retryDelaySeconds}s (${errorClass})...`,
|
|
currentPart: item.currentPart,
|
|
totalParts: item.totalParts
|
|
} as DownloadProgress);
|
|
saveQueue(downloadQueue);
|
|
emitQueueUpdated();
|
|
await sleep(retryDelaySeconds * 1000);
|
|
} else {
|
|
runtimeMetrics.retriesExhausted += 1;
|
|
}
|
|
}
|
|
|
|
if (!hasQueueItemId(item.id)) {
|
|
appendDebugLog('queue-item-finished-removed', { itemId: item.id });
|
|
runtimeMetrics.activeItemId = null;
|
|
runtimeMetrics.activeItemTitle = null;
|
|
activeQueueItemId = null;
|
|
continue;
|
|
}
|
|
|
|
const wasPaused = pauseRequested || (finalResult.error || '').includes('pausiert');
|
|
item.status = finalResult.success ? 'completed' : (wasPaused ? 'paused' : 'error');
|
|
item.progress = finalResult.success ? 100 : item.progress;
|
|
item.last_error = finalResult.success || wasPaused ? '' : (finalResult.error || 'Unbekannter Fehler beim Download');
|
|
|
|
if (finalResult.success) {
|
|
runtimeMetrics.downloadsCompleted += 1;
|
|
} else if (!wasPaused) {
|
|
runtimeMetrics.downloadsFailed += 1;
|
|
}
|
|
|
|
runtimeMetrics.activeItemId = null;
|
|
runtimeMetrics.activeItemTitle = null;
|
|
activeQueueItemId = null;
|
|
|
|
appendDebugLog('queue-item-finished', {
|
|
itemId: item.id,
|
|
status: item.status,
|
|
error: item.last_error
|
|
});
|
|
|
|
saveQueue(downloadQueue);
|
|
emitQueueUpdated();
|
|
}
|
|
|
|
isDownloading = false;
|
|
pauseRequested = false;
|
|
runtimeMetrics.activeItemId = null;
|
|
runtimeMetrics.activeItemTitle = null;
|
|
activeQueueItemId = null;
|
|
|
|
saveQueue(downloadQueue);
|
|
emitQueueUpdated();
|
|
mainWindow?.webContents.send('download-finished');
|
|
appendDebugLog('queue-finished', { items: downloadQueue.length });
|
|
}
|
|
|
|
// ==========================================
|
|
// WINDOW CREATION
|
|
// ==========================================
|
|
function createWindow(): void {
|
|
nativeTheme.themeSource = 'dark';
|
|
|
|
mainWindow = new BrowserWindow({
|
|
width: 1400,
|
|
height: 900,
|
|
minWidth: 1200,
|
|
minHeight: 700,
|
|
title: `Twitch VOD Manager [v${APP_VERSION}]`,
|
|
backgroundColor: '#0e0e10',
|
|
autoHideMenuBar: true,
|
|
webPreferences: {
|
|
nodeIntegration: false,
|
|
contextIsolation: true,
|
|
preload: path.join(__dirname, 'preload.js')
|
|
}
|
|
});
|
|
|
|
if (process.platform !== 'darwin') {
|
|
mainWindow.removeMenu();
|
|
}
|
|
|
|
mainWindow.loadFile(path.join(__dirname, '../src/index.html'));
|
|
|
|
mainWindow.webContents.on('did-finish-load', () => {
|
|
emitQueueUpdated(true);
|
|
if (isDownloading) {
|
|
mainWindow?.webContents.send('download-started');
|
|
}
|
|
|
|
if (autoUpdateReadyToInstall && downloadedUpdateVersion) {
|
|
mainWindow?.webContents.send('update-downloaded', buildUpdateInfoPayload(downloadedUpdateVersion));
|
|
}
|
|
});
|
|
|
|
mainWindow.on('closed', () => {
|
|
mainWindow = null;
|
|
});
|
|
|
|
// Setup auto-updater after window is ready
|
|
setTimeout(() => {
|
|
setupAutoUpdater();
|
|
}, 3000);
|
|
}
|
|
|
|
// ==========================================
|
|
// AUTO-UPDATER (electron-updater)
|
|
// ==========================================
|
|
function hasNewerKnownUpdateThanDownloaded(): boolean {
|
|
if (!latestKnownUpdateVersion || !downloadedUpdateVersion) {
|
|
return false;
|
|
}
|
|
|
|
return isNewerUpdateVersion(latestKnownUpdateVersion, downloadedUpdateVersion);
|
|
}
|
|
|
|
function normalizeReleaseVersionCandidate(value: unknown): string | undefined {
|
|
if (typeof value !== 'string') {
|
|
return undefined;
|
|
}
|
|
|
|
const trimmed = value.trim();
|
|
if (!trimmed) {
|
|
return undefined;
|
|
}
|
|
|
|
return normalizeUpdateVersion(trimmed) || trimmed.replace(/^v/i, '');
|
|
}
|
|
|
|
function cacheLatestReleaseUpdateInfo(releaseData: any): void {
|
|
if (!releaseData || typeof releaseData !== 'object') {
|
|
return;
|
|
}
|
|
|
|
const tagName = typeof releaseData.tag_name === 'string' ? releaseData.tag_name.trim() : '';
|
|
const version = normalizeReleaseVersionCandidate(tagName)
|
|
|| normalizeReleaseVersionCandidate(releaseData.name);
|
|
const releaseName = typeof releaseData.name === 'string' ? releaseData.name.trim() : '';
|
|
const releaseNotes = typeof releaseData.body === 'string' ? releaseData.body : '';
|
|
const releaseDate = typeof releaseData.published_at === 'string'
|
|
? releaseData.published_at
|
|
: (typeof releaseData.created_at === 'string' ? releaseData.created_at : undefined);
|
|
|
|
latestReleaseUpdateInfo = {
|
|
tagName: tagName || undefined,
|
|
version,
|
|
releaseDate,
|
|
releaseName: releaseName || undefined,
|
|
releaseNotes: releaseNotes.trim() ? releaseNotes : undefined
|
|
};
|
|
}
|
|
|
|
function buildUpdateInfoPayload(version: string, releaseDate?: string): {
|
|
version: string;
|
|
releaseDate?: string;
|
|
releaseName?: string;
|
|
releaseNotes?: string;
|
|
} {
|
|
const normalizedVersion = normalizeReleaseVersionCandidate(version) || version;
|
|
const cachedVersion = latestReleaseUpdateInfo?.version
|
|
? (normalizeReleaseVersionCandidate(latestReleaseUpdateInfo.version) || latestReleaseUpdateInfo.version)
|
|
: undefined;
|
|
const hasMatchingReleaseInfo = !cachedVersion || cachedVersion === normalizedVersion;
|
|
|
|
return {
|
|
version: normalizedVersion,
|
|
releaseDate: releaseDate || (hasMatchingReleaseInfo ? latestReleaseUpdateInfo?.releaseDate : undefined),
|
|
releaseName: hasMatchingReleaseInfo ? latestReleaseUpdateInfo?.releaseName : undefined,
|
|
releaseNotes: hasMatchingReleaseInfo ? latestReleaseUpdateInfo?.releaseNotes : undefined
|
|
};
|
|
}
|
|
|
|
async function requestUpdateCheck(source: UpdateCheckSource, force = false): Promise<{ started: boolean; reason?: string }> {
|
|
if (autoUpdateCheckInProgress) {
|
|
return { started: false, reason: 'in-progress' };
|
|
}
|
|
|
|
const now = Date.now();
|
|
if (!force && lastAutoUpdateCheckAt > 0 && (now - lastAutoUpdateCheckAt) < AUTO_UPDATE_MIN_CHECK_GAP_MS) {
|
|
return { started: false, reason: 'throttled' };
|
|
}
|
|
|
|
autoUpdateCheckInProgress = true;
|
|
lastAutoUpdateCheckAt = now;
|
|
appendDebugLog('update-check-start', { source });
|
|
|
|
try {
|
|
try {
|
|
const giteaRes = await axios.get(GITEA_RELEASES_API_LATEST_URL, {
|
|
timeout: 5000,
|
|
headers: {
|
|
'Accept': 'application/json',
|
|
'User-Agent': 'Twitch-VOD-Manager'
|
|
}
|
|
});
|
|
cacheLatestReleaseUpdateInfo(giteaRes.data);
|
|
const tagName = latestReleaseUpdateInfo?.tagName || giteaRes.data?.tag_name;
|
|
if (tagName) {
|
|
autoUpdater.setFeedURL({
|
|
provider: 'generic',
|
|
url: `${GITEA_RELEASES_DOWNLOAD_BASE_URL}/${tagName}`
|
|
});
|
|
appendDebugLog('gitea-feed-url-set', { tagName, owner: GITEA_REPO_OWNER, repo: GITEA_REPO_NAME });
|
|
}
|
|
} catch (apiErr) {
|
|
appendDebugLog('gitea-api-failed', String(apiErr));
|
|
}
|
|
|
|
let timeoutHandle: NodeJS.Timeout | null = null;
|
|
try {
|
|
await Promise.race([
|
|
autoUpdater.checkForUpdates(),
|
|
new Promise<never>((_, reject) => {
|
|
timeoutHandle = setTimeout(() => {
|
|
reject(new Error(`Update check timed out after ${AUTO_UPDATE_CHECK_TIMEOUT_MS}ms`));
|
|
}, AUTO_UPDATE_CHECK_TIMEOUT_MS);
|
|
})
|
|
]);
|
|
} finally {
|
|
if (timeoutHandle) {
|
|
clearTimeout(timeoutHandle);
|
|
timeoutHandle = null;
|
|
}
|
|
}
|
|
|
|
return { started: true };
|
|
} catch (err) {
|
|
appendDebugLog('update-check-failed', { source, error: String(err) });
|
|
console.error('Update check failed:', err);
|
|
return { started: false, reason: 'error' };
|
|
} finally {
|
|
autoUpdateCheckInProgress = false;
|
|
}
|
|
}
|
|
|
|
async function requestUpdateDownload(source: UpdateDownloadSource): Promise<{ started: boolean; reason?: string }> {
|
|
if (autoUpdateReadyToInstall && !hasNewerKnownUpdateThanDownloaded()) {
|
|
return { started: false, reason: 'ready-to-install' };
|
|
}
|
|
|
|
if (autoUpdateDownloadInProgress) {
|
|
return { started: false, reason: 'in-progress' };
|
|
}
|
|
|
|
autoUpdateDownloadInProgress = true;
|
|
appendDebugLog('update-download-start', { source });
|
|
|
|
try {
|
|
await autoUpdater.downloadUpdate();
|
|
return { started: true };
|
|
} catch (err) {
|
|
appendDebugLog('update-download-failed', { source, error: String(err) });
|
|
console.error('Download failed:', err);
|
|
return { started: false, reason: 'error' };
|
|
} finally {
|
|
autoUpdateDownloadInProgress = false;
|
|
}
|
|
}
|
|
|
|
function stopAutoUpdatePolling(): void {
|
|
if (autoUpdateCheckTimer) {
|
|
clearInterval(autoUpdateCheckTimer);
|
|
autoUpdateCheckTimer = null;
|
|
}
|
|
|
|
if (autoUpdateStartupTimer) {
|
|
clearTimeout(autoUpdateStartupTimer);
|
|
autoUpdateStartupTimer = null;
|
|
}
|
|
}
|
|
|
|
function startAutoUpdatePolling(): void {
|
|
if (!autoUpdateCheckTimer) {
|
|
autoUpdateCheckTimer = setInterval(() => {
|
|
void requestUpdateCheck('interval');
|
|
}, AUTO_UPDATE_CHECK_INTERVAL_MS);
|
|
|
|
autoUpdateCheckTimer.unref?.();
|
|
}
|
|
|
|
if (autoUpdateStartupTimer) {
|
|
clearTimeout(autoUpdateStartupTimer);
|
|
autoUpdateStartupTimer = null;
|
|
}
|
|
|
|
autoUpdateStartupTimer = setTimeout(() => {
|
|
autoUpdateStartupTimer = null;
|
|
void requestUpdateCheck('startup', true);
|
|
}, AUTO_UPDATE_STARTUP_CHECK_DELAY_MS);
|
|
}
|
|
|
|
function setupAutoUpdater() {
|
|
if (autoUpdaterInitialized) {
|
|
startAutoUpdatePolling();
|
|
return;
|
|
}
|
|
|
|
autoUpdaterInitialized = true;
|
|
autoUpdater.autoDownload = false;
|
|
autoUpdater.autoInstallOnAppQuit = true;
|
|
autoUpdater.autoRunAppAfterInstall = true;
|
|
|
|
autoUpdater.on('checking-for-update', () => {
|
|
console.log('Checking for updates...');
|
|
mainWindow?.webContents.send('update-checking');
|
|
});
|
|
|
|
autoUpdater.on('update-available', (info) => {
|
|
const incomingVersion = normalizeUpdateVersion(info.version);
|
|
const displayVersion = incomingVersion || info.version;
|
|
|
|
if (latestKnownUpdateVersion && compareUpdateVersions(incomingVersion, latestKnownUpdateVersion) < 0) {
|
|
appendDebugLog('update-available-ignored-older', {
|
|
incomingVersion: displayVersion,
|
|
knownVersion: latestKnownUpdateVersion
|
|
});
|
|
return;
|
|
}
|
|
|
|
latestKnownUpdateVersion = incomingVersion || latestKnownUpdateVersion;
|
|
|
|
const hasAlreadyDownloadedThisVersion = Boolean(
|
|
autoUpdateReadyToInstall &&
|
|
downloadedUpdateVersion &&
|
|
compareUpdateVersions(downloadedUpdateVersion, incomingVersion) === 0
|
|
);
|
|
|
|
console.log('Update available:', displayVersion);
|
|
if (!hasAlreadyDownloadedThisVersion) {
|
|
autoUpdateReadyToInstall = false;
|
|
}
|
|
|
|
autoUpdateDownloadInProgress = false;
|
|
|
|
if (hasAlreadyDownloadedThisVersion) {
|
|
if (mainWindow) {
|
|
mainWindow.webContents.send('update-downloaded', buildUpdateInfoPayload(displayVersion, info.releaseDate));
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (mainWindow) {
|
|
mainWindow.webContents.send('update-available', buildUpdateInfoPayload(displayVersion, info.releaseDate));
|
|
}
|
|
|
|
if (AUTO_UPDATE_AUTO_DOWNLOAD) {
|
|
void requestUpdateDownload('auto');
|
|
}
|
|
});
|
|
|
|
autoUpdater.on('update-not-available', () => {
|
|
console.log('No updates available');
|
|
mainWindow?.webContents.send('update-not-available');
|
|
});
|
|
|
|
autoUpdater.on('download-progress', (progress) => {
|
|
console.log(`Download progress: ${progress.percent.toFixed(1)}%`);
|
|
if (mainWindow) {
|
|
mainWindow.webContents.send('update-download-progress', {
|
|
percent: progress.percent,
|
|
bytesPerSecond: progress.bytesPerSecond,
|
|
transferred: progress.transferred,
|
|
total: progress.total
|
|
});
|
|
}
|
|
});
|
|
|
|
autoUpdater.on('update-downloaded', (info) => {
|
|
const downloadedVersion = normalizeUpdateVersion(info.version) || info.version;
|
|
console.log('Update downloaded:', downloadedVersion);
|
|
autoUpdateReadyToInstall = true;
|
|
autoUpdateDownloadInProgress = false;
|
|
downloadedUpdateVersion = downloadedVersion;
|
|
if (!latestKnownUpdateVersion || compareUpdateVersions(downloadedVersion, latestKnownUpdateVersion) > 0) {
|
|
latestKnownUpdateVersion = downloadedVersion;
|
|
}
|
|
if (mainWindow) {
|
|
mainWindow.webContents.send('update-downloaded', buildUpdateInfoPayload(downloadedVersion, info.releaseDate));
|
|
}
|
|
});
|
|
|
|
autoUpdater.on('error', (err) => {
|
|
autoUpdateCheckInProgress = false;
|
|
autoUpdateDownloadInProgress = false;
|
|
const message = String(err);
|
|
appendDebugLog('auto-updater-error', message);
|
|
mainWindow?.webContents.send('update-error', { message });
|
|
console.error('Auto-updater error:', err);
|
|
});
|
|
|
|
startAutoUpdatePolling();
|
|
}
|
|
|
|
// ==========================================
|
|
// IPC HANDLERS
|
|
// ==========================================
|
|
ipcMain.handle('get-config', () => config);
|
|
|
|
ipcMain.handle('save-config', (_, newConfig: Partial<Config>) => {
|
|
const previousClientId = config.client_id;
|
|
const previousClientSecret = config.client_secret;
|
|
const previousCacheMinutes = config.metadata_cache_minutes;
|
|
const previousPersistQueueOnRestart = config.persist_queue_on_restart;
|
|
|
|
config = normalizeConfigTemplates({ ...config, ...newConfig });
|
|
|
|
if (config.client_id !== previousClientId || config.client_secret !== previousClientSecret) {
|
|
accessToken = null;
|
|
twitchLoginInFlight = null;
|
|
}
|
|
|
|
if (config.metadata_cache_minutes !== previousCacheMinutes) {
|
|
clearMetadataCaches();
|
|
}
|
|
|
|
saveConfig(config);
|
|
|
|
if (config.persist_queue_on_restart === false) {
|
|
pendingQueueSnapshot = null;
|
|
if (queueSaveTimer) {
|
|
clearTimeout(queueSaveTimer);
|
|
queueSaveTimer = null;
|
|
}
|
|
clearQueueFileFromDisk();
|
|
} else if (previousPersistQueueOnRestart === false) {
|
|
saveQueue(downloadQueue, true);
|
|
}
|
|
|
|
return config;
|
|
});
|
|
|
|
ipcMain.handle('login', async () => {
|
|
return await twitchLogin();
|
|
});
|
|
|
|
ipcMain.handle('get-user-id', async (_, username: string) => {
|
|
return await getUserId(username);
|
|
});
|
|
|
|
ipcMain.handle('get-vods', async (_, userId: string, forceRefresh: boolean = false) => {
|
|
return await getVODs(userId, forceRefresh);
|
|
});
|
|
|
|
ipcMain.handle('get-queue', () => downloadQueue);
|
|
|
|
ipcMain.handle('add-to-queue', (_, item: Omit<QueueItem, 'id' | 'status' | 'progress'>) => {
|
|
if (config.prevent_duplicate_downloads && hasActiveDuplicate(item)) {
|
|
runtimeMetrics.duplicateSkips += 1;
|
|
mainWindow?.webContents.send('queue-duplicate-skipped', {
|
|
title: item.title,
|
|
streamer: item.streamer,
|
|
url: item.url
|
|
});
|
|
appendDebugLog('queue-item-duplicate-skipped', {
|
|
title: item.title,
|
|
url: item.url,
|
|
streamer: item.streamer
|
|
});
|
|
return downloadQueue;
|
|
}
|
|
|
|
const queueItem: QueueItem = {
|
|
...item,
|
|
id: generateQueueItemId(),
|
|
status: 'pending',
|
|
progress: 0
|
|
};
|
|
downloadQueue.push(queueItem);
|
|
saveQueue(downloadQueue);
|
|
emitQueueUpdated();
|
|
return downloadQueue;
|
|
});
|
|
|
|
ipcMain.handle('remove-from-queue', (_, id: string) => {
|
|
const wasActiveItem = activeQueueItemId === id;
|
|
|
|
if (wasActiveItem) {
|
|
currentDownloadCancelled = true;
|
|
if (currentProcess) {
|
|
currentProcess.kill();
|
|
}
|
|
activeQueueItemId = null;
|
|
runtimeMetrics.activeItemId = null;
|
|
runtimeMetrics.activeItemTitle = null;
|
|
appendDebugLog('queue-item-removed-active-cancelled', { id });
|
|
}
|
|
|
|
downloadQueue = downloadQueue.filter(item => item.id !== id);
|
|
saveQueue(downloadQueue);
|
|
emitQueueUpdated();
|
|
return downloadQueue;
|
|
});
|
|
|
|
ipcMain.handle('clear-completed', () => {
|
|
downloadQueue = downloadQueue.filter(item => item.status !== 'completed');
|
|
saveQueue(downloadQueue);
|
|
emitQueueUpdated();
|
|
return downloadQueue;
|
|
});
|
|
|
|
ipcMain.handle('reorder-queue', (_, orderIds: string[]) => {
|
|
const order = new Map(orderIds.map((id, idx) => [id, idx]));
|
|
const withOrder = [...downloadQueue].sort((a, b) => {
|
|
const ai = order.has(a.id) ? (order.get(a.id) as number) : Number.MAX_SAFE_INTEGER;
|
|
const bi = order.has(b.id) ? (order.get(b.id) as number) : Number.MAX_SAFE_INTEGER;
|
|
return ai - bi;
|
|
});
|
|
|
|
downloadQueue = withOrder;
|
|
saveQueue(downloadQueue);
|
|
emitQueueUpdated();
|
|
return downloadQueue;
|
|
});
|
|
|
|
ipcMain.handle('retry-failed-downloads', () => {
|
|
downloadQueue = downloadQueue.map((item) => {
|
|
if (item.status !== 'error') return item;
|
|
|
|
return {
|
|
...item,
|
|
status: 'pending',
|
|
progress: 0,
|
|
last_error: ''
|
|
};
|
|
});
|
|
|
|
saveQueue(downloadQueue);
|
|
emitQueueUpdated();
|
|
|
|
if (!isDownloading) {
|
|
void processQueue();
|
|
}
|
|
|
|
return downloadQueue;
|
|
});
|
|
|
|
ipcMain.handle('start-download', async () => {
|
|
downloadQueue = downloadQueue.map((item) => item.status === 'paused' ? { ...item, status: 'pending' } : item);
|
|
|
|
const hasPendingItems = downloadQueue.some(item => item.status === 'pending');
|
|
if (!hasPendingItems) {
|
|
emitQueueUpdated();
|
|
return false;
|
|
}
|
|
|
|
saveQueue(downloadQueue);
|
|
emitQueueUpdated();
|
|
|
|
processQueue();
|
|
return true;
|
|
});
|
|
|
|
ipcMain.handle('pause-download', () => {
|
|
if (!isDownloading) return false;
|
|
|
|
pauseRequested = true;
|
|
currentDownloadCancelled = true;
|
|
if (currentProcess) {
|
|
currentProcess.kill();
|
|
}
|
|
return true;
|
|
});
|
|
|
|
ipcMain.handle('cancel-download', () => {
|
|
isDownloading = false;
|
|
pauseRequested = false;
|
|
currentDownloadCancelled = true;
|
|
if (currentProcess) {
|
|
currentProcess.kill();
|
|
}
|
|
return true;
|
|
});
|
|
|
|
ipcMain.handle('select-folder', async () => {
|
|
const result = await dialog.showOpenDialog(mainWindow!, {
|
|
properties: ['openDirectory']
|
|
});
|
|
return result.filePaths[0] || null;
|
|
});
|
|
|
|
ipcMain.handle('select-video-file', async () => {
|
|
const result = await dialog.showOpenDialog(mainWindow!, {
|
|
properties: ['openFile'],
|
|
filters: [
|
|
{ name: 'Video Files', extensions: ['mp4', 'mkv', 'ts', 'mov', 'avi'] }
|
|
]
|
|
});
|
|
return result.filePaths[0] || null;
|
|
});
|
|
|
|
ipcMain.handle('open-folder', (_, folderPath: string) => {
|
|
if (fs.existsSync(folderPath)) {
|
|
shell.openPath(folderPath);
|
|
}
|
|
});
|
|
|
|
ipcMain.handle('get-version', () => APP_VERSION);
|
|
|
|
ipcMain.handle('check-update', async () => {
|
|
try {
|
|
setupAutoUpdater();
|
|
const result = await requestUpdateCheck('manual', true);
|
|
if (result.reason === 'error') {
|
|
return { error: true };
|
|
}
|
|
|
|
return result.started
|
|
? { checking: true }
|
|
: { checking: true, skipped: result.reason };
|
|
} catch (err) {
|
|
console.error('Update check failed:', err);
|
|
return { error: true };
|
|
}
|
|
});
|
|
|
|
ipcMain.handle('download-update', async () => {
|
|
try {
|
|
setupAutoUpdater();
|
|
const result = await requestUpdateDownload('manual');
|
|
if (result.reason === 'error') {
|
|
return { error: true };
|
|
}
|
|
|
|
return result.started
|
|
? { downloading: true }
|
|
: { downloading: true, skipped: result.reason };
|
|
} catch (err) {
|
|
console.error('Download failed:', err);
|
|
return { error: true };
|
|
}
|
|
});
|
|
|
|
ipcMain.handle('install-update', () => {
|
|
autoUpdater.quitAndInstall(true, true);
|
|
});
|
|
|
|
ipcMain.handle('open-external', async (_, url: string) => {
|
|
await shell.openExternal(url);
|
|
});
|
|
|
|
ipcMain.handle('download-clip', async (_, clipUrl: string) => {
|
|
let clipId = '';
|
|
const match1 = clipUrl.match(/clips\.twitch\.tv\/([A-Za-z0-9_-]+)/);
|
|
const match2 = clipUrl.match(/twitch\.tv\/[^/]+\/clip\/([A-Za-z0-9_-]+)/);
|
|
|
|
if (match1) clipId = match1[1];
|
|
else if (match2) clipId = match2[1];
|
|
else return { success: false, error: 'Ungueltige Clip-URL' };
|
|
|
|
const clipInfo = await getClipInfo(clipId);
|
|
if (!clipInfo) return { success: false, error: 'Clip nicht gefunden' };
|
|
|
|
const folder = path.join(config.download_path, 'Clips', clipInfo.broadcaster_name);
|
|
fs.mkdirSync(folder, { recursive: true });
|
|
|
|
const clipDiskCheck = ensureDiskSpace(folder, 128 * 1024 * 1024, 'Clip-Download');
|
|
if (!clipDiskCheck.success) {
|
|
return { success: false, error: clipDiskCheck.error || 'Zu wenig Speicherplatz.' };
|
|
}
|
|
|
|
const safeTitle = clipInfo.title.replace(/[^a-zA-Z0-9_\- ]/g, '').substring(0, 50);
|
|
const filename = path.join(folder, `${safeTitle}.mp4`);
|
|
|
|
return new Promise((resolve) => {
|
|
const streamlinkCmd = getStreamlinkCommand();
|
|
const proc = spawn(streamlinkCmd.command, [
|
|
...streamlinkCmd.prefixArgs,
|
|
`https://clips.twitch.tv/${clipId}`,
|
|
'best',
|
|
'-o', filename,
|
|
'--force'
|
|
], { windowsHide: true });
|
|
|
|
proc.on('close', (code) => {
|
|
if (code === 0 && fs.existsSync(filename)) {
|
|
resolve({ success: true, filename });
|
|
} else {
|
|
resolve({ success: false, error: `Download fehlgeschlagen (Exit-Code ${code ?? -1})` });
|
|
}
|
|
});
|
|
|
|
proc.on('error', () => {
|
|
resolve({ success: false, error: 'Streamlink nicht gefunden' });
|
|
});
|
|
});
|
|
});
|
|
|
|
ipcMain.handle('run-preflight', async (_, autoFix: boolean = false) => {
|
|
return await runPreflight(autoFix);
|
|
});
|
|
|
|
ipcMain.handle('get-debug-log', async (_, lines: number = 200) => {
|
|
return readDebugLog(lines);
|
|
});
|
|
|
|
ipcMain.handle('is-downloading', () => isDownloading);
|
|
|
|
ipcMain.handle('get-runtime-metrics', () => getRuntimeMetricsSnapshot());
|
|
|
|
ipcMain.handle('export-runtime-metrics', async () => {
|
|
try {
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
const defaultName = `runtime-metrics-${timestamp}.json`;
|
|
const preferredDir = fs.existsSync(config.download_path) ? config.download_path : app.getPath('desktop');
|
|
|
|
const dialogResult = await dialog.showSaveDialog(mainWindow!, {
|
|
defaultPath: path.join(preferredDir, defaultName),
|
|
filters: [{ name: 'JSON', extensions: ['json'] }]
|
|
});
|
|
|
|
if (dialogResult.canceled || !dialogResult.filePath) {
|
|
return { success: false, cancelled: true };
|
|
}
|
|
|
|
const snapshot = getRuntimeMetricsSnapshot();
|
|
fs.writeFileSync(dialogResult.filePath, JSON.stringify(snapshot, null, 2), 'utf-8');
|
|
return { success: true, filePath: dialogResult.filePath };
|
|
} catch (e) {
|
|
appendDebugLog('runtime-metrics-export-failed', String(e));
|
|
return { success: false, error: String(e) };
|
|
}
|
|
});
|
|
|
|
// Video Cutter IPC
|
|
ipcMain.handle('get-video-info', async (_, filePath: string) => {
|
|
return await getVideoInfo(filePath);
|
|
});
|
|
|
|
ipcMain.handle('extract-frame', async (_, filePath: string, timeSeconds: number) => {
|
|
return await extractFrame(filePath, timeSeconds);
|
|
});
|
|
|
|
ipcMain.handle('cut-video', async (_, inputFile: string, startTime: number, endTime: number) => {
|
|
const dir = path.dirname(inputFile);
|
|
const baseName = path.basename(inputFile, path.extname(inputFile));
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').substring(11, 19);
|
|
const outputFile = path.join(dir, `${baseName}_cut_${timestamp}.mp4`);
|
|
|
|
let lastProgress = 0;
|
|
const success = await cutVideo(inputFile, outputFile, startTime, endTime, (percent) => {
|
|
lastProgress = percent;
|
|
mainWindow?.webContents.send('cut-progress', percent);
|
|
});
|
|
|
|
return { success, outputFile: success ? outputFile : null };
|
|
});
|
|
|
|
// Merge IPC
|
|
ipcMain.handle('merge-videos', async (_, inputFiles: string[], outputFile: string) => {
|
|
const success = await mergeVideos(inputFiles, outputFile, (percent) => {
|
|
mainWindow?.webContents.send('merge-progress', percent);
|
|
});
|
|
|
|
return { success, outputFile: success ? outputFile : null };
|
|
});
|
|
|
|
ipcMain.handle('select-multiple-videos', async () => {
|
|
const result = await dialog.showOpenDialog(mainWindow!, {
|
|
properties: ['openFile', 'multiSelections'],
|
|
filters: [
|
|
{ name: 'Video Files', extensions: ['mp4', 'mkv', 'ts', 'mov', 'avi'] }
|
|
]
|
|
});
|
|
return result.filePaths;
|
|
});
|
|
|
|
ipcMain.handle('save-video-dialog', async (_, defaultName: string) => {
|
|
const result = await dialog.showSaveDialog(mainWindow!, {
|
|
defaultPath: defaultName,
|
|
filters: [
|
|
{ name: 'MP4 Video', extensions: ['mp4'] }
|
|
]
|
|
});
|
|
return result.filePath || null;
|
|
});
|
|
|
|
// ==========================================
|
|
// APP LIFECYCLE
|
|
// ==========================================
|
|
app.whenReady().then(() => {
|
|
refreshBundledToolPaths(true);
|
|
startMetadataCacheCleanup();
|
|
startDebugLogFlushTimer();
|
|
createWindow();
|
|
appendDebugLog('startup-tools-check-skipped', 'Deferred to first use');
|
|
|
|
app.on('activate', () => {
|
|
if (BrowserWindow.getAllWindows().length === 0) {
|
|
createWindow();
|
|
}
|
|
});
|
|
});
|
|
|
|
app.on('window-all-closed', () => {
|
|
stopMetadataCacheCleanup();
|
|
cleanupMetadataCaches('shutdown');
|
|
stopDebugLogFlushTimer(true);
|
|
stopAutoUpdatePolling();
|
|
|
|
if (currentProcess) {
|
|
currentProcess.kill();
|
|
}
|
|
flushQueueSave();
|
|
|
|
if (process.platform !== 'darwin') {
|
|
app.quit();
|
|
}
|
|
});
|
|
|
|
app.on('before-quit', () => {
|
|
stopMetadataCacheCleanup();
|
|
cleanupMetadataCaches('shutdown');
|
|
stopDebugLogFlushTimer(true);
|
|
stopAutoUpdatePolling();
|
|
flushQueueSave();
|
|
});
|