Lecture : repli CPU quand le GPU sature, plus de spinner infini

Serveur chargé ou VRAM limite : NVENC refuse d'ouvrir l'encodeur
(OpenEncodeSessionEx failed: out of memory), FFmpeg meurt en une
seconde et la playlist part en 502. Chaque relance retombait sur le
même GPU saturé : le flux restait illisible.

- Serveur : un échec imputable au GPU (NVENC, CUDA, mémoire) relance
  la session une fois en libx264, sous le même identifiant — les
  segments suivent sans que le lecteur s'en aperçoive. Direct, VOD et
  enregistrements
- Le GPU est écarté 2 min après une panne : les sessions suivantes
  partent directement sur le CPU au lieu d'échouer une à une
- Source injoignable ou délai dépassé : pas de repli, il doublerait la
  charge d'un serveur déjà occupé
- Process.start qui échoue (serveur à court de ressources) : 502/503
  au lieu d'une exception remontée en 500
- Client : une erreur réseau fatale avant la première playlist
  appelait hls.startLoad(), qui ne relance pas ce chargement → spinner
  infini, aucune erreur, relances de page jamais déclenchées. L'erreur
  remonte désormais à la page
- mpegts.js : au-delà de 3 erreurs en 60 s, bascule sur HLS au lieu de
  recréer sans fin (un FFmpeg neuf toutes les 1,2 s)
- player_lite.html : 3 relances avant l'erreur, comme les autres pages

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5.5 committed 2026-10-08 21:28:26 +02:00
1 parent 6d40c38d30
commit db13c85c1d
5 files changed
+340 -74

No files matched your search

+128 -53
View File
@@ -5,6 +5,7 @@ import 'package:http/http.dart' as http;
import '../database/database.dart'; import '../database/database.dart';
import '../models/playlist_config.dart'; import '../models/playlist_config.dart';
import '../services/ffmpeg_session_manager.dart'; import '../services/ffmpeg_session_manager.dart';
import '../services/gpu_fallback.dart';
import '../utils/log_redactor.dart'; import '../utils/log_redactor.dart';
import '../utils/media_probe.dart'; import '../utils/media_probe.dart';
import '../utils/stream_pipe.dart'; import '../utils/stream_pipe.dart';
@@ -17,6 +18,9 @@ final Directory _hlsTempDir =
/// Global FFmpeg session registry (initialized in [initStreaming]). /// Global FFmpeg session registry (initialized in [initStreaming]).
final FfmpegSessionManager sessionManager = FfmpegSessionManager(_hlsTempDir); 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) /// Helper to resolve FFmpeg path (SYSTEM PATH vs Portable)
String _getFFmpegPath() { String _getFFmpegPath() {
if (Platform.isWindows) { if (Platform.isWindows) {
@@ -181,6 +185,71 @@ Map<String, String> _segmentHeaders({int maxAge = 60}) => {
'Cache-Control': 'max-age=$maxAge', '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<String> 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<String> 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) // 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, id: sessionId,
isLive: true, isLive: true,
ffmpegPath: _getFFmpegPath(), wantGpu: useNvidiaGpu && quality != 'source',
argsBuilder: (dir) => [ buildArgs: (gpu) => [
'-hide_banner', '-loglevel', 'warning', '-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', '-headers', 'User-Agent: VLC/3.0.18 LibVLC/3.0.18\r\n',
'-reconnect', '1', '-reconnect_streamed', '1', '-reconnect', '1', '-reconnect_streamed', '1',
'-reconnect_at_eof', '1', '-reconnect_at_eof', '1',
@@ -330,7 +399,7 @@ Handler createLiveStreamHandler(
'-probesize', '1000000', '-probesize', '1000000',
'-analyzeduration', '1000000', '-analyzeduration', '1000000',
'-i', targetUrl, '-i', targetUrl,
..._liveVideoArgs(quality, useNvidiaGpu), ..._liveVideoArgs(quality, gpu),
..._audioArgs(withFilters: false), ..._audioArgs(withFilters: false),
// HLS sliding window: 10 x 2s segments (lower live latency than the // HLS sliding window: 10 x 2s segments (lower live latency than the
// previous 20-segment window while still safe for iOS). // previous 20-segment window while still safe for iOS).
@@ -347,8 +416,8 @@ Handler createLiveStreamHandler(
], ],
); );
final result = await sessionManager.waitForPlaylist(session); final session = result.session;
if (!result.ready) { if (!result.ready || session == null) {
sessionManager.killSession(sessionId); sessionManager.killSession(sessionId);
return Response(502, body: 'Live transcoder failed: ${result.error}'); return Response(502, body: 'Live transcoder failed: ${result.error}');
} }
@@ -400,26 +469,34 @@ Handler createLiveStreamHandler(
'[Live Turbo] $streamId: ${LogRedactor.redactUrl(targetUrl)}', '[Live Turbo] $streamId: ${LogRedactor.redactUrl(targetUrl)}',
); );
final process = await Process.start(_getFFmpegPath(), [ final Process process;
'-hide_banner', '-loglevel', 'warning', try {
'-headers', 'User-Agent: VLC/3.0.18 LibVLC/3.0.18\r\n', process = await Process.start(_getFFmpegPath(), [
'-reconnect', '1', '-reconnect_streamed', '1', '-hide_banner', '-loglevel', 'warning',
'-reconnect_at_eof', '1', '-headers', 'User-Agent: VLC/3.0.18 LibVLC/3.0.18\r\n',
'-reconnect_delay_max', '10', '-reconnect', '1', '-reconnect_streamed', '1',
'-rw_timeout', '30000000', '-reconnect_at_eof', '1',
// Démarrage rapide : ne pas bufferiser l'analyse, sonde réduite. '-reconnect_delay_max', '10',
'-fflags', 'nobuffer', '-rw_timeout', '30000000',
'-flags', 'low_delay', // Démarrage rapide : ne pas bufferiser l'analyse, sonde réduite.
'-probesize', '1000000', '-fflags', 'nobuffer',
'-analyzeduration', '1000000', '-flags', 'low_delay',
'-i', targetUrl, '-probesize', '1000000',
'-c:v', 'copy', '-analyzeduration', '1000000',
'-c:a', 'aac', '-b:a', '160k', '-ac', '2', '-ar', '48000', '-i', targetUrl,
'-af', 'aresample=async=1', '-c:v', 'copy',
// Pas de délai de mux : les paquets partent dès qu'ils existent. '-c:a', 'aac', '-b:a', '160k', '-ac', '2', '-ar', '48000',
'-muxdelay', '0', '-muxpreload', '0', '-af', 'aresample=async=1',
'-f', 'mpegts', 'pipe: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. // Journaliser les erreurs FFmpeg (redactées) sans bloquer le flux.
process.stderr.transform(const SystemEncoding().decoder).listen((line) { 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, id: sessionId,
isLive: false, isLive: false,
ffmpegPath: _getFFmpegPath(), wantGpu: useNvidiaGpu && quality != 'source',
argsBuilder: (dir) => [ buildArgs: (gpu) => [
'-hide_banner', '-loglevel', 'warning', '-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', '-headers', 'User-Agent: VLC/3.0.18 LibVLC/3.0.18\r\n',
'-reconnect', '1', '-reconnect', '1',
'-reconnect_at_eof', '1', '-reconnect_at_eof', '1',
@@ -555,7 +632,7 @@ Handler createVodStreamHandler(
'-analyzeduration', '2000000', '-analyzeduration', '2000000',
'-probesize', '5000000', '-probesize', '5000000',
'-i', targetUrl, '-i', targetUrl,
..._vodVideoArgs(quality, useNvidiaGpu), ..._vodVideoArgs(quality, gpu),
..._audioArgs(withFilters: quality != 'source'), ..._audioArgs(withFilters: quality != 'source'),
'-f', 'hls', '-f', 'hls',
'-hls_time', '4', '-hls_time', '4',
@@ -570,8 +647,8 @@ Handler createVodStreamHandler(
], ],
); );
final result = await sessionManager.waitForPlaylist(session); final session = result.session;
if (!result.ready) { if (!result.ready || session == null) {
sessionManager.killSession(sessionId); sessionManager.killSession(sessionId);
return Response(502, body: 'VOD transcoder failed: ${result.error}'); return Response(502, body: 'VOD transcoder failed: ${result.error}');
} }
@@ -677,9 +754,8 @@ Handler createRecordingStreamHandler(
final videoCodec = await MediaProbe.videoCodec(targetUrl); final videoCodec = await MediaProbe.videoCodec(targetUrl);
final canCopyVideo = videoCodec == 'h264'; final canCopyVideo = videoCodec == 'h264';
List<String> buildArgs({required bool copyVideo}) { List<String> buildArgs({required bool copyVideo, required bool gpu}) {
final useNvidiaGpu = final useNvidiaGpu = !copyVideo && gpu;
!copyVideo && (isGpuEnabled?.call() ?? _isNvidiaGpuEnabled());
return [ return [
'-hide_banner', '-loglevel', 'warning', '-hide_banner', '-loglevel', 'warning',
if (useNvidiaGpu) ...['-hwaccel', 'cuda'], if (useNvidiaGpu) ...['-hwaccel', 'cuda'],
@@ -724,9 +800,7 @@ Handler createRecordingStreamHandler(
/// au démarrage, la playlist étant servie dès le premier segment. En /// 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 /// copie vidéo elle est produite en une fraction de seconde, elle ne
/// coûte donc que sur un ré-encodage. /// coûte donc que sur un ré-encodage.
Future<({FfmpegSession session, bool ready, String? error})> run( Future<_SessionAttempt> run(bool copyVideo) {
bool copyVideo,
) async {
if (!sessionManager.contains(sessionId)) { if (!sessionManager.contains(sessionId)) {
print( print(
'[Recording] $sessionId : ' '[Recording] $sessionId : '
@@ -734,18 +808,20 @@ Handler createRecordingStreamHandler(
'(codec source : ${videoCodec ?? 'inconnu'})', '(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, id: sessionId,
isLive: false, isLive: false,
ffmpegPath: _getFFmpegPath(), wantGpu: isGpuEnabled?.call() ?? _isNvidiaGpuEnabled(),
argsBuilder: (dir) => buildArgs(copyVideo: copyVideo), buildArgs: (gpu) => buildArgs(copyVideo: false, gpu: gpu),
); minSegments: 3,
final outcome =
await sessionManager.waitForPlaylist(session, minSegments: 3);
return (
session: session,
ready: outcome.ready,
error: outcome.error,
); );
} }
@@ -763,14 +839,13 @@ Handler createRecordingStreamHandler(
attempt = await run(false); attempt = await run(false);
} }
if (!attempt.ready) { final session = attempt.session;
if (!attempt.ready || session == null) {
sessionManager.killSession(sessionId); sessionManager.killSession(sessionId);
return Response(502, return Response(502,
body: 'Recording transcoder failed: ${attempt.error}'); body: 'Recording transcoder failed: ${attempt.error}');
} }
final session = attempt.session;
session.touch(); session.touch();
// Ménage des offsets abandonnés (aller-retours dans la barre de // Ménage des offsets abandonnés (aller-retours dans la barre de
// progression), une fois la nouvelle session confirmée démarrée. // progression), une fois la nouvelle session confirmée démarrée.
+43
View File
@@ -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);
}
+76
View File
@@ -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);
});
});
}
+44 -21
View File
@@ -90,25 +90,49 @@
playOverlay.style.display = 'none'; playOverlay.style.display = 'none';
} }
var player = new XFPlayer({ // Un serveur chargé peut rater le démarrage du transcodeur : on
video: video, // relance un lecteur neuf avant d'afficher l'erreur, comme le
logPrefix: '[LitePlayer]', // font déjà player.html et player_mobile.html.
onLoading: showLoading, var retryCount = 0;
onReady: function () { var maxRetries = 3;
hideLoading(); var player = null;
playOverlay.style.display = 'none';
}, function buildPlayer() {
onError: showError, if (player) { try { player.destroy(); } catch (e) {} }
onBlocked: function () {
hideLoading(); player = new XFPlayer({
playOverlay.style.display = 'flex'; video: video,
}, logPrefix: '[LitePlayer]',
onProfileChange: function (name) { onLoading: showLoading,
loadingHint.textContent = onReady: function () {
name === 'safe' ? 'Réseau instable — buffer maximal' hideLoading();
: 'Réseau instable — buffer élargi'; 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 () { playOverlay.addEventListener('click', function () {
video.play(); video.play();
@@ -123,8 +147,7 @@
} }
}); });
player.start(); buildPlayer();
window.xfPlayer = player;
})(); })();
</script> </script>
</body> </body>
+49
View File
@@ -55,6 +55,9 @@
var ORDER = ['fast', 'balanced', 'safe']; 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) // Chargement paresseux des libs (aucune requête inutile)
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@@ -178,6 +181,7 @@
this._started = false; this._started = false;
this._stalls = 0; this._stalls = 0;
this._stallTimes = []; this._stallTimes = [];
this._mpegtsErrorTimes = [];
this._destroyed = false; this._destroyed = false;
this._reportedTime = 0; this._reportedTime = 0;
this._hlsDuration = 0; this._hlsDuration = 0;
@@ -317,10 +321,15 @@
self.hls = hls; self.hls = hls;
global.hlsInstance = hls; // compat : code existant qui inspecte l'instance 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.loadSource(self.url);
hls.attachMedia(self.video); hls.attachMedia(self.video);
hls.on(Hls.Events.MANIFEST_PARSED, function () { hls.on(Hls.Events.MANIFEST_PARSED, function () {
manifestParsed = true;
// Démarrage immédiat : on ne sonde plus le buffer toutes les 200 ms. // 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. // Si le réseau ne suit pas, l'escalade de profil s'en chargera.
self._attemptPlay(); self._attemptPlay();
@@ -337,6 +346,15 @@
if (!d.fatal) return; if (!d.fatal) return;
self.log('Erreur HLS fatale : ' + d.details); self.log('Erreur HLS fatale : ' + d.details);
if (d.type === Hls.ErrorTypes.NETWORK_ERROR) { 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'); self._escalate('erreur réseau');
hls.startLoad(); hls.startLoad();
} else if (d.type === Hls.ErrorTypes.MEDIA_ERROR) { } else if (d.type === Hls.ErrorTypes.MEDIA_ERROR) {
@@ -446,6 +464,37 @@
player.on(mpegts.Events.ERROR, function (type, detail) { player.on(mpegts.Events.ERROR, function (type, detail) {
self.log('Erreur MPEG-TS : ' + type + ' / ' + detail); self.log('Erreur MPEG-TS : ' + type + ' / ' + detail);
if (self._destroyed) return; 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'); self._escalate('erreur MPEG-TS');
// Recréation propre avec le nouveau profil. // Recréation propre avec le nouveau profil.
setTimeout(function () { setTimeout(function () {