diff --git a/bin/api/epg_api.dart b/bin/api/epg_api.dart index 62a2ad4..fab003f 100644 --- a/bin/api/epg_api.dart +++ b/bin/api/epg_api.dart @@ -2,12 +2,22 @@ import 'dart:convert'; import 'package:shelf/shelf.dart'; import 'package:http/http.dart' as http; import '../models/playlist_config.dart'; +import '../services/xmltv_epg_service.dart'; /// API EPG — proxy vers Xtream avec cache 30 minutes /// GET /api/epg/?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. class EpgApi { final Future Function(Request) _getPlaylist; + /// Repli XMLTV. `null` quand aucune source n'est configurée : l'API se + /// comporte alors exactement comme avant, sans le moindre appel sortant. + final XmltvEpgService? _xmltv; + /// Cache mémoire : « dns|username|channelId » → {data, expiresAt}. /// /// La clé inclut le compte : les `stream_id` sont propres à chaque panneau, @@ -15,7 +25,12 @@ class EpgApi { /// identifiant et se servaient alors le guide l'une de l'autre. final Map _cache = {}; - EpgApi(this._getPlaylist); + /// Correspondance `stream_id` → identifiant EPG et nom, par compte. + /// Nécessaire au repli : le dump XMLTV indexe par identifiant de chaîne, + /// pas par `stream_id` propre au panneau. + final Map _channelMaps = {}; + + EpgApi(this._getPlaylist, {XmltvEpgService? xmltv}) : _xmltv = xmltv; Future handleGetEpg(Request request, String channelId) async { final playlist = await _getPlaylist(request); @@ -71,6 +86,15 @@ class EpgApi { if ((epgData['programmes'] as List).isNotEmpty) break; } + var source = 'xtream'; + if (!_hasCurrentProgramme(epgData)) { + final fallback = await _xmltvProgrammes(playlist, channelId); + if (fallback.isNotEmpty) { + epgData = {'channel_id': channelId, 'programmes': fallback}; + source = 'xmltv'; + } + } + final jsonStr = json.encode(epgData); // Mettre en cache 30 minutes @@ -81,7 +105,11 @@ class EpgApi { return Response.ok( jsonStr, - headers: {'Content-Type': 'application/json', 'X-Cache': 'MISS'}, + headers: { + 'Content-Type': 'application/json', + 'X-Cache': 'MISS', + 'X-Epg-Source': source, + }, ); } catch (e) { return Response.internalServerError( @@ -91,6 +119,91 @@ class EpgApi { } } + /// Le guide contient-il un programme en cours ou à venir ? + /// + /// Un panneau dont l'EPG est figé répond avec des centaines de programmes, + /// tous terminés depuis plusieurs jours. Compter les entrées ne suffit donc + /// pas à décider si le guide est exploitable. + static bool _hasCurrentProgramme(Map epgData) { + final now = DateTime.now().toUtc(); + for (final programme in epgData['programmes'] as List) { + if (programme is! Map) continue; + final end = DateTime.tryParse('${programme['end']}'); + if (end != null && end.isAfter(now)) return true; + } + return false; + } + + /// Programmes issus du dump XMLTV, ou liste vide si indisponible. + Future>> _xmltvProgrammes( + PlaylistConfig playlist, + String channelId, + ) async { + final xmltv = _xmltv; + if (xmltv == null) return const []; + + try { + final channels = await _channelMapFor(playlist); + final epgId = channels.epgIds[channelId]; + final name = channels.names[channelId]; + if (epgId == null && name == null) return const []; + + final programmes = + await xmltv.programmesFor(epgId, displayName: name); + if (programmes.isEmpty) return const []; + + final now = DateTime.now().toUtc(); + return programmes + .where((p) => p.stop.isAfter(now)) + .take(64) + .map((p) => p.toJson(channelId)) + .toList(); + } catch (e) { + print('[EpgApi] repli XMLTV indisponible pour $channelId : $e'); + return const []; + } + } + + /// Table des chaînes du compte, rafraîchie toutes les 6 heures. + Future<_ChannelMap> _channelMapFor(PlaylistConfig playlist) async { + final key = '${playlist.dns}|${playlist.username}'; + final cached = _channelMaps[key]; + if (cached != null && DateTime.now().isBefore(cached.expiresAt)) { + return cached; + } + + 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)); + + 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; + } + } + } + + final map = _ChannelMap( + epgIds: epgIds, + names: names, + expiresAt: DateTime.now().add(const Duration(hours: 6)), + ); + _channelMaps[key] = map; + return map; + } + /// Normalise une réponse Xtream (`epg_listings`) vers le format servi au /// client. Public pour être couvert par `bin/test/epg_api_test.dart`. Map transformEpgData(dynamic raw, String channelId) { @@ -167,6 +280,17 @@ class EpgApi { } } +class _ChannelMap { + final Map epgIds; + final Map names; + final DateTime expiresAt; + _ChannelMap({ + required this.epgIds, + required this.names, + required this.expiresAt, + }); +} + class _CacheEntry { final String data; final DateTime expiresAt; diff --git a/bin/pubspec.yaml b/bin/pubspec.yaml index 9ad22f9..589c351 100644 --- a/bin/pubspec.yaml +++ b/bin/pubspec.yaml @@ -19,6 +19,7 @@ dependencies: uuid: ^4.3.3 hive: ^2.2.3 equatable: ^2.0.5 + xml: ^6.5.0 dev_dependencies: test: ^1.25.0 diff --git a/bin/server.dart b/bin/server.dart index 52cc2cc..0db4644 100644 --- a/bin/server.dart +++ b/bin/server.dart @@ -18,6 +18,7 @@ import 'api/streaming_handler.dart'; import 'api/proxy_handler.dart'; import 'api/recordings_api.dart'; import 'api/epg_api.dart'; +import 'services/xmltv_epg_service.dart'; import 'api/season_passes_api.dart'; import 'api/xtream_api_handler.dart'; import 'middleware/auth_middleware.dart'; @@ -115,7 +116,23 @@ void main(List args) async { final settingsHandler = SettingsHandler(db); final proxyHandler = ProxyHandler(getPlaylist); final recordingsApi = RecordingsApi(db, recordingScheduler); - final epgApi = EpgApi(getPlaylist); + // 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') + .split(',') + .map((u) => u.trim()) + .where((u) => u.isNotEmpty) + .toList(); + if (xmltvUrls.isEmpty) { + print('[EPG] Repli XMLTV désactivé (EPG_XMLTV_URLS vide)'); + } else { + print('[EPG] Repli XMLTV : ${xmltvUrls.length} source(s)'); + } + final epgApi = EpgApi( + getPlaylist, + xmltv: xmltvUrls.isEmpty ? null : XmltvEpgService(sourceUrls: xmltvUrls), + ); final seasonPassesApi = SeasonPassesApi(db); final xtreamApiHandler = XtreamApiHandler(getPlaylist); diff --git a/bin/services/xmltv_epg_service.dart b/bin/services/xmltv_epg_service.dart new file mode 100644 index 0000000..81fbcc2 --- /dev/null +++ b/bin/services/xmltv_epg_service.dart @@ -0,0 +1,305 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:http/http.dart' as http; +import 'package:xml/xml_events.dart'; + +/// Guide TV construit à partir de dumps XMLTV publics. +/// +/// Beaucoup de panneaux Xtream servent un EPG figé depuis plusieurs jours, ou +/// n'en servent aucun : `get_simple_data_table` répond alors correctement mais +/// avec des programmes périmés. Ce service télécharge des dumps XMLTV +/// indépendants, les indexe par chaîne, et permet de compléter le guide. +/// +/// Le parsing est fait en flux (`XmlEventReader`) : un dump national pèse +/// couramment 50 Mo décompressés, en charger l'arbre DOM complet coûterait +/// plusieurs centaines de mégaoctets dans le conteneur. +class XmltvEpgService { + XmltvEpgService({ + required this.sourceUrls, + this.refreshInterval = const Duration(hours: 6), + this.retention = const Duration(hours: 6), + http.Client? client, + }) : _client = client ?? http.Client(); + + /// Dumps XMLTV à agréger, dans l'ordre de priorité décroissante. + final List sourceUrls; + + /// Fréquence de rafraîchissement de l'index. + final Duration refreshInterval; + + /// Les programmes terminés depuis plus longtemps que cette durée sont + /// écartés à l'indexation : personne ne consulte le guide d'hier, et les + /// garder double la taille de l'index. + final Duration retention; + + final http.Client _client; + + /// clé de chaîne normalisée → programmes triés par heure de début. + Map> _index = {}; + DateTime? _indexedAt; + Future? _refreshInFlight; + + bool get hasData => _index.isNotEmpty; + DateTime? get indexedAt => _indexedAt; + int get channelCount => _index.length; + + /// Programmes d'une chaîne, ou liste vide si inconnue. + /// + /// [channelId] est l'`epg_channel_id` renvoyé par Xtream ; [displayName] est + /// le nom de la chaîne, utilisé en second recours car les dumps publics + /// construisent souvent leur identifiant à partir du nom affiché. + Future> programmesFor( + String? channelId, { + String? displayName, + }) async { + await ensureFresh(); + for (final candidate in [channelId, displayName]) { + final key = normalizeKey(candidate); + if (key.isEmpty) continue; + final hit = _index[key]; + if (hit != null && hit.isNotEmpty) return hit; + } + return const []; + } + + /// Recharge l'index s'il est absent ou périmé. Les appels concurrents + /// partagent le même téléchargement. + Future ensureFresh() { + final age = _indexedAt == null + ? null + : DateTime.now().difference(_indexedAt!); + if (age != null && age < refreshInterval) return Future.value(); + return _refreshInFlight ??= _refresh().whenComplete(() { + _refreshInFlight = null; + }); + } + + Future _refresh() async { + if (sourceUrls.isEmpty) return; + + final merged = >{}; + var ok = 0; + + for (final url in sourceUrls) { + try { + final body = await _download(url); + final parsed = _parse(body); + // Première source servie gagne : les suivantes ne comblent que les + // chaînes encore absentes. + for (final entry in parsed.entries) { + merged.putIfAbsent(entry.key, () => entry.value); + } + ok++; + print( + '[XmltvEpg] $url : ${parsed.length} chaînes indexées', + ); + } catch (e) { + print('[XmltvEpg] $url : échec ($e)'); + } + } + + if (ok == 0) { + // Garder l'index précédent plutôt que de servir un guide vide. + print('[XmltvEpg] aucune source disponible, index précédent conservé'); + return; + } + + _index = merged; + _indexedAt = DateTime.now(); + print('[XmltvEpg] index prêt : ${merged.length} chaînes'); + } + + Future _download(String url) async { + final response = await _client + .get(Uri.parse(url)) + .timeout(const Duration(minutes: 5)); + if (response.statusCode != 200) { + throw HttpException('HTTP ${response.statusCode}'); + } + + List bytes = response.bodyBytes; + // 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); + } + + /// Exposé pour les tests. + Map> parseForTest(String xml) => _parse(xml); + + Map> _parse(String xml) { + final cutoff = DateTime.now().toUtc().subtract(retention); + final byChannel = >{}; + + // Alias : plusieurs dumps déclarent avec un + // différent de l'identifiant. On indexe les deux pour + // maximiser les correspondances. + final aliases = {}; + + String? channelId; + String? programmeChannel; + DateTime? start; + DateTime? stop; + String? currentTag; + final title = StringBuffer(); + final desc = StringBuffer(); + final displayName = StringBuffer(); + + for (final event in parseEvents(xml)) { + if (event is XmlStartElementEvent) { + switch (event.name) { + case 'channel': + channelId = _attr(event, 'id'); + displayName.clear(); + case 'programme': + programmeChannel = _attr(event, 'channel'); + start = parseXmltvDate(_attr(event, 'start')); + stop = parseXmltvDate(_attr(event, 'stop')); + title.clear(); + desc.clear(); + case 'title': + case 'desc': + case 'display-name': + currentTag = event.name; + } + if (event.isSelfClosing) currentTag = null; + } else if (event is XmlTextEvent || event is XmlCDATAEvent) { + final text = event is XmlTextEvent + ? event.value + : (event as XmlCDATAEvent).value; + switch (currentTag) { + case 'title': + title.write(text); + case 'desc': + desc.write(text); + case 'display-name': + if (displayName.isEmpty) displayName.write(text); + } + } else if (event is XmlEndElementEvent) { + switch (event.name) { + case 'channel': + final id = normalizeKey(channelId); + final name = normalizeKey(displayName.toString()); + if (id.isNotEmpty && name.isNotEmpty && id != name) { + aliases[name] = id; + } + channelId = null; + case 'programme': + if (programmeChannel != null && + start != null && + stop != null && + stop.isAfter(cutoff)) { + final key = normalizeKey(programmeChannel); + if (key.isNotEmpty) { + byChannel.putIfAbsent(key, () => []).add( + XmltvProgramme( + title: title.toString().trim(), + description: desc.toString().trim(), + start: start, + stop: stop, + ), + ); + } + } + programmeChannel = null; + start = null; + stop = null; + } + currentTag = null; + } + } + + for (final list in byChannel.values) { + list.sort((a, b) => a.start.compareTo(b.start)); + } + + // Rendre les chaînes atteignables aussi par leur nom affiché. + aliases.forEach((name, id) { + final programmes = byChannel[id]; + if (programmes != null) byChannel.putIfAbsent(name, () => programmes); + }); + + return byChannel; + } + + static String _attr(XmlStartElementEvent event, String name) { + for (final attribute in event.attributes) { + if (attribute.name == name) return attribute.value; + } + return ''; + } + + /// Clé de correspondance : minuscules, sans ponctuation ni accents. + /// + /// Les dumps publics écrivent `France.2.fr` là où le panneau annonce + /// `France2.fr` ; sans normalisation, la moitié des chaînes ne trouvent + /// jamais leur guide. + static String normalizeKey(String? raw) { + if (raw == null) return ''; + final buffer = StringBuffer(); + for (final rune in raw.toLowerCase().runes) { + final char = String.fromCharCode(rune); + final folded = _accents[char] ?? char; + if (RegExp(r'[a-z0-9]').hasMatch(folded)) buffer.write(folded); + } + return buffer.toString(); + } + + static const _accents = { + 'à': 'a', 'á': 'a', 'â': 'a', 'ã': 'a', 'ä': 'a', 'å': 'a', + 'è': 'e', 'é': 'e', 'ê': 'e', 'ë': 'e', + 'ì': 'i', 'í': 'i', 'î': 'i', 'ï': 'i', + 'ò': 'o', 'ó': 'o', 'ô': 'o', 'õ': 'o', 'ö': 'o', + 'ù': 'u', 'ú': 'u', 'û': 'u', 'ü': 'u', + 'ç': 'c', 'ñ': 'n', + }; + + /// « 20260811200000 +0200 » → instant UTC. + static DateTime? parseXmltvDate(String? raw) { + if (raw == null || raw.length < 14) return null; + final digits = raw.substring(0, 14); + final base = DateTime.tryParse( + '${digits.substring(0, 4)}-${digits.substring(4, 6)}-' + '${digits.substring(6, 8)}T${digits.substring(8, 10)}:' + '${digits.substring(10, 12)}:${digits.substring(12, 14)}Z', + ); + if (base == null) return null; + + final offset = raw.length >= 20 ? raw.substring(15, 20) : null; + if (offset == null || offset.length != 5) return base; + + final sign = offset[0] == '-' ? -1 : 1; + final hours = int.tryParse(offset.substring(1, 3)); + final minutes = int.tryParse(offset.substring(3, 5)); + if (hours == null || minutes == null) return base; + + return base.subtract( + Duration(hours: sign * hours, minutes: sign * minutes), + ); + } +} + +class XmltvProgramme { + const XmltvProgramme({ + required this.title, + required this.description, + required this.start, + required this.stop, + }); + + final String title; + final String description; + final DateTime start; + final DateTime stop; + + Map toJson(String channelId) => { + 'title': title, + 'description': description, + 'start': start.toUtc().toIso8601String(), + 'end': stop.toUtc().toIso8601String(), + 'channel_id': channelId, + }; +} diff --git a/bin/test/xmltv_epg_test.dart b/bin/test/xmltv_epg_test.dart new file mode 100644 index 0000000..8bb9697 --- /dev/null +++ b/bin/test/xmltv_epg_test.dart @@ -0,0 +1,104 @@ +import 'package:test/test.dart'; +import '../services/xmltv_epg_service.dart'; + +/// Extrait représentatif d'un dump XMLTV public : décalage horaire explicite, +/// identifiant ponctué côté source là où le panneau annonce `France2.fr`, et +/// un `display-name` distinct de l'identifiant. +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'; + } + + return ''' + + + FR - FRANCE 2 + + Journal de 20h + L'info du soir + + +'''; +} + +void main() { + final service = XmltvEpgService(sourceUrls: const []); + + group('normalizeKey', () { + test('rapproche les identifiants ponctués différemment', () { + // Le cœur du repli : sans cette normalisation, `France2.fr` du panneau + // et `France.2.fr` du dump ne se rencontrent jamais. + expect( + XmltvEpgService.normalizeKey('France2.fr'), + XmltvEpgService.normalizeKey('France.2.fr'), + ); + }); + + test('replie la casse et les accents', () { + expect(XmltvEpgService.normalizeKey('Chérie 25'), 'cherie25'); + }); + + test('rend une clé vide sur une entrée nulle ou sans caractère utile', () { + expect(XmltvEpgService.normalizeKey(null), ''); + expect(XmltvEpgService.normalizeKey('...'), ''); + }); + }); + + group('parseXmltvDate', () { + test('applique le décalage horaire annoncé', () { + expect( + XmltvEpgService.parseXmltvDate('20260811200000 +0200'), + DateTime.utc(2026, 8, 11, 18), + ); + }); + + test('traite une date sans décalage comme de l UTC', () { + expect( + XmltvEpgService.parseXmltvDate('20260811200000'), + DateTime.utc(2026, 8, 11, 20), + ); + }); + + test('rejette une valeur tronquée', () { + expect(XmltvEpgService.parseXmltvDate('202608'), isNull); + }); + }); + + group('parse', () { + test('indexe un programme en cours et décode son titre', () { + final now = DateTime.now().toUtc(); + final index = service.parseForTest( + _fixture( + now.subtract(const Duration(minutes: 10)), + now.add(const Duration(minutes: 20)), + ), + ); + + final programmes = index[XmltvEpgService.normalizeKey('France2.fr')]; + expect(programmes, isNotNull); + expect(programmes!.single.title, 'Journal de 20h'); + expect(programmes.single.description, "L'info du soir"); + }); + + test('rend la chaîne atteignable par son nom affiché', () { + final now = DateTime.now().toUtc(); + final index = service.parseForTest( + _fixture(now, now.add(const Duration(minutes: 20))), + ); + + expect(index[XmltvEpgService.normalizeKey('FR - FRANCE 2')], isNotNull); + }); + + test('écarte les programmes terminés hors fenêtre de rétention', () { + final old = DateTime.now().toUtc().subtract(const Duration(days: 2)); + final index = service.parseForTest( + _fixture(old, old.add(const Duration(minutes: 30))), + ); + + expect(index, isEmpty); + }); + }); +} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index d32e6e5..7a1f197 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -7,6 +7,9 @@ services: environment: - ALLOWED_ORIGIN=https://xtrem.vonrodbox.eu - 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 - NVIDIA_GPU=true volumes: - xtremflow-data:/app/data diff --git a/docker-compose.yml b/docker-compose.yml index 563d18a..74987ec 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,6 +20,11 @@ services: - ALLOWED_ORIGIN=${ALLOWED_ORIGIN:-} # Nombre max d'enregistrements TV simultanés (FFmpeg -c copy, peu de CPU) - MAX_CONCURRENT_RECORDINGS=${MAX_CONCURRENT_RECORDINGS:-2} + # Source XMLTV de repli, interrogée uniquement quand le panneau ne rend + # 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} # Accélération GPU NVIDIA pour le transcodage (NVENC) : true/false - NVIDIA_GPU=${NVIDIA_GPU:-false} restart: unless-stopped diff --git a/lib/features/iptv/services/xtream_service.dart b/lib/features/iptv/services/xtream_service.dart index 4ed4511..358153e 100644 --- a/lib/features/iptv/services/xtream_service.dart +++ b/lib/features/iptv/services/xtream_service.dart @@ -7,6 +7,7 @@ import 'package:flutter/foundation.dart'; import '../../../core/models/playlist_config.dart'; import '../../../core/models/iptv_models.dart'; import '../models/xtream_models.dart' as xm; +import '../../../core/api/authed_http.dart'; /// Limiteur de concurrence minimal (pas de dépendance supplémentaire). class _Semaphore { @@ -46,17 +47,6 @@ class XtreamService { /// On sérialise donc par petits paquets. static final _Semaphore _epgGate = _Semaphore(3); - /// Action EPG retenue pour ce panneau. - /// - /// `get_short_epg` est la requête légère (2-3 programmes) mais une partie - /// des panneaux répond `{"epg_listings":[]}` en permanence ; seule - /// `get_simple_data_table` y renvoie le guide. On teste la première, et - /// dès qu'un panneau se révèle incapable de la servir on bascule - /// définitivement, pour ne pas payer deux requêtes par tuile de grille. - static const _epgActionShort = 'get_short_epg'; - static const _epgActionTable = 'get_simple_data_table'; - static String _epgAction = _epgActionShort; - late final Dio _dio; late final CacheOptions _cacheOptions; @@ -674,172 +664,102 @@ class XtreamService { /// /// Returns "Now" and "Next" program info Future> getShortEpg(String streamId) async { - final listings = await _fetchEpgListings(streamId); - return listings.map(EpgEntry.fromJson).toList(); + final programmes = await _fetchEpgProgrammes(streamId); + return programmes + .map( + (p) => EpgEntry( + id: '', + epgId: '', + title: p['title']?.toString() ?? '', + start: p['start']?.toString() ?? '', + end: p['end']?.toString() ?? '', + description: p['description']?.toString() ?? '', + channelId: streamId, + ), + ) + .toList(); } /// Get short EPG as ShortEPG object (for EPGWidget) Future getShortEPG(String streamId) async { - final listings = await _fetchEpgListings(streamId); - if (listings.isEmpty) return const xm.ShortEPG(); - return xm.ShortEPG.fromJson({'epg_listings': listings}); + final programmes = await _fetchEpgProgrammes(streamId); + if (programmes.isEmpty) return const xm.ShortEPG(); + + final now = DateTime.now(); + for (var i = 0; i < programmes.length; i++) { + final start = DateTime.tryParse('${programmes[i]['start']}'); + final stop = DateTime.tryParse('${programmes[i]['end']}'); + if (start == null || stop == null) continue; + if (now.isBefore(start) || !now.isBefore(stop)) continue; + + final duration = stop.difference(start).inSeconds; + return xm.ShortEPG( + nowPlaying: programmes[i]['title']?.toString(), + nextPlaying: i + 1 < programmes.length + ? programmes[i + 1]['title']?.toString() + : null, + progress: duration > 0 + ? now.difference(start).inSeconds / duration + : null, + ); + } + return const xm.ShortEPG(); } - /// Récupère le guide d'une chaîne, normalisé et réduit à la fenêtre utile. + /// Guide d'une chaîne, servi par le backend `/api/epg/`. /// - /// Bascule automatiquement de `get_short_epg` vers `get_simple_data_table` - /// quand le panneau ne sert pas la première : voir [_epgAction]. - Future>> _fetchEpgListings(String streamId) async { + /// Le client interrogeait auparavant `player_api.php` en direct et devait + /// donc dupliquer le choix de l'action Xtream, la conversion des fuseaux et + /// le décodage base64. Le backend fait tout cela une fois, met en cache + /// 30 minutes pour tous les utilisateurs, et sait basculer sur la source + /// XMLTV de repli quand le panneau ne rend rien d'actuel — bascule + /// impossible à faire côté navigateur. + /// + /// Les programmes reviennent déjà triés, en ISO-8601 UTC et en clair. + Future>> _fetchEpgProgrammes( + String streamId, + ) async { if (_currentPlaylist == null) throw Exception('No playlist configured'); if (isPlaybackLoading) { - debugPrint('[XtreamService] Throttling short EPG for $streamId'); + debugPrint('[XtreamService] Throttling EPG for $streamId'); await Future.delayed(_throttleDelay); } await _epgGate.acquire(); try { - var listings = await _requestEpg(streamId, _epgAction); - - // Panneau muet sur l'action légère : on retente avec le tableau complet - // et on mémorise le choix pour toutes les chaînes suivantes. - if (listings.isEmpty && _epgAction == _epgActionShort) { - listings = await _requestEpg(streamId, _epgActionTable); - if (listings.isNotEmpty) { - debugPrint( - '[XtreamService] EPG : $_epgActionShort vide sur ce panneau, ' - 'bascule définitive vers $_epgActionTable', - ); - _epgAction = _epgActionTable; - } - } - - return _normalizeListings(listings); - } finally { - _epgGate.release(); - } - } - - Future> _requestEpg(String streamId, String action) async { - try { - final response = await _apiGet( - { - 'action': action, - 'stream_id': streamId, - }, - options: Options( - extra: CacheOptions( - store: _cacheOptions.store, - policy: CachePolicy.request, - maxStale: const Duration(minutes: 5), // EPG changes frequently - ).toExtra(), - ), - ); - - var data = response.data; - - // Certains panneaux renvoient du JSON avec un Content-Type textuel : - // Dio livre alors une String brute et l'accès par clé échouait - // silencieusement, aboutissant au même « No Info » qu'une vraie panne. - if (data is String) { - if (data.trim().isEmpty) { - debugPrint('[XtreamService] EPG $streamId : réponse vide'); - return const []; - } - try { - data = json.decode(data); - } catch (_) { - debugPrint( - '[XtreamService] EPG $streamId : réponse non-JSON ' - '(${response.headers.value('content-type')}) — ' - '${data.length > 120 ? '${data.substring(0, 120)}…' : data}', - ); - return const []; - } - } - - if (data == null) { - debugPrint('[XtreamService] EPG $streamId : corps nul'); - return const []; - } - - if (data is! Map || data['epg_listings'] is! List) { - // Cas typique de `get_epg` / `get_short_epg` non supportés : le - // panneau répond 200 avec le payload d'authentification. + final response = await AuthedHttp.get(Uri.parse('/api/epg/$streamId')); + if (response.statusCode != 200) { debugPrint( - '[XtreamService] EPG $streamId ($action) : pas de champ ' - 'epg_listings (clés: ' - '${data is Map ? data.keys.toList() : data.runtimeType})', + '[XtreamService] EPG $streamId : HTTP ${response.statusCode}', ); return const []; } - return data['epg_listings'] as List; - } on DioException catch (e) { - // L'EPG reste optionnel — mais avaler l'erreur en silence rendait toute - // panne indiscernable d'une grille réellement vide. - debugPrint( - '[XtreamService] EPG $streamId ($action) en échec : ' - 'HTTP ${e.response?.statusCode} ${e.type} — ${e.message}', - ); - return const []; + final decoded = json.decode(response.body); + if (decoded is! Map || decoded['programmes'] is! List) return const []; + + final now = DateTime.now(); + final result = >[]; + for (final item in decoded['programmes'] as List) { + if (item is! Map) continue; + final end = DateTime.tryParse('${item['end']}'); + // Les appelants prennent le premier élément comme programme courant : + // laisser passer les programmes terminés afficherait celui d'hier. + if (end == null || end.isBefore(now)) continue; + result.add(Map.from(item)); + if (result.length >= 8) break; + } + return result; } catch (e) { - debugPrint('[XtreamService] EPG $streamId ($action) en échec : $e'); + // L'EPG reste optionnel : une panne ne doit pas casser la grille. + debugPrint('[XtreamService] EPG $streamId en échec : $e'); return const []; + } finally { + _epgGate.release(); } } - /// Aligne les dates sur de l'ISO-8601 UTC et ne garde que la fenêtre utile. - /// - /// Deux raisons : - /// * `get_simple_data_table` renvoie plusieurs jours de programmes (~35 Ko - /// par chaîne). Les appelants prennent « le premier élément » comme - /// programme courant : sans coupe, ils afficheraient un programme - /// d'avant-hier. - /// * Les champs texte `start`/`end` sont dans le fuseau du panneau et sans - /// indicateur de zone ; seuls les `*_timestamp` (epoch UTC) sont fiables. - static List> _normalizeListings(List raw) { - final now = DateTime.now(); - final result = >[]; - - for (final item in raw) { - if (item is! Map) continue; - final entry = Map.from(item); - - final start = _epgDate(entry['start_timestamp'], entry['start']); - final end = _epgDate( - entry['stop_timestamp'] ?? entry['end_timestamp'], - entry['stop'] ?? entry['end'], - ); - if (start == null || end == null) continue; - - // Programme déjà terminé : sans intérêt pour un « en cours / à suivre ». - if (end.isBefore(now)) continue; - - entry['start'] = start.toIso8601String(); - entry['end'] = end.toIso8601String(); - entry['stop'] = entry['end']; - result.add(entry); - - if (result.length >= 8) break; - } - - return result; - } - - /// Epoch UTC prioritaire, repli sur la chaîne « YYYY-MM-DD HH:MM:SS ». - static DateTime? _epgDate(dynamic timestamp, dynamic text) { - final seconds = timestamp is int ? timestamp : int.tryParse('$timestamp'); - if (seconds != null && seconds > 0) { - return DateTime.fromMillisecondsSinceEpoch(seconds * 1000, isUtc: true); - } - final str = text?.toString() ?? ''; - if (str.isEmpty) return null; - return DateTime.tryParse( - str.contains(' ') && !str.contains('T') ? str.replaceFirst(' ', 'T') : str, - ); - } - /// Dispose resources void dispose() { _dio.close();