diff --git a/package-lock.json b/package-lock.json index 85ba9e3..ceaf6ae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "twitch-vod-manager", - "version": "5.0.4", + "version": "5.0.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "twitch-vod-manager", - "version": "5.0.4", + "version": "5.0.5", "license": "MIT", "dependencies": { "axios": "^1.6.0", diff --git a/package.json b/package.json index f703a46..68850a9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "twitch-vod-manager", - "version": "5.0.4", + "version": "5.0.5", "description": "Twitch VOD Manager - Download Twitch VODs easily", "main": "dist/main.js", "author": "xRangerDE", diff --git a/src/main.ts b/src/main.ts index a982dcc..478168d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1338,11 +1338,18 @@ function classifyDownloadError(errorMessage: string): RetryErrorClass { 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('401') || text.includes('403') || text.includes('unauthorized') || text.includes('forbidden') || text.includes('subscriber only') || text.includes('sub-only') || text.includes('not subscribed')) return 'auth'; + if (text.includes('timed out') || text.includes('timeout') || text.includes('network') || text.includes('connection') || text.includes('dns') || text.includes('http error') || text.includes('connectionerror') || text.includes('readerror')) return 'network'; if (text.includes('streamlink nicht gefunden') || text.includes('streamlink not found') || text.includes('streamlink is missing') || text.includes('ffmpeg') || text.includes('ffprobe') || text.includes('enoent')) return 'tooling'; if (text.includes('integritaet') || text.includes('integrity') || text.includes('kein videostream') || text.includes('no video stream')) return 'integrity'; if (text.includes('access denied') || text.includes('permission') || text.includes('disk') || text.includes('file') || text.includes('ordner') || text.includes('folder')) return 'io'; + // Twitch-spezifische streamlink errors: + // "error: No playable streams found on this URL" — VOD weg / private / sub-only + // "error: Could not find any kind of stream" — gleich + // "error: Unable to validate session token" — Twitch-API rejected + // "error: Unable to fetch access token" — Auth pre-flight failed + if (text.includes('no playable streams') || text.includes('could not find any kind of stream')) return 'validation'; + if (text.includes('access token') || text.includes('session token') || text.includes('signature') || text.includes('integrity token')) return 'auth'; return 'unknown'; } @@ -3043,7 +3050,19 @@ function downloadVODPart( // in the VOD output. Off only if the user explicitly disabled it. args.push('--twitch-disable-ads'); } + // HLS-Segment-Resilience: bei vereinzelten CDN-Fehlern weiter retrien, + // statt komplett zu sterben. Twitch hat 2025/26 oefter transiente 403/ + // timeout-Errors auf einzelne HLS-Segments. Default ist 3 — 5 ist ein + // pragmatischer Kompromiss zwischen Resilience und Failing-Fast. + args.push('--stream-segment-attempts', '5'); + args.push('--stream-segment-timeout', '20'); + args.push('--stream-timeout', '120'); + // Streamlink-Plugin retry: bei "stream not found on URL"-Erstabfrage + // einmal nachhaken, bevor wir den ganzen Run failen. + args.push('--retry-streams', '3'); + args.push('--retry-max', '2'); let lastErrorLine = ''; + const stderrBuffer: string[] = []; const expectedDurationSeconds = parseClockDurationSeconds(endTime); let lastStreamlinkPercent = 0; @@ -3146,10 +3165,26 @@ function downloadVODPart( }); 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 }); + const message = data.toString(); + if (message.trim()) { + stderrBuffer.push(message); + // Bounded buffer — wir wollen nicht 100MB stderr in RAM bei einem + // streamlink-loop. 200 chunks reichen fuer normale Diagnose. + if (stderrBuffer.length > 200) stderrBuffer.shift(); + // Letzte echte Errorzeile fuer User-Surface. "[ ... ] log lines" + // ueberspringen, "error: ..." bevorzugen damit nicht ein triviales + // INFO-Statement als User-facing-Fehler landet. + const lines = message.split('\n').map(l => l.trim()).filter(Boolean); + for (const line of lines) { + const lower = line.toLowerCase(); + if (lower.startsWith('error:') || lower.includes('error:')) { + lastErrorLine = line; + } else if (!lastErrorLine && line.length > 0 && !lower.startsWith('[')) { + // Fallback: jede non-bracket non-INFO Zeile + lastErrorLine = line; + } + } + appendDebugLog('download-part-stderr', { itemId, message: message.trim() }); console.error('Streamlink error:', message); } }); @@ -3192,9 +3227,19 @@ function downloadVODPart( return; } - const genericError = lastErrorLine || tBackend('streamlinkExitCode', { code: String(code ?? -1) }); - appendDebugLog('download-part-failed', { itemId, filename, code, error: genericError }); - resolve({ success: false, error: genericError }); + // Volle stderr-History im Debug-Log (kollabiert) — fuer Forensik + // wenn der User schreibt "es funktioniert nicht". User-facing + // genericError ist die echte streamlink-Fehlerzeile, nicht nur + // "Streamlink Fehlercode 1". + const fullStderr = stderrBuffer.join('').trim(); + const userFacingError = lastErrorLine + || (fullStderr.split('\n').filter(l => l.trim()).pop() || '').trim() + || tBackend('streamlinkExitCode', { code: String(code ?? -1) }); + appendDebugLog('download-part-failed', { + itemId, filename, code, error: userFacingError, + stderrTail: fullStderr.slice(-2000), + }); + resolve({ success: false, error: userFacingError }); }); proc.on('error', (err) => {