mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-11 17:30:00 +02:00
Guide TV : plus jamais de 500, et bien plus de chaînes couvertes
Constaté en prod : chaque /api/epg répondait 500 en 4 s. Un appel player_api qui lève (connexion coupée, délai) n'était pas rattrapé, et le dump XMLTV externe, qui couvre pourtant TF1, France 2, M6…, n'était jamais consulté. Et 59 % des chaînes françaises n'ont pas d'epg_channel_id : leur nom brut (« FR - TF1 FHD ◉ ») ne correspondait à aucune entrée XMLTV. - échec player_api rattrapé : la chaîne passe à la source suivante - dump externe (déjà en mémoire) avant l'appel lent chaîne par chaîne - correspondance par nom nettoyé : préfixe pays, FHD/HD/UHD/4K/HEVC…, symboles retirés, forme « nom.pays » tentée ; « +1 » conservé - tous les display-name XMLTV indexés, plus seulement le premier - table des chaînes : un seul téléchargement partagé, décodé hors de la boucle principale, et un échec n'est plus gardé 6 h (2 min) - guide vide gardé 5 min au lieu de 30 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8d79472ca8
commit
abe7d522c1
4 files changed
+336
-33
No files matched your search
+88
-24
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:isolate';
|
||||||
import 'package:shelf/shelf.dart';
|
import 'package:shelf/shelf.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import '../models/playlist_config.dart';
|
import '../models/playlist_config.dart';
|
||||||
@@ -97,10 +98,15 @@ class EpgApi {
|
|||||||
// Le dump du panneau passe devant : une requête couvre toutes les
|
// 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
|
// chaînes, là où `player_api` en demande une par chaîne, à plusieurs
|
||||||
// secondes pièce.
|
// 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())>[
|
final sources = <(String, Future<List<Map<String, dynamic>>> Function())>[
|
||||||
('panel-xmltv', () => _panelProgrammes(playlist, channelId)),
|
('panel-xmltv', () => _panelProgrammes(playlist, channelId)),
|
||||||
('xtream', () => _xtreamProgrammes(playlist, channelId)),
|
|
||||||
('xmltv', () => _xmltvProgrammes(playlist, channelId)),
|
('xmltv', () => _xmltvProgrammes(playlist, channelId)),
|
||||||
|
('xtream', () => _xtreamProgrammes(playlist, channelId)),
|
||||||
];
|
];
|
||||||
|
|
||||||
var epgData = <String, dynamic>{
|
var epgData = <String, dynamic>{
|
||||||
@@ -133,10 +139,13 @@ class EpgApi {
|
|||||||
|
|
||||||
final jsonStr = json.encode(epgData);
|
final jsonStr = json.encode(epgData);
|
||||||
|
|
||||||
// Mettre en cache 30 minutes
|
// 30 min pour un guide trouvé ; 5 min seulement pour un guide vide,
|
||||||
|
// qui vient souvent d'une source momentanément indisponible et ne
|
||||||
|
// doit pas priver la chaîne de guide pendant une demi-heure.
|
||||||
_cache[cacheKey] = _CacheEntry(
|
_cache[cacheKey] = _CacheEntry(
|
||||||
data: jsonStr,
|
data: jsonStr,
|
||||||
expiresAt: DateTime.now().add(const Duration(minutes: 30)),
|
expiresAt: DateTime.now()
|
||||||
|
.add(Duration(minutes: found ? 30 : 5)),
|
||||||
);
|
);
|
||||||
|
|
||||||
return Response.ok(
|
return Response.ok(
|
||||||
@@ -191,8 +200,19 @@ class EpgApi {
|
|||||||
'&password=${playlist.password}'
|
'&password=${playlist.password}'
|
||||||
'&action=$action&stream_id=$channelId';
|
'&action=$action&stream_id=$channelId';
|
||||||
|
|
||||||
final response =
|
final http.Response response;
|
||||||
await _http.get(Uri.parse(url)).timeout(const Duration(seconds: 60));
|
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;
|
if (response.statusCode != 200) continue;
|
||||||
|
|
||||||
Map<String, dynamic> parsed;
|
Map<String, dynamic> parsed;
|
||||||
@@ -267,41 +287,85 @@ class EpgApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `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.
|
/// Table des chaînes du compte, rafraîchie toutes les 6 heures.
|
||||||
Future<_ChannelMap> _channelMapFor(PlaylistConfig playlist) async {
|
///
|
||||||
|
/// Les requêtes simultanées partagent un seul téléchargement : la grille
|
||||||
|
/// demande le guide de 30 chaînes d'un coup, et chacune déclenchait sinon
|
||||||
|
/// son propre `get_live_streams` (7 Mo en prod).
|
||||||
|
Future<_ChannelMap> _channelMapFor(PlaylistConfig playlist) {
|
||||||
final key = '${playlist.dns}|${playlist.username}';
|
final key = '${playlist.dns}|${playlist.username}';
|
||||||
final cached = _channelMaps[key];
|
final cached = _channelMaps[key];
|
||||||
if (cached != null && DateTime.now().isBefore(cached.expiresAt)) {
|
if (cached != null && DateTime.now().isBefore(cached.expiresAt)) {
|
||||||
return cached;
|
return Future.value(cached);
|
||||||
}
|
}
|
||||||
|
return _channelMapsInFlight[key] ??=
|
||||||
|
_loadChannelMap(playlist, key).whenComplete(() {
|
||||||
|
_channelMapsInFlight.remove(key);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<_ChannelMap> _loadChannelMap(
|
||||||
|
PlaylistConfig playlist,
|
||||||
|
String key,
|
||||||
|
) async {
|
||||||
final url = '${playlist.dns}/player_api.php'
|
final url = '${playlist.dns}/player_api.php'
|
||||||
'?username=${playlist.username}&password=${playlist.password}'
|
'?username=${playlist.username}&password=${playlist.password}'
|
||||||
'&action=get_live_streams';
|
'&action=get_live_streams';
|
||||||
final response =
|
http.Response? response;
|
||||||
await _http.get(Uri.parse(url)).timeout(const Duration(seconds: 90));
|
try {
|
||||||
|
response =
|
||||||
|
await _http.get(Uri.parse(url)).timeout(const Duration(seconds: 90));
|
||||||
|
} catch (e) {
|
||||||
|
print('[EpgApi] table des chaînes indisponible : '
|
||||||
|
'${LogRedactor.redactUrl('$e')}');
|
||||||
|
}
|
||||||
|
|
||||||
final epgIds = <String, String>{};
|
var epgIds = <String, String>{};
|
||||||
final names = <String, String>{};
|
var names = <String, String>{};
|
||||||
if (response.statusCode == 200) {
|
if (response != null && response.statusCode == 200) {
|
||||||
final decoded = json.decode(response.body);
|
// Décodage dans un isolate : 7 Mo de JSON sur la boucle principale la
|
||||||
if (decoded is List) {
|
// gelaient, et le relais turbo.ts cessait d'émettre pendant ce temps.
|
||||||
for (final item in decoded) {
|
final body = response.body;
|
||||||
if (item is! Map) continue;
|
try {
|
||||||
final streamId = item['stream_id']?.toString();
|
(epgIds, names) = await Isolate.run(() => _parseChannelTable(body));
|
||||||
if (streamId == null || streamId.isEmpty) continue;
|
} catch (e) {
|
||||||
final epgId = item['epg_channel_id']?.toString();
|
print('[EpgApi] table des chaînes illisible : ${e.runtimeType}');
|
||||||
if (epgId != null && epgId.isNotEmpty) epgIds[streamId] = epgId;
|
|
||||||
final name = item['name']?.toString();
|
|
||||||
if (name != null && name.isNotEmpty) names[streamId] = name;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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(
|
final map = _ChannelMap(
|
||||||
epgIds: epgIds,
|
epgIds: epgIds,
|
||||||
names: names,
|
names: names,
|
||||||
expiresAt: DateTime.now().add(const Duration(hours: 6)),
|
expiresAt: DateTime.now().add(
|
||||||
|
names.isEmpty ? const Duration(minutes: 2) : const Duration(hours: 6),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
_channelMaps[key] = map;
|
_channelMaps[key] = map;
|
||||||
return map;
|
return map;
|
||||||
|
|||||||
@@ -82,8 +82,11 @@ class XmltvEpgService {
|
|||||||
String? displayName,
|
String? displayName,
|
||||||
}) async {
|
}) async {
|
||||||
await ensureFresh();
|
await ensureFresh();
|
||||||
for (final candidate in [channelId, displayName]) {
|
final keys = [
|
||||||
final key = normalizeKey(candidate);
|
normalizeKey(channelId),
|
||||||
|
if (displayName != null) ...nameKeys(displayName),
|
||||||
|
];
|
||||||
|
for (final key in keys) {
|
||||||
if (key.isEmpty) continue;
|
if (key.isEmpty) continue;
|
||||||
final hit = _index[key];
|
final hit = _index[key];
|
||||||
if (hit != null && hit.isNotEmpty) return hit;
|
if (hit != null && hit.isNotEmpty) return hit;
|
||||||
@@ -91,6 +94,56 @@ class XmltvEpgService {
|
|||||||
return const [];
|
return const [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Préfixe pays des noms de panneau : `FR - `, `FR: `, `|FR| `, `[FR] `…
|
||||||
|
static final _countryPrefix = RegExp(
|
||||||
|
r'^\s*[\|\[\(]?\s*([A-Za-z]{2,3})\s*[\|\]\)]?\s*[-:|]\s*|^\s*[\|\[\(]\s*([A-Za-z]{2,3})\s*[\|\]\)]\s*',
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Marqueurs de qualité ou de source en fin de nom, sans rapport avec la
|
||||||
|
/// chaîne elle-même.
|
||||||
|
static final _qualitySuffix = RegExp(
|
||||||
|
r'(\s+|^)(fhd|uhd|hd|sd|hq|lq|4k|8k|hevc|h\.?265|h\.?264|1080p?|720p?|'
|
||||||
|
r'50fps|60fps|backup|raw|vip|multi|\(backup\)|\(multi\))\s*$',
|
||||||
|
caseSensitive: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Clés candidates pour retrouver une chaîne du panneau par son nom.
|
||||||
|
///
|
||||||
|
/// POURQUOI : en prod, 59 % des chaînes françaises n'ont pas
|
||||||
|
/// d'`epg_channel_id`. Leur nom brut (« FR - TF1 FHD ◉ » → `frtf1fhd`) ne
|
||||||
|
/// correspond à aucune entrée XMLTV (`TF1.fr`, « TF1 »). On retire le
|
||||||
|
/// préfixe pays, les marqueurs de qualité et les symboles, puis on tente
|
||||||
|
/// aussi la forme `nom.pays` qu'utilisent les dumps publics.
|
||||||
|
///
|
||||||
|
/// Le décalage horaire (« +1 ») est conservé : TF1 +1 n'a pas le guide
|
||||||
|
/// de TF1.
|
||||||
|
static List<String> nameKeys(String name) {
|
||||||
|
final keys = <String>[normalizeKey(name)];
|
||||||
|
|
||||||
|
var cleaned = name;
|
||||||
|
String? country;
|
||||||
|
final prefix = _countryPrefix.firstMatch(cleaned);
|
||||||
|
if (prefix != null) {
|
||||||
|
country = (prefix.group(1) ?? prefix.group(2))?.toLowerCase();
|
||||||
|
cleaned = cleaned.substring(prefix.end);
|
||||||
|
}
|
||||||
|
// Symboles décoratifs (◉, ᴴᴰ, ★…) : tout ce qui n'est ni lettre, ni
|
||||||
|
// chiffre, ni ponctuation utile.
|
||||||
|
cleaned = cleaned.replaceAll(RegExp(r'[^\p{L}\p{N}\s+.&\-()]', unicode: true), ' ').trim();
|
||||||
|
String previous;
|
||||||
|
do {
|
||||||
|
previous = cleaned;
|
||||||
|
cleaned = cleaned.replaceFirst(_qualitySuffix, '').trim();
|
||||||
|
} while (cleaned != previous && cleaned.isNotEmpty);
|
||||||
|
|
||||||
|
final base = normalizeKey(cleaned);
|
||||||
|
if (base.isNotEmpty) {
|
||||||
|
keys.add(base);
|
||||||
|
keys.add('$base${country ?? 'fr'}');
|
||||||
|
}
|
||||||
|
return keys.where((k) => k.isNotEmpty).toSet().toList();
|
||||||
|
}
|
||||||
|
|
||||||
/// Recharge l'index s'il est absent ou périmé. Les appels concurrents
|
/// Recharge l'index s'il est absent ou périmé. Les appels concurrents
|
||||||
/// partagent le même téléchargement.
|
/// partagent le même téléchargement.
|
||||||
Future<void> ensureFresh() {
|
Future<void> ensureFresh() {
|
||||||
@@ -212,10 +265,12 @@ class XmltvEpgService {
|
|||||||
final limit = DateTime.now().toUtc().add(horizon);
|
final limit = DateTime.now().toUtc().add(horizon);
|
||||||
final byChannel = <String, List<XmltvProgramme>>{};
|
final byChannel = <String, List<XmltvProgramme>>{};
|
||||||
|
|
||||||
// Alias : plusieurs dumps déclarent <channel id="X"> avec un
|
// Alias : plusieurs dumps déclarent <channel id="X"> avec un ou plusieurs
|
||||||
// <display-name> différent de l'identifiant. On indexe les deux pour
|
// <display-name> différents de l'identifiant (« TF1 HD », « TF1 »). On
|
||||||
// maximiser les correspondances.
|
// les indexe TOUS : seul le premier l'était, et c'était souvent la
|
||||||
|
// variante la moins proche du nom annoncé par le panneau.
|
||||||
final aliases = <String, String>{};
|
final aliases = <String, String>{};
|
||||||
|
final displayNames = <String>[];
|
||||||
|
|
||||||
String? channelId;
|
String? channelId;
|
||||||
String? programmeChannel;
|
String? programmeChannel;
|
||||||
@@ -232,6 +287,7 @@ class XmltvEpgService {
|
|||||||
case 'channel':
|
case 'channel':
|
||||||
channelId = _attr(event, 'id');
|
channelId = _attr(event, 'id');
|
||||||
displayName.clear();
|
displayName.clear();
|
||||||
|
displayNames.clear();
|
||||||
case 'programme':
|
case 'programme':
|
||||||
programmeChannel = _attr(event, 'channel');
|
programmeChannel = _attr(event, 'channel');
|
||||||
start = parseXmltvDate(_attr(event, 'start'));
|
start = parseXmltvDate(_attr(event, 'start'));
|
||||||
@@ -254,15 +310,20 @@ class XmltvEpgService {
|
|||||||
case 'desc':
|
case 'desc':
|
||||||
desc.write(text);
|
desc.write(text);
|
||||||
case 'display-name':
|
case 'display-name':
|
||||||
if (displayName.isEmpty) displayName.write(text);
|
displayName.write(text);
|
||||||
}
|
}
|
||||||
} else if (event is XmlEndElementEvent) {
|
} else if (event is XmlEndElementEvent) {
|
||||||
switch (event.name) {
|
switch (event.name) {
|
||||||
|
case 'display-name':
|
||||||
|
displayNames.add(displayName.toString());
|
||||||
|
displayName.clear();
|
||||||
case 'channel':
|
case 'channel':
|
||||||
final id = normalizeKey(channelId);
|
final id = normalizeKey(channelId);
|
||||||
final name = normalizeKey(displayName.toString());
|
for (final raw in displayNames) {
|
||||||
if (id.isNotEmpty && name.isNotEmpty && id != name) {
|
final name = normalizeKey(raw);
|
||||||
aliases[name] = id;
|
if (id.isNotEmpty && name.isNotEmpty && id != name) {
|
||||||
|
aliases.putIfAbsent(name, () => id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
channelId = null;
|
channelId = null;
|
||||||
case 'programme':
|
case 'programme':
|
||||||
|
|||||||
@@ -217,5 +217,111 @@ void main() {
|
|||||||
expect(response.headers['X-Epg-Source'], 'xtream');
|
expect(response.headers['X-Epg-Source'], 'xtream');
|
||||||
expect(actions, contains('get_simple_data_table'));
|
expect(actions, contains('get_simple_data_table'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Dump externe couvrant TF1, et panneau dont le xmltv.php est vide.
|
||||||
|
String externalDump() {
|
||||||
|
final now = DateTime.now().toUtc();
|
||||||
|
return xmltvDump(now, now.add(const Duration(hours: 1)))
|
||||||
|
.replaceAll('France2.fr', 'TF1.fr')
|
||||||
|
.replaceAll('FRANCE 2', 'TF1');
|
||||||
|
}
|
||||||
|
|
||||||
|
EpgApi apiWith(http.Client client) => EpgApi(
|
||||||
|
(_) async => playlist,
|
||||||
|
httpClient: client,
|
||||||
|
xmltv: XmltvEpgService(
|
||||||
|
sourceUrls: const ['http://ext.example/epg.xml'],
|
||||||
|
client: client,
|
||||||
|
),
|
||||||
|
panelXmltvBuilder: (config) => XmltvEpgService(
|
||||||
|
sourceUrls: ['${config.dns}/xmltv.php'],
|
||||||
|
client: client,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
test('un panneau qui plante ne prive plus la chaîne du dump externe',
|
||||||
|
() async {
|
||||||
|
// Constaté en prod : chaque /api/epg répondait 500 en 4 s, le guide
|
||||||
|
// externe (qui couvrait pourtant la chaîne) n'étant jamais consulté.
|
||||||
|
final client = MockClient((request) async {
|
||||||
|
final url = request.url;
|
||||||
|
if (url.host == 'ext.example') return http.Response(externalDump(), 200);
|
||||||
|
if (url.path.endsWith('/xmltv.php')) return http.Response('<tv></tv>', 200);
|
||||||
|
final action = url.queryParameters['action'] ?? '';
|
||||||
|
if (action == 'get_live_streams') {
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode([
|
||||||
|
{'stream_id': 1, 'name': 'FR - TF1 FHD ◉', 'epg_channel_id': ''},
|
||||||
|
]),
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw http.ClientException('Connection closed', url);
|
||||||
|
});
|
||||||
|
|
||||||
|
final response = await apiWith(client).handleGetEpg(requestFor('1'), '1');
|
||||||
|
final body = jsonDecode(await response.readAsString()) as Map;
|
||||||
|
|
||||||
|
expect(response.statusCode, 200);
|
||||||
|
expect(response.headers['X-Epg-Source'], 'xmltv');
|
||||||
|
expect((body['programmes'] as List).single['title'], 'Journal de 20h');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('le dump externe passe avant l\'appel lent chaîne par chaîne',
|
||||||
|
() async {
|
||||||
|
final actions = <String>[];
|
||||||
|
final client = MockClient((request) async {
|
||||||
|
final url = request.url;
|
||||||
|
if (url.host == 'ext.example') return http.Response(externalDump(), 200);
|
||||||
|
if (url.path.endsWith('/xmltv.php')) return http.Response('<tv></tv>', 200);
|
||||||
|
final action = url.queryParameters['action'] ?? '';
|
||||||
|
actions.add(action);
|
||||||
|
if (action == 'get_live_streams') {
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode([
|
||||||
|
{'stream_id': 1, 'name': 'TF1', 'epg_channel_id': 'TF1.fr'},
|
||||||
|
]),
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return http.Response('{}', 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
final response = await apiWith(client).handleGetEpg(requestFor('1'), '1');
|
||||||
|
expect(response.headers['X-Epg-Source'], 'xmltv');
|
||||||
|
expect(actions, isNot(contains('get_simple_data_table')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('la grille entière partage un seul téléchargement de la table',
|
||||||
|
() async {
|
||||||
|
// 30 tuiles demandent leur guide d'un coup : sans mutualisation, autant
|
||||||
|
// de `get_live_streams` (7 Mo en prod) partaient en parallèle.
|
||||||
|
var liveStreamsCalls = 0;
|
||||||
|
final client = MockClient((request) async {
|
||||||
|
final url = request.url;
|
||||||
|
if (url.host == 'ext.example') return http.Response(externalDump(), 200);
|
||||||
|
if (url.path.endsWith('/xmltv.php')) return http.Response('<tv></tv>', 200);
|
||||||
|
final action = url.queryParameters['action'] ?? '';
|
||||||
|
if (action == 'get_live_streams') {
|
||||||
|
liveStreamsCalls++;
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode([
|
||||||
|
for (var i = 1; i <= 5; i++)
|
||||||
|
{'stream_id': i, 'name': 'TF1', 'epg_channel_id': 'TF1.fr'},
|
||||||
|
]),
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return http.Response('{}', 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
final api = apiWith(client);
|
||||||
|
final responses = await Future.wait([
|
||||||
|
for (var i = 1; i <= 5; i++) api.handleGetEpg(requestFor('$i'), '$i'),
|
||||||
|
]);
|
||||||
|
expect(responses.every((r) => r.statusCode == 200), isTrue);
|
||||||
|
expect(liveStreamsCalls, 1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -28,6 +28,13 @@ String _fixture(DateTime start, DateTime stop) {
|
|||||||
''';
|
''';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _stamp(DateTime d) {
|
||||||
|
final u = d.toUtc();
|
||||||
|
String two(int v) => v.toString().padLeft(2, '0');
|
||||||
|
return '${u.year}${two(u.month)}${two(u.day)}'
|
||||||
|
'${two(u.hour)}${two(u.minute)}${two(u.second)} +0000';
|
||||||
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
final service = XmltvEpgService(sourceUrls: const []);
|
final service = XmltvEpgService(sourceUrls: const []);
|
||||||
|
|
||||||
@@ -51,6 +58,71 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('nameKeys', () {
|
||||||
|
// Noms réels du panneau (prod) : 59 % des chaînes françaises n'ont pas
|
||||||
|
// d'epg_channel_id, seul leur nom permet de retrouver le guide.
|
||||||
|
test('retire le préfixe pays et les marqueurs de qualité', () {
|
||||||
|
expect(XmltvEpgService.nameKeys('FR - TF1 FHD ◉'), contains('tf1'));
|
||||||
|
expect(XmltvEpgService.nameKeys('FR - FRANCE 4 FHD'), contains('france4'));
|
||||||
|
expect(XmltvEpgService.nameKeys('|FR| M6 HD'), contains('m6'));
|
||||||
|
expect(XmltvEpgService.nameKeys('[FR] Arte UHD 4K'), contains('arte'));
|
||||||
|
expect(XmltvEpgService.nameKeys('FR: RMC Story HEVC'), contains('rmcstory'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('propose aussi la forme identifiant « nom.pays »', () {
|
||||||
|
// Les dumps publics identifient souvent la chaîne par `TF1.fr`.
|
||||||
|
expect(XmltvEpgService.nameKeys('FR - TF1 FHD'), contains('tf1fr'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('garde la variante décalée distincte de la chaîne principale', () {
|
||||||
|
expect(XmltvEpgService.nameKeys('FR - TF1 +1 FHD'), isNot(contains('tf1')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ne réduit pas un nom court à rien', () {
|
||||||
|
expect(XmltvEpgService.nameKeys('M6'), contains('m6'));
|
||||||
|
expect(XmltvEpgService.nameKeys('HD'), isNot(contains('')));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('programmesFor par nom', () {
|
||||||
|
test('trouve le guide d\'une chaîne sans epg_channel_id par son nom nettoyé',
|
||||||
|
() async {
|
||||||
|
final now = DateTime.now().toUtc();
|
||||||
|
String stamp(DateTime d) {
|
||||||
|
final u = d.toUtc();
|
||||||
|
String two(int v) => v.toString().padLeft(2, '0');
|
||||||
|
return '${u.year}${two(u.month)}${two(u.day)}'
|
||||||
|
'${two(u.hour)}${two(u.minute)}${two(u.second)} +0000';
|
||||||
|
}
|
||||||
|
|
||||||
|
final dump = '''
|
||||||
|
<tv>
|
||||||
|
<channel id="TF1.fr"><display-name>TF1 HD</display-name><display-name>TF1</display-name></channel>
|
||||||
|
<programme start="${stamp(now)}" stop="${stamp(now.add(const Duration(hours: 1)))}" channel="TF1.fr">
|
||||||
|
<title>Le 13h</title>
|
||||||
|
</programme>
|
||||||
|
</tv>
|
||||||
|
''';
|
||||||
|
final svc = XmltvEpgService(
|
||||||
|
sourceUrls: const ['http://ext/epg.xml'],
|
||||||
|
client: MockClient((_) async => http.Response(dump, 200)),
|
||||||
|
);
|
||||||
|
final hits = await svc.programmesFor(null, displayName: 'FR - TF1 FHD ◉');
|
||||||
|
expect(hits.single.title, 'Le 13h');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('indexe chaque display-name, pas seulement le premier', () {
|
||||||
|
final now = DateTime.now().toUtc();
|
||||||
|
final index = service.parseForTest('''
|
||||||
|
<tv>
|
||||||
|
<channel id="x.fr"><display-name>Premier Nom</display-name><display-name>Second Nom</display-name></channel>
|
||||||
|
<programme start="${_stamp(now)}" stop="${_stamp(now.add(const Duration(hours: 1)))}" channel="x.fr"><title>T</title></programme>
|
||||||
|
</tv>
|
||||||
|
''');
|
||||||
|
expect(index[XmltvEpgService.normalizeKey('Second Nom')], isNotNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
group('parseXmltvDate', () {
|
group('parseXmltvDate', () {
|
||||||
test('applique le décalage horaire annoncé', () {
|
test('applique le décalage horaire annoncé', () {
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
Reference in new issue
Block a user