From 4d00409de825a66c89834c578593cd1261bc6c58 Mon Sep 17 00:00:00 2001 From: Michael Date: Sat, 10 Oct 2026 23:10:34 +0200 Subject: [PATCH] =?UTF-8?q?Guide=20TV=20:=20r=C3=A9gionales=20France=203,?= =?UTF-8?q?=20variantes=20de=20nom,=20cha=C3=AEnes=20+1,=20CH=20et=20BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 2 +- bin/server.dart | 4 +- bin/services/xmltv_epg_service.dart | 107 +++++++++++++++++++++++++--- bin/test/xmltv_epg_test.dart | 47 ++++++++++++ docker-compose.prod.yml | 2 +- docker-compose.yml | 2 +- 6 files changed, 151 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index c5bfc73..8fba73c 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/bin/server.dart b/bin/server.dart index 951fc17..c34bc45 100644 --- a/bin/server.dart +++ b/bin/server.dart @@ -132,7 +132,9 @@ void main(List 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) diff --git a/bin/services/xmltv_epg_service.dart b/bin/services/xmltv_epg_service.dart index 213e3aa..2df8d25 100644 --- a/bin/services/xmltv_epg_service.dart +++ b/bin/services/xmltv_epg_service.dart @@ -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 _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 nameKeys(String name) { final keys = [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 && diff --git a/bin/test/xmltv_epg_test.dart b/bin/test/xmltv_epg_test.dart index eb05dbc..d896164 100644 --- a/bin/test/xmltv_epg_test.dart +++ b/bin/test/xmltv_epg_test.dart @@ -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) => ''' + $name + $title'''; + + test('« F3 ALPES » du panneau trouve France.3.-.Alpes.fr', () async { + final now = DateTime.now().toUtc(); + final svc = serviceWith('${channel('France.3.-.Alpes.fr', 'France 3 Alpes', 'JT Alpes', now)}'); + 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('${channel('AB3.Channel.fr', 'AB3 Channel', 'Série', now)}'); + 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('${channel('TF1.fr', 'TF1', 'Le 13h', now)}'); + 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('' + '${channel('Boomerang.fr', 'Boomerang', 'Normal', now)}' + '${channel('Boomerang.+1.fr', 'Boomerang +1', 'Décalé', now)}'); + 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 { diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 7a1f197..6b44469 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index aa34c42..ee27b95 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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