mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-17 20:29:30 +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(
|
||||
|
||||
Reference in new issue
Block a user