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:
MichaelandClaude Opus 5 committed 2026-09-19 13:39:01 +02:00
1 parent bde32adda9
commit 9843999809
13 files changed
+722 -87

No files matched your search

+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(