diff --git a/bin/api/epg_api.dart b/bin/api/epg_api.dart index d0aea4b..2082905 100644 --- a/bin/api/epg_api.dart +++ b/bin/api/epg_api.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:isolate'; import 'package:shelf/shelf.dart'; import 'package:http/http.dart' as http; import '../models/playlist_config.dart'; @@ -97,10 +98,15 @@ class EpgApi { // 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. + // + // Le dump externe passe ensuite AVANT l'interrogation chaîne par + // chaîne : il est déjà indexé en mémoire (réponse instantanée), alors + // que `player_api` coûte plusieurs secondes par chaîne et échouait en + // prod — ce qui laissait toute la grille sans guide. final sources = <(String, Future>> Function())>[ ('panel-xmltv', () => _panelProgrammes(playlist, channelId)), - ('xtream', () => _xtreamProgrammes(playlist, channelId)), ('xmltv', () => _xmltvProgrammes(playlist, channelId)), + ('xtream', () => _xtreamProgrammes(playlist, channelId)), ]; var epgData = { @@ -133,10 +139,13 @@ class EpgApi { final jsonStr = json.encode(epgData); - // Mettre en cache 30 minutes + // 30 min pour un guide trouvé ; 5 min seulement pour un guide vide, + // qui vient souvent d'une source momentanément indisponible et ne + // doit pas priver la chaîne de guide pendant une demi-heure. _cache[cacheKey] = _CacheEntry( data: jsonStr, - expiresAt: DateTime.now().add(const Duration(minutes: 30)), + expiresAt: DateTime.now() + .add(Duration(minutes: found ? 30 : 5)), ); return Response.ok( @@ -191,8 +200,19 @@ class EpgApi { '&password=${playlist.password}' '&action=$action&stream_id=$channelId'; - final response = - await _http.get(Uri.parse(url)).timeout(const Duration(seconds: 60)); + final http.Response response; + try { + response = await _http + .get(Uri.parse(url)) + .timeout(const Duration(seconds: 20)); + } catch (e) { + // Délai dépassé, connexion coupée… : une source parmi d'autres, pas + // une raison de répondre 500 (ce qui privait la chaîne des sources + // suivantes). L'exception recopie l'URL, identifiants compris. + print('[EpgApi] player_api $action indisponible pour $channelId : ' + '${LogRedactor.redactUrl('$e')}'); + continue; + } if (response.statusCode != 200) continue; Map parsed; @@ -267,41 +287,85 @@ class EpgApi { } } + /// `stream_id` → (`epg_channel_id`, nom) depuis la réponse + /// `get_live_streams`. + static (Map, Map) _parseChannelTable( + String body, + ) { + final epgIds = {}; + final names = {}; + final decoded = json.decode(body); + if (decoded is List) { + for (final item in decoded) { + if (item is! Map) continue; + final streamId = item['stream_id']?.toString(); + if (streamId == null || streamId.isEmpty) continue; + final epgId = item['epg_channel_id']?.toString(); + if (epgId != null && epgId.isNotEmpty) epgIds[streamId] = epgId; + final name = item['name']?.toString(); + if (name != null && name.isNotEmpty) names[streamId] = name; + } + } + return (epgIds, names); + } + + /// Téléchargements de la table des chaînes en cours, par compte. + final Map> _channelMapsInFlight = {}; + /// Table des chaînes du compte, rafraîchie toutes les 6 heures. - Future<_ChannelMap> _channelMapFor(PlaylistConfig playlist) async { + /// + /// Les requêtes simultanées partagent un seul téléchargement : la grille + /// demande le guide de 30 chaînes d'un coup, et chacune déclenchait sinon + /// son propre `get_live_streams` (7 Mo en prod). + Future<_ChannelMap> _channelMapFor(PlaylistConfig playlist) { final key = '${playlist.dns}|${playlist.username}'; final cached = _channelMaps[key]; if (cached != null && DateTime.now().isBefore(cached.expiresAt)) { - return cached; + return Future.value(cached); } + return _channelMapsInFlight[key] ??= + _loadChannelMap(playlist, key).whenComplete(() { + _channelMapsInFlight.remove(key); + }); + } + Future<_ChannelMap> _loadChannelMap( + PlaylistConfig playlist, + String key, + ) async { final url = '${playlist.dns}/player_api.php' '?username=${playlist.username}&password=${playlist.password}' '&action=get_live_streams'; - final response = - await _http.get(Uri.parse(url)).timeout(const Duration(seconds: 90)); + http.Response? response; + try { + response = + await _http.get(Uri.parse(url)).timeout(const Duration(seconds: 90)); + } catch (e) { + print('[EpgApi] table des chaînes indisponible : ' + '${LogRedactor.redactUrl('$e')}'); + } - final epgIds = {}; - final names = {}; - if (response.statusCode == 200) { - final decoded = json.decode(response.body); - if (decoded is List) { - for (final item in decoded) { - if (item is! Map) continue; - final streamId = item['stream_id']?.toString(); - if (streamId == null || streamId.isEmpty) continue; - final epgId = item['epg_channel_id']?.toString(); - if (epgId != null && epgId.isNotEmpty) epgIds[streamId] = epgId; - final name = item['name']?.toString(); - if (name != null && name.isNotEmpty) names[streamId] = name; - } + var epgIds = {}; + var names = {}; + if (response != null && response.statusCode == 200) { + // Décodage dans un isolate : 7 Mo de JSON sur la boucle principale la + // gelaient, et le relais turbo.ts cessait d'émettre pendant ce temps. + final body = response.body; + try { + (epgIds, names) = await Isolate.run(() => _parseChannelTable(body)); + } catch (e) { + print('[EpgApi] table des chaînes illisible : ${e.runtimeType}'); } } + // Une table vide (panneau en échec) n'est gardée que 2 minutes : la + // garder 6 h privait TOUTES les chaînes du repli XMLTV pendant 6 h. final map = _ChannelMap( epgIds: epgIds, names: names, - expiresAt: DateTime.now().add(const Duration(hours: 6)), + expiresAt: DateTime.now().add( + names.isEmpty ? const Duration(minutes: 2) : const Duration(hours: 6), + ), ); _channelMaps[key] = map; return map; diff --git a/bin/services/xmltv_epg_service.dart b/bin/services/xmltv_epg_service.dart index 31b0efa..213e3aa 100644 --- a/bin/services/xmltv_epg_service.dart +++ b/bin/services/xmltv_epg_service.dart @@ -82,8 +82,11 @@ class XmltvEpgService { String? displayName, }) async { await ensureFresh(); - for (final candidate in [channelId, displayName]) { - final key = normalizeKey(candidate); + final keys = [ + normalizeKey(channelId), + if (displayName != null) ...nameKeys(displayName), + ]; + for (final key in keys) { if (key.isEmpty) continue; final hit = _index[key]; if (hit != null && hit.isNotEmpty) return hit; @@ -91,6 +94,56 @@ class XmltvEpgService { return const []; } + /// 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*', + ); + + /// Marqueurs de qualité ou de source en fin de nom, sans rapport avec la + /// chaîne elle-même. + static final _qualitySuffix = RegExp( + r'(\s+|^)(fhd|uhd|hd|sd|hq|lq|4k|8k|hevc|h\.?265|h\.?264|1080p?|720p?|' + r'50fps|60fps|backup|raw|vip|multi|\(backup\)|\(multi\))\s*$', + caseSensitive: false, + ); + + /// Clés candidates pour retrouver une chaîne du panneau par son nom. + /// + /// POURQUOI : en prod, 59 % des chaînes françaises n'ont pas + /// d'`epg_channel_id`. Leur nom brut (« FR - TF1 FHD ◉ » → `frtf1fhd`) ne + /// correspond à aucune entrée XMLTV (`TF1.fr`, « TF1 »). On retire le + /// préfixe pays, les marqueurs de qualité et les symboles, puis on tente + /// aussi la forme `nom.pays` qu'utilisent les dumps publics. + /// + /// Le décalage horaire (« +1 ») est conservé : TF1 +1 n'a pas le guide + /// de TF1. + static List nameKeys(String name) { + final keys = [normalizeKey(name)]; + + var cleaned = name; + String? country; + final prefix = _countryPrefix.firstMatch(cleaned); + if (prefix != null) { + country = (prefix.group(1) ?? prefix.group(2))?.toLowerCase(); + cleaned = cleaned.substring(prefix.end); + } + // 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(); + 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(); + } + /// Recharge l'index s'il est absent ou périmé. Les appels concurrents /// partagent le même téléchargement. Future ensureFresh() { @@ -212,10 +265,12 @@ class XmltvEpgService { final limit = DateTime.now().toUtc().add(horizon); final byChannel = >{}; - // Alias : plusieurs dumps déclarent avec un - // différent de l'identifiant. On indexe les deux pour - // maximiser les correspondances. + // Alias : plusieurs dumps déclarent avec un ou plusieurs + // différents de l'identifiant (« TF1 HD », « TF1 »). On + // les indexe TOUS : seul le premier l'était, et c'était souvent la + // variante la moins proche du nom annoncé par le panneau. final aliases = {}; + final displayNames = []; String? channelId; String? programmeChannel; @@ -232,6 +287,7 @@ class XmltvEpgService { case 'channel': channelId = _attr(event, 'id'); displayName.clear(); + displayNames.clear(); case 'programme': programmeChannel = _attr(event, 'channel'); start = parseXmltvDate(_attr(event, 'start')); @@ -254,15 +310,20 @@ class XmltvEpgService { case 'desc': desc.write(text); case 'display-name': - if (displayName.isEmpty) displayName.write(text); + displayName.write(text); } } else if (event is XmlEndElementEvent) { switch (event.name) { + case 'display-name': + displayNames.add(displayName.toString()); + displayName.clear(); case 'channel': final id = normalizeKey(channelId); - final name = normalizeKey(displayName.toString()); - if (id.isNotEmpty && name.isNotEmpty && id != name) { - aliases[name] = id; + for (final raw in displayNames) { + final name = normalizeKey(raw); + if (id.isNotEmpty && name.isNotEmpty && id != name) { + aliases.putIfAbsent(name, () => id); + } } channelId = null; case 'programme': diff --git a/bin/test/epg_api_test.dart b/bin/test/epg_api_test.dart index a3b3be5..46e1f44 100644 --- a/bin/test/epg_api_test.dart +++ b/bin/test/epg_api_test.dart @@ -217,5 +217,111 @@ void main() { expect(response.headers['X-Epg-Source'], 'xtream'); expect(actions, contains('get_simple_data_table')); }); + + // Dump externe couvrant TF1, et panneau dont le xmltv.php est vide. + String externalDump() { + final now = DateTime.now().toUtc(); + return xmltvDump(now, now.add(const Duration(hours: 1))) + .replaceAll('France2.fr', 'TF1.fr') + .replaceAll('FRANCE 2', 'TF1'); + } + + EpgApi apiWith(http.Client client) => EpgApi( + (_) async => playlist, + httpClient: client, + xmltv: XmltvEpgService( + sourceUrls: const ['http://ext.example/epg.xml'], + client: client, + ), + panelXmltvBuilder: (config) => XmltvEpgService( + sourceUrls: ['${config.dns}/xmltv.php'], + client: client, + ), + ); + + test('un panneau qui plante ne prive plus la chaîne du dump externe', + () async { + // Constaté en prod : chaque /api/epg répondait 500 en 4 s, le guide + // externe (qui couvrait pourtant la chaîne) n'étant jamais consulté. + final client = MockClient((request) async { + final url = request.url; + if (url.host == 'ext.example') return http.Response(externalDump(), 200); + if (url.path.endsWith('/xmltv.php')) return http.Response('', 200); + final action = url.queryParameters['action'] ?? ''; + if (action == 'get_live_streams') { + return http.Response( + jsonEncode([ + {'stream_id': 1, 'name': 'FR - TF1 FHD ◉', 'epg_channel_id': ''}, + ]), + 200, + ); + } + throw http.ClientException('Connection closed', url); + }); + + final response = await apiWith(client).handleGetEpg(requestFor('1'), '1'); + final body = jsonDecode(await response.readAsString()) as Map; + + expect(response.statusCode, 200); + expect(response.headers['X-Epg-Source'], 'xmltv'); + expect((body['programmes'] as List).single['title'], 'Journal de 20h'); + }); + + test('le dump externe passe avant l\'appel lent chaîne par chaîne', + () async { + final actions = []; + final client = MockClient((request) async { + final url = request.url; + if (url.host == 'ext.example') return http.Response(externalDump(), 200); + if (url.path.endsWith('/xmltv.php')) return http.Response('', 200); + final action = url.queryParameters['action'] ?? ''; + actions.add(action); + if (action == 'get_live_streams') { + return http.Response( + jsonEncode([ + {'stream_id': 1, 'name': 'TF1', 'epg_channel_id': 'TF1.fr'}, + ]), + 200, + ); + } + return http.Response('{}', 200); + }); + + final response = await apiWith(client).handleGetEpg(requestFor('1'), '1'); + expect(response.headers['X-Epg-Source'], 'xmltv'); + expect(actions, isNot(contains('get_simple_data_table'))); + }); + + test('la grille entière partage un seul téléchargement de la table', + () async { + // 30 tuiles demandent leur guide d'un coup : sans mutualisation, autant + // de `get_live_streams` (7 Mo en prod) partaient en parallèle. + var liveStreamsCalls = 0; + final client = MockClient((request) async { + final url = request.url; + if (url.host == 'ext.example') return http.Response(externalDump(), 200); + if (url.path.endsWith('/xmltv.php')) return http.Response('', 200); + final action = url.queryParameters['action'] ?? ''; + if (action == 'get_live_streams') { + liveStreamsCalls++; + await Future.delayed(const Duration(milliseconds: 50)); + return http.Response( + jsonEncode([ + for (var i = 1; i <= 5; i++) + {'stream_id': i, 'name': 'TF1', 'epg_channel_id': 'TF1.fr'}, + ]), + 200, + ); + } + return http.Response('{}', 200); + }); + + final api = apiWith(client); + final responses = await Future.wait([ + for (var i = 1; i <= 5; i++) api.handleGetEpg(requestFor('$i'), '$i'), + ]); + expect(responses.every((r) => r.statusCode == 200), isTrue); + expect(liveStreamsCalls, 1); + }); }); } diff --git a/bin/test/xmltv_epg_test.dart b/bin/test/xmltv_epg_test.dart index 348ccc4..eb05dbc 100644 --- a/bin/test/xmltv_epg_test.dart +++ b/bin/test/xmltv_epg_test.dart @@ -28,6 +28,13 @@ String _fixture(DateTime start, DateTime stop) { '''; } +String _stamp(DateTime d) { + final u = d.toUtc(); + String two(int v) => v.toString().padLeft(2, '0'); + return '${u.year}${two(u.month)}${two(u.day)}' + '${two(u.hour)}${two(u.minute)}${two(u.second)} +0000'; +} + void main() { final service = XmltvEpgService(sourceUrls: const []); @@ -51,6 +58,71 @@ void main() { }); }); + group('nameKeys', () { + // Noms réels du panneau (prod) : 59 % des chaînes françaises n'ont pas + // d'epg_channel_id, seul leur nom permet de retrouver le guide. + test('retire le préfixe pays et les marqueurs de qualité', () { + expect(XmltvEpgService.nameKeys('FR - TF1 FHD ◉'), contains('tf1')); + expect(XmltvEpgService.nameKeys('FR - FRANCE 4 FHD'), contains('france4')); + expect(XmltvEpgService.nameKeys('|FR| M6 HD'), contains('m6')); + expect(XmltvEpgService.nameKeys('[FR] Arte UHD 4K'), contains('arte')); + expect(XmltvEpgService.nameKeys('FR: RMC Story HEVC'), contains('rmcstory')); + }); + + test('propose aussi la forme identifiant « nom.pays »', () { + // Les dumps publics identifient souvent la chaîne par `TF1.fr`. + expect(XmltvEpgService.nameKeys('FR - TF1 FHD'), contains('tf1fr')); + }); + + test('garde la variante décalée distincte de la chaîne principale', () { + expect(XmltvEpgService.nameKeys('FR - TF1 +1 FHD'), isNot(contains('tf1'))); + }); + + test('ne réduit pas un nom court à rien', () { + expect(XmltvEpgService.nameKeys('M6'), contains('m6')); + expect(XmltvEpgService.nameKeys('HD'), isNot(contains(''))); + }); + }); + + group('programmesFor par nom', () { + test('trouve le guide d\'une chaîne sans epg_channel_id par son nom nettoyé', + () async { + final now = DateTime.now().toUtc(); + String stamp(DateTime d) { + final u = d.toUtc(); + String two(int v) => v.toString().padLeft(2, '0'); + return '${u.year}${two(u.month)}${two(u.day)}' + '${two(u.hour)}${two(u.minute)}${two(u.second)} +0000'; + } + + final dump = ''' + + TF1 HDTF1 + + Le 13h + + +'''; + final svc = XmltvEpgService( + sourceUrls: const ['http://ext/epg.xml'], + client: MockClient((_) async => http.Response(dump, 200)), + ); + final hits = await svc.programmesFor(null, displayName: 'FR - TF1 FHD ◉'); + expect(hits.single.title, 'Le 13h'); + }); + + test('indexe chaque display-name, pas seulement le premier', () { + final now = DateTime.now().toUtc(); + final index = service.parseForTest(''' + + Premier NomSecond Nom + T + +'''); + expect(index[XmltvEpgService.normalizeKey('Second Nom')], isNotNull); + }); + }); + group('parseXmltvDate', () { test('applique le décalage horaire annoncé', () { expect(