From 551690d09cfd071f93ed1905067a94d3b49b0d12 Mon Sep 17 00:00:00 2001 From: xRangerDE Date: Sat, 14 Feb 2026 05:53:42 +0100 Subject: [PATCH] Ship v3.9.0 system reliability and UX toolkit Add an in-app preflight diagnostics center with optional auto-fix, introduce backend retry handling for failed downloads, provide live debug log viewing in settings, and expand queue controls with retry-failed actions while keeping language switching instant and locale data organized. --- typescript-version/package-lock.json | 4 +- typescript-version/package.json | 2 +- typescript-version/src/index.html | 26 ++- typescript-version/src/main.ts | 182 ++++++++++++++++++- typescript-version/src/preload.ts | 3 + typescript-version/src/renderer-globals.d.ts | 19 ++ typescript-version/src/renderer-locale-de.ts | 8 + typescript-version/src/renderer-locale-en.ts | 8 + typescript-version/src/renderer-queue.ts | 5 + typescript-version/src/renderer-settings.ts | 50 +++++ typescript-version/src/renderer-shared.ts | 1 + typescript-version/src/renderer-texts.ts | 8 + typescript-version/src/renderer.ts | 3 + typescript-version/src/styles.css | 22 +++ 14 files changed, 329 insertions(+), 12 deletions(-) diff --git a/typescript-version/package-lock.json b/typescript-version/package-lock.json index 415d946..fd0ad1a 100644 --- a/typescript-version/package-lock.json +++ b/typescript-version/package-lock.json @@ -1,12 +1,12 @@ { "name": "twitch-vod-manager", - "version": "3.8.8", + "version": "3.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "twitch-vod-manager", - "version": "3.8.8", + "version": "3.9.0", "license": "MIT", "dependencies": { "axios": "^1.6.0", diff --git a/typescript-version/package.json b/typescript-version/package.json index f94e106..2c794a2 100644 --- a/typescript-version/package.json +++ b/typescript-version/package.json @@ -1,6 +1,6 @@ { "name": "twitch-vod-manager", - "version": "3.8.8", + "version": "3.9.0", "description": "Twitch VOD Manager - Download Twitch VODs easily", "main": "dist/main.js", "author": "xRangerDE", diff --git a/typescript-version/src/index.html b/typescript-version/src/index.html index 305d937..ba2109b 100644 --- a/typescript-version/src/index.html +++ b/typescript-version/src/index.html @@ -130,6 +130,7 @@
+
@@ -343,9 +344,30 @@

Updates

-

Version: v3.8.8

+

Version: v3.9.0

+ +
+

System-Check

+
+ + +
+
Noch kein Check ausgefuhrt.
+
+ +
+

Live Debug-Log

+
+ + +
+
Lade...
+
@@ -354,7 +376,7 @@
Nicht verbunden - v3.8.8 + v3.9.0 diff --git a/typescript-version/src/main.ts b/typescript-version/src/main.ts index b85981c..af33d90 100644 --- a/typescript-version/src/main.ts +++ b/typescript-version/src/main.ts @@ -8,7 +8,7 @@ import { autoUpdater } from 'electron-updater'; // ========================================== // CONFIG & CONSTANTS // ========================================== -const APP_VERSION = '3.8.8'; +const APP_VERSION = '3.9.0'; const UPDATE_CHECK_URL = 'http://24-music.de/version.json'; // Paths @@ -88,6 +88,22 @@ interface DownloadResult { 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; @@ -214,6 +230,94 @@ function getStreamlinkPath(): string { return 'streamlink'; } +function sleep(ms: number): Promise { + 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 { + 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 { + 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(); + } + + const streamlinkCmd = getStreamlinkCommand(); + checks.streamlink = canExecuteCommand(streamlinkCmd.command, [...streamlinkCmd.prefixArgs, '--version']); + + const ffmpegPath = getFFmpegPath(); + const ffprobePath = getFFprobePath(); + checks.ffmpeg = canExecuteCommand(ffmpegPath, ['-version']); + checks.ffprobe = canExecuteCommand(ffprobePath, ['-version']); + + 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 readDebugLog(lines = 200): string { + try { + if (!fs.existsSync(DEBUG_LOG_FILE)) { + return 'Debug-Log ist leer.'; + } + + const text = fs.readFileSync(DEBUG_LOG_FILE, 'utf-8'); + 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 }); @@ -1352,13 +1456,47 @@ async function processQueue(): Promise { item.last_error = ''; - const result = await downloadVOD(item, (progress) => { - mainWindow?.webContents.send('download-progress', progress); - }); + let finalResult: DownloadResult = { success: false, error: 'Unbekannter Fehler beim Download' }; - item.status = result.success ? 'completed' : 'error'; - item.progress = result.success ? 100 : 0; - item.last_error = result.success ? '' : (result.error || 'Unbekannter Fehler beim Download'); + for (let attempt = 1; attempt <= MAX_RETRY_ATTEMPTS; attempt++) { + appendDebugLog('queue-item-attempt', { itemId: item.id, attempt, max: MAX_RETRY_ATTEMPTS }); + + const result = await downloadVOD(item, (progress) => { + mainWindow?.webContents.send('download-progress', progress); + }); + + if (result.success) { + finalResult = result; + break; + } + + finalResult = result; + + if (!isDownloading || currentDownloadCancelled) { + finalResult = { success: false, error: 'Download wurde abgebrochen.' }; + break; + } + + if (attempt < MAX_RETRY_ATTEMPTS) { + item.last_error = `Versuch ${attempt}/${MAX_RETRY_ATTEMPTS} fehlgeschlagen: ${result.error || 'Unbekannter Fehler'}`; + mainWindow?.webContents.send('download-progress', { + id: item.id, + progress: -1, + speed: '', + eta: '', + status: `Neuer Versuch in ${RETRY_DELAY_SECONDS}s...`, + currentPart: item.currentPart, + totalParts: item.totalParts + } as DownloadProgress); + saveQueue(downloadQueue); + mainWindow?.webContents.send('queue-updated', downloadQueue); + await sleep(RETRY_DELAY_SECONDS * 1000); + } + } + + item.status = finalResult.success ? 'completed' : 'error'; + item.progress = finalResult.success ? 100 : 0; + item.last_error = finalResult.success ? '' : (finalResult.error || 'Unbekannter Fehler beim Download'); appendDebugLog('queue-item-finished', { itemId: item.id, status: item.status, @@ -1521,6 +1659,28 @@ ipcMain.handle('clear-completed', () => { 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); + mainWindow?.webContents.send('queue-updated', downloadQueue); + + if (!isDownloading) { + void processQueue(); + } + + return downloadQueue; +}); + ipcMain.handle('start-download', async () => { const hasPendingItems = downloadQueue.some(item => item.status !== 'completed'); if (!hasPendingItems) { @@ -1636,6 +1796,14 @@ ipcMain.handle('download-clip', async (_, clipUrl: string) => { }); }); +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); // Video Cutter IPC diff --git a/typescript-version/src/preload.ts b/typescript-version/src/preload.ts index e2577aa..0d803c6 100644 --- a/typescript-version/src/preload.ts +++ b/typescript-version/src/preload.ts @@ -61,6 +61,7 @@ contextBridge.exposeInMainWorld('api', { addToQueue: (item: Omit) => ipcRenderer.invoke('add-to-queue', item), removeFromQueue: (id: string) => ipcRenderer.invoke('remove-from-queue', id), clearCompleted: () => ipcRenderer.invoke('clear-completed'), + retryFailedDownloads: () => ipcRenderer.invoke('retry-failed-downloads'), // Download startDownload: () => ipcRenderer.invoke('start-download'), @@ -91,6 +92,8 @@ contextBridge.exposeInMainWorld('api', { downloadUpdate: () => ipcRenderer.invoke('download-update'), installUpdate: () => ipcRenderer.invoke('install-update'), openExternal: (url: string) => ipcRenderer.invoke('open-external', url), + runPreflight: (autoFix: boolean) => ipcRenderer.invoke('run-preflight', autoFix), + getDebugLog: (lines: number) => ipcRenderer.invoke('get-debug-log', lines), // Events onDownloadProgress: (callback: (progress: DownloadProgress) => void) => { diff --git a/typescript-version/src/renderer-globals.d.ts b/typescript-version/src/renderer-globals.d.ts index 9d7996d..7de094b 100644 --- a/typescript-version/src/renderer-globals.d.ts +++ b/typescript-version/src/renderer-globals.d.ts @@ -87,6 +87,22 @@ interface UpdateDownloadProgress { total: number; } +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 ApiBridge { getConfig(): Promise; saveConfig(config: Partial): Promise; @@ -97,6 +113,7 @@ interface ApiBridge { addToQueue(item: Omit): Promise; removeFromQueue(id: string): Promise; clearCompleted(): Promise; + retryFailedDownloads(): Promise; startDownload(): Promise; cancelDownload(): Promise; isDownloading(): Promise; @@ -115,6 +132,8 @@ interface ApiBridge { downloadUpdate(): Promise<{ downloading?: boolean; error?: boolean }>; installUpdate(): Promise; openExternal(url: string): Promise; + runPreflight(autoFix: boolean): Promise; + getDebugLog(lines: number): Promise; onDownloadProgress(callback: (progress: DownloadProgress) => void): void; onQueueUpdated(callback: (queue: QueueItem[]) => void): void; onDownloadStarted(callback: () => void): void; diff --git a/typescript-version/src/renderer-locale-de.ts b/typescript-version/src/renderer-locale-de.ts index 51eed3c..114619b 100644 --- a/typescript-version/src/renderer-locale-de.ts +++ b/typescript-version/src/renderer-locale-de.ts @@ -7,6 +7,7 @@ const UI_TEXT_DE = { navMerge: 'Videos zusammenfugen', navSettings: 'Einstellungen', queueTitle: 'Warteschlange', + retryFailed: 'Fehler neu', clearQueue: 'Leeren', refresh: 'Aktualisieren', streamerPlaceholder: 'Streamer hinzufugen...', @@ -36,6 +37,13 @@ const UI_TEXT_DE = { partMinutesLabel: 'Teil-Lange (Minuten)', updateTitle: 'Updates', checkUpdates: 'Nach Updates suchen', + preflightTitle: 'System-Check', + preflightRun: 'Check ausfuhren', + preflightFix: 'Auto-Fix Tools', + preflightEmpty: 'Noch kein Check ausgefuhrt.', + debugLogTitle: 'Live Debug-Log', + refreshLog: 'Aktualisieren', + autoRefresh: 'Auto-Refresh', notConnected: 'Nicht verbunden' }, status: { diff --git a/typescript-version/src/renderer-locale-en.ts b/typescript-version/src/renderer-locale-en.ts index 875dda3..79ef216 100644 --- a/typescript-version/src/renderer-locale-en.ts +++ b/typescript-version/src/renderer-locale-en.ts @@ -7,6 +7,7 @@ const UI_TEXT_EN = { navMerge: 'Merge Videos', navSettings: 'Settings', queueTitle: 'Queue', + retryFailed: 'Retry failed', clearQueue: 'Clear', refresh: 'Refresh', streamerPlaceholder: 'Add streamer...', @@ -36,6 +37,13 @@ const UI_TEXT_EN = { partMinutesLabel: 'Part Length (Minutes)', updateTitle: 'Updates', checkUpdates: 'Check for updates', + preflightTitle: 'System Check', + preflightRun: 'Run check', + preflightFix: 'Auto-fix tools', + preflightEmpty: 'No checks run yet.', + debugLogTitle: 'Live Debug Log', + refreshLog: 'Refresh', + autoRefresh: 'Auto refresh', notConnected: 'Not connected' }, status: { diff --git a/typescript-version/src/renderer-queue.ts b/typescript-version/src/renderer-queue.ts index dc936b4..d17f45b 100644 --- a/typescript-version/src/renderer-queue.ts +++ b/typescript-version/src/renderer-queue.ts @@ -19,6 +19,11 @@ async function clearCompleted(): Promise { renderQueue(); } +async function retryFailedDownloads(): Promise { + queue = await window.api.retryFailedDownloads(); + renderQueue(); +} + function getQueueStatusLabel(item: QueueItem): string { if (item.status === 'completed') return UI_TEXT.queue.statusDone; if (item.status === 'error') return UI_TEXT.queue.statusFailed; diff --git a/typescript-version/src/renderer-settings.ts b/typescript-version/src/renderer-settings.ts index 04592ae..cf5d2be 100644 --- a/typescript-version/src/renderer-settings.ts +++ b/typescript-version/src/renderer-settings.ts @@ -40,6 +40,56 @@ function changeLanguage(lang: string): void { } } +function renderPreflightResult(result: PreflightResult): void { + const entries = [ + ['Internet', result.checks.internet], + ['Streamlink', result.checks.streamlink], + ['FFmpeg', result.checks.ffmpeg], + ['FFprobe', result.checks.ffprobe], + ['Download-Pfad', result.checks.downloadPathWritable] + ]; + + const lines = entries.map(([name, ok]) => `${ok ? 'OK' : 'FAIL'} ${name}`).join('\n'); + const extra = result.messages.length ? `\n\n${result.messages.join('\n')}` : '\n\nAlles bereit.'; + + byId('preflightResult').textContent = `${lines}${extra}`; +} + +async function runPreflight(autoFix = false): Promise { + const btn = byId(autoFix ? 'btnPreflightFix' : 'btnPreflightRun'); + const old = btn.textContent || ''; + btn.disabled = true; + btn.textContent = autoFix ? 'Fixe...' : 'Prufe...'; + + try { + const result = await window.api.runPreflight(autoFix); + renderPreflightResult(result); + } finally { + btn.disabled = false; + btn.textContent = old; + } +} + +async function refreshDebugLog(): Promise { + const text = await window.api.getDebugLog(250); + const panel = byId('debugLogOutput'); + panel.textContent = text; + panel.scrollTop = panel.scrollHeight; +} + +function toggleDebugAutoRefresh(enabled: boolean): void { + if (debugLogAutoRefreshTimer) { + clearInterval(debugLogAutoRefreshTimer); + debugLogAutoRefreshTimer = null; + } + + if (enabled) { + debugLogAutoRefreshTimer = window.setInterval(() => { + void refreshDebugLog(); + }, 1500); + } +} + async function saveSettings(): Promise { const clientId = byId('clientId').value.trim(); const clientSecret = byId('clientSecret').value.trim(); diff --git a/typescript-version/src/renderer-shared.ts b/typescript-version/src/renderer-shared.ts index 36e8eac..9493b5b 100644 --- a/typescript-version/src/renderer-shared.ts +++ b/typescript-version/src/renderer-shared.ts @@ -38,3 +38,4 @@ let clipDialogData: ClipDialogData | null = null; let clipTotalSeconds = 0; let updateReady = false; +let debugLogAutoRefreshTimer: number | null = null; diff --git a/typescript-version/src/renderer-texts.ts b/typescript-version/src/renderer-texts.ts index 560b202..e861b08 100644 --- a/typescript-version/src/renderer-texts.ts +++ b/typescript-version/src/renderer-texts.ts @@ -46,6 +46,7 @@ function applyLanguageToStaticUI(): void { setText('navMergeText', UI_TEXT.static.navMerge); setText('navSettingsText', UI_TEXT.static.navSettings); setText('queueTitleText', UI_TEXT.static.queueTitle); + setText('btnRetryFailed', UI_TEXT.static.retryFailed); setText('btnClear', UI_TEXT.static.clearQueue); setText('refreshText', UI_TEXT.static.refresh); setText('clipsHeading', UI_TEXT.static.clipsHeading); @@ -74,6 +75,13 @@ function applyLanguageToStaticUI(): void { setText('partMinutesLabel', UI_TEXT.static.partMinutesLabel); setText('updateTitle', UI_TEXT.static.updateTitle); setText('checkUpdateBtn', UI_TEXT.static.checkUpdates); + setText('preflightTitle', UI_TEXT.static.preflightTitle); + setText('btnPreflightRun', UI_TEXT.static.preflightRun); + setText('btnPreflightFix', UI_TEXT.static.preflightFix); + setText('preflightResult', UI_TEXT.static.preflightEmpty); + setText('debugLogTitle', UI_TEXT.static.debugLogTitle); + setText('btnRefreshLog', UI_TEXT.static.refreshLog); + setText('autoRefreshText', UI_TEXT.static.autoRefresh); setText('updateText', UI_TEXT.updates.bannerDefault); setText('updateButton', UI_TEXT.updates.downloadNow); setPlaceholder('newStreamer', UI_TEXT.static.streamerPlaceholder); diff --git a/typescript-version/src/renderer.ts b/typescript-version/src/renderer.ts index ea4c477..24dc277 100644 --- a/typescript-version/src/renderer.ts +++ b/typescript-version/src/renderer.ts @@ -80,6 +80,9 @@ async function init(): Promise { void checkUpdateSilent(); }, 3000); + void runPreflight(false); + void refreshDebugLog(); + setInterval(() => { void syncQueueAndDownloadState(); }, 2000); diff --git a/typescript-version/src/styles.css b/typescript-version/src/styles.css index 7da2527..e96c375 100644 --- a/typescript-version/src/styles.css +++ b/typescript-version/src/styles.css @@ -318,6 +318,15 @@ body { transition: all 0.2s; } +.btn-retry { + background: #2a3344; + color: #d9e4f7; +} + +.btn-retry:hover { + background: #33405a; +} + .btn-start { background: var(--success); color: white; @@ -574,6 +583,19 @@ body { gap: 10px; } +.log-panel { + background: #11151c; + border: 1px solid rgba(255,255,255,0.12); + border-radius: 6px; + padding: 10px; + max-height: 220px; + overflow: auto; + white-space: pre-wrap; + color: #b8c7df; + font-size: 12px; + line-height: 1.35; +} + .form-row input { flex: 1; }