mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-11 17:30:00 +02:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0539ad857f | ||
|
|
a739d6451c | ||
|
|
775caad0ee |
No files matched your search
@@ -0,0 +1,70 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:shelf/shelf.dart';
|
||||
|
||||
/// Journal des incidents de lecture remontés par le lecteur web.
|
||||
///
|
||||
/// POURQUOI : les freezes (image et son figés puis reprise) se produisent
|
||||
/// chez l'utilisateur, dans son navigateur ; le serveur n'en voit rien. Le
|
||||
/// lecteur envoie ici chaque coupure, erreur, recréation et saut, et le
|
||||
/// serveur les écrit dans `docker logs` (`[Player] …`) : on sait alors si un
|
||||
/// freeze vient d'un buffer à sec, d'une erreur du démuxeur ou d'une
|
||||
/// relance du flux.
|
||||
///
|
||||
/// Entrée non fiable : seuls des champs connus, courts et typés passent.
|
||||
|
||||
const _events = {
|
||||
'start', 'first_frame', 'stall', 'seek', 'mpegts_error', 'recreate',
|
||||
'fallback_hls', 'hls_error', 'profile', 'error_shown', 'heartbeat',
|
||||
};
|
||||
|
||||
const _numericFields = {
|
||||
't', 'ms', 'buffer', 'pos', 'from', 'to', 'stalls', 'stallMs', 'seeks',
|
||||
'errors', 'rate',
|
||||
};
|
||||
|
||||
const _textFields = {'session', 'ch', 'mode', 'profile', 'detail'};
|
||||
|
||||
/// Ligne de journal pour l'événement [raw], ou null s'il est invalide.
|
||||
String? formatPlayerEvent(Object? raw) {
|
||||
if (raw is! Map) return null;
|
||||
final event = raw['event'];
|
||||
if (event is! String || !_events.contains(event)) return null;
|
||||
|
||||
final parts = <String>[event];
|
||||
for (final key in _textFields) {
|
||||
final value = raw[key];
|
||||
if (value is String && value.isNotEmpty) {
|
||||
// Ni espace, ni retour ligne, ni URL : jamais d'injection de faux
|
||||
// journaux, jamais d'identifiants recopiés depuis une URL d'erreur.
|
||||
final safe = value
|
||||
.replaceAll(RegExp(r'https?://\S+'), '<url>')
|
||||
.replaceAll(RegExp(r'[^\w.\-:/<>]'), '_');
|
||||
parts.add('$key=${safe.length > 60 ? safe.substring(0, 60) : safe}');
|
||||
}
|
||||
}
|
||||
for (final key in _numericFields) {
|
||||
final value = raw[key];
|
||||
if (value is num && value.isFinite) {
|
||||
parts.add('$key=${value is int ? value : value.toStringAsFixed(1)}');
|
||||
}
|
||||
}
|
||||
return '[Player] ${parts.join(' ')}';
|
||||
}
|
||||
|
||||
Future<Response> handlePlayerLog(Request request) async {
|
||||
final body = await request.readAsString();
|
||||
if (body.length > 4096) return Response(413);
|
||||
Object? decoded;
|
||||
try {
|
||||
decoded = jsonDecode(body);
|
||||
} catch (_) {
|
||||
return Response.badRequest();
|
||||
}
|
||||
final events = decoded is List ? decoded.take(20) : [decoded];
|
||||
for (final event in events) {
|
||||
final line = formatPlayerEvent(event);
|
||||
if (line != null) print(line);
|
||||
}
|
||||
return Response(204);
|
||||
}
|
||||
+80
-129
@@ -9,6 +9,7 @@ import '../services/gpu_fallback.dart';
|
||||
import '../services/upstream_slots.dart';
|
||||
import '../utils/log_redactor.dart';
|
||||
import '../utils/media_probe.dart';
|
||||
import '../utils/stream_pipe.dart';
|
||||
import 'recording_playlist.dart';
|
||||
|
||||
/// Directory for temporary HLS segments
|
||||
@@ -440,46 +441,6 @@ Stream<List<int>> _resilientLiveBody(
|
||||
}
|
||||
}
|
||||
|
||||
/// URL d'une chaîne chez le panneau, en HLS (`.m3u8`) ou en flux continu
|
||||
/// (`.ts`).
|
||||
String _liveSourceUrl(PlaylistConfig p, String streamId, {required bool hls}) =>
|
||||
'${p.dns}/live/${p.username}/${p.password}/$streamId.${hls ? 'm3u8' : 'ts'}';
|
||||
|
||||
/// Arguments d'entrée FFmpeg pour une chaîne du panneau.
|
||||
///
|
||||
/// POURQUOI le HLS d'abord : mesuré en prod (sonde admin), le panneau met
|
||||
/// 4,2 à 4,4 s à répondre la redirection d'un `.ts`, contre 0,2 à 1,3 s pour
|
||||
/// un `.m3u8` dont le premier segment arrive ensuite en 50 ms — un zap ~4×
|
||||
/// plus rapide. La playlist du panneau garde en plus ~60 s de segments :
|
||||
/// `-live_start_index -2` en récupère deux d'un coup, ce qui donne au
|
||||
/// lecteur une avance immédiate pour absorber les à-coups.
|
||||
///
|
||||
/// Pas de `-reconnect_at_eof` en HLS : chaque segment se termine par une
|
||||
/// fin de fichier, l'option provoquerait une reconnexion à chaque segment
|
||||
/// (leçon tirée de xtremobile, où elle causait gels et sauts).
|
||||
List<String> liveInputArgs(String url, {required bool hls}) => [
|
||||
'-headers', 'User-Agent: VLC/3.0.18 LibVLC/3.0.18\r\n',
|
||||
if (hls) ...[
|
||||
'-reconnect', '1',
|
||||
'-reconnect_on_network_error', '1',
|
||||
'-reconnect_delay_max', '5',
|
||||
'-live_start_index', '-2',
|
||||
] else ...[
|
||||
'-reconnect', '1', '-reconnect_streamed', '1',
|
||||
'-reconnect_at_eof', '1',
|
||||
'-reconnect_delay_max', '10',
|
||||
],
|
||||
// Abort reads stuck for 30s so a stalled upstream triggers the
|
||||
// reconnect logic instead of wedging the process forever.
|
||||
'-rw_timeout', '30000000',
|
||||
// Démarrage rapide : sans borne, FFmpeg peut passer plusieurs
|
||||
// secondes à sonder le flux avant d'écrire la première sortie.
|
||||
'-fflags', 'nobuffer',
|
||||
'-probesize', '1000000',
|
||||
'-analyzeduration', '1000000',
|
||||
'-i', url,
|
||||
];
|
||||
|
||||
Handler createLiveStreamHandler(
|
||||
Future<PlaylistConfig?> Function(Request) getPlaylist, {
|
||||
bool Function()? isGpuEnabled,
|
||||
@@ -497,14 +458,18 @@ Handler createLiveStreamHandler(
|
||||
final playlist = await getPlaylist(request);
|
||||
if (playlist == null) return Response.forbidden('No playlist');
|
||||
|
||||
final targetUrl =
|
||||
'${playlist.dns}/live/${playlist.username}/${playlist.password}/$streamId.ts';
|
||||
final useNvidiaGpu = isGpuEnabled?.call() ?? _isNvidiaGpuEnabled();
|
||||
final sessionId = 'live_${streamId}_$quality';
|
||||
|
||||
if (!sessionManager.contains(sessionId)) {
|
||||
print('[Live HLS] Starting $sessionId');
|
||||
print(
|
||||
'[Live HLS] Starting $sessionId: ${LogRedactor.redactUrl(targetUrl)}',
|
||||
);
|
||||
}
|
||||
|
||||
Future<_SessionAttempt> run({required bool hls}) => _runWithGpuFallback(
|
||||
final result = await _runWithGpuFallback(
|
||||
id: sessionId,
|
||||
isLive: true,
|
||||
upstream: playlist,
|
||||
@@ -512,10 +477,19 @@ Handler createLiveStreamHandler(
|
||||
buildArgs: (gpu) => [
|
||||
'-hide_banner', '-loglevel', 'warning',
|
||||
if (gpu) ...['-hwaccel', 'cuda'],
|
||||
...liveInputArgs(
|
||||
_liveSourceUrl(playlist, streamId, hls: hls),
|
||||
hls: hls,
|
||||
),
|
||||
'-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',
|
||||
// Abort reads stuck for 30s so a stalled upstream triggers the
|
||||
// reconnect logic instead of wedging the process forever.
|
||||
'-rw_timeout', '30000000',
|
||||
// Démarrage rapide : sans borne, FFmpeg peut passer plusieurs
|
||||
// secondes à sonder le flux avant d'écrire le premier segment.
|
||||
'-fflags', 'nobuffer',
|
||||
'-probesize', '1000000',
|
||||
'-analyzeduration', '1000000',
|
||||
'-i', targetUrl,
|
||||
..._liveVideoArgs(quality, gpu),
|
||||
..._audioArgs(withFilters: false),
|
||||
// HLS sliding window: 10 x 2s segments (lower live latency than the
|
||||
@@ -533,15 +507,6 @@ Handler createLiveStreamHandler(
|
||||
],
|
||||
);
|
||||
|
||||
var result = await run(hls: true);
|
||||
if (!result.ready) {
|
||||
// Panneau sans HLS pour cette chaîne (ou HLS en panne) : repli sur le
|
||||
// flux continu, plus lent à ouvrir mais universel.
|
||||
print('[Live HLS] $sessionId : HLS du panneau indisponible — repli .ts');
|
||||
sessionManager.killSession(sessionId);
|
||||
result = await run(hls: false);
|
||||
}
|
||||
|
||||
final session = result.session;
|
||||
if (!result.ready || session == null) {
|
||||
sessionManager.killSession(sessionId);
|
||||
@@ -589,93 +554,79 @@ Handler createLiveStreamHandler(
|
||||
final playlist = await getPlaylist(request);
|
||||
if (playlist == null) return Response.forbidden('No playlist');
|
||||
|
||||
print('[Live Turbo] $streamId');
|
||||
final targetUrl =
|
||||
'${playlist.dns}/live/${playlist.username}/${playlist.password}/$streamId.ts';
|
||||
print(
|
||||
'[Live Turbo] $streamId: ${LogRedactor.redactUrl(targetUrl)}',
|
||||
);
|
||||
|
||||
// Un identifiant par requête : deux onglets sur la même chaîne sont
|
||||
// deux connexions distinctes chez le fournisseur.
|
||||
final upstreamId = 'turbo_${streamId}_${++_turboCounter}';
|
||||
await _claimUpstream(playlist, upstreamId);
|
||||
|
||||
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.
|
||||
// `ProcessException` liste les arguments, donc l'URL `-i` du panneau.
|
||||
print('[Live Turbo] $streamId : FFmpeg n\'a pas démarré '
|
||||
'(${LogRedactor.redactUrl('$e')})');
|
||||
return Response(503, body: 'FFmpeg start failed');
|
||||
}
|
||||
|
||||
// Dernier octet remis au client : un lecteur en vie en reçoit à chaque
|
||||
// rafale ; un client parti (zap mal détecté derrière le proxy) n'en
|
||||
// reçoit plus, la contre-pression bloquant FFmpeg.
|
||||
var lastDelivered = DateTime.now();
|
||||
_registerUpstream(
|
||||
playlist,
|
||||
upstreamId,
|
||||
process,
|
||||
release: () => process.kill(ProcessSignal.sigterm),
|
||||
lastActivity: () => lastDelivered,
|
||||
);
|
||||
|
||||
Future<Process?> start({required bool hls}) async {
|
||||
final Process process;
|
||||
try {
|
||||
process = await Process.start(_getFFmpegPath(), [
|
||||
'-hide_banner', '-loglevel', 'warning',
|
||||
...liveInputArgs(
|
||||
_liveSourceUrl(playlist, streamId, hls: hls),
|
||||
hls: hls,
|
||||
),
|
||||
'-flags', 'low_delay',
|
||||
'-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) {
|
||||
// `ProcessException` liste les arguments, donc l'URL `-i` du panneau.
|
||||
print('[Live Turbo] $streamId : FFmpeg n\'a pas démarré '
|
||||
'(${LogRedactor.redactUrl('$e')})');
|
||||
return null;
|
||||
// Journaliser les erreurs FFmpeg (redactées) sans bloquer le flux.
|
||||
process.stderr.transform(const SystemEncoding().decoder).listen((line) {
|
||||
final trimmed = line.trim();
|
||||
if (trimmed.isNotEmpty) {
|
||||
print('[Live Turbo] $streamId ffmpeg: '
|
||||
'${LogRedactor.redactUrl(trimmed)}');
|
||||
}
|
||||
_registerUpstream(
|
||||
playlist,
|
||||
upstreamId,
|
||||
process,
|
||||
release: () => process.kill(ProcessSignal.sigterm),
|
||||
lastActivity: () => lastDelivered,
|
||||
);
|
||||
// Journaliser les erreurs FFmpeg (redactées) sans bloquer le flux.
|
||||
process.stderr.transform(const SystemEncoding().decoder).listen((line) {
|
||||
final trimmed = line.trim();
|
||||
if (trimmed.isNotEmpty) {
|
||||
print('[Live Turbo] $streamId ffmpeg: '
|
||||
'${LogRedactor.redactUrl(trimmed)}');
|
||||
}
|
||||
});
|
||||
return process;
|
||||
}
|
||||
});
|
||||
|
||||
final first = await start(hls: true);
|
||||
// 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.
|
||||
if (first == null) return Response(503, body: 'FFmpeg start failed');
|
||||
|
||||
// Relais vers le client. Un générateur `async*` garde la contre-pression
|
||||
// (`yield` attend tant que le client ne lit pas) et son `finally` tue
|
||||
// FFmpeg dès que le client zappe ou ferme l'onglet.
|
||||
//
|
||||
// Si le HLS du panneau n'a livré aucun octet (format refusé pour cette
|
||||
// chaîne, playlist vide), on rebascule sur le `.ts` dans la MÊME
|
||||
// réponse : le lecteur ne voit qu'un démarrage un peu plus long.
|
||||
Stream<List<int>> relay() async* {
|
||||
Process? process = first;
|
||||
var hls = true;
|
||||
try {
|
||||
while (process != null) {
|
||||
var delivered = false;
|
||||
await for (final chunk in process.stdout) {
|
||||
delivered = true;
|
||||
lastDelivered = DateTime.now();
|
||||
yield chunk;
|
||||
}
|
||||
if (delivered || !hls) break;
|
||||
print('[Live Turbo] $streamId : HLS du panneau muet — repli .ts');
|
||||
hls = false;
|
||||
process = await start(hls: false);
|
||||
}
|
||||
} finally {
|
||||
process?.kill(ProcessSignal.sigterm);
|
||||
}
|
||||
}
|
||||
|
||||
final body = relay();
|
||||
// Relayer stdout vers le client en gardant la contre-pression, et tuer
|
||||
// FFmpeg dès que le client zappe ou ferme l'onglet (sinon les processus
|
||||
// s'accumulent à chaque zap).
|
||||
final body = pipeWithBackpressure(
|
||||
process.stdout,
|
||||
onStop: () => process.kill(ProcessSignal.sigterm),
|
||||
).map((chunk) {
|
||||
lastDelivered = DateTime.now();
|
||||
return chunk;
|
||||
});
|
||||
|
||||
return Response(
|
||||
200,
|
||||
|
||||
@@ -29,6 +29,7 @@ import 'services/cleanup_service.dart';
|
||||
import 'services/recording_scheduler.dart';
|
||||
import 'utils/asset_versioning.dart';
|
||||
import 'api/upstream_probe.dart';
|
||||
import 'api/player_log.dart';
|
||||
|
||||
void main(List<String> args) async {
|
||||
// Parse command line arguments
|
||||
@@ -221,6 +222,14 @@ void main(List<String> args) async {
|
||||
.addMiddleware(authMiddleware(db))
|
||||
.addHandler(logoApi.handle),
|
||||
)
|
||||
// Incidents de lecture remontés par le lecteur web (freezes, erreurs),
|
||||
// écrits dans les logs pour le diagnostic.
|
||||
..post(
|
||||
'/api/player-log',
|
||||
const Pipeline()
|
||||
.addMiddleware(authMiddleware(db))
|
||||
.addHandler(handlePlayerLog),
|
||||
)
|
||||
// EPG - guide TV (auth required)
|
||||
..mount(
|
||||
'/api/epg',
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import 'package:test/test.dart';
|
||||
import '../api/streaming_handler.dart';
|
||||
|
||||
void main() {
|
||||
group('liveInputArgs', () {
|
||||
test('HLS : jamais de reconnect_at_eof (chaque segment finit par un EOF)',
|
||||
() {
|
||||
final args = liveInputArgs('http://p/live/u/p/1.m3u8', hls: true);
|
||||
expect(args, isNot(contains('-reconnect_at_eof')));
|
||||
expect(args, isNot(contains('-reconnect_streamed')));
|
||||
});
|
||||
|
||||
test('HLS : démarre deux segments avant le direct pour avoir de l\'avance',
|
||||
() {
|
||||
final args = liveInputArgs('http://p/live/u/p/1.m3u8', hls: true);
|
||||
final i = args.indexOf('-live_start_index');
|
||||
expect(i, isNot(-1));
|
||||
expect(args[i + 1], '-2');
|
||||
// Option de démuxeur : doit précéder -i.
|
||||
expect(i, lessThan(args.indexOf('-i')));
|
||||
});
|
||||
|
||||
test('.ts : garde la reconnexion en fin de flux (coupures du panneau)', () {
|
||||
final args = liveInputArgs('http://p/live/u/p/1.ts', hls: false);
|
||||
expect(args, contains('-reconnect_at_eof'));
|
||||
expect(args, isNot(contains('-live_start_index')));
|
||||
});
|
||||
|
||||
test('l\'URL est la dernière entrée, juste après -i', () {
|
||||
final args = liveInputArgs('http://p/x.m3u8', hls: true);
|
||||
expect(args.last, 'http://p/x.m3u8');
|
||||
expect(args[args.length - 2], '-i');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'package:test/test.dart';
|
||||
import '../api/player_log.dart';
|
||||
|
||||
void main() {
|
||||
group('formatPlayerEvent', () {
|
||||
test('écrit une coupure avec sa durée et le buffer', () {
|
||||
expect(
|
||||
formatPlayerEvent({
|
||||
'event': 'stall',
|
||||
'session': 'ab12',
|
||||
'ch': '554034',
|
||||
'ms': 5230,
|
||||
'buffer': 0.04,
|
||||
}),
|
||||
'[Player] stall session=ab12 ch=554034 ms=5230 buffer=0.0',
|
||||
);
|
||||
});
|
||||
|
||||
test('refuse un événement inconnu ou un payload invalide', () {
|
||||
expect(formatPlayerEvent({'event': 'rm -rf'}), isNull);
|
||||
expect(formatPlayerEvent('stall'), isNull);
|
||||
expect(formatPlayerEvent(null), isNull);
|
||||
});
|
||||
|
||||
test('pas d\'injection de lignes ni d\'URL (identifiants Xtream)', () {
|
||||
final line = formatPlayerEvent({
|
||||
'event': 'mpegts_error',
|
||||
'detail': 'NetworkError http://panel/live/user/pass/1.ts\n[Player] faux',
|
||||
})!;
|
||||
expect(line, isNot(contains('\n')));
|
||||
expect(line, isNot(contains('user/pass')));
|
||||
expect(line, contains('<url>'));
|
||||
});
|
||||
|
||||
test('ignore les champs inattendus et les nombres non finis', () {
|
||||
final line = formatPlayerEvent({
|
||||
'event': 'seek',
|
||||
'from': 10,
|
||||
'to': double.infinity,
|
||||
'cookie': 'secret',
|
||||
})!;
|
||||
expect(line, contains('from=10'));
|
||||
expect(line, isNot(contains('to=')));
|
||||
expect(line, isNot(contains('secret')));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -101,6 +101,113 @@ test('live : le retard est résorbé en douceur (liveSync) plutôt que par sauts
|
||||
assert.ok(c.liveSyncMaxLatency <= c.liveBufferLatencyMaxLatency);
|
||||
});
|
||||
|
||||
test('télémétrie : une coupure (waiting → playing) est remontée avec sa durée', async () => {
|
||||
const sent = [];
|
||||
const listeners = {};
|
||||
const video = {
|
||||
addEventListener(type, fn) { (listeners[type] ||= []).push(fn); },
|
||||
removeEventListener() {},
|
||||
canPlayType: () => '',
|
||||
play: () => Promise.resolve(),
|
||||
buffered: { length: 0 },
|
||||
seekable: { length: 0 },
|
||||
currentTime: 12,
|
||||
playbackRate: 1,
|
||||
};
|
||||
const fire = (type) => (listeners[type] || []).forEach((fn) => fn());
|
||||
const window = {
|
||||
addEventListener() {}, removeEventListener() {},
|
||||
console: { log() {} },
|
||||
location: { origin: 'https://xf.test', href: 'https://xf.test/', search: '' },
|
||||
parent: { postMessage() {} },
|
||||
localStorage: { getItem: () => 'tok' },
|
||||
};
|
||||
let now = 1000;
|
||||
const context = vm.createContext({
|
||||
window,
|
||||
location: window.location,
|
||||
document: { addEventListener() {}, removeEventListener() {} },
|
||||
URLSearchParams,
|
||||
setInterval: () => 0,
|
||||
clearInterval() {},
|
||||
setTimeout,
|
||||
Date: { now: () => now },
|
||||
fetch: (url, opts) => { sent.push({ url, body: JSON.parse(opts.body) }); return Promise.resolve(); },
|
||||
});
|
||||
vm.runInContext(source, context);
|
||||
const player = new window.XFPlayer({ video, url: '/api/live/554034/turbo.ts', type: 'live' });
|
||||
player._wireVideoEvents();
|
||||
|
||||
fire('playing'); // premier affichage
|
||||
now += 1000; fire('waiting');
|
||||
now += 4200; fire('playing'); // reprise 4,2 s plus tard
|
||||
|
||||
const stall = sent.map((s) => s.body).find((b) => b.event === 'stall');
|
||||
assert.ok(stall, 'aucun événement stall envoyé');
|
||||
assert.equal(stall.ms, 4200);
|
||||
assert.equal(stall.ch, '554034');
|
||||
assert.equal(sent[0].url, '/api/player-log');
|
||||
});
|
||||
|
||||
// Freeze mesuré en prod (télémétrie) : le panneau s'est tu ~15-20 s sur une
|
||||
// connexion restée ouverte, alors que le lecteur n'avait gardé que 11-16 s.
|
||||
const PROVIDER_OUTAGE_S = 18;
|
||||
|
||||
test('live : la réserve visée couvre un silence du panneau de ~18 s', () => {
|
||||
const c = liveMpegtsConfig();
|
||||
// liveSync ramène le retard vers la cible : c'est la réserve en régime
|
||||
// établi. Elle doit dépasser le silence mesuré.
|
||||
assert.ok(c.liveSyncTargetLatency > PROVIDER_OUTAGE_S,
|
||||
`cible ${c.liveSyncTargetLatency}s < silence mesuré ${PROVIDER_OUTAGE_S}s`);
|
||||
assert.ok(c.liveBufferLatencyMinRemain > PROVIDER_OUTAGE_S);
|
||||
});
|
||||
|
||||
test('reprise après coupure : attendre une vraie réserve avant de relire', () => {
|
||||
const calls = [];
|
||||
const listeners = {};
|
||||
let ahead = 0;
|
||||
const video = {
|
||||
addEventListener(type, fn) { (listeners[type] ||= []).push(fn); },
|
||||
removeEventListener() {},
|
||||
canPlayType: () => '',
|
||||
play: () => { calls.push('play'); return Promise.resolve(); },
|
||||
pause: () => { calls.push('pause'); },
|
||||
get buffered() { return { length: 1, start: () => 0, end: () => 100 + ahead }; },
|
||||
seekable: { length: 0 },
|
||||
currentTime: 100,
|
||||
playbackRate: 1,
|
||||
seeking: false,
|
||||
};
|
||||
const fire = (type) => (listeners[type] || []).forEach((fn) => fn());
|
||||
const window = {
|
||||
addEventListener() {}, removeEventListener() {},
|
||||
console: { log() {} },
|
||||
location: { origin: 'https://xf.test', href: 'https://xf.test/', search: '' },
|
||||
parent: { postMessage() {} },
|
||||
};
|
||||
const context = vm.createContext({
|
||||
window, location: window.location,
|
||||
document: { addEventListener() {}, removeEventListener() {} },
|
||||
URLSearchParams, setInterval: () => 0, clearInterval() {}, setTimeout, Date,
|
||||
});
|
||||
vm.runInContext(source, context);
|
||||
const player = new window.XFPlayer({ video, url: '/api/live/1/turbo.ts', type: 'live' });
|
||||
player.mpegts = {}; // flux MPEG-TS en cours
|
||||
player._wireVideoEvents();
|
||||
|
||||
fire('playing');
|
||||
fire('waiting'); // le panneau se tait
|
||||
assert.deepEqual(calls.slice(-1), ['pause'], 'le lecteur doit se mettre en attente');
|
||||
|
||||
ahead = 1; // 1 s arrive : trop peu, ne pas repartir
|
||||
player._checkHold();
|
||||
assert.ok(!calls.slice(1).includes('play'), 'reparti avec 1 s : recoupera aussitôt');
|
||||
|
||||
ahead = 5; // vraie réserve reconstituée
|
||||
player._checkHold();
|
||||
assert.equal(calls.at(-1), 'play');
|
||||
});
|
||||
|
||||
test('live : le buffer déjà lu est purgé (sinon QuotaExceeded en séance longue)', () => {
|
||||
const c = liveMpegtsConfig();
|
||||
assert.equal(c.autoCleanupSourceBuffer, true);
|
||||
|
||||
@@ -141,6 +141,12 @@
|
||||
|
||||
// Contrat historique du lite : mise en pause manuelle → overlay lecture.
|
||||
video.addEventListener('pause', function () {
|
||||
// Pause technique : le lecteur attend que la réserve se
|
||||
// reconstitue après une coupure, il repartira seul.
|
||||
if (player && player._holding) {
|
||||
showLoading('Reconstitution de la réserve');
|
||||
return;
|
||||
}
|
||||
if (video.currentTime === 0 || video.paused) {
|
||||
loading.style.display = 'none';
|
||||
playOverlay.style.display = 'flex';
|
||||
|
||||
+176
-7
@@ -58,6 +58,14 @@
|
||||
// Erreurs MPEG-TS tolérées sur 60 s avant de basculer sur HLS.
|
||||
var MPEGTS_MAX_ERRORS = 3;
|
||||
|
||||
// Après une coupure en direct, réserve à reconstituer avant de relire.
|
||||
// Repartir dès la première seconde reçue transformait un silence du
|
||||
// panneau en cascade de micro-coupures (6,3 s puis 1,7 s puis 2,0 s,
|
||||
// mesuré) ; comme mpv (cache-pause), on attend une vraie réserve.
|
||||
var REBUFFER_SECONDS = 4;
|
||||
// Au-delà, on relance quand même avec ce qu'on a.
|
||||
var REBUFFER_MAX_WAIT_MS = 15000;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Chargement paresseux des libs (aucune requête inutile)
|
||||
// ------------------------------------------------------------------
|
||||
@@ -190,8 +198,75 @@
|
||||
// ça, une relance après échec laisserait l'ancienne instance réagir aux
|
||||
// commandes du parent et republier des positions périmées.
|
||||
this._listeners = [];
|
||||
|
||||
// Télémétrie des incidents (voir _report) : chaque freeze vécu par
|
||||
// l'utilisateur finit dans les logs du serveur, avec sa cause.
|
||||
this._t0 = Date.now();
|
||||
this._session = Math.random().toString(36).slice(2, 8);
|
||||
var idMatch = /\/api\/(?:live|vod|recordings\/stream)\/([^/?#.]+)/.exec(this.url || '');
|
||||
this._channel = idMatch ? idMatch[1] : '';
|
||||
this._stallSince = null;
|
||||
this._stats = { stalls: 0, stallMs: 0, seeks: 0, errors: 0 };
|
||||
this._lastPos = 0;
|
||||
var self = this;
|
||||
var userOnError = this.onError;
|
||||
this.onError = function (msg) {
|
||||
self._report('error_shown', { detail: String(msg) });
|
||||
userOnError(msg);
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------- Télémétrie ----------------
|
||||
|
||||
/** Secondes d'avance dans le buffer à la position courante. */
|
||||
XFPlayer.prototype._bufferAhead = function () {
|
||||
var v = this.video;
|
||||
try {
|
||||
for (var i = 0; i < v.buffered.length; i++) {
|
||||
if (v.buffered.start(i) <= v.currentTime + 0.5 &&
|
||||
v.buffered.end(i) >= v.currentTime) {
|
||||
return v.buffered.end(i) - v.currentTime;
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
return 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Remonte un incident au serveur (`POST /api/player-log`, écrit dans les
|
||||
* logs `[Player] …`). Jamais bloquant : erreurs ignorées, aucun envoi
|
||||
* sans session.
|
||||
*/
|
||||
XFPlayer.prototype._report = function (event, data) {
|
||||
try {
|
||||
var token = null;
|
||||
try { token = global.localStorage.getItem('auth_token'); } catch (e) {}
|
||||
if (!token || typeof fetch !== 'function') return;
|
||||
var payload = {
|
||||
event: event,
|
||||
session: this._session,
|
||||
ch: this._channel,
|
||||
t: (Date.now() - this._t0) / 1000,
|
||||
mode: this.hls ? 'hls' : (this.mpegts ? 'ts' : 'direct'),
|
||||
profile: this.profile.name,
|
||||
buffer: this._bufferAhead(),
|
||||
pos: this.video.currentTime || 0
|
||||
};
|
||||
for (var k in data) {
|
||||
if (Object.prototype.hasOwnProperty.call(data, k)) payload[k] = data[k];
|
||||
}
|
||||
fetch('/api/player-log', {
|
||||
method: 'POST',
|
||||
keepalive: true,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ' + token
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
}).catch(function () {});
|
||||
} catch (e) {}
|
||||
};
|
||||
|
||||
/** addEventListener + mémorisation pour un retrait propre. */
|
||||
XFPlayer.prototype._on = function (target, type, handler, options) {
|
||||
target.addEventListener(type, handler, options);
|
||||
@@ -224,6 +299,7 @@
|
||||
var isLive = this.type === 'live';
|
||||
|
||||
this.onLoading('Ouverture du flux');
|
||||
this._report('start', {});
|
||||
|
||||
// Safari / iOS : HLS natif = le chemin le plus rapide, aucune lib à charger.
|
||||
if (isHls && this._canPlayNativeHls()) {
|
||||
@@ -345,6 +421,8 @@
|
||||
hls.on(Hls.Events.ERROR, function (e, d) {
|
||||
if (!d.fatal) return;
|
||||
self.log('Erreur HLS fatale : ' + d.details);
|
||||
self._stats.errors++;
|
||||
self._report('hls_error', { detail: d.type + '/' + 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.
|
||||
@@ -427,14 +505,21 @@
|
||||
// coupure toutes les 3-4 s et contenu sauté (12 sauts et 14 coupures
|
||||
// en 30 s sur une chaîne FHD). La marge de 12 s couvre deux silences ;
|
||||
// le saut ne sert plus qu'à borner un retard réellement accumulé.
|
||||
//
|
||||
// 25 s et non plus 12 : la télémétrie a mesuré un silence du panneau
|
||||
// de ~15-20 s sur une connexion restée ouverte, alors que le lecteur
|
||||
// n'avait gardé que 11-16 s — il venait de « consommer » à x1,1
|
||||
// l'avance que le panneau envoie au démarrage. On la garde : ~25 s
|
||||
// de retard sur le direct, en échange d'une lecture qui traverse ces
|
||||
// silences sans figer.
|
||||
liveBufferLatencyChasing: isLive,
|
||||
liveBufferLatencyMaxLatency: 30,
|
||||
liveBufferLatencyMinRemain: 12,
|
||||
// Au-delà de 20 s de retard, accélérer légèrement (x1,1) jusqu'à
|
||||
// revenir à 12 s : rattrapage invisible, sans saut ni coupure.
|
||||
liveBufferLatencyMaxLatency: 45,
|
||||
liveBufferLatencyMinRemain: 25,
|
||||
// Au-delà de 35 s de retard, accélérer légèrement (x1,1) jusqu'à
|
||||
// revenir à 25 s : rattrapage invisible, sans saut ni coupure.
|
||||
liveSync: isLive,
|
||||
liveSyncMaxLatency: 20,
|
||||
liveSyncTargetLatency: 12,
|
||||
liveSyncMaxLatency: 35,
|
||||
liveSyncTargetLatency: 25,
|
||||
liveSyncPlaybackRate: 1.1,
|
||||
// Purge du buffer déjà lu : sans elle, une longue séance finit par
|
||||
// saturer le SourceBuffer (QuotaExceededError → erreur MPEG-TS).
|
||||
@@ -462,6 +547,7 @@
|
||||
|
||||
self._audioFallbackDone = true;
|
||||
self.log('Aucune piste audio démuxée (codec non supporté) — bascule HLS');
|
||||
self._report('fallback_hls', { detail: 'no_audio' });
|
||||
self.onLoading('Piste audio incompatible — réencodage');
|
||||
|
||||
try {
|
||||
@@ -479,6 +565,8 @@
|
||||
player.on(mpegts.Events.ERROR, function (type, detail) {
|
||||
self.log('Erreur MPEG-TS : ' + type + ' / ' + detail);
|
||||
if (self._destroyed) return;
|
||||
self._stats.errors++;
|
||||
self._report('mpegts_error', { detail: type + '/' + detail });
|
||||
|
||||
// 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
|
||||
@@ -503,6 +591,7 @@
|
||||
return;
|
||||
}
|
||||
self.log('Erreurs MPEG-TS répétées — bascule HLS');
|
||||
self._report('fallback_hls', { detail: 'repeated_mpegts_errors' });
|
||||
self.onLoading('Ouverture du flux');
|
||||
self.url = hlsUrl;
|
||||
if (self._canPlayNativeHls()) self._playDirect(hlsUrl);
|
||||
@@ -519,6 +608,7 @@
|
||||
player.detachMediaElement();
|
||||
player.destroy();
|
||||
} catch (e) {}
|
||||
self._report('recreate', {});
|
||||
self._createMpegts(self._mpegtsIsLive);
|
||||
}, 1200);
|
||||
});
|
||||
@@ -585,6 +675,7 @@
|
||||
var next = PROFILES[ORDER[idx + 1]];
|
||||
this.profile = next;
|
||||
this.log('Escalade buffer → ' + next.name + ' (' + reason + ')');
|
||||
this._report('profile', { detail: reason });
|
||||
this.onProfileChange(next.name, reason);
|
||||
|
||||
// HLS : les fenêtres de buffer sont modifiables à chaud, pas besoin de
|
||||
@@ -602,6 +693,32 @@
|
||||
// recréation du lecteur (déclenchée par le handler d'erreur).
|
||||
};
|
||||
|
||||
/**
|
||||
* Direct MPEG-TS à sec : se mettre en pause jusqu'à [REBUFFER_SECONDS] de
|
||||
* réserve (voir la constante). HLS gère déjà sa propre reprise.
|
||||
*/
|
||||
XFPlayer.prototype._holdUntilBuffered = function () {
|
||||
if (this._holding || !this.mpegts || this.type !== 'live') return;
|
||||
var self = this;
|
||||
this._holding = true;
|
||||
this._holdSince = Date.now();
|
||||
try { this.video.pause(); } catch (e) {}
|
||||
this._holdTimer = setInterval(function () { self._checkHold(); }, 250);
|
||||
};
|
||||
|
||||
/** Relance la lecture dès que la réserve est reconstituée. */
|
||||
XFPlayer.prototype._checkHold = function () {
|
||||
if (!this._holding) return;
|
||||
if (this._destroyed) { clearInterval(this._holdTimer); return; }
|
||||
var waited = Date.now() - this._holdSince;
|
||||
if (this._bufferAhead() < REBUFFER_SECONDS && waited < REBUFFER_MAX_WAIT_MS) {
|
||||
return;
|
||||
}
|
||||
clearInterval(this._holdTimer);
|
||||
this._holding = false;
|
||||
this._attemptPlay();
|
||||
};
|
||||
|
||||
XFPlayer.prototype._noteStall = function () {
|
||||
if (!this._started) return; // les attentes d'amorçage ne comptent pas
|
||||
var now = Date.now();
|
||||
@@ -628,18 +745,62 @@
|
||||
});
|
||||
|
||||
this._on(v, 'playing', function () {
|
||||
if (!self._started) {
|
||||
self._report('first_frame', { ms: Date.now() - self._t0 });
|
||||
}
|
||||
if (self._stallSince !== null) {
|
||||
var stallMs = Date.now() - self._stallSince;
|
||||
self._stallSince = null;
|
||||
// < 250 ms : imperceptible, ne pas noyer les logs.
|
||||
if (stallMs >= 250) {
|
||||
self._stats.stalls++;
|
||||
self._stats.stallMs += stallMs;
|
||||
self._report('stall', { ms: stallMs });
|
||||
}
|
||||
}
|
||||
self._started = true;
|
||||
self.onReady();
|
||||
self.send({ type: 'playback_status', status: 'playing' });
|
||||
});
|
||||
|
||||
this._on(v, 'pause', function () {
|
||||
// Pause posée par l'attente de réserve : pas une pause utilisateur,
|
||||
// l'interface ne doit pas basculer sur « lecture ».
|
||||
if (self._holding) return;
|
||||
self.send({ type: 'playback_status', status: 'paused' });
|
||||
});
|
||||
|
||||
this._on(v, 'waiting', function () { self._noteStall(); });
|
||||
this._on(v, 'waiting', function () {
|
||||
if (self._started && self._stallSince === null) {
|
||||
self._stallSince = Date.now();
|
||||
}
|
||||
self._noteStall();
|
||||
if (self._started) self._holdUntilBuffered();
|
||||
});
|
||||
this._on(v, 'stalled', function () { self._noteStall(); });
|
||||
|
||||
// Sauts (rattrapage du direct, recherche) : d'où, vers où.
|
||||
this._on(v, 'timeupdate', function () {
|
||||
if (!v.seeking) self._lastPos = v.currentTime;
|
||||
});
|
||||
this._on(v, 'seeking', function () {
|
||||
if (!self._started) return;
|
||||
self._stats.seeks++;
|
||||
self._report('seek', { from: self._lastPos, to: v.currentTime });
|
||||
});
|
||||
|
||||
// Bilan toutes les 60 s : nombre et durée cumulée des coupures.
|
||||
this._beatTimer = setInterval(function () {
|
||||
if (!self._started) return;
|
||||
self._report('heartbeat', {
|
||||
stalls: self._stats.stalls,
|
||||
stallMs: self._stats.stallMs,
|
||||
seeks: self._stats.seeks,
|
||||
errors: self._stats.errors,
|
||||
rate: v.playbackRate
|
||||
});
|
||||
}, 60000);
|
||||
|
||||
this._on(v, 'ended', function () {
|
||||
self.send({
|
||||
type: 'playback_ended',
|
||||
@@ -781,6 +942,12 @@
|
||||
self._attemptPlay();
|
||||
break;
|
||||
case 'pause':
|
||||
// Pause voulue par l'utilisateur : l'attente de réserve ne doit
|
||||
// pas relancer la lecture derrière son dos.
|
||||
if (self._holding) {
|
||||
clearInterval(self._holdTimer);
|
||||
self._holding = false;
|
||||
}
|
||||
v.pause();
|
||||
break;
|
||||
case 'seek':
|
||||
@@ -806,6 +973,8 @@
|
||||
XFPlayer.prototype.destroy = function () {
|
||||
this._destroyed = true;
|
||||
clearInterval(this._posTimer);
|
||||
clearInterval(this._beatTimer);
|
||||
clearInterval(this._holdTimer);
|
||||
this._listeners.forEach(function (l) {
|
||||
try { l.target.removeEventListener(l.type, l.handler); } catch (e) {}
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user