mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-18 20:59:59 +02:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d00409de8 | ||
|
|
0ea60ccbfa | ||
|
|
a3640a6b5f | ||
|
|
584068b45b | ||
|
|
abe7d522c1 | ||
|
|
8d79472ca8 | ||
|
|
6812a4964d |
No files matched your search
@@ -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 |
|
| `RECORDINGS_PATH` | `./data/recordings` | Dossier hôte des enregistrements |
|
||||||
| `TZ` | `Europe/Paris` | Fuseau du conteneur (les enregistrements sont stockés en UTC) |
|
| `TZ` | `Europe/Paris` | Fuseau du conteneur (les enregistrements sont stockés en UTC) |
|
||||||
| `MAX_CONCURRENT_RECORDINGS` | `2` | Enregistrements simultanés |
|
| `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 |
|
| `NVIDIA_GPU` | `false` | Transcodage NVENC |
|
||||||
| `ADMIN_INITIAL_PASSWORD` | *(généré)* | Mot de passe initial du compte admin |
|
| `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 |
|
| `MIN_FREE_DISK_MB` | `500` | Espace libre minimal pour démarrer une capture |
|
||||||
|
|||||||
+91
-24
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:isolate';
|
||||||
import 'package:shelf/shelf.dart';
|
import 'package:shelf/shelf.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import '../models/playlist_config.dart';
|
import '../models/playlist_config.dart';
|
||||||
@@ -97,10 +98,15 @@ class EpgApi {
|
|||||||
// Le dump du panneau passe devant : une requête couvre toutes les
|
// 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
|
// chaînes, là où `player_api` en demande une par chaîne, à plusieurs
|
||||||
// secondes pièce.
|
// 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())>[
|
final sources = <(String, Future<List<Map<String, dynamic>>> Function())>[
|
||||||
('panel-xmltv', () => _panelProgrammes(playlist, channelId)),
|
('panel-xmltv', () => _panelProgrammes(playlist, channelId)),
|
||||||
('xtream', () => _xtreamProgrammes(playlist, channelId)),
|
|
||||||
('xmltv', () => _xmltvProgrammes(playlist, channelId)),
|
('xmltv', () => _xmltvProgrammes(playlist, channelId)),
|
||||||
|
('xtream', () => _xtreamProgrammes(playlist, channelId)),
|
||||||
];
|
];
|
||||||
|
|
||||||
var epgData = <String, dynamic>{
|
var epgData = <String, dynamic>{
|
||||||
@@ -133,10 +139,13 @@ class EpgApi {
|
|||||||
|
|
||||||
final jsonStr = json.encode(epgData);
|
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(
|
_cache[cacheKey] = _CacheEntry(
|
||||||
data: jsonStr,
|
data: jsonStr,
|
||||||
expiresAt: DateTime.now().add(const Duration(minutes: 30)),
|
expiresAt: DateTime.now()
|
||||||
|
.add(Duration(minutes: found ? 30 : 5)),
|
||||||
);
|
);
|
||||||
|
|
||||||
return Response.ok(
|
return Response.ok(
|
||||||
@@ -191,8 +200,19 @@ class EpgApi {
|
|||||||
'&password=${playlist.password}'
|
'&password=${playlist.password}'
|
||||||
'&action=$action&stream_id=$channelId';
|
'&action=$action&stream_id=$channelId';
|
||||||
|
|
||||||
final response =
|
final http.Response response;
|
||||||
await _http.get(Uri.parse(url)).timeout(const Duration(seconds: 60));
|
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;
|
if (response.statusCode != 200) continue;
|
||||||
|
|
||||||
Map<String, dynamic> parsed;
|
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.
|
/// 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 key = '${playlist.dns}|${playlist.username}';
|
||||||
final cached = _channelMaps[key];
|
final cached = _channelMaps[key];
|
||||||
if (cached != null && DateTime.now().isBefore(cached.expiresAt)) {
|
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'
|
final url = '${playlist.dns}/player_api.php'
|
||||||
'?username=${playlist.username}&password=${playlist.password}'
|
'?username=${playlist.username}&password=${playlist.password}'
|
||||||
'&action=get_live_streams';
|
'&action=get_live_streams';
|
||||||
final response =
|
http.Response? response;
|
||||||
await _http.get(Uri.parse(url)).timeout(const Duration(seconds: 90));
|
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>{};
|
var epgIds = <String, String>{};
|
||||||
final names = <String, String>{};
|
var names = <String, String>{};
|
||||||
if (response.statusCode == 200) {
|
if (response != null && response.statusCode == 200) {
|
||||||
final decoded = json.decode(response.body);
|
// Décodage dans un isolate : 7 Mo de JSON sur la boucle principale la
|
||||||
if (decoded is List) {
|
// gelaient, et le relais turbo.ts cessait d'émettre pendant ce temps.
|
||||||
for (final item in decoded) {
|
// UTF-8 explicite : sans charset annoncé, `response.body` peut décoder
|
||||||
if (item is! Map) continue;
|
// en latin-1 et abîmer les noms (« Chérie », « ◉ »), donc leur
|
||||||
final streamId = item['stream_id']?.toString();
|
// correspondance avec le guide.
|
||||||
if (streamId == null || streamId.isEmpty) continue;
|
final body = utf8.decode(response.bodyBytes, allowMalformed: true);
|
||||||
final epgId = item['epg_channel_id']?.toString();
|
try {
|
||||||
if (epgId != null && epgId.isNotEmpty) epgIds[streamId] = epgId;
|
(epgIds, names) = await Isolate.run(() => _parseChannelTable(body));
|
||||||
final name = item['name']?.toString();
|
} catch (e) {
|
||||||
if (name != null && name.isNotEmpty) names[streamId] = name;
|
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(
|
final map = _ChannelMap(
|
||||||
epgIds: epgIds,
|
epgIds: epgIds,
|
||||||
names: names,
|
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;
|
_channelMaps[key] = map;
|
||||||
return map;
|
return map;
|
||||||
|
|||||||
+147
-74
@@ -9,7 +9,6 @@ import '../services/gpu_fallback.dart';
|
|||||||
import '../services/upstream_slots.dart';
|
import '../services/upstream_slots.dart';
|
||||||
import '../utils/log_redactor.dart';
|
import '../utils/log_redactor.dart';
|
||||||
import '../utils/media_probe.dart';
|
import '../utils/media_probe.dart';
|
||||||
import '../utils/stream_pipe.dart';
|
|
||||||
import 'recording_playlist.dart';
|
import 'recording_playlist.dart';
|
||||||
|
|
||||||
/// Directory for temporary HLS segments
|
/// Directory for temporary HLS segments
|
||||||
@@ -48,17 +47,31 @@ Future<void> _claimUpstream(PlaylistConfig playlist, String id) async {
|
|||||||
await Future<void>.delayed(const Duration(seconds: 1));
|
await Future<void>.delayed(const Duration(seconds: 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Délai sans consommation au-delà duquel un flux est tenu pour orphelin.
|
||||||
|
///
|
||||||
|
/// Un lecteur HLS redemande sa playlist toutes les 2 à 4 s ; un flux
|
||||||
|
/// `turbo.ts` reçoit au moins une rafale toutes les ~7 s même sur un panneau
|
||||||
|
/// qui livre par à-coups. 15 s laissent une marge à ces deux rythmes.
|
||||||
|
const _viewerGrace = Duration(seconds: 15);
|
||||||
|
|
||||||
/// Inscrit [process] comme connexion amont de [playlist] tant qu'il vit.
|
/// Inscrit [process] comme connexion amont de [playlist] tant qu'il vit.
|
||||||
|
///
|
||||||
|
/// [lastActivity] : dernier signe de vie du spectateur ; la connexion n'est
|
||||||
|
/// coupée pour en ouvrir une autre que s'il remonte à plus de
|
||||||
|
/// [_viewerGrace].
|
||||||
void _registerUpstream(
|
void _registerUpstream(
|
||||||
PlaylistConfig playlist,
|
PlaylistConfig playlist,
|
||||||
String id,
|
String id,
|
||||||
Process process, {
|
Process process, {
|
||||||
required void Function() release,
|
required void Function() release,
|
||||||
|
required DateTime Function() lastActivity,
|
||||||
}) {
|
}) {
|
||||||
final token = _upstreamSlots.register(
|
final token = _upstreamSlots.register(
|
||||||
id: id,
|
id: id,
|
||||||
account: accountKeyOf(playlist),
|
account: accountKeyOf(playlist),
|
||||||
release: release,
|
release: release,
|
||||||
|
isActive: () =>
|
||||||
|
DateTime.now().difference(lastActivity()) < _viewerGrace,
|
||||||
);
|
);
|
||||||
process.exitCode.then((_) => _upstreamSlots.unregister(id, token: token));
|
process.exitCode.then((_) => _upstreamSlots.unregister(id, token: token));
|
||||||
}
|
}
|
||||||
@@ -274,6 +287,8 @@ Future<_SessionAttempt> _runSession({
|
|||||||
id,
|
id,
|
||||||
session.process,
|
session.process,
|
||||||
release: () => sessionManager.killSession(id),
|
release: () => sessionManager.killSession(id),
|
||||||
|
// Touchée à chaque requête de playlist ou de segment.
|
||||||
|
lastActivity: () => session.lastAccess,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final outcome =
|
final outcome =
|
||||||
@@ -425,6 +440,46 @@ Stream<List<int>> _resilientLiveBody(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// URL d'une chaîne chez le panneau, en HLS (`.m3u8`) ou en flux continu
|
||||||
|
/// (`.ts`).
|
||||||
|
String _liveSourceUrl(PlaylistConfig p, String streamId, {required bool hls}) =>
|
||||||
|
'${p.dns}/live/${p.username}/${p.password}/$streamId.${hls ? 'm3u8' : 'ts'}';
|
||||||
|
|
||||||
|
/// Arguments d'entrée FFmpeg pour une chaîne du panneau.
|
||||||
|
///
|
||||||
|
/// POURQUOI le HLS d'abord : mesuré en prod (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 — un zap ~4×
|
||||||
|
/// plus rapide. La playlist du panneau garde en plus ~60 s de segments :
|
||||||
|
/// `-live_start_index -2` en récupère deux d'un coup, ce qui donne au
|
||||||
|
/// lecteur une avance immédiate pour absorber les à-coups.
|
||||||
|
///
|
||||||
|
/// Pas de `-reconnect_at_eof` en HLS : chaque segment se termine par une
|
||||||
|
/// fin de fichier, l'option provoquerait une reconnexion à chaque segment
|
||||||
|
/// (leçon tirée de xtremobile, où elle causait gels et sauts).
|
||||||
|
List<String> liveInputArgs(String url, {required bool hls}) => [
|
||||||
|
'-headers', 'User-Agent: VLC/3.0.18 LibVLC/3.0.18\r\n',
|
||||||
|
if (hls) ...[
|
||||||
|
'-reconnect', '1',
|
||||||
|
'-reconnect_on_network_error', '1',
|
||||||
|
'-reconnect_delay_max', '5',
|
||||||
|
'-live_start_index', '-2',
|
||||||
|
] else ...[
|
||||||
|
'-reconnect', '1', '-reconnect_streamed', '1',
|
||||||
|
'-reconnect_at_eof', '1',
|
||||||
|
'-reconnect_delay_max', '10',
|
||||||
|
],
|
||||||
|
// Abort reads stuck for 30s so a stalled upstream triggers the
|
||||||
|
// reconnect logic instead of wedging the process forever.
|
||||||
|
'-rw_timeout', '30000000',
|
||||||
|
// Démarrage rapide : sans borne, FFmpeg peut passer plusieurs
|
||||||
|
// secondes à sonder le flux avant d'écrire la première sortie.
|
||||||
|
'-fflags', 'nobuffer',
|
||||||
|
'-probesize', '1000000',
|
||||||
|
'-analyzeduration', '1000000',
|
||||||
|
'-i', url,
|
||||||
|
];
|
||||||
|
|
||||||
Handler createLiveStreamHandler(
|
Handler createLiveStreamHandler(
|
||||||
Future<PlaylistConfig?> Function(Request) getPlaylist, {
|
Future<PlaylistConfig?> Function(Request) getPlaylist, {
|
||||||
bool Function()? isGpuEnabled,
|
bool Function()? isGpuEnabled,
|
||||||
@@ -442,18 +497,14 @@ Handler createLiveStreamHandler(
|
|||||||
final playlist = await getPlaylist(request);
|
final playlist = await getPlaylist(request);
|
||||||
if (playlist == null) return Response.forbidden('No playlist');
|
if (playlist == null) return Response.forbidden('No playlist');
|
||||||
|
|
||||||
final targetUrl =
|
|
||||||
'${playlist.dns}/live/${playlist.username}/${playlist.password}/$streamId.ts';
|
|
||||||
final useNvidiaGpu = isGpuEnabled?.call() ?? _isNvidiaGpuEnabled();
|
final useNvidiaGpu = isGpuEnabled?.call() ?? _isNvidiaGpuEnabled();
|
||||||
final sessionId = 'live_${streamId}_$quality';
|
final sessionId = 'live_${streamId}_$quality';
|
||||||
|
|
||||||
if (!sessionManager.contains(sessionId)) {
|
if (!sessionManager.contains(sessionId)) {
|
||||||
print(
|
print('[Live HLS] Starting $sessionId');
|
||||||
'[Live HLS] Starting $sessionId: ${LogRedactor.redactUrl(targetUrl)}',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final result = await _runWithGpuFallback(
|
Future<_SessionAttempt> run({required bool hls}) => _runWithGpuFallback(
|
||||||
id: sessionId,
|
id: sessionId,
|
||||||
isLive: true,
|
isLive: true,
|
||||||
upstream: playlist,
|
upstream: playlist,
|
||||||
@@ -461,19 +512,10 @@ Handler createLiveStreamHandler(
|
|||||||
buildArgs: (gpu) => [
|
buildArgs: (gpu) => [
|
||||||
'-hide_banner', '-loglevel', 'warning',
|
'-hide_banner', '-loglevel', 'warning',
|
||||||
if (gpu) ...['-hwaccel', 'cuda'],
|
if (gpu) ...['-hwaccel', 'cuda'],
|
||||||
'-headers', 'User-Agent: VLC/3.0.18 LibVLC/3.0.18\r\n',
|
...liveInputArgs(
|
||||||
'-reconnect', '1', '-reconnect_streamed', '1',
|
_liveSourceUrl(playlist, streamId, hls: hls),
|
||||||
'-reconnect_at_eof', '1',
|
hls: hls,
|
||||||
'-reconnect_delay_max', '10',
|
),
|
||||||
// Abort reads stuck for 30s so a stalled upstream triggers the
|
|
||||||
// reconnect logic instead of wedging the process forever.
|
|
||||||
'-rw_timeout', '30000000',
|
|
||||||
// Démarrage rapide : sans borne, FFmpeg peut passer plusieurs
|
|
||||||
// secondes à sonder le flux avant d'écrire le premier segment.
|
|
||||||
'-fflags', 'nobuffer',
|
|
||||||
'-probesize', '1000000',
|
|
||||||
'-analyzeduration', '1000000',
|
|
||||||
'-i', targetUrl,
|
|
||||||
..._liveVideoArgs(quality, gpu),
|
..._liveVideoArgs(quality, gpu),
|
||||||
..._audioArgs(withFilters: false),
|
..._audioArgs(withFilters: false),
|
||||||
// HLS sliding window: 10 x 2s segments (lower live latency than the
|
// HLS sliding window: 10 x 2s segments (lower live latency than the
|
||||||
@@ -491,6 +533,15 @@ Handler createLiveStreamHandler(
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
var result = await run(hls: true);
|
||||||
|
if (!result.ready) {
|
||||||
|
// Panneau sans HLS pour cette chaîne (ou HLS en panne) : repli sur le
|
||||||
|
// flux continu, plus lent à ouvrir mais universel.
|
||||||
|
print('[Live HLS] $sessionId : HLS du panneau indisponible — repli .ts');
|
||||||
|
sessionManager.killSession(sessionId);
|
||||||
|
result = await run(hls: false);
|
||||||
|
}
|
||||||
|
|
||||||
final session = result.session;
|
final session = result.session;
|
||||||
if (!result.ready || session == null) {
|
if (!result.ready || session == null) {
|
||||||
sessionManager.killSession(sessionId);
|
sessionManager.killSession(sessionId);
|
||||||
@@ -538,71 +589,93 @@ Handler createLiveStreamHandler(
|
|||||||
final playlist = await getPlaylist(request);
|
final playlist = await getPlaylist(request);
|
||||||
if (playlist == null) return Response.forbidden('No playlist');
|
if (playlist == null) return Response.forbidden('No playlist');
|
||||||
|
|
||||||
final targetUrl =
|
print('[Live Turbo] $streamId');
|
||||||
'${playlist.dns}/live/${playlist.username}/${playlist.password}/$streamId.ts';
|
|
||||||
print(
|
|
||||||
'[Live Turbo] $streamId: ${LogRedactor.redactUrl(targetUrl)}',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Un identifiant par requête : deux onglets sur la même chaîne sont
|
// Un identifiant par requête : deux onglets sur la même chaîne sont
|
||||||
// deux connexions distinctes chez le fournisseur.
|
// deux connexions distinctes chez le fournisseur.
|
||||||
final upstreamId = 'turbo_${streamId}_${++_turboCounter}';
|
final upstreamId = 'turbo_${streamId}_${++_turboCounter}';
|
||||||
await _claimUpstream(playlist, upstreamId);
|
await _claimUpstream(playlist, upstreamId);
|
||||||
|
|
||||||
final Process process;
|
// Dernier octet remis au client : un lecteur en vie en reçoit à chaque
|
||||||
try {
|
// rafale ; un client parti (zap mal détecté derrière le proxy) n'en
|
||||||
process = await Process.start(_getFFmpegPath(), [
|
// reçoit plus, la contre-pression bloquant FFmpeg.
|
||||||
'-hide_banner', '-loglevel', 'warning',
|
var lastDelivered = DateTime.now();
|
||||||
'-headers', 'User-Agent: VLC/3.0.18 LibVLC/3.0.18\r\n',
|
|
||||||
'-reconnect', '1', '-reconnect_streamed', '1',
|
Future<Process?> start({required bool hls}) async {
|
||||||
'-reconnect_at_eof', '1',
|
final Process process;
|
||||||
'-reconnect_delay_max', '10',
|
try {
|
||||||
'-rw_timeout', '30000000',
|
process = await Process.start(_getFFmpegPath(), [
|
||||||
// Démarrage rapide : ne pas bufferiser l'analyse, sonde réduite.
|
'-hide_banner', '-loglevel', 'warning',
|
||||||
'-fflags', 'nobuffer',
|
...liveInputArgs(
|
||||||
'-flags', 'low_delay',
|
_liveSourceUrl(playlist, streamId, hls: hls),
|
||||||
'-probesize', '1000000',
|
hls: hls,
|
||||||
'-analyzeduration', '1000000',
|
),
|
||||||
'-i', targetUrl,
|
'-flags', 'low_delay',
|
||||||
'-c:v', 'copy',
|
'-c:v', 'copy',
|
||||||
'-c:a', 'aac', '-b:a', '160k', '-ac', '2', '-ar', '48000',
|
'-c:a', 'aac', '-b:a', '160k', '-ac', '2', '-ar', '48000',
|
||||||
'-af', 'aresample=async=1',
|
'-af', 'aresample=async=1',
|
||||||
// Pas de délai de mux : les paquets partent dès qu'ils existent.
|
// Pas de délai de mux : les paquets partent dès qu'ils existent.
|
||||||
'-muxdelay', '0', '-muxpreload', '0',
|
'-muxdelay', '0', '-muxpreload', '0',
|
||||||
'-f', 'mpegts', 'pipe:1',
|
'-f', 'mpegts', 'pipe:1',
|
||||||
]);
|
]);
|
||||||
} on ProcessException catch (e) {
|
} on ProcessException catch (e) {
|
||||||
// Serveur saturé (plus de processus ou de mémoire) : un 503 que le
|
// `ProcessException` liste les arguments, donc l'URL `-i` du panneau.
|
||||||
// lecteur sait traiter, plutôt qu'une exception qui remonte en 500.
|
print('[Live Turbo] $streamId : FFmpeg n\'a pas démarré '
|
||||||
// `ProcessException` liste les arguments, donc l'URL `-i` du panneau.
|
'(${LogRedactor.redactUrl('$e')})');
|
||||||
print('[Live Turbo] $streamId : FFmpeg n\'a pas démarré '
|
return null;
|
||||||
'(${LogRedactor.redactUrl('$e')})');
|
}
|
||||||
return Response(503, body: 'FFmpeg start failed');
|
_registerUpstream(
|
||||||
|
playlist,
|
||||||
|
upstreamId,
|
||||||
|
process,
|
||||||
|
release: () => process.kill(ProcessSignal.sigterm),
|
||||||
|
lastActivity: () => lastDelivered,
|
||||||
|
);
|
||||||
|
// Journaliser les erreurs FFmpeg (redactées) sans bloquer le flux.
|
||||||
|
process.stderr.transform(const SystemEncoding().decoder).listen((line) {
|
||||||
|
final trimmed = line.trim();
|
||||||
|
if (trimmed.isNotEmpty) {
|
||||||
|
print('[Live Turbo] $streamId ffmpeg: '
|
||||||
|
'${LogRedactor.redactUrl(trimmed)}');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return process;
|
||||||
}
|
}
|
||||||
|
|
||||||
_registerUpstream(
|
final first = await start(hls: true);
|
||||||
playlist,
|
// Serveur saturé (plus de processus ou de mémoire) : un 503 que le
|
||||||
upstreamId,
|
// lecteur sait traiter, plutôt qu'une exception qui remonte en 500.
|
||||||
process,
|
if (first == null) return Response(503, body: 'FFmpeg start failed');
|
||||||
release: () => process.kill(ProcessSignal.sigterm),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Journaliser les erreurs FFmpeg (redactées) sans bloquer le flux.
|
// Relais vers le client. Un générateur `async*` garde la contre-pression
|
||||||
process.stderr.transform(const SystemEncoding().decoder).listen((line) {
|
// (`yield` attend tant que le client ne lit pas) et son `finally` tue
|
||||||
final trimmed = line.trim();
|
// FFmpeg dès que le client zappe ou ferme l'onglet.
|
||||||
if (trimmed.isNotEmpty) {
|
//
|
||||||
print('[Live Turbo] $streamId ffmpeg: '
|
// Si le HLS du panneau n'a livré aucun octet (format refusé pour cette
|
||||||
'${LogRedactor.redactUrl(trimmed)}');
|
// chaîne, playlist vide), on rebascule sur le `.ts` dans la MÊME
|
||||||
|
// réponse : le lecteur ne voit qu'un démarrage un peu plus long.
|
||||||
|
Stream<List<int>> relay() async* {
|
||||||
|
Process? process = first;
|
||||||
|
var hls = true;
|
||||||
|
try {
|
||||||
|
while (process != null) {
|
||||||
|
var delivered = false;
|
||||||
|
await for (final chunk in process.stdout) {
|
||||||
|
delivered = true;
|
||||||
|
lastDelivered = DateTime.now();
|
||||||
|
yield chunk;
|
||||||
|
}
|
||||||
|
if (delivered || !hls) break;
|
||||||
|
print('[Live Turbo] $streamId : HLS du panneau muet — repli .ts');
|
||||||
|
hls = false;
|
||||||
|
process = await start(hls: false);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
process?.kill(ProcessSignal.sigterm);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
// Relayer stdout vers le client en gardant la contre-pression, et tuer
|
final body = relay();
|
||||||
// FFmpeg dès que le client zappe ou ferme l'onglet (sinon les processus
|
|
||||||
// s'accumulent à chaque zap).
|
|
||||||
final body = pipeWithBackpressure(
|
|
||||||
process.stdout,
|
|
||||||
onStop: () => process.kill(ProcessSignal.sigterm),
|
|
||||||
);
|
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
200,
|
200,
|
||||||
|
|||||||
@@ -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
@@ -28,6 +28,7 @@ import 'middleware/security_middleware.dart';
|
|||||||
import 'services/cleanup_service.dart';
|
import 'services/cleanup_service.dart';
|
||||||
import 'services/recording_scheduler.dart';
|
import 'services/recording_scheduler.dart';
|
||||||
import 'utils/asset_versioning.dart';
|
import 'utils/asset_versioning.dart';
|
||||||
|
import 'api/upstream_probe.dart';
|
||||||
|
|
||||||
void main(List<String> args) async {
|
void main(List<String> args) async {
|
||||||
// Parse command line arguments
|
// 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 :
|
// Source XMLTV de repli. Vider EPG_XMLTV_URLS désactive tout appel sortant :
|
||||||
// l'EPG se limite alors au panneau de l'abonné.
|
// l'EPG se limite alors au panneau de l'abonné.
|
||||||
final xmltvUrls = (Platform.environment['EPG_XMLTV_URLS'] ??
|
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(',')
|
.split(',')
|
||||||
.map((u) => u.trim())
|
.map((u) => u.trim())
|
||||||
.where((u) => u.isNotEmpty)
|
.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);
|
return router(request);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,10 +15,18 @@ import '../models/playlist_config.dart';
|
|||||||
/// le panneau répondait `HTTP 551` ou ne répondait pas (timeout), d'où les
|
/// le panneau répondait `HTTP 551` ou ne répondait pas (timeout), d'où les
|
||||||
/// films « indisponibles » et les zaps qui échouent.
|
/// films « indisponibles » et les zaps qui échouent.
|
||||||
///
|
///
|
||||||
/// Règle : le dernier flux demandé gagne. Avant d'ouvrir une nouvelle
|
/// Règle : avant d'ouvrir une nouvelle connexion amont, on coupe les
|
||||||
/// connexion amont, on coupe les plus anciennes du même compte jusqu'à lui
|
/// connexions ORPHELINES du même compte (plus personne ne les consomme),
|
||||||
/// faire de la place. Les enregistrements n'y sont pas inscrits : ils ne
|
/// les plus anciennes d'abord, jusqu'à lui faire de la place.
|
||||||
/// sont jamais coupés.
|
///
|
||||||
|
/// Un flux encore regardé n'est jamais coupé. Une première version coupait
|
||||||
|
/// sans distinction (« le dernier gagne ») : deux lecteurs ouverts sur un
|
||||||
|
/// compte à une connexion se coupaient alors en boucle, chacun relançant
|
||||||
|
/// aussitôt — plus aucune image nulle part (constaté en prod). Le second
|
||||||
|
/// lecteur est désormais refusé par le panneau, comme avant, et le premier
|
||||||
|
/// continue.
|
||||||
|
///
|
||||||
|
/// Les enregistrements n'y sont pas inscrits : ils ne sont jamais coupés.
|
||||||
class UpstreamSlots {
|
class UpstreamSlots {
|
||||||
final DateTime Function() _now;
|
final DateTime Function() _now;
|
||||||
final Map<String, _Slot> _slots = {};
|
final Map<String, _Slot> _slots = {};
|
||||||
@@ -31,13 +39,17 @@ class UpstreamSlots {
|
|||||||
/// Renvoie un jeton à repasser à [unregister] : une session relancée sous
|
/// Renvoie un jeton à repasser à [unregister] : une session relancée sous
|
||||||
/// le même identifiant ne doit pas être désinscrite par la fin de
|
/// le même identifiant ne doit pas être désinscrite par la fin de
|
||||||
/// l'ancien processus.
|
/// l'ancien processus.
|
||||||
|
///
|
||||||
|
/// [isActive] : vrai tant qu'un spectateur consomme le flux. Une
|
||||||
|
/// connexion active n'est jamais coupée par [makeRoom].
|
||||||
int register({
|
int register({
|
||||||
required String id,
|
required String id,
|
||||||
required String account,
|
required String account,
|
||||||
required void Function() release,
|
required void Function() release,
|
||||||
|
bool Function()? isActive,
|
||||||
}) {
|
}) {
|
||||||
final token = ++_nextToken;
|
final token = ++_nextToken;
|
||||||
_slots[id] = _Slot(token, account, _now(), release);
|
_slots[id] = _Slot(token, account, _now(), release, isActive);
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,14 +72,16 @@ class UpstreamSlots {
|
|||||||
if (max <= 0) return const [];
|
if (max <= 0) return const [];
|
||||||
final others = _slots.entries
|
final others = _slots.entries
|
||||||
.where((e) => e.value.account == account && e.key != keep)
|
.where((e) => e.value.account == account && e.key != keep)
|
||||||
.toList()
|
.toList();
|
||||||
..sort((a, b) => a.value.since.compareTo(b.value.since));
|
|
||||||
|
|
||||||
final excess = others.length - (max - 1);
|
final excess = others.length - (max - 1);
|
||||||
if (excess <= 0) return const [];
|
if (excess <= 0) return const [];
|
||||||
|
|
||||||
|
final orphans = others.where((e) => !e.value.active).toList()
|
||||||
|
..sort((a, b) => a.value.since.compareTo(b.value.since));
|
||||||
|
|
||||||
final freed = <String>[];
|
final freed = <String>[];
|
||||||
for (final entry in others.take(excess)) {
|
for (final entry in orphans.take(excess)) {
|
||||||
_slots.remove(entry.key);
|
_slots.remove(entry.key);
|
||||||
try {
|
try {
|
||||||
entry.value.release();
|
entry.value.release();
|
||||||
@@ -83,7 +97,16 @@ class _Slot {
|
|||||||
final String account;
|
final String account;
|
||||||
final DateTime since;
|
final DateTime since;
|
||||||
final void Function() release;
|
final void Function() release;
|
||||||
_Slot(this.token, this.account, this.since, this.release);
|
final bool Function()? isActive;
|
||||||
|
_Slot(this.token, this.account, this.since, this.release, this.isActive);
|
||||||
|
|
||||||
|
bool get active {
|
||||||
|
try {
|
||||||
|
return isActive?.call() ?? false;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clé de compte : deux playlists sur les mêmes identifiants partagent le
|
/// Clé de compte : deux playlists sur les mêmes identifiants partagent le
|
||||||
@@ -101,13 +124,21 @@ int? parseMaxConnections(Object? json) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Quota de connexions par compte, lu chez le fournisseur et mis en cache.
|
/// Quota de connexions par compte, lu chez le fournisseur et mis en cache.
|
||||||
|
///
|
||||||
|
/// Ne fait JAMAIS attendre un flux : `player_api.php` met jusqu'à 4 s à
|
||||||
|
/// répondre (mesuré en prod), délai qui s'ajoutait au démarrage du premier
|
||||||
|
/// flux puis de chaque flux suivant l'expiration du cache. La valeur connue
|
||||||
|
/// (même périmée), sinon [fallback], est rendue immédiatement ; la lecture
|
||||||
|
/// chez le panneau se fait en arrière-plan. Sans risque : le quota ne sert
|
||||||
|
/// qu'à décider quels flux ORPHELINS couper.
|
||||||
class AccountLimits {
|
class AccountLimits {
|
||||||
final Duration ttl;
|
final Duration ttl;
|
||||||
final Future<int?> Function(PlaylistConfig) _fetch;
|
final Future<int?> Function(PlaylistConfig) _fetch;
|
||||||
final Map<String, ({int max, DateTime at})> _cache = {};
|
final Map<String, ({int max, DateTime at})> _cache = {};
|
||||||
|
final Set<String> _refreshing = {};
|
||||||
|
|
||||||
/// Valeur retenue quand le panneau ne répond pas : un seul flux, le cas
|
/// Valeur retenue tant que le panneau n'a pas répondu : un seul flux, le
|
||||||
/// de loin le plus courant chez les fournisseurs Xtream.
|
/// cas de loin le plus courant chez les fournisseurs Xtream.
|
||||||
static const fallback = 1;
|
static const fallback = 1;
|
||||||
|
|
||||||
AccountLimits({
|
AccountLimits({
|
||||||
@@ -118,19 +149,20 @@ class AccountLimits {
|
|||||||
Future<int> maxFor(PlaylistConfig p) async {
|
Future<int> maxFor(PlaylistConfig p) async {
|
||||||
final key = accountKeyOf(p);
|
final key = accountKeyOf(p);
|
||||||
final cached = _cache[key];
|
final cached = _cache[key];
|
||||||
if (cached != null && DateTime.now().difference(cached.at) < ttl) {
|
if (cached == null || DateTime.now().difference(cached.at) >= ttl) {
|
||||||
return cached.max;
|
_refresh(key, p);
|
||||||
}
|
}
|
||||||
int? value;
|
return cached?.max ?? fallback;
|
||||||
try {
|
}
|
||||||
value = await _fetch(p);
|
|
||||||
} catch (_) {
|
void _refresh(String key, PlaylistConfig p) {
|
||||||
value = null;
|
if (!_refreshing.add(key)) return; // une seule lecture à la fois
|
||||||
}
|
Future<int?>.sync(() => _fetch(p))
|
||||||
// Un échec n'est pas mis en cache longtemps : on réessaiera au prochain
|
.then<int?>((v) => v, onError: (Object _) => null)
|
||||||
// flux plutôt que de rester 10 min sur une valeur devinée.
|
.then((value) {
|
||||||
if (value != null) _cache[key] = (max: value, at: DateTime.now());
|
// Un échec n'est pas mis en cache : on réessaiera au prochain flux.
|
||||||
return value ?? fallback;
|
if (value != null) _cache[key] = (max: value, at: DateTime.now());
|
||||||
|
}).whenComplete(() => _refreshing.remove(key));
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<int?> _fetchFromPanel(PlaylistConfig p) async {
|
static Future<int?> _fetchFromPanel(PlaylistConfig p) async {
|
||||||
|
|||||||
@@ -82,8 +82,39 @@ class XmltvEpgService {
|
|||||||
String? displayName,
|
String? displayName,
|
||||||
}) async {
|
}) async {
|
||||||
await ensureFresh();
|
await ensureFresh();
|
||||||
for (final candidate in [channelId, displayName]) {
|
final direct = _lookup(channelId, displayName);
|
||||||
final key = normalizeKey(candidate);
|
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;
|
if (key.isEmpty) continue;
|
||||||
final hit = _index[key];
|
final hit = _index[key];
|
||||||
if (hit != null && hit.isNotEmpty) return hit;
|
if (hit != null && hit.isNotEmpty) return hit;
|
||||||
@@ -91,6 +122,110 @@ class XmltvEpgService {
|
|||||||
return const [];
|
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
|
/// Recharge l'index s'il est absent ou périmé. Les appels concurrents
|
||||||
/// partagent le même téléchargement.
|
/// partagent le même téléchargement.
|
||||||
Future<void> ensureFresh() {
|
Future<void> ensureFresh() {
|
||||||
@@ -212,10 +347,12 @@ class XmltvEpgService {
|
|||||||
final limit = DateTime.now().toUtc().add(horizon);
|
final limit = DateTime.now().toUtc().add(horizon);
|
||||||
final byChannel = <String, List<XmltvProgramme>>{};
|
final byChannel = <String, List<XmltvProgramme>>{};
|
||||||
|
|
||||||
// Alias : plusieurs dumps déclarent <channel id="X"> avec un
|
// Alias : plusieurs dumps déclarent <channel id="X"> avec un ou plusieurs
|
||||||
// <display-name> différent de l'identifiant. On indexe les deux pour
|
// <display-name> différents de l'identifiant (« TF1 HD », « TF1 »). On
|
||||||
// maximiser les correspondances.
|
// 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 aliases = <String, String>{};
|
||||||
|
final displayNames = <String>[];
|
||||||
|
|
||||||
String? channelId;
|
String? channelId;
|
||||||
String? programmeChannel;
|
String? programmeChannel;
|
||||||
@@ -232,6 +369,7 @@ class XmltvEpgService {
|
|||||||
case 'channel':
|
case 'channel':
|
||||||
channelId = _attr(event, 'id');
|
channelId = _attr(event, 'id');
|
||||||
displayName.clear();
|
displayName.clear();
|
||||||
|
displayNames.clear();
|
||||||
case 'programme':
|
case 'programme':
|
||||||
programmeChannel = _attr(event, 'channel');
|
programmeChannel = _attr(event, 'channel');
|
||||||
start = parseXmltvDate(_attr(event, 'start'));
|
start = parseXmltvDate(_attr(event, 'start'));
|
||||||
@@ -254,15 +392,27 @@ class XmltvEpgService {
|
|||||||
case 'desc':
|
case 'desc':
|
||||||
desc.write(text);
|
desc.write(text);
|
||||||
case 'display-name':
|
case 'display-name':
|
||||||
if (displayName.isEmpty) displayName.write(text);
|
displayName.write(text);
|
||||||
}
|
}
|
||||||
} else if (event is XmlEndElementEvent) {
|
} else if (event is XmlEndElementEvent) {
|
||||||
switch (event.name) {
|
switch (event.name) {
|
||||||
|
case 'display-name':
|
||||||
|
displayNames.add(displayName.toString());
|
||||||
|
displayName.clear();
|
||||||
case 'channel':
|
case 'channel':
|
||||||
final id = normalizeKey(channelId);
|
final id = normalizeKey(channelId);
|
||||||
final name = normalizeKey(displayName.toString());
|
for (final raw in displayNames) {
|
||||||
if (id.isNotEmpty && name.isNotEmpty && id != name) {
|
final name = normalizeKey(raw);
|
||||||
aliases[name] = id;
|
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;
|
channelId = null;
|
||||||
case 'programme':
|
case 'programme':
|
||||||
|
|||||||
@@ -217,5 +217,113 @@ void main() {
|
|||||||
expect(response.headers['X-Epg-Source'], 'xtream');
|
expect(response.headers['X-Epg-Source'], 'xtream');
|
||||||
expect(actions, contains('get_simple_data_table'));
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import 'package:test/test.dart';
|
||||||
|
import '../api/streaming_handler.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('liveInputArgs', () {
|
||||||
|
test('HLS : jamais de reconnect_at_eof (chaque segment finit par un EOF)',
|
||||||
|
() {
|
||||||
|
final args = liveInputArgs('http://p/live/u/p/1.m3u8', hls: true);
|
||||||
|
expect(args, isNot(contains('-reconnect_at_eof')));
|
||||||
|
expect(args, isNot(contains('-reconnect_streamed')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('HLS : démarre deux segments avant le direct pour avoir de l\'avance',
|
||||||
|
() {
|
||||||
|
final args = liveInputArgs('http://p/live/u/p/1.m3u8', hls: true);
|
||||||
|
final i = args.indexOf('-live_start_index');
|
||||||
|
expect(i, isNot(-1));
|
||||||
|
expect(args[i + 1], '-2');
|
||||||
|
// Option de démuxeur : doit précéder -i.
|
||||||
|
expect(i, lessThan(args.indexOf('-i')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('.ts : garde la reconnexion en fin de flux (coupures du panneau)', () {
|
||||||
|
final args = liveInputArgs('http://p/live/u/p/1.ts', hls: false);
|
||||||
|
expect(args, contains('-reconnect_at_eof'));
|
||||||
|
expect(args, isNot(contains('-live_start_index')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('l\'URL est la dernière entrée, juste après -i', () {
|
||||||
|
final args = liveInputArgs('http://p/x.m3u8', hls: true);
|
||||||
|
expect(args.last, 'http://p/x.m3u8');
|
||||||
|
expect(args[args.length - 2], '-i');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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')));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:test/test.dart';
|
import 'package:test/test.dart';
|
||||||
|
import '../models/playlist_config.dart';
|
||||||
import '../services/upstream_slots.dart';
|
import '../services/upstream_slots.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
@@ -53,12 +56,81 @@ void main() {
|
|||||||
expect(slots.countFor('acc'), 1);
|
expect(slots.countFor('acc'), 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('un flux encore regardé n\'est jamais coupé (pas de ping-pong entre deux lecteurs)', () {
|
||||||
|
slots.register(
|
||||||
|
id: 'watched',
|
||||||
|
account: 'acc',
|
||||||
|
release: () => released.add('watched'),
|
||||||
|
isActive: () => true,
|
||||||
|
);
|
||||||
|
expect(slots.makeRoom('acc', max: 1, keep: 'new'), isEmpty);
|
||||||
|
expect(released, isEmpty);
|
||||||
|
expect(slots.countFor('acc'), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('seuls les orphelins partent, même plus récents qu\'un flux regardé', () {
|
||||||
|
slots.register(
|
||||||
|
id: 'watched',
|
||||||
|
account: 'acc',
|
||||||
|
release: () => released.add('watched'),
|
||||||
|
isActive: () => true,
|
||||||
|
);
|
||||||
|
add('orphan', 'acc');
|
||||||
|
expect(slots.makeRoom('acc', max: 1, keep: 'new'), ['orphan']);
|
||||||
|
expect(released, ['orphan']);
|
||||||
|
});
|
||||||
|
|
||||||
test('quota inconnu ou nul : rien n\'est coupé', () {
|
test('quota inconnu ou nul : rien n\'est coupé', () {
|
||||||
add('a', 'acc');
|
add('a', 'acc');
|
||||||
expect(slots.makeRoom('acc', max: 0, keep: 'new'), isEmpty);
|
expect(slots.makeRoom('acc', max: 0, keep: 'new'), isEmpty);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('AccountLimits', () {
|
||||||
|
PlaylistConfig playlist() => PlaylistConfig(
|
||||||
|
id: 'p',
|
||||||
|
name: 'n',
|
||||||
|
dns: 'http://panel.test',
|
||||||
|
username: 'u',
|
||||||
|
password: 'x',
|
||||||
|
createdAt: DateTime(2026),
|
||||||
|
);
|
||||||
|
|
||||||
|
test('ne fait jamais attendre un flux : repli immédiat, valeur réelle ensuite', () async {
|
||||||
|
final pending = Completer<int?>();
|
||||||
|
final limits = AccountLimits(fetch: (_) => pending.future);
|
||||||
|
|
||||||
|
// Le panneau répond lentement (4 s mesurées en prod) : le premier flux
|
||||||
|
// part tout de suite sur la valeur de repli.
|
||||||
|
expect(await limits.maxFor(playlist()), AccountLimits.fallback);
|
||||||
|
|
||||||
|
pending.complete(3);
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
expect(await limits.maxFor(playlist()), 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('valeur périmée servie pendant le rafraîchissement', () async {
|
||||||
|
var calls = 0;
|
||||||
|
final limits = AccountLimits(
|
||||||
|
ttl: Duration.zero,
|
||||||
|
fetch: (_) async => ++calls == 1 ? 2 : 5,
|
||||||
|
);
|
||||||
|
await limits.maxFor(playlist());
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
// Périmée (ttl nul) : la valeur connue sort sans attendre la nouvelle.
|
||||||
|
expect(await limits.maxFor(playlist()), 2);
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
expect(await limits.maxFor(playlist()), 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('panneau en échec : repli, sans exception', () async {
|
||||||
|
final limits = AccountLimits(fetch: (_) async => throw Exception('down'));
|
||||||
|
expect(await limits.maxFor(playlist()), AccountLimits.fallback);
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
expect(await limits.maxFor(playlist()), AccountLimits.fallback);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
group('parseMaxConnections', () {
|
group('parseMaxConnections', () {
|
||||||
test('lit la valeur texte renvoyée par player_api', () {
|
test('lit la valeur texte renvoyée par player_api', () {
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:http/testing.dart';
|
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() {
|
void main() {
|
||||||
final service = XmltvEpgService(sourceUrls: const []);
|
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', () {
|
group('parseXmltvDate', () {
|
||||||
test('applique le décalage horaire annoncé', () {
|
test('applique le décalage horaire annoncé', () {
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ services:
|
|||||||
- MAX_CONCURRENT_RECORDINGS=2
|
- MAX_CONCURRENT_RECORDINGS=2
|
||||||
# Source XMLTV de repli, utilisée seulement quand le panneau ne rend
|
# Source XMLTV de repli, utilisée seulement quand le panneau ne rend
|
||||||
# aucun programme actuel. Vider la variable coupe tout appel sortant.
|
# 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
|
- NVIDIA_GPU=true
|
||||||
volumes:
|
volumes:
|
||||||
- xtremflow-data:/app/data
|
- xtremflow-data:/app/data
|
||||||
|
|||||||
+1
-1
@@ -27,7 +27,7 @@ services:
|
|||||||
# aucun programme couvrant l'instant présent. Vider la variable coupe
|
# aucun programme couvrant l'instant présent. Vider la variable coupe
|
||||||
# tout appel sortant : l'EPG se limite alors au panneau de l'abonné.
|
# tout appel sortant : l'EPG se limite alors au panneau de l'abonné.
|
||||||
# Plusieurs dumps se séparent par des virgules.
|
# 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
|
# Accélération GPU NVIDIA pour le transcodage (NVENC) : true/false
|
||||||
- NVIDIA_GPU=${NVIDIA_GPU:-false}
|
- NVIDIA_GPU=${NVIDIA_GPU:-false}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
Reference in new issue
Block a user