Merge pull request #16 from R0m1k3/fix/logos-repli

Logos des chaînes : repli par nom quand l'hébergeur de picons tombe
This commit is contained in:
LogiFlow authored and GitHub committed 2026-09-19 13:42:50 +02:00
commit 6d40c38d30
13 files changed
+722 -87

No files matched your search

+3 -1
View File
@@ -3,7 +3,9 @@
## Non publié
### 🖼️ Logos des chaînes
- **Plus d'acharnement sur un hébergeur de picons hors service** : quand la machine qui héberge les logos répond en erreur (maintenance, quota), le proxy sert désormais un pixel transparent mis en cache dix minutes au lieu de relayer le 503 au navigateur — qui redemandait chaque logo à chaque affichage de la grille. Au bout de trois échecs consécutifs, l'hôte est considéré hors service pendant cinq minutes et plus aucune requête ne sort. Le serveur tournant sur un seul isolate, ce flot de centaines de requêtes mortes passait devant les paquets vidéo et faisait saccader la lecture
- **Logos de repli quand l'hébergeur de picons tombe** : nouvelle route `/api/logo?src=…&name=…`. L'URL du panneau est tentée d'abord (via le proxy, mêmes protections anti-SSRF) ; en cas d'échec, le logo est cherché par nom de chaîne dans le dépôt public [`tv-logo/tv-logos`](https://github.com/tv-logo/tv-logos) — préfixe pays et suffixes de qualité ignorés, `Canal+` → `canal-plus`, repli sur la marque pour les canaux événementiels (`RMC SPORT LIVE 5` → logo RMC Sport). La liste des logos est mise en cache 24 h (l'API GitHub non authentifiée plafonne à 60 requêtes/h), les images en mémoire. Les chaînes sans `stream_icon` y gagnent aussi un logo
- **Plus d'acharnement sur un hébergeur de picons hors service** : une image en erreur n'est plus relayée telle quelle au navigateur, qui la redemandait à chaque affichage de la grille. Réponse 410 mise en cache dix minutes (410 et non 404 : la `Cascade` du serveur rattrape les 404 et perdait l'en-tête de cache) ; au bout de trois échecs consécutifs, l'hôte est considéré hors service cinq minutes et plus aucune requête ne sort. Un statut d'erreur plutôt qu'un pixel transparent : le client affiche son propre repli au lieu d'une tuile vide
- **Logos de l'onglet Enregistrements** : l'URL `http://` du panneau y était chargée directement, bloquée par le navigateur sur une page servie en HTTPS
### 📺 Guide TV (EPG)
- **Guide servi par le dump `xmltv.php` du panneau** : `player_api` réclamait un appel par chaîne, à plusieurs secondes pièce chez la plupart des revendeurs — une grille de trente chaînes mettait plus d'une minute à se remplir. Le dump du panneau couvre toutes les chaînes en une requête, réutilisée trois heures. L'interrogation chaîne par chaîne reste le repli quand le dump est absent ou ne couvre pas la chaîne, suivie de la source XMLTV externe
+20
View File
@@ -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
+72
View File
@@ -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();
}
}
+6 -26
View File
@@ -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(
+13
View File
@@ -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',
+239
View File
@@ -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;
}
+128
View File
@@ -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);
});
});
}
+135
View File
@@ -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);
});
});
}
+20
View File
@@ -0,0 +1,20 @@
/// URL du logo d'une chaîne, servie par le backend (`/api/logo`).
///
/// Le backend tente d'abord [streamIcon] (l'URL fournie par le panneau), puis
/// cherche un logo par [name] quand l'hébergeur de picons du revendeur ne
/// répond pas. Le nom est donc transmis même sans `stream_icon` : c'est
/// souvent le seul moyen d'obtenir un logo.
///
/// Une chaîne sans logo trouvé reçoit un statut d'erreur : l'`errorBuilder`
/// de l'`Image.network` appelante affiche alors son propre repli.
String channelLogoUrl({required String streamIcon, required String name}) {
final icon = streamIcon.trim();
return Uri(
path: '/api/logo',
queryParameters: {
if (icon.startsWith('http://') || icon.startsWith('https://'))
'src': icon,
'name': name,
},
).toString();
}
+21 -27
View File
@@ -5,6 +5,7 @@ import '../providers/xtream_provider.dart';
import '../providers/settings_provider.dart';
import '../providers/favorites_provider.dart';
import '../screens/player_screen.dart';
import '../../../core/api/channel_logo.dart';
import '../../../core/models/iptv_models.dart';
import '../../../core/models/playlist_config.dart';
import '../../../core/theme/app_colors.dart';
@@ -412,25 +413,26 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
),
padding: const EdgeInsets.all(16),
child: Center(
child: channel.streamIcon.isNotEmpty
? Image.network(
_getProxiedIconUrl(channel.streamIcon)!,
cacheWidth: 160,
filterQuality: FilterQuality.low,
errorBuilder: (_, __, ___) => Icon(
Icons.tv,
color: AppColors.onSurface.withOpacity(0.3),
size: 40,
),
)
: Text(
channel.name.characters.first.toUpperCase(),
style: GoogleFonts.fraunces(
fontSize: 40,
fontWeight: FontWeight.bold,
color: AppColors.onSurface24,
),
),
// Toujours demandé au backend, même sans
// stream_icon : il cherche alors un logo par nom.
child: Image.network(
channelLogoUrl(
streamIcon: channel.streamIcon,
name: channel.name,
),
cacheWidth: 160,
filterQuality: FilterQuality.low,
errorBuilder: (_, __, ___) => Text(
channel.name.isEmpty
? '?'
: channel.name.characters.first.toUpperCase(),
style: GoogleFonts.fraunces(
fontSize: 40,
fontWeight: FontWeight.bold,
color: AppColors.onSurface24,
),
),
),
),
),
),
@@ -599,14 +601,6 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
);
}
String? _getProxiedIconUrl(String originalUrl) {
if (originalUrl.isEmpty) return null;
if (originalUrl.startsWith('http://')) {
return '/api/xtream/$originalUrl';
}
return originalUrl;
}
void _playChannel(Channel channel, List<Channel> contextList) {
Navigator.of(context).push(
MaterialPageRoute(
+20 -19
View File
@@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../../core/api/authed_http.dart';
import '../../../core/api/recording_requests.dart';
import '../../../core/api/channel_logo.dart';
import '../../../core/models/iptv_models.dart';
import '../../../core/models/playlist_config.dart';
import '../../../core/theme/app_colors.dart';
@@ -368,25 +369,25 @@ class _EpgGuideViewState extends ConsumerState<_EpgGuideView>
),
child: Row(
children: [
ch.streamIcon.isNotEmpty
? Image.network(
ch.streamIcon,
width: 24,
height: 16,
cacheWidth: 64,
fit: BoxFit.contain,
errorBuilder: (_, __, ___) =>
const Icon(
Icons.tv,
color: AppColors.onSurface24,
size: 16,
),
)
: const Icon(
Icons.tv,
color: AppColors.onSurface24,
size: 16,
),
// URL panneau brute auparavant : en
// http depuis une page https, le
// navigateur la bloquait.
Image.network(
channelLogoUrl(
streamIcon: ch.streamIcon,
name: ch.name,
),
width: 24,
height: 16,
cacheWidth: 64,
fit: BoxFit.contain,
errorBuilder: (_, __, ___) =>
const Icon(
Icons.tv,
color: AppColors.onSurface24,
size: 16,
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
@@ -5,6 +5,7 @@ import '../../../../features/iptv/providers/xtream_provider.dart';
import '../../../../features/iptv/providers/settings_provider.dart';
import '../../../../features/iptv/providers/favorites_provider.dart';
import '../screens/mobile_player_screen.dart';
import '../../../../core/api/channel_logo.dart';
import '../../../../core/models/iptv_models.dart';
import '../../../../core/models/playlist_config.dart';
import '../../../../core/theme/app_colors.dart';
@@ -349,10 +350,10 @@ class _MobileChannelTile extends ConsumerWidget {
// Sans ce bouton, aucun moyen d'ajouter un favori : le filtre « Favoris »
// de l'en-tête affichait toujours une liste vide.
final isFav = ref.watch(favoritesProvider).contains(channel.streamId);
final iconUrl =
channel.streamIcon.isNotEmpty && channel.streamIcon.startsWith('http')
? '/api/xtream/${channel.streamIcon}'
: null;
final iconUrl = channelLogoUrl(
streamIcon: channel.streamIcon,
name: channel.name,
);
return GlassContainer.glass(
borderRadius: 12,
@@ -372,16 +373,14 @@ class _MobileChannelTile extends ConsumerWidget {
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.all(4),
child: iconUrl != null
? Image.network(
iconUrl,
fit: BoxFit.contain,
cacheWidth: 120,
filterQuality: FilterQuality.low,
errorBuilder: (_, __, ___) =>
const Icon(Icons.tv, color: AppColors.onSurface24),
)
: const Icon(Icons.tv, color: AppColors.onSurface24),
child: Image.network(
iconUrl,
fit: BoxFit.contain,
cacheWidth: 120,
filterQuality: FilterQuality.low,
errorBuilder: (_, __, ___) =>
const Icon(Icons.tv, color: AppColors.onSurface24),
),
),
const SizedBox(width: 16),
Expanded(
+32
View File
@@ -0,0 +1,32 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:xtremflow/core/api/channel_logo.dart';
void main() {
group('channelLogoUrl', () {
test('transmet l’URL panneau et le nom, encodés', () {
final url = Uri.parse(
channelLogoUrl(
streamIcon: 'http://picons.example/logos/1.png?x=1&y=2',
name: 'FR - CANAL+ SPORT',
),
);
expect(url.path, '/api/logo');
expect(
url.queryParameters['src'],
'http://picons.example/logos/1.png?x=1&y=2',
);
// Le « + » doit survivre à l'encodage : sinon Canal+ devient « Canal ».
expect(url.queryParameters['name'], 'FR - CANAL+ SPORT');
});
test('envoie le nom seul sans stream_icon exploitable', () {
// Sans URL panneau, le nom reste le seul moyen de trouver un logo.
for (final icon in ['', ' ', 'logo.png']) {
final url = Uri.parse(channelLogoUrl(streamIcon: icon, name: 'TF1'));
expect(url.queryParameters.containsKey('src'), isFalse);
expect(url.queryParameters['name'], 'TF1');
}
});
});
}