Compare commits

...
Author SHA1 Message Date
MichaelandClaude Opus 5.5 775caad0ee Annule « Zap : ouvrir les chaînes par le HLS du panneau »
Mesure après déploiement : premier octet de turbo.ts à 4,5–4,8 s avec la
source HLS, contre 4,3–4,7 s en .ts — aucun gain. Les logs confirment
que le HLS était bien utilisé (aucun repli). La sonde qui annonçait
0,5 s ouvrait le .ts AVANT le .m3u8 : la chaîne était déjà démarrée chez
le panneau. Les ~4,5 s sont le temps de démarrage d'une chaîne chez le
fournisseur, quel que soit le format.

Retour au .ts, déjà validé en lecture, plutôt que de garder un chemin
plus complexe (segments de 10 s, repli) sans bénéfice mesuré.

This reverts commit 0ea60cc.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:30:51 +02:00
MichaelandClaude Opus 5.5 4d00409de8 Guide TV : régionales France 3, variantes de nom, chaînes +1, CH et BE
Mesuré en prod après le premier correctif : 69 chaînes sur 149 avec
guide. Les vides restantes relevaient surtout de formes de nom que le
dump écrit autrement, ou de pays non couverts :

- « F3 ALPES » → France 3 Alpes (France.3.-.Alpes.fr) : F2 à F5 développés
- clé souple en dernier recours, dans son propre espace de noms :
  sans code pays ni « channel/tv/hd » (« AB3 » → AB3.Channel.fr)
- chaîne « +1 » absente du dump (TF1 +1) : guide de la chaîne principale
  décalé d'une heure ; une +1 qui a son propre guide le garde
- sources par défaut : FR1 puis CH1 (RTS…) et BE2 (même fournisseur),
  le dump français reste prioritaire

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:17:28 +02:00
MichaelandClaude Opus 5.5 0ea60ccbfa Zap : ouvrir les chaînes par le HLS du panneau, ~4 s gagnées
Mesuré en prod avec la sonde admin : le panneau met 4,2 à 4,4 s à
répondre la redirection d'un .ts, contre 0,2 à 1,3 s pour un .m3u8 dont
le premier segment arrive ensuite en 50 ms.

turbo.ts et les sessions HLS live lisent désormais le .m3u8 du panneau
(-live_start_index -2 : deux segments d'avance d'un coup), avec repli
automatique sur le .ts si le HLS ne livre rien — dans la même réponse
pour turbo.ts, le lecteur ne voit qu'un démarrage plus long. Pas de
reconnect_at_eof en HLS : chaque segment finit par un EOF (leçon de
xtremobile). Le navigateur reçoit toujours le même flux MPEG-TS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:08:08 +02:00
MichaelandClaude Opus 5.5 a3640a6b5f Diagnostic admin : chronométrer l'ouverture d'une chaîne chez le panneau
~4,3 s des ~4,6 s d'un zap se passent avant le premier octet du panneau.
Le compte autorise aussi le HLS (allowed_output_formats : m3u8, ts) ;
GET /api/admin/upstream-probe?stream=<id> mesure, pour .ts et .m3u8,
chaque redirection, les en-têtes, le premier octet et le premier segment,
pour décider du format à utiliser. Admin uniquement ; seuls l'hôte et les
durées sont rendus, jamais les identifiants ni les jetons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:56:34 +02:00
MichaelandClaude Opus 5.5 584068b45b Guide TV : décoder la table des chaînes en UTF-8
Sans charset annoncé, Response.body pouvait décoder le JSON du panneau en
latin-1 et abîmer les noms accentués ou décorés (« Chérie », « ◉ »), donc
leur correspondance avec le guide XMLTV. Le test simule désormais des
octets UTF-8 sans charset, comme le panneau réel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:54:46 +02:00
MichaelandClaude Opus 5.5 abe7d522c1 Guide TV : plus jamais de 500, et bien plus de chaînes couvertes
Constaté en prod : chaque /api/epg répondait 500 en 4 s. Un appel
player_api qui lève (connexion coupée, délai) n'était pas rattrapé, et le
dump XMLTV externe, qui couvre pourtant TF1, France 2, M6…, n'était jamais
consulté. Et 59 % des chaînes françaises n'ont pas d'epg_channel_id : leur
nom brut (« FR - TF1 FHD ◉ ») ne correspondait à aucune entrée XMLTV.

- échec player_api rattrapé : la chaîne passe à la source suivante
- dump externe (déjà en mémoire) avant l'appel lent chaîne par chaîne
- correspondance par nom nettoyé : préfixe pays, FHD/HD/UHD/4K/HEVC…,
  symboles retirés, forme « nom.pays » tentée ; « +1 » conservé
- tous les display-name XMLTV indexés, plus seulement le premier
- table des chaînes : un seul téléchargement partagé, décodé hors de la
  boucle principale, et un échec n'est plus gardé 6 h (2 min)
- guide vide gardé 5 min au lieu de 30

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:51:24 +02:00
10 changed files with 702 additions and 37 deletions

No files matched your search

+1 -1
View File
@@ -37,7 +37,7 @@ Au premier démarrage, un compte `admin` est créé avec un **mot de passe aléa
| `RECORDINGS_PATH` | `./data/recordings` | Dossier hôte des enregistrements |
| `TZ` | `Europe/Paris` | Fuseau du conteneur (les enregistrements sont stockés en UTC) |
| `MAX_CONCURRENT_RECORDINGS` | `2` | Enregistrements simultanés |
| `EPG_XMLTV_URLS` | dump FR | Sources XMLTV de repli (vide = aucun appel sortant) |
| `EPG_XMLTV_URLS` | dumps FR, CH, BE | Sources XMLTV de repli (vide = aucun appel sortant) |
| `NVIDIA_GPU` | `false` | Transcodage NVENC |
| `ADMIN_INITIAL_PASSWORD` | *(généré)* | Mot de passe initial du compte admin |
| `MIN_FREE_DISK_MB` | `500` | Espace libre minimal pour démarrer une capture |
+91 -24
View File
@@ -1,4 +1,5 @@
import 'dart:convert';
import 'dart:isolate';
import 'package:shelf/shelf.dart';
import 'package:http/http.dart' as http;
import '../models/playlist_config.dart';
@@ -97,10 +98,15 @@ class EpgApi {
// Le dump du panneau passe devant : une requête couvre toutes les
// chaînes, là où `player_api` en demande une par chaîne, à plusieurs
// secondes pièce.
//
// Le dump externe passe ensuite AVANT l'interrogation chaîne par
// chaîne : il est déjà indexé en mémoire (réponse instantanée), alors
// que `player_api` coûte plusieurs secondes par chaîne et échouait en
// prod — ce qui laissait toute la grille sans guide.
final sources = <(String, Future<List<Map<String, dynamic>>> Function())>[
('panel-xmltv', () => _panelProgrammes(playlist, channelId)),
('xtream', () => _xtreamProgrammes(playlist, channelId)),
('xmltv', () => _xmltvProgrammes(playlist, channelId)),
('xtream', () => _xtreamProgrammes(playlist, channelId)),
];
var epgData = <String, dynamic>{
@@ -133,10 +139,13 @@ class EpgApi {
final jsonStr = json.encode(epgData);
// Mettre en cache 30 minutes
// 30 min pour un guide trouvé ; 5 min seulement pour un guide vide,
// qui vient souvent d'une source momentanément indisponible et ne
// doit pas priver la chaîne de guide pendant une demi-heure.
_cache[cacheKey] = _CacheEntry(
data: jsonStr,
expiresAt: DateTime.now().add(const Duration(minutes: 30)),
expiresAt: DateTime.now()
.add(Duration(minutes: found ? 30 : 5)),
);
return Response.ok(
@@ -191,8 +200,19 @@ class EpgApi {
'&password=${playlist.password}'
'&action=$action&stream_id=$channelId';
final response =
await _http.get(Uri.parse(url)).timeout(const Duration(seconds: 60));
final http.Response response;
try {
response = await _http
.get(Uri.parse(url))
.timeout(const Duration(seconds: 20));
} catch (e) {
// Délai dépassé, connexion coupée… : une source parmi d'autres, pas
// une raison de répondre 500 (ce qui privait la chaîne des sources
// suivantes). L'exception recopie l'URL, identifiants compris.
print('[EpgApi] player_api $action indisponible pour $channelId : '
'${LogRedactor.redactUrl('$e')}');
continue;
}
if (response.statusCode != 200) continue;
Map<String, dynamic> parsed;
@@ -267,41 +287,88 @@ class EpgApi {
}
}
/// `stream_id` → (`epg_channel_id`, nom) depuis la réponse
/// `get_live_streams`.
static (Map<String, String>, Map<String, String>) _parseChannelTable(
String body,
) {
final epgIds = <String, String>{};
final names = <String, String>{};
final decoded = json.decode(body);
if (decoded is List) {
for (final item in decoded) {
if (item is! Map) continue;
final streamId = item['stream_id']?.toString();
if (streamId == null || streamId.isEmpty) continue;
final epgId = item['epg_channel_id']?.toString();
if (epgId != null && epgId.isNotEmpty) epgIds[streamId] = epgId;
final name = item['name']?.toString();
if (name != null && name.isNotEmpty) names[streamId] = name;
}
}
return (epgIds, names);
}
/// Téléchargements de la table des chaînes en cours, par compte.
final Map<String, Future<_ChannelMap>> _channelMapsInFlight = {};
/// Table des chaînes du compte, rafraîchie toutes les 6 heures.
Future<_ChannelMap> _channelMapFor(PlaylistConfig playlist) async {
///
/// Les requêtes simultanées partagent un seul téléchargement : la grille
/// demande le guide de 30 chaînes d'un coup, et chacune déclenchait sinon
/// son propre `get_live_streams` (7 Mo en prod).
Future<_ChannelMap> _channelMapFor(PlaylistConfig playlist) {
final key = '${playlist.dns}|${playlist.username}';
final cached = _channelMaps[key];
if (cached != null && DateTime.now().isBefore(cached.expiresAt)) {
return cached;
return Future.value(cached);
}
return _channelMapsInFlight[key] ??=
_loadChannelMap(playlist, key).whenComplete(() {
_channelMapsInFlight.remove(key);
});
}
Future<_ChannelMap> _loadChannelMap(
PlaylistConfig playlist,
String key,
) async {
final url = '${playlist.dns}/player_api.php'
'?username=${playlist.username}&password=${playlist.password}'
'&action=get_live_streams';
final response =
await _http.get(Uri.parse(url)).timeout(const Duration(seconds: 90));
http.Response? response;
try {
response =
await _http.get(Uri.parse(url)).timeout(const Duration(seconds: 90));
} catch (e) {
print('[EpgApi] table des chaînes indisponible : '
'${LogRedactor.redactUrl('$e')}');
}
final epgIds = <String, String>{};
final names = <String, String>{};
if (response.statusCode == 200) {
final decoded = json.decode(response.body);
if (decoded is List) {
for (final item in decoded) {
if (item is! Map) continue;
final streamId = item['stream_id']?.toString();
if (streamId == null || streamId.isEmpty) continue;
final epgId = item['epg_channel_id']?.toString();
if (epgId != null && epgId.isNotEmpty) epgIds[streamId] = epgId;
final name = item['name']?.toString();
if (name != null && name.isNotEmpty) names[streamId] = name;
}
var epgIds = <String, String>{};
var names = <String, String>{};
if (response != null && response.statusCode == 200) {
// Décodage dans un isolate : 7 Mo de JSON sur la boucle principale la
// gelaient, et le relais turbo.ts cessait d'émettre pendant ce temps.
// UTF-8 explicite : sans charset annoncé, `response.body` peut décoder
// en latin-1 et abîmer les noms (« Chérie », « ◉ »), donc leur
// correspondance avec le guide.
final body = utf8.decode(response.bodyBytes, allowMalformed: true);
try {
(epgIds, names) = await Isolate.run(() => _parseChannelTable(body));
} catch (e) {
print('[EpgApi] table des chaînes illisible : ${e.runtimeType}');
}
}
// Une table vide (panneau en échec) n'est gardée que 2 minutes : la
// garder 6 h privait TOUTES les chaînes du repli XMLTV pendant 6 h.
final map = _ChannelMap(
epgIds: epgIds,
names: names,
expiresAt: DateTime.now().add(const Duration(hours: 6)),
expiresAt: DateTime.now().add(
names.isEmpty ? const Duration(minutes: 2) : const Duration(hours: 6),
),
);
_channelMaps[key] = map;
return map;
+133
View File
@@ -0,0 +1,133 @@
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../models/playlist_config.dart';
/// Diagnostic admin : chronomètre l'ouverture d'une chaîne chez le
/// fournisseur, en `.ts` et en `.m3u8`.
///
/// POURQUOI : ~4,3 s des ~4,6 s d'un zap se passent avant le premier octet
/// du panneau (mesuré en prod). Le panneau autorise aussi le HLS ; reste à
/// savoir lequel des deux formats démarre le plus vite, et où part le temps
/// (redirection, en-têtes, premier octet). Les identifiants n'apparaissent
/// jamais dans la réponse : seuls l'hôte et les durées sont rendus.
Future<Map<String, Object?>> probeUpstream(
PlaylistConfig playlist,
String streamId, {
http.Client? client,
}) async {
final c = client ?? http.Client();
try {
final base = '${playlist.dns}/live/${playlist.username}/'
'${playlist.password}/$streamId';
final ts = await _probeStream(c, Uri.parse('$base.ts'));
// Laisser le panneau libérer la connexion (comptes à une connexion).
await Future<void>.delayed(const Duration(seconds: 2));
final hls = await _probeHls(c, Uri.parse('$base.m3u8'));
return {'stream': streamId, 'ts': ts, 'm3u8': hls};
} finally {
if (client == null) c.close();
}
}
/// Suit les redirections à la main pour chronométrer chaque saut.
Future<({http.StreamedResponse? response, List<Map<String, Object?>> hops, Uri url})>
_open(http.Client c, Uri url, Stopwatch sw) async {
final hops = <Map<String, Object?>>[];
var current = url;
for (var i = 0; i < 5; i++) {
final request = http.Request('GET', current)
..followRedirects = false
..headers['User-Agent'] = 'VLC/3.0.18 LibVLC/3.0.18';
final response =
await c.send(request).timeout(const Duration(seconds: 20));
hops.add({
'host': current.host,
'status': response.statusCode,
'headersMs': sw.elapsedMilliseconds,
});
final location = response.headers['location'];
if (const {301, 302, 303, 307, 308}.contains(response.statusCode) &&
location != null) {
await response.stream.drain<void>().catchError((_) {});
current = current.resolve(location);
continue;
}
return (response: response, hops: hops, url: current);
}
return (response: null, hops: hops, url: current);
}
Future<Map<String, Object?>> _probeStream(http.Client c, Uri url) async {
final sw = Stopwatch()..start();
try {
final opened = await _open(c, url, sw);
final response = opened.response;
if (response == null || response.statusCode != 200) {
return {'hops': opened.hops, 'error': 'status ${response?.statusCode}'};
}
var bytes = 0;
int? firstByteMs;
final sub = response.stream.listen(null);
final done = Completer<void>();
sub.onData((chunk) {
firstByteMs ??= sw.elapsedMilliseconds;
bytes += chunk.length;
// 1 Mo suffit à voir le premier envoi (préchargement du panneau).
if (bytes > 1000000 && !done.isCompleted) done.complete();
});
sub.onDone(() {
if (!done.isCompleted) done.complete();
});
await done.future.timeout(const Duration(seconds: 15), onTimeout: () {});
await sub.cancel();
return {
'hops': opened.hops,
'firstByteMs': firstByteMs,
'firstMegabyteMs': bytes > 1000000 ? sw.elapsedMilliseconds : null,
};
} catch (e) {
return {'error': e.runtimeType.toString(), 'afterMs': sw.elapsedMilliseconds};
}
}
Future<Map<String, Object?>> _probeHls(http.Client c, Uri url) async {
final sw = Stopwatch()..start();
try {
final opened = await _open(c, url, sw);
final response = opened.response;
if (response == null || response.statusCode != 200) {
return {'hops': opened.hops, 'error': 'status ${response?.statusCode}'};
}
final body = await response.stream.bytesToString();
final playlistMs = sw.elapsedMilliseconds;
final lines = const LineSplitter().convert(body);
final segments =
lines.where((l) => l.isNotEmpty && !l.startsWith('#')).toList();
final target = lines
.firstWhere((l) => l.startsWith('#EXT-X-TARGETDURATION'),
orElse: () => '')
.split(':')
.last;
if (segments.isEmpty) {
return {'hops': opened.hops, 'playlistMs': playlistMs, 'segments': 0};
}
// Un lecteur démarre près du direct : chronométrer l'avant-dernier.
final pick = segments.length >= 2
? segments[segments.length - 2]
: segments.last;
final segment = await _probeStream(c, opened.url.resolve(pick));
return {
'hops': opened.hops,
'playlistMs': playlistMs,
'segments': segments.length,
'targetDuration': target,
'segment': segment,
'segmentStartedAtMs': playlistMs,
};
} catch (e) {
return {'error': e.runtimeType.toString(), 'afterMs': sw.elapsedMilliseconds};
}
}
+27 -1
View File
@@ -28,6 +28,7 @@ import 'middleware/security_middleware.dart';
import 'services/cleanup_service.dart';
import 'services/recording_scheduler.dart';
import 'utils/asset_versioning.dart';
import 'api/upstream_probe.dart';
void main(List<String> args) async {
// Parse command line arguments
@@ -131,7 +132,9 @@ void main(List<String> args) async {
// Source XMLTV de repli. Vider EPG_XMLTV_URLS désactive tout appel sortant :
// l'EPG se limite alors au panneau de l'abonné.
final xmltvUrls = (Platform.environment['EPG_XMLTV_URLS'] ??
'https://epgshare01.online/epgshare01/epg_ripper_FR1.xml.gz')
'https://epgshare01.online/epgshare01/epg_ripper_FR1.xml.gz,'
'https://epgshare01.online/epgshare01/epg_ripper_CH1.xml.gz,'
'https://epgshare01.online/epgshare01/epg_ripper_BE2.xml.gz')
.split(',')
.map((u) => u.trim())
.where((u) => u.isNotEmpty)
@@ -269,6 +272,29 @@ void main(List<String> args) async {
);
});
// GET /api/admin/upstream-probe?stream=<id> : chronomètre l'ouverture
// d'une chaîne chez le fournisseur en .ts et en .m3u8 (diagnostic du
// délai de zap ; aucun identifiant dans la réponse).
router.get('/upstream-probe', (Request req) async {
final user = req.context['user'] as User?;
if (user == null || !user.isAdmin) {
return Response.forbidden(
jsonEncode({'error': 'Admin access required'}),
);
}
final streamId = req.url.queryParameters['stream'] ?? '';
if (!RegExp(r'^[0-9]+$').hasMatch(streamId)) {
return Response.badRequest(body: 'stream=<id numérique> requis');
}
final playlist = await getPlaylist(req);
if (playlist == null) return Response.forbidden('No playlist');
final result = await probeUpstream(playlist, streamId);
return Response.ok(
jsonEncode(result),
headers: {'content-type': 'application/json'},
);
});
return router(request);
}),
);
+159 -9
View File
@@ -82,8 +82,39 @@ class XmltvEpgService {
String? displayName,
}) async {
await ensureFresh();
for (final candidate in [channelId, displayName]) {
final key = normalizeKey(candidate);
final direct = _lookup(channelId, displayName);
if (direct.isNotEmpty) return direct;
// Chaîne décalée sans guide propre (« TF1 +1 » absent des dumps) : le
// guide de la chaîne principale, une heure plus tard, est exact.
if (displayName != null && _plusOne.hasMatch(displayName)) {
final base = displayName.replaceAll(_plusOne, ' ');
final hits = _lookup(null, base);
return [
for (final p in hits)
XmltvProgramme(
title: p.title,
description: p.description,
start: p.start.add(const Duration(hours: 1)),
stop: p.stop.add(const Duration(hours: 1)),
),
];
}
return const [];
}
static final _plusOne = RegExp(r'\+\s*1(?![0-9])');
List<XmltvProgramme> _lookup(String? channelId, String? displayName) {
final keys = [
normalizeKey(channelId),
if (displayName != null) ...nameKeys(displayName),
// Clés souples en dernier recours, dans leur propre espace de noms.
_looseIndexKey(looseKey(channelId)),
if (displayName != null)
_looseIndexKey(looseKey(cleanChannelName(displayName).name)),
];
for (final key in keys) {
if (key.isEmpty) continue;
final hit = _index[key];
if (hit != null && hit.isNotEmpty) return hit;
@@ -91,6 +122,110 @@ class XmltvEpgService {
return const [];
}
static String _looseIndexKey(String loose) => loose.isEmpty ? '' : '~$loose';
/// Jetons sans valeur distinctive pour reconnaître une chaîne.
static const _looseNoise = {
'channel', 'tv', 'hd', 'fhd', 'uhd', 'sd', '4k', 'hevc',
};
/// Codes pays en fin d'identifiant XMLTV (`TF1.fr`, `RTS1.ch`).
static const _countryCodes = {
'fr', 'be', 'ch', 'lu', 'mc', 'ca', 'uk', 'us', 'es', 'it', 'de', 'pt',
'nl',
};
/// Clé « souple » : sans code pays final ni mots génériques
/// (« AB3.Channel.fr » et « AB3 » → `ab3`).
///
/// Servie dans un espace de noms à part, consultée en tout dernier : elle
/// rattrape les variantes d'écriture sans fausser les correspondances
/// exactes.
static String looseKey(String? raw) {
if (raw == null) return '';
final folded = StringBuffer();
for (final rune in raw.toLowerCase().runes) {
final char = String.fromCharCode(rune);
folded.write(_accents[char] ?? char);
}
final tokens = folded
.toString()
.split(RegExp(r'[^a-z0-9]+'))
.where((t) => t.isNotEmpty)
.toList();
if (tokens.length > 1 && _countryCodes.contains(tokens.last)) {
tokens.removeLast();
}
tokens.removeWhere(_looseNoise.contains);
return tokens.join();
}
/// Préfixe pays des noms de panneau : `FR - `, `FR: `, `|FR| `, `[FR] `…
static final _countryPrefix = RegExp(
r'^\s*[\|\[\(]?\s*([A-Za-z]{2,3})\s*[\|\]\)]?\s*[-:|]\s*|^\s*[\|\[\(]\s*([A-Za-z]{2,3})\s*[\|\]\)]\s*',
);
/// Marqueurs de qualité ou de source en fin de nom, sans rapport avec la
/// chaîne elle-même.
static final _qualitySuffix = RegExp(
r'(\s+|^)(fhd|uhd|hd|sd|hq|lq|4k|8k|hevc|h\.?265|h\.?264|1080p?|720p?|'
r'50fps|60fps|backup|raw|vip|multi|\(backup\)|\(multi\))\s*$',
caseSensitive: false,
);
/// Clés candidates pour retrouver une chaîne du panneau par son nom.
///
/// POURQUOI : en prod, 59 % des chaînes françaises n'ont pas
/// d'`epg_channel_id`. Leur nom brut (« FR - TF1 FHD ◉ » → `frtf1fhd`) ne
/// correspond à aucune entrée XMLTV (`TF1.fr`, « TF1 »). On retire le
/// préfixe pays, les marqueurs de qualité et les symboles, puis on tente
/// aussi la forme `nom.pays` qu'utilisent les dumps publics.
///
/// Le décalage horaire (« +1 ») est conservé : TF1 +1 n'a pas le guide
/// de TF1.
static List<String> nameKeys(String name) {
final keys = <String>[normalizeKey(name)];
final cleaned = cleanChannelName(name);
final country = cleaned.country;
final base = normalizeKey(cleaned.name);
if (base.isNotEmpty) {
keys.add(base);
keys.add('$base${country ?? 'fr'}');
}
return keys.where((k) => k.isNotEmpty).toSet().toList();
}
/// Abréviations du panneau pour les chaînes publiques (« F3 ALPES »), là
/// où les dumps écrivent `France.3.-.Alpes.fr`.
static final _franceAbbrev = RegExp(r'^F\s?([2-5])\b', caseSensitive: false);
/// Nom de chaîne débarrassé du préfixe pays, des symboles et des
/// marqueurs de qualité ; [country] = code pays du préfixe, s'il y en a.
static ({String name, String? country}) cleanChannelName(String raw) {
var cleaned = raw;
String? country;
final prefix = _countryPrefix.firstMatch(cleaned);
if (prefix != null) {
country = (prefix.group(1) ?? prefix.group(2))?.toLowerCase();
cleaned = cleaned.substring(prefix.end);
}
// Symboles décoratifs (◉, ᴴᴰ, ★…) : tout ce qui n'est ni lettre, ni
// chiffre, ni ponctuation utile.
cleaned = cleaned
.replaceAll(RegExp(r'[^\p{L}\p{N}\s+.&\-()]', unicode: true), ' ')
.trim();
String previous;
do {
previous = cleaned;
cleaned = cleaned.replaceFirst(_qualitySuffix, '').trim();
} while (cleaned != previous && cleaned.isNotEmpty);
cleaned = cleaned.replaceFirstMapped(
_franceAbbrev,
(m) => 'France ${m[1]}',
);
return (name: cleaned, country: country);
}
/// Recharge l'index s'il est absent ou périmé. Les appels concurrents
/// partagent le même téléchargement.
Future<void> ensureFresh() {
@@ -212,10 +347,12 @@ class XmltvEpgService {
final limit = DateTime.now().toUtc().add(horizon);
final byChannel = <String, List<XmltvProgramme>>{};
// Alias : plusieurs dumps déclarent <channel id="X"> avec un
// <display-name> différent de l'identifiant. On indexe les deux pour
// maximiser les correspondances.
// Alias : plusieurs dumps déclarent <channel id="X"> avec un ou plusieurs
// <display-name> différents de l'identifiant (« TF1 HD », « TF1 »). On
// les indexe TOUS : seul le premier l'était, et c'était souvent la
// variante la moins proche du nom annoncé par le panneau.
final aliases = <String, String>{};
final displayNames = <String>[];
String? channelId;
String? programmeChannel;
@@ -232,6 +369,7 @@ class XmltvEpgService {
case 'channel':
channelId = _attr(event, 'id');
displayName.clear();
displayNames.clear();
case 'programme':
programmeChannel = _attr(event, 'channel');
start = parseXmltvDate(_attr(event, 'start'));
@@ -254,15 +392,27 @@ class XmltvEpgService {
case 'desc':
desc.write(text);
case 'display-name':
if (displayName.isEmpty) displayName.write(text);
displayName.write(text);
}
} else if (event is XmlEndElementEvent) {
switch (event.name) {
case 'display-name':
displayNames.add(displayName.toString());
displayName.clear();
case 'channel':
final id = normalizeKey(channelId);
final name = normalizeKey(displayName.toString());
if (id.isNotEmpty && name.isNotEmpty && id != name) {
aliases[name] = id;
for (final raw in displayNames) {
final name = normalizeKey(raw);
if (id.isNotEmpty && name.isNotEmpty && id != name) {
aliases.putIfAbsent(name, () => id);
}
}
// Clés souples (espace de noms « ~ ») : premier arrivé gagne.
if (id.isNotEmpty) {
for (final raw in [channelId, ...displayNames]) {
final loose = _looseIndexKey(looseKey(raw));
if (loose.isNotEmpty) aliases.putIfAbsent(loose, () => id);
}
}
channelId = null;
case 'programme':
+108
View File
@@ -217,5 +217,113 @@ void main() {
expect(response.headers['X-Epg-Source'], 'xtream');
expect(actions, contains('get_simple_data_table'));
});
// Dump externe couvrant TF1, et panneau dont le xmltv.php est vide.
String externalDump() {
final now = DateTime.now().toUtc();
return xmltvDump(now, now.add(const Duration(hours: 1)))
.replaceAll('France2.fr', 'TF1.fr')
.replaceAll('FRANCE 2', 'TF1');
}
EpgApi apiWith(http.Client client) => EpgApi(
(_) async => playlist,
httpClient: client,
xmltv: XmltvEpgService(
sourceUrls: const ['http://ext.example/epg.xml'],
client: client,
),
panelXmltvBuilder: (config) => XmltvEpgService(
sourceUrls: ['${config.dns}/xmltv.php'],
client: client,
),
);
test('un panneau qui plante ne prive plus la chaîne du dump externe',
() async {
// Constaté en prod : chaque /api/epg répondait 500 en 4 s, le guide
// externe (qui couvrait pourtant la chaîne) n'étant jamais consulté.
final client = MockClient((request) async {
final url = request.url;
if (url.host == 'ext.example') return http.Response(externalDump(), 200);
if (url.path.endsWith('/xmltv.php')) return http.Response('<tv></tv>', 200);
final action = url.queryParameters['action'] ?? '';
if (action == 'get_live_streams') {
// Octets UTF-8 et JSON sans charset annoncé, comme le panneau réel.
return http.Response.bytes(
utf8.encode(jsonEncode([
{'stream_id': 1, 'name': 'FR - TF1 FHD ◉', 'epg_channel_id': ''},
])),
200,
headers: {'content-type': 'application/json'},
);
}
throw http.ClientException('Connection closed', url);
});
final response = await apiWith(client).handleGetEpg(requestFor('1'), '1');
final body = jsonDecode(await response.readAsString()) as Map;
expect(response.statusCode, 200);
expect(response.headers['X-Epg-Source'], 'xmltv');
expect((body['programmes'] as List).single['title'], 'Journal de 20h');
});
test('le dump externe passe avant l\'appel lent chaîne par chaîne',
() async {
final actions = <String>[];
final client = MockClient((request) async {
final url = request.url;
if (url.host == 'ext.example') return http.Response(externalDump(), 200);
if (url.path.endsWith('/xmltv.php')) return http.Response('<tv></tv>', 200);
final action = url.queryParameters['action'] ?? '';
actions.add(action);
if (action == 'get_live_streams') {
return http.Response(
jsonEncode([
{'stream_id': 1, 'name': 'TF1', 'epg_channel_id': 'TF1.fr'},
]),
200,
);
}
return http.Response('{}', 200);
});
final response = await apiWith(client).handleGetEpg(requestFor('1'), '1');
expect(response.headers['X-Epg-Source'], 'xmltv');
expect(actions, isNot(contains('get_simple_data_table')));
});
test('la grille entière partage un seul téléchargement de la table',
() async {
// 30 tuiles demandent leur guide d'un coup : sans mutualisation, autant
// de `get_live_streams` (7 Mo en prod) partaient en parallèle.
var liveStreamsCalls = 0;
final client = MockClient((request) async {
final url = request.url;
if (url.host == 'ext.example') return http.Response(externalDump(), 200);
if (url.path.endsWith('/xmltv.php')) return http.Response('<tv></tv>', 200);
final action = url.queryParameters['action'] ?? '';
if (action == 'get_live_streams') {
liveStreamsCalls++;
await Future<void>.delayed(const Duration(milliseconds: 50));
return http.Response(
jsonEncode([
for (var i = 1; i <= 5; i++)
{'stream_id': i, 'name': 'TF1', 'epg_channel_id': 'TF1.fr'},
]),
200,
);
}
return http.Response('{}', 200);
});
final api = apiWith(client);
final responses = await Future.wait([
for (var i = 1; i <= 5; i++) api.handleGetEpg(requestFor('$i'), '$i'),
]);
expect(responses.every((r) => r.statusCode == 200), isTrue);
expect(liveStreamsCalls, 1);
});
});
}
+62
View File
@@ -0,0 +1,62 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:test/test.dart';
import '../api/upstream_probe.dart';
import '../models/playlist_config.dart';
void main() {
final playlist = PlaylistConfig(
id: 'p',
name: 'n',
dns: 'http://panel.test',
username: 'secretuser',
password: 'secretpass',
createdAt: DateTime(2026),
);
// Panneau simulé : redirige vers un serveur de diffusion avec jeton.
final client = MockClient.streaming((request, _) async {
final path = request.url.path;
if (request.url.host == 'panel.test') {
return http.StreamedResponse(
const Stream.empty(),
302,
headers: {
'location': 'http://lb.test$path?token=secrettoken',
},
);
}
if (path.endsWith('.m3u8')) {
return http.StreamedResponse(
Stream.value(utf8.encode(
'#EXTM3U\n#EXT-X-TARGETDURATION:6\n#EXTINF:6,\nseg1.ts\n'
'#EXTINF:6,\nseg2.ts\n',
)),
200,
);
}
return http.StreamedResponse(Stream.value(List.filled(2000, 0x47)), 200);
});
test('chronomètre .ts et .m3u8 en suivant les redirections', () async {
final result = await probeUpstream(playlist, '123', client: client);
final ts = result['ts'] as Map;
expect((ts['hops'] as List).map((h) => (h as Map)['status']), [302, 200]);
expect(ts['firstByteMs'], isNotNull);
final hls = result['m3u8'] as Map;
expect(hls['segments'], 2);
expect(hls['targetDuration'], '6');
expect((hls['segment'] as Map)['firstByteMs'], isNotNull);
});
test('aucun identifiant ni jeton dans la réponse', () async {
final json = jsonEncode(await probeUpstream(playlist, '123', client: client));
expect(json, isNot(contains('secretuser')));
expect(json, isNot(contains('secretpass')));
expect(json, isNot(contains('secrettoken')));
});
}
+119
View File
@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
@@ -28,6 +29,13 @@ String _fixture(DateTime start, DateTime stop) {
''';
}
String _stamp(DateTime d) {
final u = d.toUtc();
String two(int v) => v.toString().padLeft(2, '0');
return '${u.year}${two(u.month)}${two(u.day)}'
'${two(u.hour)}${two(u.minute)}${two(u.second)} +0000';
}
void main() {
final service = XmltvEpgService(sourceUrls: const []);
@@ -51,6 +59,117 @@ void main() {
});
});
group('nameKeys', () {
// Noms réels du panneau (prod) : 59 % des chaînes françaises n'ont pas
// d'epg_channel_id, seul leur nom permet de retrouver le guide.
test('retire le préfixe pays et les marqueurs de qualité', () {
expect(XmltvEpgService.nameKeys('FR - TF1 FHD ◉'), contains('tf1'));
expect(XmltvEpgService.nameKeys('FR - FRANCE 4 FHD'), contains('france4'));
expect(XmltvEpgService.nameKeys('|FR| M6 HD'), contains('m6'));
expect(XmltvEpgService.nameKeys('[FR] Arte UHD 4K'), contains('arte'));
expect(XmltvEpgService.nameKeys('FR: RMC Story HEVC'), contains('rmcstory'));
});
test('propose aussi la forme identifiant « nom.pays »', () {
// Les dumps publics identifient souvent la chaîne par `TF1.fr`.
expect(XmltvEpgService.nameKeys('FR - TF1 FHD'), contains('tf1fr'));
});
test('garde la variante décalée distincte de la chaîne principale', () {
expect(XmltvEpgService.nameKeys('FR - TF1 +1 FHD'), isNot(contains('tf1')));
});
test('ne réduit pas un nom court à rien', () {
expect(XmltvEpgService.nameKeys('M6'), contains('m6'));
expect(XmltvEpgService.nameKeys('HD'), isNot(contains('')));
});
});
group('correspondances élargies (chaînes vides mesurées en prod)', () {
XmltvEpgService serviceWith(String dump) => XmltvEpgService(
sourceUrls: const ['http://ext/epg.xml'],
client: MockClient((_) async => http.Response.bytes(
// Octets UTF-8 : les ids réels portent des accents.
const Utf8Encoder().convert(dump),
200,
)),
);
String channel(String id, String name, String title, DateTime start) => '''
<channel id="$id"><display-name>$name</display-name></channel>
<programme start="${_stamp(start)}" stop="${_stamp(start.add(const Duration(hours: 1)))}" channel="$id"><title>$title</title></programme>''';
test('« F3 ALPES » du panneau trouve France.3.-.Alpes.fr', () async {
final now = DateTime.now().toUtc();
final svc = serviceWith('<tv>${channel('France.3.-.Alpes.fr', 'France 3 Alpes', 'JT Alpes', now)}</tv>');
final hits = await svc.programmesFor(null, displayName: 'FR - F3 ALPES HD');
expect(hits.single.title, 'JT Alpes');
});
test('« AB3 » trouve AB3.Channel.fr (clé souple)', () async {
final now = DateTime.now().toUtc();
final svc = serviceWith('<tv>${channel('AB3.Channel.fr', 'AB3 Channel', 'Série', now)}</tv>');
final hits = await svc.programmesFor('AB3.fr', displayName: 'FR - AB3 FHD');
expect(hits.single.title, 'Série');
});
test('« TF1 +1 » reprend le guide de TF1 décalé d\'une heure', () async {
final now = DateTime.now().toUtc();
final svc = serviceWith('<tv>${channel('TF1.fr', 'TF1', 'Le 13h', now)}</tv>');
final hits = await svc.programmesFor(null, displayName: 'FR - TF1 +1 FHD');
expect(hits.single.title, 'Le 13h');
expect(hits.single.start, now.add(const Duration(hours: 1)).copyWith(microsecond: 0, millisecond: 0));
});
test('une chaîne +1 qui a son propre guide le garde', () async {
final now = DateTime.now().toUtc();
final svc = serviceWith('<tv>'
'${channel('Boomerang.fr', 'Boomerang', 'Normal', now)}'
'${channel('Boomerang.+1.fr', 'Boomerang +1', 'Décalé', now)}</tv>');
final hits = await svc.programmesFor(null, displayName: 'FR - BOOMERANG +1');
expect(hits.single.title, 'Décalé');
});
});
group('programmesFor par nom', () {
test('trouve le guide d\'une chaîne sans epg_channel_id par son nom nettoyé',
() async {
final now = DateTime.now().toUtc();
String stamp(DateTime d) {
final u = d.toUtc();
String two(int v) => v.toString().padLeft(2, '0');
return '${u.year}${two(u.month)}${two(u.day)}'
'${two(u.hour)}${two(u.minute)}${two(u.second)} +0000';
}
final dump = '''
<tv>
<channel id="TF1.fr"><display-name>TF1 HD</display-name><display-name>TF1</display-name></channel>
<programme start="${stamp(now)}" stop="${stamp(now.add(const Duration(hours: 1)))}" channel="TF1.fr">
<title>Le 13h</title>
</programme>
</tv>
''';
final svc = XmltvEpgService(
sourceUrls: const ['http://ext/epg.xml'],
client: MockClient((_) async => http.Response(dump, 200)),
);
final hits = await svc.programmesFor(null, displayName: 'FR - TF1 FHD ◉');
expect(hits.single.title, 'Le 13h');
});
test('indexe chaque display-name, pas seulement le premier', () {
final now = DateTime.now().toUtc();
final index = service.parseForTest('''
<tv>
<channel id="x.fr"><display-name>Premier Nom</display-name><display-name>Second Nom</display-name></channel>
<programme start="${_stamp(now)}" stop="${_stamp(now.add(const Duration(hours: 1)))}" channel="x.fr"><title>T</title></programme>
</tv>
''');
expect(index[XmltvEpgService.normalizeKey('Second Nom')], isNotNull);
});
});
group('parseXmltvDate', () {
test('applique le décalage horaire annoncé', () {
expect(
+1 -1
View File
@@ -9,7 +9,7 @@ services:
- MAX_CONCURRENT_RECORDINGS=2
# Source XMLTV de repli, utilisée seulement quand le panneau ne rend
# aucun programme actuel. Vider la variable coupe tout appel sortant.
- EPG_XMLTV_URLS=https://epgshare01.online/epgshare01/epg_ripper_FR1.xml.gz
- EPG_XMLTV_URLS=https://epgshare01.online/epgshare01/epg_ripper_FR1.xml.gz,https://epgshare01.online/epgshare01/epg_ripper_CH1.xml.gz,https://epgshare01.online/epgshare01/epg_ripper_BE2.xml.gz
- NVIDIA_GPU=true
volumes:
- xtremflow-data:/app/data
+1 -1
View File
@@ -27,7 +27,7 @@ services:
# aucun programme couvrant l'instant présent. Vider la variable coupe
# tout appel sortant : l'EPG se limite alors au panneau de l'abonné.
# Plusieurs dumps se séparent par des virgules.
- EPG_XMLTV_URLS=${EPG_XMLTV_URLS-https://epgshare01.online/epgshare01/epg_ripper_FR1.xml.gz}
- EPG_XMLTV_URLS=${EPG_XMLTV_URLS-https://epgshare01.online/epgshare01/epg_ripper_FR1.xml.gz,https://epgshare01.online/epgshare01/epg_ripper_CH1.xml.gz,https://epgshare01.online/epgshare01/epg_ripper_BE2.xml.gz}
# Accélération GPU NVIDIA pour le transcodage (NVENC) : true/false
- NVIDIA_GPU=${NVIDIA_GPU:-false}
restart: unless-stopped