mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-11 17:30:00 +02:00
feat(epg): source XMLTV de repli quand le panneau ne rend rien d'actuel
Le guide du fournisseur est figé sur beaucoup de revendeurs : sur le compte de test, get_simple_data_table répond correctement mais le dernier programme français date de deux jours, et le xmltv.php du panneau ne couvre aucune chaîne française. Aucun correctif côté appel Xtream ne pouvait donc remplir la grille. Nouveau service XmltvEpgService : téléchargement de dumps XMLTV publics, décompression gzip détectée sur le nombre magique, parsing en flux via XmlEventReader — un dump national fait 46 Mo décompressés, en charger l'arbre DOM coûterait plusieurs centaines de mégaoctets dans le conteneur. Mesuré à environ 1 s pour 973 chaînes. Index rafraîchi toutes les 6 h, programmes terminés depuis plus de 6 h écartés à l'indexation. L'appel sortant reste un dernier recours. L'ordre est inchangé — get_simple_data_table, puis get_short_epg — et le dump n'est consulté que si le panneau ne rend aucun programme couvrant l'instant présent : compter les entrées ne suffisait pas, un guide périmé en renvoie des centaines. La source se configure par EPG_XMLTV_URLS ; vider la variable supprime tout appel sortant. La réponse porte un en-tête X-Epg-Source pour savoir qui a répondu. Les identifiants sont rapprochés après normalisation : le dump écrit France.2.fr là où le panneau annonce France2.fr. Repli sur le nom affiché de la chaîne, et table stream_id vers identifiant EPG mise en cache 6 h. Le client passe désormais par /api/epg au lieu d'appeler player_api.php en direct. Il dupliquait le choix de l'action, la conversion des fuseaux et le décodage base64 ; le backend le fait une fois, met en cache pour tous les utilisateurs, et sait basculer sur le repli — bascule impossible depuis le navigateur. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c3b28b8278
commit
5b9eb74a84
8 files changed
+634
-155
No files matched your search
+126
-2
@@ -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/<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.
|
||||
class EpgApi {
|
||||
final Future<PlaylistConfig?> 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<String, _CacheEntry> _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<String, _ChannelMap> _channelMaps = {};
|
||||
|
||||
EpgApi(this._getPlaylist, {XmltvEpgService? xmltv}) : _xmltv = xmltv;
|
||||
|
||||
Future<Response> 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<String, dynamic> 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<List<Map<String, dynamic>>> _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 = <String, String>{};
|
||||
final names = <String, String>{};
|
||||
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<String, dynamic> transformEpgData(dynamic raw, String channelId) {
|
||||
@@ -167,6 +280,17 @@ class EpgApi {
|
||||
}
|
||||
}
|
||||
|
||||
class _ChannelMap {
|
||||
final Map<String, String> epgIds;
|
||||
final Map<String, String> names;
|
||||
final DateTime expiresAt;
|
||||
_ChannelMap({
|
||||
required this.epgIds,
|
||||
required this.names,
|
||||
required this.expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
class _CacheEntry {
|
||||
final String data;
|
||||
final DateTime expiresAt;
|
||||
|
||||
@@ -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
|
||||
+18
-1
@@ -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<String> 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);
|
||||
|
||||
|
||||
@@ -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<String> 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<String, List<XmltvProgramme>> _index = {};
|
||||
DateTime? _indexedAt;
|
||||
Future<void>? _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<List<XmltvProgramme>> 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<void> 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<void> _refresh() async {
|
||||
if (sourceUrls.isEmpty) return;
|
||||
|
||||
final merged = <String, List<XmltvProgramme>>{};
|
||||
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<String> _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<int> 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<String, List<XmltvProgramme>> parseForTest(String xml) => _parse(xml);
|
||||
|
||||
Map<String, List<XmltvProgramme>> _parse(String xml) {
|
||||
final cutoff = DateTime.now().toUtc().subtract(retention);
|
||||
final byChannel = <String, List<XmltvProgramme>>{};
|
||||
|
||||
// Alias : plusieurs dumps déclarent <channel id="X"> avec un
|
||||
// <display-name> différent de l'identifiant. On indexe les deux pour
|
||||
// maximiser les correspondances.
|
||||
final aliases = <String, String>{};
|
||||
|
||||
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<String, dynamic> toJson(String channelId) => {
|
||||
'title': title,
|
||||
'description': description,
|
||||
'start': start.toUtc().toIso8601String(),
|
||||
'end': stop.toUtc().toIso8601String(),
|
||||
'channel_id': channelId,
|
||||
};
|
||||
}
|
||||
@@ -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 '''
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<tv>
|
||||
<channel id="France.2.fr"><display-name>FR - FRANCE 2</display-name></channel>
|
||||
<programme start="${stamp(start)}" stop="${stamp(stop)}" channel="France.2.fr">
|
||||
<title lang="fr">Journal de 20h</title>
|
||||
<desc lang="fr">L'info du soir</desc>
|
||||
</programme>
|
||||
</tv>
|
||||
''';
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<List<EpgEntry>> 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<xm.ShortEPG> 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/<streamId>`.
|
||||
///
|
||||
/// Bascule automatiquement de `get_short_epg` vers `get_simple_data_table`
|
||||
/// quand le panneau ne sert pas la première : voir [_epgAction].
|
||||
Future<List<Map<String, dynamic>>> _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<List<Map<String, dynamic>>> _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<List<dynamic>> _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<dynamic>;
|
||||
} 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 = <Map<String, dynamic>>[];
|
||||
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<String, dynamic>.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<Map<String, dynamic>> _normalizeListings(List<dynamic> raw) {
|
||||
final now = DateTime.now();
|
||||
final result = <Map<String, dynamic>>[];
|
||||
|
||||
for (final item in raw) {
|
||||
if (item is! Map) continue;
|
||||
final entry = Map<String, dynamic>.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();
|
||||
|
||||
Reference in new issue
Block a user