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:
MichaelandClaude Opus 5 committed 2026-09-19 11:57:38 +02:00
1 parent 2151f2820c
commit b6ca9dab96
11 files changed
+716 -78

No files matched your search

+57
View File
@@ -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);
});
});
}
+147
View File
@@ -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'));
});
});
}
+77
View File
@@ -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);
});
});
}
+63
View File
@@ -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,
);
});
});
}