diff --git a/bin/api/streaming_handler.dart b/bin/api/streaming_handler.dart index 8ab61d3..fa641d9 100644 --- a/bin/api/streaming_handler.dart +++ b/bin/api/streaming_handler.dart @@ -5,6 +5,7 @@ import 'package:http/http.dart' as http; import '../database/database.dart'; import '../models/playlist_config.dart'; import '../services/ffmpeg_session_manager.dart'; +import '../services/gpu_fallback.dart'; import '../utils/log_redactor.dart'; import '../utils/media_probe.dart'; import '../utils/stream_pipe.dart'; @@ -17,6 +18,9 @@ final Directory _hlsTempDir = /// Global FFmpeg session registry (initialized in [initStreaming]). final FfmpegSessionManager sessionManager = FfmpegSessionManager(_hlsTempDir); +/// Pannes GPU récentes, partagées par toutes les routes de transcodage. +final GpuHealth _gpuHealth = GpuHealth(); + /// Helper to resolve FFmpeg path (SYSTEM PATH vs Portable) String _getFFmpegPath() { if (Platform.isWindows) { @@ -181,6 +185,71 @@ Map _segmentHeaders({int maxAge = 60}) => { 'Cache-Control': 'max-age=$maxAge', }; +typedef _SessionAttempt = ({FfmpegSession? session, bool ready, String? error}); + +/// Démarre (ou récupère) la session [id] et attend sa playlist. +/// +/// Un `Process.start` qui échoue (serveur à court de processus ou de +/// mémoire) devient un échec ordinaire au lieu d'une exception non +/// rattrapée qui remontait en 500. +Future<_SessionAttempt> _runSession({ + required String id, + required bool isLive, + required List Function(Directory dir) argsBuilder, + int minSegments = 1, +}) async { + final FfmpegSession session; + try { + session = await sessionManager.getOrStart( + id: id, + isLive: isLive, + ffmpegPath: _getFFmpegPath(), + argsBuilder: argsBuilder, + ); + } on ProcessException catch (e) { + return (session: null, ready: false, error: 'FFmpeg start failed: $e'); + } + final outcome = + await sessionManager.waitForPlaylist(session, minSegments: minSegments); + return (session: session, ready: outcome.ready, error: outcome.error); +} + +/// Comme [_runSession], avec repli sur le CPU si l'encodage GPU échoue. +/// +/// Le repli garde le même identifiant de session : les requêtes de segments +/// qui suivent tombent sur la session CPU sans que le lecteur s'en aperçoive. +/// Une panne GPU fait aussi partir les sessions suivantes directement sur le +/// CPU pendant le délai de [GpuHealth], au lieu d'échouer une à une. +Future<_SessionAttempt> _runWithGpuFallback({ + required String id, + required bool isLive, + required bool wantGpu, + required List Function(bool gpu) buildArgs, + int minSegments = 1, +}) async { + final gpu = wantGpu && _gpuHealth.available; + var attempt = await _runSession( + id: id, + isLive: isLive, + argsBuilder: (_) => buildArgs(gpu), + minSegments: minSegments, + ); + + if (!attempt.ready && gpu && isGpuFailure(attempt.error)) { + _gpuHealth.markFailed(); + print('[GPU] $id : encodage GPU refusé ' + '(${LogRedactor.redactUrl(attempt.error ?? '')}) — repli sur le CPU'); + sessionManager.killSession(id); + attempt = await _runSession( + id: id, + isLive: isLive, + argsBuilder: (_) => buildArgs(false), + minSegments: minSegments, + ); + } + return attempt; +} + // ========================================== // 1. LIVE TV HANDLER (FFmpeg HLS + direct TS proxy) // ========================================== @@ -310,13 +379,13 @@ Handler createLiveStreamHandler( ); } - final session = await sessionManager.getOrStart( + final result = await _runWithGpuFallback( id: sessionId, isLive: true, - ffmpegPath: _getFFmpegPath(), - argsBuilder: (dir) => [ + wantGpu: useNvidiaGpu && quality != 'source', + buildArgs: (gpu) => [ '-hide_banner', '-loglevel', 'warning', - if (useNvidiaGpu && quality != 'source') ...['-hwaccel', 'cuda'], + if (gpu) ...['-hwaccel', 'cuda'], '-headers', 'User-Agent: VLC/3.0.18 LibVLC/3.0.18\r\n', '-reconnect', '1', '-reconnect_streamed', '1', '-reconnect_at_eof', '1', @@ -330,7 +399,7 @@ Handler createLiveStreamHandler( '-probesize', '1000000', '-analyzeduration', '1000000', '-i', targetUrl, - ..._liveVideoArgs(quality, useNvidiaGpu), + ..._liveVideoArgs(quality, gpu), ..._audioArgs(withFilters: false), // HLS sliding window: 10 x 2s segments (lower live latency than the // previous 20-segment window while still safe for iOS). @@ -347,8 +416,8 @@ Handler createLiveStreamHandler( ], ); - final result = await sessionManager.waitForPlaylist(session); - if (!result.ready) { + final session = result.session; + if (!result.ready || session == null) { sessionManager.killSession(sessionId); return Response(502, body: 'Live transcoder failed: ${result.error}'); } @@ -400,26 +469,34 @@ Handler createLiveStreamHandler( '[Live Turbo] $streamId: ${LogRedactor.redactUrl(targetUrl)}', ); - final process = await Process.start(_getFFmpegPath(), [ - '-hide_banner', '-loglevel', 'warning', - '-headers', 'User-Agent: VLC/3.0.18 LibVLC/3.0.18\r\n', - '-reconnect', '1', '-reconnect_streamed', '1', - '-reconnect_at_eof', '1', - '-reconnect_delay_max', '10', - '-rw_timeout', '30000000', - // Démarrage rapide : ne pas bufferiser l'analyse, sonde réduite. - '-fflags', 'nobuffer', - '-flags', 'low_delay', - '-probesize', '1000000', - '-analyzeduration', '1000000', - '-i', targetUrl, - '-c:v', 'copy', - '-c:a', 'aac', '-b:a', '160k', '-ac', '2', '-ar', '48000', - '-af', 'aresample=async=1', - // Pas de délai de mux : les paquets partent dès qu'ils existent. - '-muxdelay', '0', '-muxpreload', '0', - '-f', 'mpegts', 'pipe:1', - ]); + final Process process; + try { + process = await Process.start(_getFFmpegPath(), [ + '-hide_banner', '-loglevel', 'warning', + '-headers', 'User-Agent: VLC/3.0.18 LibVLC/3.0.18\r\n', + '-reconnect', '1', '-reconnect_streamed', '1', + '-reconnect_at_eof', '1', + '-reconnect_delay_max', '10', + '-rw_timeout', '30000000', + // Démarrage rapide : ne pas bufferiser l'analyse, sonde réduite. + '-fflags', 'nobuffer', + '-flags', 'low_delay', + '-probesize', '1000000', + '-analyzeduration', '1000000', + '-i', targetUrl, + '-c:v', 'copy', + '-c:a', 'aac', '-b:a', '160k', '-ac', '2', '-ar', '48000', + '-af', 'aresample=async=1', + // Pas de délai de mux : les paquets partent dès qu'ils existent. + '-muxdelay', '0', '-muxpreload', '0', + '-f', 'mpegts', 'pipe:1', + ]); + } on ProcessException catch (e) { + // Serveur saturé (plus de processus ou de mémoire) : un 503 que le + // lecteur sait traiter, plutôt qu'une exception qui remonte en 500. + print('[Live Turbo] $streamId : FFmpeg n\'a pas démarré ($e)'); + return Response(503, body: 'FFmpeg start failed'); + } // Journaliser les erreurs FFmpeg (redactées) sans bloquer le flux. process.stderr.transform(const SystemEncoding().decoder).listen((line) { @@ -535,13 +612,13 @@ Handler createVodStreamHandler( ); } - final session = await sessionManager.getOrStart( + final result = await _runWithGpuFallback( id: sessionId, isLive: false, - ffmpegPath: _getFFmpegPath(), - argsBuilder: (dir) => [ + wantGpu: useNvidiaGpu && quality != 'source', + buildArgs: (gpu) => [ '-hide_banner', '-loglevel', 'warning', - if (useNvidiaGpu && quality != 'source') ...['-hwaccel', 'cuda'], + if (gpu) ...['-hwaccel', 'cuda'], '-headers', 'User-Agent: VLC/3.0.18 LibVLC/3.0.18\r\n', '-reconnect', '1', '-reconnect_at_eof', '1', @@ -555,7 +632,7 @@ Handler createVodStreamHandler( '-analyzeduration', '2000000', '-probesize', '5000000', '-i', targetUrl, - ..._vodVideoArgs(quality, useNvidiaGpu), + ..._vodVideoArgs(quality, gpu), ..._audioArgs(withFilters: quality != 'source'), '-f', 'hls', '-hls_time', '4', @@ -570,8 +647,8 @@ Handler createVodStreamHandler( ], ); - final result = await sessionManager.waitForPlaylist(session); - if (!result.ready) { + final session = result.session; + if (!result.ready || session == null) { sessionManager.killSession(sessionId); return Response(502, body: 'VOD transcoder failed: ${result.error}'); } @@ -677,9 +754,8 @@ Handler createRecordingStreamHandler( final videoCodec = await MediaProbe.videoCodec(targetUrl); final canCopyVideo = videoCodec == 'h264'; - List buildArgs({required bool copyVideo}) { - final useNvidiaGpu = - !copyVideo && (isGpuEnabled?.call() ?? _isNvidiaGpuEnabled()); + List buildArgs({required bool copyVideo, required bool gpu}) { + final useNvidiaGpu = !copyVideo && gpu; return [ '-hide_banner', '-loglevel', 'warning', if (useNvidiaGpu) ...['-hwaccel', 'cuda'], @@ -724,9 +800,7 @@ Handler createRecordingStreamHandler( /// au démarrage, la playlist étant servie dès le premier segment. En /// copie vidéo elle est produite en une fraction de seconde, elle ne /// coûte donc que sur un ré-encodage. - Future<({FfmpegSession session, bool ready, String? error})> run( - bool copyVideo, - ) async { + Future<_SessionAttempt> run(bool copyVideo) { if (!sessionManager.contains(sessionId)) { print( '[Recording] $sessionId : ' @@ -734,18 +808,20 @@ Handler createRecordingStreamHandler( '(codec source : ${videoCodec ?? 'inconnu'})', ); } - final session = await sessionManager.getOrStart( + if (copyVideo) { + return _runSession( + id: sessionId, + isLive: false, + argsBuilder: (_) => buildArgs(copyVideo: true, gpu: false), + minSegments: 3, + ); + } + return _runWithGpuFallback( id: sessionId, isLive: false, - ffmpegPath: _getFFmpegPath(), - argsBuilder: (dir) => buildArgs(copyVideo: copyVideo), - ); - final outcome = - await sessionManager.waitForPlaylist(session, minSegments: 3); - return ( - session: session, - ready: outcome.ready, - error: outcome.error, + wantGpu: isGpuEnabled?.call() ?? _isNvidiaGpuEnabled(), + buildArgs: (gpu) => buildArgs(copyVideo: false, gpu: gpu), + minSegments: 3, ); } @@ -763,14 +839,13 @@ Handler createRecordingStreamHandler( attempt = await run(false); } - if (!attempt.ready) { + final session = attempt.session; + if (!attempt.ready || session == null) { sessionManager.killSession(sessionId); return Response(502, body: 'Recording transcoder failed: ${attempt.error}'); } - final session = attempt.session; - session.touch(); // Ménage des offsets abandonnés (aller-retours dans la barre de // progression), une fois la nouvelle session confirmée démarrée. diff --git a/bin/services/gpu_fallback.dart b/bin/services/gpu_fallback.dart new file mode 100644 index 0000000..8ebb45d --- /dev/null +++ b/bin/services/gpu_fallback.dart @@ -0,0 +1,43 @@ +/// Repli GPU → CPU des transcodages. +/// +/// Quand la VRAM est saturée (plusieurs sessions NVENC, un autre processus +/// sur la carte) ou que le GPU dépasse son quota de sessions d'encodage, +/// FFmpeg échoue à l'ouverture de l'encodeur et meurt en une seconde. Sans +/// repli, le lecteur recevait un 502, relançait… et retombait sur le même +/// GPU saturé : le flux restait illisible tant que la carte était pleine. + +/// Signatures FFmpeg d'un échec imputable au GPU NVIDIA (NVENC/NVDEC/CUDA). +/// +/// N'est consulté que pour une session lancée sur le GPU : « opening +/// encoder » y désigne donc forcément h264_nvenc. +final _gpuFailurePattern = RegExp( + r'nvenc|cuda|cuvid|nvdec|OpenEncodeSession|No capable devices|' + r'out of memory|Device creation failed|hwaccel|opening encoder', + caseSensitive: false, +); + +/// Vrai si [error] (stderr récent de FFmpeg) trahit une panne GPU, et non +/// une source injoignable ou un délai dépassé — qu'un repli CPU ne +/// réglerait pas, et qui doublerait inutilement la charge d'un serveur déjà +/// occupé. +bool isGpuFailure(String? error) => + error != null && _gpuFailurePattern.hasMatch(error); + +/// Mémorise une panne GPU récente pour que les sessions suivantes partent +/// directement sur le CPU, au lieu de payer chacune un échec NVENC avant +/// leur repli. +class GpuHealth { + final Duration cooldown; + final DateTime Function() _now; + DateTime? _unavailableUntil; + + GpuHealth({ + this.cooldown = const Duration(minutes: 2), + DateTime Function()? now, + }) : _now = now ?? DateTime.now; + + bool get available => + _unavailableUntil == null || !_now().isBefore(_unavailableUntil!); + + void markFailed() => _unavailableUntil = _now().add(cooldown); +} diff --git a/bin/test/gpu_fallback_test.dart b/bin/test/gpu_fallback_test.dart new file mode 100644 index 0000000..5dec20b --- /dev/null +++ b/bin/test/gpu_fallback_test.dart @@ -0,0 +1,76 @@ +import 'package:test/test.dart'; +import '../services/gpu_fallback.dart'; + +void main() { + group('isGpuFailure', () { + test('detects NVENC out-of-memory', () { + expect( + isGpuFailure( + 'FFmpeg exited (1): [h264_nvenc @ 0x55] OpenEncodeSessionEx failed: ' + 'out of memory (10)', + ), + isTrue, + ); + }); + + test('detects missing NVENC device', () { + expect( + isGpuFailure('[h264_nvenc @ 0x1] No capable devices found'), + isTrue, + ); + }); + + test('detects CUDA errors', () { + expect( + isGpuFailure('cu->cuInit(0) failed -> CUDA_ERROR_OUT_OF_MEMORY'), + isTrue, + ); + }); + + test('detects encoder opening failure', () { + expect( + isGpuFailure( + 'Error while opening encoder for output stream #0:0 - maybe ' + 'incorrect parameters such as bit_rate, rate, width or height', + ), + isTrue, + ); + }); + + test('ignores upstream failures', () { + expect( + isGpuFailure( + 'FFmpeg exited (1): http://x/live/u/p/1.ts: Server returned 404 ' + 'Not Found', + ), + isFalse, + ); + }); + + test('ignores timeouts and null', () { + expect(isGpuFailure('Timeout waiting for transcoder'), isFalse); + expect(isGpuFailure(null), isFalse); + }); + }); + + group('GpuHealth', () { + test('available until a failure, then again after cooldown', () { + var now = DateTime.utc(2026, 10, 8, 20, 0); + final health = GpuHealth( + cooldown: const Duration(minutes: 2), + now: () => now, + ); + + expect(health.available, isTrue); + + health.markFailed(); + expect(health.available, isFalse); + + now = now.add(const Duration(minutes: 1, seconds: 59)); + expect(health.available, isFalse); + + now = now.add(const Duration(seconds: 1)); + expect(health.available, isTrue); + }); + }); +} diff --git a/web/player_lite.html b/web/player_lite.html index 406e42b..99ebb6e 100644 --- a/web/player_lite.html +++ b/web/player_lite.html @@ -90,25 +90,49 @@ playOverlay.style.display = 'none'; } - var player = new XFPlayer({ - video: video, - logPrefix: '[LitePlayer]', - onLoading: showLoading, - onReady: function () { - hideLoading(); - playOverlay.style.display = 'none'; - }, - onError: showError, - onBlocked: function () { - hideLoading(); - playOverlay.style.display = 'flex'; - }, - onProfileChange: function (name) { - loadingHint.textContent = - name === 'safe' ? 'Réseau instable — buffer maximal' - : 'Réseau instable — buffer élargi'; - } - }); + // Un serveur chargé peut rater le démarrage du transcodeur : on + // relance un lecteur neuf avant d'afficher l'erreur, comme le + // font déjà player.html et player_mobile.html. + var retryCount = 0; + var maxRetries = 3; + var player = null; + + function buildPlayer() { + if (player) { try { player.destroy(); } catch (e) {} } + + player = new XFPlayer({ + video: video, + logPrefix: '[LitePlayer]', + onLoading: showLoading, + onReady: function () { + hideLoading(); + retryCount = 0; + playOverlay.style.display = 'none'; + }, + onError: function (msg) { + if (retryCount < maxRetries) { + retryCount++; + showLoading( + 'Reconnexion ' + retryCount + '/' + maxRetries + ); + setTimeout(buildPlayer, 1500); + } else { + showError(msg); + } + }, + onBlocked: function () { + hideLoading(); + playOverlay.style.display = 'flex'; + }, + onProfileChange: function (name) { + loadingHint.textContent = + name === 'safe' ? 'Réseau instable — buffer maximal' + : 'Réseau instable — buffer élargi'; + } + }); + window.xfPlayer = player; + player.start(); + } playOverlay.addEventListener('click', function () { video.play(); @@ -123,8 +147,7 @@ } }); - player.start(); - window.xfPlayer = player; + buildPlayer(); })(); diff --git a/web/xf-player-core.js b/web/xf-player-core.js index f408ff5..c37103f 100644 --- a/web/xf-player-core.js +++ b/web/xf-player-core.js @@ -55,6 +55,9 @@ var ORDER = ['fast', 'balanced', 'safe']; + // Erreurs MPEG-TS tolérées sur 60 s avant de basculer sur HLS. + var MPEGTS_MAX_ERRORS = 3; + // ------------------------------------------------------------------ // Chargement paresseux des libs (aucune requête inutile) // ------------------------------------------------------------------ @@ -178,6 +181,7 @@ this._started = false; this._stalls = 0; this._stallTimes = []; + this._mpegtsErrorTimes = []; this._destroyed = false; this._reportedTime = 0; this._hlsDuration = 0; @@ -317,10 +321,15 @@ self.hls = hls; global.hlsInstance = hls; // compat : code existant qui inspecte l'instance + // Faux tant que la première playlist n'a pas été obtenue : voir le + // traitement des erreurs réseau plus bas. + var manifestParsed = false; + hls.loadSource(self.url); hls.attachMedia(self.video); hls.on(Hls.Events.MANIFEST_PARSED, function () { + manifestParsed = true; // Démarrage immédiat : on ne sonde plus le buffer toutes les 200 ms. // Si le réseau ne suit pas, l'escalade de profil s'en chargera. self._attemptPlay(); @@ -337,6 +346,15 @@ if (!d.fatal) return; self.log('Erreur HLS fatale : ' + d.details); if (d.type === Hls.ErrorTypes.NETWORK_ERROR) { + // Playlist jamais obtenue (transcodeur en échec → 502, serveur trop + // chargé → délai dépassé), après les relances internes de hls.js. + // `startLoad()` ne relance pas ce chargement-là : le lecteur + // restait muet sur un spinner infini. On remonte l'erreur pour que + // la page relance un lecteur neuf ou l'affiche. + if (!manifestParsed) { + self.onError('Flux indisponible : le serveur n\'a pas pu le préparer'); + return; + } self._escalate('erreur réseau'); hls.startLoad(); } else if (d.type === Hls.ErrorTypes.MEDIA_ERROR) { @@ -446,6 +464,37 @@ player.on(mpegts.Events.ERROR, function (type, detail) { self.log('Erreur MPEG-TS : ' + type + ' / ' + detail); if (self._destroyed) return; + + // Chaque recréation relance un FFmpeg côté serveur : sur un serveur + // déjà saturé, recréer sans fin toutes les 1,2 s ne faisait + // qu'aggraver la charge. Au-delà de quelques échecs rapprochés, on + // passe par la route HLS, qui renvoie un vrai code d'erreur. + var now = Date.now(); + self._mpegtsErrorTimes = self._mpegtsErrorTimes.filter(function (t) { + return now - t < 60000; + }); + self._mpegtsErrorTimes.push(now); + if (self._mpegtsErrorTimes.length > MPEGTS_MAX_ERRORS) { + try { + player.unload(); + player.detachMediaElement(); + player.destroy(); + } catch (e) {} + self.mpegts = null; + + var hlsUrl = self._hlsEquivalent(self.url); + if (!hlsUrl) { + self.onError('Flux indisponible'); + return; + } + self.log('Erreurs MPEG-TS répétées — bascule HLS'); + self.onLoading('Ouverture du flux'); + self.url = hlsUrl; + if (self._canPlayNativeHls()) self._playDirect(hlsUrl); + else self._startHls(); + return; + } + self._escalate('erreur MPEG-TS'); // Recréation propre avec le nouveau profil. setTimeout(function () {