mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-24 07:28:05 +02:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
775caad0ee | ||
|
|
4d00409de8 |
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 |
|
||||||
|
|||||||
+80
-129
@@ -9,6 +9,7 @@ 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
|
||||||
@@ -440,46 +441,6 @@ 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,
|
||||||
@@ -497,14 +458,18 @@ 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('[Live HLS] Starting $sessionId');
|
print(
|
||||||
|
'[Live HLS] Starting $sessionId: ${LogRedactor.redactUrl(targetUrl)}',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<_SessionAttempt> run({required bool hls}) => _runWithGpuFallback(
|
final result = await _runWithGpuFallback(
|
||||||
id: sessionId,
|
id: sessionId,
|
||||||
isLive: true,
|
isLive: true,
|
||||||
upstream: playlist,
|
upstream: playlist,
|
||||||
@@ -512,10 +477,19 @@ Handler createLiveStreamHandler(
|
|||||||
buildArgs: (gpu) => [
|
buildArgs: (gpu) => [
|
||||||
'-hide_banner', '-loglevel', 'warning',
|
'-hide_banner', '-loglevel', 'warning',
|
||||||
if (gpu) ...['-hwaccel', 'cuda'],
|
if (gpu) ...['-hwaccel', 'cuda'],
|
||||||
...liveInputArgs(
|
'-headers', 'User-Agent: VLC/3.0.18 LibVLC/3.0.18\r\n',
|
||||||
_liveSourceUrl(playlist, streamId, hls: hls),
|
'-reconnect', '1', '-reconnect_streamed', '1',
|
||||||
hls: hls,
|
'-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 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
|
||||||
@@ -533,15 +507,6 @@ 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);
|
||||||
@@ -589,93 +554,79 @@ 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');
|
||||||
|
|
||||||
print('[Live Turbo] $streamId');
|
final targetUrl =
|
||||||
|
'${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;
|
||||||
|
try {
|
||||||
|
process = await Process.start(_getFFmpegPath(), [
|
||||||
|
'-hide_banner', '-loglevel', 'warning',
|
||||||
|
'-headers', 'User-Agent: VLC/3.0.18 LibVLC/3.0.18\r\n',
|
||||||
|
'-reconnect', '1', '-reconnect_streamed', '1',
|
||||||
|
'-reconnect_at_eof', '1',
|
||||||
|
'-reconnect_delay_max', '10',
|
||||||
|
'-rw_timeout', '30000000',
|
||||||
|
// Démarrage rapide : ne pas bufferiser l'analyse, sonde réduite.
|
||||||
|
'-fflags', 'nobuffer',
|
||||||
|
'-flags', 'low_delay',
|
||||||
|
'-probesize', '1000000',
|
||||||
|
'-analyzeduration', '1000000',
|
||||||
|
'-i', targetUrl,
|
||||||
|
'-c:v', 'copy',
|
||||||
|
'-c:a', 'aac', '-b:a', '160k', '-ac', '2', '-ar', '48000',
|
||||||
|
'-af', 'aresample=async=1',
|
||||||
|
// Pas de délai de mux : les paquets partent dès qu'ils existent.
|
||||||
|
'-muxdelay', '0', '-muxpreload', '0',
|
||||||
|
'-f', 'mpegts', 'pipe:1',
|
||||||
|
]);
|
||||||
|
} on ProcessException catch (e) {
|
||||||
|
// Serveur saturé (plus de processus ou de mémoire) : un 503 que le
|
||||||
|
// lecteur sait traiter, plutôt qu'une exception qui remonte en 500.
|
||||||
|
// `ProcessException` liste les arguments, donc l'URL `-i` du panneau.
|
||||||
|
print('[Live Turbo] $streamId : FFmpeg n\'a pas démarré '
|
||||||
|
'(${LogRedactor.redactUrl('$e')})');
|
||||||
|
return Response(503, body: 'FFmpeg start failed');
|
||||||
|
}
|
||||||
|
|
||||||
// Dernier octet remis au client : un lecteur en vie en reçoit à chaque
|
// Dernier octet remis au client : un lecteur en vie en reçoit à chaque
|
||||||
// rafale ; un client parti (zap mal détecté derrière le proxy) n'en
|
// rafale ; un client parti (zap mal détecté derrière le proxy) n'en
|
||||||
// reçoit plus, la contre-pression bloquant FFmpeg.
|
// reçoit plus, la contre-pression bloquant FFmpeg.
|
||||||
var lastDelivered = DateTime.now();
|
var lastDelivered = DateTime.now();
|
||||||
|
_registerUpstream(
|
||||||
|
playlist,
|
||||||
|
upstreamId,
|
||||||
|
process,
|
||||||
|
release: () => process.kill(ProcessSignal.sigterm),
|
||||||
|
lastActivity: () => lastDelivered,
|
||||||
|
);
|
||||||
|
|
||||||
Future<Process?> start({required bool hls}) async {
|
// Journaliser les erreurs FFmpeg (redactées) sans bloquer le flux.
|
||||||
final Process process;
|
process.stderr.transform(const SystemEncoding().decoder).listen((line) {
|
||||||
try {
|
final trimmed = line.trim();
|
||||||
process = await Process.start(_getFFmpegPath(), [
|
if (trimmed.isNotEmpty) {
|
||||||
'-hide_banner', '-loglevel', 'warning',
|
print('[Live Turbo] $streamId ffmpeg: '
|
||||||
...liveInputArgs(
|
'${LogRedactor.redactUrl(trimmed)}');
|
||||||
_liveSourceUrl(playlist, streamId, hls: hls),
|
|
||||||
hls: hls,
|
|
||||||
),
|
|
||||||
'-flags', 'low_delay',
|
|
||||||
'-c:v', 'copy',
|
|
||||||
'-c:a', 'aac', '-b:a', '160k', '-ac', '2', '-ar', '48000',
|
|
||||||
'-af', 'aresample=async=1',
|
|
||||||
// Pas de délai de mux : les paquets partent dès qu'ils existent.
|
|
||||||
'-muxdelay', '0', '-muxpreload', '0',
|
|
||||||
'-f', 'mpegts', 'pipe:1',
|
|
||||||
]);
|
|
||||||
} on ProcessException catch (e) {
|
|
||||||
// `ProcessException` liste les arguments, donc l'URL `-i` du panneau.
|
|
||||||
print('[Live Turbo] $streamId : FFmpeg n\'a pas démarré '
|
|
||||||
'(${LogRedactor.redactUrl('$e')})');
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
_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;
|
|
||||||
}
|
|
||||||
|
|
||||||
final first = await start(hls: true);
|
// Relayer stdout vers le client en gardant la contre-pression, et tuer
|
||||||
// Serveur saturé (plus de processus ou de mémoire) : un 503 que le
|
// FFmpeg dès que le client zappe ou ferme l'onglet (sinon les processus
|
||||||
// lecteur sait traiter, plutôt qu'une exception qui remonte en 500.
|
// s'accumulent à chaque zap).
|
||||||
if (first == null) return Response(503, body: 'FFmpeg start failed');
|
final body = pipeWithBackpressure(
|
||||||
|
process.stdout,
|
||||||
// Relais vers le client. Un générateur `async*` garde la contre-pression
|
onStop: () => process.kill(ProcessSignal.sigterm),
|
||||||
// (`yield` attend tant que le client ne lit pas) et son `finally` tue
|
).map((chunk) {
|
||||||
// FFmpeg dès que le client zappe ou ferme l'onglet.
|
lastDelivered = DateTime.now();
|
||||||
//
|
return chunk;
|
||||||
// Si le HLS du panneau n'a livré aucun octet (format refusé pour cette
|
});
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final body = relay();
|
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
200,
|
200,
|
||||||
|
|||||||
+3
-1
@@ -132,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)
|
||||||
|
|||||||
@@ -82,9 +82,37 @@ class XmltvEpgService {
|
|||||||
String? displayName,
|
String? displayName,
|
||||||
}) async {
|
}) async {
|
||||||
await ensureFresh();
|
await ensureFresh();
|
||||||
|
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 = [
|
final keys = [
|
||||||
normalizeKey(channelId),
|
normalizeKey(channelId),
|
||||||
if (displayName != null) ...nameKeys(displayName),
|
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) {
|
for (final key in keys) {
|
||||||
if (key.isEmpty) continue;
|
if (key.isEmpty) continue;
|
||||||
@@ -94,6 +122,44 @@ 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] `…
|
/// Préfixe pays des noms de panneau : `FR - `, `FR: `, `|FR| `, `[FR] `…
|
||||||
static final _countryPrefix = RegExp(
|
static final _countryPrefix = RegExp(
|
||||||
r'^\s*[\|\[\(]?\s*([A-Za-z]{2,3})\s*[\|\]\)]?\s*[-:|]\s*|^\s*[\|\[\(]\s*([A-Za-z]{2,3})\s*[\|\]\)]\s*',
|
r'^\s*[\|\[\(]?\s*([A-Za-z]{2,3})\s*[\|\]\)]?\s*[-:|]\s*|^\s*[\|\[\(]\s*([A-Za-z]{2,3})\s*[\|\]\)]\s*',
|
||||||
@@ -119,8 +185,24 @@ class XmltvEpgService {
|
|||||||
/// de TF1.
|
/// de TF1.
|
||||||
static List<String> nameKeys(String name) {
|
static List<String> nameKeys(String name) {
|
||||||
final keys = <String>[normalizeKey(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();
|
||||||
|
}
|
||||||
|
|
||||||
var cleaned = name;
|
/// 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;
|
String? country;
|
||||||
final prefix = _countryPrefix.firstMatch(cleaned);
|
final prefix = _countryPrefix.firstMatch(cleaned);
|
||||||
if (prefix != null) {
|
if (prefix != null) {
|
||||||
@@ -129,19 +211,19 @@ class XmltvEpgService {
|
|||||||
}
|
}
|
||||||
// Symboles décoratifs (◉, ᴴᴰ, ★…) : tout ce qui n'est ni lettre, ni
|
// Symboles décoratifs (◉, ᴴᴰ, ★…) : tout ce qui n'est ni lettre, ni
|
||||||
// chiffre, ni ponctuation utile.
|
// chiffre, ni ponctuation utile.
|
||||||
cleaned = cleaned.replaceAll(RegExp(r'[^\p{L}\p{N}\s+.&\-()]', unicode: true), ' ').trim();
|
cleaned = cleaned
|
||||||
|
.replaceAll(RegExp(r'[^\p{L}\p{N}\s+.&\-()]', unicode: true), ' ')
|
||||||
|
.trim();
|
||||||
String previous;
|
String previous;
|
||||||
do {
|
do {
|
||||||
previous = cleaned;
|
previous = cleaned;
|
||||||
cleaned = cleaned.replaceFirst(_qualitySuffix, '').trim();
|
cleaned = cleaned.replaceFirst(_qualitySuffix, '').trim();
|
||||||
} while (cleaned != previous && cleaned.isNotEmpty);
|
} while (cleaned != previous && cleaned.isNotEmpty);
|
||||||
|
cleaned = cleaned.replaceFirstMapped(
|
||||||
final base = normalizeKey(cleaned);
|
_franceAbbrev,
|
||||||
if (base.isNotEmpty) {
|
(m) => 'France ${m[1]}',
|
||||||
keys.add(base);
|
);
|
||||||
keys.add('$base${country ?? 'fr'}');
|
return (name: cleaned, country: country);
|
||||||
}
|
|
||||||
return keys.where((k) => k.isNotEmpty).toSet().toList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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
|
||||||
@@ -325,6 +407,13 @@ class XmltvEpgService {
|
|||||||
aliases.putIfAbsent(name, () => id);
|
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':
|
||||||
if (programmeChannel != null &&
|
if (programmeChannel != null &&
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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';
|
||||||
@@ -84,6 +85,52 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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', () {
|
group('programmesFor par nom', () {
|
||||||
test('trouve le guide d\'une chaîne sans epg_channel_id par son nom nettoyé',
|
test('trouve le guide d\'une chaîne sans epg_channel_id par son nom nettoyé',
|
||||||
() async {
|
() async {
|
||||||
|
|||||||
@@ -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