mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-20 21:48:06 +02:00
Logos des chaînes : repli par nom quand l'hébergeur de picons tombe
L'hébergeur des picons du fournisseur (51.158.145.100) répond toujours 503 sur toutes ses URL. Couper le flot de requêtes ne suffisait pas : la grille restait sans logo. Pire, le pixel transparent servi en 200 était pris pour une image valide par Flutter, qui n'affichait plus son icône de repli — d'où des tuiles entièrement vides. - Nouvelle route `/api/logo?src=…&name=…` : URL du panneau d'abord (via le proxy, mêmes protections anti-SSRF et même mémoire des hôtes morts), puis recherche par nom dans le dépôt public tv-logo/tv-logos - Appariement par nom : préfixe pays et suffixes de qualité ignorés, `+` → `plus`, variante plus longue (`bein-sports-1-french`), puis marque sans numéro pour les canaux événementiels. Aucune approximation sur un mot seul - Liste de logos en cache 24 h (API GitHub : 60 requêtes/h non authentifiées), 15 min de recul après échec, images en mémoire - Image indisponible : 410 mis en cache au lieu du pixel. 410 et non 404 : la Cascade du serveur rattrape les 404 et renvoyait celui du handler statique, sans cache-control - Client (grille bureau, mobile, enregistrements) : logo toujours demandé au backend, nom compris, même sans stream_icon. L'onglet Enregistrements chargeait l'URL http brute, bloquée en HTTPS Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
bde32adda9
commit
9843999809
13 files changed
+722
-87
No files matched your search
@@ -1,3 +1,23 @@
|
||||
import 'package:shelf/shelf.dart';
|
||||
|
||||
/// Réponse pour une image indisponible.
|
||||
///
|
||||
/// Statut 410 et non 404 : la `Cascade` du serveur retombe sur le handler
|
||||
/// suivant pour tout 404 et renvoie finalement celui du handler statique, sans
|
||||
/// notre `cache-control`. Un 410 s'arrête ici.
|
||||
///
|
||||
/// Un statut d'erreur plutôt qu'une image vide : le client affiche alors son
|
||||
/// propre repli (icône, initiale) au lieu d'une tuile blanche. Le `max-age`
|
||||
/// est essentiel : sans lui le navigateur redemande le logo à chaque
|
||||
/// affichage de la grille et le flot de requêtes mortes reprend aussitôt.
|
||||
Response missingImageResponse() => Response(
|
||||
410,
|
||||
headers: {
|
||||
'cache-control': 'public, max-age=600',
|
||||
'access-control-allow-origin': '*',
|
||||
},
|
||||
);
|
||||
|
||||
/// Mémoire courte des hôtes d'images injoignables.
|
||||
///
|
||||
/// POURQUOI : les URL de picons pointent chez l'hébergeur du revendeur, pas
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'package:shelf/shelf.dart';
|
||||
|
||||
import '../services/logo_catalog.dart';
|
||||
import 'asset_failure_cache.dart';
|
||||
|
||||
/// GET /api/logo?src=<stream_icon>&name=<nom de chaîne>
|
||||
///
|
||||
/// Logo d'une chaîne, avec repli. L'URL fournie par le panneau est tentée
|
||||
/// d'abord, via le proxy Xtream (mêmes protections anti-SSRF, même mémoire
|
||||
/// des hôtes morts). En cas d'échec, le logo est cherché par nom dans le
|
||||
/// dépôt public `tv-logos`.
|
||||
///
|
||||
/// POURQUOI : quand l'hébergeur de picons du revendeur tombe, toute la grille
|
||||
/// perd ses logos d'un coup, alors que les autres applications IPTV les
|
||||
/// affichent encore grâce à leurs propres sources de repli.
|
||||
class LogoApi {
|
||||
LogoApi(this._proxy, this._catalog);
|
||||
|
||||
/// Proxy Xtream (`ProxyHandler.handler`), qui attend `/api/xtream/<url>`.
|
||||
final Handler _proxy;
|
||||
|
||||
final LogoCatalog _catalog;
|
||||
|
||||
/// Un logo trouvé change rarement : un jour de cache navigateur.
|
||||
static const _found = 'public, max-age=86400';
|
||||
|
||||
Future<Response> handle(Request request) async {
|
||||
final src = request.url.queryParameters['src'] ?? '';
|
||||
final name = request.url.queryParameters['name'] ?? '';
|
||||
|
||||
// Une URL panneau malformée ne doit pas priver la chaîne du repli.
|
||||
final proxied = src.startsWith('http://') || src.startsWith('https://')
|
||||
? Uri.tryParse('${request.requestedUri.origin}/api/xtream/$src')
|
||||
: null;
|
||||
|
||||
if (proxied != null) {
|
||||
final upstream = await _proxy(
|
||||
Request(
|
||||
'GET',
|
||||
proxied,
|
||||
headers: {
|
||||
for (final header in const ['cookie', 'authorization'])
|
||||
if (request.headers[header] != null)
|
||||
header: request.headers[header]!,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final type = upstream.headers['content-type'] ?? '';
|
||||
if (upstream.statusCode == 200 && type.startsWith('image/')) {
|
||||
return Response.ok(
|
||||
upstream.read(),
|
||||
headers: {'content-type': type, 'cache-control': _found},
|
||||
);
|
||||
}
|
||||
// Corps d'erreur consommé : sinon la connexion amont reste ouverte.
|
||||
await upstream.read().drain<void>();
|
||||
}
|
||||
|
||||
if (name.isNotEmpty) {
|
||||
final bytes = await _catalog.logoFor(name);
|
||||
if (bytes != null) {
|
||||
return Response.ok(
|
||||
bytes,
|
||||
headers: {'content-type': 'image/png', 'cache-control': _found},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return missingImageResponse();
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
import 'package:shelf/shelf.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../database/database.dart';
|
||||
@@ -10,11 +9,6 @@ import '../models/playlist_config.dart';
|
||||
import '../utils/log_redactor.dart';
|
||||
import 'asset_failure_cache.dart';
|
||||
|
||||
/// PNG transparent 1×1, servi à la place d'un logo introuvable.
|
||||
final Uint8List _placeholderPixel = base64Decode(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=',
|
||||
);
|
||||
|
||||
/// L'URL désigne-t-elle une image ou un logo ?
|
||||
///
|
||||
/// Ces URL échappent à l'allowlist de domaine (les revendeurs hébergent leurs
|
||||
@@ -30,20 +24,6 @@ bool _isStaticAssetUrl(Uri url) =>
|
||||
url.path.contains('/picons/') ||
|
||||
url.path.contains('/logos/');
|
||||
|
||||
/// Réponse de repli pour une image indisponible.
|
||||
///
|
||||
/// Le `max-age` est essentiel : sans lui le navigateur redemande le logo à
|
||||
/// chaque affichage de la grille et le flot de requêtes mortes reprend
|
||||
/// immédiatement, coupure serveur ou pas.
|
||||
Response _placeholderImageResponse() => Response.ok(
|
||||
_placeholderPixel,
|
||||
headers: {
|
||||
'content-type': 'image/png',
|
||||
'cache-control': 'public, max-age=600',
|
||||
'access-control-allow-origin': '*',
|
||||
},
|
||||
);
|
||||
|
||||
/// Returns true when [host] must never be proxied (loopback, private LAN,
|
||||
/// link-local/cloud-metadata ranges) — SSRF protection for asset URLs that
|
||||
/// bypass the playlist-domain allowlist.
|
||||
@@ -182,11 +162,11 @@ class ProxyHandler {
|
||||
// Xtream providers often use separate CDN servers for picons/images
|
||||
final isStaticAsset = _isStaticAssetUrl(targetUrl);
|
||||
|
||||
// Hôte d'images déjà constaté mort : on sert le pixel tout de suite.
|
||||
// Hôte d'images déjà constaté mort : on répond tout de suite.
|
||||
// Aucun appel sortant, aucune entrée de log — c'est précisément le
|
||||
// flot qu'on cherche à éteindre.
|
||||
if (isStaticAsset && _assetFailures.isDown(targetUrl.host)) {
|
||||
return _placeholderImageResponse();
|
||||
return missingImageResponse();
|
||||
}
|
||||
|
||||
String? allowedHost;
|
||||
@@ -292,7 +272,7 @@ class ProxyHandler {
|
||||
|
||||
// Une image en erreur n'a rien à transmettre au navigateur : le
|
||||
// relais du 503 déclenchait un nouveau cycle de requêtes à chaque
|
||||
// rendu. On sert le pixel, mis en cache, et on compte l'échec.
|
||||
// rendu. On répond un 404 mis en cache, et on compte l'échec.
|
||||
if (isStaticAsset) {
|
||||
if (response.statusCode >= 400) {
|
||||
// Compté sur l'hôte demandé, pas sur la cible finale d'une
|
||||
@@ -302,7 +282,7 @@ class ProxyHandler {
|
||||
// Le corps d'erreur doit être consommé, sinon la connexion
|
||||
// reste ouverte jusqu'au timeout.
|
||||
unawaited(response.stream.drain<void>().catchError((_) {}));
|
||||
return _placeholderImageResponse();
|
||||
return missingImageResponse();
|
||||
}
|
||||
_assetFailures.recordSuccess(targetUrl.host);
|
||||
}
|
||||
@@ -338,10 +318,10 @@ class ProxyHandler {
|
||||
'${LogRedactor.redactUrl('$e')}',
|
||||
);
|
||||
|
||||
// Return transparent 1x1 pixel image fallback for images
|
||||
// Image injoignable : 404 mis en cache plutôt qu'une erreur 500
|
||||
if (targetUrl != null && _isStaticAssetUrl(targetUrl)) {
|
||||
_assetFailures.recordFailure(targetUrl.host);
|
||||
return _placeholderImageResponse();
|
||||
return missingImageResponse();
|
||||
}
|
||||
|
||||
return Response.internalServerError(
|
||||
|
||||
@@ -18,7 +18,9 @@ import 'api/streaming_handler.dart';
|
||||
import 'api/proxy_handler.dart';
|
||||
import 'api/recordings_api.dart';
|
||||
import 'api/epg_api.dart';
|
||||
import 'api/logo_api.dart';
|
||||
import 'services/xmltv_epg_service.dart';
|
||||
import 'services/logo_catalog.dart';
|
||||
import 'api/season_passes_api.dart';
|
||||
import 'api/xtream_api_handler.dart';
|
||||
import 'middleware/auth_middleware.dart';
|
||||
@@ -121,6 +123,9 @@ void main(List<String> args) async {
|
||||
final usersHandler = UsersHandler(db);
|
||||
final settingsHandler = SettingsHandler(db);
|
||||
final proxyHandler = ProxyHandler(getPlaylist, db);
|
||||
// Logos de chaînes : URL du panneau d'abord, puis repli par nom sur le
|
||||
// dépôt public tv-logos quand l'hébergeur de picons du revendeur tombe.
|
||||
final logoApi = LogoApi(proxyHandler.handler, LogoCatalog());
|
||||
final recordingsApi = RecordingsApi(db, recordingScheduler);
|
||||
// Source XMLTV de repli. Vider EPG_XMLTV_URLS désactive tout appel sortant :
|
||||
// l'EPG se limite alors au panneau de l'abonné.
|
||||
@@ -204,6 +209,14 @@ void main(List<String> args) async {
|
||||
.addMiddleware(authMiddleware(db))
|
||||
.addHandler(recordingsRouter.call),
|
||||
)
|
||||
// Logos de chaînes avec repli (auth required ; le cookie de session
|
||||
// accompagne les requêtes d'image du navigateur)
|
||||
..get(
|
||||
'/api/logo',
|
||||
const Pipeline()
|
||||
.addMiddleware(authMiddleware(db))
|
||||
.addHandler(logoApi.handle),
|
||||
)
|
||||
// EPG - guide TV (auth required)
|
||||
..mount(
|
||||
'/api/epg',
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
/// Logos de chaînes de repli, tirés du dépôt public `tv-logo/tv-logos`.
|
||||
///
|
||||
/// POURQUOI : les URL `stream_icon` du panneau pointent chez l'hébergeur du
|
||||
/// revendeur. Quand cette machine tombe — cas observé : serveur de picons en
|
||||
/// maintenance, 503 sur toutes les URL —, toute la grille reste sans logo.
|
||||
/// Le dépôt `tv-logos` est maintenu, nommé de façon régulière
|
||||
/// (`countries/france/rmc-sport-2-fr.png`) et s'apparie au nom de chaîne.
|
||||
class LogoCatalog {
|
||||
LogoCatalog({
|
||||
http.Client? client,
|
||||
this.listingTtl = const Duration(hours: 24),
|
||||
this.retryBackoff = const Duration(minutes: 15),
|
||||
}) : _client = client ?? http.Client();
|
||||
|
||||
final http.Client _client;
|
||||
|
||||
/// Durée de vie d'une liste de logos par pays. L'API GitHub non
|
||||
/// authentifiée plafonne à 60 requêtes par heure : une par jour suffit.
|
||||
final Duration listingTtl;
|
||||
|
||||
/// Recul après un échec de téléchargement de liste (limite de débit,
|
||||
/// réseau), pour ne pas marteler l'API à chaque tuile affichée.
|
||||
final Duration retryBackoff;
|
||||
|
||||
final Map<String, _Listing> _listings = {};
|
||||
final Map<String, Future<_Listing?>> _listingsInFlight = {};
|
||||
final Map<String, DateTime> _listingFailedAt = {};
|
||||
final Map<String, List<int>> _bytes = {};
|
||||
|
||||
/// Octets PNG du logo correspondant à [channelName], ou `null`.
|
||||
Future<List<int>?> logoFor(String channelName) async {
|
||||
final country = countryFolderFor(channelName);
|
||||
final listing = await _listingFor(country);
|
||||
if (listing == null) return null;
|
||||
|
||||
final key = matchLogoKey(channelName, listing.urls.keys);
|
||||
if (key == null) return null;
|
||||
|
||||
final url = listing.urls[key]!;
|
||||
final cached = _bytes[url];
|
||||
if (cached != null) return cached;
|
||||
|
||||
try {
|
||||
final response = await _client
|
||||
.get(Uri.parse(url))
|
||||
.timeout(const Duration(seconds: 20));
|
||||
if (response.statusCode != 200) return null;
|
||||
return _bytes[url] = response.bodyBytes;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<_Listing?> _listingFor(String country) async {
|
||||
final cached = _listings[country];
|
||||
if (cached != null &&
|
||||
DateTime.now().difference(cached.fetchedAt) < listingTtl) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
final failedAt = _listingFailedAt[country];
|
||||
if (failedAt != null &&
|
||||
DateTime.now().difference(failedAt) < retryBackoff) {
|
||||
// Liste périmée plutôt que rien : les logos ne changent pas d'un jour
|
||||
// à l'autre.
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Les tuiles d'une grille arrivent en rafale : une seule requête de liste.
|
||||
return _listingsInFlight[country] ??=
|
||||
_fetchListing(country).whenComplete(() {
|
||||
_listingsInFlight.remove(country);
|
||||
});
|
||||
}
|
||||
|
||||
Future<_Listing?> _fetchListing(String country) async {
|
||||
final url = 'https://api.github.com/repos/tv-logo/tv-logos/contents/'
|
||||
'countries/$country';
|
||||
try {
|
||||
final response = await _client.get(
|
||||
Uri.parse(url),
|
||||
headers: {'Accept': 'application/vnd.github+json'},
|
||||
).timeout(const Duration(seconds: 20));
|
||||
if (response.statusCode != 200) {
|
||||
throw StateError('HTTP ${response.statusCode}');
|
||||
}
|
||||
|
||||
final urls = <String, String>{};
|
||||
final decoded = json.decode(response.body);
|
||||
if (decoded is List) {
|
||||
for (final entry in decoded) {
|
||||
if (entry is! Map) continue;
|
||||
final name = entry['name']?.toString() ?? '';
|
||||
final download = entry['download_url']?.toString();
|
||||
if (download == null || !name.endsWith('.png')) continue;
|
||||
urls[_keyFromFileName(name)] = download;
|
||||
}
|
||||
}
|
||||
|
||||
final listing = _Listing(urls, DateTime.now());
|
||||
_listings[country] = listing;
|
||||
_listingFailedAt.remove(country);
|
||||
print('[LogoCatalog] $country : ${urls.length} logos');
|
||||
return listing;
|
||||
} catch (e) {
|
||||
_listingFailedAt[country] = DateTime.now();
|
||||
print('[LogoCatalog] liste $country indisponible : $e');
|
||||
return _listings[country];
|
||||
}
|
||||
}
|
||||
|
||||
/// `rmc-sport-2-fr.png` → `rmc-sport-2`.
|
||||
static String _keyFromFileName(String name) {
|
||||
final base = name.substring(0, name.length - '.png'.length);
|
||||
return base.replaceFirst(RegExp(r'-[a-z]{2}$'), '');
|
||||
}
|
||||
}
|
||||
|
||||
class _Listing {
|
||||
_Listing(this.urls, this.fetchedAt);
|
||||
|
||||
/// Clé normalisée → URL de téléchargement brute.
|
||||
final Map<String, String> urls;
|
||||
final DateTime fetchedAt;
|
||||
}
|
||||
|
||||
/// Code pays du préfixe panneau → dossier du dépôt `tv-logos`.
|
||||
const _countryFolders = {
|
||||
'fr': 'france',
|
||||
'be': 'belgium',
|
||||
'ch': 'switzerland',
|
||||
'uk': 'united-kingdom',
|
||||
'gb': 'united-kingdom',
|
||||
'us': 'united-states',
|
||||
'ca': 'canada',
|
||||
'de': 'germany',
|
||||
'es': 'spain',
|
||||
'it': 'italy',
|
||||
'pt': 'portugal',
|
||||
'nl': 'netherlands',
|
||||
};
|
||||
|
||||
/// Préfixe pays des noms panneau : `FR - `, `FR: `, `|FR| `.
|
||||
final _countryPrefix = RegExp(r'^\W*([A-Za-z]{2})[\s\-:|]+');
|
||||
|
||||
/// Dossier `tv-logos` à consulter pour [channelName]. France par défaut :
|
||||
/// c'est l'audience de l'application.
|
||||
String countryFolderFor(String channelName) {
|
||||
final match = _countryPrefix.firstMatch(channelName);
|
||||
final code = match?.group(1)?.toLowerCase();
|
||||
return _countryFolders[code] ?? 'france';
|
||||
}
|
||||
|
||||
/// Mots sans rapport avec l'identité de la chaîne : qualité, codec, variante
|
||||
/// de flux. Ils sont propres au panneau et absents des noms de logos.
|
||||
const _noise = {
|
||||
'fhd', 'uhd', 'hd', 'sd', '4k', '8k', 'hdr', 'hevc', 'h264', 'h265',
|
||||
'1080p', '720p', '50fps', 'fps', 'raw', 'backup', 'vip', 'multi',
|
||||
'vf', 'vo', 'vostfr',
|
||||
};
|
||||
|
||||
const _accents = {
|
||||
'à': 'a', 'á': 'a', 'â': 'a', 'ä': 'a', 'è': 'e', 'é': 'e', 'ê': 'e',
|
||||
'ë': 'e', 'ì': 'i', 'í': 'i', 'î': 'i', 'ï': 'i', 'ò': 'o', 'ó': 'o',
|
||||
'ô': 'o', 'ö': 'o', 'ù': 'u', 'ú': 'u', 'û': 'u', 'ü': 'u', 'ç': 'c',
|
||||
};
|
||||
|
||||
List<String> _tokens(String channelName) {
|
||||
var name = channelName;
|
||||
final prefix = _countryPrefix.firstMatch(name);
|
||||
if (prefix != null &&
|
||||
_countryFolders.containsKey(prefix.group(1)!.toLowerCase())) {
|
||||
name = name.substring(prefix.end);
|
||||
}
|
||||
|
||||
final folded = StringBuffer();
|
||||
for (final rune in name.toLowerCase().runes) {
|
||||
final char = String.fromCharCode(rune);
|
||||
folded.write(_accents[char] ?? char);
|
||||
}
|
||||
|
||||
return folded
|
||||
.toString()
|
||||
.replaceAll('+', ' plus ')
|
||||
.replaceAll('&', ' and ')
|
||||
.split(RegExp(r'[^a-z0-9]+'))
|
||||
.where((t) => t.isNotEmpty && !_noise.contains(t))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Clé de logo la plus proche de [channelName] parmi [available], ou `null`.
|
||||
///
|
||||
/// Par ordre de confiance : nom exact, nom sans « live », variante plus
|
||||
/// longue du même identifiant (`bein-sports-1` → `bein-sports-1-french`),
|
||||
/// puis la marque sans numéro de canal (`rmc-sport-live-5` → `rmc-sport-1`).
|
||||
/// Aucune approximation sur un mot seul : « France » ne doit pas devenir
|
||||
/// France 24.
|
||||
String? matchLogoKey(String channelName, Iterable<String> available) {
|
||||
final keys = available.toSet();
|
||||
final tokens = _tokens(channelName);
|
||||
if (tokens.isEmpty) return null;
|
||||
|
||||
List<String> withoutLive(List<String> parts) =>
|
||||
parts.where((t) => t != 'live').toList();
|
||||
|
||||
String? exact(List<String> parts) {
|
||||
final slug = parts.join('-');
|
||||
return parts.isNotEmpty && keys.contains(slug) ? slug : null;
|
||||
}
|
||||
|
||||
String? longerVariant(List<String> parts) {
|
||||
if (parts.length < 2) return null;
|
||||
final slug = parts.join('-');
|
||||
final longer = keys.where((k) => k.startsWith('$slug-')).toList()
|
||||
..sort((a, b) {
|
||||
final byLength = a.length.compareTo(b.length);
|
||||
return byLength != 0 ? byLength : a.compareTo(b);
|
||||
});
|
||||
return longer.isEmpty ? null : longer.first;
|
||||
}
|
||||
|
||||
final noLive = withoutLive(tokens);
|
||||
final direct = exact(tokens) ??
|
||||
exact(noLive) ??
|
||||
longerVariant(tokens) ??
|
||||
longerVariant(noLive);
|
||||
if (direct != null) return direct;
|
||||
|
||||
// Marque sans numéro de canal, seulement s'il reste au moins deux mots.
|
||||
if (RegExp(r'^\d+$').hasMatch(tokens.last)) {
|
||||
final brand = withoutLive(tokens.sublist(0, tokens.length - 1));
|
||||
if (brand.length >= 2) return exact(brand) ?? longerVariant(brand);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
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/logo_api.dart';
|
||||
import '../services/logo_catalog.dart';
|
||||
|
||||
/// Catalogue qui connaît uniquement `rmc-sport-2` et sert [bytes].
|
||||
LogoCatalog _catalog(List<int> bytes, {void Function()? onUse}) {
|
||||
return LogoCatalog(
|
||||
client: MockClient((request) async {
|
||||
onUse?.call();
|
||||
if (request.url.host == 'api.github.com') {
|
||||
return http.Response(
|
||||
jsonEncode([
|
||||
{
|
||||
'name': 'rmc-sport-2-fr.png',
|
||||
'download_url': 'https://raw.example/rmc-sport-2-fr.png',
|
||||
},
|
||||
]),
|
||||
200,
|
||||
);
|
||||
}
|
||||
return http.Response.bytes(bytes, 200);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Request _logoRequest(String src, String name) => Request(
|
||||
'GET',
|
||||
Uri.http('localhost:8089', '/api/logo', {'src': src, 'name': name}),
|
||||
headers: {'cookie': 'session=abc'},
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('LogoApi', () {
|
||||
test('sert le logo du panneau quand il répond', () async {
|
||||
Request? forwarded;
|
||||
var catalogUsed = false;
|
||||
final api = LogoApi(
|
||||
(request) {
|
||||
forwarded = request;
|
||||
return Response.ok(
|
||||
[9, 9],
|
||||
headers: {'content-type': 'image/png'},
|
||||
);
|
||||
},
|
||||
_catalog([1], onUse: () => catalogUsed = true),
|
||||
);
|
||||
|
||||
final response = await api.handle(
|
||||
_logoRequest('http://picons.example/logos/1.png', 'FR - RMC SPORT 2'),
|
||||
);
|
||||
|
||||
expect(response.statusCode, 200);
|
||||
expect(await response.read().expand((b) => b).toList(), [9, 9]);
|
||||
expect(response.headers['cache-control'], contains('max-age=86400'));
|
||||
expect(catalogUsed, isFalse);
|
||||
// Le proxy reçoit l'URL au format attendu et la session du client.
|
||||
expect(forwarded!.url.path, 'api/xtream/http://picons.example/logos/1.png');
|
||||
expect(forwarded!.headers['cookie'], 'session=abc');
|
||||
});
|
||||
|
||||
test('retombe sur le catalogue quand le panneau échoue', () async {
|
||||
final api = LogoApi(
|
||||
(_) => Response.notFound(null),
|
||||
_catalog([4, 2]),
|
||||
);
|
||||
|
||||
final response = await api.handle(
|
||||
_logoRequest('http://picons.example/logos/1.png', 'FR - RMC SPORT 2'),
|
||||
);
|
||||
|
||||
expect(response.statusCode, 200);
|
||||
expect(response.headers['content-type'], 'image/png');
|
||||
expect(await response.read().expand((b) => b).toList(), [4, 2]);
|
||||
});
|
||||
|
||||
test('ignore une réponse panneau qui n’est pas une image', () async {
|
||||
// Page HTML de maintenance servie en 200 : pas un logo.
|
||||
final api = LogoApi(
|
||||
(_) => Response.ok('<html>', headers: {'content-type': 'text/html'}),
|
||||
_catalog([4, 2]),
|
||||
);
|
||||
|
||||
final response = await api.handle(
|
||||
_logoRequest('http://picons.example/logos/1.png', 'FR - RMC SPORT 2'),
|
||||
);
|
||||
|
||||
expect(await response.read().expand((b) => b).toList(), [4, 2]);
|
||||
});
|
||||
|
||||
test('rend une erreur mise en cache quand rien ne correspond', () async {
|
||||
// Un statut d'erreur laisse le client afficher son icône de repli ; une
|
||||
// image vide donnait une tuile blanche. 410 et non 404 : la Cascade du
|
||||
// serveur rattrape les 404 et perdrait le cache-control.
|
||||
final api = LogoApi(
|
||||
(_) => Response.notFound(null),
|
||||
_catalog([1]),
|
||||
);
|
||||
|
||||
final response = await api.handle(
|
||||
_logoRequest('http://picons.example/logos/1.png', 'FR - INCONNUE'),
|
||||
);
|
||||
|
||||
expect(response.statusCode, 410);
|
||||
expect(response.headers['cache-control'], contains('max-age=600'));
|
||||
});
|
||||
|
||||
test('cherche par nom quand le panneau ne fournit pas de logo', () async {
|
||||
var proxyCalled = false;
|
||||
final api = LogoApi(
|
||||
(_) {
|
||||
proxyCalled = true;
|
||||
return Response.notFound(null);
|
||||
},
|
||||
_catalog([7]),
|
||||
);
|
||||
|
||||
final response = await api.handle(_logoRequest('', 'FR - RMC SPORT 2'));
|
||||
|
||||
expect(response.statusCode, 200);
|
||||
expect(proxyCalled, isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:test/test.dart';
|
||||
import '../services/logo_catalog.dart';
|
||||
|
||||
/// Extrait réel de `tv-logo/tv-logos/countries/france` (noms sans `-fr.png`).
|
||||
const _france = {
|
||||
'bein-sports-1-french',
|
||||
'bein-sports-2-french',
|
||||
'bein-sports',
|
||||
'canal-plus',
|
||||
'canal-plus-sport',
|
||||
'canal-plus-sport-360',
|
||||
'eurosport-1',
|
||||
'france-2',
|
||||
'france-24',
|
||||
'lequipe',
|
||||
'rmc-sport-1',
|
||||
'rmc-sport-2',
|
||||
'rmc-sport-access-1',
|
||||
};
|
||||
|
||||
void main() {
|
||||
group('matchLogoKey', () {
|
||||
String? match(String name) => matchLogoKey(name, _france);
|
||||
|
||||
test('correspondance exacte après nettoyage du nom panneau', () {
|
||||
// Préfixe pays et suffixe de qualité sont du bruit propre au panneau.
|
||||
expect(match('FR - RMC SPORT 2 FHD'), 'rmc-sport-2');
|
||||
expect(match('FR: France 2 HD'), 'france-2');
|
||||
expect(match('|FR| EUROSPORT 1 UHD'), 'eurosport-1');
|
||||
});
|
||||
|
||||
test('traduit le « + » de Canal+', () {
|
||||
expect(match('FR - CANAL+ SPORT FHD'), 'canal-plus-sport');
|
||||
expect(match('FR - CANAL+'), 'canal-plus');
|
||||
});
|
||||
|
||||
test('accepte une variante plus longue du même identifiant', () {
|
||||
// Le dépôt nomme `bein-sports-1-french`, le panneau `BEIN SPORTS 1`.
|
||||
expect(match('FR - BEIN SPORTS 1 FHD'), 'bein-sports-1-french');
|
||||
});
|
||||
|
||||
test('retombe sur la marque quand le numéro de canal est inconnu', () {
|
||||
// Les canaux événementiels (« Live 5 ») n'ont pas de logo propre :
|
||||
// celui de la marque vaut mieux qu'une tuile vide.
|
||||
expect(match('FR - RMC SPORT LIVE 5 FHD'), 'rmc-sport-1');
|
||||
expect(match('FR - CANAL+ SPORT 2'), 'canal-plus-sport');
|
||||
});
|
||||
|
||||
test('ne devine pas sur un seul mot', () {
|
||||
// « France » seul ne doit pas donner le logo de France 24.
|
||||
expect(match('FR - FRANCE'), isNull);
|
||||
});
|
||||
|
||||
test('rend null quand rien ne correspond', () {
|
||||
expect(match('FR - DAZN FHD'), isNull);
|
||||
expect(match(''), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('countryFolderFor', () {
|
||||
test('lit le préfixe pays du nom', () {
|
||||
expect(countryFolderFor('FR - TF1'), 'france');
|
||||
expect(countryFolderFor('BE: RTL TVI'), 'belgium');
|
||||
expect(countryFolderFor('|UK| BBC ONE'), 'united-kingdom');
|
||||
});
|
||||
|
||||
test('France par défaut sans préfixe reconnu', () {
|
||||
expect(countryFolderFor('TF1 HD'), 'france');
|
||||
});
|
||||
});
|
||||
|
||||
group('LogoCatalog', () {
|
||||
test('télécharge le logo apparié et le garde en cache', () async {
|
||||
final requested = <String>[];
|
||||
final client = MockClient((request) async {
|
||||
requested.add(request.url.toString());
|
||||
if (request.url.host == 'api.github.com') {
|
||||
return http.Response(
|
||||
jsonEncode([
|
||||
{
|
||||
'name': 'rmc-sport-2-fr.png',
|
||||
'download_url': 'https://raw.example/rmc-sport-2-fr.png',
|
||||
},
|
||||
{'name': 'README.md', 'download_url': 'https://raw.example/r'},
|
||||
]),
|
||||
200,
|
||||
);
|
||||
}
|
||||
return http.Response.bytes([1, 2, 3], 200);
|
||||
});
|
||||
|
||||
final catalog = LogoCatalog(client: client);
|
||||
final first = await catalog.logoFor('FR - RMC SPORT 2 FHD');
|
||||
final second = await catalog.logoFor('FR - RMC SPORT 2 FHD');
|
||||
|
||||
expect(first, [1, 2, 3]);
|
||||
expect(second, [1, 2, 3]);
|
||||
// Une liste et un logo, pas un aller-retour par affichage.
|
||||
expect(requested, hasLength(2));
|
||||
});
|
||||
|
||||
test('rend null sans correspondance, sans télécharger d’image', () async {
|
||||
var downloads = 0;
|
||||
final client = MockClient((request) async {
|
||||
if (request.url.host == 'api.github.com') {
|
||||
return http.Response(jsonEncode(const []), 200);
|
||||
}
|
||||
downloads++;
|
||||
return http.Response.bytes([1], 200);
|
||||
});
|
||||
|
||||
final catalog = LogoCatalog(client: client);
|
||||
expect(await catalog.logoFor('FR - CHAINE INCONNUE'), isNull);
|
||||
expect(downloads, 0);
|
||||
});
|
||||
|
||||
test('espace les tentatives quand la liste est injoignable', () async {
|
||||
var listings = 0;
|
||||
final client = MockClient((request) async {
|
||||
listings++;
|
||||
return http.Response('rate limited', 403);
|
||||
});
|
||||
|
||||
final catalog = LogoCatalog(client: client);
|
||||
await catalog.logoFor('FR - RMC SPORT 2');
|
||||
await catalog.logoFor('FR - RMC SPORT 1');
|
||||
|
||||
expect(listings, 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user