mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-20 13:38:05 +02:00
Logos, guide TV et fluidité : couper le flot de requêtes mortes
Logos absents : l'hébergeur des picons du fournisseur répond 503 sur tout, y compris sa racine (serveur en maintenance). Le proxy relayait ce 503 au navigateur, qui redemandait chaque logo à chaque affichage de la grille — des centaines de requêtes sortantes mortes par seconde. - Un statut >= 400 sur une image rend désormais un pixel transparent avec `Cache-Control: max-age=600`, au lieu de propager l'erreur - Après trois échecs consécutifs, l'hôte est marqué hors service cinq minutes : plus aucun appel sortant, réponse servie localement - Le corps d'erreur amont est drainé, sinon la connexion restait ouverte jusqu'au timeout Guide TV très lent (4 à 8 s par chaîne dans les logs) : `player_api` demande un appel par chaîne, et le panneau met ~4 s à répondre — une grille de trente chaînes dépassait la minute. - Le dump `xmltv.php` du panneau passe en premier : une requête couvre toutes les chaînes, réutilisée trois heures - L'interrogation chaîne par chaîne reste le repli, puis la source XMLTV externe - Une source XMLTV en échec n'est plus retéléchargée à chaque consultation : quinze minutes de recul - Index borné à 48 h en avant : un dump national couvre sept jours pour un millier de chaînes, soit des centaines de Mo pour un guide qui n'affiche que le programme courant Lecture saccadée : le relais FFmpeg -> navigateur de `/turbo.ts` ne transmettait pas la pause du client à la source. Un lecteur plus lent que le flux laissait FFmpeg produire à pleine vitesse et le serveur empilait les paquets en mémoire, d'où la dérive derrière le direct. La contre-pression est maintenant propagée (`pipeWithBackpressure`). Le serveur tourne sur un seul isolate : le flot de requêtes mortes et les attentes EPG de plusieurs secondes partageaient la boucle d'événements avec les paquets vidéo. Tests ajoutés : cache d'échecs par hôte, contre-pression du relais, priorité des sources EPG, recul après échec XMLTV, horizon d'index. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2151f2820c
commit
b6ca9dab96
11 files changed
+716
-78
No files matched your search
+139
-45
@@ -8,10 +8,12 @@ import '../utils/log_redactor.dart';
|
||||
/// API EPG — proxy vers Xtream avec cache 30 minutes
|
||||
/// GET /api/epg/<channel_id>?days=1
|
||||
///
|
||||
/// Le panneau de l'abonné reste la source de référence. Une source XMLTV
|
||||
/// externe n'est interrogée qu'en dernier recours, quand le panneau ne rend
|
||||
/// aucun programme couvrant l'instant présent ou à venir — cas fréquent des
|
||||
/// revendeurs dont le guide est figé depuis plusieurs jours.
|
||||
/// Le panneau de l'abonné reste la source de référence, mais par son dump
|
||||
/// `xmltv.php` d'abord : une requête couvre toutes les chaînes, là où
|
||||
/// `player_api` en réclame une par chaîne à plusieurs secondes pièce. Vient
|
||||
/// ensuite l'interrogation chaîne par chaîne, puis une source XMLTV externe
|
||||
/// en dernier recours — cas des revendeurs dont le guide est figé depuis
|
||||
/// plusieurs jours.
|
||||
class EpgApi {
|
||||
final Future<PlaylistConfig?> Function(Request) _getPlaylist;
|
||||
|
||||
@@ -31,7 +33,43 @@ class EpgApi {
|
||||
/// pas par `stream_id` propre au panneau.
|
||||
final Map<String, _ChannelMap> _channelMaps = {};
|
||||
|
||||
EpgApi(this._getPlaylist, {XmltvEpgService? xmltv}) : _xmltv = xmltv;
|
||||
/// Client HTTP des appels `player_api`. Injectable pour les tests.
|
||||
final http.Client _http;
|
||||
|
||||
/// Fabrique de la source XMLTV du panneau, une par compte.
|
||||
final XmltvEpgService Function(PlaylistConfig) _panelXmltvBuilder;
|
||||
|
||||
/// Index XMLTV du panneau, par compte.
|
||||
final Map<String, XmltvEpgService> _panelXmltv = {};
|
||||
|
||||
EpgApi(
|
||||
this._getPlaylist, {
|
||||
XmltvEpgService? xmltv,
|
||||
http.Client? httpClient,
|
||||
XmltvEpgService Function(PlaylistConfig)? panelXmltvBuilder,
|
||||
}) : _xmltv = xmltv,
|
||||
_http = httpClient ?? http.Client(),
|
||||
_panelXmltvBuilder = panelXmltvBuilder ?? _defaultPanelXmltv;
|
||||
|
||||
/// Dump XMLTV servi par le panneau lui-même.
|
||||
///
|
||||
/// POURQUOI : `player_api.php?action=get_simple_data_table` répond en
|
||||
/// plusieurs secondes chez beaucoup de revendeurs, et il faut un appel par
|
||||
/// chaîne — afficher une grille de trente chaînes demandait donc plus d'une
|
||||
/// minute. `xmltv.php` renvoie le guide de toutes les chaînes en une seule
|
||||
/// requête, réutilisée ensuite pendant des heures. C'est ce que font les
|
||||
/// clients IPTV rapides.
|
||||
static XmltvEpgService _defaultPanelXmltv(PlaylistConfig playlist) {
|
||||
final user = Uri.encodeQueryComponent(playlist.username);
|
||||
final password = Uri.encodeQueryComponent(playlist.password);
|
||||
return XmltvEpgService(
|
||||
sourceUrls: ['${playlist.dns}/xmltv.php?username=$user&password=$password'],
|
||||
refreshInterval: const Duration(hours: 3),
|
||||
// Le guide est facultatif : on ne fait pas patienter l'utilisateur
|
||||
// plusieurs minutes sur un panneau qui traîne.
|
||||
downloadTimeout: const Duration(seconds: 90),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Response> handleGetEpg(Request request, String channelId) async {
|
||||
final playlist = await _getPlaylist(request);
|
||||
@@ -52,47 +90,44 @@ class EpgApi {
|
||||
}
|
||||
|
||||
try {
|
||||
final dns = playlist.dns;
|
||||
Map<String, dynamic> epgData = {
|
||||
'channel_id': channelId,
|
||||
'programmes': [],
|
||||
};
|
||||
|
||||
// Ordre d'essai des actions Xtream.
|
||||
// Sources par ordre de préférence. On s'arrête à la première qui
|
||||
// contient un programme en cours ou à venir ; à défaut, la première
|
||||
// non vide sert de repli.
|
||||
//
|
||||
// `get_simple_data_table` d'abord : c'est la seule action réellement
|
||||
// universelle. `get_epg` n'existe pas sur beaucoup de panneaux — au lieu
|
||||
// d'une erreur, ils renvoient poliment le payload d'authentification,
|
||||
// sans champ `epg_listings`, ce qui produisait un guide vide impossible
|
||||
// à distinguer d'une chaîne sans programme.
|
||||
const actions = [
|
||||
'get_simple_data_table',
|
||||
'get_short_epg',
|
||||
// Le dump du panneau passe devant : une requête couvre toutes les
|
||||
// chaînes, là où `player_api` en demande une par chaîne, à plusieurs
|
||||
// secondes pièce.
|
||||
final sources = <(String, Future<List<Map<String, dynamic>>> Function())>[
|
||||
('panel-xmltv', () => _panelProgrammes(playlist, channelId)),
|
||||
('xtream', () => _xtreamProgrammes(playlist, channelId)),
|
||||
('xmltv', () => _xmltvProgrammes(playlist, channelId)),
|
||||
];
|
||||
|
||||
for (final action in actions) {
|
||||
final url =
|
||||
'$dns/player_api.php?username=${playlist.username}&password=${playlist.password}'
|
||||
'&action=$action&stream_id=$channelId';
|
||||
|
||||
final response =
|
||||
await http.get(Uri.parse(url)).timeout(const Duration(seconds: 60));
|
||||
if (response.statusCode != 200) continue;
|
||||
|
||||
try {
|
||||
epgData = transformEpgData(json.decode(response.body), channelId);
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
if ((epgData['programmes'] as List).isNotEmpty) break;
|
||||
}
|
||||
|
||||
var epgData = <String, dynamic>{
|
||||
'channel_id': channelId,
|
||||
'programmes': <Map<String, dynamic>>[],
|
||||
};
|
||||
var source = 'xtream';
|
||||
if (!_hasCurrentProgramme(epgData)) {
|
||||
final fallback = await _xmltvProgrammes(playlist, channelId);
|
||||
if (fallback.isNotEmpty) {
|
||||
epgData = {'channel_id': channelId, 'programmes': fallback};
|
||||
source = 'xmltv';
|
||||
var found = false;
|
||||
|
||||
for (final (name, fetch) in sources) {
|
||||
final programmes = await fetch();
|
||||
if (programmes.isEmpty) continue;
|
||||
|
||||
final candidate = <String, dynamic>{
|
||||
'channel_id': channelId,
|
||||
'programmes': programmes,
|
||||
};
|
||||
if (!found) {
|
||||
// Meilleur repli connu à ce stade, même si le guide est périmé.
|
||||
epgData = candidate;
|
||||
source = name;
|
||||
found = true;
|
||||
}
|
||||
if (_hasCurrentProgramme(candidate)) {
|
||||
epgData = candidate;
|
||||
source = name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,6 +158,57 @@ class EpgApi {
|
||||
}
|
||||
}
|
||||
|
||||
/// Programmes tirés du dump XMLTV du panneau, ou liste vide.
|
||||
Future<List<Map<String, dynamic>>> _panelProgrammes(
|
||||
PlaylistConfig playlist,
|
||||
String channelId,
|
||||
) async {
|
||||
final key = '${playlist.dns}|${playlist.username}';
|
||||
final service =
|
||||
_panelXmltv.putIfAbsent(key, () => _panelXmltvBuilder(playlist));
|
||||
return _fromXmltv(service, playlist, channelId);
|
||||
}
|
||||
|
||||
/// Programmes obtenus en interrogeant le panneau chaîne par chaîne.
|
||||
///
|
||||
/// Chemin lent : deux actions possibles, plusieurs secondes chacune chez la
|
||||
/// plupart des revendeurs. Il ne sert que lorsque le dump XMLTV du panneau
|
||||
/// est absent ou ne couvre pas la chaîne.
|
||||
Future<List<Map<String, dynamic>>> _xtreamProgrammes(
|
||||
PlaylistConfig playlist,
|
||||
String channelId,
|
||||
) async {
|
||||
// `get_simple_data_table` d'abord : c'est la seule action réellement
|
||||
// universelle. `get_epg` n'existe pas sur beaucoup de panneaux — au lieu
|
||||
// d'une erreur, ils renvoient poliment le payload d'authentification,
|
||||
// sans champ `epg_listings`, ce qui produisait un guide vide impossible
|
||||
// à distinguer d'une chaîne sans programme.
|
||||
const actions = ['get_simple_data_table', 'get_short_epg'];
|
||||
|
||||
for (final action in actions) {
|
||||
final url =
|
||||
'${playlist.dns}/player_api.php?username=${playlist.username}'
|
||||
'&password=${playlist.password}'
|
||||
'&action=$action&stream_id=$channelId';
|
||||
|
||||
final response =
|
||||
await _http.get(Uri.parse(url)).timeout(const Duration(seconds: 60));
|
||||
if (response.statusCode != 200) continue;
|
||||
|
||||
Map<String, dynamic> parsed;
|
||||
try {
|
||||
parsed = transformEpgData(json.decode(response.body), channelId);
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
final programmes = (parsed['programmes'] as List)
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.toList();
|
||||
if (programmes.isNotEmpty) return programmes;
|
||||
}
|
||||
return const [];
|
||||
}
|
||||
|
||||
/// Le guide contient-il un programme en cours ou à venir ?
|
||||
///
|
||||
/// Un panneau dont l'EPG est figé répond avec des centaines de programmes,
|
||||
@@ -142,8 +228,16 @@ class EpgApi {
|
||||
Future<List<Map<String, dynamic>>> _xmltvProgrammes(
|
||||
PlaylistConfig playlist,
|
||||
String channelId,
|
||||
) =>
|
||||
_fromXmltv(_xmltv, playlist, channelId);
|
||||
|
||||
/// Programmes d'une chaîne dans un index XMLTV quelconque (panneau ou
|
||||
/// source externe), ou liste vide si l'index ne la connaît pas.
|
||||
Future<List<Map<String, dynamic>>> _fromXmltv(
|
||||
XmltvEpgService? xmltv,
|
||||
PlaylistConfig playlist,
|
||||
String channelId,
|
||||
) async {
|
||||
final xmltv = _xmltv;
|
||||
if (xmltv == null) return const [];
|
||||
|
||||
try {
|
||||
@@ -163,7 +257,7 @@ class EpgApi {
|
||||
.map((p) => p.toJson(channelId))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
print('[EpgApi] repli XMLTV indisponible pour $channelId : $e');
|
||||
print('[EpgApi] source XMLTV indisponible pour $channelId : $e');
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
@@ -180,7 +274,7 @@ class EpgApi {
|
||||
'?username=${playlist.username}&password=${playlist.password}'
|
||||
'&action=get_live_streams';
|
||||
final response =
|
||||
await http.get(Uri.parse(url)).timeout(const Duration(seconds: 90));
|
||||
await _http.get(Uri.parse(url)).timeout(const Duration(seconds: 90));
|
||||
|
||||
final epgIds = <String, String>{};
|
||||
final names = <String, String>{};
|
||||
|
||||
Reference in new issue
Block a user