diff --git a/src/main-runtime.production-path.test.ts b/src/main-runtime.production-path.test.ts index 6a679e4..67eac41 100644 --- a/src/main-runtime.production-path.test.ts +++ b/src/main-runtime.production-path.test.ts @@ -85,6 +85,15 @@ describe('main runtime safety production paths', () => { expect(closeHandler.indexOf('finish({ success: true, filename })')).toBeGreaterThan(closeHandler.indexOf('partialDownloadRegistry.commit')); }); + it('reports the auto-install failure guidance on every download tool gate', () => { + const source = mainSource(); + const mergeGroup = source.slice(source.indexOf('async function processDownloadMergeGroup'), source.indexOf('// ---- PHASE 2: MERGING ----')); + expect(mergeGroup).toContain("tBackend('streamlinkAutoInstallFailed')"); + expect(mergeGroup).toContain("tBackend('ffmpegAutoInstallFailed')"); + expect(source).not.toContain("tBackend('streamlinkMissing')"); + expect(source).not.toContain("tBackend('ffmpegMissing')"); + }); + it('guards and tracks every standalone cut and merge process across shutdown', () => { const source = mainSource(); const cut = source.slice(source.indexOf('async function cutVideo('), source.indexOf('async function mergeVideos(')); diff --git a/src/main.ts b/src/main.ts index f05c512..6723515 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6471,12 +6471,12 @@ async function processDownloadMergeGroup( if (mg.mergePhase === 'downloading') { const streamlinkReady = await ensureStreamlinkInstalled(); if (!streamlinkReady) { - return { success: false, error: tBackend('streamlinkMissing') }; + return { success: false, error: tBackend('streamlinkAutoInstallFailed') }; } const ffmpegReady = await ensureFfmpegInstalled(); if (!ffmpegReady) { - return { success: false, error: tBackend('ffmpegMissing') }; + return { success: false, error: tBackend('ffmpegAutoInstallFailed') }; } const streamer = mg.items[0].streamer.replace(/[^a-zA-Z0-9_-]/g, ''); diff --git a/src/main/domain/i18n-backend.ts b/src/main/domain/i18n-backend.ts index f5fc85d..1922c69 100644 --- a/src/main/domain/i18n-backend.ts +++ b/src/main/domain/i18n-backend.ts @@ -7,10 +7,9 @@ export const BACKEND_MESSAGES = { invalidClipUrl: 'Ungültige Clip-URL', clipNotFound: 'Clip nicht gefunden', streamlinkAutoInstallFailed: 'Streamlink fehlt und konnte nicht automatisch installiert werden. Siehe debug.log.', - streamlinkMissing: 'Streamlink fehlt.', streamlinkNotFound: 'Streamlink nicht gefunden. Installiere Streamlink oder Python+streamlink (py -3 -m pip install streamlink).', streamlinkExitCode: 'Streamlink Fehlercode {code}', - ffmpegMissing: 'FFmpeg fehlt.', + ffmpegAutoInstallFailed: 'FFmpeg fehlt und konnte nicht automatisch installiert werden. Siehe debug.log.', ffmpegMergeFailed: 'FFmpeg Merge fehlgeschlagen.', ffmpegSplitFailed: 'FFmpeg Split fehlgeschlagen.', fileTooSmall: 'Datei zu klein ({bytes} Bytes)', @@ -49,10 +48,9 @@ export const BACKEND_MESSAGES = { invalidClipUrl: 'Invalid clip URL', clipNotFound: 'Clip not found', streamlinkAutoInstallFailed: 'Streamlink is missing and could not be auto-installed. See debug.log.', - streamlinkMissing: 'Streamlink is missing.', streamlinkNotFound: 'Streamlink not found. Install streamlink or Python+streamlink (py -3 -m pip install streamlink).', streamlinkExitCode: 'Streamlink exit code {code}', - ffmpegMissing: 'FFmpeg is missing.', + ffmpegAutoInstallFailed: 'FFmpeg is missing and could not be auto-installed. See debug.log.', ffmpegMergeFailed: 'FFmpeg merge failed.', ffmpegSplitFailed: 'FFmpeg split failed.', fileTooSmall: 'File too small ({bytes} bytes)', diff --git a/src/renderer-globals.d.ts b/src/renderer-globals.d.ts index 7944879..33d9dae 100644 --- a/src/renderer-globals.d.ts +++ b/src/renderer-globals.d.ts @@ -305,6 +305,7 @@ interface ManagedToolStatus { archiveName: string; state: 'missing' | 'installing' | 'verified' | 'unverified' | 'corrupt'; verified: boolean; + fallbackRunnable: boolean; } interface ManagedToolStatuses { diff --git a/src/renderer-locale-de.ts b/src/renderer-locale-de.ts index b7b799f..2de98de 100644 --- a/src/renderer-locale-de.ts +++ b/src/renderer-locale-de.ts @@ -342,6 +342,7 @@ const UI_TEXT_DE = { managedToolsVerified: 'Verifiziert', managedToolsUnverified: 'Nicht verifiziert', managedToolsCorrupt: 'Beschädigt', + managedToolsFallbackActive: 'Downloads weiterhin möglich', debugLogTitle: 'Live Debug-Log', refreshLog: 'Aktualisieren', autoRefresh: 'Auto-Refresh', diff --git a/src/renderer-locale-en.ts b/src/renderer-locale-en.ts index a550ee7..4ead8e8 100644 --- a/src/renderer-locale-en.ts +++ b/src/renderer-locale-en.ts @@ -342,6 +342,7 @@ const UI_TEXT_EN = { managedToolsVerified: 'Verified', managedToolsUnverified: 'Unverified', managedToolsCorrupt: 'Corrupt', + managedToolsFallbackActive: 'Downloads still available', debugLogTitle: 'Live Debug Log', refreshLog: 'Refresh', autoRefresh: 'Auto refresh', diff --git a/src/renderer-settings.production-path.test.ts b/src/renderer-settings.production-path.test.ts index f8e7144..7f82d4c 100644 --- a/src/renderer-settings.production-path.test.ts +++ b/src/renderer-settings.production-path.test.ts @@ -77,6 +77,43 @@ function createElements(...ids: string[]): Map { } describe('renderer settings production diagnostics paths', () => { + it('marks unverified managed tools as still downloadable when a runnable installation exists', () => { + const elements = createElements('managedToolStatus'); + const context = { + UI_TEXT: { + static: { + managedToolsMissing: 'Missing', + managedToolsInstalling: 'Installing', + managedToolsVerified: 'Verified', + managedToolsUnverified: 'Unverified', + managedToolsCorrupt: 'Corrupt', + managedToolsFallbackActive: 'Downloads still available', + }, + }, + byId: (id: string) => elements.get(id), + }; + const api = evaluate( + sourceFragment('function getManagedToolStateLabel', 'async function refreshManagedToolStatus'), + context, + 'renderManagedToolStatus' + ); + + api.renderManagedToolStatus({ + streamlink: { id: 'streamlink', version: '8.4.0', state: 'missing', verified: false, fallbackRunnable: true }, + ffmpeg: { id: 'ffmpeg', version: '8.1.2', state: 'missing', verified: false, fallbackRunnable: false }, + }); + + const lines = (elements.get('managedToolStatus')?.textContent ?? '').split('\n'); + expect(lines[0]).toBe('streamlink 8.4.0: Missing · Unverified · Downloads still available'); + expect(lines[1]).toBe('ffmpeg 8.1.2: Missing · Unverified'); + + api.renderManagedToolStatus({ + streamlink: { id: 'streamlink', version: '8.4.0', state: 'verified', verified: true, fallbackRunnable: true }, + ffmpeg: { id: 'ffmpeg', version: '8.1.2', state: 'verified', verified: true, fallbackRunnable: true }, + }); + expect(elements.get('managedToolStatus')?.textContent).not.toContain('Downloads still available'); + }); + it('ends every consecutive runtime metrics rejection in the localized error state', async () => { const elements = createElements('runtimeMetricsOutput'); const context = { diff --git a/src/renderer-settings.ts b/src/renderer-settings.ts index 9bf3906..b499102 100644 --- a/src/renderer-settings.ts +++ b/src/renderer-settings.ts @@ -471,7 +471,11 @@ function getManagedToolStateLabel(state: ManagedToolStatus['state']): string { function renderManagedToolStatus(statuses: ManagedToolStatuses): void { const lines = [statuses.streamlink, statuses.ffmpeg].map((status) => { const verification = status.verified ? UI_TEXT.static.managedToolsVerified : UI_TEXT.static.managedToolsUnverified; - return `${status.id} ${status.version}: ${getManagedToolStateLabel(status.state)} · ${verification}`; + const parts = [`${status.id} ${status.version}: ${getManagedToolStateLabel(status.state)} · ${verification}`]; + if (!status.verified && status.fallbackRunnable) { + parts.push(UI_TEXT.static.managedToolsFallbackActive); + } + return parts.join(' · '); }); byId('managedToolStatus').textContent = lines.join('\n'); } diff --git a/src/tools.test.ts b/src/tools.test.ts new file mode 100644 index 0000000..9321cd1 --- /dev/null +++ b/src/tools.test.ts @@ -0,0 +1,178 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { PassThrough } from 'node:stream'; + +vi.mock('axios', () => ({ + default: { + get: vi.fn() + } +})); + +vi.mock('child_process', () => ({ + spawn: vi.fn(), + execSync: vi.fn(() => { + throw new Error('not on PATH'); + }), + spawnSync: vi.fn(() => ({ status: 1 })) +})); + +interface ToolsModule { + initToolDirs(streamlinkDir: string, ffmpegDir: string, getTempPath: () => string): void; + setDebugLogFn(fn: (message: string, details?: unknown) => void): void; + ensureStreamlinkInstalled(): Promise; + ensureFfmpegInstalled(): Promise; + getManagedToolStatuses(): Promise<{ + streamlink: { state: string; verified: boolean; fallbackRunnable: boolean }; + ffmpeg: { state: string; verified: boolean; fallbackRunnable: boolean }; + }>; +} + +const originalPlatform = process.platform; +let tempRoot: string; +let streamlinkDir: string; +let ffmpegDir: string; +let debugMessages: string[]; + +function forcePlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { value: platform, configurable: true }); +} + +function writeUnverifiedStreamlinkInstall(): string { + const binDir = path.join(streamlinkDir, 'bin'); + fs.mkdirSync(binDir, { recursive: true }); + const exePath = path.join(binDir, 'streamlink.exe'); + fs.writeFileSync(exePath, 'dummy-streamlink-binary'); + return exePath; +} + +function writeUnverifiedFfmpegInstall(): { ffmpegPath: string; ffprobePath: string } { + const binDir = path.join(ffmpegDir, 'bin'); + fs.mkdirSync(binDir, { recursive: true }); + const ffmpegPath = path.join(binDir, 'ffmpeg.exe'); + const ffprobePath = path.join(binDir, 'ffprobe.exe'); + fs.writeFileSync(ffmpegPath, 'dummy-ffmpeg-binary'); + fs.writeFileSync(ffprobePath, 'dummy-ffprobe-binary'); + return { ffmpegPath, ffprobePath }; +} + +async function loadTools(): Promise<{ tools: ToolsModule; axiosGet: ReturnType; spawnSync: ReturnType }> { + vi.resetModules(); + const axios = (await import('axios')).default as unknown as { get: ReturnType }; + const childProcess = await import('child_process') as unknown as { spawnSync: ReturnType }; + const tools = await import('./tools') as unknown as ToolsModule; + tools.initToolDirs(streamlinkDir, ffmpegDir, () => path.join(tempRoot, 'tmp')); + tools.setDebugLogFn((message) => { + debugMessages.push(message); + }); + return { tools, axiosGet: axios.get, spawnSync: childProcess.spawnSync }; +} + +function allowExecutionOf(spawnSync: ReturnType, runnablePaths: string[]): void { + spawnSync.mockImplementation((command: string) => ( + runnablePaths.includes(command) ? { status: 0 } : { status: 1 } + )); +} + +beforeEach(() => { + forcePlatform('win32'); + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'tvm-tools-test-')); + streamlinkDir = path.join(tempRoot, 'tools', 'streamlink'); + ffmpegDir = path.join(tempRoot, 'tools', 'ffmpeg'); + fs.mkdirSync(streamlinkDir, { recursive: true }); + fs.mkdirSync(ffmpegDir, { recursive: true }); + debugMessages = []; +}); + +afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +describe('ensureStreamlinkInstalled with an unverified managed install', () => { + it('returns true when the forced repair download fails but the existing streamlink runs', async () => { + const exePath = writeUnverifiedStreamlinkInstall(); + const { tools, axiosGet, spawnSync } = await loadTools(); + allowExecutionOf(spawnSync, [exePath]); + axiosGet.mockRejectedValue(new Error('offline')); + + await expect(tools.ensureStreamlinkInstalled()).resolves.toBe(true); + expect(debugMessages).toContain('streamlink-install-start'); + expect(debugMessages).toContain('streamlink-install-failed'); + }); + + it('returns false when the forced repair fails and the existing streamlink does not run', async () => { + writeUnverifiedStreamlinkInstall(); + const { tools, axiosGet, spawnSync } = await loadTools(); + allowExecutionOf(spawnSync, []); + axiosGet.mockRejectedValue(new Error('offline')); + + await expect(tools.ensureStreamlinkInstalled()).resolves.toBe(false); + expect(debugMessages).toContain('streamlink-install-failed'); + }); + + it('resolves instead of hanging when the download response stream errors mid-transfer', async () => { + const exePath = writeUnverifiedStreamlinkInstall(); + const { tools, axiosGet, spawnSync } = await loadTools(); + allowExecutionOf(spawnSync, [exePath]); + const brokenStream = new PassThrough(); + let errorScheduled = false; + brokenStream.on('newListener', (event) => { + if (event === 'error' && !errorScheduled) { + errorScheduled = true; + setImmediate(() => brokenStream.emit('error', new Error('connection reset'))); + } + }); + axiosGet.mockResolvedValue({ data: brokenStream }); + + await expect(tools.ensureStreamlinkInstalled()).resolves.toBe(true); + expect(debugMessages).toContain('streamlink-install-failed'); + }, 8000); +}); + +describe('getManagedToolStatuses fallback availability', () => { + it('reports a runnable unverified install as still downloadable', async () => { + const exePath = writeUnverifiedStreamlinkInstall(); + const { tools, spawnSync } = await loadTools(); + allowExecutionOf(spawnSync, [exePath]); + + const statuses = await tools.getManagedToolStatuses(); + expect(statuses.streamlink.verified).toBe(false); + expect(statuses.streamlink.fallbackRunnable).toBe(true); + expect(statuses.ffmpeg.fallbackRunnable).toBe(false); + }); + + it('reports nothing runnable when no installation responds', async () => { + writeUnverifiedStreamlinkInstall(); + const { tools, spawnSync } = await loadTools(); + allowExecutionOf(spawnSync, []); + + const statuses = await tools.getManagedToolStatuses(); + expect(statuses.streamlink.fallbackRunnable).toBe(false); + expect(statuses.ffmpeg.fallbackRunnable).toBe(false); + }); +}); + +describe('ensureFfmpegInstalled with an unverified managed install', () => { + it('returns true when the forced repair download fails but the existing ffmpeg and ffprobe run', async () => { + const { ffmpegPath, ffprobePath } = writeUnverifiedFfmpegInstall(); + const { tools, axiosGet, spawnSync } = await loadTools(); + allowExecutionOf(spawnSync, [ffmpegPath, ffprobePath]); + axiosGet.mockRejectedValue(new Error('offline')); + + await expect(tools.ensureFfmpegInstalled()).resolves.toBe(true); + expect(debugMessages).toContain('ffmpeg-install-start'); + expect(debugMessages).toContain('ffmpeg-install-failed'); + }); + + it('returns false when the forced repair fails and the existing ffmpeg does not run', async () => { + writeUnverifiedFfmpegInstall(); + const { tools, axiosGet, spawnSync } = await loadTools(); + allowExecutionOf(spawnSync, []); + axiosGet.mockRejectedValue(new Error('offline')); + + await expect(tools.ensureFfmpegInstalled()).resolves.toBe(false); + expect(debugMessages).toContain('ffmpeg-install-failed'); + }); +}); diff --git a/src/tools.ts b/src/tools.ts index df64a51..642c80c 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -338,6 +338,10 @@ async function downloadFile(url: string, destinationPath: string): Promise((resolve, reject) => { const writer = fs.createWriteStream(destinationPath); + response.data.on('error', (err: Error) => { + writer.destroy(); + reject(err); + }); response.data.pipe(writer); writer.on('finish', () => resolve()); writer.on('error', (err) => reject(err)); @@ -412,14 +416,27 @@ function getManagedToolInstaller(toolId: 'streamlink' | 'ffmpeg'): ManagedToolIn } export interface ManagedToolStatuses { - streamlink: ManagedToolStatus; - ffmpeg: ManagedToolStatus; + streamlink: ManagedToolStatus & { fallbackRunnable: boolean }; + ffmpeg: ManagedToolStatus & { fallbackRunnable: boolean }; } export async function getManagedToolStatuses(): Promise { + refreshBundledToolPaths(); + const streamlinkCommand = getStreamlinkCommand(); + const streamlinkVersionArgs = [...streamlinkCommand.prefixArgs, '--version']; + const ffmpegPath = getFFmpegPath(); + const ffprobePath = getFFprobePath(); return { - streamlink: await getManagedToolInstaller('streamlink').status(APPLICATION_TOOL_MANIFEST.streamlink), - ffmpeg: await getManagedToolInstaller('ffmpeg').status(APPLICATION_TOOL_MANIFEST.ffmpeg) + streamlink: { + ...(await getManagedToolInstaller('streamlink').status(APPLICATION_TOOL_MANIFEST.streamlink)), + fallbackRunnable: isVerifiedStreamlinkCommand(streamlinkCommand.command, streamlinkVersionArgs) + || fallbackToRunnableStreamlink(streamlinkCommand.command, streamlinkVersionArgs) + }, + ffmpeg: { + ...(await getManagedToolInstaller('ffmpeg').status(APPLICATION_TOOL_MANIFEST.ffmpeg)), + fallbackRunnable: isVerifiedFfmpegCommands(ffmpegPath, ffprobePath) + || fallbackToRunnableFfmpeg(ffmpegPath, ffprobePath) + } }; } @@ -450,6 +467,22 @@ export async function resetManagedTools(): Promise<{ success: boolean; statuses: // ========================================== // AUTO-INSTALL TOOLS // ========================================== +function fallbackToRunnableStreamlink(command: string, versionArgs: string[]): boolean { + if (!canExecuteCommand(command, versionArgs)) { + return false; + } + cacheVerifiedStreamlinkCommand(command, versionArgs); + return true; +} + +function fallbackToRunnableFfmpeg(ffmpegPath: string, ffprobePath: string): boolean { + if (!canExecuteCommand(ffmpegPath, ['-version']) || !canExecuteCommand(ffprobePath, ['-version'])) { + return false; + } + cacheVerifiedFfmpegCommands(ffmpegPath, ffprobePath); + return true; +} + export async function ensureStreamlinkInstalled(): Promise { refreshBundledToolPaths(); @@ -468,7 +501,7 @@ export async function ensureStreamlinkInstalled(): Promise { } if (process.platform !== 'win32') { - return false; + return fallbackToRunnableStreamlink(current.command, versionArgs); } _appendDebugLog('streamlink-install-start'); @@ -476,7 +509,7 @@ export async function ensureStreamlinkInstalled(): Promise { const result = await getManagedToolInstaller('streamlink').install(manifest); if (!result.success) { _appendDebugLog('streamlink-install-failed', { error: result.error, status: result.status }); - return false; + return fallbackToRunnableStreamlink(current.command, versionArgs); } refreshBundledToolPaths(true); @@ -489,10 +522,10 @@ export async function ensureStreamlinkInstalled(): Promise { cacheVerifiedStreamlinkCommand(cmd.command, installedVersionArgs); } _appendDebugLog('streamlink-install-finished', { works, command: cmd.command, prefixArgs: cmd.prefixArgs }); - return works; + return works || fallbackToRunnableStreamlink(current.command, versionArgs); } catch (e) { _appendDebugLog('streamlink-install-failed', String(e)); - return false; + return fallbackToRunnableStreamlink(current.command, versionArgs); } } @@ -514,7 +547,7 @@ export async function ensureFfmpegInstalled(): Promise { } if (process.platform !== 'win32') { - return false; + return fallbackToRunnableFfmpeg(ffmpegPath, ffprobePath); } _appendDebugLog('ffmpeg-install-start'); @@ -522,7 +555,7 @@ export async function ensureFfmpegInstalled(): Promise { const result = await getManagedToolInstaller('ffmpeg').install(manifest); if (!result.success) { _appendDebugLog('ffmpeg-install-failed', { error: result.error, status: result.status }); - return false; + return fallbackToRunnableFfmpeg(ffmpegPath, ffprobePath); } refreshBundledToolPaths(true); @@ -534,9 +567,9 @@ export async function ensureFfmpegInstalled(): Promise { cacheVerifiedFfmpegCommands(newFfmpegPath, newFfprobePath); } _appendDebugLog('ffmpeg-install-finished', { works, ffmpeg: newFfmpegPath, ffprobe: newFfprobePath }); - return works; + return works || fallbackToRunnableFfmpeg(ffmpegPath, ffprobePath); } catch (e) { _appendDebugLog('ffmpeg-install-failed', String(e)); - return false; + return fallbackToRunnableFfmpeg(ffmpegPath, ffprobePath); } }