From 8d79472ca8e152c01b7c4d3b7d4c26e4342512d9 Mon Sep 17 00:00:00 2001 From: Michael Date: Sat, 10 Oct 2026 22:34:26 +0200 Subject: [PATCH] =?UTF-8?q?D=C3=A9marrage=20:=20la=20lecture=20du=20quota?= =?UTF-8?q?=20ne=20fait=20plus=20attendre=20le=20flux?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AccountLimits interrogeait player_api.php avant d'ouvrir le flux quand sa valeur n'était pas en cache (premier flux, puis toutes les 10 min). Le panneau met jusqu'à 4 s à répondre (mesuré en prod) : autant de retard au démarrage d'une chaîne ou d'un film. La valeur connue, même périmée, ou le repli (1) est rendue tout de suite ; la lecture chez le panneau se fait en arrière-plan, une seule à la fois. Sans risque : le quota ne sert qu'à choisir quels flux orphelins couper. Co-Authored-By: Claude Opus 5.5 --- bin/services/upstream_slots.dart | 37 +++++++++++++++--------- bin/test/upstream_slots_test.dart | 48 +++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 14 deletions(-) diff --git a/bin/services/upstream_slots.dart b/bin/services/upstream_slots.dart index 4e5ddcc..23ea804 100644 --- a/bin/services/upstream_slots.dart +++ b/bin/services/upstream_slots.dart @@ -124,13 +124,21 @@ int? parseMaxConnections(Object? json) { } /// Quota de connexions par compte, lu chez le fournisseur et mis en cache. +/// +/// Ne fait JAMAIS attendre un flux : `player_api.php` met jusqu'à 4 s à +/// répondre (mesuré en prod), délai qui s'ajoutait au démarrage du premier +/// flux puis de chaque flux suivant l'expiration du cache. La valeur connue +/// (même périmée), sinon [fallback], est rendue immédiatement ; la lecture +/// chez le panneau se fait en arrière-plan. Sans risque : le quota ne sert +/// qu'à décider quels flux ORPHELINS couper. class AccountLimits { final Duration ttl; final Future Function(PlaylistConfig) _fetch; final Map _cache = {}; + final Set _refreshing = {}; - /// Valeur retenue quand le panneau ne répond pas : un seul flux, le cas - /// de loin le plus courant chez les fournisseurs Xtream. + /// Valeur retenue tant que le panneau n'a pas répondu : un seul flux, le + /// cas de loin le plus courant chez les fournisseurs Xtream. static const fallback = 1; AccountLimits({ @@ -141,19 +149,20 @@ class AccountLimits { Future maxFor(PlaylistConfig p) async { final key = accountKeyOf(p); final cached = _cache[key]; - if (cached != null && DateTime.now().difference(cached.at) < ttl) { - return cached.max; + if (cached == null || DateTime.now().difference(cached.at) >= ttl) { + _refresh(key, p); } - int? value; - try { - value = await _fetch(p); - } catch (_) { - value = null; - } - // Un échec n'est pas mis en cache longtemps : on réessaiera au prochain - // flux plutôt que de rester 10 min sur une valeur devinée. - if (value != null) _cache[key] = (max: value, at: DateTime.now()); - return value ?? fallback; + return cached?.max ?? fallback; + } + + void _refresh(String key, PlaylistConfig p) { + if (!_refreshing.add(key)) return; // une seule lecture à la fois + Future.sync(() => _fetch(p)) + .then((v) => v, onError: (Object _) => null) + .then((value) { + // Un échec n'est pas mis en cache : on réessaiera au prochain flux. + if (value != null) _cache[key] = (max: value, at: DateTime.now()); + }).whenComplete(() => _refreshing.remove(key)); } static Future _fetchFromPanel(PlaylistConfig p) async { diff --git a/bin/test/upstream_slots_test.dart b/bin/test/upstream_slots_test.dart index fd8dce4..8a595e3 100644 --- a/bin/test/upstream_slots_test.dart +++ b/bin/test/upstream_slots_test.dart @@ -1,4 +1,7 @@ +import 'dart:async'; + import 'package:test/test.dart'; +import '../models/playlist_config.dart'; import '../services/upstream_slots.dart'; void main() { @@ -83,6 +86,51 @@ void main() { }); }); + group('AccountLimits', () { + PlaylistConfig playlist() => PlaylistConfig( + id: 'p', + name: 'n', + dns: 'http://panel.test', + username: 'u', + password: 'x', + createdAt: DateTime(2026), + ); + + test('ne fait jamais attendre un flux : repli immédiat, valeur réelle ensuite', () async { + final pending = Completer(); + final limits = AccountLimits(fetch: (_) => pending.future); + + // Le panneau répond lentement (4 s mesurées en prod) : le premier flux + // part tout de suite sur la valeur de repli. + expect(await limits.maxFor(playlist()), AccountLimits.fallback); + + pending.complete(3); + await Future.delayed(Duration.zero); + expect(await limits.maxFor(playlist()), 3); + }); + + test('valeur périmée servie pendant le rafraîchissement', () async { + var calls = 0; + final limits = AccountLimits( + ttl: Duration.zero, + fetch: (_) async => ++calls == 1 ? 2 : 5, + ); + await limits.maxFor(playlist()); + await Future.delayed(Duration.zero); + // Périmée (ttl nul) : la valeur connue sort sans attendre la nouvelle. + expect(await limits.maxFor(playlist()), 2); + await Future.delayed(Duration.zero); + expect(await limits.maxFor(playlist()), 5); + }); + + test('panneau en échec : repli, sans exception', () async { + final limits = AccountLimits(fetch: (_) async => throw Exception('down')); + expect(await limits.maxFor(playlist()), AccountLimits.fallback); + await Future.delayed(Duration.zero); + expect(await limits.maxFor(playlist()), AccountLimits.fallback); + }); + }); + group('parseMaxConnections', () { test('lit la valeur texte renvoyée par player_api', () { expect(