mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-11 17:30:00 +02:00
Logos, guide TV et fluidité : couper le flot de requêtes mortes
Logos absents : l'hébergeur des picons du fournisseur répond 503 sur tout, y compris sa racine (serveur en maintenance). Le proxy relayait ce 503 au navigateur, qui redemandait chaque logo à chaque affichage de la grille — des centaines de requêtes sortantes mortes par seconde. - Un statut >= 400 sur une image rend désormais un pixel transparent avec `Cache-Control: max-age=600`, au lieu de propager l'erreur - Après trois échecs consécutifs, l'hôte est marqué hors service cinq minutes : plus aucun appel sortant, réponse servie localement - Le corps d'erreur amont est drainé, sinon la connexion restait ouverte jusqu'au timeout Guide TV très lent (4 à 8 s par chaîne dans les logs) : `player_api` demande un appel par chaîne, et le panneau met ~4 s à répondre — une grille de trente chaînes dépassait la minute. - Le dump `xmltv.php` du panneau passe en premier : une requête couvre toutes les chaînes, réutilisée trois heures - L'interrogation chaîne par chaîne reste le repli, puis la source XMLTV externe - Une source XMLTV en échec n'est plus retéléchargée à chaque consultation : quinze minutes de recul - Index borné à 48 h en avant : un dump national couvre sept jours pour un millier de chaînes, soit des centaines de Mo pour un guide qui n'affiche que le programme courant Lecture saccadée : le relais FFmpeg -> navigateur de `/turbo.ts` ne transmettait pas la pause du client à la source. Un lecteur plus lent que le flux laissait FFmpeg produire à pleine vitesse et le serveur empilait les paquets en mémoire, d'où la dérive derrière le direct. La contre-pression est maintenant propagée (`pipeWithBackpressure`). Le serveur tourne sur un seul isolate : le flot de requêtes mortes et les attentes EPG de plusieurs secondes partageaient la boucle d'événements avec les paquets vidéo. Tests ajoutés : cache d'échecs par hôte, contre-pression du relais, priorité des sources EPG, recul après échec XMLTV, horizon d'index. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2151f2820c
commit
b6ca9dab96
11 files changed
+716
-78
No files matched your search
@@ -0,0 +1,57 @@
|
||||
import 'package:test/test.dart';
|
||||
import '../api/asset_failure_cache.dart';
|
||||
|
||||
void main() {
|
||||
group('AssetFailureCache', () {
|
||||
test('un hôte inconnu est considéré joignable', () {
|
||||
final cache = AssetFailureCache();
|
||||
expect(cache.isDown('cdn.example'), isFalse);
|
||||
});
|
||||
|
||||
test('un échec isolé ne coupe pas l’hôte', () {
|
||||
// Un 5xx passager ne doit pas priver l'utilisateur de ses logos
|
||||
// pendant plusieurs minutes.
|
||||
final cache = AssetFailureCache(threshold: 3);
|
||||
cache.recordFailure('cdn.example');
|
||||
expect(cache.isDown('cdn.example'), isFalse);
|
||||
});
|
||||
|
||||
test('coupe l’hôte après le seuil d’échecs consécutifs', () {
|
||||
final cache = AssetFailureCache(threshold: 3);
|
||||
for (var i = 0; i < 3; i++) {
|
||||
cache.recordFailure('cdn.example');
|
||||
}
|
||||
expect(cache.isDown('cdn.example'), isTrue);
|
||||
});
|
||||
|
||||
test('un succès remet le compteur à zéro', () {
|
||||
final cache = AssetFailureCache(threshold: 3);
|
||||
cache.recordFailure('cdn.example');
|
||||
cache.recordFailure('cdn.example');
|
||||
cache.recordSuccess('cdn.example');
|
||||
cache.recordFailure('cdn.example');
|
||||
expect(cache.isDown('cdn.example'), isFalse);
|
||||
});
|
||||
|
||||
test('la coupure expire après le TTL', () {
|
||||
var now = DateTime(2026, 9, 19, 9);
|
||||
final cache = AssetFailureCache(
|
||||
threshold: 1,
|
||||
ttl: const Duration(minutes: 5),
|
||||
clock: () => now,
|
||||
);
|
||||
cache.recordFailure('cdn.example');
|
||||
expect(cache.isDown('cdn.example'), isTrue);
|
||||
|
||||
now = now.add(const Duration(minutes: 6));
|
||||
expect(cache.isDown('cdn.example'), isFalse);
|
||||
});
|
||||
|
||||
test('les hôtes sont indépendants', () {
|
||||
final cache = AssetFailureCache(threshold: 1);
|
||||
cache.recordFailure('mort.example');
|
||||
expect(cache.isDown('mort.example'), isTrue);
|
||||
expect(cache.isDown('vivant.example'), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,12 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:shelf/shelf.dart';
|
||||
import 'package:test/test.dart';
|
||||
import '../api/epg_api.dart';
|
||||
import '../models/playlist_config.dart';
|
||||
import '../services/xmltv_epg_service.dart';
|
||||
|
||||
/// Extrait réel d'une réponse `get_simple_data_table` (panneau Xtream) :
|
||||
/// titres/descriptions en base64, horodatages epoch UTC doublés d'une chaîne
|
||||
@@ -71,4 +78,144 @@ void main() {
|
||||
expect(result['programmes'], isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('handleGetEpg', () {
|
||||
final playlist = PlaylistConfig(
|
||||
id: 'p1',
|
||||
name: 'Test',
|
||||
dns: 'http://panel.example',
|
||||
username: 'u',
|
||||
password: 'p',
|
||||
createdAt: DateTime(2026, 1, 1),
|
||||
);
|
||||
|
||||
String xmltvDump(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="France2.fr"><display-name>FRANCE 2</display-name></channel>
|
||||
<programme start="${stamp(start)}" stop="${stamp(stop)}" channel="France2.fr">
|
||||
<title lang="fr">Journal de 20h</title>
|
||||
<desc lang="fr">L'info du soir</desc>
|
||||
</programme>
|
||||
</tv>
|
||||
''';
|
||||
}
|
||||
|
||||
Request requestFor(String channelId) =>
|
||||
Request('GET', Uri.parse('http://localhost/api/epg/$channelId'));
|
||||
|
||||
test('sert le guide depuis le dump XMLTV du panneau, sans appel par '
|
||||
'chaîne', () async {
|
||||
// Un appel `player_api` par chaîne coûte plusieurs secondes chez le
|
||||
// fournisseur : le dump du panneau couvre toutes les chaînes d'un coup.
|
||||
final now = DateTime.now().toUtc();
|
||||
final actions = <String>[];
|
||||
|
||||
final client = MockClient((request) async {
|
||||
final url = request.url;
|
||||
if (url.path.endsWith('/xmltv.php')) {
|
||||
return http.Response(
|
||||
xmltvDump(now.subtract(const Duration(minutes: 5)),
|
||||
now.add(const Duration(minutes: 25))),
|
||||
200,
|
||||
);
|
||||
}
|
||||
final action = url.queryParameters['action'] ?? '';
|
||||
actions.add(action);
|
||||
if (action == 'get_live_streams') {
|
||||
return http.Response(
|
||||
jsonEncode([
|
||||
{
|
||||
'stream_id': 845452,
|
||||
'name': 'FRANCE 2',
|
||||
'epg_channel_id': 'France2.fr',
|
||||
},
|
||||
]),
|
||||
200,
|
||||
);
|
||||
}
|
||||
return http.Response('{}', 200);
|
||||
});
|
||||
|
||||
final api = EpgApi(
|
||||
(_) async => playlist,
|
||||
httpClient: client,
|
||||
panelXmltvBuilder: (config) => XmltvEpgService(
|
||||
sourceUrls: ['${config.dns}/xmltv.php'],
|
||||
client: client,
|
||||
),
|
||||
);
|
||||
|
||||
final response = await api.handleGetEpg(requestFor('845452'), '845452');
|
||||
final body = jsonDecode(await response.readAsString()) as Map;
|
||||
|
||||
expect(response.statusCode, 200);
|
||||
expect((body['programmes'] as List).single['title'], 'Journal de 20h');
|
||||
expect(response.headers['X-Epg-Source'], 'panel-xmltv');
|
||||
// Seule la table des chaînes est interrogée : aucune action EPG.
|
||||
expect(actions, ['get_live_streams']);
|
||||
});
|
||||
|
||||
test('retombe sur player_api quand le dump ne couvre pas la chaîne',
|
||||
() async {
|
||||
final actions = <String>[];
|
||||
// Heure locale volontairement : le panneau émet des horodatages sans
|
||||
// fuseau, que le serveur relit comme de l'heure locale.
|
||||
final future = DateTime.now().add(const Duration(minutes: 10));
|
||||
String panelStamp(DateTime d) =>
|
||||
d.toIso8601String().substring(0, 19).replaceFirst('T', ' ');
|
||||
|
||||
final client = MockClient((request) async {
|
||||
final url = request.url;
|
||||
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(const []), 200);
|
||||
}
|
||||
if (action == 'get_simple_data_table') {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'epg_listings': [
|
||||
{
|
||||
'title': base64Encode(utf8.encode('Match')),
|
||||
'description': '',
|
||||
'start': panelStamp(future),
|
||||
'end': panelStamp(future.add(const Duration(hours: 2))),
|
||||
},
|
||||
],
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
return http.Response('{}', 200);
|
||||
});
|
||||
|
||||
final api = EpgApi(
|
||||
(_) async => playlist,
|
||||
httpClient: client,
|
||||
panelXmltvBuilder: (config) => XmltvEpgService(
|
||||
sourceUrls: ['${config.dns}/xmltv.php'],
|
||||
client: client,
|
||||
),
|
||||
);
|
||||
|
||||
final response = await api.handleGetEpg(requestFor('999'), '999');
|
||||
final body = jsonDecode(await response.readAsString()) as Map;
|
||||
|
||||
expect((body['programmes'] as List).single['title'], 'Match');
|
||||
expect(response.headers['X-Epg-Source'], 'xtream');
|
||||
expect(actions, contains('get_simple_data_table'));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:test/test.dart';
|
||||
import '../utils/stream_pipe.dart';
|
||||
|
||||
void main() {
|
||||
group('pipeWithBackpressure', () {
|
||||
test('relaie les données au client', () async {
|
||||
final source = StreamController<List<int>>();
|
||||
final piped = pipeWithBackpressure(source.stream, onStop: () {});
|
||||
|
||||
final received = <List<int>>[];
|
||||
final done = piped.listen(received.add).asFuture<void>();
|
||||
|
||||
source.add([1, 2, 3]);
|
||||
await source.close();
|
||||
await done;
|
||||
|
||||
expect(received, [
|
||||
[1, 2, 3]
|
||||
]);
|
||||
});
|
||||
|
||||
test('propage la pause du client vers la source', () async {
|
||||
// Sans cette propagation, un client plus lent que le flux laissait
|
||||
// FFmpeg produire à pleine vitesse et le serveur empilait les paquets
|
||||
// en mémoire : latence qui dérive et lecture qui saccade.
|
||||
var paused = false;
|
||||
var resumed = false;
|
||||
final source = StreamController<List<int>>(
|
||||
onPause: () => paused = true,
|
||||
onResume: () => resumed = true,
|
||||
);
|
||||
|
||||
final subscription = pipeWithBackpressure(
|
||||
source.stream,
|
||||
onStop: () {},
|
||||
).listen((_) {});
|
||||
|
||||
subscription.pause();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(paused, isTrue);
|
||||
|
||||
subscription.resume();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(resumed, isTrue);
|
||||
|
||||
await subscription.cancel();
|
||||
await source.close();
|
||||
});
|
||||
|
||||
test('arrête le producteur quand le client se déconnecte', () async {
|
||||
var stopped = 0;
|
||||
final source = StreamController<List<int>>();
|
||||
final subscription =
|
||||
pipeWithBackpressure(source.stream, onStop: () => stopped++)
|
||||
.listen((_) {});
|
||||
|
||||
await subscription.cancel();
|
||||
|
||||
expect(stopped, 1);
|
||||
await source.close();
|
||||
});
|
||||
|
||||
test('arrête le producteur quand la source se termine', () async {
|
||||
var stopped = 0;
|
||||
final source = StreamController<List<int>>();
|
||||
final piped = pipeWithBackpressure(source.stream, onStop: () => stopped++);
|
||||
final done = piped.listen((_) {}).asFuture<void>();
|
||||
|
||||
await source.close();
|
||||
await done;
|
||||
|
||||
expect(stopped, 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:test/test.dart';
|
||||
import '../services/xmltv_epg_service.dart';
|
||||
|
||||
@@ -101,4 +103,65 @@ void main() {
|
||||
expect(index, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('ensureFresh', () {
|
||||
test('ne retélécharge pas tant que l’index est frais', () async {
|
||||
final now = DateTime.now().toUtc();
|
||||
var calls = 0;
|
||||
final service = XmltvEpgService(
|
||||
sourceUrls: const ['http://dump.example/epg.xml'],
|
||||
client: MockClient((_) async {
|
||||
calls++;
|
||||
return http.Response(
|
||||
_fixture(now, now.add(const Duration(minutes: 30))),
|
||||
200,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
await service.ensureFresh();
|
||||
await service.ensureFresh();
|
||||
|
||||
expect(calls, 1);
|
||||
expect(service.hasData, isTrue);
|
||||
});
|
||||
|
||||
test('espace les tentatives après un échec', () async {
|
||||
// Sans ce recul, une source morte était retéléchargée à chaque
|
||||
// consultation du guide : une requête sortante par chaîne affichée.
|
||||
var calls = 0;
|
||||
final service = XmltvEpgService(
|
||||
sourceUrls: const ['http://dump.example/epg.xml'],
|
||||
retryBackoff: const Duration(minutes: 15),
|
||||
client: MockClient((_) async {
|
||||
calls++;
|
||||
return http.Response('nope', 500);
|
||||
}),
|
||||
);
|
||||
|
||||
await service.ensureFresh();
|
||||
await service.ensureFresh();
|
||||
await service.ensureFresh();
|
||||
|
||||
expect(calls, 1);
|
||||
expect(service.hasData, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('horizon', () {
|
||||
test('écarte les programmes au-delà de l’horizon d’indexation', () {
|
||||
// Un dump national couvre sept jours : tout garder ferait grossir
|
||||
// l'index pour un guide qui n'affiche que le programme courant.
|
||||
final service = XmltvEpgService(
|
||||
sourceUrls: const [],
|
||||
horizon: const Duration(hours: 48),
|
||||
);
|
||||
final far = DateTime.now().toUtc().add(const Duration(days: 5));
|
||||
|
||||
expect(
|
||||
service.parseForTest(_fixture(far, far.add(const Duration(hours: 1)))),
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user