release: 5.0.5 — streamlink Resilience + bessere Fehler-Diagnose

Problem: User berichtet 'streamlink exit code 1' bei VOD-Downloads ohne
sinnvolle Fehlermeldung — UI zeigt 'Retrying in 8s (unknown)...'. Root
Cause: classifyDownloadError matched die echten Twitch-Errors nicht und
nur die letzte stderr-Zeile wurde im Debug-Log gespeichert.

Fixes:
- Volle stderr-History wird gepuffert + im download-part-failed Debug-Log
  als stderrTail (letzte 2000 chars) gespeichert
- UI bekommt jetzt die echte streamlink Error-Zeile statt 'Streamlink
  Fehlercode N' (prefer 'error:'-prefixed Zeilen, dann last non-bracket
  non-INFO line)
- classifyDownloadError matcht jetzt zusaetzlich: 'no playable streams',
  'could not find any kind of stream', 'access token', 'session token',
  'signature', 'integrity token', 'subscriber only', 'sub-only',
  'not subscribed', 'http error', 'connectionerror', 'readerror'

Streamlink-Args:
- --stream-segment-attempts 5 (default 3 — mehr Retries bei flaky CDN)
- --stream-segment-timeout 20
- --stream-timeout 120
- --retry-streams 3 (retry initial stream listing)
- --retry-max 2

Damit ueberlebt der Download transiente Twitch-CDN-Hicks und der User
sieht im naechsten Fail die echte Fehlerursache in der UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
xRangerDE 2026-05-19 03:56:46 +02:00
parent 261aaa362e
commit e270e1ec12
3 changed files with 57 additions and 12 deletions

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "twitch-vod-manager", "name": "twitch-vod-manager",
"version": "5.0.4", "version": "5.0.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "twitch-vod-manager", "name": "twitch-vod-manager",
"version": "5.0.4", "version": "5.0.5",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"axios": "^1.6.0", "axios": "^1.6.0",

View File

@ -1,6 +1,6 @@
{ {
"name": "twitch-vod-manager", "name": "twitch-vod-manager",
"version": "5.0.4", "version": "5.0.5",
"description": "Twitch VOD Manager - Download Twitch VODs easily", "description": "Twitch VOD Manager - Download Twitch VODs easily",
"main": "dist/main.js", "main": "dist/main.js",
"author": "xRangerDE", "author": "xRangerDE",

View File

@ -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('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('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('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')) return 'network'; 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('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('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'; 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'; return 'unknown';
} }
@ -3043,7 +3050,19 @@ function downloadVODPart(
// in the VOD output. Off only if the user explicitly disabled it. // in the VOD output. Off only if the user explicitly disabled it.
args.push('--twitch-disable-ads'); 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 = ''; let lastErrorLine = '';
const stderrBuffer: string[] = [];
const expectedDurationSeconds = parseClockDurationSeconds(endTime); const expectedDurationSeconds = parseClockDurationSeconds(endTime);
let lastStreamlinkPercent = 0; let lastStreamlinkPercent = 0;
@ -3146,10 +3165,26 @@ function downloadVODPart(
}); });
proc.stderr?.on('data', (data: Buffer) => { proc.stderr?.on('data', (data: Buffer) => {
const message = data.toString().trim(); const message = data.toString();
if (message) { if (message.trim()) {
lastErrorLine = message.split('\n').pop() || message; stderrBuffer.push(message);
appendDebugLog('download-part-stderr', { itemId, message: lastErrorLine }); // 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); console.error('Streamlink error:', message);
} }
}); });
@ -3192,9 +3227,19 @@ function downloadVODPart(
return; return;
} }
const genericError = lastErrorLine || tBackend('streamlinkExitCode', { code: String(code ?? -1) }); // Volle stderr-History im Debug-Log (kollabiert) — fuer Forensik
appendDebugLog('download-part-failed', { itemId, filename, code, error: genericError }); // wenn der User schreibt "es funktioniert nicht". User-facing
resolve({ success: false, error: genericError }); // 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) => { proc.on('error', (err) => {