From da67c479b3ff5a65764afd8641eb7ecdcd3e7092 Mon Sep 17 00:00:00 2001 From: Michael Date: Sat, 10 Oct 2026 09:11:54 +0200 Subject: [PATCH] Guide TV : indexer le XMLTV hors de la boucle principale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le serveur n'a qu'une boucle d'événements. La décompression et le parsing des dumps XMLTV (7000+ chaînes, ~90 Mo) la gelaient pendant plusieurs secondes : plus rien ne répondait, y compris le relais turbo.ts. La lecture se coupait donc au démarrage, au moment où l'écran des chaînes demandait le guide (EPG à 19-26 s dans les logs). - Téléchargement inchangé, puis gunzip + utf8 + parse dans Isolate.run - _parse devient statique (aucune capture de this, client HTTP non transférable) - Test : la boucle d'événements ne doit pas geler plus de 250 ms pendant l'indexation d'un dump de taille réelle (1,65 s avant) Co-Authored-By: Claude Opus 5.5 --- bin/services/xmltv_epg_service.dart | 49 ++++++++++++++++++--- bin/test/xmltv_epg_test.dart | 66 +++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 7 deletions(-) diff --git a/bin/services/xmltv_epg_service.dart b/bin/services/xmltv_epg_service.dart index 809841a..06b750d 100644 --- a/bin/services/xmltv_epg_service.dart +++ b/bin/services/xmltv_epg_service.dart @@ -1,5 +1,7 @@ import 'dart:convert'; import 'dart:io'; +import 'dart:isolate'; +import 'dart:typed_data'; import 'package:http/http.dart' as http; import 'package:xml/xml_events.dart'; @@ -115,8 +117,7 @@ class XmltvEpgService { for (final url in sourceUrls) { try { - final body = await _download(url); - final parsed = _parse(body); + final parsed = await _downloadAndParse(url); // Première source servie gagne : les suivantes ne comblent que les // chaînes encore absentes. for (final entry in parsed.entries) { @@ -144,26 +145,60 @@ class XmltvEpgService { print('[XmltvEpg] index prêt : ${merged.length} chaînes'); } - Future _download(String url) async { + /// Télécharge [url] puis l'indexe dans un isolate dédié. + /// + /// Le serveur n'a qu'une boucle d'événements : décompresser et parser un + /// dump de plusieurs milliers de chaînes dessus la gelait pendant des + /// secondes. Plus rien d'autre ne répondait — en particulier le relais + /// `turbo.ts`, qui cessait d'envoyer des octets au lecteur : la lecture + /// se coupait systématiquement au démarrage, au moment précis où l'écran + /// des chaînes demandait le guide. + Future>> _downloadAndParse( + String url, + ) async { final response = await _client.get(Uri.parse(url)).timeout(downloadTimeout); if (response.statusCode != 200) { throw HttpException('HTTP ${response.statusCode}'); } - List bytes = response.bodyBytes; + // Variables locales : la fermeture envoyée à l'isolate ne doit pas + // capturer `this` (le client HTTP n'est pas transférable). + final bytes = response.bodyBytes; + final retention = this.retention; + final horizon = this.horizon; + return Isolate.run( + () => _decodeAndParse(bytes, retention: retention, horizon: horizon), + ); + } + + static Map> _decodeAndParse( + Uint8List raw, { + required Duration retention, + required Duration horizon, + }) { + List bytes = raw; // Beaucoup de miroirs servent du .gz sans en-tête Content-Encoding : on // regarde le nombre magique plutôt que de se fier aux en-têtes. if (bytes.length > 2 && bytes[0] == 0x1f && bytes[1] == 0x8b) { bytes = gzip.decode(bytes); } - return utf8.decode(bytes, allowMalformed: true); + return _parse( + utf8.decode(bytes, allowMalformed: true), + retention: retention, + horizon: horizon, + ); } /// Exposé pour les tests. - Map> parseForTest(String xml) => _parse(xml); + Map> parseForTest(String xml) => + _parse(xml, retention: retention, horizon: horizon); - Map> _parse(String xml) { + static Map> _parse( + String xml, { + required Duration retention, + required Duration horizon, + }) { final cutoff = DateTime.now().toUtc().subtract(retention); final limit = DateTime.now().toUtc().add(horizon); final byChannel = >{}; diff --git a/bin/test/xmltv_epg_test.dart b/bin/test/xmltv_epg_test.dart index 05d4311..348ccc4 100644 --- a/bin/test/xmltv_epg_test.dart +++ b/bin/test/xmltv_epg_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:test/test.dart'; @@ -148,6 +150,70 @@ void main() { }); }); + group('réactivité du serveur', () { + test('l’indexation ne gèle pas la boucle d’événements', () async { + // Le serveur n'a qu'un isolate : décoder et parser un dump de + // plusieurs milliers de chaînes dessus bloquait tout le reste pendant + // des secondes — y compris le relais `turbo.ts` des flux en cours, + // d'où des coupures systématiques au démarrage de la lecture. + final now = DateTime.now().toUtc(); + String stamp(DateTime d) { + String two(int v) => v.toString().padLeft(2, '0'); + return '${d.year}${two(d.month)}${two(d.day)}' + '${two(d.hour)}${two(d.minute)}${two(d.second)} +0000'; + } + + // Volume d'un vrai dump de panneau : ~7000 chaînes, résumés de + // plusieurs phrases. + final desc = 'Résumé du programme, avec assez de texte pour peser ' + 'comme un vrai guide. ' * 4; + final xml = StringBuffer(''); + for (var c = 0; c < 7000; c++) { + xml.write('' + 'FR - CHAINE $c'); + for (var p = 0; p < 30; p++) { + final start = now.add(Duration(minutes: 30 * p)); + final stop = start.add(const Duration(minutes: 30)); + xml.write('' + 'Programme $p$desc' + ''); + } + } + xml.write(''); + final body = xml.toString(); + + final service = XmltvEpgService( + sourceUrls: const ['http://dump.example/epg.xml'], + client: MockClient((_) async => http.Response(body, 200)), + ); + + // Un « battement » toutes les 10 ms : le plus long écart observé est + // le temps pendant lequel la boucle d'événements est restée bloquée. + var last = DateTime.now(); + var worstGap = Duration.zero; + final heartbeat = Timer.periodic(const Duration(milliseconds: 10), (_) { + final now = DateTime.now(); + final gap = now.difference(last); + if (gap > worstGap) worstGap = gap; + last = now; + }); + + await service.ensureFresh(); + heartbeat.cancel(); + // Le dernier blocage n'est suivi d'aucun battement : le compter aussi. + final tail = DateTime.now().difference(last); + if (tail > worstGap) worstGap = tail; + + expect(service.channelCount, greaterThanOrEqualTo(7000)); + expect( + worstGap, + lessThan(const Duration(milliseconds: 250)), + reason: 'boucle d’événements gelée ${worstGap.inMilliseconds} ms', + ); + }); + }); + group('horizon', () { test('écarte les programmes au-delà de l’horizon d’indexation', () { // Un dump national couvre sept jours : tout garder ferait grossir