mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-11 17:30:00 +02:00
Sans charset annoncé, Response.body pouvait décoder le JSON du panneau en latin-1 et abîmer les noms accentués ou décorés (« Chérie », « ◉ »), donc leur correspondance avec le guide XMLTV. Le test simule désormais des octets UTF-8 sans charset, comme le panneau réel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
474 lines
17 KiB
Dart
474 lines
17 KiB
Dart
import 'dart:convert';
|
|
import 'dart:isolate';
|
|
import 'package:shelf/shelf.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import '../models/playlist_config.dart';
|
|
import '../services/xmltv_epg_service.dart';
|
|
import '../utils/log_redactor.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, mais par son dump
|
|
/// `xmltv.php` d'abord : une requête couvre toutes les chaînes, là où
|
|
/// `player_api` en réclame une par chaîne à plusieurs secondes pièce. Vient
|
|
/// ensuite l'interrogation chaîne par chaîne, puis une source XMLTV externe
|
|
/// en dernier recours — cas 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,
|
|
/// deux playlists distinctes peuvent parfaitement partager le même
|
|
/// identifiant et se servaient alors le guide l'une de l'autre.
|
|
final Map<String, _CacheEntry> _cache = {};
|
|
|
|
/// 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 = {};
|
|
|
|
/// Client HTTP des appels `player_api`. Injectable pour les tests.
|
|
final http.Client _http;
|
|
|
|
/// Fabrique de la source XMLTV du panneau, une par compte.
|
|
final XmltvEpgService Function(PlaylistConfig) _panelXmltvBuilder;
|
|
|
|
/// Index XMLTV du panneau, par compte.
|
|
final Map<String, XmltvEpgService> _panelXmltv = {};
|
|
|
|
EpgApi(
|
|
this._getPlaylist, {
|
|
XmltvEpgService? xmltv,
|
|
http.Client? httpClient,
|
|
XmltvEpgService Function(PlaylistConfig)? panelXmltvBuilder,
|
|
}) : _xmltv = xmltv,
|
|
_http = httpClient ?? http.Client(),
|
|
_panelXmltvBuilder = panelXmltvBuilder ?? _defaultPanelXmltv;
|
|
|
|
/// Dump XMLTV servi par le panneau lui-même.
|
|
///
|
|
/// POURQUOI : `player_api.php?action=get_simple_data_table` répond en
|
|
/// plusieurs secondes chez beaucoup de revendeurs, et il faut un appel par
|
|
/// chaîne — afficher une grille de trente chaînes demandait donc plus d'une
|
|
/// minute. `xmltv.php` renvoie le guide de toutes les chaînes en une seule
|
|
/// requête, réutilisée ensuite pendant des heures. C'est ce que font les
|
|
/// clients IPTV rapides.
|
|
static XmltvEpgService _defaultPanelXmltv(PlaylistConfig playlist) {
|
|
final user = Uri.encodeQueryComponent(playlist.username);
|
|
final password = Uri.encodeQueryComponent(playlist.password);
|
|
return XmltvEpgService(
|
|
sourceUrls: ['${playlist.dns}/xmltv.php?username=$user&password=$password'],
|
|
refreshInterval: const Duration(hours: 3),
|
|
// Le guide est facultatif : on ne fait pas patienter l'utilisateur
|
|
// plusieurs minutes sur un panneau qui traîne.
|
|
downloadTimeout: const Duration(seconds: 90),
|
|
);
|
|
}
|
|
|
|
Future<Response> handleGetEpg(Request request, String channelId) async {
|
|
final playlist = await _getPlaylist(request);
|
|
if (playlist == null) {
|
|
return Response.forbidden(
|
|
json.encode({'error': 'Playlist non trouvée'}),
|
|
headers: {'Content-Type': 'application/json'},
|
|
);
|
|
}
|
|
|
|
final cacheKey = '${playlist.dns}|${playlist.username}|$channelId';
|
|
final cached = _cache[cacheKey];
|
|
if (cached != null && DateTime.now().isBefore(cached.expiresAt)) {
|
|
return Response.ok(
|
|
cached.data,
|
|
headers: {'Content-Type': 'application/json', 'X-Cache': 'HIT'},
|
|
);
|
|
}
|
|
|
|
try {
|
|
// Sources par ordre de préférence. On s'arrête à la première qui
|
|
// contient un programme en cours ou à venir ; à défaut, la première
|
|
// non vide sert de repli.
|
|
//
|
|
// 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<List<Map<String, dynamic>>> Function())>[
|
|
('panel-xmltv', () => _panelProgrammes(playlist, channelId)),
|
|
('xmltv', () => _xmltvProgrammes(playlist, channelId)),
|
|
('xtream', () => _xtreamProgrammes(playlist, channelId)),
|
|
];
|
|
|
|
var epgData = <String, dynamic>{
|
|
'channel_id': channelId,
|
|
'programmes': <Map<String, dynamic>>[],
|
|
};
|
|
var source = 'xtream';
|
|
var found = false;
|
|
|
|
for (final (name, fetch) in sources) {
|
|
final programmes = await fetch();
|
|
if (programmes.isEmpty) continue;
|
|
|
|
final candidate = <String, dynamic>{
|
|
'channel_id': channelId,
|
|
'programmes': programmes,
|
|
};
|
|
if (!found) {
|
|
// Meilleur repli connu à ce stade, même si le guide est périmé.
|
|
epgData = candidate;
|
|
source = name;
|
|
found = true;
|
|
}
|
|
if (_hasCurrentProgramme(candidate)) {
|
|
epgData = candidate;
|
|
source = name;
|
|
break;
|
|
}
|
|
}
|
|
|
|
final jsonStr = json.encode(epgData);
|
|
|
|
// 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(Duration(minutes: found ? 30 : 5)),
|
|
);
|
|
|
|
return Response.ok(
|
|
jsonStr,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-Cache': 'MISS',
|
|
'X-Epg-Source': source,
|
|
},
|
|
);
|
|
} catch (e) {
|
|
// Détail redacté en log uniquement : une ClientException Dart contient
|
|
// l'URI amont, credentials Xtream inclus.
|
|
print('[EpgApi] Erreur EPG: ${LogRedactor.redactUrl('$e')}');
|
|
return Response.internalServerError(
|
|
body: json.encode({'error': 'Erreur lors de la récupération EPG'}),
|
|
headers: {'Content-Type': 'application/json'},
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Programmes tirés du dump XMLTV du panneau, ou liste vide.
|
|
Future<List<Map<String, dynamic>>> _panelProgrammes(
|
|
PlaylistConfig playlist,
|
|
String channelId,
|
|
) async {
|
|
final key = '${playlist.dns}|${playlist.username}';
|
|
final service =
|
|
_panelXmltv.putIfAbsent(key, () => _panelXmltvBuilder(playlist));
|
|
return _fromXmltv(service, playlist, channelId);
|
|
}
|
|
|
|
/// Programmes obtenus en interrogeant le panneau chaîne par chaîne.
|
|
///
|
|
/// Chemin lent : deux actions possibles, plusieurs secondes chacune chez la
|
|
/// plupart des revendeurs. Il ne sert que lorsque le dump XMLTV du panneau
|
|
/// est absent ou ne couvre pas la chaîne.
|
|
Future<List<Map<String, dynamic>>> _xtreamProgrammes(
|
|
PlaylistConfig playlist,
|
|
String channelId,
|
|
) async {
|
|
// `get_simple_data_table` d'abord : c'est la seule action réellement
|
|
// universelle. `get_epg` n'existe pas sur beaucoup de panneaux — au lieu
|
|
// d'une erreur, ils renvoient poliment le payload d'authentification,
|
|
// sans champ `epg_listings`, ce qui produisait un guide vide impossible
|
|
// à distinguer d'une chaîne sans programme.
|
|
const actions = ['get_simple_data_table', 'get_short_epg'];
|
|
|
|
for (final action in actions) {
|
|
final url =
|
|
'${playlist.dns}/player_api.php?username=${playlist.username}'
|
|
'&password=${playlist.password}'
|
|
'&action=$action&stream_id=$channelId';
|
|
|
|
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<String, dynamic> parsed;
|
|
try {
|
|
parsed = transformEpgData(json.decode(response.body), channelId);
|
|
} catch (_) {
|
|
continue;
|
|
}
|
|
final programmes = (parsed['programmes'] as List)
|
|
.whereType<Map<String, dynamic>>()
|
|
.toList();
|
|
if (programmes.isNotEmpty) return programmes;
|
|
}
|
|
return const [];
|
|
}
|
|
|
|
/// 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,
|
|
) =>
|
|
_fromXmltv(_xmltv, playlist, channelId);
|
|
|
|
/// Programmes d'une chaîne dans un index XMLTV quelconque (panneau ou
|
|
/// source externe), ou liste vide si l'index ne la connaît pas.
|
|
Future<List<Map<String, dynamic>>> _fromXmltv(
|
|
XmltvEpgService? xmltv,
|
|
PlaylistConfig playlist,
|
|
String channelId,
|
|
) async {
|
|
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) {
|
|
// L'exception peut recopier l'URL `player_api`/`xmltv.php`, identifiants
|
|
// compris.
|
|
print(
|
|
'[EpgApi] source XMLTV indisponible pour $channelId : '
|
|
'${LogRedactor.redactUrl('$e')}',
|
|
);
|
|
return const [];
|
|
}
|
|
}
|
|
|
|
/// `stream_id` → (`epg_channel_id`, nom) depuis la réponse
|
|
/// `get_live_streams`.
|
|
static (Map<String, String>, Map<String, String>) _parseChannelTable(
|
|
String body,
|
|
) {
|
|
final epgIds = <String, String>{};
|
|
final names = <String, String>{};
|
|
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<String, Future<_ChannelMap>> _channelMapsInFlight = {};
|
|
|
|
/// Table des chaînes du compte, rafraîchie toutes les 6 heures.
|
|
///
|
|
/// 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 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';
|
|
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')}');
|
|
}
|
|
|
|
var epgIds = <String, String>{};
|
|
var names = <String, String>{};
|
|
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.
|
|
// UTF-8 explicite : sans charset annoncé, `response.body` peut décoder
|
|
// en latin-1 et abîmer les noms (« Chérie », « ◉ »), donc leur
|
|
// correspondance avec le guide.
|
|
final body = utf8.decode(response.bodyBytes, allowMalformed: true);
|
|
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(
|
|
names.isEmpty ? const Duration(minutes: 2) : 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) {
|
|
try {
|
|
List<dynamic> listings = [];
|
|
|
|
if (raw is Map && raw.containsKey('epg_listings')) {
|
|
listings = raw['epg_listings'] as List<dynamic>? ?? [];
|
|
} else if (raw is List) {
|
|
listings = raw;
|
|
}
|
|
|
|
final programmes = listings.map((item) {
|
|
// Les champs texte `start`/`end` sont exprimés dans le fuseau du
|
|
// panneau (souvent Europe/Amsterdam), sans indicateur de zone : le
|
|
// client les relisait comme de l'heure locale et décalait tout le
|
|
// guide. Les `*_timestamp` sont de l'epoch UTC — on s'en sert dès
|
|
// qu'ils sont présents et on émet de l'ISO-8601 UTC explicite.
|
|
final start = _isoUtc(item['start_timestamp']) ??
|
|
_normalizeDate(item['start'] as String? ?? '');
|
|
final end = _isoUtc(item['stop_timestamp']) ??
|
|
_isoUtc(item['end_timestamp']) ??
|
|
_normalizeDate(
|
|
(item['stop'] as String?) ?? (item['end'] as String?) ?? '',
|
|
);
|
|
|
|
// Décoder le titre (base64 si nécessaire)
|
|
String title = item['title'] as String? ?? '';
|
|
try {
|
|
if (title.isNotEmpty) {
|
|
final decoded = utf8.decode(base64Decode(title));
|
|
if (decoded.isNotEmpty) title = decoded;
|
|
}
|
|
} catch (_) {}
|
|
|
|
String description = item['description'] as String? ?? '';
|
|
try {
|
|
if (description.isNotEmpty) {
|
|
final decoded = utf8.decode(base64Decode(description));
|
|
if (decoded.isNotEmpty) description = decoded;
|
|
}
|
|
} catch (_) {}
|
|
|
|
return {
|
|
'title': title,
|
|
'description': description,
|
|
'start': start,
|
|
'end': end,
|
|
'channel_id': channelId,
|
|
};
|
|
}).toList();
|
|
|
|
return {'channel_id': channelId, 'programmes': programmes};
|
|
} catch (e) {
|
|
print('[EpgApi] Erreur panneau pour $channelId: ${LogRedactor.redactUrl('$e')}');
|
|
return {
|
|
'channel_id': channelId,
|
|
'programmes': [],
|
|
'error': 'EPG indisponible',
|
|
};
|
|
}
|
|
}
|
|
|
|
/// Epoch (secondes, String ou int) → ISO-8601 UTC, ou `null` si absent.
|
|
static String? _isoUtc(dynamic timestamp) {
|
|
if (timestamp == null) return null;
|
|
final seconds = timestamp is int ? timestamp : int.tryParse('$timestamp');
|
|
if (seconds == null || seconds <= 0) return null;
|
|
return DateTime.fromMillisecondsSinceEpoch(seconds * 1000, isUtc: true)
|
|
.toIso8601String();
|
|
}
|
|
|
|
/// Repli : « YYYY-MM-DD HH:MM:SS » → « YYYY-MM-DDTHH:MM:SS ».
|
|
static String _normalizeDate(String raw) {
|
|
if (raw.contains(' ') && !raw.contains('T')) {
|
|
return raw.replaceFirst(' ', 'T');
|
|
}
|
|
return raw;
|
|
}
|
|
}
|
|
|
|
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;
|
|
_CacheEntry({required this.data, required this.expiresAt});
|
|
}
|