Compare commits

...
Author SHA1 Message Date
MichaelandClaude Opus 5.5 f82c70ae05 Lecture TV : reconstituer la réserve en lisant à x0,97
Télémétrie (session olfp14) : le panneau n'avait envoyé que ~18 s
d'avance au démarrage ; la réserve est restée à 13-20 s et un silence du
panneau a fini en coupure de 5,5 s. Le lecteur ne fabriquait pas de
réserve : il gardait seulement celle offerte par le panneau.

Régulateur de vitesse unique pour le direct MPEG-TS, chaque seconde :
sous 20 s d'avance, x0,97 (hauteur du son préservée) jusqu'à 25 s ;
au-delà de 35 s (rafale de rattrapage), x1,1 jusqu'à 28 s. Seuils
d'entrée/sortie distincts : pas d'oscillation. liveSync de mpegts.js
désactivé : il ne sait qu'accélérer et remettait la vitesse à 1 sous sa
cible, ce qui annulerait le ralenti.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 01:11:59 +02:00
MichaelandClaude Opus 5.5 6733f80ffd Lecture TV : plus de saut de 20 s après un silence du panneau
Télémétrie (session de 25 min avec la réserve de 25 s) : un silence du
panneau a été absorbé sans coupure (avance 27 → 14,7 s), mais à la
reprise le panneau renvoie d'un coup tout le retard. L'avance dépassait
alors le seuil de rattrapage (45 s) et le lecteur sautait 20 s de
programme — deux fois en une minute.

Saut seulement au-delà de 90 s de retard ; liveSync (x1,1 au-delà de
35 s) résorbe l'excédent en douceur. Vidéo déjà vue purgée plus tôt
(30 s) pour rester sous le quota du SourceBuffer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:41:43 +02:00
MichaelandClaude Opus 5.5 0539ad857f Lecture TV : traverser les silences du panneau sans figer
Télémétrie d'un freeze en prod : le panneau s'est tu ~15-20 s sur une
connexion restée ouverte (aucune reconnexion FFmpeg dans les logs).
Le lecteur n'avait gardé que 11-16 s d'avance — il « consommait » à x1,1
celle que le panneau envoie au démarrage pour revenir à 12 s — puis,
après la coupure de 6,3 s, il repartait avec 1 s de stock et recoupait
aussitôt (1,7 s puis 2,0 s).

- réserve visée 25 s (liveSync 25/35, saut au-delà de 45 s) : ~25 s de
  retard sur le direct en échange de la traversée de ces silences
- après une coupure, attente de 4 s de réserve avant de relire (comme
  mpv cache-pause), 15 s au plus ; une pause utilisateur l'annule ; le
  lecteur lite affiche « Reconstitution de la réserve » au lieu du
  bouton lecture

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:04:25 +02:00
MichaelandClaude Opus 5.5 a739d6451c Diagnostic : le lecteur remonte ses freezes dans les logs du serveur
Les freezes (image et son figés puis reprise) se produisent dans le
navigateur de l'utilisateur ; le serveur n'en voyait rien. Le lecteur
envoie désormais chaque coupure (durée, buffer), saut (de → vers),
erreur MPEG-TS/HLS, recréation, repli HLS et changement de profil, plus
un bilan toutes les 60 s, à POST /api/player-log (authentifié). Le
serveur écrit une ligne « [Player] … » par événement.

Entrée filtrée : événements et champs connus seulement, texte borné,
URL masquées (une erreur réseau peut recopier les identifiants Xtream),
pas d'injection de lignes. Envoi jamais bloquant pour la lecture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:38:17 +02:00
MichaelandClaude Opus 5.5 775caad0ee Annule « Zap : ouvrir les chaînes par le HLS du panneau »
Mesure après déploiement : premier octet de turbo.ts à 4,5–4,8 s avec la
source HLS, contre 4,3–4,7 s en .ts — aucun gain. Les logs confirment
que le HLS était bien utilisé (aucun repli). La sonde qui annonçait
0,5 s ouvrait le .ts AVANT le .m3u8 : la chaîne était déjà démarrée chez
le panneau. Les ~4,5 s sont le temps de démarrage d'une chaîne chez le
fournisseur, quel que soit le format.

Retour au .ts, déjà validé en lecture, plutôt que de garder un chemin
plus complexe (segments de 10 s, repli) sans bénéfice mesuré.

This reverts commit 0ea60cc.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:30:51 +02:00
MichaelandClaude Opus 5.5 4d00409de8 Guide TV : régionales France 3, variantes de nom, chaînes +1, CH et BE
Mesuré en prod après le premier correctif : 69 chaînes sur 149 avec
guide. Les vides restantes relevaient surtout de formes de nom que le
dump écrit autrement, ou de pays non couverts :

- « F3 ALPES » → France 3 Alpes (France.3.-.Alpes.fr) : F2 à F5 développés
- clé souple en dernier recours, dans son propre espace de noms :
  sans code pays ni « channel/tv/hd » (« AB3 » → AB3.Channel.fr)
- chaîne « +1 » absente du dump (TF1 +1) : guide de la chaîne principale
  décalé d'une heure ; une +1 qui a son propre guide le garde
- sources par défaut : FR1 puis CH1 (RTS…) et BE2 (même fournisseur),
  le dump français reste prioritaire

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:17:28 +02:00
13 changed files with 800 additions and 193 deletions

No files matched your search

+1 -1
View File
@@ -37,7 +37,7 @@ Au premier démarrage, un compte `admin` est créé avec un **mot de passe aléa
| `RECORDINGS_PATH` | `./data/recordings` | Dossier hôte des enregistrements |
| `TZ` | `Europe/Paris` | Fuseau du conteneur (les enregistrements sont stockés en UTC) |
| `MAX_CONCURRENT_RECORDINGS` | `2` | Enregistrements simultanés |
| `EPG_XMLTV_URLS` | dump FR | Sources XMLTV de repli (vide = aucun appel sortant) |
| `EPG_XMLTV_URLS` | dumps FR, CH, BE | Sources XMLTV de repli (vide = aucun appel sortant) |
| `NVIDIA_GPU` | `false` | Transcodage NVENC |
| `ADMIN_INITIAL_PASSWORD` | *(généré)* | Mot de passe initial du compte admin |
| `MIN_FREE_DISK_MB` | `500` | Espace libre minimal pour démarrer une capture |
+70
View File
@@ -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
View File
@@ -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,
+12 -1
View File
@@ -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
@@ -132,7 +133,9 @@ void main(List<String> args) async {
// Source XMLTV de repli. Vider EPG_XMLTV_URLS désactive tout appel sortant :
// l'EPG se limite alors au panneau de l'abonné.
final xmltvUrls = (Platform.environment['EPG_XMLTV_URLS'] ??
'https://epgshare01.online/epgshare01/epg_ripper_FR1.xml.gz')
'https://epgshare01.online/epgshare01/epg_ripper_FR1.xml.gz,'
'https://epgshare01.online/epgshare01/epg_ripper_CH1.xml.gz,'
'https://epgshare01.online/epgshare01/epg_ripper_BE2.xml.gz')
.split(',')
.map((u) => u.trim())
.where((u) => u.isNotEmpty)
@@ -219,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',
+98 -9
View File
@@ -82,9 +82,37 @@ class XmltvEpgService {
String? displayName,
}) async {
await ensureFresh();
final direct = _lookup(channelId, displayName);
if (direct.isNotEmpty) return direct;
// Chaîne décalée sans guide propre (« TF1 +1 » absent des dumps) : le
// guide de la chaîne principale, une heure plus tard, est exact.
if (displayName != null && _plusOne.hasMatch(displayName)) {
final base = displayName.replaceAll(_plusOne, ' ');
final hits = _lookup(null, base);
return [
for (final p in hits)
XmltvProgramme(
title: p.title,
description: p.description,
start: p.start.add(const Duration(hours: 1)),
stop: p.stop.add(const Duration(hours: 1)),
),
];
}
return const [];
}
static final _plusOne = RegExp(r'\+\s*1(?![0-9])');
List<XmltvProgramme> _lookup(String? channelId, String? displayName) {
final keys = [
normalizeKey(channelId),
if (displayName != null) ...nameKeys(displayName),
// Clés souples en dernier recours, dans leur propre espace de noms.
_looseIndexKey(looseKey(channelId)),
if (displayName != null)
_looseIndexKey(looseKey(cleanChannelName(displayName).name)),
];
for (final key in keys) {
if (key.isEmpty) continue;
@@ -94,6 +122,44 @@ class XmltvEpgService {
return const [];
}
static String _looseIndexKey(String loose) => loose.isEmpty ? '' : '~$loose';
/// Jetons sans valeur distinctive pour reconnaître une chaîne.
static const _looseNoise = {
'channel', 'tv', 'hd', 'fhd', 'uhd', 'sd', '4k', 'hevc',
};
/// Codes pays en fin d'identifiant XMLTV (`TF1.fr`, `RTS1.ch`).
static const _countryCodes = {
'fr', 'be', 'ch', 'lu', 'mc', 'ca', 'uk', 'us', 'es', 'it', 'de', 'pt',
'nl',
};
/// Clé « souple » : sans code pays final ni mots génériques
/// (« AB3.Channel.fr » et « AB3 » → `ab3`).
///
/// Servie dans un espace de noms à part, consultée en tout dernier : elle
/// rattrape les variantes d'écriture sans fausser les correspondances
/// exactes.
static String looseKey(String? raw) {
if (raw == null) return '';
final folded = StringBuffer();
for (final rune in raw.toLowerCase().runes) {
final char = String.fromCharCode(rune);
folded.write(_accents[char] ?? char);
}
final tokens = folded
.toString()
.split(RegExp(r'[^a-z0-9]+'))
.where((t) => t.isNotEmpty)
.toList();
if (tokens.length > 1 && _countryCodes.contains(tokens.last)) {
tokens.removeLast();
}
tokens.removeWhere(_looseNoise.contains);
return tokens.join();
}
/// Préfixe pays des noms de panneau : `FR - `, `FR: `, `|FR| `, `[FR] `…
static final _countryPrefix = RegExp(
r'^\s*[\|\[\(]?\s*([A-Za-z]{2,3})\s*[\|\]\)]?\s*[-:|]\s*|^\s*[\|\[\(]\s*([A-Za-z]{2,3})\s*[\|\]\)]\s*',
@@ -119,8 +185,24 @@ class XmltvEpgService {
/// de TF1.
static List<String> nameKeys(String name) {
final keys = <String>[normalizeKey(name)];
final cleaned = cleanChannelName(name);
final country = cleaned.country;
final base = normalizeKey(cleaned.name);
if (base.isNotEmpty) {
keys.add(base);
keys.add('$base${country ?? 'fr'}');
}
return keys.where((k) => k.isNotEmpty).toSet().toList();
}
var cleaned = name;
/// Abréviations du panneau pour les chaînes publiques (« F3 ALPES »), là
/// où les dumps écrivent `France.3.-.Alpes.fr`.
static final _franceAbbrev = RegExp(r'^F\s?([2-5])\b', caseSensitive: false);
/// Nom de chaîne débarrassé du préfixe pays, des symboles et des
/// marqueurs de qualité ; [country] = code pays du préfixe, s'il y en a.
static ({String name, String? country}) cleanChannelName(String raw) {
var cleaned = raw;
String? country;
final prefix = _countryPrefix.firstMatch(cleaned);
if (prefix != null) {
@@ -129,19 +211,19 @@ class XmltvEpgService {
}
// Symboles décoratifs (◉, ᴴᴰ, ★…) : tout ce qui n'est ni lettre, ni
// chiffre, ni ponctuation utile.
cleaned = cleaned.replaceAll(RegExp(r'[^\p{L}\p{N}\s+.&\-()]', unicode: true), ' ').trim();
cleaned = cleaned
.replaceAll(RegExp(r'[^\p{L}\p{N}\s+.&\-()]', unicode: true), ' ')
.trim();
String previous;
do {
previous = cleaned;
cleaned = cleaned.replaceFirst(_qualitySuffix, '').trim();
} while (cleaned != previous && cleaned.isNotEmpty);
final base = normalizeKey(cleaned);
if (base.isNotEmpty) {
keys.add(base);
keys.add('$base${country ?? 'fr'}');
}
return keys.where((k) => k.isNotEmpty).toSet().toList();
cleaned = cleaned.replaceFirstMapped(
_franceAbbrev,
(m) => 'France ${m[1]}',
);
return (name: cleaned, country: country);
}
/// Recharge l'index s'il est absent ou périmé. Les appels concurrents
@@ -325,6 +407,13 @@ class XmltvEpgService {
aliases.putIfAbsent(name, () => id);
}
}
// Clés souples (espace de noms « ~ ») : premier arrivé gagne.
if (id.isNotEmpty) {
for (final raw in [channelId, ...displayNames]) {
final loose = _looseIndexKey(looseKey(raw));
if (loose.isNotEmpty) aliases.putIfAbsent(loose, () => id);
}
}
channelId = null;
case 'programme':
if (programmeChannel != null &&
-35
View File
@@ -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');
});
});
}
+47
View File
@@ -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')));
});
});
}
+47
View File
@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
@@ -84,6 +85,52 @@ void main() {
});
});
group('correspondances élargies (chaînes vides mesurées en prod)', () {
XmltvEpgService serviceWith(String dump) => XmltvEpgService(
sourceUrls: const ['http://ext/epg.xml'],
client: MockClient((_) async => http.Response.bytes(
// Octets UTF-8 : les ids réels portent des accents.
const Utf8Encoder().convert(dump),
200,
)),
);
String channel(String id, String name, String title, DateTime start) => '''
<channel id="$id"><display-name>$name</display-name></channel>
<programme start="${_stamp(start)}" stop="${_stamp(start.add(const Duration(hours: 1)))}" channel="$id"><title>$title</title></programme>''';
test('« F3 ALPES » du panneau trouve France.3.-.Alpes.fr', () async {
final now = DateTime.now().toUtc();
final svc = serviceWith('<tv>${channel('France.3.-.Alpes.fr', 'France 3 Alpes', 'JT Alpes', now)}</tv>');
final hits = await svc.programmesFor(null, displayName: 'FR - F3 ALPES HD');
expect(hits.single.title, 'JT Alpes');
});
test('« AB3 » trouve AB3.Channel.fr (clé souple)', () async {
final now = DateTime.now().toUtc();
final svc = serviceWith('<tv>${channel('AB3.Channel.fr', 'AB3 Channel', 'Série', now)}</tv>');
final hits = await svc.programmesFor('AB3.fr', displayName: 'FR - AB3 FHD');
expect(hits.single.title, 'Série');
});
test('« TF1 +1 » reprend le guide de TF1 décalé d\'une heure', () async {
final now = DateTime.now().toUtc();
final svc = serviceWith('<tv>${channel('TF1.fr', 'TF1', 'Le 13h', now)}</tv>');
final hits = await svc.programmesFor(null, displayName: 'FR - TF1 +1 FHD');
expect(hits.single.title, 'Le 13h');
expect(hits.single.start, now.add(const Duration(hours: 1)).copyWith(microsecond: 0, millisecond: 0));
});
test('une chaîne +1 qui a son propre guide le garde', () async {
final now = DateTime.now().toUtc();
final svc = serviceWith('<tv>'
'${channel('Boomerang.fr', 'Boomerang', 'Normal', now)}'
'${channel('Boomerang.+1.fr', 'Boomerang +1', 'Décalé', now)}</tv>');
final hits = await svc.programmesFor(null, displayName: 'FR - BOOMERANG +1');
expect(hits.single.title, 'Décalé');
});
});
group('programmesFor par nom', () {
test('trouve le guide d\'une chaîne sans epg_channel_id par son nom nettoyé',
() async {
+1 -1
View File
@@ -9,7 +9,7 @@ services:
- MAX_CONCURRENT_RECORDINGS=2
# Source XMLTV de repli, utilisée seulement quand le panneau ne rend
# aucun programme actuel. Vider la variable coupe tout appel sortant.
- EPG_XMLTV_URLS=https://epgshare01.online/epgshare01/epg_ripper_FR1.xml.gz
- EPG_XMLTV_URLS=https://epgshare01.online/epgshare01/epg_ripper_FR1.xml.gz,https://epgshare01.online/epgshare01/epg_ripper_CH1.xml.gz,https://epgshare01.online/epgshare01/epg_ripper_BE2.xml.gz
- NVIDIA_GPU=true
volumes:
- xtremflow-data:/app/data
+1 -1
View File
@@ -27,7 +27,7 @@ services:
# aucun programme couvrant l'instant présent. Vider la variable coupe
# tout appel sortant : l'EPG se limite alors au panneau de l'abonné.
# Plusieurs dumps se séparent par des virgules.
- EPG_XMLTV_URLS=${EPG_XMLTV_URLS-https://epgshare01.online/epgshare01/epg_ripper_FR1.xml.gz}
- EPG_XMLTV_URLS=${EPG_XMLTV_URLS-https://epgshare01.online/epgshare01/epg_ripper_FR1.xml.gz,https://epgshare01.online/epgshare01/epg_ripper_CH1.xml.gz,https://epgshare01.online/epgshare01/epg_ripper_BE2.xml.gz}
# Accélération GPU NVIDIA pour le transcodage (NVENC) : true/false
- NVIDIA_GPU=${NVIDIA_GPU:-false}
restart: unless-stopped
+198 -5
View File
@@ -93,12 +93,205 @@ for (const profile of ['fast', 'balanced', 'safe']) {
});
}
test('live : le retard est résorbé en douceur (liveSync) plutôt que par sauts', () => {
/** Réglages du régulateur de vitesse (exposés par le moteur). */
function liveTuning() {
const window = {
addEventListener() {}, removeEventListener() {},
console: { log() {} },
location: { origin: 'https://xf.test', href: 'https://xf.test/', search: '' },
parent: { postMessage() {} },
};
vm.runInContext(source, vm.createContext({
window, location: window.location,
document: { addEventListener() {}, removeEventListener() {} },
URLSearchParams, setInterval: () => 0, clearInterval() {}, setTimeout,
}));
return window.XFPlayerLiveTuning;
}
test('live : un seul régulateur de vitesse (liveSync de mpegts.js désactivé)', () => {
// liveSync ne sait qu'accélérer et remet la vitesse à 1 sous sa cible :
// il annulerait le ralenti qui reconstitue la réserve.
const c = liveMpegtsConfig();
assert.equal(c.liveSync, true);
assert.ok(c.liveSyncPlaybackRate > 1 && c.liveSyncPlaybackRate <= 1.2);
assert.ok(c.liveSyncTargetLatency < c.liveSyncMaxLatency);
assert.ok(c.liveSyncMaxLatency <= c.liveBufferLatencyMaxLatency);
assert.notEqual(c.liveSync, true);
const t = liveTuning();
assert.ok(t.slowRate < 1 && t.slowRate >= 0.95, 'ralenti audible au-delà');
assert.ok(t.fastRate > 1 && t.fastRate <= 1.15);
assert.ok(t.lowAhead < t.targetAhead && t.targetAhead < t.highAhead);
assert.ok(t.highAhead < 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();
const t = liveTuning();
// Le régulateur ramène l'avance vers la cible : c'est la réserve en
// régime établi. Elle doit dépasser le silence mesuré — et le ralenti
// doit se déclencher AVANT d'être passé sous ce seuil de sécurité.
assert.ok(t.targetAhead > PROVIDER_OUTAGE_S,
`cible ${t.targetAhead}s < silence mesuré ${PROVIDER_OUTAGE_S}s`);
assert.ok(t.lowAhead > PROVIDER_OUTAGE_S);
assert.ok(c.liveBufferLatencyMinRemain > PROVIDER_OUTAGE_S);
});
test('régulateur : ralentit sous la réserve, accélère au-dessus, sans osciller', () => {
const listeners = {};
let ahead = 0;
const video = {
addEventListener(type, fn) { (listeners[type] ||= []).push(fn); },
removeEventListener() {},
canPlayType: () => '',
play: () => Promise.resolve(),
pause() {},
get buffered() { return { length: 1, start: () => 0, end: () => 100 + ahead }; },
seekable: { length: 0 },
currentTime: 100,
playbackRate: 1,
paused: false,
seeking: false,
};
const window = {
addEventListener() {}, removeEventListener() {},
console: { log() {} },
location: { origin: 'https://xf.test', href: 'https://xf.test/', search: '' },
parent: { postMessage() {} },
};
vm.runInContext(source, vm.createContext({
window, location: window.location,
document: { addEventListener() {}, removeEventListener() {} },
URLSearchParams, setInterval: () => 0, clearInterval() {}, setTimeout, Date,
}));
const t = window.XFPlayerLiveTuning;
const p = new window.XFPlayer({ video, url: '/api/live/1/turbo.ts', type: 'live' });
p.mpegts = {};
p._started = true;
ahead = 13; p._tuneRate(); // session réelle : 13 s
assert.equal(video.playbackRate, t.slowRate);
ahead = t.lowAhead + 2; p._tuneRate(); // remonte, pas encore la cible
assert.equal(video.playbackRate, t.slowRate, 'repasse à 1 trop tôt : oscille');
ahead = t.targetAhead; p._tuneRate();
assert.equal(video.playbackRate, 1);
ahead = t.highAhead + 5; p._tuneRate(); // rafale de rattrapage
assert.equal(video.playbackRate, t.fastRate);
ahead = t.highAhead - 2; p._tuneRate();
assert.equal(video.playbackRate, t.fastRate, 'freine trop tôt : oscille');
ahead = t.targetAhead + 2; p._tuneRate();
assert.equal(video.playbackRate, 1);
});
test('live : la rafale de rattrapage après un silence ne fait pas sauter le programme', () => {
// Mesuré en prod : après un silence, le panneau renvoie d'un coup tout le
// retard ; l'avance a dépassé 45 s et le lecteur a sauté 2 fois de 20 s
// (programme perdu). Le retard doit se résorber en douceur (liveSync).
const observedPeakAhead = 45;
const c = liveMpegtsConfig();
assert.ok(c.liveBufferLatencyMaxLatency >= 2 * observedPeakAhead - 10,
`saut dès ${c.liveBufferLatencyMaxLatency}s d'avance : la rafale de rattrapage le déclenche`);
assert.ok(liveTuning().highAhead < observedPeakAhead,
'le régulateur doit déjà accélérer avant ce niveau');
});
test('live : la vidéo déjà vue est purgée plus tôt, l\'avance étant plus grande', () => {
const c = liveMpegtsConfig();
assert.ok(c.autoCleanupMaxBackwardDuration <= 30);
});
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)', () => {
+6
View File
@@ -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';
+239 -11
View File
@@ -58,6 +58,32 @@
// 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;
// Régulateur de vitesse du direct MPEG-TS (voir _tuneRate).
//
// La réserve ne venait que de l'avance envoyée par le panneau au
// démarrage : 28 s une fois, 18 s une autre — et avec 13-20 s, un silence
// du panneau a fini en coupure de 5,5 s (télémétrie). Sous lowAhead, on
// lit à x0,97 (hauteur du son préservée) jusqu'à reconstituer targetAhead ;
// au-delà de highAhead (rafale de rattrapage du panneau), x1,1 jusqu'à
// settleHigh. Les seuils d'entrée et de sortie diffèrent : pas
// d'oscillation de vitesse.
var LIVE_TUNING = {
lowAhead: 20,
targetAhead: 25,
settleHigh: 28,
highAhead: 35,
slowRate: 0.97,
fastRate: 1.1
};
// ------------------------------------------------------------------
// Chargement paresseux des libs (aucune requête inutile)
// ------------------------------------------------------------------
@@ -190,8 +216,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 +317,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 +439,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,20 +523,33 @@
// 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.
//
// Saut seulement au-delà de 90 s : après un silence, le panneau
// renvoie d'un coup tout le retard. Avec un seuil à 45 s, l'avance
// le dépassait et le lecteur sautait 20 s de programme (2 fois en
// 1 min, mesuré). Le régulateur de vitesse (_tuneRate) résorbe
// désormais cet excédent en douceur.
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.
liveSync: isLive,
liveSyncMaxLatency: 20,
liveSyncTargetLatency: 12,
liveSyncPlaybackRate: 1.1,
liveBufferLatencyMaxLatency: 90,
liveBufferLatencyMinRemain: 25,
// liveSync de mpegts.js désactivé : il ne sait qu'accélérer et remet
// la vitesse à 1 sous sa cible, ce qui annulerait le ralenti de
// _tuneRate qui reconstitue la réserve.
liveSync: false,
// Purge du buffer déjà lu : sans elle, une longue séance finit par
// saturer le SourceBuffer (QuotaExceededError → erreur MPEG-TS).
// Avance jusqu'à 90 s : la vidéo déjà vue est purgée plus tôt pour
// rester sous le quota du SourceBuffer (~150 Mo en vidéo sur Chrome).
autoCleanupSourceBuffer: true,
autoCleanupMaxBackwardDuration: 60,
autoCleanupMinBackwardDuration: 30,
autoCleanupMaxBackwardDuration: 30,
autoCleanupMinBackwardDuration: 15,
lazyLoad: false
}
);
@@ -462,6 +571,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 +589,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 +615,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 +632,7 @@
player.detachMediaElement();
player.destroy();
} catch (e) {}
self._report('recreate', {});
self._createMpegts(self._mpegtsIsLive);
}, 1200);
});
@@ -585,6 +699,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 +717,62 @@
// 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);
};
/**
* Ajuste la vitesse du direct MPEG-TS selon l'avance (voir LIVE_TUNING).
* Appelé chaque seconde ; sans effet en pause, en attente de réserve,
* hors direct ou en HLS.
*/
XFPlayer.prototype._tuneRate = function () {
var v = this.video;
if (!this.mpegts || this.type !== 'live' || !this._started ||
this._holding || v.paused) {
return;
}
var ahead = this._bufferAhead();
var t = LIVE_TUNING;
var mode = this._rateMode || 'normal';
if (mode === 'slow' && ahead >= t.targetAhead) mode = 'normal';
else if (mode === 'fast' && ahead <= t.settleHigh) mode = 'normal';
else if (mode === 'normal' && ahead < t.lowAhead) mode = 'slow';
else if (mode === 'normal' && ahead > t.highAhead) mode = 'fast';
// Un saut de rattrapage peut faire passer directement d'un extrême à
// l'autre.
if (mode === 'slow' && ahead > t.highAhead) mode = 'fast';
if (mode === 'fast' && ahead < t.lowAhead) mode = 'slow';
this._rateMode = mode;
var rate = mode === 'slow' ? t.slowRate : (mode === 'fast' ? t.fastRate : 1);
if (v.playbackRate !== rate) {
try { v.playbackRate = rate; } catch (e) {}
}
};
/** 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 +799,65 @@
});
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 });
});
// Régulateur de vitesse du direct (voir LIVE_TUNING).
this._rateTimer = setInterval(function () { self._tuneRate(); }, 1000);
// 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 +999,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 +1030,9 @@
XFPlayer.prototype.destroy = function () {
this._destroyed = true;
clearInterval(this._posTimer);
clearInterval(this._beatTimer);
clearInterval(this._holdTimer);
clearInterval(this._rateTimer);
this._listeners.forEach(function (l) {
try { l.target.removeEventListener(l.type, l.handler); } catch (e) {}
});
@@ -822,4 +1049,5 @@
global.XFPlayer = XFPlayer;
global.XFPlayerProfiles = PROFILES;
global.XFPlayerLiveTuning = LIVE_TUNING;
})(window);