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

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