mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-22 06:28:04 +02:00
Serveur réécrit en Rust
Le serveur Dart (bin/) est remplacé par un serveur Rust (server/) : tokio + axum, reqwest/rustls, SQLite embarqué via rusqlite. Même API, mêmes routes et mêmes réponses : le front Flutter ne change pas. La base de production est reprise telle quelle : schéma, migrations et formats de dates (DateTime.toIso8601String de Dart) sont reproduits à l'identique, les sessions et hachages existants restent valides. Comportement conservé : cascade de routage, anti-SSRF, logs masqués, rate limits, transcodage HLS/turbo.ts avec repli GPU, quotas fournisseur, enregistrements (reprise, relances, fusion), season passes, EPG, logos de repli, arrêt gracieux sur SIGTERM. Tests Dart portés (132 tests Rust) ; la CI exécute fmt, test et clippy -D warnings, l'image Docker compile le serveur dans un étage rust:1.98.1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f82c70ae05
commit
3ea6fa0ae3
109 files changed
+14008
-11047
No files matched your search
@@ -1,81 +0,0 @@
|
||||
import 'package:shelf/shelf.dart';
|
||||
|
||||
/// Réponse pour une image indisponible.
|
||||
///
|
||||
/// Statut 410 et non 404 : la `Cascade` du serveur retombe sur le handler
|
||||
/// suivant pour tout 404 et renvoie finalement celui du handler statique, sans
|
||||
/// notre `cache-control`. Un 410 s'arrête ici.
|
||||
///
|
||||
/// Un statut d'erreur plutôt qu'une image vide : le client affiche alors son
|
||||
/// propre repli (icône, initiale) au lieu d'une tuile blanche. Le `max-age`
|
||||
/// est essentiel : sans lui le navigateur redemande le logo à chaque
|
||||
/// affichage de la grille et le flot de requêtes mortes reprend aussitôt.
|
||||
Response missingImageResponse() => Response(
|
||||
410,
|
||||
headers: {
|
||||
'cache-control': 'public, max-age=600',
|
||||
'access-control-allow-origin': '*',
|
||||
},
|
||||
);
|
||||
|
||||
/// Mémoire courte des hôtes d'images injoignables.
|
||||
///
|
||||
/// POURQUOI : les URL de picons pointent chez l'hébergeur du revendeur, pas
|
||||
/// sur le panneau. Quand cette machine tombe (maintenance, DNS, quota), elle
|
||||
/// répond 5xx en quelques millisecondes, le navigateur réessaie à chaque
|
||||
/// rendu de la grille et le proxy relaie des centaines de requêtes mortes par
|
||||
/// seconde. Le serveur tournant sur un unique isolate Dart, ce flot passe
|
||||
/// devant les paquets vidéo dans la boucle d'événements : l'image saccade
|
||||
/// pendant qu'on s'acharne sur un hôte qu'on sait hors service.
|
||||
///
|
||||
/// On coupe donc court : au bout de [threshold] échecs consécutifs, l'hôte
|
||||
/// est considéré mort pendant [ttl] et les requêtes suivantes sont servies
|
||||
/// localement, sans appel sortant.
|
||||
class AssetFailureCache {
|
||||
AssetFailureCache({
|
||||
this.threshold = 3,
|
||||
this.ttl = const Duration(minutes: 5),
|
||||
DateTime Function()? clock,
|
||||
}) : _clock = clock ?? DateTime.now;
|
||||
|
||||
/// Nombre d'échecs consécutifs avant de couper un hôte. Un 5xx passager ne
|
||||
/// doit pas priver l'utilisateur de ses logos.
|
||||
final int threshold;
|
||||
|
||||
/// Durée de la coupure. Assez courte pour qu'un hôte réparé revienne seul.
|
||||
final Duration ttl;
|
||||
|
||||
final DateTime Function() _clock;
|
||||
|
||||
final Map<String, int> _failures = {};
|
||||
final Map<String, DateTime> _downUntil = {};
|
||||
|
||||
/// L'hôte est-il réputé hors service en ce moment ?
|
||||
bool isDown(String host) {
|
||||
final key = host.toLowerCase();
|
||||
final until = _downUntil[key];
|
||||
if (until == null) return false;
|
||||
if (_clock().isBefore(until)) return true;
|
||||
|
||||
// Coupure expirée : on repart d'une ardoise vierge pour laisser une
|
||||
// vraie chance au prochain appel.
|
||||
_downUntil.remove(key);
|
||||
_failures.remove(key);
|
||||
return false;
|
||||
}
|
||||
|
||||
void recordFailure(String host) {
|
||||
final key = host.toLowerCase();
|
||||
final count = (_failures[key] ?? 0) + 1;
|
||||
_failures[key] = count;
|
||||
if (count >= threshold) {
|
||||
_downUntil[key] = _clock().add(ttl);
|
||||
}
|
||||
}
|
||||
|
||||
void recordSuccess(String host) {
|
||||
final key = host.toLowerCase();
|
||||
_failures.remove(key);
|
||||
_downUntil.remove(key);
|
||||
}
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'package:shelf/shelf.dart';
|
||||
import 'package:shelf_router/shelf_router.dart';
|
||||
import '../database/database.dart';
|
||||
|
||||
class AuthHandler {
|
||||
final AppDatabase db;
|
||||
|
||||
AuthHandler(this.db);
|
||||
|
||||
Router get router {
|
||||
final router = Router();
|
||||
|
||||
router.post('/login', _login);
|
||||
router.post('/logout', _logout);
|
||||
router.get('/me', _getCurrentUser);
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
/// POST /api/auth/login
|
||||
Future<Response> _login(Request request) async {
|
||||
try {
|
||||
print('[Auth] Login attempt received');
|
||||
final bodyStr = await request.readAsString();
|
||||
|
||||
final payload = jsonDecode(bodyStr) as Map<String, dynamic>;
|
||||
final username = payload['username'] as String?;
|
||||
final password = payload['password'] as String?;
|
||||
|
||||
if (username == null || password == null) {
|
||||
print('[Auth] Missing username or password');
|
||||
return Response(400, body: jsonEncode({
|
||||
'success': false,
|
||||
'error': 'Username and password are required',
|
||||
}), headers: {'Content-Type': 'application/json'},);
|
||||
}
|
||||
|
||||
print('[Auth] Verifying credentials for user: $username');
|
||||
// Verify credentials
|
||||
final user = db.verifyCredentials(username, password);
|
||||
if (user == null) {
|
||||
print('[Auth] Invalid credentials for user: $username');
|
||||
return Response(401, body: jsonEncode({
|
||||
'success': false,
|
||||
'error': 'Invalid credentials',
|
||||
}), headers: {'Content-Type': 'application/json'},);
|
||||
}
|
||||
|
||||
print('[Auth] User verified, creating session for userId: ${user.id}');
|
||||
// Create session
|
||||
final session = db.createSession(user.id);
|
||||
|
||||
// HttpOnly session cookie so same-origin media requests (hls.js inside
|
||||
// the player iframe cannot send Authorization headers) are authenticated.
|
||||
final maxAge = session.expiresAt.difference(DateTime.now()).inSeconds;
|
||||
return Response.ok(jsonEncode({
|
||||
'success': true,
|
||||
'user': user.toJson(),
|
||||
'token': session.token,
|
||||
}), headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Set-Cookie':
|
||||
'session=${session.token}; HttpOnly; SameSite=Lax; Path=/; Max-Age=$maxAge',
|
||||
},);
|
||||
} catch (e, stackTrace) {
|
||||
print('[Auth] ERROR during login: $e');
|
||||
print('[Auth] Stack trace: $stackTrace');
|
||||
return Response.internalServerError(
|
||||
body: jsonEncode({'success': false, 'error': e.toString()}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/auth/logout
|
||||
Future<Response> _logout(Request request) async {
|
||||
try {
|
||||
final token = _extractToken(request);
|
||||
if (token == null) {
|
||||
return Response(401, body: jsonEncode({
|
||||
'success': false,
|
||||
'error': 'Unauthorized',
|
||||
}), headers: {'Content-Type': 'application/json'},);
|
||||
}
|
||||
|
||||
db.deleteSession(token);
|
||||
|
||||
return Response.ok(jsonEncode({
|
||||
'success': true,
|
||||
}), headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Set-Cookie': 'session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0',
|
||||
},);
|
||||
} catch (e) {
|
||||
return Response.internalServerError(
|
||||
body: jsonEncode({'success': false, 'error': e.toString()}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/auth/me
|
||||
Future<Response> _getCurrentUser(Request request) async {
|
||||
try {
|
||||
final token = _extractToken(request);
|
||||
if (token == null) {
|
||||
return Response(401, body: jsonEncode({
|
||||
'success': false,
|
||||
'error': 'Unauthorized',
|
||||
}), headers: {'Content-Type': 'application/json'},);
|
||||
}
|
||||
|
||||
final session = db.findSessionByToken(token);
|
||||
if (session == null) {
|
||||
return Response(401, body: jsonEncode({
|
||||
'success': false,
|
||||
'error': 'Invalid or expired session',
|
||||
}), headers: {'Content-Type': 'application/json'},);
|
||||
}
|
||||
|
||||
final user = db.findUserById(session.userId);
|
||||
if (user == null) {
|
||||
return Response(404, body: jsonEncode({
|
||||
'success': false,
|
||||
'error': 'User not found',
|
||||
}), headers: {'Content-Type': 'application/json'},);
|
||||
}
|
||||
|
||||
return Response.ok(jsonEncode({
|
||||
'user': user.toJson(),
|
||||
}), headers: {'Content-Type': 'application/json'},);
|
||||
} catch (e) {
|
||||
return Response.internalServerError(
|
||||
body: jsonEncode({'success': false, 'error': e.toString()}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract token from Authorization header or cookie
|
||||
String? _extractToken(Request request) {
|
||||
// Try Authorization header first
|
||||
final authHeader = request.headers['authorization'];
|
||||
if (authHeader != null && authHeader.startsWith('Bearer ')) {
|
||||
return authHeader.substring(7);
|
||||
}
|
||||
|
||||
// Try cookie
|
||||
final cookie = request.headers['cookie'];
|
||||
if (cookie != null) {
|
||||
final parts = cookie.split(';');
|
||||
for (final part in parts) {
|
||||
final trimmed = part.trim();
|
||||
if (trimmed.startsWith('session=')) {
|
||||
return trimmed.substring(8);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,473 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:isolate';
|
||||
import 'package:shelf/shelf.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../models/playlist_config.dart';
|
||||
import '../services/xmltv_epg_service.dart';
|
||||
import '../utils/log_redactor.dart';
|
||||
|
||||
/// API EPG — proxy vers Xtream avec cache 30 minutes
|
||||
/// GET /api/epg/<channel_id>?days=1
|
||||
///
|
||||
/// Le panneau de l'abonné reste la source de référence, mais par son dump
|
||||
/// `xmltv.php` d'abord : une requête couvre toutes les chaînes, là où
|
||||
/// `player_api` en réclame une par chaîne à plusieurs secondes pièce. Vient
|
||||
/// ensuite l'interrogation chaîne par chaîne, puis une source XMLTV externe
|
||||
/// en dernier recours — cas des revendeurs dont le guide est figé depuis
|
||||
/// plusieurs jours.
|
||||
class EpgApi {
|
||||
final Future<PlaylistConfig?> Function(Request) _getPlaylist;
|
||||
|
||||
/// Repli XMLTV. `null` quand aucune source n'est configurée : l'API se
|
||||
/// comporte alors exactement comme avant, sans le moindre appel sortant.
|
||||
final XmltvEpgService? _xmltv;
|
||||
|
||||
/// Cache mémoire : « dns|username|channelId » → {data, expiresAt}.
|
||||
///
|
||||
/// La clé inclut le compte : les `stream_id` sont propres à chaque panneau,
|
||||
/// deux playlists distinctes peuvent parfaitement partager le même
|
||||
/// identifiant et se servaient alors le guide l'une de l'autre.
|
||||
final Map<String, _CacheEntry> _cache = {};
|
||||
|
||||
/// Correspondance `stream_id` → identifiant EPG et nom, par compte.
|
||||
/// Nécessaire au repli : le dump XMLTV indexe par identifiant de chaîne,
|
||||
/// pas par `stream_id` propre au panneau.
|
||||
final Map<String, _ChannelMap> _channelMaps = {};
|
||||
|
||||
/// Client HTTP des appels `player_api`. Injectable pour les tests.
|
||||
final http.Client _http;
|
||||
|
||||
/// Fabrique de la source XMLTV du panneau, une par compte.
|
||||
final XmltvEpgService Function(PlaylistConfig) _panelXmltvBuilder;
|
||||
|
||||
/// Index XMLTV du panneau, par compte.
|
||||
final Map<String, XmltvEpgService> _panelXmltv = {};
|
||||
|
||||
EpgApi(
|
||||
this._getPlaylist, {
|
||||
XmltvEpgService? xmltv,
|
||||
http.Client? httpClient,
|
||||
XmltvEpgService Function(PlaylistConfig)? panelXmltvBuilder,
|
||||
}) : _xmltv = xmltv,
|
||||
_http = httpClient ?? http.Client(),
|
||||
_panelXmltvBuilder = panelXmltvBuilder ?? _defaultPanelXmltv;
|
||||
|
||||
/// Dump XMLTV servi par le panneau lui-même.
|
||||
///
|
||||
/// POURQUOI : `player_api.php?action=get_simple_data_table` répond en
|
||||
/// plusieurs secondes chez beaucoup de revendeurs, et il faut un appel par
|
||||
/// chaîne — afficher une grille de trente chaînes demandait donc plus d'une
|
||||
/// minute. `xmltv.php` renvoie le guide de toutes les chaînes en une seule
|
||||
/// requête, réutilisée ensuite pendant des heures. C'est ce que font les
|
||||
/// clients IPTV rapides.
|
||||
static XmltvEpgService _defaultPanelXmltv(PlaylistConfig playlist) {
|
||||
final user = Uri.encodeQueryComponent(playlist.username);
|
||||
final password = Uri.encodeQueryComponent(playlist.password);
|
||||
return XmltvEpgService(
|
||||
sourceUrls: ['${playlist.dns}/xmltv.php?username=$user&password=$password'],
|
||||
refreshInterval: const Duration(hours: 3),
|
||||
// Le guide est facultatif : on ne fait pas patienter l'utilisateur
|
||||
// plusieurs minutes sur un panneau qui traîne.
|
||||
downloadTimeout: const Duration(seconds: 90),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Response> handleGetEpg(Request request, String channelId) async {
|
||||
final playlist = await _getPlaylist(request);
|
||||
if (playlist == null) {
|
||||
return Response.forbidden(
|
||||
json.encode({'error': 'Playlist non trouvée'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
|
||||
final cacheKey = '${playlist.dns}|${playlist.username}|$channelId';
|
||||
final cached = _cache[cacheKey];
|
||||
if (cached != null && DateTime.now().isBefore(cached.expiresAt)) {
|
||||
return Response.ok(
|
||||
cached.data,
|
||||
headers: {'Content-Type': 'application/json', 'X-Cache': 'HIT'},
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Sources par ordre de préférence. On s'arrête à la première qui
|
||||
// contient un programme en cours ou à venir ; à défaut, la première
|
||||
// non vide sert de repli.
|
||||
//
|
||||
// Le dump du panneau passe devant : une requête couvre toutes les
|
||||
// chaînes, là où `player_api` en demande une par chaîne, à plusieurs
|
||||
// secondes pièce.
|
||||
//
|
||||
// Le dump externe passe ensuite AVANT l'interrogation chaîne par
|
||||
// chaîne : il est déjà indexé en mémoire (réponse instantanée), alors
|
||||
// que `player_api` coûte plusieurs secondes par chaîne et échouait en
|
||||
// prod — ce qui laissait toute la grille sans guide.
|
||||
final sources = <(String, Future<List<Map<String, dynamic>>> Function())>[
|
||||
('panel-xmltv', () => _panelProgrammes(playlist, channelId)),
|
||||
('xmltv', () => _xmltvProgrammes(playlist, channelId)),
|
||||
('xtream', () => _xtreamProgrammes(playlist, channelId)),
|
||||
];
|
||||
|
||||
var epgData = <String, dynamic>{
|
||||
'channel_id': channelId,
|
||||
'programmes': <Map<String, dynamic>>[],
|
||||
};
|
||||
var source = 'xtream';
|
||||
var found = false;
|
||||
|
||||
for (final (name, fetch) in sources) {
|
||||
final programmes = await fetch();
|
||||
if (programmes.isEmpty) continue;
|
||||
|
||||
final candidate = <String, dynamic>{
|
||||
'channel_id': channelId,
|
||||
'programmes': programmes,
|
||||
};
|
||||
if (!found) {
|
||||
// Meilleur repli connu à ce stade, même si le guide est périmé.
|
||||
epgData = candidate;
|
||||
source = name;
|
||||
found = true;
|
||||
}
|
||||
if (_hasCurrentProgramme(candidate)) {
|
||||
epgData = candidate;
|
||||
source = name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
final jsonStr = json.encode(epgData);
|
||||
|
||||
// 30 min pour un guide trouvé ; 5 min seulement pour un guide vide,
|
||||
// qui vient souvent d'une source momentanément indisponible et ne
|
||||
// doit pas priver la chaîne de guide pendant une demi-heure.
|
||||
_cache[cacheKey] = _CacheEntry(
|
||||
data: jsonStr,
|
||||
expiresAt: DateTime.now()
|
||||
.add(Duration(minutes: found ? 30 : 5)),
|
||||
);
|
||||
|
||||
return Response.ok(
|
||||
jsonStr,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Cache': 'MISS',
|
||||
'X-Epg-Source': source,
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
// Détail redacté en log uniquement : une ClientException Dart contient
|
||||
// l'URI amont, credentials Xtream inclus.
|
||||
print('[EpgApi] Erreur EPG: ${LogRedactor.redactUrl('$e')}');
|
||||
return Response.internalServerError(
|
||||
body: json.encode({'error': 'Erreur lors de la récupération EPG'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Programmes tirés du dump XMLTV du panneau, ou liste vide.
|
||||
Future<List<Map<String, dynamic>>> _panelProgrammes(
|
||||
PlaylistConfig playlist,
|
||||
String channelId,
|
||||
) async {
|
||||
final key = '${playlist.dns}|${playlist.username}';
|
||||
final service =
|
||||
_panelXmltv.putIfAbsent(key, () => _panelXmltvBuilder(playlist));
|
||||
return _fromXmltv(service, playlist, channelId);
|
||||
}
|
||||
|
||||
/// Programmes obtenus en interrogeant le panneau chaîne par chaîne.
|
||||
///
|
||||
/// Chemin lent : deux actions possibles, plusieurs secondes chacune chez la
|
||||
/// plupart des revendeurs. Il ne sert que lorsque le dump XMLTV du panneau
|
||||
/// est absent ou ne couvre pas la chaîne.
|
||||
Future<List<Map<String, dynamic>>> _xtreamProgrammes(
|
||||
PlaylistConfig playlist,
|
||||
String channelId,
|
||||
) async {
|
||||
// `get_simple_data_table` d'abord : c'est la seule action réellement
|
||||
// universelle. `get_epg` n'existe pas sur beaucoup de panneaux — au lieu
|
||||
// d'une erreur, ils renvoient poliment le payload d'authentification,
|
||||
// sans champ `epg_listings`, ce qui produisait un guide vide impossible
|
||||
// à distinguer d'une chaîne sans programme.
|
||||
const actions = ['get_simple_data_table', 'get_short_epg'];
|
||||
|
||||
for (final action in actions) {
|
||||
final url =
|
||||
'${playlist.dns}/player_api.php?username=${playlist.username}'
|
||||
'&password=${playlist.password}'
|
||||
'&action=$action&stream_id=$channelId';
|
||||
|
||||
final http.Response response;
|
||||
try {
|
||||
response = await _http
|
||||
.get(Uri.parse(url))
|
||||
.timeout(const Duration(seconds: 20));
|
||||
} catch (e) {
|
||||
// Délai dépassé, connexion coupée… : une source parmi d'autres, pas
|
||||
// une raison de répondre 500 (ce qui privait la chaîne des sources
|
||||
// suivantes). L'exception recopie l'URL, identifiants compris.
|
||||
print('[EpgApi] player_api $action indisponible pour $channelId : '
|
||||
'${LogRedactor.redactUrl('$e')}');
|
||||
continue;
|
||||
}
|
||||
if (response.statusCode != 200) continue;
|
||||
|
||||
Map<String, dynamic> parsed;
|
||||
try {
|
||||
parsed = transformEpgData(json.decode(response.body), channelId);
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
final programmes = (parsed['programmes'] as List)
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.toList();
|
||||
if (programmes.isNotEmpty) return programmes;
|
||||
}
|
||||
return const [];
|
||||
}
|
||||
|
||||
/// Le guide contient-il un programme en cours ou à venir ?
|
||||
///
|
||||
/// Un panneau dont l'EPG est figé répond avec des centaines de programmes,
|
||||
/// tous terminés depuis plusieurs jours. Compter les entrées ne suffit donc
|
||||
/// pas à décider si le guide est exploitable.
|
||||
static bool _hasCurrentProgramme(Map<String, dynamic> epgData) {
|
||||
final now = DateTime.now().toUtc();
|
||||
for (final programme in epgData['programmes'] as List) {
|
||||
if (programme is! Map) continue;
|
||||
final end = DateTime.tryParse('${programme['end']}');
|
||||
if (end != null && end.isAfter(now)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Programmes issus du dump XMLTV, ou liste vide si indisponible.
|
||||
Future<List<Map<String, dynamic>>> _xmltvProgrammes(
|
||||
PlaylistConfig playlist,
|
||||
String channelId,
|
||||
) =>
|
||||
_fromXmltv(_xmltv, playlist, channelId);
|
||||
|
||||
/// Programmes d'une chaîne dans un index XMLTV quelconque (panneau ou
|
||||
/// source externe), ou liste vide si l'index ne la connaît pas.
|
||||
Future<List<Map<String, dynamic>>> _fromXmltv(
|
||||
XmltvEpgService? xmltv,
|
||||
PlaylistConfig playlist,
|
||||
String channelId,
|
||||
) async {
|
||||
if (xmltv == null) return const [];
|
||||
|
||||
try {
|
||||
final channels = await _channelMapFor(playlist);
|
||||
final epgId = channels.epgIds[channelId];
|
||||
final name = channels.names[channelId];
|
||||
if (epgId == null && name == null) return const [];
|
||||
|
||||
final programmes =
|
||||
await xmltv.programmesFor(epgId, displayName: name);
|
||||
if (programmes.isEmpty) return const [];
|
||||
|
||||
final now = DateTime.now().toUtc();
|
||||
return programmes
|
||||
.where((p) => p.stop.isAfter(now))
|
||||
.take(64)
|
||||
.map((p) => p.toJson(channelId))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
// L'exception peut recopier l'URL `player_api`/`xmltv.php`, identifiants
|
||||
// compris.
|
||||
print(
|
||||
'[EpgApi] source XMLTV indisponible pour $channelId : '
|
||||
'${LogRedactor.redactUrl('$e')}',
|
||||
);
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
/// `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.
|
||||
///
|
||||
/// Les requêtes simultanées partagent un seul téléchargement : la grille
|
||||
/// demande le guide de 30 chaînes d'un coup, et chacune déclenchait sinon
|
||||
/// son propre `get_live_streams` (7 Mo en prod).
|
||||
Future<_ChannelMap> _channelMapFor(PlaylistConfig playlist) {
|
||||
final key = '${playlist.dns}|${playlist.username}';
|
||||
final cached = _channelMaps[key];
|
||||
if (cached != null && DateTime.now().isBefore(cached.expiresAt)) {
|
||||
return Future.value(cached);
|
||||
}
|
||||
return _channelMapsInFlight[key] ??=
|
||||
_loadChannelMap(playlist, key).whenComplete(() {
|
||||
_channelMapsInFlight.remove(key);
|
||||
});
|
||||
}
|
||||
|
||||
Future<_ChannelMap> _loadChannelMap(
|
||||
PlaylistConfig playlist,
|
||||
String key,
|
||||
) async {
|
||||
final url = '${playlist.dns}/player_api.php'
|
||||
'?username=${playlist.username}&password=${playlist.password}'
|
||||
'&action=get_live_streams';
|
||||
http.Response? response;
|
||||
try {
|
||||
response =
|
||||
await _http.get(Uri.parse(url)).timeout(const Duration(seconds: 90));
|
||||
} catch (e) {
|
||||
print('[EpgApi] table des chaînes indisponible : '
|
||||
'${LogRedactor.redactUrl('$e')}');
|
||||
}
|
||||
|
||||
var epgIds = <String, String>{};
|
||||
var names = <String, String>{};
|
||||
if (response != null && response.statusCode == 200) {
|
||||
// Décodage dans un isolate : 7 Mo de JSON sur la boucle principale la
|
||||
// gelaient, et le relais turbo.ts cessait d'émettre pendant ce temps.
|
||||
// UTF-8 explicite : sans charset annoncé, `response.body` peut décoder
|
||||
// en latin-1 et abîmer les noms (« Chérie », « ◉ »), donc leur
|
||||
// correspondance avec le guide.
|
||||
final body = utf8.decode(response.bodyBytes, allowMalformed: true);
|
||||
try {
|
||||
(epgIds, names) = await Isolate.run(() => _parseChannelTable(body));
|
||||
} catch (e) {
|
||||
print('[EpgApi] table des chaînes illisible : ${e.runtimeType}');
|
||||
}
|
||||
}
|
||||
|
||||
// Une table vide (panneau en échec) n'est gardée que 2 minutes : la
|
||||
// garder 6 h privait TOUTES les chaînes du repli XMLTV pendant 6 h.
|
||||
final map = _ChannelMap(
|
||||
epgIds: epgIds,
|
||||
names: names,
|
||||
expiresAt: DateTime.now().add(
|
||||
names.isEmpty ? const Duration(minutes: 2) : const Duration(hours: 6),
|
||||
),
|
||||
);
|
||||
_channelMaps[key] = map;
|
||||
return map;
|
||||
}
|
||||
|
||||
/// Normalise une réponse Xtream (`epg_listings`) vers le format servi au
|
||||
/// client. Public pour être couvert par `bin/test/epg_api_test.dart`.
|
||||
Map<String, dynamic> transformEpgData(dynamic raw, String channelId) {
|
||||
try {
|
||||
List<dynamic> listings = [];
|
||||
|
||||
if (raw is Map && raw.containsKey('epg_listings')) {
|
||||
listings = raw['epg_listings'] as List<dynamic>? ?? [];
|
||||
} else if (raw is List) {
|
||||
listings = raw;
|
||||
}
|
||||
|
||||
final programmes = listings.map((item) {
|
||||
// Les champs texte `start`/`end` sont exprimés dans le fuseau du
|
||||
// panneau (souvent Europe/Amsterdam), sans indicateur de zone : le
|
||||
// client les relisait comme de l'heure locale et décalait tout le
|
||||
// guide. Les `*_timestamp` sont de l'epoch UTC — on s'en sert dès
|
||||
// qu'ils sont présents et on émet de l'ISO-8601 UTC explicite.
|
||||
final start = _isoUtc(item['start_timestamp']) ??
|
||||
_normalizeDate(item['start'] as String? ?? '');
|
||||
final end = _isoUtc(item['stop_timestamp']) ??
|
||||
_isoUtc(item['end_timestamp']) ??
|
||||
_normalizeDate(
|
||||
(item['stop'] as String?) ?? (item['end'] as String?) ?? '',
|
||||
);
|
||||
|
||||
// Décoder le titre (base64 si nécessaire)
|
||||
String title = item['title'] as String? ?? '';
|
||||
try {
|
||||
if (title.isNotEmpty) {
|
||||
final decoded = utf8.decode(base64Decode(title));
|
||||
if (decoded.isNotEmpty) title = decoded;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
String description = item['description'] as String? ?? '';
|
||||
try {
|
||||
if (description.isNotEmpty) {
|
||||
final decoded = utf8.decode(base64Decode(description));
|
||||
if (decoded.isNotEmpty) description = decoded;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
return {
|
||||
'title': title,
|
||||
'description': description,
|
||||
'start': start,
|
||||
'end': end,
|
||||
'channel_id': channelId,
|
||||
};
|
||||
}).toList();
|
||||
|
||||
return {'channel_id': channelId, 'programmes': programmes};
|
||||
} catch (e) {
|
||||
print('[EpgApi] Erreur panneau pour $channelId: ${LogRedactor.redactUrl('$e')}');
|
||||
return {
|
||||
'channel_id': channelId,
|
||||
'programmes': [],
|
||||
'error': 'EPG indisponible',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Epoch (secondes, String ou int) → ISO-8601 UTC, ou `null` si absent.
|
||||
static String? _isoUtc(dynamic timestamp) {
|
||||
if (timestamp == null) return null;
|
||||
final seconds = timestamp is int ? timestamp : int.tryParse('$timestamp');
|
||||
if (seconds == null || seconds <= 0) return null;
|
||||
return DateTime.fromMillisecondsSinceEpoch(seconds * 1000, isUtc: true)
|
||||
.toIso8601String();
|
||||
}
|
||||
|
||||
/// Repli : « YYYY-MM-DD HH:MM:SS » → « YYYY-MM-DDTHH:MM:SS ».
|
||||
static String _normalizeDate(String raw) {
|
||||
if (raw.contains(' ') && !raw.contains('T')) {
|
||||
return raw.replaceFirst(' ', 'T');
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
class _ChannelMap {
|
||||
final Map<String, String> epgIds;
|
||||
final Map<String, String> names;
|
||||
final DateTime expiresAt;
|
||||
_ChannelMap({
|
||||
required this.epgIds,
|
||||
required this.names,
|
||||
required this.expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
class _CacheEntry {
|
||||
final String data;
|
||||
final DateTime expiresAt;
|
||||
_CacheEntry({required this.data, required this.expiresAt});
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import 'package:shelf/shelf.dart';
|
||||
|
||||
import '../services/logo_catalog.dart';
|
||||
import 'asset_failure_cache.dart';
|
||||
|
||||
/// GET /api/logo?src=<stream_icon>&name=<nom de chaîne>
|
||||
///
|
||||
/// Logo d'une chaîne, avec repli. L'URL fournie par le panneau est tentée
|
||||
/// d'abord, via le proxy Xtream (mêmes protections anti-SSRF, même mémoire
|
||||
/// des hôtes morts). En cas d'échec, le logo est cherché par nom dans le
|
||||
/// dépôt public `tv-logos`.
|
||||
///
|
||||
/// POURQUOI : quand l'hébergeur de picons du revendeur tombe, toute la grille
|
||||
/// perd ses logos d'un coup, alors que les autres applications IPTV les
|
||||
/// affichent encore grâce à leurs propres sources de repli.
|
||||
class LogoApi {
|
||||
LogoApi(this._proxy, this._catalog);
|
||||
|
||||
/// Proxy Xtream (`ProxyHandler.handler`), qui attend `/api/xtream/<url>`.
|
||||
final Handler _proxy;
|
||||
|
||||
final LogoCatalog _catalog;
|
||||
|
||||
/// Un logo trouvé change rarement : un jour de cache navigateur.
|
||||
static const _found = 'public, max-age=86400';
|
||||
|
||||
Future<Response> handle(Request request) async {
|
||||
final src = request.url.queryParameters['src'] ?? '';
|
||||
final name = request.url.queryParameters['name'] ?? '';
|
||||
|
||||
// Une URL panneau malformée ne doit pas priver la chaîne du repli.
|
||||
final proxied = src.startsWith('http://') || src.startsWith('https://')
|
||||
? Uri.tryParse('${request.requestedUri.origin}/api/xtream/$src')
|
||||
: null;
|
||||
|
||||
if (proxied != null) {
|
||||
final upstream = await _proxy(
|
||||
Request(
|
||||
'GET',
|
||||
proxied,
|
||||
headers: {
|
||||
for (final header in const ['cookie', 'authorization'])
|
||||
if (request.headers[header] != null)
|
||||
header: request.headers[header]!,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final type = upstream.headers['content-type'] ?? '';
|
||||
if (upstream.statusCode == 200 && type.startsWith('image/')) {
|
||||
return Response.ok(
|
||||
upstream.read(),
|
||||
headers: {'content-type': type, 'cache-control': _found},
|
||||
);
|
||||
}
|
||||
// Corps d'erreur consommé : sinon la connexion amont reste ouverte.
|
||||
await upstream.read().drain<void>();
|
||||
}
|
||||
|
||||
if (name.isNotEmpty) {
|
||||
final bytes = await _catalog.logoFor(name);
|
||||
if (bytes != null) {
|
||||
return Response.ok(
|
||||
bytes,
|
||||
headers: {'content-type': 'image/png', 'cache-control': _found},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return missingImageResponse();
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:shelf/shelf.dart';
|
||||
|
||||
/// Journal des incidents de lecture remontés par le lecteur web.
|
||||
///
|
||||
/// POURQUOI : les freezes (image et son figés puis reprise) se produisent
|
||||
/// chez l'utilisateur, dans son navigateur ; le serveur n'en voit rien. Le
|
||||
/// lecteur envoie ici chaque coupure, erreur, recréation et saut, et le
|
||||
/// serveur les écrit dans `docker logs` (`[Player] …`) : on sait alors si un
|
||||
/// freeze vient d'un buffer à sec, d'une erreur du démuxeur ou d'une
|
||||
/// relance du flux.
|
||||
///
|
||||
/// Entrée non fiable : seuls des champs connus, courts et typés passent.
|
||||
|
||||
const _events = {
|
||||
'start', 'first_frame', 'stall', 'seek', 'mpegts_error', 'recreate',
|
||||
'fallback_hls', 'hls_error', 'profile', 'error_shown', 'heartbeat',
|
||||
};
|
||||
|
||||
const _numericFields = {
|
||||
't', 'ms', 'buffer', 'pos', 'from', 'to', 'stalls', 'stallMs', 'seeks',
|
||||
'errors', 'rate',
|
||||
};
|
||||
|
||||
const _textFields = {'session', 'ch', 'mode', 'profile', 'detail'};
|
||||
|
||||
/// Ligne de journal pour l'événement [raw], ou null s'il est invalide.
|
||||
String? formatPlayerEvent(Object? raw) {
|
||||
if (raw is! Map) return null;
|
||||
final event = raw['event'];
|
||||
if (event is! String || !_events.contains(event)) return null;
|
||||
|
||||
final parts = <String>[event];
|
||||
for (final key in _textFields) {
|
||||
final value = raw[key];
|
||||
if (value is String && value.isNotEmpty) {
|
||||
// Ni espace, ni retour ligne, ni URL : jamais d'injection de faux
|
||||
// journaux, jamais d'identifiants recopiés depuis une URL d'erreur.
|
||||
final safe = value
|
||||
.replaceAll(RegExp(r'https?://\S+'), '<url>')
|
||||
.replaceAll(RegExp(r'[^\w.\-:/<>]'), '_');
|
||||
parts.add('$key=${safe.length > 60 ? safe.substring(0, 60) : safe}');
|
||||
}
|
||||
}
|
||||
for (final key in _numericFields) {
|
||||
final value = raw[key];
|
||||
if (value is num && value.isFinite) {
|
||||
parts.add('$key=${value is int ? value : value.toStringAsFixed(1)}');
|
||||
}
|
||||
}
|
||||
return '[Player] ${parts.join(' ')}';
|
||||
}
|
||||
|
||||
Future<Response> handlePlayerLog(Request request) async {
|
||||
final body = await request.readAsString();
|
||||
if (body.length > 4096) return Response(413);
|
||||
Object? decoded;
|
||||
try {
|
||||
decoded = jsonDecode(body);
|
||||
} catch (_) {
|
||||
return Response.badRequest();
|
||||
}
|
||||
final events = decoded is List ? decoded.take(20) : [decoded];
|
||||
for (final event in events) {
|
||||
final line = formatPlayerEvent(event);
|
||||
if (line != null) print(line);
|
||||
}
|
||||
return Response(204);
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'package:shelf/shelf.dart';
|
||||
import 'package:shelf_router/shelf_router.dart';
|
||||
import '../database/database.dart';
|
||||
|
||||
class PlaylistsHandler {
|
||||
final AppDatabase db;
|
||||
|
||||
PlaylistsHandler(this.db);
|
||||
|
||||
Router get router {
|
||||
final router = Router();
|
||||
|
||||
router.get('/', _getPlaylists);
|
||||
router.post('/', _createPlaylist);
|
||||
router.put('/<id>', _updatePlaylist);
|
||||
router.delete('/<id>', _deletePlaylist);
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
/// GET /api/playlists
|
||||
Future<Response> _getPlaylists(Request request) async {
|
||||
try {
|
||||
final userId = request.context['userId'] as String?;
|
||||
if (userId == null) {
|
||||
return Response(401, body: jsonEncode({
|
||||
'success': false,
|
||||
'error': 'Unauthorized',
|
||||
}), headers: {'Content-Type': 'application/json'},);
|
||||
}
|
||||
|
||||
final playlists = db.getPlaylists(userId);
|
||||
|
||||
return Response.ok(jsonEncode({
|
||||
'playlists': playlists.map((p) => p.toJson()).toList(),
|
||||
}), headers: {'Content-Type': 'application/json'},);
|
||||
} catch (e) {
|
||||
return Response.internalServerError(
|
||||
body: jsonEncode({'success': false, 'error': e.toString()}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/playlists
|
||||
Future<Response> _createPlaylist(Request request) async {
|
||||
try {
|
||||
final userId = request.context['userId'] as String?;
|
||||
if (userId == null) {
|
||||
return Response(401, body: jsonEncode({
|
||||
'success': false,
|
||||
'error': 'Unauthorized',
|
||||
}), headers: {'Content-Type': 'application/json'},);
|
||||
}
|
||||
|
||||
final payload = jsonDecode(await request.readAsString()) as Map<String, dynamic>;
|
||||
final name = payload['name'] as String?;
|
||||
final serverUrl = payload['serverUrl'] as String?;
|
||||
final username = payload['username'] as String?;
|
||||
final password = payload['password'] as String?;
|
||||
final dns = payload['dns'] as String?;
|
||||
|
||||
if (name == null || serverUrl == null || username == null || password == null) {
|
||||
return Response(400, body: jsonEncode({
|
||||
'success': false,
|
||||
'error': 'Missing required fields',
|
||||
}), headers: {'Content-Type': 'application/json'},);
|
||||
}
|
||||
|
||||
final playlist = db.createPlaylist(
|
||||
userId: userId,
|
||||
name: name,
|
||||
serverUrl: serverUrl,
|
||||
username: username,
|
||||
password: password,
|
||||
dns: dns ?? serverUrl,
|
||||
);
|
||||
|
||||
return Response.ok(jsonEncode({
|
||||
'success': true,
|
||||
'playlist': playlist.toJson(),
|
||||
}), headers: {'Content-Type': 'application/json'},);
|
||||
} catch (e) {
|
||||
return Response.internalServerError(
|
||||
body: jsonEncode({'success': false, 'error': e.toString()}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// PUT /api/playlists/:id
|
||||
Future<Response> _updatePlaylist(Request request, String id) async {
|
||||
try {
|
||||
final userId = request.context['userId'] as String?;
|
||||
if (userId == null) {
|
||||
return Response(401, body: jsonEncode({
|
||||
'success': false,
|
||||
'error': 'Unauthorized',
|
||||
}), headers: {'Content-Type': 'application/json'},);
|
||||
}
|
||||
|
||||
// Verify playlist belongs to user
|
||||
final existing = db.getPlaylistById(id);
|
||||
if (existing == null) {
|
||||
return Response(404, body: jsonEncode({
|
||||
'success': false,
|
||||
'error': 'Playlist not found',
|
||||
}), headers: {'Content-Type': 'application/json'},);
|
||||
}
|
||||
|
||||
if (existing.userId != userId) {
|
||||
return Response(403, body: jsonEncode({
|
||||
'success': false,
|
||||
'error': 'Forbidden',
|
||||
}), headers: {'Content-Type': 'application/json'},);
|
||||
}
|
||||
|
||||
final payload = jsonDecode(await request.readAsString()) as Map<String, dynamic>;
|
||||
final name = payload['name'] as String? ?? existing.name;
|
||||
final serverUrl = payload['serverUrl'] as String? ?? existing.serverUrl;
|
||||
final username = payload['username'] as String? ?? existing.username;
|
||||
// Empty password means "keep current": clients never receive the real
|
||||
// password, so edit forms send it back blank.
|
||||
final incomingPassword = payload['password'] as String?;
|
||||
final password = (incomingPassword == null || incomingPassword.isEmpty)
|
||||
? existing.password
|
||||
: incomingPassword;
|
||||
final dns = payload['dns'] as String? ?? existing.dns;
|
||||
|
||||
final playlist = db.updatePlaylist(
|
||||
playlistId: id,
|
||||
name: name,
|
||||
serverUrl: serverUrl,
|
||||
username: username,
|
||||
password: password,
|
||||
dns: dns,
|
||||
);
|
||||
|
||||
return Response.ok(jsonEncode({
|
||||
'success': true,
|
||||
'playlist': playlist.toJson(),
|
||||
}), headers: {'Content-Type': 'application/json'},);
|
||||
} catch (e) {
|
||||
return Response.internalServerError(
|
||||
body: jsonEncode({'success': false, 'error': e.toString()}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// DELETE /api/playlists/:id
|
||||
Future<Response> _deletePlaylist(Request request, String id) async {
|
||||
try {
|
||||
final userId = request.context['userId'] as String?;
|
||||
if (userId == null) {
|
||||
return Response(401, body: jsonEncode({
|
||||
'success': false,
|
||||
'error': 'Unauthorized',
|
||||
}), headers: {'Content-Type': 'application/json'},);
|
||||
}
|
||||
|
||||
// Verify playlist belongs to user
|
||||
final existing = db.getPlaylistById(id);
|
||||
if (existing == null) {
|
||||
return Response(404,body: jsonEncode({
|
||||
'success': false,
|
||||
'error': 'Playlist not found',
|
||||
}), headers: {'Content-Type': 'application/json'},);
|
||||
}
|
||||
|
||||
if (existing.userId != userId) {
|
||||
return Response(403, body: jsonEncode({
|
||||
'success': false,
|
||||
'error': 'Forbidden',
|
||||
}), headers: {'Content-Type': 'application/json'},);
|
||||
}
|
||||
|
||||
db.deletePlaylist(id);
|
||||
|
||||
return Response.ok(jsonEncode({
|
||||
'success': true,
|
||||
}), headers: {'Content-Type': 'application/json'},);
|
||||
} catch (e) {
|
||||
return Response.internalServerError(
|
||||
body: jsonEncode({'success': false, 'error': e.toString()}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,334 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:shelf/shelf.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../database/database.dart';
|
||||
import '../middleware/auth_middleware.dart';
|
||||
import '../models/playlist_config.dart';
|
||||
import '../utils/log_redactor.dart';
|
||||
import 'asset_failure_cache.dart';
|
||||
|
||||
/// L'URL désigne-t-elle une image ou un logo ?
|
||||
///
|
||||
/// Ces URL échappent à l'allowlist de domaine (les revendeurs hébergent leurs
|
||||
/// picons ailleurs que le panneau) et méritent un repli visuel plutôt qu'une
|
||||
/// erreur propagée au navigateur.
|
||||
bool _isStaticAssetUrl(Uri url) =>
|
||||
url.path.endsWith('.png') ||
|
||||
url.path.endsWith('.jpg') ||
|
||||
url.path.endsWith('.jpeg') ||
|
||||
url.path.endsWith('.gif') ||
|
||||
url.path.endsWith('.webp') ||
|
||||
url.path.endsWith('.ico') ||
|
||||
url.path.contains('/picons/') ||
|
||||
url.path.contains('/logos/');
|
||||
|
||||
/// Returns true when [host] must never be proxied (loopback, private LAN,
|
||||
/// link-local/cloud-metadata ranges) — SSRF protection for asset URLs that
|
||||
/// bypass the playlist-domain allowlist.
|
||||
bool isForbiddenProxyHost(String host) {
|
||||
final lower = host.toLowerCase();
|
||||
if (lower == 'localhost' || lower == '::1') return true;
|
||||
|
||||
final ip = InternetAddress.tryParse(lower);
|
||||
if (ip == null) return false; // Hostname: validated by domain allowlist path
|
||||
|
||||
if (ip.isLoopback || ip.isLinkLocal) return true;
|
||||
if (ip.type == InternetAddressType.IPv4) {
|
||||
final parts = ip.address.split('.').map(int.parse).toList();
|
||||
if (parts[0] == 10) return true; // 10.0.0.0/8
|
||||
if (parts[0] == 172 && parts[1] >= 16 && parts[1] <= 31) return true;
|
||||
if (parts[0] == 192 && parts[1] == 168) return true; // 192.168.0.0/16
|
||||
if (parts[0] == 169 && parts[1] == 254) return true; // metadata/link-local
|
||||
if (parts[0] == 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Handler for the Xtream Proxy
|
||||
class ProxyHandler {
|
||||
final Future<PlaylistConfig?> Function(Request) _getPlaylist;
|
||||
final AppDatabase _db;
|
||||
final http.Client _client = http.Client();
|
||||
|
||||
final Map<String, (PlaylistConfig, DateTime)> _playlistCache = {};
|
||||
static const _cacheDuration = Duration(minutes: 5);
|
||||
|
||||
/// Hôtes d'images réputés hors service (voir [AssetFailureCache]).
|
||||
final AssetFailureCache _assetFailures = AssetFailureCache();
|
||||
|
||||
static const _allowedHeaders = [
|
||||
'content-type',
|
||||
'content-range',
|
||||
'accept-ranges',
|
||||
'cache-control',
|
||||
'server',
|
||||
'date',
|
||||
];
|
||||
|
||||
static const _allowedRequestHeaders = [
|
||||
'user-agent',
|
||||
'accept',
|
||||
'range',
|
||||
'referer',
|
||||
];
|
||||
|
||||
Future<PlaylistConfig?> _getCachedPlaylist(Request request) async {
|
||||
// Basic caching to avoid DB overhead on every video segment
|
||||
final now = DateTime.now();
|
||||
const cacheKey =
|
||||
'global_playlist'; // Currently app has one primary playlist per user/global
|
||||
|
||||
if (_playlistCache.containsKey(cacheKey)) {
|
||||
final (cached, expiry) = _playlistCache[cacheKey]!;
|
||||
if (now.isBefore(expiry)) return cached;
|
||||
}
|
||||
|
||||
final playlist = await _getPlaylist(request);
|
||||
if (playlist != null) {
|
||||
_playlistCache[cacheKey] = (playlist, now.add(_cacheDuration));
|
||||
}
|
||||
return playlist;
|
||||
}
|
||||
|
||||
ProxyHandler(this._getPlaylist, this._db);
|
||||
|
||||
/// Create Xtream proxy handler with M3U8 URL rewriting support
|
||||
Handler get handler {
|
||||
return (Request request) async {
|
||||
final path = request.url.path;
|
||||
|
||||
// Only handle /api/xtream/* requests
|
||||
// CRITICAL: Check path BEFORE anything else so non-proxy requests fall through to static handler
|
||||
if (!path.startsWith('api/xtream/')) {
|
||||
// Return 404 so Cascade tries next handler (streamingRouter)
|
||||
return Response.notFound(null);
|
||||
}
|
||||
|
||||
// Authentification par session. Les requêtes initiées par le navigateur
|
||||
// (img src, hls.js) ne portent pas d'en-tête Authorization mais envoient
|
||||
// le cookie HttpOnly `session` (SameSite=Lax, même origine) posé au
|
||||
// login : extractAuthToken accepte les deux. Un proxy ouvert offrait un
|
||||
// rebond SSRF non authentifié vers n'importe quel hôte public via les
|
||||
// extensions d'image.
|
||||
final token = extractAuthToken(request);
|
||||
if (token == null || _db.findSessionByToken(token) == null) {
|
||||
return Response(401, body: 'Unauthorized');
|
||||
}
|
||||
|
||||
Uri? targetUrl;
|
||||
|
||||
// === PROXY LOGIC ===
|
||||
try {
|
||||
// Extract target URL from request
|
||||
// Format: /api/xtream/http://server:port/path
|
||||
String apiPath = path.substring('api/xtream/'.length);
|
||||
|
||||
// Decode URL if it's encoded
|
||||
if (apiPath.startsWith('http%3A') || apiPath.startsWith('https%3A')) {
|
||||
apiPath = Uri.decodeComponent(apiPath);
|
||||
}
|
||||
|
||||
if (!apiPath.startsWith('http://') && !apiPath.startsWith('https://')) {
|
||||
return Response.badRequest(
|
||||
body: 'Invalid API URL. Expected format: /api/xtream/http://...',
|
||||
);
|
||||
}
|
||||
|
||||
String fullUrl = apiPath;
|
||||
if (request.url.query.isNotEmpty) {
|
||||
if (fullUrl.contains('?')) {
|
||||
fullUrl = '$fullUrl&${request.url.query}';
|
||||
} else {
|
||||
fullUrl = '$fullUrl?${request.url.query}';
|
||||
}
|
||||
}
|
||||
|
||||
targetUrl = Uri.parse(fullUrl);
|
||||
|
||||
// Only plain http(s) may be proxied
|
||||
if (targetUrl.scheme != 'http' && targetUrl.scheme != 'https') {
|
||||
return Response.forbidden('Unsupported URL scheme');
|
||||
}
|
||||
|
||||
// Never proxy to loopback/private/link-local targets (SSRF)
|
||||
if (isForbiddenProxyHost(targetUrl.host)) {
|
||||
print('[Proxy] Blocked SSRF attempt to private host: ${targetUrl.host}');
|
||||
return Response.forbidden('Access to this host is forbidden');
|
||||
}
|
||||
|
||||
// SSRF Protection - but allow images/static assets from any host
|
||||
// Xtream providers often use separate CDN servers for picons/images
|
||||
final isStaticAsset = _isStaticAssetUrl(targetUrl);
|
||||
|
||||
// Hôte d'images déjà constaté mort : on répond tout de suite.
|
||||
// Aucun appel sortant, aucune entrée de log — c'est précisément le
|
||||
// flot qu'on cherche à éteindre.
|
||||
if (isStaticAsset && _assetFailures.isDown(targetUrl.host)) {
|
||||
return missingImageResponse();
|
||||
}
|
||||
|
||||
String? allowedHost;
|
||||
if (!isStaticAsset) {
|
||||
// For API calls, enforce domain allowlist
|
||||
final playlist = await _getCachedPlaylist(request);
|
||||
if (playlist == null) {
|
||||
return Response.forbidden(
|
||||
'No active playlist configuration found to validate request',
|
||||
);
|
||||
}
|
||||
|
||||
final targetHost = targetUrl.host.toLowerCase();
|
||||
allowedHost = Uri.parse(playlist.dns).host.toLowerCase();
|
||||
|
||||
if (targetHost != allowedHost) {
|
||||
print(
|
||||
'[Proxy] Blocked SSRF attempt to $targetHost (Allowed: $allowedHost)',
|
||||
);
|
||||
return Response.forbidden(
|
||||
'Access to this domain is forbidden by policy',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final proxyHeaders = <String, String>{
|
||||
'User-Agent': 'VLC/3.0.18 LibVLC/3.0.18',
|
||||
'Accept': '*/*',
|
||||
'Accept-Encoding': 'identity',
|
||||
'Keep-Alive': 'timeout=30, max=100', // Request persistent connection
|
||||
};
|
||||
|
||||
// Forward Range header if present
|
||||
if (request.headers['range'] != null) {
|
||||
proxyHeaders['range'] = request.headers['range']!;
|
||||
}
|
||||
|
||||
try {
|
||||
print('[Proxy] Forwarding to: ${LogRedactor.redactUrl(targetUrl.toString())}');
|
||||
|
||||
// Forward safe request headers
|
||||
for (final header in _allowedRequestHeaders) {
|
||||
if (request.headers.containsKey(header)) {
|
||||
proxyHeaders[header] = request.headers[header]!;
|
||||
}
|
||||
}
|
||||
|
||||
List<int>? postBody;
|
||||
if (request.method == 'POST') {
|
||||
final bodyBytes = await request.read().toList();
|
||||
postBody = bodyBytes.expand((i) => i).toList();
|
||||
}
|
||||
|
||||
// Redirections suivies MANUELLEMENT : chaque destination est
|
||||
// revalidée (hôte privé, allowlist de domaine). Avec
|
||||
// followRedirects, la validation ne portait que sur l'URL
|
||||
// initiale — une 302 du serveur amont suffisait pour atteindre
|
||||
// un hôte interne malgré l'anti-SSRF.
|
||||
http.StreamedResponse response;
|
||||
var currentUrl = targetUrl;
|
||||
var redirects = 0;
|
||||
while (true) {
|
||||
final proxyRequest = http.Request(request.method, currentUrl);
|
||||
proxyRequest.headers.addAll(proxyHeaders);
|
||||
proxyRequest.followRedirects = false;
|
||||
if (postBody != null) proxyRequest.bodyBytes = postBody;
|
||||
|
||||
// Added 90s timeout to allow frontend (60s) to time out gracefully first
|
||||
response = await _client
|
||||
.send(proxyRequest)
|
||||
.timeout(const Duration(seconds: 90));
|
||||
|
||||
final location = response.headers['location'];
|
||||
final isRedirect = response.statusCode >= 300 &&
|
||||
response.statusCode < 400 &&
|
||||
location != null;
|
||||
if (!isRedirect) break;
|
||||
|
||||
if (++redirects > 3) {
|
||||
return Response.forbidden('Too many redirects');
|
||||
}
|
||||
final next = Uri.parse(location);
|
||||
currentUrl = next.isAbsolute ? next : currentUrl.resolve(location);
|
||||
if (currentUrl.scheme != 'http' && currentUrl.scheme != 'https') {
|
||||
return Response.forbidden('Unsupported redirect scheme');
|
||||
}
|
||||
if (isForbiddenProxyHost(currentUrl.host)) {
|
||||
print(
|
||||
'[Proxy] Blocked SSRF redirect to private host: ${currentUrl.host}',
|
||||
);
|
||||
return Response.forbidden('Access to this host is forbidden');
|
||||
}
|
||||
if (allowedHost != null &&
|
||||
currentUrl.host.toLowerCase() != allowedHost) {
|
||||
print(
|
||||
'[Proxy] Blocked redirect to ${currentUrl.host} (Allowed: $allowedHost)',
|
||||
);
|
||||
return Response.forbidden(
|
||||
'Access to this domain is forbidden by policy',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Une image en erreur n'a rien à transmettre au navigateur : le
|
||||
// relais du 503 déclenchait un nouveau cycle de requêtes à chaque
|
||||
// rendu. On répond un 404 mis en cache, et on compte l'échec.
|
||||
if (isStaticAsset) {
|
||||
if (response.statusCode >= 400) {
|
||||
// Compté sur l'hôte demandé, pas sur la cible finale d'une
|
||||
// redirection : c'est cette clé-là que consulte le garde-fou
|
||||
// en tête de requête.
|
||||
_assetFailures.recordFailure(targetUrl.host);
|
||||
// Le corps d'erreur doit être consommé, sinon la connexion
|
||||
// reste ouverte jusqu'au timeout.
|
||||
unawaited(response.stream.drain<void>().catchError((_) {}));
|
||||
return missingImageResponse();
|
||||
}
|
||||
_assetFailures.recordSuccess(targetUrl.host);
|
||||
}
|
||||
|
||||
// Build response headers from source response
|
||||
final responseHeaders = <String, String>{
|
||||
'access-control-allow-origin': '*',
|
||||
'connection': 'keep-alive',
|
||||
'keep-alive': 'timeout=30',
|
||||
};
|
||||
|
||||
// Forward specific safe headers
|
||||
for (final header in _allowedHeaders) {
|
||||
if (response.headers.containsKey(header)) {
|
||||
responseHeaders[header] = response.headers[header]!;
|
||||
}
|
||||
}
|
||||
|
||||
// ALWAYS stream the response for maximum performance and to avoid memory issues with large JSONs
|
||||
return Response(
|
||||
response.statusCode,
|
||||
body: response.stream,
|
||||
headers: responseHeaders,
|
||||
);
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
} catch (e) {
|
||||
// Redaction : une ClientException porte l'URL amont, credentials
|
||||
// Xtream inclus. Jamais de détail d'exception vers le client.
|
||||
print(
|
||||
'[ProxyHandler] error on ${LogRedactor.redactUrl(path)}: '
|
||||
'${LogRedactor.redactUrl('$e')}',
|
||||
);
|
||||
|
||||
// Image injoignable : 404 mis en cache plutôt qu'une erreur 500
|
||||
if (targetUrl != null && _isStaticAssetUrl(targetUrl)) {
|
||||
_assetFailures.recordFailure(targetUrl.host);
|
||||
return missingImageResponse();
|
||||
}
|
||||
|
||||
return Response.internalServerError(
|
||||
body: jsonEncode({'error': 'Proxy error'}),
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
/// Adressage des sessions de lecture d'un enregistrement.
|
||||
///
|
||||
/// Un enregistrement est transcodé à la volée par FFmpeg, séquentiellement.
|
||||
/// Reprendre à 45 min sans le lui dire imposerait d'attendre que l'encodeur
|
||||
/// y arrive : le lecteur demande donc une playlist qui *commence* à cette
|
||||
/// position (`?start=2700`), servie par une session FFmpeg dédiée.
|
||||
///
|
||||
/// Ces fonctions sont isolées du handler HTTP pour rester testables.
|
||||
library;
|
||||
|
||||
/// Position de départ demandée (`?start=<secondes>`), bornée au positif.
|
||||
/// Toute valeur absente, négative ou non numérique vaut « depuis le début ».
|
||||
int parseRecordingStart(String? raw) {
|
||||
final value = double.tryParse(raw ?? '');
|
||||
if (value == null || !value.isFinite || value <= 0) return 0;
|
||||
return value.floor();
|
||||
}
|
||||
|
||||
/// Segment de chemin qui rattache un segment `.ts` à SA session.
|
||||
///
|
||||
/// Les segments sont référencés en relatif dans la playlist et la
|
||||
/// query string n'y survit pas : sans ce répertoire virtuel, la playlist
|
||||
/// démarrée à 0 s et celle démarrée à 45 min demanderaient toutes deux
|
||||
/// `segment_000.ts` à la même URL.
|
||||
String recordingOffsetKey(int start) => 't$start';
|
||||
|
||||
/// Inverse de [recordingOffsetKey] ; `null` si la clé est mal formée.
|
||||
int? parseRecordingOffsetKey(String key) {
|
||||
if (!key.startsWith('t') || key.length < 2) return null;
|
||||
final value = int.tryParse(key.substring(1));
|
||||
return (value == null || value < 0) ? null : value;
|
||||
}
|
||||
|
||||
/// Préfixe les segments de [playlist] par [offsetKey].
|
||||
///
|
||||
/// Seules les lignes de segment sont touchées : les balises `#EXT-X-…` et
|
||||
/// les lignes vides passent telles quelles.
|
||||
String rewriteRecordingPlaylist(String playlist, String offsetKey) {
|
||||
return playlist.replaceAllMapped(
|
||||
RegExp(r'^(segment_\d+\.ts)\s*$', multiLine: true),
|
||||
(m) => '$offsetKey/${m[1]}',
|
||||
);
|
||||
}
|
||||
@@ -1,290 +0,0 @@
|
||||
import 'dart:io';
|
||||
import 'dart:convert';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:shelf/shelf.dart';
|
||||
import '../database/database.dart';
|
||||
import '../models/recording.dart';
|
||||
import '../models/user.dart';
|
||||
import '../services/recording_scheduler.dart';
|
||||
import '../utils/media_probe.dart';
|
||||
import '../utils/safe_path.dart';
|
||||
|
||||
class RecordingsApi {
|
||||
final AppDatabase _db;
|
||||
final RecordingScheduler _scheduler;
|
||||
|
||||
/// Durée maximale d'un enregistrement (env MAX_RECORDING_HOURS).
|
||||
final int maxRecordingHours = int.tryParse(
|
||||
Platform.environment['MAX_RECORDING_HOURS'] ?? '',
|
||||
) ??
|
||||
12;
|
||||
|
||||
RecordingsApi(this._db, this._scheduler);
|
||||
|
||||
Response _json(int status, Map<String, dynamic> body) => Response(
|
||||
status,
|
||||
body: json.encode(body),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
|
||||
/// L'utilisateur courant peut-il agir sur cet enregistrement ?
|
||||
/// (même patron de contrôle de propriété que playlists_handler)
|
||||
bool _canAccess(User? user, Recording recording) {
|
||||
if (user == null) return false;
|
||||
return user.isAdmin || recording.userId == user.id;
|
||||
}
|
||||
|
||||
/// Handler pour GET /api/recordings/logs/<id>
|
||||
/// Exposé séparément car shelf_router a un conflit entre DELETE /<id> et GET /logs/<id>
|
||||
Future<Response> getLogHandler(Request request, String id) async {
|
||||
final recording = _db.getRecordingById(id);
|
||||
|
||||
if (recording == null) {
|
||||
return _json(404, {'error': 'Enregistrement non trouvé'});
|
||||
}
|
||||
|
||||
final user = request.context['user'] as User?;
|
||||
if (!_canAccess(user, recording)) {
|
||||
return _json(403, {'error': 'Accès refusé'});
|
||||
}
|
||||
|
||||
if (recording.filePath == null) {
|
||||
return _json(404, {'error': 'Aucun fichier ni log associé pour le moment.'});
|
||||
}
|
||||
|
||||
// Les enregistrements sont écrits en .mkv avec un .log à côté
|
||||
// (l'ancien replaceAll('.mp4', '.log') ne trouvait jamais le fichier).
|
||||
final logFilePath = p.setExtension(recording.filePath!, '.log');
|
||||
|
||||
// Anti path-traversal : le log doit rester dans le dossier des enregistrements
|
||||
final safeLogPath = SafePath.resolveWithin(recordingsDirPath, logFilePath);
|
||||
if (safeLogPath == null) {
|
||||
return _json(403, {'error': 'Chemin de log invalide'});
|
||||
}
|
||||
|
||||
final logFile = File(safeLogPath);
|
||||
|
||||
if (!await logFile.exists()) {
|
||||
return _json(404, {'error': 'Le fichier de log est introuvable.'});
|
||||
}
|
||||
|
||||
final logs = await logFile.readAsString();
|
||||
return _json(200, {'logs': logs});
|
||||
}
|
||||
|
||||
/// GET /api/recordings — Liste les enregistrements de l'utilisateur
|
||||
/// (tous les enregistrements pour un admin), enrichis des informations de
|
||||
/// suivi : taille du fichier, progression, relances FFmpeg.
|
||||
Future<Response> handleGetAll(Request request) async {
|
||||
final user = request.context['user'] as User?;
|
||||
final recordings = (user != null && !user.isAdmin)
|
||||
? _db.getUserRecordings(user.id)
|
||||
: _db.getAllRecordings();
|
||||
final now = DateTime.now().toUtc();
|
||||
final enriched = <Map<String, dynamic>>[];
|
||||
for (final r in recordings) {
|
||||
final map = _enrich(r, now);
|
||||
// Durée réelle du média : `end_time - start_time` n'est que la durée
|
||||
// *programmée*. Un enregistrement arrêté en avance ou tronqué par une
|
||||
// coupure amont donnait une barre de progression fausse et un « reprendre »
|
||||
// hors des clous.
|
||||
final duration = await _probeDuration(r);
|
||||
if (duration != null) map['duration_seconds'] = duration;
|
||||
enriched.add(map);
|
||||
}
|
||||
return Response.ok(
|
||||
json.encode(enriched),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
|
||||
/// Durée du média en secondes, ou `null` si elle n'est pas mesurable.
|
||||
///
|
||||
/// Un enregistrement en cours grossit en permanence : le cache de
|
||||
/// [MediaProbe] serait invalidé à chaque appel et ffprobe tournerait en
|
||||
/// boucle. La durée programmée suffit tant que la capture n'est pas
|
||||
/// terminée.
|
||||
Future<double?> _probeDuration(Recording r) async {
|
||||
final path = r.filePath;
|
||||
if (path == null || r.status != 'completed') return null;
|
||||
|
||||
// Anti path-traversal : la mesure doit rester dans le dossier des
|
||||
// enregistrements, comme la lecture des logs.
|
||||
final safePath = SafePath.resolveWithin(recordingsDirPath, path);
|
||||
if (safePath == null) return null;
|
||||
|
||||
return MediaProbe.duration(safePath);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _enrich(Recording r, DateTime now) {
|
||||
final map = r.toMap();
|
||||
|
||||
if (r.status == 'recording') {
|
||||
final start = r.startTime.toUtc();
|
||||
final end = r.endTime.toUtc();
|
||||
final total = end.difference(start).inSeconds;
|
||||
if (total > 0) {
|
||||
final elapsed = now.difference(start).inSeconds;
|
||||
map['progress_pct'] =
|
||||
(elapsed * 100 / total).clamp(0, 100).round();
|
||||
}
|
||||
map['is_active'] = _scheduler.isCapturing(r.id);
|
||||
final retries = _scheduler.retryCountOf(r.id);
|
||||
if (retries != null) map['retry_count'] = retries;
|
||||
}
|
||||
|
||||
final path = r.filePath;
|
||||
if (path != null) {
|
||||
try {
|
||||
final file = File(path);
|
||||
if (file.existsSync()) map['file_size_bytes'] = file.lengthSync();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
/// POST /api/recordings — Planifie un nouvel enregistrement
|
||||
Future<Response> handlePost(Request request) async {
|
||||
final user = request.context['user'] as User?;
|
||||
final userId = user?.id ?? request.context['userId'] as String?;
|
||||
if (userId == null) {
|
||||
return _json(401, {'error': 'Authentification requise'});
|
||||
}
|
||||
|
||||
Map<String, dynamic> data;
|
||||
try {
|
||||
data = json.decode(await request.readAsString()) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
return _json(400, {'error': 'Corps JSON invalide'});
|
||||
}
|
||||
|
||||
final channelId = data['channel_id']?.toString() ?? '';
|
||||
final streamUrl = data['stream_url']?.toString() ?? '';
|
||||
if (channelId.isEmpty) {
|
||||
return _json(400, {'error': 'channel_id est requis'});
|
||||
}
|
||||
if (streamUrl.isEmpty) {
|
||||
return _json(400, {'error': 'stream_url est requis'});
|
||||
}
|
||||
|
||||
final startTime = _parseZonedDate(data['start_time']);
|
||||
final endTime = _parseZonedDate(data['end_time']);
|
||||
if (startTime == null || endTime == null) {
|
||||
// Une date sans indicateur de fuseau ('Z' ou ±hh:mm) est ambiguë :
|
||||
// l'interpréter dans le fuseau du serveur décale l'enregistrement
|
||||
// de plusieurs heures selon le TZ du conteneur.
|
||||
return _json(400, {
|
||||
'error':
|
||||
'start_time et end_time doivent être des dates ISO-8601 avec fuseau '
|
||||
'(ex: 2026-08-27T21:00:00Z)',
|
||||
});
|
||||
}
|
||||
if (!endTime.isAfter(startTime)) {
|
||||
return _json(400, {'error': 'end_time doit être après start_time'});
|
||||
}
|
||||
if (endTime.difference(startTime) > Duration(hours: maxRecordingHours)) {
|
||||
return _json(400, {
|
||||
'error': 'Durée maximale dépassée ($maxRecordingHours h)',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
final recording = _db.createRecording(
|
||||
userId: userId,
|
||||
channelId: channelId,
|
||||
streamUrl: streamUrl,
|
||||
title: data['title']?.toString() ?? 'Sans Titre',
|
||||
startTime: startTime,
|
||||
endTime: endTime,
|
||||
);
|
||||
|
||||
return Response.ok(
|
||||
recording.toJson(),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
} catch (e) {
|
||||
print('[RecordingsApi] Erreur à la création: $e');
|
||||
return _json(500, {'error': 'Erreur lors de la programmation'});
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse une date ISO-8601 en exigeant un indicateur de fuseau, et la
|
||||
/// normalise en UTC. Retourne null si absente, invalide ou naïve.
|
||||
DateTime? _parseZonedDate(dynamic raw) {
|
||||
final str = raw?.toString() ?? '';
|
||||
if (str.isEmpty) return null;
|
||||
// 'Z' final ou offset ±hh[:mm] après l'heure
|
||||
final hasZone =
|
||||
str.endsWith('Z') || RegExp(r'[+-]\d{2}:?\d{2}$').hasMatch(str);
|
||||
if (!hasZone) return null;
|
||||
return DateTime.tryParse(str)?.toUtc();
|
||||
}
|
||||
|
||||
/// DELETE /api/recordings/<id> — Annule ou supprime un enregistrement
|
||||
/// Si un enregistrement FFmpeg est actif, il est arrêté avant la suppression.
|
||||
/// Les fichiers associés (.mkv, .log, parties) sont supprimés avec la ligne.
|
||||
Future<Response> handleDelete(Request request, String id) async {
|
||||
final recording = _db.getRecordingById(id);
|
||||
|
||||
if (recording == null) {
|
||||
return _json(404, {'error': 'Enregistrement non trouvé'});
|
||||
}
|
||||
|
||||
final user = request.context['user'] as User?;
|
||||
if (!_canAccess(user, recording)) {
|
||||
return _json(403, {'error': 'Accès refusé'});
|
||||
}
|
||||
|
||||
// Tuer FFmpeg si cet enregistrement est en cours AVANT de supprimer de la DB
|
||||
await _scheduler.stopRecording(id);
|
||||
|
||||
// Supprimer les fichiers pour ne pas laisser d'orphelins sur le volume,
|
||||
// en restant confiné au dossier des enregistrements.
|
||||
final path = recording.filePath;
|
||||
if (path != null) {
|
||||
final safePath = SafePath.resolveWithin(recordingsDirPath, path);
|
||||
if (safePath != null) {
|
||||
await _scheduler.deleteRecordingFiles(safePath);
|
||||
}
|
||||
}
|
||||
|
||||
_db.deleteRecording(id);
|
||||
|
||||
return _json(200, {'message': 'Enregistrement supprimé avec succès'});
|
||||
}
|
||||
|
||||
/// POST /api/recordings/stop/<id> — Arrête un enregistrement FFmpeg en cours
|
||||
Future<Response> handleStop(Request request, String id) async {
|
||||
final recording = _db.getRecordingById(id);
|
||||
if (recording == null) {
|
||||
return _json(404, {'error': 'Enregistrement non trouvé'});
|
||||
}
|
||||
|
||||
final user = request.context['user'] as User?;
|
||||
if (!_canAccess(user, recording)) {
|
||||
return _json(403, {'error': 'Accès refusé'});
|
||||
}
|
||||
|
||||
final stopped = await _scheduler.stopRecording(id);
|
||||
if (stopped) {
|
||||
return _json(200, {'message': 'Enregistrement arrêté'});
|
||||
}
|
||||
|
||||
if (recording.status == 'scheduled') {
|
||||
// Rien n'a encore été capturé : annulé, pas « terminé ». Marquer
|
||||
// completed sans fichier faisait ensuite échouer la lecture.
|
||||
_db.updateRecordingStatus(id, 'cancelled');
|
||||
return _json(200, {'message': 'Enregistrement annulé'});
|
||||
}
|
||||
|
||||
if (recording.status == 'recording') {
|
||||
// Statut « recording » sans processus actif (orphelin) : clôturer.
|
||||
_db.updateRecordingStatus(id, 'completed');
|
||||
return _json(200, {'message': 'Enregistrement marqué comme terminé'});
|
||||
}
|
||||
|
||||
// Déjà completed/failed/cancelled : ne pas écraser le statut final.
|
||||
return _json(200, {'message': 'Enregistrement déjà clôturé'});
|
||||
}
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'package:shelf/shelf.dart';
|
||||
import '../database/database.dart';
|
||||
import '../models/user.dart';
|
||||
|
||||
/// API Season Passes — enregistrements répétés par titre d'émission
|
||||
class SeasonPassesApi {
|
||||
final AppDatabase _db;
|
||||
|
||||
SeasonPassesApi(this._db);
|
||||
|
||||
/// GET /api/season-passes — liste les season passes de l'utilisateur
|
||||
/// (tous les passes pour un admin)
|
||||
Response handleGetAll(Request request) {
|
||||
try {
|
||||
final user = request.context['user'] as User?;
|
||||
if (user == null) {
|
||||
return Response(
|
||||
401,
|
||||
body: json.encode({'error': 'Authentification requise'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
final passes = user.isAdmin
|
||||
? _db.getAllSeasonPasses()
|
||||
: _db.getSeasonPassesForUser(user.id);
|
||||
return Response.ok(
|
||||
json.encode(passes),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
} catch (e) {
|
||||
print('[SeasonPass] Erreur au listage: $e');
|
||||
return Response.internalServerError(
|
||||
body: json.encode({'error': 'Erreur interne'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/season-passes — créer un season pass
|
||||
Future<Response> handlePost(Request request) async {
|
||||
try {
|
||||
final body = await request.readAsString();
|
||||
final data = json.decode(body) as Map<String, dynamic>;
|
||||
|
||||
final showTitle = data['show_title'] as String?;
|
||||
final channelId = data['channel_id'] as String?;
|
||||
final streamUrl = data['stream_url'] as String?;
|
||||
|
||||
if (showTitle == null || showTitle.isEmpty) {
|
||||
return Response(
|
||||
400,
|
||||
body: json.encode({'error': 'show_title est requis'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
if (channelId == null || channelId.isEmpty) {
|
||||
return Response(
|
||||
400,
|
||||
body: json.encode({'error': 'channel_id est requis'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
if (streamUrl == null || streamUrl.isEmpty) {
|
||||
return Response(
|
||||
400,
|
||||
body: json.encode({'error': 'stream_url est requis'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
|
||||
// Récupérer l'utilisateur depuis le contexte
|
||||
final user = request.context['user'] as User?;
|
||||
if (user == null) {
|
||||
return Response(
|
||||
401,
|
||||
body: json.encode({'error': 'Authentification requise'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
|
||||
// 'exact' par défaut : « Journal » ne doit pas capturer tous les
|
||||
// programmes qui contiennent le mot. 'contains' reste disponible.
|
||||
final matchMode = data['match_mode'] == 'contains' ? 'contains' : 'exact';
|
||||
|
||||
// Vérifier si un season pass identique existe déjà pour cet utilisateur
|
||||
final existing = _db.getSeasonPassesForUser(user.id);
|
||||
final duplicate = existing.any(
|
||||
(p) =>
|
||||
(p['show_title'] as String).toLowerCase() ==
|
||||
showTitle.toLowerCase() &&
|
||||
p['channel_id'] == channelId,
|
||||
);
|
||||
if (duplicate) {
|
||||
return Response(
|
||||
409,
|
||||
body: json.encode({'error': 'Un Season Pass identique existe déjà'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
|
||||
final pass = _db.createSeasonPass(
|
||||
userId: user.id,
|
||||
showTitle: showTitle,
|
||||
channelId: channelId,
|
||||
streamUrl: streamUrl,
|
||||
matchMode: matchMode,
|
||||
);
|
||||
|
||||
print('[SeasonPass] Créé: "$showTitle" sur chaîne $channelId');
|
||||
return Response(
|
||||
201,
|
||||
body: json.encode(pass),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
} catch (e) {
|
||||
return Response.internalServerError(
|
||||
body: json.encode({'error': 'Erreur: $e'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// DELETE /api/season-passes/<id> — supprimer un season pass
|
||||
Response handleDelete(Request request, String id) {
|
||||
try {
|
||||
final user = request.context['user'] as User?;
|
||||
final pass = _db.getSeasonPassById(id);
|
||||
if (pass == null) {
|
||||
return Response.notFound(
|
||||
json.encode({'error': 'Season Pass non trouvé'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
// Contrôle de propriété : seul le propriétaire ou un admin supprime.
|
||||
if (user == null || (!user.isAdmin && pass['user_id'] != user.id)) {
|
||||
return Response.forbidden(
|
||||
json.encode({'error': 'Accès refusé'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
_db.deleteSeasonPass(id);
|
||||
return Response.ok(
|
||||
json.encode({'message': 'Season Pass supprimé'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
} catch (e) {
|
||||
print('[SeasonPass] Erreur à la suppression: $e');
|
||||
return Response.internalServerError(
|
||||
body: json.encode({'error': 'Erreur interne'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'package:shelf/shelf.dart';
|
||||
import 'package:shelf_router/shelf_router.dart';
|
||||
import '../database/database.dart';
|
||||
|
||||
class SettingsHandler {
|
||||
final AppDatabase db;
|
||||
|
||||
SettingsHandler(this.db);
|
||||
|
||||
Router get router {
|
||||
final router = Router();
|
||||
|
||||
// GET /api/settings
|
||||
router.get('/', (Request req) {
|
||||
// Get userId from context (set by auth middleware)
|
||||
final userId = req.context['userId'] as String?;
|
||||
if (userId == null) {
|
||||
return Response.forbidden(jsonEncode({'error': 'Authentication required'}));
|
||||
}
|
||||
|
||||
final settingsJson = db.getUserSettings(userId);
|
||||
|
||||
// If no settings exist, return empty object or default
|
||||
return Response.ok(
|
||||
settingsJson ?? '{}',
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
});
|
||||
|
||||
// POST /api/settings
|
||||
router.post('/', (Request req) async {
|
||||
// Get userId from context (set by auth middleware)
|
||||
final userId = req.context['userId'] as String?;
|
||||
if (userId == null) {
|
||||
return Response.forbidden(jsonEncode({'error': 'Authentication required'}));
|
||||
}
|
||||
|
||||
try {
|
||||
final body = await req.readAsString();
|
||||
// Validate JSON
|
||||
try {
|
||||
jsonDecode(body);
|
||||
} catch (e) {
|
||||
return Response.badRequest(body: jsonEncode({'error': 'Invalid JSON'}));
|
||||
}
|
||||
|
||||
db.updateUserSettings(userId, body);
|
||||
|
||||
return Response.ok(
|
||||
body,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
} catch (e) {
|
||||
return Response.internalServerError(
|
||||
body: jsonEncode({'error': 'Failed to save settings: $e'}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'package:shelf/shelf.dart';
|
||||
import 'package:shelf_router/shelf_router.dart';
|
||||
import '../database/database.dart';
|
||||
import '../services/simple_recorder.dart';
|
||||
|
||||
/// 🎯 Simple Recording API - 3 endpoints only!
|
||||
class SimpleRecordingApi {
|
||||
final AppDatabase db;
|
||||
final SimpleRecorder recorder;
|
||||
|
||||
SimpleRecordingApi(this.db, this.recorder);
|
||||
|
||||
/// Setup routes
|
||||
Router get router {
|
||||
final router = Router();
|
||||
|
||||
router.post('/record/now', _recordNow);
|
||||
router.post('/record/schedule', _scheduleRecord);
|
||||
router.post('/record/stop/<channelId>', _stopRecord);
|
||||
router.get('/record/list', _listRecordings);
|
||||
router.get('/record/active', _getActive);
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
/// 🟢 POST /record/now
|
||||
/// Start recording RIGHT NOW
|
||||
/// Body: { channel_id, stream_url, title, duration_minutes }
|
||||
Future<Response> _recordNow(Request request) async {
|
||||
try {
|
||||
final data = jsonDecode(await request.readAsString());
|
||||
|
||||
final id = await recorder.startRecording(
|
||||
channelId: data['channel_id'],
|
||||
streamUrl: data['stream_url'],
|
||||
title: data['title'] ?? 'Recording',
|
||||
duration: Duration(minutes: data['duration_minutes'] ?? 60),
|
||||
);
|
||||
|
||||
return Response.ok(
|
||||
jsonEncode({
|
||||
'status': 'recording',
|
||||
'id': id,
|
||||
'message': 'Recording started!',
|
||||
}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
} catch (e) {
|
||||
return Response.badRequest(
|
||||
body: jsonEncode({'error': '$e'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔵 POST /record/schedule
|
||||
/// Schedule recording for later
|
||||
/// Body: { channel_id, stream_url, title, start_time (ISO), end_time (ISO) }
|
||||
Future<Response> _scheduleRecord(Request request) async {
|
||||
try {
|
||||
final data = jsonDecode(await request.readAsString());
|
||||
|
||||
final id = await recorder.scheduleRecording(
|
||||
channelId: data['channel_id'],
|
||||
streamUrl: data['stream_url'],
|
||||
title: data['title'] ?? 'Recording',
|
||||
startTime: DateTime.parse(data['start_time']),
|
||||
endTime: DateTime.parse(data['end_time']),
|
||||
);
|
||||
|
||||
return Response.ok(
|
||||
jsonEncode({
|
||||
'status': 'scheduled',
|
||||
'id': id,
|
||||
'message': 'Recording scheduled!',
|
||||
}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
} catch (e) {
|
||||
return Response.badRequest(
|
||||
body: jsonEncode({'error': '$e'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔴 POST /record/stop/<channelId>
|
||||
/// Stop recording immediately
|
||||
Future<Response> _stopRecord(Request request, String channelId) async {
|
||||
try {
|
||||
await recorder.stopRecording(channelId);
|
||||
return Response.ok(
|
||||
jsonEncode({'status': 'stopped', 'message': 'Recording stopped!'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
} catch (e) {
|
||||
return Response.badRequest(
|
||||
body: jsonEncode({'error': '$e'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 📋 GET /record/list
|
||||
/// List all recordings
|
||||
Response _listRecordings(Request request) {
|
||||
final recordings = db.getAllRecordings();
|
||||
return Response.ok(
|
||||
jsonEncode({
|
||||
'total': recordings.length,
|
||||
'recordings': recordings.map((r) => r.toMap()).toList(),
|
||||
}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
|
||||
/// 🟢 GET /record/active
|
||||
/// List currently recording
|
||||
Response _getActive(Request request) {
|
||||
return Response.ok(
|
||||
jsonEncode({
|
||||
'active': recorder.getActive(),
|
||||
'count': recorder.getActive().length,
|
||||
}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,133 +0,0 @@
|
||||
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};
|
||||
}
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'package:shelf/shelf.dart';
|
||||
import 'package:shelf_router/shelf_router.dart';
|
||||
import '../database/database.dart';
|
||||
|
||||
class UsersHandler {
|
||||
final AppDatabase db;
|
||||
|
||||
UsersHandler(this.db);
|
||||
|
||||
Router get router {
|
||||
final router = Router();
|
||||
router.get('/', _getAllUsers);
|
||||
router.post('/', _createUser);
|
||||
router.put('/<id>/password', _updatePassword);
|
||||
router.put('/<id>', _updateUser);
|
||||
router.delete('/<id>', _deleteUser);
|
||||
return router;
|
||||
}
|
||||
|
||||
/// Check if requester is admin
|
||||
bool _isAdmin(Request request) {
|
||||
final userId = request.context['userId'] as String?;
|
||||
if (userId == null) return false;
|
||||
final user = db.findUserById(userId);
|
||||
return user?.isAdmin ?? false;
|
||||
}
|
||||
|
||||
/// GET /api/users
|
||||
Future<Response> _getAllUsers(Request request) async {
|
||||
try {
|
||||
if (!_isAdmin(request)) {
|
||||
return Response.forbidden(jsonEncode({'success': false, 'error': 'Admin required'}));
|
||||
}
|
||||
|
||||
final users = db.getAllUsers();
|
||||
return Response.ok(jsonEncode({
|
||||
'success': true,
|
||||
'users': users.map((u) => u.toJson()).toList(),
|
||||
}), headers: {'Content-Type': 'application/json'},);
|
||||
} catch (e) {
|
||||
return Response.internalServerError(
|
||||
body: jsonEncode({'success': false, 'error': e.toString()}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/users
|
||||
Future<Response> _createUser(Request request) async {
|
||||
try {
|
||||
if (!_isAdmin(request)) {
|
||||
return Response.forbidden(jsonEncode({'success': false, 'error': 'Admin required'}));
|
||||
}
|
||||
|
||||
final payload = jsonDecode(await request.readAsString()) as Map<String, dynamic>;
|
||||
final username = payload['username'] as String?;
|
||||
final password = payload['password'] as String?;
|
||||
final isAdmin = payload['isAdmin'] as bool? ?? false;
|
||||
|
||||
if (username == null || password == null) {
|
||||
return Response.badRequest(body: jsonEncode({
|
||||
'success': false,
|
||||
'error': 'Missing required fields',
|
||||
}), headers: {'Content-Type': 'application/json'},);
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
return Response.badRequest(body: jsonEncode({
|
||||
'success': false,
|
||||
'error': 'Le mot de passe doit faire au moins 8 caractères',
|
||||
}), headers: {'Content-Type': 'application/json'},);
|
||||
}
|
||||
|
||||
if (db.findUserByUsername(username) != null) {
|
||||
return Response.badRequest(body: jsonEncode({
|
||||
'success': false,
|
||||
'error': 'Username already exists',
|
||||
}), headers: {'Content-Type': 'application/json'},);
|
||||
}
|
||||
|
||||
final user = db.createUser(username, password, isAdmin: isAdmin);
|
||||
return Response.ok(jsonEncode({
|
||||
'success': true,
|
||||
'user': user.toJson(),
|
||||
}), headers: {'Content-Type': 'application/json'},);
|
||||
|
||||
} catch (e) {
|
||||
return Response.internalServerError(
|
||||
body: jsonEncode({'success': false, 'error': e.toString()}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// PUT /api/users/:id/password
|
||||
Future<Response> _updatePassword(Request request, String id) async {
|
||||
try {
|
||||
if (!_isAdmin(request)) {
|
||||
return Response.forbidden(jsonEncode({'success': false, 'error': 'Admin required'}));
|
||||
}
|
||||
|
||||
final payload = jsonDecode(await request.readAsString()) as Map<String, dynamic>;
|
||||
final password = payload['password'] as String?;
|
||||
|
||||
if (password == null) {
|
||||
return Response.badRequest(body: jsonEncode({
|
||||
'success': false,
|
||||
'error': 'Missing password',
|
||||
}), headers: {'Content-Type': 'application/json'},);
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
return Response.badRequest(body: jsonEncode({
|
||||
'success': false,
|
||||
'error': 'Le mot de passe doit faire au moins 8 caractères',
|
||||
}), headers: {'Content-Type': 'application/json'},);
|
||||
}
|
||||
|
||||
db.updateUserPassword(id, password);
|
||||
return Response.ok(jsonEncode({'success': true}), headers: {'Content-Type': 'application/json'});
|
||||
|
||||
} catch (e) {
|
||||
return Response.internalServerError(
|
||||
body: jsonEncode({'success': false, 'error': e.toString()}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// PUT /api/users/:id (Toggle Admin)
|
||||
Future<Response> _updateUser(Request request, String id) async {
|
||||
try {
|
||||
if (!_isAdmin(request)) {
|
||||
return Response.forbidden(jsonEncode({'success': false, 'error': 'Admin required'}));
|
||||
}
|
||||
|
||||
final payload = jsonDecode(await request.readAsString()) as Map<String, dynamic>;
|
||||
final isAdmin = payload['isAdmin'] as bool?;
|
||||
|
||||
if (isAdmin != null) {
|
||||
db.updateUserAdminStatus(id, isAdmin);
|
||||
}
|
||||
|
||||
return Response.ok(jsonEncode({'success': true}), headers: {'Content-Type': 'application/json'});
|
||||
|
||||
} catch (e) {
|
||||
return Response.internalServerError(
|
||||
body: jsonEncode({'success': false, 'error': e.toString()}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// DELETE /api/users/:id
|
||||
Future<Response> _deleteUser(Request request, String id) async {
|
||||
try {
|
||||
if (!_isAdmin(request)) {
|
||||
return Response.forbidden(jsonEncode({'success': false, 'error': 'Admin required'}));
|
||||
}
|
||||
|
||||
// Prevent deleting self
|
||||
final currentUserId = request.context['userId'] as String;
|
||||
if (currentUserId == id) {
|
||||
return Response.badRequest(body: jsonEncode({
|
||||
'success': false,
|
||||
'error': 'Cannot delete yourself',
|
||||
}), headers: {'Content-Type': 'application/json'},);
|
||||
}
|
||||
|
||||
db.deleteUser(id);
|
||||
return Response.ok(jsonEncode({'success': true}), headers: {'Content-Type': 'application/json'});
|
||||
} catch (e) {
|
||||
return Response.internalServerError(
|
||||
body: jsonEncode({'success': false, 'error': e.toString()}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'package:shelf/shelf.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../models/playlist_config.dart';
|
||||
import '../utils/log_redactor.dart';
|
||||
|
||||
/// Authenticated gateway to the Xtream `player_api.php` endpoint.
|
||||
///
|
||||
/// The frontend never sees the Xtream credentials: it calls
|
||||
/// `GET /api/xtream-api?action=...` with its session token and the server
|
||||
/// injects the username/password of the user's playlist before forwarding.
|
||||
class XtreamApiHandler {
|
||||
final Future<PlaylistConfig?> Function(Request) _getPlaylist;
|
||||
final http.Client _client = http.Client();
|
||||
|
||||
XtreamApiHandler(this._getPlaylist);
|
||||
|
||||
/// Query parameters the client is allowed to pass through to Xtream.
|
||||
static const _allowedParams = {
|
||||
'action',
|
||||
'category_id',
|
||||
'stream_id',
|
||||
'series_id',
|
||||
'vod_id',
|
||||
'limit',
|
||||
'type',
|
||||
};
|
||||
|
||||
Future<Response> handle(Request request) async {
|
||||
final playlist = await _getPlaylist(request);
|
||||
if (playlist == null) {
|
||||
return Response.forbidden(
|
||||
jsonEncode({'error': 'No playlist configured'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
|
||||
final params = <String, String>{
|
||||
'username': playlist.username,
|
||||
'password': playlist.password,
|
||||
};
|
||||
request.url.queryParameters.forEach((key, value) {
|
||||
if (_allowedParams.contains(key)) params[key] = value;
|
||||
});
|
||||
|
||||
final base = Uri.parse('${playlist.dns}/player_api.php');
|
||||
final targetUrl = base.replace(queryParameters: params);
|
||||
|
||||
try {
|
||||
final proxyRequest = http.Request('GET', targetUrl)
|
||||
..headers['User-Agent'] = 'VLC/3.0.18 LibVLC/3.0.18'
|
||||
..headers['Accept'] = '*/*'
|
||||
..followRedirects = true;
|
||||
|
||||
final response = await _client
|
||||
.send(proxyRequest)
|
||||
.timeout(const Duration(seconds: 90));
|
||||
|
||||
return Response(
|
||||
response.statusCode,
|
||||
body: response.stream,
|
||||
headers: {
|
||||
'Content-Type':
|
||||
response.headers['content-type'] ?? 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
print(
|
||||
'[XtreamApi] Error forwarding to ${LogRedactor.redactUrl(targetUrl.toString())}: $e',
|
||||
);
|
||||
return Response.internalServerError(
|
||||
body: jsonEncode({'error': 'Upstream Xtream API error'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,795 +0,0 @@
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
import 'package:sqlite3/sqlite3.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import '../models/user.dart';
|
||||
import '../models/playlist.dart';
|
||||
import '../models/session.dart' as models;
|
||||
import '../models/recording.dart';
|
||||
import '../utils/password_hasher.dart';
|
||||
|
||||
class AppDatabase {
|
||||
late final Database _db;
|
||||
final _uuid = const Uuid();
|
||||
|
||||
/// Initialize database and create tables
|
||||
Future<void> init() async {
|
||||
const dbPath = '/app/data/xtremflow.db';
|
||||
|
||||
// Ensure data directory exists
|
||||
final dir = Directory('/app/data');
|
||||
if (!await dir.exists()) {
|
||||
await dir.create(recursive: true);
|
||||
}
|
||||
|
||||
_db = sqlite3.open(dbPath);
|
||||
|
||||
// Sans foreign_keys, les ON DELETE CASCADE déclarés dans le schéma sont
|
||||
// ignorés par SQLite : supprimer un utilisateur laissait ses sessions
|
||||
// (donc des jetons valides), playlists et enregistrements orphelins.
|
||||
_db.execute('PRAGMA foreign_keys = ON');
|
||||
_db.execute('PRAGMA journal_mode = WAL');
|
||||
_db.execute('PRAGMA busy_timeout = 5000');
|
||||
|
||||
await _createTables();
|
||||
_runMigrations();
|
||||
print('Database initialized: $dbPath');
|
||||
}
|
||||
|
||||
/// Migrations de schéma pour les bases créées par une version antérieure.
|
||||
///
|
||||
/// `CREATE TABLE IF NOT EXISTS` n'ajoute jamais de colonne à une table
|
||||
/// existante : chaque colonne introduite après coup doit avoir sa migration.
|
||||
/// Les migrations sont numérotées et rejouées uniquement si nécessaire.
|
||||
void _runMigrations() {
|
||||
_db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS schema_version (
|
||||
version INTEGER PRIMARY KEY
|
||||
)
|
||||
''');
|
||||
|
||||
final result = _db.select(
|
||||
'SELECT COALESCE(MAX(version), 0) AS v FROM schema_version',
|
||||
);
|
||||
var current = result.first['v'] as int;
|
||||
|
||||
final migrations = <int, void Function()>{
|
||||
// v1 : colonne error_reason absente des bases d'avant son introduction.
|
||||
1: () => _addColumnIfMissing('tv_recordings', 'error_reason', 'TEXT'),
|
||||
// v2 : mode de correspondance des season passes. 'contains' pour les
|
||||
// lignes existantes (comportement historique) ; les nouvelles créations
|
||||
// passent par l'API qui choisit 'exact' par défaut.
|
||||
2: () => _addColumnIfMissing(
|
||||
'season_passes',
|
||||
'match_mode',
|
||||
"TEXT NOT NULL DEFAULT 'contains'",
|
||||
),
|
||||
};
|
||||
|
||||
for (final entry in migrations.entries) {
|
||||
if (entry.key <= current) continue;
|
||||
entry.value();
|
||||
_db.execute(
|
||||
'INSERT INTO schema_version (version) VALUES (?)',
|
||||
[entry.key],
|
||||
);
|
||||
current = entry.key;
|
||||
print('[DB] Migration v${entry.key} appliquée');
|
||||
}
|
||||
}
|
||||
|
||||
void _addColumnIfMissing(String table, String column, String definition) {
|
||||
final columns = _db.select('PRAGMA table_info($table)');
|
||||
final exists = columns.any((row) => row['name'] == column);
|
||||
if (!exists) {
|
||||
_db.execute('ALTER TABLE $table ADD COLUMN $column $definition');
|
||||
}
|
||||
}
|
||||
|
||||
/// Create database tables
|
||||
Future<void> _createTables() async {
|
||||
// Users table
|
||||
_db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''');
|
||||
|
||||
// Playlists table
|
||||
_db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS playlists (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
server_url TEXT NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
dns TEXT,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
''');
|
||||
|
||||
// Sessions table
|
||||
_db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
token TEXT UNIQUE NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
''');
|
||||
|
||||
// User Settings table
|
||||
_db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS user_settings (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
settings_json TEXT NOT NULL,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
''');
|
||||
|
||||
// TV Recordings table
|
||||
_db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS tv_recordings (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
channel_id TEXT NOT NULL,
|
||||
stream_url TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
start_time TEXT NOT NULL,
|
||||
end_time TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'scheduled',
|
||||
file_path TEXT,
|
||||
error_reason TEXT,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
''');
|
||||
|
||||
// Season Passes table (enregistrements répétés intelligents)
|
||||
_db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS season_passes (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
show_title TEXT NOT NULL,
|
||||
channel_id TEXT NOT NULL,
|
||||
stream_url TEXT NOT NULL,
|
||||
enabled INTEGER DEFAULT 1,
|
||||
match_mode TEXT NOT NULL DEFAULT 'exact',
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
''');
|
||||
|
||||
// Indexes
|
||||
_db.execute(
|
||||
'CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token)',
|
||||
);
|
||||
_db.execute(
|
||||
'CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at)',
|
||||
);
|
||||
_db.execute(
|
||||
'CREATE INDEX IF NOT EXISTS idx_playlists_user ON playlists(user_id)',
|
||||
);
|
||||
_db.execute(
|
||||
'CREATE INDEX IF NOT EXISTS idx_recordings_status ON tv_recordings(status)',
|
||||
);
|
||||
_db.execute(
|
||||
'CREATE INDEX IF NOT EXISTS idx_recordings_user ON tv_recordings(user_id)',
|
||||
);
|
||||
_db.execute(
|
||||
'CREATE INDEX IF NOT EXISTS idx_recordings_start ON tv_recordings(start_time)',
|
||||
);
|
||||
_db.execute(
|
||||
'CREATE INDEX IF NOT EXISTS idx_season_passes_user ON season_passes(user_id)',
|
||||
);
|
||||
}
|
||||
|
||||
/// Seed default admin user if no users exist.
|
||||
///
|
||||
/// Le mot de passe initial vient de ADMIN_INITIAL_PASSWORD, ou est généré
|
||||
/// aléatoirement et affiché UNE FOIS dans les logs de démarrage. L'ancien
|
||||
/// couple admin/admin restait souvent en place sur les instances exposées.
|
||||
Future<void> seedAdmin() async {
|
||||
final result = _db.select('SELECT COUNT(*) as count FROM users');
|
||||
final count = result.first['count'] as int;
|
||||
|
||||
if (count == 0) {
|
||||
final adminId = _uuid.v4();
|
||||
final envPassword = Platform.environment['ADMIN_INITIAL_PASSWORD'];
|
||||
final generated = envPassword == null || envPassword.isEmpty;
|
||||
final password = generated ? _generatePassword() : envPassword;
|
||||
final passwordHash = PasswordHasher.hash(password);
|
||||
|
||||
_db.execute(
|
||||
'''
|
||||
INSERT INTO users (id, username, password_hash, is_admin)
|
||||
VALUES (?, ?, ?, 1)
|
||||
''',
|
||||
[adminId, 'admin', passwordHash],
|
||||
);
|
||||
|
||||
if (generated) {
|
||||
print('╔══════════════════════════════════════════════════════════╗');
|
||||
print(' Compte admin créé — mot de passe initial (affiché une');
|
||||
print(' seule fois, changez-le après la première connexion) :');
|
||||
print(' utilisateur: admin');
|
||||
print(' mot de passe: $password');
|
||||
print('╚══════════════════════════════════════════════════════════╝');
|
||||
} else {
|
||||
print(
|
||||
'Default admin user created (username: admin, '
|
||||
'password: ADMIN_INITIAL_PASSWORD)',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static String _generatePassword({int length = 16}) {
|
||||
// Sans caractères ambigus (0/O, 1/l/I) : le mot de passe est recopié
|
||||
// depuis les logs du conteneur.
|
||||
const chars =
|
||||
'abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
final random = Random.secure();
|
||||
return List.generate(
|
||||
length,
|
||||
(_) => chars[random.nextInt(chars.length)],
|
||||
).join();
|
||||
}
|
||||
|
||||
// ==================== Users ====================
|
||||
|
||||
/// Find user by username
|
||||
User? findUserByUsername(String username) {
|
||||
final result = _db.select(
|
||||
'SELECT * FROM users WHERE username = ?',
|
||||
[username],
|
||||
);
|
||||
|
||||
if (result.isEmpty) return null;
|
||||
return User.fromMap(result.first);
|
||||
}
|
||||
|
||||
/// Find user by ID
|
||||
User? findUserById(String userId) {
|
||||
final result = _db.select(
|
||||
'SELECT * FROM users WHERE id = ?',
|
||||
[userId],
|
||||
);
|
||||
|
||||
if (result.isEmpty) return null;
|
||||
return User.fromMap(result.first);
|
||||
}
|
||||
|
||||
/// Verify user credentials
|
||||
User? verifyCredentials(String username, String password) {
|
||||
final result = _db.select(
|
||||
'SELECT * FROM users WHERE username = ?',
|
||||
[username],
|
||||
);
|
||||
|
||||
if (result.isEmpty) return null;
|
||||
|
||||
final passwordHash = result.first['password_hash'] as String;
|
||||
if (!PasswordHasher.verify(password, passwordHash)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Lazy migration: rehash legacy SHA-256 hashes with bcrypt on
|
||||
// successful login (the only moment the plaintext is available).
|
||||
if (PasswordHasher.isLegacy(passwordHash)) {
|
||||
updatePasswordHash(
|
||||
result.first['id'] as String,
|
||||
PasswordHasher.hash(password),
|
||||
);
|
||||
print('[Auth] Migrated legacy password hash for user: $username');
|
||||
}
|
||||
|
||||
return User.fromMap(result.first);
|
||||
}
|
||||
|
||||
/// Replace a user's stored password hash (used by lazy bcrypt migration)
|
||||
void updatePasswordHash(String userId, String newHash) {
|
||||
_db.execute(
|
||||
'UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
[newHash, userId],
|
||||
);
|
||||
}
|
||||
|
||||
/// Create new user
|
||||
User createUser(String username, String password, {bool isAdmin = false}) {
|
||||
final userId = _uuid.v4();
|
||||
final passwordHash = PasswordHasher.hash(password);
|
||||
|
||||
_db.execute(
|
||||
'''
|
||||
INSERT INTO users (id, username, password_hash, is_admin)
|
||||
VALUES (?, ?, ?, ?)
|
||||
''',
|
||||
[userId, username, passwordHash, isAdmin ? 1 : 0],
|
||||
);
|
||||
|
||||
return User(
|
||||
id: userId,
|
||||
username: username,
|
||||
isAdmin: isAdmin,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Get all users
|
||||
List<User> getAllUsers() {
|
||||
final result = _db.select('SELECT * FROM users ORDER BY username ASC');
|
||||
return result.map((row) => User.fromMap(row)).toList();
|
||||
}
|
||||
|
||||
/// Update user password
|
||||
void updateUserPassword(String userId, String newPassword) {
|
||||
final passwordHash = PasswordHasher.hash(newPassword);
|
||||
_db.execute(
|
||||
'UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
[passwordHash, userId],
|
||||
);
|
||||
}
|
||||
|
||||
/// Update user administration status
|
||||
void updateUserAdminStatus(String userId, bool isAdmin) {
|
||||
_db.execute(
|
||||
'UPDATE users SET is_admin = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
[isAdmin ? 1 : 0, userId],
|
||||
);
|
||||
}
|
||||
|
||||
/// Delete user
|
||||
void deleteUser(String userId) {
|
||||
_db.execute('DELETE FROM users WHERE id = ?', [userId]);
|
||||
}
|
||||
|
||||
// ==================== Sessions ====================
|
||||
|
||||
/// Create new session
|
||||
models.Session createSession(String userId, {Duration? duration}) {
|
||||
final sessionId = _uuid.v4();
|
||||
final token = _uuid.v4();
|
||||
final expiresAt = DateTime.now().add(duration ?? const Duration(days: 7));
|
||||
|
||||
_db.execute(
|
||||
'''
|
||||
INSERT INTO sessions (id, user_id, token, expires_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
''',
|
||||
[sessionId, userId, token, expiresAt.toIso8601String()],
|
||||
);
|
||||
|
||||
return models.Session(
|
||||
id: sessionId,
|
||||
userId: userId,
|
||||
token: token,
|
||||
expiresAt: expiresAt,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Find session by token
|
||||
models.Session? findSessionByToken(String token) {
|
||||
final result = _db.select(
|
||||
'SELECT * FROM sessions WHERE token = ?',
|
||||
[token],
|
||||
);
|
||||
|
||||
if (result.isEmpty) return null;
|
||||
|
||||
final session = models.Session.fromMap(result.first);
|
||||
|
||||
// Check if expired
|
||||
if (session.isExpired) {
|
||||
deleteSession(token);
|
||||
return null;
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/// Delete session (logout)
|
||||
void deleteSession(String token) {
|
||||
_db.execute('DELETE FROM sessions WHERE token = ?', [token]);
|
||||
}
|
||||
|
||||
/// Clean expired sessions
|
||||
void cleanExpiredSessions() {
|
||||
_db.execute(
|
||||
'DELETE FROM sessions WHERE expires_at < ?',
|
||||
[DateTime.now().toIso8601String()],
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Playlists ====================
|
||||
|
||||
/// Get all playlists for a user
|
||||
List<Playlist> getPlaylists(String userId) {
|
||||
final result = _db.select(
|
||||
'SELECT * FROM playlists WHERE user_id = ? ORDER BY created_at DESC',
|
||||
[userId],
|
||||
);
|
||||
|
||||
return result.map((row) => Playlist.fromMap(row)).toList();
|
||||
}
|
||||
|
||||
/// Get playlist by ID
|
||||
Playlist? getPlaylistById(String playlistId) {
|
||||
final result = _db.select(
|
||||
'SELECT * FROM playlists WHERE id = ?',
|
||||
[playlistId],
|
||||
);
|
||||
|
||||
if (result.isEmpty) return null;
|
||||
return Playlist.fromMap(result.first);
|
||||
}
|
||||
|
||||
/// Create new playlist
|
||||
Playlist createPlaylist({
|
||||
required String userId,
|
||||
required String name,
|
||||
required String serverUrl,
|
||||
required String username,
|
||||
required String password,
|
||||
String? dns,
|
||||
}) {
|
||||
final playlistId = _uuid.v4();
|
||||
final now = DateTime.now().toIso8601String();
|
||||
|
||||
_db.execute(
|
||||
'''
|
||||
INSERT INTO playlists (id, user_id, name, server_url, username, password, dns, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
''',
|
||||
[playlistId, userId, name, serverUrl, username, password, dns, now, now],
|
||||
);
|
||||
|
||||
return Playlist(
|
||||
id: playlistId,
|
||||
userId: userId,
|
||||
name: name,
|
||||
serverUrl: serverUrl,
|
||||
username: username,
|
||||
password: password,
|
||||
dns: dns,
|
||||
createdAt: DateTime.parse(now),
|
||||
updatedAt: DateTime.parse(now),
|
||||
);
|
||||
}
|
||||
|
||||
/// Update playlist
|
||||
Playlist updatePlaylist({
|
||||
required String playlistId,
|
||||
required String name,
|
||||
required String serverUrl,
|
||||
required String username,
|
||||
required String password,
|
||||
String? dns,
|
||||
}) {
|
||||
final now = DateTime.now().toIso8601String();
|
||||
|
||||
_db.execute(
|
||||
'''
|
||||
UPDATE playlists
|
||||
SET name = ?, server_url = ?, username = ?, password = ?, dns = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
''',
|
||||
[name, serverUrl, username, password, dns, now, playlistId],
|
||||
);
|
||||
|
||||
return getPlaylistById(playlistId)!;
|
||||
}
|
||||
|
||||
/// Delete playlist
|
||||
void deletePlaylist(String playlistId) {
|
||||
_db.execute('DELETE FROM playlists WHERE id = ?', [playlistId]);
|
||||
}
|
||||
|
||||
// ==================== User Settings ====================
|
||||
|
||||
/// Get user settings as JSON string
|
||||
String? getUserSettings(String userId) {
|
||||
final result = _db.select(
|
||||
'SELECT settings_json FROM user_settings WHERE user_id = ?',
|
||||
[userId],
|
||||
);
|
||||
|
||||
if (result.isEmpty) return null;
|
||||
return result.first['settings_json'] as String;
|
||||
}
|
||||
|
||||
/// Update user settings
|
||||
void updateUserSettings(String userId, String settingsJson) {
|
||||
final now = DateTime.now().toIso8601String();
|
||||
|
||||
// UPSERT (Insert or Replace)
|
||||
_db.execute(
|
||||
'''
|
||||
INSERT INTO user_settings (user_id, settings_json, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
settings_json = excluded.settings_json,
|
||||
updated_at = excluded.updated_at
|
||||
''',
|
||||
[userId, settingsJson, now],
|
||||
);
|
||||
}
|
||||
|
||||
/// Check if NVIDIA GPU is enabled in any user's settings
|
||||
/// This is a server-wide setting that applies to all streaming
|
||||
bool isNvidiaGpuEnabled() {
|
||||
try {
|
||||
// Check all user settings for enable_nvidia_gpu
|
||||
final result = _db.select(
|
||||
'SELECT settings_json FROM user_settings ORDER BY updated_at DESC LIMIT 1',
|
||||
);
|
||||
|
||||
if (result.isEmpty) {
|
||||
print('[GPU] No user settings found in database');
|
||||
return false;
|
||||
}
|
||||
|
||||
final settingsJson = result.first['settings_json'] as String;
|
||||
print('[GPU] Settings found: $settingsJson');
|
||||
|
||||
// Parse JSON properly to check the setting
|
||||
// Handle both "enable_nvidia_gpu":true and "enable_nvidia_gpu": true
|
||||
final RegExp gpuRegex =
|
||||
RegExp(r'"enable_nvidia_gpu"\s*:\s*true', caseSensitive: false);
|
||||
final isEnabled = gpuRegex.hasMatch(settingsJson);
|
||||
|
||||
print('[GPU] NVIDIA GPU enabled: $isEnabled');
|
||||
return isEnabled;
|
||||
} catch (e) {
|
||||
print('[GPU] Error checking GPU setting: $e');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ==================== TV Recordings ====================
|
||||
|
||||
/// Créer un nouvel enregistrement
|
||||
Recording createRecording({
|
||||
required String userId,
|
||||
required String channelId,
|
||||
required String streamUrl,
|
||||
required String title,
|
||||
required DateTime startTime,
|
||||
required DateTime endTime,
|
||||
}) {
|
||||
final recordingId = _uuid.v4();
|
||||
final now = DateTime.now().toIso8601String();
|
||||
|
||||
// Toujours stocker en UTC avec suffixe 'Z' : les comparaisons du scheduler
|
||||
// et la déduplication des season passes reposent sur ce format unique.
|
||||
final startUtc = startTime.toUtc();
|
||||
final endUtc = endTime.toUtc();
|
||||
|
||||
_db.execute(
|
||||
'''
|
||||
INSERT INTO tv_recordings (id, user_id, channel_id, stream_url, title, start_time, end_time, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
''',
|
||||
[
|
||||
recordingId,
|
||||
userId,
|
||||
channelId,
|
||||
streamUrl,
|
||||
title,
|
||||
startUtc.toIso8601String(),
|
||||
endUtc.toIso8601String(),
|
||||
now,
|
||||
now,
|
||||
],
|
||||
);
|
||||
|
||||
return Recording(
|
||||
id: recordingId,
|
||||
userId: userId,
|
||||
channelId: channelId,
|
||||
streamUrl: streamUrl,
|
||||
title: title,
|
||||
startTime: startUtc,
|
||||
endTime: endUtc,
|
||||
status: 'scheduled',
|
||||
createdAt: DateTime.parse(now),
|
||||
updatedAt: DateTime.parse(now),
|
||||
);
|
||||
}
|
||||
|
||||
/// Lister tous les enregistrements (pour l'admin)
|
||||
List<Recording> getAllRecordings() {
|
||||
final result =
|
||||
_db.select('SELECT * FROM tv_recordings ORDER BY start_time ASC');
|
||||
return result.map((row) => Recording.fromMap(row)).toList();
|
||||
}
|
||||
|
||||
/// Enregistrements qui intéressent le scheduler : à lancer ou en cours.
|
||||
/// Évite de désérialiser tout l'historique toutes les 10 secondes.
|
||||
List<Recording> getPendingRecordings() {
|
||||
final result = _db.select(
|
||||
"SELECT * FROM tv_recordings WHERE status IN ('scheduled', 'recording') "
|
||||
'ORDER BY start_time ASC',
|
||||
);
|
||||
return result.map((row) => Recording.fromMap(row)).toList();
|
||||
}
|
||||
|
||||
/// Enregistrements terminés (completed/failed/cancelled) du plus ancien au
|
||||
/// plus récent — utilisé par la rotation disque.
|
||||
List<Recording> getFinishedRecordingsOldestFirst() {
|
||||
final result = _db.select(
|
||||
"SELECT * FROM tv_recordings WHERE status IN ('completed', 'failed', 'cancelled') "
|
||||
'ORDER BY start_time ASC',
|
||||
);
|
||||
return result.map((row) => Recording.fromMap(row)).toList();
|
||||
}
|
||||
|
||||
/// Lister les enregistrements d'un utilisateur spécifique
|
||||
List<Recording> getUserRecordings(String userId) {
|
||||
final result = _db.select(
|
||||
'SELECT * FROM tv_recordings WHERE user_id = ? ORDER BY start_time ASC',
|
||||
[userId],
|
||||
);
|
||||
return result.map((row) => Recording.fromMap(row)).toList();
|
||||
}
|
||||
|
||||
/// Récupérer un enregistrement par ID
|
||||
Recording? getRecordingById(String id) {
|
||||
final result = _db.select('SELECT * FROM tv_recordings WHERE id = ?', [id]);
|
||||
if (result.isEmpty) return null;
|
||||
return Recording.fromMap(result.first);
|
||||
}
|
||||
|
||||
/// Mettre à jour le statut et éventuellement le chemin d'un enregistrement
|
||||
///
|
||||
/// [clearError] efface le motif d'erreur au lieu de conserver l'ancien :
|
||||
/// utile quand un enregistrement précédemment en échec est relancé.
|
||||
void updateRecordingStatus(
|
||||
String id,
|
||||
String status, {
|
||||
String? filePath,
|
||||
String? errorReason,
|
||||
bool clearError = false,
|
||||
}) {
|
||||
final now = DateTime.now().toIso8601String();
|
||||
final errorExpr = clearError ? '?' : 'COALESCE(?, error_reason)';
|
||||
_db.execute(
|
||||
'''
|
||||
UPDATE tv_recordings
|
||||
SET status = ?, file_path = COALESCE(?, file_path), error_reason = $errorExpr, updated_at = ?
|
||||
WHERE id = ?
|
||||
''',
|
||||
[status, filePath, errorReason, now, id],
|
||||
);
|
||||
}
|
||||
|
||||
/// Réaligner la fenêtre d'un enregistrement planifié (programme déplacé
|
||||
/// dans l'EPG depuis sa création par un season pass).
|
||||
void updateRecordingWindow(String id, DateTime start, DateTime end) {
|
||||
_db.execute(
|
||||
'''
|
||||
UPDATE tv_recordings
|
||||
SET start_time = ?, end_time = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
''',
|
||||
[
|
||||
start.toUtc().toIso8601String(),
|
||||
end.toUtc().toIso8601String(),
|
||||
DateTime.now().toIso8601String(),
|
||||
id,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Supprimer un enregistrement depuis la BDD (ne supprime pas le fichier)
|
||||
void deleteRecording(String id) {
|
||||
_db.execute('DELETE FROM tv_recordings WHERE id = ?', [id]);
|
||||
}
|
||||
|
||||
// ==================== Season Passes ====================
|
||||
|
||||
/// Créer un Season Pass (enregistrement répété par titre d'émission)
|
||||
Map<String, dynamic> createSeasonPass({
|
||||
required String userId,
|
||||
required String showTitle,
|
||||
required String channelId,
|
||||
required String streamUrl,
|
||||
String matchMode = 'exact',
|
||||
}) {
|
||||
final id = _uuid.v4();
|
||||
final now = DateTime.now().toIso8601String();
|
||||
_db.execute(
|
||||
'INSERT INTO season_passes (id, user_id, show_title, channel_id, stream_url, match_mode, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
[id, userId, showTitle, channelId, streamUrl, matchMode, now],
|
||||
);
|
||||
return {
|
||||
'id': id,
|
||||
'user_id': userId,
|
||||
'show_title': showTitle,
|
||||
'channel_id': channelId,
|
||||
'stream_url': streamUrl,
|
||||
'match_mode': matchMode,
|
||||
'enabled': 1,
|
||||
'created_at': now,
|
||||
};
|
||||
}
|
||||
|
||||
/// Lister les Season Passes actifs (pour le scheduler)
|
||||
List<Map<String, dynamic>> getAllSeasonPasses() {
|
||||
final result = _db.select(
|
||||
'SELECT * FROM season_passes WHERE enabled = 1 ORDER BY created_at DESC',
|
||||
);
|
||||
return result.map((r) => Map<String, dynamic>.from(r)).toList();
|
||||
}
|
||||
|
||||
/// Lister les Season Passes d'un utilisateur (actifs ou non, pour l'API)
|
||||
List<Map<String, dynamic>> getSeasonPassesForUser(String userId) {
|
||||
final result = _db.select(
|
||||
'SELECT * FROM season_passes WHERE user_id = ? ORDER BY created_at DESC',
|
||||
[userId],
|
||||
);
|
||||
return result.map((r) => Map<String, dynamic>.from(r)).toList();
|
||||
}
|
||||
|
||||
/// Récupérer un Season Pass par id (contrôle de propriété côté API)
|
||||
Map<String, dynamic>? getSeasonPassById(String id) {
|
||||
final result =
|
||||
_db.select('SELECT * FROM season_passes WHERE id = ?', [id]);
|
||||
if (result.isEmpty) return null;
|
||||
return Map<String, dynamic>.from(result.first);
|
||||
}
|
||||
|
||||
/// Supprimer un Season Pass
|
||||
void deleteSeasonPass(String id) {
|
||||
_db.execute('DELETE FROM season_passes WHERE id = ?', [id]);
|
||||
}
|
||||
|
||||
/// Cherche un enregistrement existant pour cet épisode (déduplication des
|
||||
/// season passes) : même titre, début à ±[tolerance] près.
|
||||
///
|
||||
/// La comparaison par plage remplace l'ancienne égalité de chaîne, qui
|
||||
/// échouait dès que le format stocké différait (avec/sans 'Z') ou que le
|
||||
/// panneau décalait le programme de quelques secondes — l'épisode était
|
||||
/// alors réenregistré en double.
|
||||
Recording? findRecordingForEpisode(
|
||||
String title,
|
||||
DateTime startTime, {
|
||||
Duration tolerance = const Duration(minutes: 2),
|
||||
}) {
|
||||
final startUtc = startTime.toUtc();
|
||||
// Les dates sont stockées en ISO-8601 UTC : l'ordre lexicographique
|
||||
// correspond à l'ordre chronologique, un BETWEEN sur chaînes suffit.
|
||||
final result = _db.select(
|
||||
'''SELECT * FROM tv_recordings
|
||||
WHERE LOWER(title) = LOWER(?)
|
||||
AND start_time BETWEEN ? AND ?
|
||||
AND status NOT IN ('failed', 'cancelled')
|
||||
LIMIT 1''',
|
||||
[
|
||||
title,
|
||||
startUtc.subtract(tolerance).toIso8601String(),
|
||||
startUtc.add(tolerance).toIso8601String(),
|
||||
],
|
||||
);
|
||||
if (result.isEmpty) return null;
|
||||
return Recording.fromMap(result.first);
|
||||
}
|
||||
|
||||
/// Close database connection
|
||||
void close() {
|
||||
_db.dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
import 'dart:io';
|
||||
import 'package:shelf/shelf.dart';
|
||||
import '../database/database.dart';
|
||||
|
||||
/// Middleware to authenticate requests
|
||||
Middleware authMiddleware(AppDatabase db) {
|
||||
return (Handler handler) {
|
||||
return (Request request) async {
|
||||
// Skip auth for login endpoint
|
||||
if (request.url.path.startsWith('api/auth/login')) {
|
||||
return handler(request);
|
||||
}
|
||||
|
||||
// Extract token
|
||||
final token = _extractToken(request);
|
||||
if (token == null) {
|
||||
return Response(401, body: 'Unauthorized');
|
||||
}
|
||||
|
||||
// Verify session (findSessionByToken enforces expires_at)
|
||||
final session = db.findSessionByToken(token);
|
||||
if (session == null) {
|
||||
return Response(401, body: 'Invalid or expired session');
|
||||
}
|
||||
|
||||
// Add user info to context. Both keys are populated because handlers
|
||||
// are inconsistent: playlists_handler reads 'userId', while
|
||||
// getPlaylist/admin routes read 'user'.
|
||||
final user = db.findUserById(session.userId);
|
||||
final updatedRequest = request.change(context: {
|
||||
...request.context,
|
||||
'userId': session.userId,
|
||||
if (user != null) 'user': user,
|
||||
},);
|
||||
|
||||
return handler(updatedRequest);
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/// Auth middleware for streaming routes (HLS playlists/segments).
|
||||
///
|
||||
/// hls.js/mpegts.js inside the player iframe cannot send Authorization
|
||||
/// headers, so these routes accept the HttpOnly session cookie instead.
|
||||
/// Loopback requests without X-Forwarded-For are allowed through because
|
||||
/// the recording scheduler's local FFmpeg fetches
|
||||
/// `http://localhost:8089/api/live/<id>.ts` without credentials.
|
||||
Middleware streamAuthMiddleware(AppDatabase db) {
|
||||
return (Handler handler) {
|
||||
return (Request request) async {
|
||||
// Only guard actual streaming routes. Anything else must fall through
|
||||
// untouched: the wrapped router returns 404 for unmatched paths and the
|
||||
// Cascade then reaches the static file handler (a 401 here would block
|
||||
// the whole app, e.g. GET / through the reverse proxy).
|
||||
final path = request.url.path;
|
||||
final isStreamingPath = path.startsWith('api/live') ||
|
||||
path.startsWith('api/vod') ||
|
||||
path.startsWith('api/recordings/stream');
|
||||
if (!isStreamingPath) {
|
||||
return handler(request);
|
||||
}
|
||||
|
||||
final connectionInfo =
|
||||
request.context['shelf.io.connection_info'] as HttpConnectionInfo?;
|
||||
final isLoopback = connectionInfo?.remoteAddress.isLoopback ?? false;
|
||||
final viaProxy = request.headers.containsKey('x-forwarded-for');
|
||||
if (isLoopback && !viaProxy) {
|
||||
return handler(request);
|
||||
}
|
||||
|
||||
final token = _extractToken(request);
|
||||
if (token == null || db.findSessionByToken(token) == null) {
|
||||
return Response(401, body: 'Unauthorized');
|
||||
}
|
||||
return handler(request);
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/// Extract token from Authorization header or cookie.
|
||||
/// Public : le proxy /api/xtream fait sa propre vérification de session
|
||||
/// (le contrôle doit rester DANS le handler, après le test de chemin,
|
||||
/// pour que les requêtes non-proxy tombent sur le handler statique).
|
||||
String? extractAuthToken(Request request) => _extractToken(request);
|
||||
|
||||
String? _extractToken(Request request) {
|
||||
// Try Authorization header first
|
||||
final authHeader = request.headers['authorization'];
|
||||
if (authHeader != null && authHeader.startsWith('Bearer ')) {
|
||||
return authHeader.substring(7);
|
||||
}
|
||||
|
||||
// Try cookie
|
||||
final cookie = request.headers['cookie'];
|
||||
if (cookie != null) {
|
||||
final parts = cookie.split(';');
|
||||
for (final part in parts) {
|
||||
final trimmed = part.trim();
|
||||
if (trimmed.startsWith('session=')) {
|
||||
return trimmed.substring(8);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,248 +0,0 @@
|
||||
import 'package:shelf/shelf.dart';
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import '../utils/log_redactor.dart';
|
||||
|
||||
/// Security Middleware Collection
|
||||
///
|
||||
/// Includes:
|
||||
/// - Redacted request logging
|
||||
/// - Honeypot Routes (Trap for bots)
|
||||
/// - Security Headers (HSTS, XSS Protection, CSP Report-Only)
|
||||
/// - Rate Limiting (Basic DoS protection)
|
||||
/// - Login-specific rate limiting (brute-force protection)
|
||||
|
||||
/// Proxys de confiance dont l'en-tête X-Forwarded-For est honoré.
|
||||
/// Par défaut : loopback et plages privées RFC1918 (le reverse proxy du
|
||||
/// docker-compose parle depuis le réseau Docker). Surcharger avec
|
||||
/// TRUSTED_PROXIES (liste d'IP séparées par des virgules) pour restreindre.
|
||||
final List<String> _trustedProxies =
|
||||
(Platform.environment['TRUSTED_PROXIES'] ?? '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.where((s) => s.isNotEmpty)
|
||||
.toList();
|
||||
|
||||
bool _isTrustedProxy(String address) {
|
||||
if (_trustedProxies.isNotEmpty) return _trustedProxies.contains(address);
|
||||
final ip = InternetAddress.tryParse(address);
|
||||
if (ip == null) return false;
|
||||
if (ip.isLoopback) return true;
|
||||
if (ip.type == InternetAddressType.IPv4) {
|
||||
final parts = ip.address.split('.').map(int.parse).toList();
|
||||
if (parts[0] == 10) return true;
|
||||
if (parts[0] == 172 && parts[1] >= 16 && parts[1] <= 31) return true;
|
||||
if (parts[0] == 192 && parts[1] == 168) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Resolve the real client IP.
|
||||
///
|
||||
/// X-Forwarded-For n'est honoré que si la connexion socket provient d'un
|
||||
/// proxy de confiance : sinon un client direct peut forger l'en-tête et
|
||||
/// contourner le rate limit global comme la limite de tentatives de login.
|
||||
String clientIpOf(Request request) {
|
||||
final connectionInfo =
|
||||
request.context['shelf.io.connection_info'] as HttpConnectionInfo?;
|
||||
final socketAddress = connectionInfo?.remoteAddress.address;
|
||||
|
||||
final forwarded = request.headers['x-forwarded-for'];
|
||||
if (forwarded != null &&
|
||||
forwarded.isNotEmpty &&
|
||||
socketAddress != null &&
|
||||
_isTrustedProxy(socketAddress)) {
|
||||
return forwarded.split(',').first.trim();
|
||||
}
|
||||
return socketAddress ?? 'unknown';
|
||||
}
|
||||
|
||||
/// 0. Redacted request logging.
|
||||
///
|
||||
/// Remplace `logRequests()` de shelf : le chemin `/api/xtream/<url>` embarque
|
||||
/// `username`/`password` Xtream en clair dans l'URI, que le logger standard
|
||||
/// écrivait tels quels — annulant l'effort de LogRedactor partout ailleurs.
|
||||
Middleware redactedLogRequests() {
|
||||
return (Handler handler) {
|
||||
return (Request request) async {
|
||||
final watch = Stopwatch()..start();
|
||||
try {
|
||||
final response = await handler(request);
|
||||
watch.stop();
|
||||
final query =
|
||||
request.requestedUri.hasQuery ? '?${request.requestedUri.query}' : '';
|
||||
print(
|
||||
'${DateTime.now().toIso8601String()} ${response.statusCode} '
|
||||
'${request.method} '
|
||||
'${LogRedactor.redactUrl('${request.requestedUri.path}$query')} '
|
||||
'(${watch.elapsedMilliseconds}ms)',
|
||||
);
|
||||
return response;
|
||||
} catch (e) {
|
||||
watch.stop();
|
||||
print(
|
||||
'${DateTime.now().toIso8601String()} ERR ${request.method} '
|
||||
'${LogRedactor.redactUrl(request.requestedUri.path)}: '
|
||||
'${LogRedactor.redactUrl('$e')}',
|
||||
);
|
||||
rethrow;
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/// 1. Security Headers Middleware
|
||||
/// Adds standard security headers to every response.
|
||||
Middleware securityHeadersMiddleware() {
|
||||
return (Handler handler) {
|
||||
return (Request request) async {
|
||||
final response = await handler(request);
|
||||
|
||||
return response.change(headers: {
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'X-Frame-Options': 'SAMEORIGIN', // Allow embedding player.html iframes
|
||||
'X-XSS-Protection': '1; mode=block',
|
||||
'Strict-Transport-Security': 'max-age=63072000; includeSubDomains; preload',
|
||||
'Referrer-Policy': 'strict-origin-when-cross-origin',
|
||||
// Report-Only first: Flutter Web (CanvasKit/wasm) and google_fonts
|
||||
// can break under an enforcing CSP. Promote to enforcing only after
|
||||
// a clean soak with no violations in browser consoles.
|
||||
'Content-Security-Policy-Report-Only':
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval'; "
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
|
||||
"font-src 'self' https://fonts.gstatic.com; "
|
||||
"img-src 'self' data: blob: https:; "
|
||||
"media-src 'self' blob:; "
|
||||
"connect-src 'self' https://fonts.gstatic.com",
|
||||
},);
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/// 2. Honeypot Middleware
|
||||
/// Intercepts requests to common vulnerability scanning paths.
|
||||
/// Returns a 403 Forbidden immediately and logs the incident.
|
||||
Middleware honeypotMiddleware() {
|
||||
// list of common bot targets
|
||||
const honeypotPaths = [
|
||||
'/admin/phpmyadmin',
|
||||
'/phpmyadmin',
|
||||
'/wp-admin',
|
||||
'/wp-login.php',
|
||||
'/.env',
|
||||
'/config.php',
|
||||
'/api/.env',
|
||||
'/console',
|
||||
'/actuator/health',
|
||||
];
|
||||
|
||||
return (Handler handler) {
|
||||
return (Request request) {
|
||||
// Comparaison sur le chemin exact ou un préfixe de segment. L'ancienne
|
||||
// comparaison `contains(trap.replaceAll('/', ''))` bloquait toute URL
|
||||
// contenant « console », « env » ou « wpadmin » n'importe où — y
|
||||
// compris des URLs proxifiées parfaitement légitimes.
|
||||
final path = '/${request.url.path}';
|
||||
|
||||
for (final trap in honeypotPaths) {
|
||||
if (path == trap || path.startsWith('$trap/')) {
|
||||
print('SECURITY ALERT: Honeypot triggered by ${clientIpOf(request)} on path: $path');
|
||||
return Response.forbidden('Access Denied');
|
||||
}
|
||||
}
|
||||
|
||||
return handler(request);
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/// 3. Rate Limit Middleware (In-Memory)
|
||||
/// Limits requests per IP address.
|
||||
/// Default: 200 requests per minute per IP.
|
||||
/// 600 plutôt que 200 : une grille de chaînes déclenche une requête de
|
||||
/// logo et une de guide par tuile, et tous les clients partagent l'IP du
|
||||
/// reverse proxy. Le plafond précédent coupait la grille en 429 au bout de
|
||||
/// quelques écrans de défilement.
|
||||
Middleware rateLimitMiddleware({int requestsPerMinute = 600}) {
|
||||
final clientRequests = <String, List<DateTime>>{};
|
||||
|
||||
// Cleanup timer to remove old entries and prevent memory leaks
|
||||
Timer.periodic(const Duration(minutes: 5), (_) {
|
||||
final now = DateTime.now();
|
||||
clientRequests.removeWhere((_, times) {
|
||||
// Remove timestamps older than 1 minute
|
||||
times.removeWhere((t) => now.difference(t).inMinutes > 1);
|
||||
return times.isEmpty;
|
||||
});
|
||||
});
|
||||
|
||||
return (Handler handler) {
|
||||
return (Request request) {
|
||||
final clientIp = clientIpOf(request);
|
||||
|
||||
if (clientIp != 'unknown' && clientIp != '127.0.0.1') {
|
||||
final now = DateTime.now();
|
||||
|
||||
// Get or create history for this IP
|
||||
final history = clientRequests.putIfAbsent(clientIp, () => []);
|
||||
|
||||
// Clean old requests (older than 1 minute)
|
||||
history.removeWhere((t) => now.difference(t).inMinutes >= 1);
|
||||
|
||||
// Check limit
|
||||
if (history.length >= requestsPerMinute) {
|
||||
print('SECURITY WARN: Rate limit exceeded for $clientIp');
|
||||
return Response(429, body: 'Too Many Requests');
|
||||
}
|
||||
|
||||
// Add current request
|
||||
history.add(now);
|
||||
}
|
||||
|
||||
return handler(request);
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/// 4. Login Rate Limit Middleware
|
||||
/// Strict per-IP limit on login attempts (brute-force protection),
|
||||
/// plus a small delay on each attempt to slow credential stuffing.
|
||||
Middleware loginRateLimitMiddleware({int attemptsPerMinute = 10}) {
|
||||
final attempts = <String, List<DateTime>>{};
|
||||
|
||||
Timer.periodic(const Duration(minutes: 5), (_) {
|
||||
final now = DateTime.now();
|
||||
attempts.removeWhere((_, times) {
|
||||
times.removeWhere((t) => now.difference(t).inMinutes > 1);
|
||||
return times.isEmpty;
|
||||
});
|
||||
});
|
||||
|
||||
return (Handler handler) {
|
||||
return (Request request) async {
|
||||
// Only throttle the login POST; other auth routes pass through
|
||||
if (!(request.method == 'POST' && request.url.path.endsWith('login'))) {
|
||||
return handler(request);
|
||||
}
|
||||
|
||||
final clientIp = clientIpOf(request);
|
||||
final now = DateTime.now();
|
||||
final history = attempts.putIfAbsent(clientIp, () => []);
|
||||
history.removeWhere((t) => now.difference(t).inMinutes >= 1);
|
||||
|
||||
if (history.length >= attemptsPerMinute) {
|
||||
print('SECURITY WARN: Login rate limit exceeded for $clientIp');
|
||||
return Response(429, body: 'Too many login attempts. Try again later.');
|
||||
}
|
||||
history.add(now);
|
||||
|
||||
final response = await handler(request);
|
||||
if (response.statusCode == 401) {
|
||||
// Slow down brute-force attempts on failed logins
|
||||
await Future.delayed(const Duration(milliseconds: 300));
|
||||
}
|
||||
return response;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
class Playlist {
|
||||
final String id;
|
||||
final String userId;
|
||||
final String name;
|
||||
final String serverUrl;
|
||||
final String username;
|
||||
final String password;
|
||||
final String? dns;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
|
||||
Playlist({
|
||||
required this.id,
|
||||
required this.userId,
|
||||
required this.name,
|
||||
required this.serverUrl,
|
||||
required this.username,
|
||||
required this.password,
|
||||
this.dns,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
factory Playlist.fromMap(Map<String, dynamic> map) {
|
||||
return Playlist(
|
||||
id: map['id'] as String,
|
||||
userId: map['user_id'] as String,
|
||||
name: map['name'] as String,
|
||||
serverUrl: map['server_url'] as String,
|
||||
username: map['username'] as String,
|
||||
password: map['password'] as String,
|
||||
dns: map['dns'] as String?,
|
||||
createdAt: DateTime.parse(map['created_at'] as String),
|
||||
updatedAt: DateTime.parse(map['updated_at'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'userId': userId,
|
||||
'name': name,
|
||||
'serverUrl': serverUrl,
|
||||
'username': username,
|
||||
// Never expose the Xtream password to clients; the backend injects
|
||||
// credentials server-side (see xtream_api_handler.dart).
|
||||
'password': '',
|
||||
'dns': dns,
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
'updatedAt': updatedAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'id': id,
|
||||
'user_id': userId,
|
||||
'name': name,
|
||||
'server_url': serverUrl,
|
||||
'username': username,
|
||||
'password': password,
|
||||
'dns': dns,
|
||||
'created_at': createdAt.toIso8601String(),
|
||||
'updated_at': updatedAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
/// Pure Dart model for Xtream Codes playlist configuration
|
||||
/// This is a server-side copy without Hive/Equatable dependencies
|
||||
class PlaylistConfig {
|
||||
final String id;
|
||||
final String name;
|
||||
|
||||
/// Xtream server URL (without trailing slash)
|
||||
final String dns;
|
||||
|
||||
final String username;
|
||||
|
||||
/// Xtream password
|
||||
final String password;
|
||||
|
||||
final DateTime createdAt;
|
||||
|
||||
final bool isActive;
|
||||
|
||||
const PlaylistConfig({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.dns,
|
||||
required this.username,
|
||||
required this.password,
|
||||
required this.createdAt,
|
||||
this.isActive = true,
|
||||
});
|
||||
|
||||
/// Creates a copy of this playlist with updated fields
|
||||
PlaylistConfig copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
String? dns,
|
||||
String? username,
|
||||
String? password,
|
||||
DateTime? createdAt,
|
||||
bool? isActive,
|
||||
}) {
|
||||
return PlaylistConfig(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
dns: dns ?? this.dns,
|
||||
username: username ?? this.username,
|
||||
password: password ?? this.password,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
isActive: isActive ?? this.isActive,
|
||||
);
|
||||
}
|
||||
|
||||
/// Generate Xtream API base URL
|
||||
String get apiBaseUrl => '$dns/player_api.php';
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
import 'dart:convert';
|
||||
|
||||
class Recording {
|
||||
final String id;
|
||||
final String userId;
|
||||
final String channelId;
|
||||
final String streamUrl;
|
||||
final String title;
|
||||
final DateTime startTime;
|
||||
final DateTime endTime;
|
||||
final String status; // 'scheduled', 'recording', 'completed', 'failed', 'cancelled'
|
||||
final String? filePath;
|
||||
final String? errorReason;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
|
||||
Recording({
|
||||
required this.id,
|
||||
required this.userId,
|
||||
required this.channelId,
|
||||
required this.streamUrl,
|
||||
required this.title,
|
||||
required this.startTime,
|
||||
required this.endTime,
|
||||
required this.status,
|
||||
this.filePath,
|
||||
this.errorReason,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'id': id,
|
||||
'user_id': userId,
|
||||
'channel_id': channelId,
|
||||
'stream_url': streamUrl,
|
||||
'title': title,
|
||||
'start_time': startTime.toIso8601String(),
|
||||
'end_time': endTime.toIso8601String(),
|
||||
'status': status,
|
||||
'file_path': filePath,
|
||||
'error_reason': errorReason,
|
||||
'created_at': createdAt.toIso8601String(),
|
||||
'updated_at': updatedAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
factory Recording.fromMap(Map<String, dynamic> map) {
|
||||
return Recording(
|
||||
id: map['id'] ?? '',
|
||||
userId: map['user_id'] ?? '',
|
||||
channelId: map['channel_id'] ?? '',
|
||||
streamUrl: map['stream_url'] ?? '',
|
||||
title: map['title'] ?? '',
|
||||
startTime: DateTime.parse(map['start_time']),
|
||||
endTime: DateTime.parse(map['end_time']),
|
||||
status: map['status'] ?? 'scheduled',
|
||||
filePath: map['file_path'],
|
||||
errorReason: map['error_reason'],
|
||||
createdAt: DateTime.parse(map['created_at']),
|
||||
updatedAt: DateTime.parse(map['updated_at']),
|
||||
);
|
||||
}
|
||||
|
||||
String toJson() => json.encode(toMap());
|
||||
|
||||
factory Recording.fromJson(String source) =>
|
||||
Recording.fromMap(json.decode(source));
|
||||
|
||||
Recording copyWith({
|
||||
String? id,
|
||||
String? userId,
|
||||
String? channelId,
|
||||
String? streamUrl,
|
||||
String? title,
|
||||
DateTime? startTime,
|
||||
DateTime? endTime,
|
||||
String? status,
|
||||
String? filePath,
|
||||
String? errorReason,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
}) {
|
||||
return Recording(
|
||||
id: id ?? this.id,
|
||||
userId: userId ?? this.userId,
|
||||
channelId: channelId ?? this.channelId,
|
||||
streamUrl: streamUrl ?? this.streamUrl,
|
||||
title: title ?? this.title,
|
||||
startTime: startTime ?? this.startTime,
|
||||
endTime: endTime ?? this.endTime,
|
||||
status: status ?? this.status,
|
||||
filePath: filePath ?? this.filePath,
|
||||
errorReason: errorReason ?? this.errorReason,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
class Session {
|
||||
final String id;
|
||||
final String userId;
|
||||
final String token;
|
||||
final DateTime expiresAt;
|
||||
final DateTime createdAt;
|
||||
|
||||
Session({
|
||||
required this.id,
|
||||
required this.userId,
|
||||
required this.token,
|
||||
required this.expiresAt,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
factory Session.fromMap(Map<String, dynamic> map) {
|
||||
return Session(
|
||||
id: map['id'] as String,
|
||||
userId: map['user_id'] as String,
|
||||
token: map['token'] as String,
|
||||
expiresAt: DateTime.parse(map['expires_at'] as String),
|
||||
createdAt: DateTime.parse(map['created_at'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'id': id,
|
||||
'user_id': userId,
|
||||
'token': token,
|
||||
'expires_at': expiresAt.toIso8601String(),
|
||||
'created_at': createdAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
bool get isExpired => DateTime.now().isAfter(expiresAt);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
class User {
|
||||
final String id;
|
||||
final String username;
|
||||
final bool isAdmin;
|
||||
final DateTime createdAt;
|
||||
|
||||
User({
|
||||
required this.id,
|
||||
required this.username,
|
||||
required this.isAdmin,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
factory User.fromMap(Map<String, dynamic> map) {
|
||||
return User(
|
||||
id: map['id'] as String,
|
||||
username: map['username'] as String,
|
||||
isAdmin: (map['is_admin'] as int) == 1,
|
||||
createdAt: DateTime.parse(map['created_at'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'username': username,
|
||||
'isAdmin': isAdmin,
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,477 +0,0 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
_fe_analyzer_shared:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: _fe_analyzer_shared
|
||||
sha256: "9a3386eea899815698dd55995277cf7cb8572ee52b399a6edfb7ae2b50e5fc19"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "105.0.0"
|
||||
analyzer:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: analyzer
|
||||
sha256: "62993bed6eadbe9596c5c20d5c167e7bc563c5fe266657a04ddeb93bdb84f4c9"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "14.1.0"
|
||||
args:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: args
|
||||
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.7.0"
|
||||
async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: async
|
||||
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.1"
|
||||
bcrypt:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: bcrypt
|
||||
sha256: "6073a700cbbc59f1d4ab27cd532755e3de5e676c4941f535f351374df849270b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.0"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: boolean_selector
|
||||
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
cli_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cli_config
|
||||
sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.0"
|
||||
collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: collection
|
||||
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
convert:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: convert
|
||||
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
coverage:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: coverage
|
||||
sha256: "956a3de0725ca232ad353565a8290d3357592bf4250f6f298a185e2d949c5d3d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.15.1"
|
||||
crypto:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: crypto
|
||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
equatable:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: equatable
|
||||
sha256: "3bce007a596ff8b3119c45d68aaef631272537c03d30e5d4534dd24bf4c5eaa2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
ffi:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ffi
|
||||
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file
|
||||
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fixnum
|
||||
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
frontend_server_client:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: frontend_server_client
|
||||
sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
glob:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: glob
|
||||
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.3"
|
||||
hive:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: hive
|
||||
sha256: "8dcf6db979d7933da8217edcec84e9df1bdb4e4edc7fc77dbd5aa74356d6d941"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.3"
|
||||
http:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: http
|
||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.6.0"
|
||||
http_methods:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_methods
|
||||
sha256: "6bccce8f1ec7b5d701e7921dca35e202d425b57e317ba1a37f2638590e29e566"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
http_multi_server:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_multi_server
|
||||
sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.2"
|
||||
http_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_parser
|
||||
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
io:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: io
|
||||
sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.5"
|
||||
logging:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: logging
|
||||
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.20"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.0"
|
||||
mime:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: mime
|
||||
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
node_preamble:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: node_preamble
|
||||
sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.2"
|
||||
package_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_config
|
||||
sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
path:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: path
|
||||
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
petitparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: petitparser
|
||||
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.2"
|
||||
pool:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pool
|
||||
sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.2"
|
||||
pub_semver:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pub_semver
|
||||
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
shelf:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: shelf
|
||||
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.2"
|
||||
shelf_packages_handler:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shelf_packages_handler
|
||||
sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
shelf_router:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: shelf_router
|
||||
sha256: f5e5d492440a7fb165fe1e2e1a623f31f734d3370900070b2b1e0d0428d59864
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.4"
|
||||
shelf_static:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: shelf_static
|
||||
sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.3"
|
||||
shelf_web_socket:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shelf_web_socket
|
||||
sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
source_map_stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_map_stack_trace
|
||||
sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
source_maps:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_maps
|
||||
sha256: "14c2945847669b44089bb1222f66873d7ff7103c58911917f2a63c5a62327898"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.10.14"
|
||||
source_span:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_span
|
||||
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.2"
|
||||
sqlite3:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: sqlite3
|
||||
sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.9.4"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stack_trace
|
||||
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.12.1"
|
||||
stream_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stream_channel
|
||||
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
string_scanner:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: string_scanner
|
||||
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: term_glyph
|
||||
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.2"
|
||||
test:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: test
|
||||
sha256: "0d5ba5602ec3baa28c8ce365e1efc5575969c765f45c554a3e167dc7945b9c30"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.31.2"
|
||||
test_api:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "475610b2aa23c19687cce2961e44b0cc57cafe220f67c2b80201231b2a07fbe7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.13"
|
||||
test_core:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_core
|
||||
sha256: a39c204a4fc7a7ccb04a2b985e359fda3cc37e45e0b8ac61c3fb1a05aa832132
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.19"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: typed_data
|
||||
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
uuid:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: uuid
|
||||
sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.6.0"
|
||||
vm_service:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vm_service
|
||||
sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.3.0"
|
||||
watcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: watcher
|
||||
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web
|
||||
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
web_socket:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web_socket
|
||||
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
web_socket_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web_socket_channel
|
||||
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.3"
|
||||
webkit_inspection_protocol:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webkit_inspection_protocol
|
||||
sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
xml:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: xml
|
||||
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.6.1"
|
||||
yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: yaml
|
||||
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.13.0 <4.0.0"
|
||||
@@ -1,25 +0,0 @@
|
||||
name: xtremflow_server
|
||||
description: Backend server with API proxy
|
||||
publish_to: 'none'
|
||||
version: 1.0.0+1
|
||||
|
||||
environment:
|
||||
sdk: ^3.13.0
|
||||
|
||||
dependencies:
|
||||
shelf: ^1.4.1
|
||||
shelf_static: ^1.1.2
|
||||
shelf_router: ^1.1.4
|
||||
http: ^1.2.0
|
||||
args: ^2.4.2
|
||||
sqlite3: ^2.4.0
|
||||
crypto: ^3.0.3
|
||||
bcrypt: ^1.1.3
|
||||
path: ^1.9.0
|
||||
uuid: ^4.3.3
|
||||
hive: ^2.2.3
|
||||
equatable: ^2.0.5
|
||||
xml: ^6.5.0
|
||||
|
||||
dev_dependencies:
|
||||
test: ^1.25.0
|
||||
-502
@@ -1,502 +0,0 @@
|
||||
import 'dart:io';
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:shelf/shelf.dart';
|
||||
import 'package:shelf/shelf_io.dart' as shelf_io;
|
||||
import 'package:shelf_static/shelf_static.dart';
|
||||
import 'package:shelf_router/shelf_router.dart';
|
||||
import 'package:args/args.dart';
|
||||
import 'database/database.dart';
|
||||
import 'models/user.dart';
|
||||
import 'models/playlist.dart';
|
||||
import 'models/playlist_config.dart';
|
||||
import 'api/auth_handler.dart';
|
||||
import 'api/users_handler.dart';
|
||||
import 'api/playlists_handler.dart';
|
||||
import 'api/settings_handler.dart';
|
||||
import 'api/streaming_handler.dart';
|
||||
import 'api/proxy_handler.dart';
|
||||
import 'api/recordings_api.dart';
|
||||
import 'api/epg_api.dart';
|
||||
import 'api/logo_api.dart';
|
||||
import 'services/xmltv_epg_service.dart';
|
||||
import 'services/logo_catalog.dart';
|
||||
import 'api/season_passes_api.dart';
|
||||
import 'api/xtream_api_handler.dart';
|
||||
import 'middleware/auth_middleware.dart';
|
||||
import 'middleware/security_middleware.dart';
|
||||
import 'services/cleanup_service.dart';
|
||||
import 'services/recording_scheduler.dart';
|
||||
import 'utils/asset_versioning.dart';
|
||||
import 'api/upstream_probe.dart';
|
||||
import 'api/player_log.dart';
|
||||
|
||||
void main(List<String> args) async {
|
||||
// Parse command line arguments
|
||||
final parser = ArgParser()
|
||||
..addOption('port', abbr: 'p', defaultsTo: '8089')
|
||||
..addOption('path', defaultsTo: '/app/web');
|
||||
|
||||
final result = parser.parse(args);
|
||||
final port = int.parse(result['port']);
|
||||
final webPath = result['path'];
|
||||
|
||||
// Initialize database
|
||||
final db = AppDatabase();
|
||||
await db.init();
|
||||
await db.seedAdmin();
|
||||
|
||||
// Initialize and start Recording Scheduler
|
||||
// (les Season Passes résolvent la playlist de leur propriétaire à chaque
|
||||
// scan : plus d'injection figée du premier utilisateur au démarrage)
|
||||
final recordingScheduler = RecordingScheduler(db);
|
||||
recordingScheduler.start();
|
||||
|
||||
// Initialize Streaming Subsystem
|
||||
await initStreaming();
|
||||
|
||||
// Arrêt gracieux unique (docker stop / Ctrl+C) : clôturer d'abord les
|
||||
// enregistrements (kill FFmpeg, fusion des parties, statut en base), puis
|
||||
// les sessions de streaming, puis sortir. Sans cela les enregistrements
|
||||
// restaient au statut « recording » et la reprise d'orphelins devait
|
||||
// systématiquement rattraper au redémarrage.
|
||||
var shuttingDown = false;
|
||||
Future<void> shutdownServer(String signal) async {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
print('[Server] $signal reçu, arrêt en cours…');
|
||||
try {
|
||||
await recordingScheduler.shutdown();
|
||||
} catch (e) {
|
||||
print('[Server] Erreur à l\'arrêt du scheduler: $e');
|
||||
}
|
||||
sessionManager.killAll();
|
||||
db.close();
|
||||
exit(0);
|
||||
}
|
||||
|
||||
ProcessSignal.sigterm.watch().listen((_) => shutdownServer('SIGTERM'));
|
||||
ProcessSignal.sigint.watch().listen((_) => shutdownServer('SIGINT'));
|
||||
|
||||
// Helper to get playlist from request
|
||||
Future<PlaylistConfig?> getPlaylist(Request request) async {
|
||||
Playlist? playlist;
|
||||
|
||||
final user = request.context['user'] as User?;
|
||||
// If we have a user from auth middleware, prefer their playlists
|
||||
if (user != null) {
|
||||
print('[getPlaylist] User from context: ${user.username}');
|
||||
final playlists = db.getPlaylists(user.id);
|
||||
if (playlists.isNotEmpty) playlist = playlists.first;
|
||||
} else {
|
||||
// Fallback for proxy: get first user's first playlist
|
||||
print('[getPlaylist] No user in context, using fallback');
|
||||
final users = db.getAllUsers();
|
||||
print('[getPlaylist] Total users in DB: ${users.length}');
|
||||
if (users.isNotEmpty) {
|
||||
final playlists = db.getPlaylists(users[0].id);
|
||||
print(
|
||||
'[getPlaylist] User ${users[0].username} has ${playlists.length} playlists',
|
||||
);
|
||||
if (playlists.isNotEmpty) playlist = playlists.first;
|
||||
}
|
||||
}
|
||||
|
||||
if (playlist != null) {
|
||||
print(
|
||||
'[getPlaylist] Returning playlist: ${playlist.name} (DNS: ${playlist.serverUrl})',
|
||||
);
|
||||
return PlaylistConfig(
|
||||
id: playlist.id,
|
||||
name: playlist.name,
|
||||
dns: playlist.serverUrl,
|
||||
username: playlist.username,
|
||||
password: playlist.password,
|
||||
createdAt: playlist.createdAt,
|
||||
isActive: true,
|
||||
);
|
||||
}
|
||||
print('[getPlaylist] WARNING: No playlist found, returning null');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create API handlers
|
||||
final authHandler = AuthHandler(db);
|
||||
final playlistsHandler = PlaylistsHandler(db);
|
||||
final usersHandler = UsersHandler(db);
|
||||
final settingsHandler = SettingsHandler(db);
|
||||
final proxyHandler = ProxyHandler(getPlaylist, db);
|
||||
// Logos de chaînes : URL du panneau d'abord, puis repli par nom sur le
|
||||
// dépôt public tv-logos quand l'hébergeur de picons du revendeur tombe.
|
||||
final logoApi = LogoApi(proxyHandler.handler, LogoCatalog());
|
||||
final recordingsApi = RecordingsApi(db, recordingScheduler);
|
||||
// Source XMLTV de repli. Vider EPG_XMLTV_URLS désactive tout appel sortant :
|
||||
// l'EPG se limite alors au panneau de l'abonné.
|
||||
final xmltvUrls = (Platform.environment['EPG_XMLTV_URLS'] ??
|
||||
'https://epgshare01.online/epgshare01/epg_ripper_FR1.xml.gz,'
|
||||
'https://epgshare01.online/epgshare01/epg_ripper_CH1.xml.gz,'
|
||||
'https://epgshare01.online/epgshare01/epg_ripper_BE2.xml.gz')
|
||||
.split(',')
|
||||
.map((u) => u.trim())
|
||||
.where((u) => u.isNotEmpty)
|
||||
.toList();
|
||||
if (xmltvUrls.isEmpty) {
|
||||
print('[EPG] Repli XMLTV désactivé (EPG_XMLTV_URLS vide)');
|
||||
} else {
|
||||
print('[EPG] Repli XMLTV : ${xmltvUrls.length} source(s)');
|
||||
}
|
||||
final epgApi = EpgApi(
|
||||
getPlaylist,
|
||||
xmltv: xmltvUrls.isEmpty ? null : XmltvEpgService(sourceUrls: xmltvUrls),
|
||||
);
|
||||
final seasonPassesApi = SeasonPassesApi(db);
|
||||
final xtreamApiHandler = XtreamApiHandler(getPlaylist);
|
||||
|
||||
// TV Recordings sub-router (wrapped with auth below).
|
||||
// NOTE: /api/recordings/stream/* falls through this router (404) and is
|
||||
// handled by streamingRouter further down the Cascade.
|
||||
final recordingsRouter = Router()
|
||||
..get('/', recordingsApi.handleGetAll)
|
||||
..post('/', recordingsApi.handlePost)
|
||||
..delete('/<id>', recordingsApi.handleDelete)
|
||||
..post('/stop/<id>', recordingsApi.handleStop)
|
||||
..get('/logs/<id>', recordingsApi.getLogHandler);
|
||||
|
||||
final epgRouter = Router()..get('/<channelId>', epgApi.handleGetEpg);
|
||||
|
||||
final seasonPassesRouter = Router()
|
||||
..get('/', seasonPassesApi.handleGetAll)
|
||||
..post('/', seasonPassesApi.handlePost)
|
||||
..delete('/<id>', seasonPassesApi.handleDelete);
|
||||
|
||||
// Setup router
|
||||
final apiRouter = Router()
|
||||
// Auth endpoints (with per-IP brute-force protection on login)
|
||||
..mount(
|
||||
'/api/auth',
|
||||
const Pipeline()
|
||||
.addMiddleware(loginRateLimitMiddleware())
|
||||
.addHandler(authHandler.router.call),
|
||||
)
|
||||
// Xtream API gateway: injects credentials server-side so the
|
||||
// frontend never sees them
|
||||
..mount(
|
||||
'/api/xtream-api',
|
||||
const Pipeline()
|
||||
.addMiddleware(authMiddleware(db))
|
||||
.addHandler(xtreamApiHandler.handle),
|
||||
)
|
||||
// Playlists endpoints
|
||||
..mount(
|
||||
'/api/playlists',
|
||||
const Pipeline()
|
||||
.addMiddleware(authMiddleware(db))
|
||||
.addHandler(playlistsHandler.router.call),
|
||||
)
|
||||
// Users endpoints
|
||||
..mount(
|
||||
'/api/users',
|
||||
const Pipeline()
|
||||
.addMiddleware(authMiddleware(db))
|
||||
.addHandler(usersHandler.router.call),
|
||||
)
|
||||
// Settings endpoints
|
||||
..mount(
|
||||
'/api/settings',
|
||||
const Pipeline()
|
||||
.addMiddleware(authMiddleware(db))
|
||||
.addHandler(settingsHandler.router.call),
|
||||
)
|
||||
// TV Recordings (auth required)
|
||||
..mount(
|
||||
'/api/recordings',
|
||||
const Pipeline()
|
||||
.addMiddleware(authMiddleware(db))
|
||||
.addHandler(recordingsRouter.call),
|
||||
)
|
||||
// Logos de chaînes avec repli (auth required ; le cookie de session
|
||||
// accompagne les requêtes d'image du navigateur)
|
||||
..get(
|
||||
'/api/logo',
|
||||
const Pipeline()
|
||||
.addMiddleware(authMiddleware(db))
|
||||
.addHandler(logoApi.handle),
|
||||
)
|
||||
// Incidents de lecture remontés par le lecteur web (freezes, erreurs),
|
||||
// écrits dans les logs pour le diagnostic.
|
||||
..post(
|
||||
'/api/player-log',
|
||||
const Pipeline()
|
||||
.addMiddleware(authMiddleware(db))
|
||||
.addHandler(handlePlayerLog),
|
||||
)
|
||||
// EPG - guide TV (auth required)
|
||||
..mount(
|
||||
'/api/epg',
|
||||
const Pipeline()
|
||||
.addMiddleware(authMiddleware(db))
|
||||
.addHandler(epgRouter.call),
|
||||
)
|
||||
// Season Passes - enregistrements répétés (auth required)
|
||||
..mount(
|
||||
'/api/season-passes',
|
||||
const Pipeline()
|
||||
.addMiddleware(authMiddleware(db))
|
||||
.addHandler(seasonPassesRouter.call),
|
||||
);
|
||||
// NOTE: /api/xtream is handled by proxyHandler in the Cascade below
|
||||
// Do NOT mount here as it would intercept and block the actual proxy
|
||||
|
||||
// Initialize Cleanup Service
|
||||
// Ne JAMAIS cibler Directory.systemTemp en récursif : il contient les
|
||||
// temporaires de la VM Dart et le dossier des sessions HLS — les fichiers
|
||||
// de plus de 24 h y étaient supprimés aveuglément.
|
||||
final cleanupService = CleanupService();
|
||||
cleanupService.addTarget(Directory('/app/data/logs'));
|
||||
cleanupService.addTarget(Directory('/app/data/tmp'));
|
||||
|
||||
cleanupService.start();
|
||||
|
||||
// Admin Routes (protected)
|
||||
apiRouter.mount(
|
||||
'/api/admin',
|
||||
const Pipeline()
|
||||
.addMiddleware(authMiddleware(db))
|
||||
.addHandler((Request request) {
|
||||
final router = Router();
|
||||
|
||||
// POST /api/admin/purge
|
||||
router.post('/purge', (Request req) async {
|
||||
final user = req.context['user'] as User?;
|
||||
if (user == null || !user.isAdmin) {
|
||||
return Response.forbidden(
|
||||
jsonEncode({'error': 'Admin access required'}),
|
||||
);
|
||||
}
|
||||
|
||||
final result = await cleanupService.runCleanup();
|
||||
return Response.ok(
|
||||
jsonEncode(result),
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
});
|
||||
|
||||
// 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);
|
||||
}),
|
||||
);
|
||||
|
||||
// Les libs de lecture sont servies depuis l'image, plus depuis un CDN : si
|
||||
// elles manquent (build web incomplet, copie Docker partielle), le lecteur
|
||||
// échoue côté navigateur avec un simple « Échec du chargement » sans que
|
||||
// rien ne l'ait signalé au démarrage. On le dit ici, une fois, clairement.
|
||||
for (final lib in const ['hls.min.js', 'mpegts.min.js']) {
|
||||
final file = File('$webPath/vendor/$lib');
|
||||
if (!file.existsSync()) {
|
||||
print(
|
||||
'[Server] ATTENTION: web/vendor/$lib absent de $webPath — '
|
||||
'la lecture échouera dans le navigateur.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Create static handler
|
||||
final baseStaticHandler = createStaticHandler(
|
||||
webPath,
|
||||
defaultDocument: 'index.html',
|
||||
listDirectories: false,
|
||||
);
|
||||
|
||||
// Points d'entrée réécrits avec `?v=<empreinte>` sur leurs JS : voir
|
||||
// asset_versioning.dart (un proxy qui cache les .js servait l'ancienne
|
||||
// app et l'ancien lecteur après chaque déploiement).
|
||||
final versionedEntryPoints = buildVersionedEntryPoints(webPath);
|
||||
print('[Server] Assets versionnés : ${versionedEntryPoints.keys.join(', ')}');
|
||||
|
||||
// Wrap static handler to enforce cache policies
|
||||
FutureOr<Response> staticHandler(Request request) async {
|
||||
final path = request.url.path;
|
||||
|
||||
final versioned =
|
||||
versionedEntryPoints[path.isEmpty ? 'index.html' : path];
|
||||
if (versioned != null && request.method == 'GET') {
|
||||
return Response.ok(
|
||||
versioned,
|
||||
headers: {
|
||||
'Content-Type': path.endsWith('.js')
|
||||
? 'text/javascript; charset=utf-8'
|
||||
: 'text/html; charset=utf-8',
|
||||
'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
|
||||
'Pragma': 'no-cache',
|
||||
'Expires': '0',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
final response = await baseStaticHandler(request);
|
||||
|
||||
// Disable cache for entry points to ensure updates are seen immediately.
|
||||
// Vendored player libraries (hls.js/mpegts.js, ~750 KB) are pinned
|
||||
// versions: keep them cacheable or every player open re-downloads them.
|
||||
final isVendored = path.startsWith('vendor/');
|
||||
if (!isVendored &&
|
||||
(path.isEmpty ||
|
||||
path.endsWith('.html') ||
|
||||
path.endsWith('.js') ||
|
||||
path.endsWith('.json'))) {
|
||||
return response.change(
|
||||
headers: {
|
||||
'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
|
||||
'Pragma': 'no-cache',
|
||||
'Expires': '0',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Allow aggressive caching for hashed assets
|
||||
return response.change(
|
||||
headers: {
|
||||
'Cache-Control': 'public, max-age=86400',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Create Streaming Router (Mount handlers on correct paths)
|
||||
final streamingRouter = Router()
|
||||
..mount(
|
||||
'/api/live',
|
||||
createLiveStreamHandler(
|
||||
getPlaylist,
|
||||
isGpuEnabled: db.isNvidiaGpuEnabled,
|
||||
),
|
||||
)
|
||||
..mount(
|
||||
'/api/vod',
|
||||
createVodStreamHandler(
|
||||
getPlaylist,
|
||||
isGpuEnabled: db.isNvidiaGpuEnabled,
|
||||
),
|
||||
)
|
||||
..mount(
|
||||
'/api/recordings/stream',
|
||||
createRecordingStreamHandler(
|
||||
db,
|
||||
isGpuEnabled: db.isNvidiaGpuEnabled,
|
||||
),
|
||||
);
|
||||
|
||||
// Proxy Handler (auth is now handled INSIDE the handler, after path check)
|
||||
// This allows non-/api/xtream requests to fall through to static handler
|
||||
final proxyPipeline = proxyHandler.handler;
|
||||
|
||||
// Streaming routes accept the HttpOnly session cookie (hls.js cannot send
|
||||
// Authorization headers); local FFmpeg loopback fetches bypass auth.
|
||||
final protectedStreaming = const Pipeline()
|
||||
.addMiddleware(streamAuthMiddleware(db))
|
||||
.addHandler(streamingRouter.call);
|
||||
|
||||
// Main handler
|
||||
final handler = Cascade()
|
||||
.add(apiRouter.call) /* Standard API endpoints */
|
||||
.add(proxyPipeline) /* Xtream Proxy (auth inside handler) */
|
||||
.add(protectedStreaming) /* Streaming endpoints */
|
||||
.add(staticHandler)
|
||||
.handler;
|
||||
|
||||
// Add middleware
|
||||
// redactedLogRequests remplace logRequests() : l'URI de /api/xtream/<url>
|
||||
// contient username/password Xtream en clair.
|
||||
final pipeline = const Pipeline()
|
||||
.addMiddleware(redactedLogRequests())
|
||||
.addMiddleware(securityHeadersMiddleware())
|
||||
.addMiddleware(honeypotMiddleware())
|
||||
.addMiddleware(rateLimitMiddleware())
|
||||
.addMiddleware(_corsMiddleware())
|
||||
.addHandler(handler);
|
||||
|
||||
// Start server
|
||||
final server = await shelf_io.serve(
|
||||
pipeline,
|
||||
InternetAddress.anyIPv4,
|
||||
port,
|
||||
);
|
||||
|
||||
print('Server started on port ${server.port}');
|
||||
print('Serving static files from: $webPath');
|
||||
print('REST API available at: /api/auth/* and /api/playlists/*');
|
||||
print('Xtream proxy available at: /api/xtream/* (SSRF Protected)');
|
||||
|
||||
// Clean expired sessions periodically (every hour)
|
||||
Timer.periodic(const Duration(hours: 1), (_) {
|
||||
db.cleanExpiredSessions();
|
||||
print('Cleaned expired sessions');
|
||||
});
|
||||
}
|
||||
|
||||
/// CORS middleware.
|
||||
///
|
||||
/// The app is served same-origin by this server, so CORS headers are only
|
||||
/// needed for development (Flutter dev server on another port) or an
|
||||
/// explicitly configured external origin via the ALLOWED_ORIGIN env var.
|
||||
/// The request Origin is echoed back only when it matches the allowlist —
|
||||
/// never a wildcard.
|
||||
Middleware _corsMiddleware() {
|
||||
final extraOrigin = Platform.environment['ALLOWED_ORIGIN'];
|
||||
|
||||
bool isAllowed(String origin) {
|
||||
if (extraOrigin != null && extraOrigin.isNotEmpty && origin == extraOrigin) {
|
||||
return true;
|
||||
}
|
||||
// Local development origins (flutter run -d chrome, etc.)
|
||||
final uri = Uri.tryParse(origin);
|
||||
return uri != null && (uri.host == 'localhost' || uri.host == '127.0.0.1');
|
||||
}
|
||||
|
||||
return (Handler handler) {
|
||||
return (Request request) async {
|
||||
final origin = request.headers['origin'];
|
||||
final headers = <String, String>{
|
||||
if (origin != null && isAllowed(origin)) ...{
|
||||
'Access-Control-Allow-Origin': origin,
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
||||
'Access-Control-Allow-Headers':
|
||||
'Origin, Content-Type, Accept, Authorization, Range',
|
||||
'Access-Control-Expose-Headers': 'Content-Length, Content-Range',
|
||||
'Access-Control-Allow-Credentials': 'true',
|
||||
'Vary': 'Origin',
|
||||
},
|
||||
};
|
||||
|
||||
// Handle preflight requests
|
||||
if (request.method == 'OPTIONS') {
|
||||
return Response.ok('', headers: headers);
|
||||
}
|
||||
|
||||
// Process request and add CORS headers to response
|
||||
final response = await handler(request);
|
||||
return headers.isEmpty ? response : response.change(headers: headers);
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
import 'dart:io';
|
||||
import 'dart:async';
|
||||
|
||||
class CleanupService {
|
||||
final List<Directory> _targetDirectories = [];
|
||||
Timer? _timer;
|
||||
final Duration _defaultMaxAge;
|
||||
|
||||
CleanupService({Duration defaultMaxAge = const Duration(hours: 24)})
|
||||
: _defaultMaxAge = defaultMaxAge;
|
||||
|
||||
/// Add a directory to be monitored
|
||||
void addTarget(Directory dir) {
|
||||
if (!dir.existsSync()) {
|
||||
try {
|
||||
dir.createSync(recursive: true);
|
||||
print('CleanupService: Created missing directory: ${dir.path}');
|
||||
} catch (e) {
|
||||
print('CleanupService: Should monitor ${dir.path} but it does not exist and could not be created: $e');
|
||||
return;
|
||||
}
|
||||
}
|
||||
_targetDirectories.add(dir);
|
||||
print('CleanupService: Monitoring ${dir.path}');
|
||||
}
|
||||
|
||||
/// Start the periodic cleanup task
|
||||
void start({Duration interval = const Duration(hours: 6)}) {
|
||||
_timer?.cancel();
|
||||
print('CleanupService: Starting scheduler (Interval: ${interval.inHours}h)');
|
||||
|
||||
// Run immediately on start
|
||||
runCleanup();
|
||||
|
||||
_timer = Timer.periodic(interval, (_) => runCleanup());
|
||||
}
|
||||
|
||||
/// Stop the scheduler
|
||||
void stop() {
|
||||
_timer?.cancel();
|
||||
print('CleanupService: Scheduler stopped');
|
||||
}
|
||||
|
||||
/// Trigger a manual cleanup
|
||||
Future<Map<String, dynamic>> runCleanup() async {
|
||||
print('CleanupService: Running cleanup task...');
|
||||
int deletedCount = 0;
|
||||
int freedBytes = 0;
|
||||
List<String> errors = [];
|
||||
|
||||
for (final dir in _targetDirectories) {
|
||||
if (!dir.existsSync()) continue;
|
||||
|
||||
try {
|
||||
final result = await _cleanDirectory(dir, _defaultMaxAge);
|
||||
deletedCount += result.count;
|
||||
freedBytes += result.bytes;
|
||||
} catch (e) {
|
||||
print('CleanupService: Error processing ${dir.path}: $e');
|
||||
errors.add('Error in ${dir.path}: $e');
|
||||
}
|
||||
}
|
||||
|
||||
final summary = {
|
||||
'status': 'complete',
|
||||
'deleted_files': deletedCount,
|
||||
'freed_bytes': freedBytes,
|
||||
'timestamp': DateTime.now().toIso8601String(),
|
||||
'errors': errors,
|
||||
};
|
||||
|
||||
print('CleanupService: Cleanup complete. Deleted $deletedCount files, freed ${(freedBytes / 1024 / 1024).toStringAsFixed(2)} MB');
|
||||
return summary;
|
||||
}
|
||||
|
||||
Future<({int count, int bytes})> _cleanDirectory(Directory dir, Duration maxAge) async {
|
||||
int count = 0;
|
||||
int bytes = 0;
|
||||
final now = DateTime.now();
|
||||
final threshold = now.subtract(maxAge);
|
||||
|
||||
try {
|
||||
await for (final entity in dir.list(recursive: true, followLinks: false)) {
|
||||
if (entity is File) {
|
||||
try {
|
||||
final stat = await entity.stat();
|
||||
// Check Modified AND Accessed time (if available) to be safe
|
||||
// Ideally we use modified time for temp files
|
||||
if (stat.modified.isBefore(threshold)) {
|
||||
final fileSize = stat.size;
|
||||
await entity.delete();
|
||||
count++;
|
||||
bytes += fileSize;
|
||||
// print('CleanupService: Deleted ${entity.path} (Age: ${now.difference(stat.modified).inHours}h)');
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore individual file errors (locked files etc)
|
||||
// print('CleanupService: Failed to delete ${entity.path}: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
print('CleanupService: Failed to list ${dir.path}: $e');
|
||||
rethrow;
|
||||
}
|
||||
|
||||
return (count: count, bytes: bytes);
|
||||
}
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import '../utils/log_redactor.dart';
|
||||
|
||||
/// One running FFmpeg transcoding session (live, VOD or recording playback).
|
||||
class FfmpegSession {
|
||||
final String id;
|
||||
final Process process;
|
||||
final Directory dir;
|
||||
final bool isLive;
|
||||
final DateTime startedAt = DateTime.now();
|
||||
DateTime lastAccess = DateTime.now();
|
||||
bool exited = false;
|
||||
int? exitCode;
|
||||
|
||||
/// Last stderr lines, kept for fast-fail diagnostics.
|
||||
final List<String> recentStderr = [];
|
||||
|
||||
FfmpegSession({
|
||||
required this.id,
|
||||
required this.process,
|
||||
required this.dir,
|
||||
required this.isLive,
|
||||
});
|
||||
|
||||
void touch() => lastAccess = DateTime.now();
|
||||
}
|
||||
|
||||
/// Registry of FFmpeg transcoding processes.
|
||||
///
|
||||
/// Responsibilities:
|
||||
/// - one process per session id (`live_{id}_{quality}`, `vod_{id}`, ...)
|
||||
/// - reaper kills sessions idle beyond a TTL (no viewer fetching segments)
|
||||
/// - startup wipe of the HLS temp dir (orphan dirs after a crash)
|
||||
/// - SIGTERM/SIGINT hook so `docker stop` leaves no orphan ffmpeg
|
||||
class FfmpegSessionManager {
|
||||
final Directory baseDir;
|
||||
final Map<String, FfmpegSession> _sessions = {};
|
||||
Timer? _reaper;
|
||||
|
||||
static const liveIdleTimeout = Duration(minutes: 4);
|
||||
static const vodIdleTimeout = Duration(minutes: 15);
|
||||
|
||||
/// Live playlist not rewritten for this long => FFmpeg is wedged.
|
||||
static const liveStallTimeout = Duration(seconds: 45);
|
||||
|
||||
FfmpegSessionManager(this.baseDir);
|
||||
|
||||
/// Wipe orphan session dirs and start the reaper. Call once at startup.
|
||||
Future<void> init() async {
|
||||
if (baseDir.existsSync()) {
|
||||
try {
|
||||
baseDir.deleteSync(recursive: true);
|
||||
} catch (e) {
|
||||
print('[FFmpegManager] Could not wipe temp dir: $e');
|
||||
}
|
||||
}
|
||||
baseDir.createSync(recursive: true);
|
||||
|
||||
_reaper = Timer.periodic(const Duration(seconds: 60), (_) => _reap());
|
||||
|
||||
// L'arrêt propre (docker stop / Ctrl+C) est orchestré par server.dart :
|
||||
// il clôture d'abord les enregistrements puis appelle killAll(). Un
|
||||
// handler local qui ferait exit(0) immédiatement court-circuiterait
|
||||
// cette clôture.
|
||||
}
|
||||
|
||||
FfmpegSession? get(String id) => _sessions[id];
|
||||
|
||||
/// Marks a session as recently used (call from playlist AND segment routes).
|
||||
void touch(String id) => _sessions[id]?.touch();
|
||||
|
||||
bool contains(String id) => _sessions.containsKey(id);
|
||||
|
||||
/// Vrai si [getOrStart] lancerait un nouveau processus FFmpeg pour [id] —
|
||||
/// donc une nouvelle connexion au fournisseur.
|
||||
bool needsStart(String id, {required bool isLive}) {
|
||||
final existing = _sessions[id];
|
||||
if (existing == null) return true;
|
||||
if (!existing.exited) return false;
|
||||
// A VOD/recording transcode that finished cleanly is still fully
|
||||
// playable from its segments — reuse it instead of re-transcoding.
|
||||
return isLive || existing.exitCode != 0 || !_playlistComplete(existing);
|
||||
}
|
||||
|
||||
/// Returns the existing healthy session or starts a new FFmpeg process.
|
||||
///
|
||||
/// [argsBuilder] receives the session working directory and returns the
|
||||
/// FFmpeg argument list. The session directory is recreated for new
|
||||
/// sessions.
|
||||
Future<FfmpegSession> getOrStart({
|
||||
required String id,
|
||||
required bool isLive,
|
||||
required String ffmpegPath,
|
||||
required List<String> Function(Directory dir) argsBuilder,
|
||||
}) async {
|
||||
final existing = _sessions[id];
|
||||
if (existing != null && !needsStart(id, isLive: isLive)) {
|
||||
existing.touch();
|
||||
return existing;
|
||||
}
|
||||
if (existing != null) {
|
||||
// Process died: clean up before restarting
|
||||
killSession(id);
|
||||
}
|
||||
|
||||
final dir = Directory('${baseDir.path}/$id');
|
||||
if (dir.existsSync()) dir.deleteSync(recursive: true);
|
||||
dir.createSync(recursive: true);
|
||||
|
||||
final args = argsBuilder(dir);
|
||||
final process = await Process.start(
|
||||
ffmpegPath,
|
||||
args,
|
||||
workingDirectory: dir.path,
|
||||
);
|
||||
|
||||
final session = FfmpegSession(
|
||||
id: id,
|
||||
process: process,
|
||||
dir: dir,
|
||||
isLive: isLive,
|
||||
);
|
||||
_sessions[id] = session;
|
||||
|
||||
process.stderr.transform(utf8.decoder).listen((data) {
|
||||
session.recentStderr.add(data);
|
||||
if (session.recentStderr.length > 20) session.recentStderr.removeAt(0);
|
||||
// FFmpeg rappelle l'URL d'entrée (« Input #0 … from 'http://…' ») :
|
||||
// masquer les identifiants Xtream avant de journaliser.
|
||||
print('[FFmpeg $id] ${LogRedactor.redactUrl(data)}');
|
||||
});
|
||||
|
||||
process.exitCode.then((code) {
|
||||
session.exited = true;
|
||||
session.exitCode = code;
|
||||
print('[FFmpegManager] Session $id exited with code $code');
|
||||
});
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
bool _playlistComplete(FfmpegSession session) {
|
||||
final playlistFile = File('${session.dir.path}/playlist.m3u8');
|
||||
if (!playlistFile.existsSync()) return false;
|
||||
try {
|
||||
return playlistFile.readAsStringSync().contains('#EXT-X-ENDLIST');
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Waits until the session's playlist references at least [minSegments]
|
||||
/// segments. Fails fast when the process dies before producing output,
|
||||
/// returning the recent stderr for diagnostics.
|
||||
///
|
||||
/// [minSegments] > 1 donne au lecteur une avance de démarrage : servir la
|
||||
/// playlist dès le premier segment le fait partir avec 4 s de marge sur un
|
||||
/// encodeur qui n'a pas fini — la moindre hésitation coupe la lecture. La
|
||||
/// playlist déjà close (`#EXT-X-ENDLIST`) est renvoyée telle quelle, quel
|
||||
/// que soit son nombre de segments : rien de plus n'arrivera.
|
||||
Future<({bool ready, String? error})> waitForPlaylist(
|
||||
FfmpegSession session, {
|
||||
Duration timeout = const Duration(seconds: 30),
|
||||
int minSegments = 1,
|
||||
}) async {
|
||||
final playlistFile = File('${session.dir.path}/playlist.m3u8');
|
||||
final deadline = DateTime.now().add(timeout);
|
||||
|
||||
while (DateTime.now().isBefore(deadline)) {
|
||||
if (playlistFile.existsSync()) {
|
||||
final content = playlistFile.readAsStringSync();
|
||||
final segments = '.ts'.allMatches(content).length;
|
||||
if (segments >= minSegments ||
|
||||
(segments > 0 && content.contains('#EXT-X-ENDLIST'))) {
|
||||
return (ready: true, error: null);
|
||||
}
|
||||
}
|
||||
if (session.exited) {
|
||||
// Un encodeur qui s'est terminé proprement en ayant produit moins de
|
||||
// segments que demandé a simplement fini : c'est un succès.
|
||||
if (session.exitCode == 0 &&
|
||||
playlistFile.existsSync() &&
|
||||
playlistFile.readAsStringSync().contains('.ts')) {
|
||||
return (ready: true, error: null);
|
||||
}
|
||||
return (
|
||||
ready: false,
|
||||
error: 'FFmpeg exited (${session.exitCode}): '
|
||||
'${session.recentStderr.join().trim()}'
|
||||
);
|
||||
}
|
||||
// 100 ms : à 500 ms, on ajoutait en moyenne un quart de seconde de
|
||||
// latence pure entre la disponibilité de la playlist et sa réponse.
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
// Le délai est écoulé : mieux vaut servir ce qui existe que rien.
|
||||
if (playlistFile.existsSync() &&
|
||||
playlistFile.readAsStringSync().contains('.ts')) {
|
||||
return (ready: true, error: null);
|
||||
}
|
||||
return (ready: false, error: 'Timeout waiting for transcoder');
|
||||
}
|
||||
|
||||
void killSession(String id) {
|
||||
final session = _sessions.remove(id);
|
||||
if (session == null) return;
|
||||
try {
|
||||
session.process.kill(ProcessSignal.sigterm);
|
||||
} catch (_) {}
|
||||
try {
|
||||
if (session.dir.existsSync()) session.dir.deleteSync(recursive: true);
|
||||
} catch (e) {
|
||||
print('[FFmpegManager] Could not delete dir for $id: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Tue les sessions frères d'un même contenu (`rec_<id>_t0`, `rec_<id>_t600`…)
|
||||
/// que plus personne ne consomme depuis [idleFor].
|
||||
///
|
||||
/// Chaque saut hors de la zone déjà transcodée démarre un FFmpeg à un
|
||||
/// nouvel offset ; sans ce ménage, une poignée d'allers-retours dans la
|
||||
/// barre de progression laisserait autant de processus vivants jusqu'au
|
||||
/// TTL de 15 minutes. Le délai d'inactivité protège un second spectateur
|
||||
/// du même enregistrement : sa session est « touchée » à chaque segment.
|
||||
void killIdleSiblings(
|
||||
String prefix, {
|
||||
required String keep,
|
||||
Duration idleFor = const Duration(seconds: 30),
|
||||
}) {
|
||||
final now = DateTime.now();
|
||||
for (final session in _sessions.values.toList()) {
|
||||
if (session.id == keep || !session.id.startsWith(prefix)) continue;
|
||||
if (now.difference(session.lastAccess) < idleFor) continue;
|
||||
print('[FFmpegManager] Dropping stale sibling ${session.id}');
|
||||
killSession(session.id);
|
||||
}
|
||||
}
|
||||
|
||||
void killAll() {
|
||||
print('[FFmpegManager] Killing ${_sessions.length} session(s)');
|
||||
for (final id in _sessions.keys.toList()) {
|
||||
killSession(id);
|
||||
}
|
||||
_reaper?.cancel();
|
||||
}
|
||||
|
||||
void _reap() {
|
||||
final now = DateTime.now();
|
||||
for (final session in _sessions.values.toList()) {
|
||||
final timeout = session.isLive ? liveIdleTimeout : vodIdleTimeout;
|
||||
final idle = now.difference(session.lastAccess);
|
||||
|
||||
// Exited sessions keep their segments until the idle timeout: a
|
||||
// finished VOD transcode is usually still being watched, and killing
|
||||
// it here would delete the segments mid-playback.
|
||||
if (idle > timeout) {
|
||||
print(
|
||||
'[FFmpegManager] Reaping idle session ${session.id} '
|
||||
'(idle ${idle.inSeconds}s)',
|
||||
);
|
||||
killSession(session.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Watchdog: a live FFmpeg that is running but has not updated its
|
||||
// playlist recently is wedged on a stalled upstream. Kill it so the
|
||||
// player's next playlist poll restarts the transcoder.
|
||||
if (session.isLive && !session.exited && _isStalled(session, now)) {
|
||||
print('[FFmpegManager] Restarting stalled live session ${session.id}');
|
||||
killSession(session.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A live session is stalled when its playlist exists but has not been
|
||||
/// rewritten for [liveStallTimeout] (FFmpeg rewrites it on every segment).
|
||||
bool _isStalled(FfmpegSession session, DateTime now) {
|
||||
final playlistFile = File('${session.dir.path}/playlist.m3u8');
|
||||
if (!playlistFile.existsSync()) {
|
||||
// Never produced output: give it until liveStallTimeout after start.
|
||||
return now.difference(session.startedAt) > liveStallTimeout;
|
||||
}
|
||||
try {
|
||||
return now.difference(playlistFile.lastModifiedSync()) >
|
||||
liveStallTimeout;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
/// Repli GPU → CPU des transcodages.
|
||||
///
|
||||
/// Quand la VRAM est saturée (plusieurs sessions NVENC, un autre processus
|
||||
/// sur la carte) ou que le GPU dépasse son quota de sessions d'encodage,
|
||||
/// FFmpeg échoue à l'ouverture de l'encodeur et meurt en une seconde. Sans
|
||||
/// repli, le lecteur recevait un 502, relançait… et retombait sur le même
|
||||
/// GPU saturé : le flux restait illisible tant que la carte était pleine.
|
||||
|
||||
/// Signatures FFmpeg d'un échec imputable au GPU NVIDIA (NVENC/NVDEC/CUDA).
|
||||
///
|
||||
/// N'est consulté que pour une session lancée sur le GPU : « opening
|
||||
/// encoder » y désigne donc forcément h264_nvenc.
|
||||
final _gpuFailurePattern = RegExp(
|
||||
r'nvenc|cuda|cuvid|nvdec|OpenEncodeSession|No capable devices|'
|
||||
r'out of memory|Device creation failed|hwaccel|opening encoder',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
/// Vrai si [error] (stderr récent de FFmpeg) trahit une panne GPU, et non
|
||||
/// une source injoignable ou un délai dépassé — qu'un repli CPU ne
|
||||
/// réglerait pas, et qui doublerait inutilement la charge d'un serveur déjà
|
||||
/// occupé.
|
||||
bool isGpuFailure(String? error) =>
|
||||
error != null && _gpuFailurePattern.hasMatch(error);
|
||||
|
||||
/// Mémorise une panne GPU récente pour que les sessions suivantes partent
|
||||
/// directement sur le CPU, au lieu de payer chacune un échec NVENC avant
|
||||
/// leur repli.
|
||||
class GpuHealth {
|
||||
final Duration cooldown;
|
||||
final DateTime Function() _now;
|
||||
DateTime? _unavailableUntil;
|
||||
|
||||
GpuHealth({
|
||||
this.cooldown = const Duration(minutes: 2),
|
||||
DateTime Function()? now,
|
||||
}) : _now = now ?? DateTime.now;
|
||||
|
||||
bool get available =>
|
||||
_unavailableUntil == null || !_now().isBefore(_unavailableUntil!);
|
||||
|
||||
void markFailed() => _unavailableUntil = _now().add(cooldown);
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
/// Logos de chaînes de repli, tirés du dépôt public `tv-logo/tv-logos`.
|
||||
///
|
||||
/// POURQUOI : les URL `stream_icon` du panneau pointent chez l'hébergeur du
|
||||
/// revendeur. Quand cette machine tombe — cas observé : serveur de picons en
|
||||
/// maintenance, 503 sur toutes les URL —, toute la grille reste sans logo.
|
||||
/// Le dépôt `tv-logos` est maintenu, nommé de façon régulière
|
||||
/// (`countries/france/rmc-sport-2-fr.png`) et s'apparie au nom de chaîne.
|
||||
class LogoCatalog {
|
||||
LogoCatalog({
|
||||
http.Client? client,
|
||||
this.listingTtl = const Duration(hours: 24),
|
||||
this.retryBackoff = const Duration(minutes: 15),
|
||||
}) : _client = client ?? http.Client();
|
||||
|
||||
final http.Client _client;
|
||||
|
||||
/// Durée de vie d'une liste de logos par pays. L'API GitHub non
|
||||
/// authentifiée plafonne à 60 requêtes par heure : une par jour suffit.
|
||||
final Duration listingTtl;
|
||||
|
||||
/// Recul après un échec de téléchargement de liste (limite de débit,
|
||||
/// réseau), pour ne pas marteler l'API à chaque tuile affichée.
|
||||
final Duration retryBackoff;
|
||||
|
||||
final Map<String, _Listing> _listings = {};
|
||||
final Map<String, Future<_Listing?>> _listingsInFlight = {};
|
||||
final Map<String, DateTime> _listingFailedAt = {};
|
||||
final Map<String, List<int>> _bytes = {};
|
||||
|
||||
/// Octets PNG du logo correspondant à [channelName], ou `null`.
|
||||
Future<List<int>?> logoFor(String channelName) async {
|
||||
final country = countryFolderFor(channelName);
|
||||
final listing = await _listingFor(country);
|
||||
if (listing == null) return null;
|
||||
|
||||
final key = matchLogoKey(channelName, listing.urls.keys);
|
||||
if (key == null) return null;
|
||||
|
||||
final url = listing.urls[key]!;
|
||||
final cached = _bytes[url];
|
||||
if (cached != null) return cached;
|
||||
|
||||
try {
|
||||
final response = await _client
|
||||
.get(Uri.parse(url))
|
||||
.timeout(const Duration(seconds: 20));
|
||||
if (response.statusCode != 200) return null;
|
||||
return _bytes[url] = response.bodyBytes;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<_Listing?> _listingFor(String country) async {
|
||||
final cached = _listings[country];
|
||||
if (cached != null &&
|
||||
DateTime.now().difference(cached.fetchedAt) < listingTtl) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
final failedAt = _listingFailedAt[country];
|
||||
if (failedAt != null &&
|
||||
DateTime.now().difference(failedAt) < retryBackoff) {
|
||||
// Liste périmée plutôt que rien : les logos ne changent pas d'un jour
|
||||
// à l'autre.
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Les tuiles d'une grille arrivent en rafale : une seule requête de liste.
|
||||
return _listingsInFlight[country] ??=
|
||||
_fetchListing(country).whenComplete(() {
|
||||
_listingsInFlight.remove(country);
|
||||
});
|
||||
}
|
||||
|
||||
Future<_Listing?> _fetchListing(String country) async {
|
||||
final url = 'https://api.github.com/repos/tv-logo/tv-logos/contents/'
|
||||
'countries/$country';
|
||||
try {
|
||||
final response = await _client.get(
|
||||
Uri.parse(url),
|
||||
headers: {'Accept': 'application/vnd.github+json'},
|
||||
).timeout(const Duration(seconds: 20));
|
||||
if (response.statusCode != 200) {
|
||||
throw StateError('HTTP ${response.statusCode}');
|
||||
}
|
||||
|
||||
final urls = <String, String>{};
|
||||
final decoded = json.decode(response.body);
|
||||
if (decoded is List) {
|
||||
for (final entry in decoded) {
|
||||
if (entry is! Map) continue;
|
||||
final name = entry['name']?.toString() ?? '';
|
||||
final download = entry['download_url']?.toString();
|
||||
if (download == null || !name.endsWith('.png')) continue;
|
||||
urls[_keyFromFileName(name)] = download;
|
||||
}
|
||||
}
|
||||
|
||||
final listing = _Listing(urls, DateTime.now());
|
||||
_listings[country] = listing;
|
||||
_listingFailedAt.remove(country);
|
||||
print('[LogoCatalog] $country : ${urls.length} logos');
|
||||
return listing;
|
||||
} catch (e) {
|
||||
_listingFailedAt[country] = DateTime.now();
|
||||
print('[LogoCatalog] liste $country indisponible : $e');
|
||||
return _listings[country];
|
||||
}
|
||||
}
|
||||
|
||||
/// `rmc-sport-2-fr.png` → `rmc-sport-2`.
|
||||
static String _keyFromFileName(String name) {
|
||||
final base = name.substring(0, name.length - '.png'.length);
|
||||
return base.replaceFirst(RegExp(r'-[a-z]{2}$'), '');
|
||||
}
|
||||
}
|
||||
|
||||
class _Listing {
|
||||
_Listing(this.urls, this.fetchedAt);
|
||||
|
||||
/// Clé normalisée → URL de téléchargement brute.
|
||||
final Map<String, String> urls;
|
||||
final DateTime fetchedAt;
|
||||
}
|
||||
|
||||
/// Code pays du préfixe panneau → dossier du dépôt `tv-logos`.
|
||||
const _countryFolders = {
|
||||
'fr': 'france',
|
||||
'be': 'belgium',
|
||||
'ch': 'switzerland',
|
||||
'uk': 'united-kingdom',
|
||||
'gb': 'united-kingdom',
|
||||
'us': 'united-states',
|
||||
'ca': 'canada',
|
||||
'de': 'germany',
|
||||
'es': 'spain',
|
||||
'it': 'italy',
|
||||
'pt': 'portugal',
|
||||
'nl': 'netherlands',
|
||||
};
|
||||
|
||||
/// Préfixe pays des noms panneau : `FR - `, `FR: `, `|FR| `.
|
||||
final _countryPrefix = RegExp(r'^\W*([A-Za-z]{2})[\s\-:|]+');
|
||||
|
||||
/// Dossier `tv-logos` à consulter pour [channelName]. France par défaut :
|
||||
/// c'est l'audience de l'application.
|
||||
String countryFolderFor(String channelName) {
|
||||
final match = _countryPrefix.firstMatch(channelName);
|
||||
final code = match?.group(1)?.toLowerCase();
|
||||
return _countryFolders[code] ?? 'france';
|
||||
}
|
||||
|
||||
/// Mots sans rapport avec l'identité de la chaîne : qualité, codec, variante
|
||||
/// de flux. Ils sont propres au panneau et absents des noms de logos.
|
||||
const _noise = {
|
||||
'fhd', 'uhd', 'hd', 'sd', '4k', '8k', 'hdr', 'hevc', 'h264', 'h265',
|
||||
'1080p', '720p', '50fps', 'fps', 'raw', 'backup', 'vip', 'multi',
|
||||
'vf', 'vo', 'vostfr',
|
||||
};
|
||||
|
||||
const _accents = {
|
||||
'à': 'a', 'á': 'a', 'â': 'a', 'ä': 'a', 'è': 'e', 'é': 'e', 'ê': 'e',
|
||||
'ë': 'e', 'ì': 'i', 'í': 'i', 'î': 'i', 'ï': 'i', 'ò': 'o', 'ó': 'o',
|
||||
'ô': 'o', 'ö': 'o', 'ù': 'u', 'ú': 'u', 'û': 'u', 'ü': 'u', 'ç': 'c',
|
||||
};
|
||||
|
||||
List<String> _tokens(String channelName) {
|
||||
var name = channelName;
|
||||
final prefix = _countryPrefix.firstMatch(name);
|
||||
if (prefix != null &&
|
||||
_countryFolders.containsKey(prefix.group(1)!.toLowerCase())) {
|
||||
name = name.substring(prefix.end);
|
||||
}
|
||||
|
||||
final folded = StringBuffer();
|
||||
for (final rune in name.toLowerCase().runes) {
|
||||
final char = String.fromCharCode(rune);
|
||||
folded.write(_accents[char] ?? char);
|
||||
}
|
||||
|
||||
return folded
|
||||
.toString()
|
||||
.replaceAll('+', ' plus ')
|
||||
.replaceAll('&', ' and ')
|
||||
.split(RegExp(r'[^a-z0-9]+'))
|
||||
.where((t) => t.isNotEmpty && !_noise.contains(t))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Clé de logo la plus proche de [channelName] parmi [available], ou `null`.
|
||||
///
|
||||
/// Par ordre de confiance : nom exact, nom sans « live », variante plus
|
||||
/// longue du même identifiant (`bein-sports-1` → `bein-sports-1-french`),
|
||||
/// puis la marque sans numéro de canal (`rmc-sport-live-5` → `rmc-sport-1`).
|
||||
/// Aucune approximation sur un mot seul : « France » ne doit pas devenir
|
||||
/// France 24.
|
||||
String? matchLogoKey(String channelName, Iterable<String> available) {
|
||||
final keys = available.toSet();
|
||||
final tokens = _tokens(channelName);
|
||||
if (tokens.isEmpty) return null;
|
||||
|
||||
List<String> withoutLive(List<String> parts) =>
|
||||
parts.where((t) => t != 'live').toList();
|
||||
|
||||
String? exact(List<String> parts) {
|
||||
final slug = parts.join('-');
|
||||
return parts.isNotEmpty && keys.contains(slug) ? slug : null;
|
||||
}
|
||||
|
||||
String? longerVariant(List<String> parts) {
|
||||
if (parts.length < 2) return null;
|
||||
final slug = parts.join('-');
|
||||
final longer = keys.where((k) => k.startsWith('$slug-')).toList()
|
||||
..sort((a, b) {
|
||||
final byLength = a.length.compareTo(b.length);
|
||||
return byLength != 0 ? byLength : a.compareTo(b);
|
||||
});
|
||||
return longer.isEmpty ? null : longer.first;
|
||||
}
|
||||
|
||||
final noLive = withoutLive(tokens);
|
||||
final direct = exact(tokens) ??
|
||||
exact(noLive) ??
|
||||
longerVariant(tokens) ??
|
||||
longerVariant(noLive);
|
||||
if (direct != null) return direct;
|
||||
|
||||
// Marque sans numéro de canal, seulement s'il reste au moins deux mots.
|
||||
if (RegExp(r'^\d+$').hasMatch(tokens.last)) {
|
||||
final brand = withoutLive(tokens.sublist(0, tokens.length - 1));
|
||||
if (brand.length >= 2) return exact(brand) ?? longerVariant(brand);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
/// Pure decision logic for the recording scheduler, extracted for testability.
|
||||
|
||||
enum RecordingAction {
|
||||
/// Start the recording now.
|
||||
start,
|
||||
|
||||
/// Capacity reached but the window is still open: retry on a later tick.
|
||||
wait,
|
||||
|
||||
/// The end time passed before the recording could start.
|
||||
fail,
|
||||
|
||||
/// Not yet due — nothing to do.
|
||||
none,
|
||||
}
|
||||
|
||||
/// Decides what to do with a scheduled recording on a scheduler tick.
|
||||
RecordingAction decideRecordingAction({
|
||||
required DateTime now,
|
||||
required DateTime startTime,
|
||||
required DateTime endTime,
|
||||
required int activeCount,
|
||||
required int maxConcurrent,
|
||||
}) {
|
||||
final nowUtc = now.toUtc();
|
||||
final startUtc = startTime.toUtc();
|
||||
final endUtc = endTime.toUtc();
|
||||
|
||||
if (nowUtc.isAfter(endUtc)) return RecordingAction.fail;
|
||||
if (!nowUtc.isAfter(startUtc)) return RecordingAction.none;
|
||||
if (activeCount >= maxConcurrent) return RecordingAction.wait;
|
||||
return RecordingAction.start;
|
||||
}
|
||||
|
||||
/// Ce que le planificateur doit faire d'un enregistrement resté au statut
|
||||
/// « recording » en base alors qu'aucun processus FFmpeg ne lui correspond
|
||||
/// (redémarrage du conteneur, crash de l'isolate…).
|
||||
enum OrphanAction {
|
||||
/// La fenêtre est encore ouverte : relancer la capture.
|
||||
resume,
|
||||
|
||||
/// La fenêtre est passée mais un fichier partiel existe : le conserver.
|
||||
finish,
|
||||
|
||||
/// Rien d'exploitable n'a été capturé.
|
||||
fail,
|
||||
}
|
||||
|
||||
/// Décide du sort d'un enregistrement orphelin détecté au démarrage/à un tick.
|
||||
OrphanAction decideOrphanAction({
|
||||
required DateTime now,
|
||||
required DateTime endTime,
|
||||
required bool hasFile,
|
||||
Duration minRemaining = const Duration(seconds: 60),
|
||||
}) {
|
||||
final remaining = endTime.toUtc().difference(now.toUtc());
|
||||
if (remaining > minRemaining) return OrphanAction.resume;
|
||||
return hasFile ? OrphanAction.finish : OrphanAction.fail;
|
||||
}
|
||||
|
||||
/// Ce que le planificateur doit faire quand un processus FFmpeg se termine.
|
||||
enum PostExitAction {
|
||||
/// La capture est allée jusqu'au bout (ou assez loin) : marquer terminé.
|
||||
complete,
|
||||
|
||||
/// FFmpeg s'est arrêté trop tôt : relancer la capture sur la fin de fenêtre.
|
||||
retry,
|
||||
|
||||
/// Arrêt prématuré sans rien d'exploitable.
|
||||
fail,
|
||||
}
|
||||
|
||||
/// Nombre d'échecs FFmpeg consécutifs tolérés avant d'abandonner un
|
||||
/// enregistrement dont la fenêtre est encore ouverte.
|
||||
///
|
||||
/// Le compteur est remis à zéro dès qu'un lancement a duré assez longtemps
|
||||
/// pour être considéré sain : une coupure toutes les dix minutes ne doit pas
|
||||
/// finir par épuiser le quota.
|
||||
const int maxFfmpegAttempts = 30;
|
||||
|
||||
/// Décide de la suite à donner à la sortie d'un processus FFmpeg.
|
||||
///
|
||||
/// FFmpeg rend la main dès que la source se tarit : sur un flux IPTV, une
|
||||
/// coupure amont à mi-parcours ne doit pas condamner l'heure restante.
|
||||
PostExitAction decidePostExitAction({
|
||||
required DateTime now,
|
||||
required DateTime endTime,
|
||||
required int exitCode,
|
||||
required int consecutiveFailures,
|
||||
required bool hasFile,
|
||||
int maxAttempts = maxFfmpegAttempts,
|
||||
Duration minRemaining = const Duration(seconds: 60),
|
||||
}) {
|
||||
final remaining = endTime.toUtc().difference(now.toUtc());
|
||||
if (remaining > minRemaining && consecutiveFailures + 1 < maxAttempts) {
|
||||
return PostExitAction.retry;
|
||||
}
|
||||
// `255` est le code renvoyé par FFmpeg quand on l'interrompt proprement.
|
||||
final cleanExit = exitCode == 0 || exitCode == 255;
|
||||
return (cleanExit || hasFile) ? PostExitAction.complete : PostExitAction.fail;
|
||||
}
|
||||
|
||||
/// Durée de capture à demander à FFmpeg (`-t`).
|
||||
///
|
||||
/// Se base sur le temps qu'il reste jusqu'à la fin programmée, et non sur la
|
||||
/// durée théorique du programme : un démarrage tardif (ou une relance après
|
||||
/// coupure) ne doit pas décaler la fin de l'enregistrement.
|
||||
Duration captureDuration({
|
||||
required DateTime now,
|
||||
required DateTime endTime,
|
||||
Duration minimum = const Duration(seconds: 30),
|
||||
}) {
|
||||
final remaining = endTime.toUtc().difference(now.toUtc());
|
||||
return remaining < minimum ? minimum : remaining;
|
||||
}
|
||||
|
||||
/// Délai avant relance de FFmpeg, croissant avec les échecs consécutifs.
|
||||
///
|
||||
/// Rouvrir la source dans la seconde se fait refuser sur les comptes limités
|
||||
/// en connexions simultanées ; s'acharner à cette cadence pendant une panne
|
||||
/// amont ne ferait qu'épuiser le quota de relances.
|
||||
Duration ffmpegRetryDelay(int consecutiveFailures) {
|
||||
final steps = (consecutiveFailures - 1).clamp(0, 4);
|
||||
final seconds = 3 * (1 << steps);
|
||||
return Duration(seconds: seconds > 30 ? 30 : seconds);
|
||||
}
|
||||
@@ -1,970 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../database/database.dart';
|
||||
import '../models/recording.dart';
|
||||
import '../utils/log_redactor.dart';
|
||||
import 'recording_decision.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
/// Dossier où sont écrits les enregistrements et leurs logs.
|
||||
final String recordingsDirPath =
|
||||
Platform.environment['RECORDINGS_DIR'] ?? '/app/recordings';
|
||||
|
||||
/// Binaire FFmpeg utilisé pour la capture et la fusion des parties.
|
||||
Future<String> resolveFfmpegPath() async {
|
||||
final fromEnv = Platform.environment['FFMPEG_PATH'];
|
||||
if (fromEnv != null && fromEnv.isNotEmpty) return fromEnv;
|
||||
if (Platform.isLinux && await File('/usr/local/bin/ffmpeg').exists()) {
|
||||
return '/usr/local/bin/ffmpeg';
|
||||
}
|
||||
return 'ffmpeg'; // Depuis le PATH
|
||||
}
|
||||
|
||||
/// Écrivain de log tolérant aux fermetures.
|
||||
///
|
||||
/// FFmpeg continue de livrer des morceaux de `stderr` après la résolution de
|
||||
/// `exitCode` : écrire sur un `IOSink` déjà fermé lève une `StateError` depuis
|
||||
/// un callback de stream, c'est-à-dire une erreur asynchrone non rattrapée qui
|
||||
/// tue l'isolate — et donc le serveur entier, laissant l'enregistrement en
|
||||
/// cours au statut « recording » (« Interruption inattendue du serveur »).
|
||||
class _RecordingLog {
|
||||
final IOSink _sink;
|
||||
bool _closed = false;
|
||||
|
||||
_RecordingLog(this._sink) {
|
||||
// Une erreur d'écriture disque ne doit pas non plus remonter en erreur
|
||||
// asynchrone non rattrapée.
|
||||
_sink.done.catchError((_) {});
|
||||
}
|
||||
|
||||
/// Ouvre le log, ou renvoie `null` si le fichier n'est pas créable :
|
||||
/// l'enregistrement reste prioritaire sur sa journalisation.
|
||||
static _RecordingLog? open(String path, {bool append = false}) {
|
||||
try {
|
||||
return _RecordingLog(
|
||||
File(path).openWrite(mode: append ? FileMode.append : FileMode.write),
|
||||
);
|
||||
} catch (e) {
|
||||
print(
|
||||
'[RecordingScheduler] AVERTISSEMENT: log indisponible ($path): $e',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
void writeln([String line = '']) => _guard(() => _sink.writeln(line));
|
||||
|
||||
void add(List<int> data) => _guard(() => _sink.add(data));
|
||||
|
||||
void _guard(void Function() action) {
|
||||
if (_closed) return;
|
||||
try {
|
||||
action();
|
||||
} catch (_) {
|
||||
// Sink cassé : on arrête d'écrire plutôt que de propager.
|
||||
_closed = true;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> close() async {
|
||||
if (_closed) return;
|
||||
_closed = true;
|
||||
try {
|
||||
await _sink.close();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
class _ActiveRecording {
|
||||
final Recording recording;
|
||||
|
||||
/// Fichier principal (celui exposé par l'API).
|
||||
final String filePath;
|
||||
|
||||
final _RecordingLog? log;
|
||||
|
||||
/// Fichiers additionnels écrits après une relance, dans l'ordre.
|
||||
final List<String> extraParts;
|
||||
|
||||
/// Index du lancement FFmpeg en cours (0 = fichier principal).
|
||||
int attempt;
|
||||
|
||||
/// Échecs consécutifs de FFmpeg, remis à zéro dès qu'une capture tient
|
||||
/// assez longtemps pour être considérée saine.
|
||||
int consecutiveFailures = 0;
|
||||
|
||||
/// Heure du dernier lancement de FFmpeg.
|
||||
DateTime launchedAt = DateTime.now();
|
||||
|
||||
Process? process;
|
||||
|
||||
/// Arrêt volontaire : la sortie de FFmpeg ne doit pas déclencher de relance.
|
||||
bool stopping = false;
|
||||
|
||||
_ActiveRecording({
|
||||
required this.recording,
|
||||
required this.filePath,
|
||||
required this.log,
|
||||
this.attempt = 0,
|
||||
List<String>? extraParts,
|
||||
}) : extraParts = extraParts ?? <String>[];
|
||||
}
|
||||
|
||||
class RecordingScheduler {
|
||||
final AppDatabase _db;
|
||||
Timer? _timer;
|
||||
Timer? _seasonPassTimer;
|
||||
bool _isRunning = false;
|
||||
|
||||
/// Enregistrements actuellement en cours, indexés par id.
|
||||
final Map<String, _ActiveRecording> _active = {};
|
||||
|
||||
/// Nombre maximum d'enregistrements simultanés (env MAX_CONCURRENT_RECORDINGS).
|
||||
final int maxConcurrent = int.tryParse(
|
||||
Platform.environment['MAX_CONCURRENT_RECORDINGS'] ?? '',
|
||||
) ??
|
||||
2;
|
||||
|
||||
/// Espace libre minimal (Mo) exigé pour démarrer une capture.
|
||||
final int minFreeDiskMb = int.tryParse(
|
||||
Platform.environment['MIN_FREE_DISK_MB'] ?? '',
|
||||
) ??
|
||||
500;
|
||||
|
||||
/// Quota du dossier d'enregistrements en Go (0 = rotation désactivée).
|
||||
/// Remplace l'ancienne rotation « max 50 fichiers » qui supprimait
|
||||
/// aveuglément, y compris des enregistrements en cours d'écriture.
|
||||
final int recordingsQuotaGb = int.tryParse(
|
||||
Platform.environment['RECORDINGS_QUOTA_GB'] ?? '',
|
||||
) ??
|
||||
0;
|
||||
|
||||
/// Nombre maximal d'enregistrements créés par season pass et par scan.
|
||||
final int seasonPassMaxPerScan = int.tryParse(
|
||||
Platform.environment['SEASON_PASS_MAX_PER_SCAN'] ?? '',
|
||||
) ??
|
||||
10;
|
||||
|
||||
RecordingScheduler(this._db);
|
||||
|
||||
/// Un enregistrement est-il activement capturé par un processus FFmpeg ?
|
||||
bool isCapturing(String id) => _active.containsKey(id);
|
||||
|
||||
/// Nombre de relances FFmpeg de l'enregistrement actif [id] (null si inactif).
|
||||
int? retryCountOf(String id) => _active[id]?.consecutiveFailures;
|
||||
|
||||
void start() {
|
||||
print(
|
||||
'[RecordingScheduler] Démarrage du planificateur d\'enregistrements TV '
|
||||
'(max simultanés: $maxConcurrent)',
|
||||
);
|
||||
// Vérifier toutes les 10 secondes (pour un démarrage quasi-immédiat)
|
||||
_timer = Timer.periodic(
|
||||
const Duration(seconds: 10),
|
||||
(_) => _checkAndRunRecordings(),
|
||||
);
|
||||
// Season Passes : vérifier toutes les 4 heures
|
||||
_seasonPassTimer =
|
||||
Timer.periodic(const Duration(hours: 4), (_) => _checkSeasonPasses());
|
||||
// Lancer une première vérification immédiatement
|
||||
_checkAndRunRecordings();
|
||||
// Vérification initiale des season passes après 30s (laisser le serveur démarrer)
|
||||
Timer(const Duration(seconds: 30), _checkSeasonPasses);
|
||||
}
|
||||
|
||||
void stop() {
|
||||
_timer?.cancel();
|
||||
_seasonPassTimer?.cancel();
|
||||
for (final id in _active.keys.toList()) {
|
||||
_stopActiveRecording(id, reason: 'Arrêt du service planificateur');
|
||||
}
|
||||
print('[RecordingScheduler] Arrêté');
|
||||
}
|
||||
|
||||
/// Arrêt gracieux pour un `docker stop` : arrête les timers, clôt chaque
|
||||
/// enregistrement actif (kill FFmpeg, fusion des parties, statut en base)
|
||||
/// et attend la fin des clôtures dans la limite de [timeout].
|
||||
///
|
||||
/// Sans cette attente, le conteneur meurt avant la clôture : les
|
||||
/// enregistrements restent au statut « recording » et la reprise d'orphelins
|
||||
/// doit systématiquement rattraper au redémarrage.
|
||||
Future<void> shutdown({Duration timeout = const Duration(seconds: 8)}) async {
|
||||
_timer?.cancel();
|
||||
_seasonPassTimer?.cancel();
|
||||
final closings = <Future<void>>[];
|
||||
for (final id in _active.keys.toList()) {
|
||||
final future =
|
||||
_stopActiveRecording(id, reason: 'Arrêt du serveur');
|
||||
if (future != null) closings.add(future);
|
||||
}
|
||||
if (closings.isNotEmpty) {
|
||||
print(
|
||||
'[RecordingScheduler] Clôture de ${closings.length} enregistrement(s)…',
|
||||
);
|
||||
await Future.wait(closings).timeout(timeout, onTimeout: () => const []);
|
||||
}
|
||||
print('[RecordingScheduler] Arrêté proprement');
|
||||
}
|
||||
|
||||
Future<void> _checkAndRunRecordings() async {
|
||||
if (_isRunning) return;
|
||||
_isRunning = true;
|
||||
|
||||
try {
|
||||
// Toujours comparer en UTC pour éviter les problèmes de fuseau horaire
|
||||
final now = DateTime.now().toUtc();
|
||||
|
||||
// Arrêter les enregistrements actifs dont l'heure de fin est passée
|
||||
for (final id in _active.keys.toList()) {
|
||||
final active = _active[id]!;
|
||||
if (now.isAfter(active.recording.endTime.toUtc())) {
|
||||
print(
|
||||
'[RecordingScheduler] Fin de l\'enregistrement : ${active.recording.title}',
|
||||
);
|
||||
_stopActiveRecording(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Lire la base APRÈS les arrêts : un instantané pris avant ferait passer
|
||||
// l'enregistrement tout juste terminé pour un orphelin (il n'est plus
|
||||
// dans `_active` alors que l'instantané le dit encore « recording »).
|
||||
final recordings = _db.getPendingRecordings();
|
||||
|
||||
// Rechercher les enregistrements planifiés
|
||||
for (final recording in recordings) {
|
||||
// Enregistrement marqué "recording" sans processus associé : le serveur
|
||||
// a redémarré (ou l'enregistrement n'a jamais démarré).
|
||||
if (recording.status == 'recording' &&
|
||||
!_active.containsKey(recording.id)) {
|
||||
await _recoverOrphan(recording, now);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (recording.status == 'scheduled') {
|
||||
final action = decideRecordingAction(
|
||||
now: now,
|
||||
startTime: recording.startTime,
|
||||
endTime: recording.endTime,
|
||||
activeCount: _active.length,
|
||||
maxConcurrent: maxConcurrent,
|
||||
);
|
||||
|
||||
switch (action) {
|
||||
case RecordingAction.start:
|
||||
print(
|
||||
'[RecordingScheduler] *** DÉMARRAGE DE L\'ENREGISTREMENT : ${recording.title} ***',
|
||||
);
|
||||
await _startRecording(recording);
|
||||
case RecordingAction.wait:
|
||||
// Capacité atteinte mais la fenêtre est encore ouverte :
|
||||
// on garde le statut "scheduled" et on réessaiera au prochain tick.
|
||||
print(
|
||||
'[RecordingScheduler] Capacité max atteinte (${_active.length}/$maxConcurrent), '
|
||||
'"${recording.title}" en attente.',
|
||||
);
|
||||
case RecordingAction.fail:
|
||||
print(
|
||||
'[RecordingScheduler] Enregistrement "${recording.title}" manqué (fin dépassée).',
|
||||
);
|
||||
_db.updateRecordingStatus(
|
||||
recording.id,
|
||||
'failed',
|
||||
errorReason: 'Heure de fin dépassée avant le démarrage',
|
||||
);
|
||||
case RecordingAction.none:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e, st) {
|
||||
print('[RecordingScheduler] ERREUR CRITIQUE: $e\n$st');
|
||||
} finally {
|
||||
_isRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Reprend (ou clôture) un enregistrement laissé au statut "recording" par
|
||||
/// une interruption du serveur.
|
||||
Future<void> _recoverOrphan(Recording recording, DateTime now) async {
|
||||
// Le statut a pu changer depuis la lecture (clôture asynchrone en cours) :
|
||||
// ne jamais requalifier un enregistrement sur une donnée périmée.
|
||||
final current = _db.getRecordingById(recording.id);
|
||||
if (current == null || current.status != 'recording') return;
|
||||
|
||||
final action = decideOrphanAction(
|
||||
now: now,
|
||||
endTime: current.endTime,
|
||||
hasFile: _fileHasData(current.filePath),
|
||||
);
|
||||
|
||||
switch (action) {
|
||||
case OrphanAction.resume:
|
||||
if (_active.length >= maxConcurrent) {
|
||||
// Pas de slot libre pour l'instant : on retentera au prochain tick.
|
||||
return;
|
||||
}
|
||||
print(
|
||||
'[RecordingScheduler] Reprise après interruption : ${current.title}',
|
||||
);
|
||||
await _startRecording(current, resume: true);
|
||||
case OrphanAction.finish:
|
||||
print(
|
||||
'[RecordingScheduler] Enregistrement interrompu conservé (partiel): ${current.title}',
|
||||
);
|
||||
_db.updateRecordingStatus(
|
||||
current.id,
|
||||
'completed',
|
||||
errorReason:
|
||||
'Interruption du serveur pendant la capture : fichier partiel',
|
||||
);
|
||||
case OrphanAction.fail:
|
||||
print(
|
||||
'[RecordingScheduler] Enregistrement orphelin sans fichier: ${current.id}',
|
||||
);
|
||||
_db.updateRecordingStatus(
|
||||
current.id,
|
||||
'failed',
|
||||
errorReason: 'Interruption inattendue du serveur',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalise un titre pour la correspondance : minuscules, espaces réduits.
|
||||
static String _normalizeTitle(String title) =>
|
||||
title.toLowerCase().trim().replaceAll(RegExp(r'\s+'), ' ');
|
||||
|
||||
/// Le titre EPG [title] correspond-il au pass selon son [matchMode] ?
|
||||
static bool _titleMatches(String title, String showTitle, String matchMode) {
|
||||
final t = _normalizeTitle(title);
|
||||
final s = _normalizeTitle(showTitle);
|
||||
if (s.isEmpty) return false;
|
||||
return matchMode == 'contains' ? t.contains(s) : t == s;
|
||||
}
|
||||
|
||||
Future<void> _checkSeasonPasses() async {
|
||||
final passes = _db.getAllSeasonPasses();
|
||||
if (passes.isEmpty) return;
|
||||
|
||||
print('[SeasonPass] Vérification de ${passes.length} Season Pass(s)...');
|
||||
|
||||
for (final pass in passes) {
|
||||
try {
|
||||
final channelId = pass['channel_id'] as String;
|
||||
final streamUrl = pass['stream_url'] as String;
|
||||
final showTitle = pass['show_title'] as String;
|
||||
final userId = pass['user_id'] as String;
|
||||
final matchMode = pass['match_mode'] as String? ?? 'contains';
|
||||
|
||||
// Résoudre la playlist du PROPRIÉTAIRE du pass au moment du scan.
|
||||
// L'ancienne config injectée au démarrage venait du premier utilisateur
|
||||
// de la base et n'était jamais réactualisée : une playlist ajoutée
|
||||
// après coup rendait les passes muets, et en multi-utilisateurs les
|
||||
// credentials de l'un servaient aux passes d'un autre.
|
||||
final playlists = _db.getPlaylists(userId);
|
||||
if (playlists.isEmpty) {
|
||||
print(
|
||||
'[SeasonPass] Aucune playlist pour le propriétaire du pass '
|
||||
'"$showTitle", scan ignoré',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
final playlist = playlists.first;
|
||||
|
||||
// Récupérer l'EPG de la chaîne (les prochains programmes ; `limit`
|
||||
// est un nombre de programmes, pas des heures)
|
||||
final url =
|
||||
'${playlist.serverUrl}/player_api.php?username=${playlist.username}'
|
||||
'&password=${playlist.password}'
|
||||
'&action=get_simple_data_table&stream_id=$channelId&type=epg&limit=48';
|
||||
|
||||
final response =
|
||||
await http.get(Uri.parse(url)).timeout(const Duration(seconds: 60));
|
||||
if (response.statusCode != 200) continue;
|
||||
|
||||
final raw = json.decode(response.body);
|
||||
final listings =
|
||||
(raw is Map ? raw['epg_listings'] : raw) as List<dynamic>? ?? [];
|
||||
|
||||
var createdThisScan = 0;
|
||||
for (final item in listings) {
|
||||
String title = item['title'] as String? ?? '';
|
||||
try {
|
||||
title = utf8.decode(base64Decode(title));
|
||||
} catch (_) {}
|
||||
|
||||
if (!_titleMatches(title, showTitle, matchMode)) continue;
|
||||
|
||||
// Parser les heures de début/fin
|
||||
final startStr = item['start'] as String? ?? '';
|
||||
final endStr =
|
||||
item['stop'] as String? ?? item['end'] as String? ?? '';
|
||||
if (startStr.isEmpty || endStr.isEmpty) continue;
|
||||
|
||||
DateTime startTime, endTime;
|
||||
try {
|
||||
startTime = DateTime.parse(startStr).toUtc();
|
||||
endTime = DateTime.parse(endStr).toUtc();
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ne pas créer pour les programmes déjà terminés
|
||||
if (endTime.isBefore(DateTime.now().toUtc())) continue;
|
||||
|
||||
// Déduplication : cet épisode est-il déjà planifié/enregistré ?
|
||||
final existing = _db.findRecordingForEpisode(title, startTime);
|
||||
if (existing != null) {
|
||||
// Programme déplacé dans l'EPG depuis la planification :
|
||||
// réaligner la fenêtre tant que la capture n'a pas commencé.
|
||||
if (existing.status == 'scheduled' &&
|
||||
(existing.startTime.toUtc() != startTime ||
|
||||
existing.endTime.toUtc() != endTime)) {
|
||||
_db.updateRecordingWindow(existing.id, startTime, endTime);
|
||||
print(
|
||||
'[SeasonPass] "$title" réaligné sur le nouvel horaire '
|
||||
'${startTime.toLocal()}',
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (createdThisScan >= seasonPassMaxPerScan) {
|
||||
print(
|
||||
'[SeasonPass] Plafond de $seasonPassMaxPerScan créations atteint '
|
||||
'pour "$showTitle" sur ce scan',
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
// Créer l'enregistrement automatiquement
|
||||
_db.createRecording(
|
||||
userId: userId,
|
||||
channelId: channelId,
|
||||
streamUrl: streamUrl,
|
||||
title: title,
|
||||
startTime: startTime,
|
||||
endTime: endTime,
|
||||
);
|
||||
createdThisScan++;
|
||||
print(
|
||||
'[SeasonPass] ✓ Planifié automatiquement: "$title" le ${startTime.toLocal()}',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
// Ne jamais imprimer l'exception brute : une ClientException peut
|
||||
// contenir l'URL amont avec les credentials Xtream.
|
||||
print(
|
||||
'[SeasonPass] Erreur pour le pass "${pass['show_title']}": '
|
||||
'${LogRedactor.redactUrl('$e')}',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _startRecording(
|
||||
Recording recording, {
|
||||
bool resume = false,
|
||||
}) async {
|
||||
// Un (re)démarrage rend obsolète un éventuel motif d'échec précédent.
|
||||
_db.updateRecordingStatus(recording.id, 'recording', clearError: true);
|
||||
|
||||
// Un seul bloc try/catch englobant TOUT pour éviter les Unhandled exceptions
|
||||
// qui tueraient le serveur entier
|
||||
try {
|
||||
// Préparation du dossier d'enregistrement
|
||||
final recordingsDir = Directory(recordingsDirPath);
|
||||
if (!await recordingsDir.exists()) {
|
||||
await recordingsDir.create(recursive: true);
|
||||
}
|
||||
|
||||
// Nettoyer l'espace disque si nécessaire
|
||||
await _checkDiskSpaceAndRotate(recordingsDir);
|
||||
|
||||
// Refuser de démarrer sur un volume plein : mieux vaut un échec
|
||||
// explicite immédiat qu'une capture qui meurt à mi-parcours.
|
||||
final freeBytes = await _freeDiskBytes(recordingsDir.path);
|
||||
if (freeBytes != null && freeBytes < minFreeDiskMb * 1024 * 1024) {
|
||||
final freeMb = freeBytes ~/ (1024 * 1024);
|
||||
print(
|
||||
'[RecordingScheduler] Espace disque insuffisant ($freeMb Mo libres, '
|
||||
'minimum $minFreeDiskMb Mo) : "${recording.title}" refusé',
|
||||
);
|
||||
_db.updateRecordingStatus(
|
||||
recording.id,
|
||||
'failed',
|
||||
errorReason:
|
||||
'Espace disque insuffisant ($freeMb Mo libres, minimum $minFreeDiskMb Mo)',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final filePath = p.join(recordingsDir.path, _fileNameFor(recording));
|
||||
final logPath = p.setExtension(filePath, '.log');
|
||||
|
||||
final active = _ActiveRecording(
|
||||
recording: recording,
|
||||
filePath: filePath,
|
||||
// Une reprise complète le log existant au lieu de l'écraser.
|
||||
log: _RecordingLog.open(logPath, append: resume),
|
||||
);
|
||||
|
||||
if (resume) {
|
||||
// Le fichier principal (et d'éventuelles parties) survivent au
|
||||
// redémarrage : on repart sur une nouvelle partie pour ne rien écraser.
|
||||
active.extraParts.addAll(_existingParts(filePath));
|
||||
active.attempt = _fileHasData(filePath) || active.extraParts.isNotEmpty
|
||||
? active.extraParts.length + 1
|
||||
: 0;
|
||||
active.log?.writeln(
|
||||
'\n[${DateTime.now()}] Reprise après interruption du serveur',
|
||||
);
|
||||
} else {
|
||||
active.log?.writeln('[${DateTime.now()}] Démarrage: ${recording.title}');
|
||||
active.log?.writeln('Destination: $filePath');
|
||||
}
|
||||
|
||||
_active[recording.id] = active;
|
||||
|
||||
// Enregistrer le chemin du fichier dans la BDD
|
||||
_db.updateRecordingStatus(recording.id, 'recording', filePath: filePath);
|
||||
|
||||
await _launchFfmpeg(active);
|
||||
} catch (e, st) {
|
||||
// Attraper TOUTES les exceptions pour éviter de crasher le serveur
|
||||
// `ProcessException` liste les arguments FFmpeg, URL de la source
|
||||
// comprise : masquer les identifiants avant de journaliser.
|
||||
print(
|
||||
'[RecordingScheduler] ERREUR dans _startRecording: '
|
||||
'${LogRedactor.redactUrl('$e')}\n$st',
|
||||
);
|
||||
_db.updateRecordingStatus(
|
||||
recording.id,
|
||||
'failed',
|
||||
errorReason: 'Erreur au lancement: $e',
|
||||
);
|
||||
final active = _active.remove(recording.id);
|
||||
unawaited(active?.log?.close() ?? Future<void>.value());
|
||||
}
|
||||
}
|
||||
|
||||
/// Lance (ou relance) le processus FFmpeg d'un enregistrement actif.
|
||||
Future<void> _launchFfmpeg(_ActiveRecording active) async {
|
||||
final recording = active.recording;
|
||||
final output = active.attempt == 0
|
||||
? active.filePath
|
||||
: _partPath(active.filePath, active.attempt);
|
||||
if (active.attempt > 0 && !active.extraParts.contains(output)) {
|
||||
active.extraParts.add(output);
|
||||
}
|
||||
|
||||
// Résoudre l'URL relative en URL absolue pour FFmpeg
|
||||
// Le serveur Xtremflow tourne sur le port 8089 en interne Docker
|
||||
String streamUrl = recording.streamUrl;
|
||||
if (streamUrl.startsWith('/')) {
|
||||
streamUrl = 'http://localhost:8089$streamUrl';
|
||||
}
|
||||
|
||||
final duration = captureDuration(
|
||||
now: DateTime.now().toUtc(),
|
||||
endTime: recording.endTime.toUtc(),
|
||||
);
|
||||
|
||||
final args = [
|
||||
'-hide_banner',
|
||||
// Les logs sont relus entièrement par l'API : sans -nostats, une heure de
|
||||
// capture produit des mégaoctets de lignes de progression.
|
||||
'-nostats',
|
||||
'-user_agent', 'VLC/3.0.18 LibVLC/3.0.18',
|
||||
// Une coupure amont (bascule de source, recyclage nginx du panneau) ne
|
||||
// doit pas terminer la capture : FFmpeg rouvre le flux lui-même.
|
||||
'-reconnect', '1',
|
||||
'-reconnect_at_eof', '1',
|
||||
'-reconnect_streamed', '1',
|
||||
'-reconnect_delay_max', '30',
|
||||
'-rw_timeout', '30000000',
|
||||
'-y',
|
||||
'-i', streamUrl,
|
||||
'-c', 'copy',
|
||||
'-t', '${duration.inSeconds}',
|
||||
output,
|
||||
];
|
||||
|
||||
final ffmpegPath = await resolveFfmpegPath();
|
||||
|
||||
// Le contenu du log est exposé via l'API : ne jamais y écrire de credentials.
|
||||
final redactedArgs = args.map(LogRedactor.redactUrl).join(' ');
|
||||
active.log?.writeln('URL: ${LogRedactor.redactUrl(streamUrl)}');
|
||||
active.log?.writeln('Commande: $ffmpegPath $redactedArgs\n');
|
||||
print('[RecordingScheduler] Exécution: $ffmpegPath $redactedArgs');
|
||||
|
||||
final process = await Process.start(ffmpegPath, args);
|
||||
active.process = process;
|
||||
active.launchedAt = DateTime.now();
|
||||
|
||||
// Rediriger stdout/stderr dans le log ; on attend la fin des deux flux
|
||||
// avant de fermer le sink, sinon les derniers morceaux arrivent sur un
|
||||
// fichier déjà fermé.
|
||||
final drained = Future.wait([
|
||||
_pipeToLog(process.stdout, active.log),
|
||||
_pipeToLog(process.stderr, active.log),
|
||||
]);
|
||||
|
||||
unawaited(
|
||||
process.exitCode.then((exitCode) async {
|
||||
await drained;
|
||||
await _onFfmpegExit(active, exitCode);
|
||||
}).catchError((Object e, StackTrace st) {
|
||||
print('[RecordingScheduler] ERREUR à la sortie de FFmpeg: $e\n$st');
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pipeToLog(Stream<List<int>> stream, _RecordingLog? log) {
|
||||
return stream.forEach((chunk) => log?.add(chunk)).catchError((_) {});
|
||||
}
|
||||
|
||||
/// Décide de la suite quand un processus FFmpeg se termine.
|
||||
Future<void> _onFfmpegExit(_ActiveRecording active, int exitCode) async {
|
||||
final recording = active.recording;
|
||||
|
||||
// Arrêt volontaire, ou enregistrement déjà repris ailleurs : la clôture est
|
||||
// gérée par celui qui a demandé l'arrêt.
|
||||
if (active.stopping || !identical(_active[recording.id], active)) return;
|
||||
|
||||
// Une capture qui a tenu longtemps repart d'un quota de relances neuf.
|
||||
final lasted = DateTime.now().difference(active.launchedAt);
|
||||
if (lasted > const Duration(minutes: 2) && _activeHasData(active)) {
|
||||
active.consecutiveFailures = 0;
|
||||
}
|
||||
|
||||
final action = decidePostExitAction(
|
||||
now: DateTime.now().toUtc(),
|
||||
endTime: recording.endTime.toUtc(),
|
||||
exitCode: exitCode,
|
||||
consecutiveFailures: active.consecutiveFailures,
|
||||
hasFile: _activeHasData(active),
|
||||
);
|
||||
|
||||
switch (action) {
|
||||
case PostExitAction.retry:
|
||||
active.attempt++;
|
||||
active.consecutiveFailures++;
|
||||
final delay = ffmpegRetryDelay(active.consecutiveFailures);
|
||||
final message =
|
||||
'FFmpeg s\'est arrêté (code $exitCode) après ${lasted.inSeconds}s, '
|
||||
'avant la fin prévue — relance dans ${delay.inSeconds}s '
|
||||
'(tentative ${active.consecutiveFailures}/${maxFfmpegAttempts - 1})';
|
||||
print('[RecordingScheduler] ${recording.title}: $message');
|
||||
active.log?.writeln('\n[${DateTime.now()}] $message');
|
||||
// Laisser la source respirer : sur les comptes limités en connexions
|
||||
// simultanées, rouvrir immédiatement se fait refuser.
|
||||
await Future<void>.delayed(delay);
|
||||
if (active.stopping || !identical(_active[recording.id], active)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await _launchFfmpeg(active);
|
||||
} catch (e) {
|
||||
print(
|
||||
'[RecordingScheduler] Relance impossible: '
|
||||
'${LogRedactor.redactUrl('$e')}',
|
||||
);
|
||||
_active.remove(recording.id);
|
||||
await _finalize(
|
||||
active,
|
||||
status: 'failed',
|
||||
errorReason: 'Relance de FFmpeg impossible: $e',
|
||||
);
|
||||
}
|
||||
case PostExitAction.complete:
|
||||
print(
|
||||
'[RecordingScheduler] Enregistrement terminé: ${recording.title}',
|
||||
);
|
||||
_active.remove(recording.id);
|
||||
await _finalize(active, status: 'completed');
|
||||
case PostExitAction.fail:
|
||||
print(
|
||||
'[RecordingScheduler] Erreur FFmpeg (code: $exitCode) pour ${recording.title}',
|
||||
);
|
||||
_active.remove(recording.id);
|
||||
await _finalize(
|
||||
active,
|
||||
status: 'failed',
|
||||
errorReason: 'Erreur FFmpeg code $exitCode. Voir logs.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Clôture un enregistrement : fusion des parties, fermeture du log, statut.
|
||||
Future<void> _finalize(
|
||||
_ActiveRecording active, {
|
||||
required String status,
|
||||
String? errorReason,
|
||||
}) async {
|
||||
try {
|
||||
await _mergeParts(active);
|
||||
} catch (e) {
|
||||
print('[RecordingScheduler] Fusion des parties impossible: $e');
|
||||
}
|
||||
active.log?.writeln(
|
||||
'\n[${DateTime.now()}] Enregistrement clôturé avec le statut "$status"',
|
||||
);
|
||||
await active.log?.close();
|
||||
_db.updateRecordingStatus(
|
||||
active.recording.id,
|
||||
status,
|
||||
filePath: active.filePath,
|
||||
errorReason: errorReason,
|
||||
);
|
||||
}
|
||||
|
||||
/// Recolle les parties issues des relances dans le fichier principal.
|
||||
///
|
||||
/// Les parties proviennent de la même source avec `-c copy` : le demuxer
|
||||
/// `concat` de FFmpeg les rassemble sans réencodage. En cas d'échec, le
|
||||
/// fichier principal et les parties sont conservés tels quels.
|
||||
Future<void> _mergeParts(_ActiveRecording active) async {
|
||||
final parts = active.extraParts.where(_fileHasData).toList();
|
||||
if (parts.isEmpty) return;
|
||||
|
||||
final segments = [
|
||||
if (_fileHasData(active.filePath)) active.filePath,
|
||||
...parts,
|
||||
];
|
||||
if (segments.length < 2) {
|
||||
// Le fichier principal est vide : la première partie le remplace.
|
||||
await File(segments.first).rename(active.filePath);
|
||||
return;
|
||||
}
|
||||
|
||||
final listFile = File('${active.filePath}.parts.txt');
|
||||
await listFile.writeAsString(
|
||||
segments.map((s) => 'file \'${s.replaceAll("'", r"'\''")}\'').join('\n'),
|
||||
);
|
||||
final mergedPath = '${active.filePath}.merged.mkv';
|
||||
final ffmpegPath = await resolveFfmpegPath();
|
||||
|
||||
active.log?.writeln(
|
||||
'\n[${DateTime.now()}] Fusion de ${segments.length} parties…',
|
||||
);
|
||||
final result = await Process.run(ffmpegPath, [
|
||||
'-hide_banner',
|
||||
'-nostats',
|
||||
'-y',
|
||||
'-f', 'concat',
|
||||
'-safe', '0',
|
||||
'-i', listFile.path,
|
||||
'-c', 'copy',
|
||||
mergedPath,
|
||||
]);
|
||||
await _deleteQuietly(listFile.path);
|
||||
|
||||
if (result.exitCode != 0 || !_fileHasData(mergedPath)) {
|
||||
active.log?.writeln(
|
||||
'Fusion échouée (code ${result.exitCode}) : les parties sont '
|
||||
'conservées séparément.',
|
||||
);
|
||||
await _deleteQuietly(mergedPath);
|
||||
return;
|
||||
}
|
||||
|
||||
await File(mergedPath).rename(active.filePath);
|
||||
for (final part in parts) {
|
||||
await _deleteQuietly(part);
|
||||
}
|
||||
active.log?.writeln('Fusion terminée.');
|
||||
}
|
||||
|
||||
/// Arrêter un enregistrement en cours (appelé depuis l'API)
|
||||
Future<bool> stopRecording(String id) async {
|
||||
if (_active.containsKey(id)) {
|
||||
print(
|
||||
'[RecordingScheduler] Arrêt demandé pour: ${_active[id]!.recording.title}',
|
||||
);
|
||||
_stopActiveRecording(id);
|
||||
return true;
|
||||
}
|
||||
return false; // Pas d'enregistrement actif avec cet ID
|
||||
}
|
||||
|
||||
/// Arrête l'enregistrement actif [id] et retourne la future de clôture
|
||||
/// (fusion des parties + statut), ou null si aucun n'est actif.
|
||||
Future<void>? _stopActiveRecording(String id, {String? reason}) {
|
||||
final active = _active.remove(id);
|
||||
if (active == null) return null;
|
||||
active.stopping = true;
|
||||
if (reason != null) {
|
||||
print('[RecordingScheduler] Arrêt: $reason (${active.recording.title})');
|
||||
}
|
||||
active.log?.writeln(
|
||||
'\n[${DateTime.now()}] Arrêt: ${reason ?? 'fin de la fenêtre programmée'}',
|
||||
);
|
||||
active.process?.kill(ProcessSignal.sigterm);
|
||||
_db.updateRecordingStatus(id, 'completed');
|
||||
final closing = _finalizeStopped(active);
|
||||
unawaited(closing);
|
||||
return closing;
|
||||
}
|
||||
|
||||
/// Attend la fin effective de FFmpeg puis clôture proprement (fusion + log).
|
||||
Future<void> _finalizeStopped(_ActiveRecording active) async {
|
||||
try {
|
||||
final process = active.process;
|
||||
if (process != null) {
|
||||
await process.exitCode.timeout(
|
||||
const Duration(seconds: 15),
|
||||
onTimeout: () {
|
||||
process.kill(ProcessSignal.sigkill);
|
||||
return -1;
|
||||
},
|
||||
);
|
||||
}
|
||||
await _finalize(active, status: 'completed');
|
||||
} catch (e, st) {
|
||||
print('[RecordingScheduler] ERREUR à la clôture: $e\n$st');
|
||||
await active.log?.close();
|
||||
}
|
||||
}
|
||||
|
||||
String _fileNameFor(Recording recording) {
|
||||
// Génération d'un nom de fichier unique et sûr. Le fragment d'id garantit
|
||||
// l'unicité : deux utilisateurs enregistrant le même programme sur la même
|
||||
// chaîne s'écrasaient mutuellement (FFmpeg est lancé avec -y).
|
||||
final safeTitle =
|
||||
recording.title.replaceAll(RegExp(r'[^a-zA-Z0-9_\-]'), '_');
|
||||
final dateStr = recording.startTime
|
||||
.toUtc()
|
||||
.toIso8601String()
|
||||
.replaceAll(':', '')
|
||||
.split('.')[0];
|
||||
final idFragment = recording.id.length >= 8
|
||||
? recording.id.substring(0, 8)
|
||||
: recording.id;
|
||||
return '${safeTitle}_${dateStr}_$idFragment.mkv';
|
||||
}
|
||||
|
||||
/// Chemin de la n-ième partie (la partie 1 étant le fichier principal).
|
||||
String _partPath(String filePath, int attempt) =>
|
||||
'${p.withoutExtension(filePath)}.part${attempt + 1}.mkv';
|
||||
|
||||
/// Parties déjà écrites sur disque pour cet enregistrement, dans l'ordre.
|
||||
List<String> _existingParts(String filePath) {
|
||||
final parts = <String>[];
|
||||
for (var attempt = 1;; attempt++) {
|
||||
final path = _partPath(filePath, attempt);
|
||||
if (!_fileHasData(path)) break;
|
||||
parts.add(path);
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
bool _activeHasData(_ActiveRecording active) =>
|
||||
_fileHasData(active.filePath) || active.extraParts.any(_fileHasData);
|
||||
|
||||
bool _fileHasData(String? path) {
|
||||
if (path == null) return false;
|
||||
try {
|
||||
final file = File(path);
|
||||
return file.existsSync() && file.lengthSync() > 0;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteQuietly(String path) async {
|
||||
try {
|
||||
final file = File(path);
|
||||
if (await file.exists()) await file.delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// Espace libre (octets) sur le volume qui porte [path], ou null si `df`
|
||||
/// n'est pas disponible.
|
||||
Future<int?> _freeDiskBytes(String path) async {
|
||||
try {
|
||||
final result = await Process.run('df', ['-B1', '--output=avail', path]);
|
||||
if (result.exitCode != 0) return null;
|
||||
final lines = (result.stdout as String).trim().split('\n');
|
||||
return int.tryParse(lines.last.trim());
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Fichiers sur disque associés à un enregistrement : fichier principal,
|
||||
/// log, et parties issues des relances.
|
||||
List<String> filesFor(String filePath) {
|
||||
final paths = <String>[filePath, p.setExtension(filePath, '.log')];
|
||||
for (var attempt = 1;; attempt++) {
|
||||
final part = _partPath(filePath, attempt);
|
||||
if (!File(part).existsSync()) break;
|
||||
paths.add(part);
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
/// Supprime les fichiers d'un enregistrement (appelé par l'API à la
|
||||
/// suppression, et par la rotation disque).
|
||||
Future<void> deleteRecordingFiles(String filePath) async {
|
||||
for (final path in filesFor(filePath)) {
|
||||
await _deleteQuietly(path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Rotation par quota d'octets (env RECORDINGS_QUOTA_GB, 0 = désactivée).
|
||||
///
|
||||
/// Remplace l'ancienne rotation « max 50 fichiers » qui supprimait les plus
|
||||
/// anciens fichiers du dossier sans distinction : elle pouvait effacer une
|
||||
/// partie en cours d'écriture par FFmpeg et laissait en base des lignes
|
||||
/// pointant vers des fichiers disparus. Ici on ne supprime que des
|
||||
/// enregistrements TERMINÉS connus de la base, du plus ancien au plus
|
||||
/// récent, fichiers et ligne BDD ensemble, jamais un enregistrement actif.
|
||||
Future<void> _checkDiskSpaceAndRotate(Directory dir) async {
|
||||
if (recordingsQuotaGb <= 0) return;
|
||||
try {
|
||||
final quotaBytes = recordingsQuotaGb * 1024 * 1024 * 1024;
|
||||
var totalBytes = 0;
|
||||
await for (final entity in dir.list()) {
|
||||
if (entity is File) {
|
||||
try {
|
||||
totalBytes += await entity.length();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
if (totalBytes <= quotaBytes) return;
|
||||
|
||||
print(
|
||||
'[RecordingScheduler] Quota disque dépassé '
|
||||
'(${totalBytes ~/ (1024 * 1024)} Mo > $recordingsQuotaGb Go) : '
|
||||
'rotation des enregistrements terminés les plus anciens',
|
||||
);
|
||||
|
||||
for (final old in _db.getFinishedRecordingsOldestFirst()) {
|
||||
if (totalBytes <= quotaBytes) break;
|
||||
final path = old.filePath;
|
||||
if (path == null) continue;
|
||||
// Jamais un enregistrement encore capturé (statut périmé en base).
|
||||
if (_active.containsKey(old.id)) continue;
|
||||
var freed = 0;
|
||||
for (final f in filesFor(path)) {
|
||||
try {
|
||||
freed += File(f).existsSync() ? File(f).lengthSync() : 0;
|
||||
} catch (_) {}
|
||||
}
|
||||
await deleteRecordingFiles(path);
|
||||
_db.deleteRecording(old.id);
|
||||
totalBytes -= freed;
|
||||
print(
|
||||
'[RecordingScheduler] Rotation : "${old.title}" supprimé '
|
||||
'(${freed ~/ (1024 * 1024)} Mo libérés)',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
print(
|
||||
'[RecordingScheduler] Erreur lors de la rotation de l\'espace disque : $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import '../database/database.dart';
|
||||
|
||||
/// 🎯 SIMPLIFIED Recording System - One job: Record streams
|
||||
class SimpleRecorder {
|
||||
final AppDatabase db;
|
||||
final String recordingsDir;
|
||||
|
||||
// Active recordings (streamId -> process)
|
||||
final Map<String, _ActiveRecording> _active = {};
|
||||
|
||||
SimpleRecorder(this.db, {this.recordingsDir = '/app/recordings'});
|
||||
|
||||
/// Initialize recordings directory
|
||||
Future<void> init() async {
|
||||
final dir = Directory(recordingsDir);
|
||||
if (!await dir.exists()) {
|
||||
await dir.create(recursive: true);
|
||||
}
|
||||
print('🎬 Recorder ready -> $recordingsDir');
|
||||
}
|
||||
|
||||
/// Start recording immediately
|
||||
Future<String> startRecording({
|
||||
required String channelId,
|
||||
required String streamUrl,
|
||||
required String title,
|
||||
required Duration duration,
|
||||
}) async {
|
||||
if (_active.containsKey(channelId)) {
|
||||
throw Exception('Already recording: $title');
|
||||
}
|
||||
|
||||
final recordingId = const Uuid().v4();
|
||||
final now = DateTime.now();
|
||||
final endTime = now.add(duration);
|
||||
|
||||
// Create recording in DB
|
||||
db.createRecording(
|
||||
userId: 'system',
|
||||
channelId: channelId,
|
||||
streamUrl: streamUrl,
|
||||
title: title,
|
||||
startTime: now,
|
||||
endTime: endTime,
|
||||
);
|
||||
|
||||
// Generate safe filename
|
||||
final filename = _safeName(title, recordingId);
|
||||
final filepath = '$recordingsDir/$filename';
|
||||
|
||||
// Start FFmpeg process
|
||||
try {
|
||||
final ffmpeg = await Process.start('ffmpeg', [
|
||||
'-y',
|
||||
'-fflags', '+genpts+igndts', // FORCE generation of missing timestamps for MKV/MP4 compatibility
|
||||
'-i',
|
||||
streamUrl,
|
||||
'-c',
|
||||
'copy',
|
||||
'-t',
|
||||
'${duration.inSeconds}',
|
||||
filepath,
|
||||
]);
|
||||
|
||||
_active[channelId] = _ActiveRecording(
|
||||
id: recordingId,
|
||||
process: ffmpeg,
|
||||
filepath: filepath,
|
||||
endTime: endTime,
|
||||
);
|
||||
|
||||
// Update DB
|
||||
db.updateRecordingStatus(recordingId, 'recording', filePath: filepath);
|
||||
|
||||
// Auto-stop when time is up or process ends
|
||||
ffmpeg.exitCode.then((code) {
|
||||
if (code == 0 || code == 255) { // 255 is usually returned when gracefully stopped via q/SIGTERM
|
||||
db.updateRecordingStatus(recordingId, 'completed');
|
||||
} else {
|
||||
db.updateRecordingStatus(
|
||||
recordingId,
|
||||
'failed',
|
||||
errorReason: 'FFmpeg crashed with exit code $code',
|
||||
);
|
||||
}
|
||||
_active.remove(channelId);
|
||||
print('✅ Recording finished: $title (Code: $code)');
|
||||
}).catchError((e) {
|
||||
db.updateRecordingStatus(
|
||||
recordingId,
|
||||
'failed',
|
||||
errorReason: 'FFmpeg error',
|
||||
);
|
||||
_active.remove(channelId);
|
||||
});
|
||||
|
||||
print('🔴 Recording: $title ($duration)');
|
||||
return recordingId;
|
||||
} catch (e) {
|
||||
db.updateRecordingStatus(recordingId, 'failed', errorReason: '$e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Schedule recording for later
|
||||
Future<String> scheduleRecording({
|
||||
required String channelId,
|
||||
required String streamUrl,
|
||||
required String title,
|
||||
required DateTime startTime,
|
||||
required DateTime endTime,
|
||||
}) async {
|
||||
final recordingId = const Uuid().v4();
|
||||
|
||||
// Create in DB with 'scheduled' status
|
||||
db.createRecording(
|
||||
userId: 'system',
|
||||
channelId: channelId,
|
||||
streamUrl: streamUrl,
|
||||
title: title,
|
||||
startTime: startTime,
|
||||
endTime: endTime,
|
||||
);
|
||||
|
||||
// Schedule auto-start
|
||||
final delay = startTime.difference(DateTime.now());
|
||||
if (delay.isNegative) {
|
||||
throw Exception('Start time is in the past');
|
||||
}
|
||||
|
||||
Timer(delay, () async {
|
||||
try {
|
||||
await startRecording(
|
||||
channelId: channelId,
|
||||
streamUrl: streamUrl,
|
||||
title: title,
|
||||
duration: endTime.difference(startTime),
|
||||
);
|
||||
} catch (e) {
|
||||
print('❌ Auto-start failed: $e');
|
||||
}
|
||||
});
|
||||
|
||||
print('⏰ Scheduled: $title at ${startTime.toLocal()}');
|
||||
return recordingId;
|
||||
}
|
||||
|
||||
/// Stop recording immediately
|
||||
Future<void> stopRecording(String channelId) async {
|
||||
final active = _active[channelId];
|
||||
if (active == null) return;
|
||||
|
||||
active.process.kill();
|
||||
db.updateRecordingStatus(active.id, 'completed');
|
||||
_active.remove(channelId);
|
||||
print('⏹️ Stopped: $channelId');
|
||||
}
|
||||
|
||||
/// Check scheduled recordings (call every minute)
|
||||
Future<void> checkScheduled() async {
|
||||
final now = DateTime.now();
|
||||
for (final rec in db.getAllRecordings()) {
|
||||
if (rec.status != 'scheduled') continue;
|
||||
if (now.isBefore(rec.startTime)) continue;
|
||||
|
||||
try {
|
||||
await startRecording(
|
||||
channelId: rec.channelId,
|
||||
streamUrl: rec.streamUrl,
|
||||
title: rec.title,
|
||||
duration: rec.endTime.difference(rec.startTime),
|
||||
);
|
||||
} catch (e) {
|
||||
print('❌ Auto-start error: $e');
|
||||
db.updateRecordingStatus(rec.id, 'failed', errorReason: '$e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get active recordings
|
||||
List<Map<String, dynamic>> getActive() {
|
||||
return _active.entries
|
||||
.map(
|
||||
(e) => {
|
||||
'id': e.value.id,
|
||||
'channel': e.key,
|
||||
'filepath': e.value.filepath,
|
||||
'endsAt': e.value.endTime.toIso8601String(),
|
||||
},
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Cleanup old recordings (keep last 20)
|
||||
Future<void> cleanupOld({int keepCount = 20}) async {
|
||||
final dir = Directory(recordingsDir);
|
||||
final files = dir.listSync().whereType<File>().toList()
|
||||
..sort((a, b) => b.statSync().modified.compareTo(a.statSync().modified));
|
||||
|
||||
if (files.length > keepCount) {
|
||||
for (final file in files.skip(keepCount)) {
|
||||
await file.delete();
|
||||
print('🗑️ Deleted: ${file.path}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _safeName(String title, String id) {
|
||||
final safe = title.replaceAll(RegExp(r'[^a-zA-Z0-9_-]'), '_');
|
||||
final timestamp =
|
||||
DateTime.now().toIso8601String().replaceAll(':', '').split('.')[0];
|
||||
return '${safe}_$timestamp.mkv'; // The user explicitly mandated MKV or MP4.
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
for (final rec in _active.values) {
|
||||
rec.process.kill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal: Active recording tracking
|
||||
class _ActiveRecording {
|
||||
final String id;
|
||||
final Process process;
|
||||
final String filepath;
|
||||
final DateTime endTime;
|
||||
|
||||
_ActiveRecording({
|
||||
required this.id,
|
||||
required this.process,
|
||||
required this.filepath,
|
||||
required this.endTime,
|
||||
});
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../models/playlist_config.dart';
|
||||
|
||||
/// Connexions ouvertes vers le fournisseur Xtream, par compte.
|
||||
///
|
||||
/// POURQUOI : la plupart des abonnements n'autorisent qu'UNE connexion
|
||||
/// simultanée (`max_connections: "1"`, vérifié en prod). Or un FFmpeg de
|
||||
/// lecture survit au spectateur : 4 min pour un direct HLS, jusqu'à 15 min
|
||||
/// ou la fin du téléchargement pour un film, le temps de détecter la
|
||||
/// déconnexion pour `turbo.ts`. Le flux suivant tombait sur un slot occupé :
|
||||
/// le panneau répondait `HTTP 551` ou ne répondait pas (timeout), d'où les
|
||||
/// films « indisponibles » et les zaps qui échouent.
|
||||
///
|
||||
/// Règle : avant d'ouvrir une nouvelle connexion amont, on coupe les
|
||||
/// connexions ORPHELINES du même compte (plus personne ne les consomme),
|
||||
/// les plus anciennes d'abord, jusqu'à lui faire de la place.
|
||||
///
|
||||
/// 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 {
|
||||
final DateTime Function() _now;
|
||||
final Map<String, _Slot> _slots = {};
|
||||
int _nextToken = 0;
|
||||
|
||||
UpstreamSlots({DateTime Function()? now}) : _now = now ?? DateTime.now;
|
||||
|
||||
/// Inscrit la connexion [id] du compte [account]. [release] la coupe.
|
||||
///
|
||||
/// Renvoie un jeton à repasser à [unregister] : une session relancée sous
|
||||
/// le même identifiant ne doit pas être désinscrite par la fin de
|
||||
/// l'ancien processus.
|
||||
///
|
||||
/// [isActive] : vrai tant qu'un spectateur consomme le flux. Une
|
||||
/// connexion active n'est jamais coupée par [makeRoom].
|
||||
int register({
|
||||
required String id,
|
||||
required String account,
|
||||
required void Function() release,
|
||||
bool Function()? isActive,
|
||||
}) {
|
||||
final token = ++_nextToken;
|
||||
_slots[id] = _Slot(token, account, _now(), release, isActive);
|
||||
return token;
|
||||
}
|
||||
|
||||
void unregister(String id, {int? token}) {
|
||||
final slot = _slots[id];
|
||||
if (slot == null) return;
|
||||
if (token != null && slot.token != token) return;
|
||||
_slots.remove(id);
|
||||
}
|
||||
|
||||
int countFor(String account) =>
|
||||
_slots.values.where((s) => s.account == account).length;
|
||||
|
||||
/// Libère assez de connexions de [account] pour qu'une nouvelle tienne
|
||||
/// sous [max]. [keep] (la session demandée) n'est jamais coupée.
|
||||
///
|
||||
/// [max] <= 0 = quota inconnu : on ne coupe rien plutôt que de risquer
|
||||
/// d'interrompre un autre spectateur.
|
||||
List<String> makeRoom(String account, {required int max, required String keep}) {
|
||||
if (max <= 0) return const [];
|
||||
final others = _slots.entries
|
||||
.where((e) => e.value.account == account && e.key != keep)
|
||||
.toList();
|
||||
|
||||
final excess = others.length - (max - 1);
|
||||
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>[];
|
||||
for (final entry in orphans.take(excess)) {
|
||||
_slots.remove(entry.key);
|
||||
try {
|
||||
entry.value.release();
|
||||
} catch (_) {}
|
||||
freed.add(entry.key);
|
||||
}
|
||||
return freed;
|
||||
}
|
||||
}
|
||||
|
||||
class _Slot {
|
||||
final int token;
|
||||
final String account;
|
||||
final DateTime since;
|
||||
final void Function() 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
|
||||
/// même quota chez le fournisseur.
|
||||
String accountKeyOf(PlaylistConfig p) => '${p.dns}|${p.username}';
|
||||
|
||||
/// `user_info.max_connections` d'une réponse `player_api.php`, ou null.
|
||||
int? parseMaxConnections(Object? json) {
|
||||
if (json is! Map) return null;
|
||||
final info = json['user_info'];
|
||||
if (info is! Map) return null;
|
||||
final raw = info['max_connections'];
|
||||
final value = raw is int ? raw : int.tryParse('$raw');
|
||||
return (value != null && value > 0) ? value : null;
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
final Duration ttl;
|
||||
final Future<int?> Function(PlaylistConfig) _fetch;
|
||||
final Map<String, ({int max, DateTime at})> _cache = {};
|
||||
final Set<String> _refreshing = {};
|
||||
|
||||
/// Valeur retenue tant que le panneau n'a pas répondu : un seul flux, le
|
||||
/// cas de loin le plus courant chez les fournisseurs Xtream.
|
||||
static const fallback = 1;
|
||||
|
||||
AccountLimits({
|
||||
this.ttl = const Duration(minutes: 10),
|
||||
Future<int?> Function(PlaylistConfig)? fetch,
|
||||
}) : _fetch = fetch ?? _fetchFromPanel;
|
||||
|
||||
Future<int> maxFor(PlaylistConfig p) async {
|
||||
final key = accountKeyOf(p);
|
||||
final cached = _cache[key];
|
||||
if (cached == null || DateTime.now().difference(cached.at) >= ttl) {
|
||||
_refresh(key, p);
|
||||
}
|
||||
return cached?.max ?? fallback;
|
||||
}
|
||||
|
||||
void _refresh(String key, PlaylistConfig p) {
|
||||
if (!_refreshing.add(key)) return; // une seule lecture à la fois
|
||||
Future<int?>.sync(() => _fetch(p))
|
||||
.then<int?>((v) => v, onError: (Object _) => null)
|
||||
.then((value) {
|
||||
// Un échec n'est pas mis en cache : on réessaiera au prochain flux.
|
||||
if (value != null) _cache[key] = (max: value, at: DateTime.now());
|
||||
}).whenComplete(() => _refreshing.remove(key));
|
||||
}
|
||||
|
||||
static Future<int?> _fetchFromPanel(PlaylistConfig p) async {
|
||||
final uri = Uri.parse('${p.dns}/player_api.php').replace(
|
||||
queryParameters: {'username': p.username, 'password': p.password},
|
||||
);
|
||||
final client = http.Client();
|
||||
try {
|
||||
final response = await client.get(uri, headers: {
|
||||
'User-Agent': 'VLC/3.0.18 LibVLC/3.0.18',
|
||||
}).timeout(const Duration(seconds: 5));
|
||||
if (response.statusCode != 200) return null;
|
||||
return parseMaxConnections(jsonDecode(response.body));
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,534 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:isolate';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:xml/xml_events.dart';
|
||||
|
||||
import '../utils/log_redactor.dart';
|
||||
|
||||
/// Guide TV construit à partir de dumps XMLTV publics.
|
||||
///
|
||||
/// Beaucoup de panneaux Xtream servent un EPG figé depuis plusieurs jours, ou
|
||||
/// n'en servent aucun : `get_simple_data_table` répond alors correctement mais
|
||||
/// avec des programmes périmés. Ce service télécharge des dumps XMLTV
|
||||
/// indépendants, les indexe par chaîne, et permet de compléter le guide.
|
||||
///
|
||||
/// Le parsing est fait en flux (`XmlEventReader`) : un dump national pèse
|
||||
/// couramment 50 Mo décompressés, en charger l'arbre DOM complet coûterait
|
||||
/// plusieurs centaines de mégaoctets dans le conteneur.
|
||||
class XmltvEpgService {
|
||||
XmltvEpgService({
|
||||
required this.sourceUrls,
|
||||
this.refreshInterval = const Duration(hours: 6),
|
||||
this.retention = const Duration(hours: 6),
|
||||
this.horizon = const Duration(hours: 48),
|
||||
this.retryBackoff = const Duration(minutes: 15),
|
||||
this.downloadTimeout = const Duration(minutes: 5),
|
||||
http.Client? client,
|
||||
}) : _client = client ?? http.Client();
|
||||
|
||||
/// Dumps XMLTV à agréger, dans l'ordre de priorité décroissante.
|
||||
final List<String> sourceUrls;
|
||||
|
||||
/// Fréquence de rafraîchissement de l'index.
|
||||
final Duration refreshInterval;
|
||||
|
||||
/// Les programmes terminés depuis plus longtemps que cette durée sont
|
||||
/// écartés à l'indexation : personne ne consulte le guide d'hier, et les
|
||||
/// garder double la taille de l'index.
|
||||
final Duration retention;
|
||||
|
||||
/// Les programmes qui commencent au-delà de cet horizon sont écartés à
|
||||
/// l'indexation.
|
||||
///
|
||||
/// Un dump national couvre souvent sept jours pour un millier de chaînes ;
|
||||
/// tout garder en mémoire dans le conteneur coûte des centaines de
|
||||
/// mégaoctets pour un guide qui n'affiche que le programme courant et les
|
||||
/// suivants.
|
||||
final Duration horizon;
|
||||
|
||||
/// Délai minimal entre deux tentatives quand aucune source n'a répondu.
|
||||
///
|
||||
/// Sans ce recul, une source morte était retéléchargée à chaque consultation
|
||||
/// du guide — une requête sortante par chaîne affichée, soit exactement le
|
||||
/// flot qu'un index est censé éviter.
|
||||
final Duration retryBackoff;
|
||||
|
||||
/// Plafond de téléchargement d'un dump. Le guide est facultatif : mieux vaut
|
||||
/// abandonner et servir le panneau que faire attendre l'utilisateur.
|
||||
final Duration downloadTimeout;
|
||||
|
||||
final http.Client _client;
|
||||
|
||||
/// clé de chaîne normalisée → programmes triés par heure de début.
|
||||
Map<String, List<XmltvProgramme>> _index = {};
|
||||
DateTime? _indexedAt;
|
||||
DateTime? _failedAt;
|
||||
Future<void>? _refreshInFlight;
|
||||
|
||||
bool get hasData => _index.isNotEmpty;
|
||||
DateTime? get indexedAt => _indexedAt;
|
||||
int get channelCount => _index.length;
|
||||
|
||||
/// Programmes d'une chaîne, ou liste vide si inconnue.
|
||||
///
|
||||
/// [channelId] est l'`epg_channel_id` renvoyé par Xtream ; [displayName] est
|
||||
/// le nom de la chaîne, utilisé en second recours car les dumps publics
|
||||
/// construisent souvent leur identifiant à partir du nom affiché.
|
||||
Future<List<XmltvProgramme>> programmesFor(
|
||||
String? channelId, {
|
||||
String? displayName,
|
||||
}) async {
|
||||
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 = [
|
||||
normalizeKey(channelId),
|
||||
if (displayName != null) ...nameKeys(displayName),
|
||||
// Clés souples en dernier recours, dans leur propre espace de noms.
|
||||
_looseIndexKey(looseKey(channelId)),
|
||||
if (displayName != null)
|
||||
_looseIndexKey(looseKey(cleanChannelName(displayName).name)),
|
||||
];
|
||||
for (final key in keys) {
|
||||
if (key.isEmpty) continue;
|
||||
final hit = _index[key];
|
||||
if (hit != null && hit.isNotEmpty) return hit;
|
||||
}
|
||||
return const [];
|
||||
}
|
||||
|
||||
static String _looseIndexKey(String loose) => loose.isEmpty ? '' : '~$loose';
|
||||
|
||||
/// Jetons sans valeur distinctive pour reconnaître une chaîne.
|
||||
static const _looseNoise = {
|
||||
'channel', 'tv', 'hd', 'fhd', 'uhd', 'sd', '4k', 'hevc',
|
||||
};
|
||||
|
||||
/// Codes pays en fin d'identifiant XMLTV (`TF1.fr`, `RTS1.ch`).
|
||||
static const _countryCodes = {
|
||||
'fr', 'be', 'ch', 'lu', 'mc', 'ca', 'uk', 'us', 'es', 'it', 'de', 'pt',
|
||||
'nl',
|
||||
};
|
||||
|
||||
/// Clé « souple » : sans code pays final ni mots génériques
|
||||
/// (« AB3.Channel.fr » et « AB3 » → `ab3`).
|
||||
///
|
||||
/// Servie dans un espace de noms à part, consultée en tout dernier : elle
|
||||
/// rattrape les variantes d'écriture sans fausser les correspondances
|
||||
/// exactes.
|
||||
static String looseKey(String? raw) {
|
||||
if (raw == null) return '';
|
||||
final folded = StringBuffer();
|
||||
for (final rune in raw.toLowerCase().runes) {
|
||||
final char = String.fromCharCode(rune);
|
||||
folded.write(_accents[char] ?? char);
|
||||
}
|
||||
final tokens = folded
|
||||
.toString()
|
||||
.split(RegExp(r'[^a-z0-9]+'))
|
||||
.where((t) => t.isNotEmpty)
|
||||
.toList();
|
||||
if (tokens.length > 1 && _countryCodes.contains(tokens.last)) {
|
||||
tokens.removeLast();
|
||||
}
|
||||
tokens.removeWhere(_looseNoise.contains);
|
||||
return tokens.join();
|
||||
}
|
||||
|
||||
/// Préfixe pays des noms de panneau : `FR - `, `FR: `, `|FR| `, `[FR] `…
|
||||
static final _countryPrefix = RegExp(
|
||||
r'^\s*[\|\[\(]?\s*([A-Za-z]{2,3})\s*[\|\]\)]?\s*[-:|]\s*|^\s*[\|\[\(]\s*([A-Za-z]{2,3})\s*[\|\]\)]\s*',
|
||||
);
|
||||
|
||||
/// Marqueurs de qualité ou de source en fin de nom, sans rapport avec la
|
||||
/// chaîne elle-même.
|
||||
static final _qualitySuffix = RegExp(
|
||||
r'(\s+|^)(fhd|uhd|hd|sd|hq|lq|4k|8k|hevc|h\.?265|h\.?264|1080p?|720p?|'
|
||||
r'50fps|60fps|backup|raw|vip|multi|\(backup\)|\(multi\))\s*$',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
/// Clés candidates pour retrouver une chaîne du panneau par son nom.
|
||||
///
|
||||
/// POURQUOI : en prod, 59 % des chaînes françaises n'ont pas
|
||||
/// d'`epg_channel_id`. Leur nom brut (« FR - TF1 FHD ◉ » → `frtf1fhd`) ne
|
||||
/// correspond à aucune entrée XMLTV (`TF1.fr`, « TF1 »). On retire le
|
||||
/// préfixe pays, les marqueurs de qualité et les symboles, puis on tente
|
||||
/// aussi la forme `nom.pays` qu'utilisent les dumps publics.
|
||||
///
|
||||
/// Le décalage horaire (« +1 ») est conservé : TF1 +1 n'a pas le guide
|
||||
/// de TF1.
|
||||
static List<String> nameKeys(String name) {
|
||||
final keys = <String>[normalizeKey(name)];
|
||||
final cleaned = cleanChannelName(name);
|
||||
final country = cleaned.country;
|
||||
final base = normalizeKey(cleaned.name);
|
||||
if (base.isNotEmpty) {
|
||||
keys.add(base);
|
||||
keys.add('$base${country ?? 'fr'}');
|
||||
}
|
||||
return keys.where((k) => k.isNotEmpty).toSet().toList();
|
||||
}
|
||||
|
||||
/// Abréviations du panneau pour les chaînes publiques (« F3 ALPES »), là
|
||||
/// où les dumps écrivent `France.3.-.Alpes.fr`.
|
||||
static final _franceAbbrev = RegExp(r'^F\s?([2-5])\b', caseSensitive: false);
|
||||
|
||||
/// Nom de chaîne débarrassé du préfixe pays, des symboles et des
|
||||
/// marqueurs de qualité ; [country] = code pays du préfixe, s'il y en a.
|
||||
static ({String name, String? country}) cleanChannelName(String raw) {
|
||||
var cleaned = raw;
|
||||
String? country;
|
||||
final prefix = _countryPrefix.firstMatch(cleaned);
|
||||
if (prefix != null) {
|
||||
country = (prefix.group(1) ?? prefix.group(2))?.toLowerCase();
|
||||
cleaned = cleaned.substring(prefix.end);
|
||||
}
|
||||
// Symboles décoratifs (◉, ᴴᴰ, ★…) : tout ce qui n'est ni lettre, ni
|
||||
// chiffre, ni ponctuation utile.
|
||||
cleaned = cleaned
|
||||
.replaceAll(RegExp(r'[^\p{L}\p{N}\s+.&\-()]', unicode: true), ' ')
|
||||
.trim();
|
||||
String previous;
|
||||
do {
|
||||
previous = cleaned;
|
||||
cleaned = cleaned.replaceFirst(_qualitySuffix, '').trim();
|
||||
} while (cleaned != previous && cleaned.isNotEmpty);
|
||||
cleaned = cleaned.replaceFirstMapped(
|
||||
_franceAbbrev,
|
||||
(m) => 'France ${m[1]}',
|
||||
);
|
||||
return (name: cleaned, country: country);
|
||||
}
|
||||
|
||||
/// Recharge l'index s'il est absent ou périmé. Les appels concurrents
|
||||
/// partagent le même téléchargement.
|
||||
Future<void> ensureFresh() {
|
||||
final age = _indexedAt == null
|
||||
? null
|
||||
: DateTime.now().difference(_indexedAt!);
|
||||
if (age != null && age < refreshInterval) return Future.value();
|
||||
|
||||
final sinceFailure = _failedAt == null
|
||||
? null
|
||||
: DateTime.now().difference(_failedAt!);
|
||||
if (sinceFailure != null && sinceFailure < retryBackoff) {
|
||||
return Future.value();
|
||||
}
|
||||
|
||||
return _refreshInFlight ??= _refresh().whenComplete(() {
|
||||
_refreshInFlight = null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _refresh() async {
|
||||
if (sourceUrls.isEmpty) return;
|
||||
|
||||
final merged = <String, List<XmltvProgramme>>{};
|
||||
var ok = 0;
|
||||
|
||||
for (final url in sourceUrls) {
|
||||
// La source peut être le `xmltv.php` du panneau, dont l'URL porte les
|
||||
// identifiants de l'abonné en clair : ne jamais la journaliser telle
|
||||
// quelle (elle finirait dans `docker logs`). Le texte de l'exception
|
||||
// est masqué aussi, un `ClientException` recopiant l'URL demandée.
|
||||
final safeUrl = LogRedactor.redactUrl(url);
|
||||
try {
|
||||
final parsed = await _downloadAndParse(url);
|
||||
// Première source servie gagne : les suivantes ne comblent que les
|
||||
// chaînes encore absentes.
|
||||
for (final entry in parsed.entries) {
|
||||
merged.putIfAbsent(entry.key, () => entry.value);
|
||||
}
|
||||
ok++;
|
||||
print(
|
||||
'[XmltvEpg] $safeUrl : ${parsed.length} chaînes indexées',
|
||||
);
|
||||
} catch (e) {
|
||||
print(
|
||||
'[XmltvEpg] $safeUrl : échec (${LogRedactor.redactUrl('$e')})',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (ok == 0) {
|
||||
// Garder l'index précédent plutôt que de servir un guide vide.
|
||||
_failedAt = DateTime.now();
|
||||
print('[XmltvEpg] aucune source disponible, index précédent conservé');
|
||||
return;
|
||||
}
|
||||
|
||||
_index = merged;
|
||||
_indexedAt = DateTime.now();
|
||||
_failedAt = null;
|
||||
print('[XmltvEpg] index prêt : ${merged.length} chaînes');
|
||||
}
|
||||
|
||||
/// Télécharge [url] puis l'indexe dans un isolate dédié.
|
||||
///
|
||||
/// Le serveur n'a qu'une boucle d'événements : décompresser et parser un
|
||||
/// dump de plusieurs milliers de chaînes dessus la gelait pendant des
|
||||
/// secondes. Plus rien d'autre ne répondait — en particulier le relais
|
||||
/// `turbo.ts`, qui cessait d'envoyer des octets au lecteur : la lecture
|
||||
/// se coupait systématiquement au démarrage, au moment précis où l'écran
|
||||
/// des chaînes demandait le guide.
|
||||
Future<Map<String, List<XmltvProgramme>>> _downloadAndParse(
|
||||
String url,
|
||||
) async {
|
||||
final response =
|
||||
await _client.get(Uri.parse(url)).timeout(downloadTimeout);
|
||||
if (response.statusCode != 200) {
|
||||
throw HttpException('HTTP ${response.statusCode}');
|
||||
}
|
||||
|
||||
// Variables locales : la fermeture envoyée à l'isolate ne doit pas
|
||||
// capturer `this` (le client HTTP n'est pas transférable).
|
||||
final bytes = response.bodyBytes;
|
||||
final retention = this.retention;
|
||||
final horizon = this.horizon;
|
||||
return Isolate.run(
|
||||
() => _decodeAndParse(bytes, retention: retention, horizon: horizon),
|
||||
);
|
||||
}
|
||||
|
||||
static Map<String, List<XmltvProgramme>> _decodeAndParse(
|
||||
Uint8List raw, {
|
||||
required Duration retention,
|
||||
required Duration horizon,
|
||||
}) {
|
||||
List<int> bytes = raw;
|
||||
// Beaucoup de miroirs servent du .gz sans en-tête Content-Encoding : on
|
||||
// regarde le nombre magique plutôt que de se fier aux en-têtes.
|
||||
if (bytes.length > 2 && bytes[0] == 0x1f && bytes[1] == 0x8b) {
|
||||
bytes = gzip.decode(bytes);
|
||||
}
|
||||
return _parse(
|
||||
utf8.decode(bytes, allowMalformed: true),
|
||||
retention: retention,
|
||||
horizon: horizon,
|
||||
);
|
||||
}
|
||||
|
||||
/// Exposé pour les tests.
|
||||
Map<String, List<XmltvProgramme>> parseForTest(String xml) =>
|
||||
_parse(xml, retention: retention, horizon: horizon);
|
||||
|
||||
static Map<String, List<XmltvProgramme>> _parse(
|
||||
String xml, {
|
||||
required Duration retention,
|
||||
required Duration horizon,
|
||||
}) {
|
||||
final cutoff = DateTime.now().toUtc().subtract(retention);
|
||||
final limit = DateTime.now().toUtc().add(horizon);
|
||||
final byChannel = <String, List<XmltvProgramme>>{};
|
||||
|
||||
// Alias : plusieurs dumps déclarent <channel id="X"> avec un ou plusieurs
|
||||
// <display-name> différents de l'identifiant (« TF1 HD », « TF1 »). On
|
||||
// les indexe TOUS : seul le premier l'était, et c'était souvent la
|
||||
// variante la moins proche du nom annoncé par le panneau.
|
||||
final aliases = <String, String>{};
|
||||
final displayNames = <String>[];
|
||||
|
||||
String? channelId;
|
||||
String? programmeChannel;
|
||||
DateTime? start;
|
||||
DateTime? stop;
|
||||
String? currentTag;
|
||||
final title = StringBuffer();
|
||||
final desc = StringBuffer();
|
||||
final displayName = StringBuffer();
|
||||
|
||||
for (final event in parseEvents(xml)) {
|
||||
if (event is XmlStartElementEvent) {
|
||||
switch (event.name) {
|
||||
case 'channel':
|
||||
channelId = _attr(event, 'id');
|
||||
displayName.clear();
|
||||
displayNames.clear();
|
||||
case 'programme':
|
||||
programmeChannel = _attr(event, 'channel');
|
||||
start = parseXmltvDate(_attr(event, 'start'));
|
||||
stop = parseXmltvDate(_attr(event, 'stop'));
|
||||
title.clear();
|
||||
desc.clear();
|
||||
case 'title':
|
||||
case 'desc':
|
||||
case 'display-name':
|
||||
currentTag = event.name;
|
||||
}
|
||||
if (event.isSelfClosing) currentTag = null;
|
||||
} else if (event is XmlTextEvent || event is XmlCDATAEvent) {
|
||||
final text = event is XmlTextEvent
|
||||
? event.value
|
||||
: (event as XmlCDATAEvent).value;
|
||||
switch (currentTag) {
|
||||
case 'title':
|
||||
title.write(text);
|
||||
case 'desc':
|
||||
desc.write(text);
|
||||
case 'display-name':
|
||||
displayName.write(text);
|
||||
}
|
||||
} else if (event is XmlEndElementEvent) {
|
||||
switch (event.name) {
|
||||
case 'display-name':
|
||||
displayNames.add(displayName.toString());
|
||||
displayName.clear();
|
||||
case 'channel':
|
||||
final id = normalizeKey(channelId);
|
||||
for (final raw in displayNames) {
|
||||
final name = normalizeKey(raw);
|
||||
if (id.isNotEmpty && name.isNotEmpty && id != name) {
|
||||
aliases.putIfAbsent(name, () => id);
|
||||
}
|
||||
}
|
||||
// Clés souples (espace de noms « ~ ») : premier arrivé gagne.
|
||||
if (id.isNotEmpty) {
|
||||
for (final raw in [channelId, ...displayNames]) {
|
||||
final loose = _looseIndexKey(looseKey(raw));
|
||||
if (loose.isNotEmpty) aliases.putIfAbsent(loose, () => id);
|
||||
}
|
||||
}
|
||||
channelId = null;
|
||||
case 'programme':
|
||||
if (programmeChannel != null &&
|
||||
start != null &&
|
||||
stop != null &&
|
||||
stop.isAfter(cutoff) &&
|
||||
start.isBefore(limit)) {
|
||||
final key = normalizeKey(programmeChannel);
|
||||
if (key.isNotEmpty) {
|
||||
byChannel.putIfAbsent(key, () => []).add(
|
||||
XmltvProgramme(
|
||||
title: title.toString().trim(),
|
||||
description: desc.toString().trim(),
|
||||
start: start,
|
||||
stop: stop,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
programmeChannel = null;
|
||||
start = null;
|
||||
stop = null;
|
||||
}
|
||||
currentTag = null;
|
||||
}
|
||||
}
|
||||
|
||||
for (final list in byChannel.values) {
|
||||
list.sort((a, b) => a.start.compareTo(b.start));
|
||||
}
|
||||
|
||||
// Rendre les chaînes atteignables aussi par leur nom affiché.
|
||||
aliases.forEach((name, id) {
|
||||
final programmes = byChannel[id];
|
||||
if (programmes != null) byChannel.putIfAbsent(name, () => programmes);
|
||||
});
|
||||
|
||||
return byChannel;
|
||||
}
|
||||
|
||||
static String _attr(XmlStartElementEvent event, String name) {
|
||||
for (final attribute in event.attributes) {
|
||||
if (attribute.name == name) return attribute.value;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/// Clé de correspondance : minuscules, sans ponctuation ni accents.
|
||||
///
|
||||
/// Les dumps publics écrivent `France.2.fr` là où le panneau annonce
|
||||
/// `France2.fr` ; sans normalisation, la moitié des chaînes ne trouvent
|
||||
/// jamais leur guide.
|
||||
static String normalizeKey(String? raw) {
|
||||
if (raw == null) return '';
|
||||
final buffer = StringBuffer();
|
||||
for (final rune in raw.toLowerCase().runes) {
|
||||
final char = String.fromCharCode(rune);
|
||||
final folded = _accents[char] ?? char;
|
||||
if (RegExp(r'[a-z0-9]').hasMatch(folded)) buffer.write(folded);
|
||||
}
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
static const _accents = {
|
||||
'à': 'a', 'á': 'a', 'â': 'a', 'ã': 'a', 'ä': 'a', 'å': 'a',
|
||||
'è': 'e', 'é': 'e', 'ê': 'e', 'ë': 'e',
|
||||
'ì': 'i', 'í': 'i', 'î': 'i', 'ï': 'i',
|
||||
'ò': 'o', 'ó': 'o', 'ô': 'o', 'õ': 'o', 'ö': 'o',
|
||||
'ù': 'u', 'ú': 'u', 'û': 'u', 'ü': 'u',
|
||||
'ç': 'c', 'ñ': 'n',
|
||||
};
|
||||
|
||||
/// « 20260811200000 +0200 » → instant UTC.
|
||||
static DateTime? parseXmltvDate(String? raw) {
|
||||
if (raw == null || raw.length < 14) return null;
|
||||
final digits = raw.substring(0, 14);
|
||||
final base = DateTime.tryParse(
|
||||
'${digits.substring(0, 4)}-${digits.substring(4, 6)}-'
|
||||
'${digits.substring(6, 8)}T${digits.substring(8, 10)}:'
|
||||
'${digits.substring(10, 12)}:${digits.substring(12, 14)}Z',
|
||||
);
|
||||
if (base == null) return null;
|
||||
|
||||
final offset = raw.length >= 20 ? raw.substring(15, 20) : null;
|
||||
if (offset == null || offset.length != 5) return base;
|
||||
|
||||
final sign = offset[0] == '-' ? -1 : 1;
|
||||
final hours = int.tryParse(offset.substring(1, 3));
|
||||
final minutes = int.tryParse(offset.substring(3, 5));
|
||||
if (hours == null || minutes == null) return base;
|
||||
|
||||
return base.subtract(
|
||||
Duration(hours: sign * hours, minutes: sign * minutes),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class XmltvProgramme {
|
||||
const XmltvProgramme({
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.start,
|
||||
required this.stop,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String description;
|
||||
final DateTime start;
|
||||
final DateTime stop;
|
||||
|
||||
Map<String, dynamic> toJson(String channelId) => {
|
||||
'title': title,
|
||||
'description': description,
|
||||
'start': start.toUtc().toIso8601String(),
|
||||
'end': stop.toUtc().toIso8601String(),
|
||||
'channel_id': channelId,
|
||||
};
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import 'package:test/test.dart';
|
||||
import '../api/asset_failure_cache.dart';
|
||||
|
||||
void main() {
|
||||
group('AssetFailureCache', () {
|
||||
test('un hôte inconnu est considéré joignable', () {
|
||||
final cache = AssetFailureCache();
|
||||
expect(cache.isDown('cdn.example'), isFalse);
|
||||
});
|
||||
|
||||
test('un échec isolé ne coupe pas l’hôte', () {
|
||||
// Un 5xx passager ne doit pas priver l'utilisateur de ses logos
|
||||
// pendant plusieurs minutes.
|
||||
final cache = AssetFailureCache(threshold: 3);
|
||||
cache.recordFailure('cdn.example');
|
||||
expect(cache.isDown('cdn.example'), isFalse);
|
||||
});
|
||||
|
||||
test('coupe l’hôte après le seuil d’échecs consécutifs', () {
|
||||
final cache = AssetFailureCache(threshold: 3);
|
||||
for (var i = 0; i < 3; i++) {
|
||||
cache.recordFailure('cdn.example');
|
||||
}
|
||||
expect(cache.isDown('cdn.example'), isTrue);
|
||||
});
|
||||
|
||||
test('un succès remet le compteur à zéro', () {
|
||||
final cache = AssetFailureCache(threshold: 3);
|
||||
cache.recordFailure('cdn.example');
|
||||
cache.recordFailure('cdn.example');
|
||||
cache.recordSuccess('cdn.example');
|
||||
cache.recordFailure('cdn.example');
|
||||
expect(cache.isDown('cdn.example'), isFalse);
|
||||
});
|
||||
|
||||
test('la coupure expire après le TTL', () {
|
||||
var now = DateTime(2026, 9, 19, 9);
|
||||
final cache = AssetFailureCache(
|
||||
threshold: 1,
|
||||
ttl: const Duration(minutes: 5),
|
||||
clock: () => now,
|
||||
);
|
||||
cache.recordFailure('cdn.example');
|
||||
expect(cache.isDown('cdn.example'), isTrue);
|
||||
|
||||
now = now.add(const Duration(minutes: 6));
|
||||
expect(cache.isDown('cdn.example'), isFalse);
|
||||
});
|
||||
|
||||
test('les hôtes sont indépendants', () {
|
||||
final cache = AssetFailureCache(threshold: 1);
|
||||
cache.recordFailure('mort.example');
|
||||
expect(cache.isDown('mort.example'), isTrue);
|
||||
expect(cache.isDown('vivant.example'), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import 'package:test/test.dart';
|
||||
import '../utils/asset_versioning.dart';
|
||||
|
||||
void main() {
|
||||
group('versionAssetRefs', () {
|
||||
test('versionne la balise script du lecteur', () {
|
||||
expect(
|
||||
versionAssetRefs(
|
||||
'<script src="xf-player-core.js"></script>',
|
||||
{'xf-player-core.js': 'abc123'},
|
||||
),
|
||||
'<script src="xf-player-core.js?v=abc123"></script>',
|
||||
);
|
||||
});
|
||||
|
||||
test('versionne mainJsPath dans la config de build Flutter', () {
|
||||
expect(
|
||||
versionAssetRefs(
|
||||
'{"compileTarget":"dart2js","mainJsPath":"main.dart.js"}',
|
||||
{'main.dart.js': 'f00'},
|
||||
),
|
||||
'{"compileTarget":"dart2js","mainJsPath":"main.dart.js?v=f00"}',
|
||||
);
|
||||
});
|
||||
|
||||
test('ne touche ni un nom qui ne fait que contenir l\'asset ni une ref déjà versionnée', () {
|
||||
const html = '<script src="vendor/xf-player-core.js"></script>'
|
||||
'<script src="xf-player-core.js?v=old"></script>';
|
||||
expect(versionAssetRefs(html, {'xf-player-core.js': 'new'}), html);
|
||||
});
|
||||
|
||||
test('guillemets simples acceptés', () {
|
||||
expect(
|
||||
versionAssetRefs("<link href='xf-player.css'>", {'xf-player.css': '1'}),
|
||||
"<link href='xf-player.css?v=1'>",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('contentVersion', () {
|
||||
test('stable pour un même contenu, différente sinon', () {
|
||||
expect(contentVersion([1, 2, 3]), contentVersion([1, 2, 3]));
|
||||
expect(contentVersion([1, 2, 3]), isNot(contentVersion([1, 2, 4])));
|
||||
expect(contentVersion([1, 2, 3]), hasLength(12));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,329 +0,0 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:shelf/shelf.dart';
|
||||
import 'package:test/test.dart';
|
||||
import '../api/epg_api.dart';
|
||||
import '../models/playlist_config.dart';
|
||||
import '../services/xmltv_epg_service.dart';
|
||||
|
||||
/// Extrait réel d'une réponse `get_simple_data_table` (panneau Xtream) :
|
||||
/// titres/descriptions en base64, horodatages epoch UTC doublés d'une chaîne
|
||||
/// exprimée dans le fuseau du panneau — ici Europe/Amsterdam (UTC+2).
|
||||
const _sampleListing = {
|
||||
'epg_listings': [
|
||||
{
|
||||
'id': '336987829',
|
||||
'epg_id': '104',
|
||||
'title': 'Sm91cm5hbCBkZSAyMGg=', // "Journal de 20h"
|
||||
'lang': '',
|
||||
'start': '2026-08-11 20:00:00',
|
||||
'end': '2026-08-11 20:35:00',
|
||||
'description': 'TCdpbmZvIGR1IHNvaXI=', // "L'info du soir"
|
||||
'channel_id': 'TF1.fr',
|
||||
'start_timestamp': '1786471200', // 2026-08-11T18:00:00Z
|
||||
'stop_timestamp': '1786473300', // 2026-08-11T18:35:00Z
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
void main() {
|
||||
final api = EpgApi((_) async => null);
|
||||
|
||||
group('transformEpgData', () {
|
||||
test('convertit les horodatages epoch en ISO-8601 UTC', () {
|
||||
final result = api.transformEpgData(_sampleListing, '845452');
|
||||
final programme = (result['programmes'] as List).single as Map;
|
||||
|
||||
// Sans cette conversion, le client relisait « 2026-08-11T20:00:00 »
|
||||
// comme de l'heure locale et décalait tout le guide de 2 h.
|
||||
expect(programme['start'], '2026-08-11T18:00:00.000Z');
|
||||
expect(programme['end'], '2026-08-11T18:35:00.000Z');
|
||||
});
|
||||
|
||||
test('décode les titres et descriptions base64', () {
|
||||
final result = api.transformEpgData(_sampleListing, '845452');
|
||||
final programme = (result['programmes'] as List).single as Map;
|
||||
|
||||
expect(programme['title'], 'Journal de 20h');
|
||||
expect(programme['description'], "L'info du soir");
|
||||
expect(programme['channel_id'], '845452');
|
||||
});
|
||||
|
||||
test('retombe sur les champs texte quand les epoch manquent', () {
|
||||
final result = api.transformEpgData({
|
||||
'epg_listings': [
|
||||
{
|
||||
'title': 'Sm91cm5hbCBkZSAyMGg=',
|
||||
'start': '2026-08-11 20:00:00',
|
||||
'stop': '2026-08-11 20:35:00',
|
||||
},
|
||||
],
|
||||
}, '845452');
|
||||
final programme = (result['programmes'] as List).single as Map;
|
||||
|
||||
expect(programme['start'], '2026-08-11T20:00:00');
|
||||
expect(programme['end'], '2026-08-11T20:35:00');
|
||||
});
|
||||
|
||||
test('rend une liste vide sur un payload sans epg_listings', () {
|
||||
// Ce que renvoie un panneau qui ne connaît pas l'action demandée :
|
||||
// un 200 contenant le bloc d'authentification.
|
||||
final result = api.transformEpgData({
|
||||
'user_info': {'auth': 1},
|
||||
'server_info': {'url': 'tit.example'},
|
||||
}, '845452');
|
||||
|
||||
expect(result['programmes'], isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('handleGetEpg', () {
|
||||
final playlist = PlaylistConfig(
|
||||
id: 'p1',
|
||||
name: 'Test',
|
||||
dns: 'http://panel.example',
|
||||
username: 'u',
|
||||
password: 'p',
|
||||
createdAt: DateTime(2026, 1, 1),
|
||||
);
|
||||
|
||||
String xmltvDump(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';
|
||||
}
|
||||
|
||||
return '''
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<tv>
|
||||
<channel id="France2.fr"><display-name>FRANCE 2</display-name></channel>
|
||||
<programme start="${stamp(start)}" stop="${stamp(stop)}" channel="France2.fr">
|
||||
<title lang="fr">Journal de 20h</title>
|
||||
<desc lang="fr">L'info du soir</desc>
|
||||
</programme>
|
||||
</tv>
|
||||
''';
|
||||
}
|
||||
|
||||
Request requestFor(String channelId) =>
|
||||
Request('GET', Uri.parse('http://localhost/api/epg/$channelId'));
|
||||
|
||||
test('sert le guide depuis le dump XMLTV du panneau, sans appel par '
|
||||
'chaîne', () async {
|
||||
// Un appel `player_api` par chaîne coûte plusieurs secondes chez le
|
||||
// fournisseur : le dump du panneau couvre toutes les chaînes d'un coup.
|
||||
final now = DateTime.now().toUtc();
|
||||
final actions = <String>[];
|
||||
|
||||
final client = MockClient((request) async {
|
||||
final url = request.url;
|
||||
if (url.path.endsWith('/xmltv.php')) {
|
||||
return http.Response(
|
||||
xmltvDump(now.subtract(const Duration(minutes: 5)),
|
||||
now.add(const Duration(minutes: 25))),
|
||||
200,
|
||||
);
|
||||
}
|
||||
final action = url.queryParameters['action'] ?? '';
|
||||
actions.add(action);
|
||||
if (action == 'get_live_streams') {
|
||||
return http.Response(
|
||||
jsonEncode([
|
||||
{
|
||||
'stream_id': 845452,
|
||||
'name': 'FRANCE 2',
|
||||
'epg_channel_id': 'France2.fr',
|
||||
},
|
||||
]),
|
||||
200,
|
||||
);
|
||||
}
|
||||
return http.Response('{}', 200);
|
||||
});
|
||||
|
||||
final api = EpgApi(
|
||||
(_) async => playlist,
|
||||
httpClient: client,
|
||||
panelXmltvBuilder: (config) => XmltvEpgService(
|
||||
sourceUrls: ['${config.dns}/xmltv.php'],
|
||||
client: client,
|
||||
),
|
||||
);
|
||||
|
||||
final response = await api.handleGetEpg(requestFor('845452'), '845452');
|
||||
final body = jsonDecode(await response.readAsString()) as Map;
|
||||
|
||||
expect(response.statusCode, 200);
|
||||
expect((body['programmes'] as List).single['title'], 'Journal de 20h');
|
||||
expect(response.headers['X-Epg-Source'], 'panel-xmltv');
|
||||
// Seule la table des chaînes est interrogée : aucune action EPG.
|
||||
expect(actions, ['get_live_streams']);
|
||||
});
|
||||
|
||||
test('retombe sur player_api quand le dump ne couvre pas la chaîne',
|
||||
() async {
|
||||
final actions = <String>[];
|
||||
// Heure locale volontairement : le panneau émet des horodatages sans
|
||||
// fuseau, que le serveur relit comme de l'heure locale.
|
||||
final future = DateTime.now().add(const Duration(minutes: 10));
|
||||
String panelStamp(DateTime d) =>
|
||||
d.toIso8601String().substring(0, 19).replaceFirst('T', ' ');
|
||||
|
||||
final client = MockClient((request) async {
|
||||
final url = request.url;
|
||||
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(const []), 200);
|
||||
}
|
||||
if (action == 'get_simple_data_table') {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'epg_listings': [
|
||||
{
|
||||
'title': base64Encode(utf8.encode('Match')),
|
||||
'description': '',
|
||||
'start': panelStamp(future),
|
||||
'end': panelStamp(future.add(const Duration(hours: 2))),
|
||||
},
|
||||
],
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
return http.Response('{}', 200);
|
||||
});
|
||||
|
||||
final api = EpgApi(
|
||||
(_) async => playlist,
|
||||
httpClient: client,
|
||||
panelXmltvBuilder: (config) => XmltvEpgService(
|
||||
sourceUrls: ['${config.dns}/xmltv.php'],
|
||||
client: client,
|
||||
),
|
||||
);
|
||||
|
||||
final response = await api.handleGetEpg(requestFor('999'), '999');
|
||||
final body = jsonDecode(await response.readAsString()) as Map;
|
||||
|
||||
expect((body['programmes'] as List).single['title'], 'Match');
|
||||
expect(response.headers['X-Epg-Source'], 'xtream');
|
||||
expect(actions, contains('get_simple_data_table'));
|
||||
});
|
||||
|
||||
// Dump externe couvrant TF1, et panneau dont le xmltv.php est vide.
|
||||
String externalDump() {
|
||||
final now = DateTime.now().toUtc();
|
||||
return xmltvDump(now, now.add(const Duration(hours: 1)))
|
||||
.replaceAll('France2.fr', 'TF1.fr')
|
||||
.replaceAll('FRANCE 2', 'TF1');
|
||||
}
|
||||
|
||||
EpgApi apiWith(http.Client client) => EpgApi(
|
||||
(_) async => playlist,
|
||||
httpClient: client,
|
||||
xmltv: XmltvEpgService(
|
||||
sourceUrls: const ['http://ext.example/epg.xml'],
|
||||
client: client,
|
||||
),
|
||||
panelXmltvBuilder: (config) => XmltvEpgService(
|
||||
sourceUrls: ['${config.dns}/xmltv.php'],
|
||||
client: client,
|
||||
),
|
||||
);
|
||||
|
||||
test('un panneau qui plante ne prive plus la chaîne du dump externe',
|
||||
() async {
|
||||
// Constaté en prod : chaque /api/epg répondait 500 en 4 s, le guide
|
||||
// externe (qui couvrait pourtant la chaîne) n'étant jamais consulté.
|
||||
final client = MockClient((request) async {
|
||||
final url = request.url;
|
||||
if (url.host == 'ext.example') return http.Response(externalDump(), 200);
|
||||
if (url.path.endsWith('/xmltv.php')) return http.Response('<tv></tv>', 200);
|
||||
final action = url.queryParameters['action'] ?? '';
|
||||
if (action == 'get_live_streams') {
|
||||
// Octets UTF-8 et JSON sans charset annoncé, comme le panneau réel.
|
||||
return http.Response.bytes(
|
||||
utf8.encode(jsonEncode([
|
||||
{'stream_id': 1, 'name': 'FR - TF1 FHD ◉', 'epg_channel_id': ''},
|
||||
])),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}
|
||||
throw http.ClientException('Connection closed', url);
|
||||
});
|
||||
|
||||
final response = await apiWith(client).handleGetEpg(requestFor('1'), '1');
|
||||
final body = jsonDecode(await response.readAsString()) as Map;
|
||||
|
||||
expect(response.statusCode, 200);
|
||||
expect(response.headers['X-Epg-Source'], 'xmltv');
|
||||
expect((body['programmes'] as List).single['title'], 'Journal de 20h');
|
||||
});
|
||||
|
||||
test('le dump externe passe avant l\'appel lent chaîne par chaîne',
|
||||
() async {
|
||||
final actions = <String>[];
|
||||
final client = MockClient((request) async {
|
||||
final url = request.url;
|
||||
if (url.host == 'ext.example') return http.Response(externalDump(), 200);
|
||||
if (url.path.endsWith('/xmltv.php')) return http.Response('<tv></tv>', 200);
|
||||
final action = url.queryParameters['action'] ?? '';
|
||||
actions.add(action);
|
||||
if (action == 'get_live_streams') {
|
||||
return http.Response(
|
||||
jsonEncode([
|
||||
{'stream_id': 1, 'name': 'TF1', 'epg_channel_id': 'TF1.fr'},
|
||||
]),
|
||||
200,
|
||||
);
|
||||
}
|
||||
return http.Response('{}', 200);
|
||||
});
|
||||
|
||||
final response = await apiWith(client).handleGetEpg(requestFor('1'), '1');
|
||||
expect(response.headers['X-Epg-Source'], 'xmltv');
|
||||
expect(actions, isNot(contains('get_simple_data_table')));
|
||||
});
|
||||
|
||||
test('la grille entière partage un seul téléchargement de la table',
|
||||
() async {
|
||||
// 30 tuiles demandent leur guide d'un coup : sans mutualisation, autant
|
||||
// de `get_live_streams` (7 Mo en prod) partaient en parallèle.
|
||||
var liveStreamsCalls = 0;
|
||||
final client = MockClient((request) async {
|
||||
final url = request.url;
|
||||
if (url.host == 'ext.example') return http.Response(externalDump(), 200);
|
||||
if (url.path.endsWith('/xmltv.php')) return http.Response('<tv></tv>', 200);
|
||||
final action = url.queryParameters['action'] ?? '';
|
||||
if (action == 'get_live_streams') {
|
||||
liveStreamsCalls++;
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
return http.Response(
|
||||
jsonEncode([
|
||||
for (var i = 1; i <= 5; i++)
|
||||
{'stream_id': i, 'name': 'TF1', 'epg_channel_id': 'TF1.fr'},
|
||||
]),
|
||||
200,
|
||||
);
|
||||
}
|
||||
return http.Response('{}', 200);
|
||||
});
|
||||
|
||||
final api = apiWith(client);
|
||||
final responses = await Future.wait([
|
||||
for (var i = 1; i <= 5; i++) api.handleGetEpg(requestFor('$i'), '$i'),
|
||||
]);
|
||||
expect(responses.every((r) => r.statusCode == 200), isTrue);
|
||||
expect(liveStreamsCalls, 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import 'package:test/test.dart';
|
||||
import '../services/gpu_fallback.dart';
|
||||
|
||||
void main() {
|
||||
group('isGpuFailure', () {
|
||||
test('detects NVENC out-of-memory', () {
|
||||
expect(
|
||||
isGpuFailure(
|
||||
'FFmpeg exited (1): [h264_nvenc @ 0x55] OpenEncodeSessionEx failed: '
|
||||
'out of memory (10)',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('detects missing NVENC device', () {
|
||||
expect(
|
||||
isGpuFailure('[h264_nvenc @ 0x1] No capable devices found'),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('detects CUDA errors', () {
|
||||
expect(
|
||||
isGpuFailure('cu->cuInit(0) failed -> CUDA_ERROR_OUT_OF_MEMORY'),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('detects encoder opening failure', () {
|
||||
expect(
|
||||
isGpuFailure(
|
||||
'Error while opening encoder for output stream #0:0 - maybe '
|
||||
'incorrect parameters such as bit_rate, rate, width or height',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('ignores upstream failures', () {
|
||||
expect(
|
||||
isGpuFailure(
|
||||
'FFmpeg exited (1): http://x/live/u/p/1.ts: Server returned 404 '
|
||||
'Not Found',
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('ignores timeouts and null', () {
|
||||
expect(isGpuFailure('Timeout waiting for transcoder'), isFalse);
|
||||
expect(isGpuFailure(null), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('GpuHealth', () {
|
||||
test('available until a failure, then again after cooldown', () {
|
||||
var now = DateTime.utc(2026, 10, 8, 20, 0);
|
||||
final health = GpuHealth(
|
||||
cooldown: const Duration(minutes: 2),
|
||||
now: () => now,
|
||||
);
|
||||
|
||||
expect(health.available, isTrue);
|
||||
|
||||
health.markFailed();
|
||||
expect(health.available, isFalse);
|
||||
|
||||
now = now.add(const Duration(minutes: 1, seconds: 59));
|
||||
expect(health.available, isFalse);
|
||||
|
||||
now = now.add(const Duration(seconds: 1));
|
||||
expect(health.available, isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import 'package:test/test.dart';
|
||||
import '../utils/log_redactor.dart';
|
||||
|
||||
void main() {
|
||||
group('LogRedactor.redactUrl', () {
|
||||
test('masks credentials in query strings', () {
|
||||
final out = LogRedactor.redactUrl(
|
||||
'http://srv:8080/player_api.php?username=john&password=hunter2&action=get_live_streams',
|
||||
);
|
||||
expect(out, isNot(contains('john')));
|
||||
expect(out, isNot(contains('hunter2')));
|
||||
expect(out, contains('username=***'));
|
||||
expect(out, contains('password=***'));
|
||||
expect(out, contains('action=get_live_streams'));
|
||||
});
|
||||
|
||||
test('masks credentials in live/movie/series path segments', () {
|
||||
for (final kind in ['live', 'movie', 'series']) {
|
||||
final out = LogRedactor.redactUrl(
|
||||
'http://srv:8080/$kind/john/hunter2/12345.ts',
|
||||
);
|
||||
expect(out, isNot(contains('john')), reason: kind);
|
||||
expect(out, isNot(contains('hunter2')), reason: kind);
|
||||
expect(out, contains('/$kind/***/***/12345.ts'), reason: kind);
|
||||
}
|
||||
});
|
||||
|
||||
test('leaves URLs without credentials untouched', () {
|
||||
const url = 'http://srv:8080/images/logo.png';
|
||||
expect(LogRedactor.redactUrl(url), url);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:shelf/shelf.dart';
|
||||
import 'package:test/test.dart';
|
||||
import '../api/logo_api.dart';
|
||||
import '../services/logo_catalog.dart';
|
||||
|
||||
/// Catalogue qui connaît uniquement `rmc-sport-2` et sert [bytes].
|
||||
LogoCatalog _catalog(List<int> bytes, {void Function()? onUse}) {
|
||||
return LogoCatalog(
|
||||
client: MockClient((request) async {
|
||||
onUse?.call();
|
||||
if (request.url.host == 'api.github.com') {
|
||||
return http.Response(
|
||||
jsonEncode([
|
||||
{
|
||||
'name': 'rmc-sport-2-fr.png',
|
||||
'download_url': 'https://raw.example/rmc-sport-2-fr.png',
|
||||
},
|
||||
]),
|
||||
200,
|
||||
);
|
||||
}
|
||||
return http.Response.bytes(bytes, 200);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Request _logoRequest(String src, String name) => Request(
|
||||
'GET',
|
||||
Uri.http('localhost:8089', '/api/logo', {'src': src, 'name': name}),
|
||||
headers: {'cookie': 'session=abc'},
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('LogoApi', () {
|
||||
test('sert le logo du panneau quand il répond', () async {
|
||||
Request? forwarded;
|
||||
var catalogUsed = false;
|
||||
final api = LogoApi(
|
||||
(request) {
|
||||
forwarded = request;
|
||||
return Response.ok(
|
||||
[9, 9],
|
||||
headers: {'content-type': 'image/png'},
|
||||
);
|
||||
},
|
||||
_catalog([1], onUse: () => catalogUsed = true),
|
||||
);
|
||||
|
||||
final response = await api.handle(
|
||||
_logoRequest('http://picons.example/logos/1.png', 'FR - RMC SPORT 2'),
|
||||
);
|
||||
|
||||
expect(response.statusCode, 200);
|
||||
expect(await response.read().expand((b) => b).toList(), [9, 9]);
|
||||
expect(response.headers['cache-control'], contains('max-age=86400'));
|
||||
expect(catalogUsed, isFalse);
|
||||
// Le proxy reçoit l'URL au format attendu et la session du client.
|
||||
expect(forwarded!.url.path, 'api/xtream/http://picons.example/logos/1.png');
|
||||
expect(forwarded!.headers['cookie'], 'session=abc');
|
||||
});
|
||||
|
||||
test('retombe sur le catalogue quand le panneau échoue', () async {
|
||||
final api = LogoApi(
|
||||
(_) => Response.notFound(null),
|
||||
_catalog([4, 2]),
|
||||
);
|
||||
|
||||
final response = await api.handle(
|
||||
_logoRequest('http://picons.example/logos/1.png', 'FR - RMC SPORT 2'),
|
||||
);
|
||||
|
||||
expect(response.statusCode, 200);
|
||||
expect(response.headers['content-type'], 'image/png');
|
||||
expect(await response.read().expand((b) => b).toList(), [4, 2]);
|
||||
});
|
||||
|
||||
test('ignore une réponse panneau qui n’est pas une image', () async {
|
||||
// Page HTML de maintenance servie en 200 : pas un logo.
|
||||
final api = LogoApi(
|
||||
(_) => Response.ok('<html>', headers: {'content-type': 'text/html'}),
|
||||
_catalog([4, 2]),
|
||||
);
|
||||
|
||||
final response = await api.handle(
|
||||
_logoRequest('http://picons.example/logos/1.png', 'FR - RMC SPORT 2'),
|
||||
);
|
||||
|
||||
expect(await response.read().expand((b) => b).toList(), [4, 2]);
|
||||
});
|
||||
|
||||
test('rend une erreur mise en cache quand rien ne correspond', () async {
|
||||
// Un statut d'erreur laisse le client afficher son icône de repli ; une
|
||||
// image vide donnait une tuile blanche. 410 et non 404 : la Cascade du
|
||||
// serveur rattrape les 404 et perdrait le cache-control.
|
||||
final api = LogoApi(
|
||||
(_) => Response.notFound(null),
|
||||
_catalog([1]),
|
||||
);
|
||||
|
||||
final response = await api.handle(
|
||||
_logoRequest('http://picons.example/logos/1.png', 'FR - INCONNUE'),
|
||||
);
|
||||
|
||||
expect(response.statusCode, 410);
|
||||
expect(response.headers['cache-control'], contains('max-age=600'));
|
||||
});
|
||||
|
||||
test('cherche par nom quand le panneau ne fournit pas de logo', () async {
|
||||
var proxyCalled = false;
|
||||
final api = LogoApi(
|
||||
(_) {
|
||||
proxyCalled = true;
|
||||
return Response.notFound(null);
|
||||
},
|
||||
_catalog([7]),
|
||||
);
|
||||
|
||||
final response = await api.handle(_logoRequest('', 'FR - RMC SPORT 2'));
|
||||
|
||||
expect(response.statusCode, 200);
|
||||
expect(proxyCalled, isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:test/test.dart';
|
||||
import '../services/logo_catalog.dart';
|
||||
|
||||
/// Extrait réel de `tv-logo/tv-logos/countries/france` (noms sans `-fr.png`).
|
||||
const _france = {
|
||||
'bein-sports-1-french',
|
||||
'bein-sports-2-french',
|
||||
'bein-sports',
|
||||
'canal-plus',
|
||||
'canal-plus-sport',
|
||||
'canal-plus-sport-360',
|
||||
'eurosport-1',
|
||||
'france-2',
|
||||
'france-24',
|
||||
'lequipe',
|
||||
'rmc-sport-1',
|
||||
'rmc-sport-2',
|
||||
'rmc-sport-access-1',
|
||||
};
|
||||
|
||||
void main() {
|
||||
group('matchLogoKey', () {
|
||||
String? match(String name) => matchLogoKey(name, _france);
|
||||
|
||||
test('correspondance exacte après nettoyage du nom panneau', () {
|
||||
// Préfixe pays et suffixe de qualité sont du bruit propre au panneau.
|
||||
expect(match('FR - RMC SPORT 2 FHD'), 'rmc-sport-2');
|
||||
expect(match('FR: France 2 HD'), 'france-2');
|
||||
expect(match('|FR| EUROSPORT 1 UHD'), 'eurosport-1');
|
||||
});
|
||||
|
||||
test('traduit le « + » de Canal+', () {
|
||||
expect(match('FR - CANAL+ SPORT FHD'), 'canal-plus-sport');
|
||||
expect(match('FR - CANAL+'), 'canal-plus');
|
||||
});
|
||||
|
||||
test('accepte une variante plus longue du même identifiant', () {
|
||||
// Le dépôt nomme `bein-sports-1-french`, le panneau `BEIN SPORTS 1`.
|
||||
expect(match('FR - BEIN SPORTS 1 FHD'), 'bein-sports-1-french');
|
||||
});
|
||||
|
||||
test('retombe sur la marque quand le numéro de canal est inconnu', () {
|
||||
// Les canaux événementiels (« Live 5 ») n'ont pas de logo propre :
|
||||
// celui de la marque vaut mieux qu'une tuile vide.
|
||||
expect(match('FR - RMC SPORT LIVE 5 FHD'), 'rmc-sport-1');
|
||||
expect(match('FR - CANAL+ SPORT 2'), 'canal-plus-sport');
|
||||
});
|
||||
|
||||
test('ne devine pas sur un seul mot', () {
|
||||
// « France » seul ne doit pas donner le logo de France 24.
|
||||
expect(match('FR - FRANCE'), isNull);
|
||||
});
|
||||
|
||||
test('rend null quand rien ne correspond', () {
|
||||
expect(match('FR - DAZN FHD'), isNull);
|
||||
expect(match(''), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('countryFolderFor', () {
|
||||
test('lit le préfixe pays du nom', () {
|
||||
expect(countryFolderFor('FR - TF1'), 'france');
|
||||
expect(countryFolderFor('BE: RTL TVI'), 'belgium');
|
||||
expect(countryFolderFor('|UK| BBC ONE'), 'united-kingdom');
|
||||
});
|
||||
|
||||
test('France par défaut sans préfixe reconnu', () {
|
||||
expect(countryFolderFor('TF1 HD'), 'france');
|
||||
});
|
||||
});
|
||||
|
||||
group('LogoCatalog', () {
|
||||
test('télécharge le logo apparié et le garde en cache', () async {
|
||||
final requested = <String>[];
|
||||
final client = MockClient((request) async {
|
||||
requested.add(request.url.toString());
|
||||
if (request.url.host == 'api.github.com') {
|
||||
return http.Response(
|
||||
jsonEncode([
|
||||
{
|
||||
'name': 'rmc-sport-2-fr.png',
|
||||
'download_url': 'https://raw.example/rmc-sport-2-fr.png',
|
||||
},
|
||||
{'name': 'README.md', 'download_url': 'https://raw.example/r'},
|
||||
]),
|
||||
200,
|
||||
);
|
||||
}
|
||||
return http.Response.bytes([1, 2, 3], 200);
|
||||
});
|
||||
|
||||
final catalog = LogoCatalog(client: client);
|
||||
final first = await catalog.logoFor('FR - RMC SPORT 2 FHD');
|
||||
final second = await catalog.logoFor('FR - RMC SPORT 2 FHD');
|
||||
|
||||
expect(first, [1, 2, 3]);
|
||||
expect(second, [1, 2, 3]);
|
||||
// Une liste et un logo, pas un aller-retour par affichage.
|
||||
expect(requested, hasLength(2));
|
||||
});
|
||||
|
||||
test('rend null sans correspondance, sans télécharger d’image', () async {
|
||||
var downloads = 0;
|
||||
final client = MockClient((request) async {
|
||||
if (request.url.host == 'api.github.com') {
|
||||
return http.Response(jsonEncode(const []), 200);
|
||||
}
|
||||
downloads++;
|
||||
return http.Response.bytes([1], 200);
|
||||
});
|
||||
|
||||
final catalog = LogoCatalog(client: client);
|
||||
expect(await catalog.logoFor('FR - CHAINE INCONNUE'), isNull);
|
||||
expect(downloads, 0);
|
||||
});
|
||||
|
||||
test('espace les tentatives quand la liste est injoignable', () async {
|
||||
var listings = 0;
|
||||
final client = MockClient((request) async {
|
||||
listings++;
|
||||
return http.Response('rate limited', 403);
|
||||
});
|
||||
|
||||
final catalog = LogoCatalog(client: client);
|
||||
await catalog.logoFor('FR - RMC SPORT 2');
|
||||
await catalog.logoFor('FR - RMC SPORT 1');
|
||||
|
||||
expect(listings, 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:test/test.dart';
|
||||
import '../utils/password_hasher.dart';
|
||||
|
||||
void main() {
|
||||
group('PasswordHasher', () {
|
||||
test('bcrypt roundtrip verifies the original password', () {
|
||||
final hash = PasswordHasher.hash('s3cret-Pass!');
|
||||
expect(hash, startsWith(r'$2'));
|
||||
expect(PasswordHasher.verify('s3cret-Pass!', hash), isTrue);
|
||||
expect(PasswordHasher.verify('wrong', hash), isFalse);
|
||||
});
|
||||
|
||||
test('legacy unsalted SHA-256 hashes still verify', () {
|
||||
final legacy = sha256.convert(utf8.encode('admin')).toString();
|
||||
expect(PasswordHasher.verify('admin', legacy), isTrue);
|
||||
expect(PasswordHasher.verify('not-admin', legacy), isFalse);
|
||||
});
|
||||
|
||||
test('isLegacy detects SHA-256 hex digests only', () {
|
||||
final legacy = sha256.convert(utf8.encode('admin')).toString();
|
||||
expect(PasswordHasher.isLegacy(legacy), isTrue);
|
||||
expect(PasswordHasher.isLegacy(PasswordHasher.hash('admin')), isFalse);
|
||||
});
|
||||
|
||||
test('rehash produces a non-legacy hash', () {
|
||||
final rehashed = PasswordHasher.hash('admin');
|
||||
expect(PasswordHasher.isLegacy(rehashed), isFalse);
|
||||
expect(PasswordHasher.verify('admin', rehashed), isTrue);
|
||||
});
|
||||
|
||||
test('malformed hash never verifies', () {
|
||||
expect(PasswordHasher.verify('x', 'garbage-hash'), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import 'package:test/test.dart';
|
||||
import '../api/player_log.dart';
|
||||
|
||||
void main() {
|
||||
group('formatPlayerEvent', () {
|
||||
test('écrit une coupure avec sa durée et le buffer', () {
|
||||
expect(
|
||||
formatPlayerEvent({
|
||||
'event': 'stall',
|
||||
'session': 'ab12',
|
||||
'ch': '554034',
|
||||
'ms': 5230,
|
||||
'buffer': 0.04,
|
||||
}),
|
||||
'[Player] stall session=ab12 ch=554034 ms=5230 buffer=0.0',
|
||||
);
|
||||
});
|
||||
|
||||
test('refuse un événement inconnu ou un payload invalide', () {
|
||||
expect(formatPlayerEvent({'event': 'rm -rf'}), isNull);
|
||||
expect(formatPlayerEvent('stall'), isNull);
|
||||
expect(formatPlayerEvent(null), isNull);
|
||||
});
|
||||
|
||||
test('pas d\'injection de lignes ni d\'URL (identifiants Xtream)', () {
|
||||
final line = formatPlayerEvent({
|
||||
'event': 'mpegts_error',
|
||||
'detail': 'NetworkError http://panel/live/user/pass/1.ts\n[Player] faux',
|
||||
})!;
|
||||
expect(line, isNot(contains('\n')));
|
||||
expect(line, isNot(contains('user/pass')));
|
||||
expect(line, contains('<url>'));
|
||||
});
|
||||
|
||||
test('ignore les champs inattendus et les nombres non finis', () {
|
||||
final line = formatPlayerEvent({
|
||||
'event': 'seek',
|
||||
'from': 10,
|
||||
'to': double.infinity,
|
||||
'cookie': 'secret',
|
||||
})!;
|
||||
expect(line, contains('from=10'));
|
||||
expect(line, isNot(contains('to=')));
|
||||
expect(line, isNot(contains('secret')));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
import 'package:test/test.dart';
|
||||
import '../services/recording_decision.dart';
|
||||
|
||||
void main() {
|
||||
final now = DateTime.utc(2026, 6, 10, 12, 0);
|
||||
|
||||
group('decideRecordingAction', () {
|
||||
test('starts when due and capacity available', () {
|
||||
expect(
|
||||
decideRecordingAction(
|
||||
now: now,
|
||||
startTime: now.subtract(const Duration(minutes: 1)),
|
||||
endTime: now.add(const Duration(hours: 1)),
|
||||
activeCount: 0,
|
||||
maxConcurrent: 2,
|
||||
),
|
||||
RecordingAction.start,
|
||||
);
|
||||
});
|
||||
|
||||
test('waits (keeps scheduled) when at capacity but window still open', () {
|
||||
expect(
|
||||
decideRecordingAction(
|
||||
now: now,
|
||||
startTime: now.subtract(const Duration(minutes: 1)),
|
||||
endTime: now.add(const Duration(hours: 1)),
|
||||
activeCount: 2,
|
||||
maxConcurrent: 2,
|
||||
),
|
||||
RecordingAction.wait,
|
||||
);
|
||||
});
|
||||
|
||||
test('fails when the end time has passed', () {
|
||||
expect(
|
||||
decideRecordingAction(
|
||||
now: now,
|
||||
startTime: now.subtract(const Duration(hours: 2)),
|
||||
endTime: now.subtract(const Duration(minutes: 5)),
|
||||
activeCount: 0,
|
||||
maxConcurrent: 2,
|
||||
),
|
||||
RecordingAction.fail,
|
||||
);
|
||||
});
|
||||
|
||||
test('does nothing before the start time', () {
|
||||
expect(
|
||||
decideRecordingAction(
|
||||
now: now,
|
||||
startTime: now.add(const Duration(minutes: 30)),
|
||||
endTime: now.add(const Duration(hours: 1)),
|
||||
activeCount: 0,
|
||||
maxConcurrent: 2,
|
||||
),
|
||||
RecordingAction.none,
|
||||
);
|
||||
});
|
||||
|
||||
test('second overlapping recording starts when maxConcurrent is 2', () {
|
||||
expect(
|
||||
decideRecordingAction(
|
||||
now: now,
|
||||
startTime: now.subtract(const Duration(minutes: 1)),
|
||||
endTime: now.add(const Duration(hours: 1)),
|
||||
activeCount: 1,
|
||||
maxConcurrent: 2,
|
||||
),
|
||||
RecordingAction.start,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('decideOrphanAction', () {
|
||||
test('resumes while the window is still open', () {
|
||||
expect(
|
||||
decideOrphanAction(
|
||||
now: now,
|
||||
endTime: now.add(const Duration(minutes: 30)),
|
||||
hasFile: true,
|
||||
),
|
||||
OrphanAction.resume,
|
||||
);
|
||||
});
|
||||
|
||||
test('resumes even without a file (crash before the first byte)', () {
|
||||
expect(
|
||||
decideOrphanAction(
|
||||
now: now,
|
||||
endTime: now.add(const Duration(minutes: 30)),
|
||||
hasFile: false,
|
||||
),
|
||||
OrphanAction.resume,
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps a partial file when the window has closed', () {
|
||||
expect(
|
||||
decideOrphanAction(
|
||||
now: now,
|
||||
endTime: now.subtract(const Duration(minutes: 5)),
|
||||
hasFile: true,
|
||||
),
|
||||
OrphanAction.finish,
|
||||
);
|
||||
});
|
||||
|
||||
test('fails when the window has closed with nothing captured', () {
|
||||
expect(
|
||||
decideOrphanAction(
|
||||
now: now,
|
||||
endTime: now.subtract(const Duration(minutes: 5)),
|
||||
hasFile: false,
|
||||
),
|
||||
OrphanAction.fail,
|
||||
);
|
||||
});
|
||||
|
||||
test('does not resume for the last seconds of the window', () {
|
||||
expect(
|
||||
decideOrphanAction(
|
||||
now: now,
|
||||
endTime: now.add(const Duration(seconds: 20)),
|
||||
hasFile: true,
|
||||
),
|
||||
OrphanAction.finish,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('decidePostExitAction', () {
|
||||
test('retries when ffmpeg dies mid-window', () {
|
||||
expect(
|
||||
decidePostExitAction(
|
||||
now: now,
|
||||
endTime: now.add(const Duration(minutes: 40)),
|
||||
exitCode: 1,
|
||||
consecutiveFailures: 0,
|
||||
hasFile: true,
|
||||
),
|
||||
PostExitAction.retry,
|
||||
);
|
||||
});
|
||||
|
||||
test('retries on a clean exit too (upstream ended early)', () {
|
||||
expect(
|
||||
decidePostExitAction(
|
||||
now: now,
|
||||
endTime: now.add(const Duration(minutes: 40)),
|
||||
exitCode: 0,
|
||||
consecutiveFailures: 2,
|
||||
hasFile: true,
|
||||
),
|
||||
PostExitAction.retry,
|
||||
);
|
||||
});
|
||||
|
||||
test('stops retrying once the attempt budget is spent', () {
|
||||
expect(
|
||||
decidePostExitAction(
|
||||
now: now,
|
||||
endTime: now.add(const Duration(minutes: 40)),
|
||||
exitCode: 1,
|
||||
consecutiveFailures: maxFfmpegAttempts - 1,
|
||||
hasFile: true,
|
||||
),
|
||||
PostExitAction.complete,
|
||||
);
|
||||
});
|
||||
|
||||
test('completes at the end of the window', () {
|
||||
expect(
|
||||
decidePostExitAction(
|
||||
now: now,
|
||||
endTime: now,
|
||||
exitCode: 0,
|
||||
consecutiveFailures: 0,
|
||||
hasFile: true,
|
||||
),
|
||||
PostExitAction.complete,
|
||||
);
|
||||
});
|
||||
|
||||
test('completes when ffmpeg errored but a file was captured', () {
|
||||
expect(
|
||||
decidePostExitAction(
|
||||
now: now,
|
||||
endTime: now,
|
||||
exitCode: 1,
|
||||
consecutiveFailures: 0,
|
||||
hasFile: true,
|
||||
),
|
||||
PostExitAction.complete,
|
||||
);
|
||||
});
|
||||
|
||||
test('fails when nothing was captured and the window is over', () {
|
||||
expect(
|
||||
decidePostExitAction(
|
||||
now: now,
|
||||
endTime: now,
|
||||
exitCode: 1,
|
||||
consecutiveFailures: 0,
|
||||
hasFile: false,
|
||||
),
|
||||
PostExitAction.fail,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('captureDuration', () {
|
||||
test('uses the time left until the scheduled end, not the planned length',
|
||||
() {
|
||||
// Démarrage avec 2 minutes de retard sur une fenêtre d'une heure.
|
||||
expect(
|
||||
captureDuration(
|
||||
now: now,
|
||||
endTime: now.add(const Duration(minutes: 58)),
|
||||
),
|
||||
const Duration(minutes: 58),
|
||||
);
|
||||
});
|
||||
|
||||
test('never asks ffmpeg for a zero or negative duration', () {
|
||||
expect(
|
||||
captureDuration(
|
||||
now: now,
|
||||
endTime: now.subtract(const Duration(minutes: 5)),
|
||||
),
|
||||
const Duration(seconds: 30),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('ffmpegRetryDelay', () {
|
||||
test('backs off on repeated failures and caps at 30s', () {
|
||||
expect(ffmpegRetryDelay(1), const Duration(seconds: 3));
|
||||
expect(ffmpegRetryDelay(2), const Duration(seconds: 6));
|
||||
expect(ffmpegRetryDelay(3), const Duration(seconds: 12));
|
||||
expect(ffmpegRetryDelay(4), const Duration(seconds: 24));
|
||||
expect(ffmpegRetryDelay(5), const Duration(seconds: 30));
|
||||
expect(ffmpegRetryDelay(20), const Duration(seconds: 30));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import 'package:test/test.dart';
|
||||
import '../api/recording_playlist.dart';
|
||||
|
||||
void main() {
|
||||
group('parseRecordingStart', () {
|
||||
test('reads a positive position in seconds', () {
|
||||
expect(parseRecordingStart('2700'), 2700);
|
||||
});
|
||||
|
||||
test('truncates fractions of a second', () {
|
||||
expect(parseRecordingStart('2700.9'), 2700);
|
||||
});
|
||||
|
||||
test('falls back to the beginning when absent or unusable', () {
|
||||
expect(parseRecordingStart(null), 0);
|
||||
expect(parseRecordingStart(''), 0);
|
||||
expect(parseRecordingStart('abc'), 0);
|
||||
expect(parseRecordingStart('-30'), 0);
|
||||
expect(parseRecordingStart('NaN'), 0);
|
||||
expect(parseRecordingStart('Infinity'), 0);
|
||||
});
|
||||
});
|
||||
|
||||
group('recordingOffsetKey', () {
|
||||
test('round-trips through its parser', () {
|
||||
for (final start in [0, 1, 2700, 86400]) {
|
||||
expect(parseRecordingOffsetKey(recordingOffsetKey(start)), start);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects keys that are not an offset', () {
|
||||
expect(parseRecordingOffsetKey('t'), isNull);
|
||||
expect(parseRecordingOffsetKey('segment_000.ts'), isNull);
|
||||
expect(parseRecordingOffsetKey('..'), isNull);
|
||||
expect(parseRecordingOffsetKey('t-30'), isNull);
|
||||
expect(parseRecordingOffsetKey('tabc'), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('rewriteRecordingPlaylist', () {
|
||||
const playlist = '#EXTM3U\n'
|
||||
'#EXT-X-VERSION:3\n'
|
||||
'#EXT-X-TARGETDURATION:4\n'
|
||||
'#EXT-X-PLAYLIST-TYPE:EVENT\n'
|
||||
'#EXTINF:4.000000,\n'
|
||||
'segment_000.ts\n'
|
||||
'#EXTINF:4.000000,\n'
|
||||
'segment_001.ts\n';
|
||||
|
||||
test('binds every segment to its own session directory', () {
|
||||
final rewritten = rewriteRecordingPlaylist(playlist, 't2700');
|
||||
expect(rewritten, contains('t2700/segment_000.ts'));
|
||||
expect(rewritten, contains('t2700/segment_001.ts'));
|
||||
expect(rewritten, isNot(contains('\nsegment_')));
|
||||
});
|
||||
|
||||
test('leaves tags untouched', () {
|
||||
final rewritten = rewriteRecordingPlaylist(playlist, 't0');
|
||||
expect(rewritten, startsWith('#EXTM3U\n#EXT-X-VERSION:3\n'));
|
||||
expect(rewritten, contains('#EXT-X-PLAYLIST-TYPE:EVENT'));
|
||||
expect('#EXTINF:4.000000,'.allMatches(rewritten).length, 2);
|
||||
});
|
||||
|
||||
test('is idempotent on an already rewritten playlist', () {
|
||||
final once = rewriteRecordingPlaylist(playlist, 't60');
|
||||
expect(rewriteRecordingPlaylist(once, 't60'), once);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import 'package:test/test.dart';
|
||||
import '../utils/safe_path.dart';
|
||||
|
||||
void main() {
|
||||
group('SafePath.resolveWithin', () {
|
||||
const base = '/app/recordings';
|
||||
|
||||
test('accepts files inside the base directory', () {
|
||||
expect(
|
||||
SafePath.resolveWithin(base, '/app/recordings/show.log'),
|
||||
isNotNull,
|
||||
);
|
||||
expect(SafePath.resolveWithin(base, 'show.log'), isNotNull);
|
||||
expect(
|
||||
SafePath.resolveWithin(base, '/app/recordings/sub/dir/file.log'),
|
||||
isNotNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects .. traversal', () {
|
||||
expect(
|
||||
SafePath.resolveWithin(base, '/app/recordings/../data/xtremflow.db'),
|
||||
isNull,
|
||||
);
|
||||
expect(SafePath.resolveWithin(base, '../../etc/passwd'), isNull);
|
||||
});
|
||||
|
||||
test('rejects absolute paths outside the base', () {
|
||||
expect(SafePath.resolveWithin(base, '/etc/passwd'), isNull);
|
||||
expect(SafePath.resolveWithin(base, '/app/data/xtremflow.db'), isNull);
|
||||
});
|
||||
|
||||
test('rejects sibling directories with a shared prefix', () {
|
||||
expect(
|
||||
SafePath.resolveWithin(base, '/app/recordings-evil/file.log'),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import 'package:test/test.dart';
|
||||
import '../api/proxy_handler.dart';
|
||||
|
||||
void main() {
|
||||
group('isForbiddenProxyHost', () {
|
||||
test('blocks loopback', () {
|
||||
expect(isForbiddenProxyHost('127.0.0.1'), isTrue);
|
||||
expect(isForbiddenProxyHost('localhost'), isTrue);
|
||||
expect(isForbiddenProxyHost('::1'), isTrue);
|
||||
});
|
||||
|
||||
test('blocks private LAN ranges', () {
|
||||
expect(isForbiddenProxyHost('10.0.0.5'), isTrue);
|
||||
expect(isForbiddenProxyHost('172.16.0.1'), isTrue);
|
||||
expect(isForbiddenProxyHost('172.31.255.255'), isTrue);
|
||||
expect(isForbiddenProxyHost('192.168.1.10'), isTrue);
|
||||
});
|
||||
|
||||
test('blocks link-local / cloud metadata range', () {
|
||||
expect(isForbiddenProxyHost('169.254.169.254'), isTrue);
|
||||
});
|
||||
|
||||
test('allows public IPs and hostnames', () {
|
||||
expect(isForbiddenProxyHost('8.8.8.8'), isFalse);
|
||||
expect(isForbiddenProxyHost('172.32.0.1'), isFalse);
|
||||
expect(isForbiddenProxyHost('cdn.example.com'), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:test/test.dart';
|
||||
import '../utils/stream_pipe.dart';
|
||||
|
||||
void main() {
|
||||
group('pipeWithBackpressure', () {
|
||||
test('relaie les données au client', () async {
|
||||
final source = StreamController<List<int>>();
|
||||
final piped = pipeWithBackpressure(source.stream, onStop: () {});
|
||||
|
||||
final received = <List<int>>[];
|
||||
final done = piped.listen(received.add).asFuture<void>();
|
||||
|
||||
source.add([1, 2, 3]);
|
||||
await source.close();
|
||||
await done;
|
||||
|
||||
expect(received, [
|
||||
[1, 2, 3]
|
||||
]);
|
||||
});
|
||||
|
||||
test('propage la pause du client vers la source', () async {
|
||||
// Sans cette propagation, un client plus lent que le flux laissait
|
||||
// FFmpeg produire à pleine vitesse et le serveur empilait les paquets
|
||||
// en mémoire : latence qui dérive et lecture qui saccade.
|
||||
var paused = false;
|
||||
var resumed = false;
|
||||
final source = StreamController<List<int>>(
|
||||
onPause: () => paused = true,
|
||||
onResume: () => resumed = true,
|
||||
);
|
||||
|
||||
final subscription = pipeWithBackpressure(
|
||||
source.stream,
|
||||
onStop: () {},
|
||||
).listen((_) {});
|
||||
|
||||
subscription.pause();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(paused, isTrue);
|
||||
|
||||
subscription.resume();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(resumed, isTrue);
|
||||
|
||||
await subscription.cancel();
|
||||
await source.close();
|
||||
});
|
||||
|
||||
test('arrête le producteur quand le client se déconnecte', () async {
|
||||
var stopped = 0;
|
||||
final source = StreamController<List<int>>();
|
||||
final subscription =
|
||||
pipeWithBackpressure(source.stream, onStop: () => stopped++)
|
||||
.listen((_) {});
|
||||
|
||||
await subscription.cancel();
|
||||
|
||||
expect(stopped, 1);
|
||||
await source.close();
|
||||
});
|
||||
|
||||
test('arrête le producteur quand la source se termine', () async {
|
||||
var stopped = 0;
|
||||
final source = StreamController<List<int>>();
|
||||
final piped = pipeWithBackpressure(source.stream, onStop: () => stopped++);
|
||||
final done = piped.listen((_) {}).asFuture<void>();
|
||||
|
||||
await source.close();
|
||||
await done;
|
||||
|
||||
expect(stopped, 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
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,164 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:test/test.dart';
|
||||
import '../models/playlist_config.dart';
|
||||
import '../services/upstream_slots.dart';
|
||||
|
||||
void main() {
|
||||
group('UpstreamSlots.makeRoom', () {
|
||||
late UpstreamSlots slots;
|
||||
late List<String> released;
|
||||
var clock = DateTime(2026, 1, 1);
|
||||
|
||||
void add(String id, String account) {
|
||||
clock = clock.add(const Duration(seconds: 1));
|
||||
slots.register(id: id, account: account, release: () => released.add(id));
|
||||
}
|
||||
|
||||
setUp(() {
|
||||
slots = UpstreamSlots(now: () => clock);
|
||||
released = [];
|
||||
});
|
||||
|
||||
test('compte à 1 connexion : le flux précédent est coupé', () {
|
||||
add('live_1_source', 'acc');
|
||||
final freed = slots.makeRoom('acc', max: 1, keep: 'live_2_source');
|
||||
expect(freed, ['live_1_source']);
|
||||
expect(released, ['live_1_source']);
|
||||
expect(slots.countFor('acc'), 0);
|
||||
});
|
||||
|
||||
test('la session demandée elle-même n\'est jamais coupée', () {
|
||||
add('live_1_source', 'acc');
|
||||
expect(slots.makeRoom('acc', max: 1, keep: 'live_1_source'), isEmpty);
|
||||
expect(released, isEmpty);
|
||||
});
|
||||
|
||||
test('les plus anciennes partent d\'abord, dans la limite du quota', () {
|
||||
add('a', 'acc');
|
||||
add('b', 'acc');
|
||||
add('c', 'acc');
|
||||
// 3 connexions max : il faut une place pour la nouvelle → 1 seule coupée.
|
||||
expect(slots.makeRoom('acc', max: 3, keep: 'new'), ['a']);
|
||||
expect(slots.countFor('acc'), 2);
|
||||
});
|
||||
|
||||
test('un autre compte n\'est jamais touché', () {
|
||||
add('x', 'other');
|
||||
expect(slots.makeRoom('acc', max: 1, keep: 'new'), isEmpty);
|
||||
expect(slots.countFor('other'), 1);
|
||||
});
|
||||
|
||||
test('unregister avec un jeton périmé ne libère pas la session relancée', () {
|
||||
final old = slots.register(id: 's', account: 'acc', release: () {});
|
||||
slots.register(id: 's', account: 'acc', release: () {});
|
||||
slots.unregister('s', token: old);
|
||||
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é', () {
|
||||
add('a', 'acc');
|
||||
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', () {
|
||||
test('lit la valeur texte renvoyée par player_api', () {
|
||||
expect(
|
||||
parseMaxConnections({
|
||||
'user_info': {'max_connections': '1'},
|
||||
}),
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test('accepte un entier', () {
|
||||
expect(
|
||||
parseMaxConnections({
|
||||
'user_info': {'max_connections': 3},
|
||||
}),
|
||||
3,
|
||||
);
|
||||
});
|
||||
|
||||
test('réponse inexploitable → null', () {
|
||||
expect(parseMaxConnections({'user_info': {}}), isNull);
|
||||
expect(parseMaxConnections('oops'), isNull);
|
||||
expect(
|
||||
parseMaxConnections({
|
||||
'user_info': {'max_connections': '0'},
|
||||
}),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import 'package:test/test.dart';
|
||||
import '../api/streaming_handler.dart';
|
||||
|
||||
void main() {
|
||||
group('vodContainerExtension', () {
|
||||
test('garde l\'extension réelle du catalogue', () {
|
||||
expect(vodContainerExtension('mp4'), 'mp4');
|
||||
expect(vodContainerExtension('MKV'), 'mkv');
|
||||
expect(vodContainerExtension('avi'), 'avi');
|
||||
});
|
||||
|
||||
test('ancien client sans ext : mkv, comme avant', () {
|
||||
expect(vodContainerExtension(null), 'mkv');
|
||||
expect(vodContainerExtension(''), 'mkv');
|
||||
});
|
||||
|
||||
test('refuse tout ce qui pourrait sortir du nom de fichier', () {
|
||||
expect(vodContainerExtension('mp4/../x'), 'mkv');
|
||||
expect(vodContainerExtension('mp4?a=b'), 'mkv');
|
||||
expect(vodContainerExtension('toolongext'), 'mkv');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:test/test.dart';
|
||||
import '../services/xmltv_epg_service.dart';
|
||||
|
||||
/// URL du dump `xmltv.php` d'un panneau Xtream : les identifiants de
|
||||
/// l'abonné y figurent en clair, comme en production.
|
||||
const _panelUrl =
|
||||
'http://panel.example:8080/xmltv.php?username=john&password=hunter2';
|
||||
|
||||
/// Exécute [body] en capturant tout ce qui passe par `print`.
|
||||
Future<List<String>> _capturePrints(Future<void> Function() body) async {
|
||||
final lines = <String>[];
|
||||
await runZoned(
|
||||
body,
|
||||
zoneSpecification: ZoneSpecification(
|
||||
print: (self, parent, zone, line) => lines.add(line),
|
||||
),
|
||||
);
|
||||
return lines;
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('XmltvEpgService — journalisation', () {
|
||||
test('masque les identifiants de l\'URL quand la source répond', () async {
|
||||
final service = XmltvEpgService(
|
||||
sourceUrls: const [_panelUrl],
|
||||
client: MockClient(
|
||||
(_) async => http.Response('<?xml version="1.0"?><tv></tv>', 200),
|
||||
),
|
||||
);
|
||||
|
||||
final lines = await _capturePrints(service.ensureFresh);
|
||||
final xmltvLines = lines.where((l) => l.startsWith('[XmltvEpg]'));
|
||||
|
||||
expect(xmltvLines, isNotEmpty);
|
||||
for (final line in xmltvLines) {
|
||||
expect(line, isNot(contains('john')));
|
||||
expect(line, isNot(contains('hunter2')));
|
||||
}
|
||||
expect(
|
||||
lines,
|
||||
contains(contains('username=***&password=***')),
|
||||
);
|
||||
});
|
||||
|
||||
test('masque les identifiants de l\'URL et de l\'exception en cas d\'échec',
|
||||
() async {
|
||||
// `ClientException` recopie l'URL demandée dans son message : c'est
|
||||
// par là que les identifiants fuyaient aussi.
|
||||
final service = XmltvEpgService(
|
||||
sourceUrls: const [_panelUrl],
|
||||
client: MockClient(
|
||||
(request) async => throw http.ClientException(
|
||||
'Connection refused',
|
||||
request.url,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final lines = await _capturePrints(service.ensureFresh);
|
||||
final failure = lines.firstWhere(
|
||||
(l) => l.contains('échec'),
|
||||
orElse: () => fail('aucune ligne d\'échec journalisée : $lines'),
|
||||
);
|
||||
|
||||
expect(failure, isNot(contains('john')));
|
||||
expect(failure, isNot(contains('hunter2')));
|
||||
expect(failure, contains('username=***&password=***'));
|
||||
expect(failure, contains('Connection refused'));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,352 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:test/test.dart';
|
||||
import '../services/xmltv_epg_service.dart';
|
||||
|
||||
/// Extrait représentatif d'un dump XMLTV public : décalage horaire explicite,
|
||||
/// identifiant ponctué côté source là où le panneau annonce `France2.fr`, et
|
||||
/// un `display-name` distinct de l'identifiant.
|
||||
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';
|
||||
}
|
||||
|
||||
return '''
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<tv>
|
||||
<channel id="France.2.fr"><display-name>FR - FRANCE 2</display-name></channel>
|
||||
<programme start="${stamp(start)}" stop="${stamp(stop)}" channel="France.2.fr">
|
||||
<title lang="fr">Journal de 20h</title>
|
||||
<desc lang="fr">L'info du soir</desc>
|
||||
</programme>
|
||||
</tv>
|
||||
''';
|
||||
}
|
||||
|
||||
String _stamp(DateTime d) {
|
||||
final u = d.toUtc();
|
||||
String two(int v) => v.toString().padLeft(2, '0');
|
||||
return '${u.year}${two(u.month)}${two(u.day)}'
|
||||
'${two(u.hour)}${two(u.minute)}${two(u.second)} +0000';
|
||||
}
|
||||
|
||||
void main() {
|
||||
final service = XmltvEpgService(sourceUrls: const []);
|
||||
|
||||
group('normalizeKey', () {
|
||||
test('rapproche les identifiants ponctués différemment', () {
|
||||
// Le cœur du repli : sans cette normalisation, `France2.fr` du panneau
|
||||
// et `France.2.fr` du dump ne se rencontrent jamais.
|
||||
expect(
|
||||
XmltvEpgService.normalizeKey('France2.fr'),
|
||||
XmltvEpgService.normalizeKey('France.2.fr'),
|
||||
);
|
||||
});
|
||||
|
||||
test('replie la casse et les accents', () {
|
||||
expect(XmltvEpgService.normalizeKey('Chérie 25'), 'cherie25');
|
||||
});
|
||||
|
||||
test('rend une clé vide sur une entrée nulle ou sans caractère utile', () {
|
||||
expect(XmltvEpgService.normalizeKey(null), '');
|
||||
expect(XmltvEpgService.normalizeKey('...'), '');
|
||||
});
|
||||
});
|
||||
|
||||
group('nameKeys', () {
|
||||
// Noms réels du panneau (prod) : 59 % des chaînes françaises n'ont pas
|
||||
// d'epg_channel_id, seul leur nom permet de retrouver le guide.
|
||||
test('retire le préfixe pays et les marqueurs de qualité', () {
|
||||
expect(XmltvEpgService.nameKeys('FR - TF1 FHD ◉'), contains('tf1'));
|
||||
expect(XmltvEpgService.nameKeys('FR - FRANCE 4 FHD'), contains('france4'));
|
||||
expect(XmltvEpgService.nameKeys('|FR| M6 HD'), contains('m6'));
|
||||
expect(XmltvEpgService.nameKeys('[FR] Arte UHD 4K'), contains('arte'));
|
||||
expect(XmltvEpgService.nameKeys('FR: RMC Story HEVC'), contains('rmcstory'));
|
||||
});
|
||||
|
||||
test('propose aussi la forme identifiant « nom.pays »', () {
|
||||
// Les dumps publics identifient souvent la chaîne par `TF1.fr`.
|
||||
expect(XmltvEpgService.nameKeys('FR - TF1 FHD'), contains('tf1fr'));
|
||||
});
|
||||
|
||||
test('garde la variante décalée distincte de la chaîne principale', () {
|
||||
expect(XmltvEpgService.nameKeys('FR - TF1 +1 FHD'), isNot(contains('tf1')));
|
||||
});
|
||||
|
||||
test('ne réduit pas un nom court à rien', () {
|
||||
expect(XmltvEpgService.nameKeys('M6'), contains('m6'));
|
||||
expect(XmltvEpgService.nameKeys('HD'), isNot(contains('')));
|
||||
});
|
||||
});
|
||||
|
||||
group('correspondances élargies (chaînes vides mesurées en prod)', () {
|
||||
XmltvEpgService serviceWith(String dump) => XmltvEpgService(
|
||||
sourceUrls: const ['http://ext/epg.xml'],
|
||||
client: MockClient((_) async => http.Response.bytes(
|
||||
// Octets UTF-8 : les ids réels portent des accents.
|
||||
const Utf8Encoder().convert(dump),
|
||||
200,
|
||||
)),
|
||||
);
|
||||
|
||||
String channel(String id, String name, String title, DateTime start) => '''
|
||||
<channel id="$id"><display-name>$name</display-name></channel>
|
||||
<programme start="${_stamp(start)}" stop="${_stamp(start.add(const Duration(hours: 1)))}" channel="$id"><title>$title</title></programme>''';
|
||||
|
||||
test('« F3 ALPES » du panneau trouve France.3.-.Alpes.fr', () async {
|
||||
final now = DateTime.now().toUtc();
|
||||
final svc = serviceWith('<tv>${channel('France.3.-.Alpes.fr', 'France 3 Alpes', 'JT Alpes', now)}</tv>');
|
||||
final hits = await svc.programmesFor(null, displayName: 'FR - F3 ALPES HD');
|
||||
expect(hits.single.title, 'JT Alpes');
|
||||
});
|
||||
|
||||
test('« AB3 » trouve AB3.Channel.fr (clé souple)', () async {
|
||||
final now = DateTime.now().toUtc();
|
||||
final svc = serviceWith('<tv>${channel('AB3.Channel.fr', 'AB3 Channel', 'Série', now)}</tv>');
|
||||
final hits = await svc.programmesFor('AB3.fr', displayName: 'FR - AB3 FHD');
|
||||
expect(hits.single.title, 'Série');
|
||||
});
|
||||
|
||||
test('« TF1 +1 » reprend le guide de TF1 décalé d\'une heure', () async {
|
||||
final now = DateTime.now().toUtc();
|
||||
final svc = serviceWith('<tv>${channel('TF1.fr', 'TF1', 'Le 13h', now)}</tv>');
|
||||
final hits = await svc.programmesFor(null, displayName: 'FR - TF1 +1 FHD');
|
||||
expect(hits.single.title, 'Le 13h');
|
||||
expect(hits.single.start, now.add(const Duration(hours: 1)).copyWith(microsecond: 0, millisecond: 0));
|
||||
});
|
||||
|
||||
test('une chaîne +1 qui a son propre guide le garde', () async {
|
||||
final now = DateTime.now().toUtc();
|
||||
final svc = serviceWith('<tv>'
|
||||
'${channel('Boomerang.fr', 'Boomerang', 'Normal', now)}'
|
||||
'${channel('Boomerang.+1.fr', 'Boomerang +1', 'Décalé', now)}</tv>');
|
||||
final hits = await svc.programmesFor(null, displayName: 'FR - BOOMERANG +1');
|
||||
expect(hits.single.title, 'Décalé');
|
||||
});
|
||||
});
|
||||
|
||||
group('programmesFor par nom', () {
|
||||
test('trouve le guide d\'une chaîne sans epg_channel_id par son nom nettoyé',
|
||||
() async {
|
||||
final now = DateTime.now().toUtc();
|
||||
String stamp(DateTime d) {
|
||||
final u = d.toUtc();
|
||||
String two(int v) => v.toString().padLeft(2, '0');
|
||||
return '${u.year}${two(u.month)}${two(u.day)}'
|
||||
'${two(u.hour)}${two(u.minute)}${two(u.second)} +0000';
|
||||
}
|
||||
|
||||
final dump = '''
|
||||
<tv>
|
||||
<channel id="TF1.fr"><display-name>TF1 HD</display-name><display-name>TF1</display-name></channel>
|
||||
<programme start="${stamp(now)}" stop="${stamp(now.add(const Duration(hours: 1)))}" channel="TF1.fr">
|
||||
<title>Le 13h</title>
|
||||
</programme>
|
||||
</tv>
|
||||
''';
|
||||
final svc = XmltvEpgService(
|
||||
sourceUrls: const ['http://ext/epg.xml'],
|
||||
client: MockClient((_) async => http.Response(dump, 200)),
|
||||
);
|
||||
final hits = await svc.programmesFor(null, displayName: 'FR - TF1 FHD ◉');
|
||||
expect(hits.single.title, 'Le 13h');
|
||||
});
|
||||
|
||||
test('indexe chaque display-name, pas seulement le premier', () {
|
||||
final now = DateTime.now().toUtc();
|
||||
final index = service.parseForTest('''
|
||||
<tv>
|
||||
<channel id="x.fr"><display-name>Premier Nom</display-name><display-name>Second Nom</display-name></channel>
|
||||
<programme start="${_stamp(now)}" stop="${_stamp(now.add(const Duration(hours: 1)))}" channel="x.fr"><title>T</title></programme>
|
||||
</tv>
|
||||
''');
|
||||
expect(index[XmltvEpgService.normalizeKey('Second Nom')], isNotNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('parseXmltvDate', () {
|
||||
test('applique le décalage horaire annoncé', () {
|
||||
expect(
|
||||
XmltvEpgService.parseXmltvDate('20260811200000 +0200'),
|
||||
DateTime.utc(2026, 8, 11, 18),
|
||||
);
|
||||
});
|
||||
|
||||
test('traite une date sans décalage comme de l UTC', () {
|
||||
expect(
|
||||
XmltvEpgService.parseXmltvDate('20260811200000'),
|
||||
DateTime.utc(2026, 8, 11, 20),
|
||||
);
|
||||
});
|
||||
|
||||
test('rejette une valeur tronquée', () {
|
||||
expect(XmltvEpgService.parseXmltvDate('202608'), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('parse', () {
|
||||
test('indexe un programme en cours et décode son titre', () {
|
||||
final now = DateTime.now().toUtc();
|
||||
final index = service.parseForTest(
|
||||
_fixture(
|
||||
now.subtract(const Duration(minutes: 10)),
|
||||
now.add(const Duration(minutes: 20)),
|
||||
),
|
||||
);
|
||||
|
||||
final programmes = index[XmltvEpgService.normalizeKey('France2.fr')];
|
||||
expect(programmes, isNotNull);
|
||||
expect(programmes!.single.title, 'Journal de 20h');
|
||||
expect(programmes.single.description, "L'info du soir");
|
||||
});
|
||||
|
||||
test('rend la chaîne atteignable par son nom affiché', () {
|
||||
final now = DateTime.now().toUtc();
|
||||
final index = service.parseForTest(
|
||||
_fixture(now, now.add(const Duration(minutes: 20))),
|
||||
);
|
||||
|
||||
expect(index[XmltvEpgService.normalizeKey('FR - FRANCE 2')], isNotNull);
|
||||
});
|
||||
|
||||
test('écarte les programmes terminés hors fenêtre de rétention', () {
|
||||
final old = DateTime.now().toUtc().subtract(const Duration(days: 2));
|
||||
final index = service.parseForTest(
|
||||
_fixture(old, old.add(const Duration(minutes: 30))),
|
||||
);
|
||||
|
||||
expect(index, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('ensureFresh', () {
|
||||
test('ne retélécharge pas tant que l’index est frais', () async {
|
||||
final now = DateTime.now().toUtc();
|
||||
var calls = 0;
|
||||
final service = XmltvEpgService(
|
||||
sourceUrls: const ['http://dump.example/epg.xml'],
|
||||
client: MockClient((_) async {
|
||||
calls++;
|
||||
return http.Response(
|
||||
_fixture(now, now.add(const Duration(minutes: 30))),
|
||||
200,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
await service.ensureFresh();
|
||||
await service.ensureFresh();
|
||||
|
||||
expect(calls, 1);
|
||||
expect(service.hasData, isTrue);
|
||||
});
|
||||
|
||||
test('espace les tentatives après un échec', () async {
|
||||
// Sans ce recul, une source morte était retéléchargée à chaque
|
||||
// consultation du guide : une requête sortante par chaîne affichée.
|
||||
var calls = 0;
|
||||
final service = XmltvEpgService(
|
||||
sourceUrls: const ['http://dump.example/epg.xml'],
|
||||
retryBackoff: const Duration(minutes: 15),
|
||||
client: MockClient((_) async {
|
||||
calls++;
|
||||
return http.Response('nope', 500);
|
||||
}),
|
||||
);
|
||||
|
||||
await service.ensureFresh();
|
||||
await service.ensureFresh();
|
||||
await service.ensureFresh();
|
||||
|
||||
expect(calls, 1);
|
||||
expect(service.hasData, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('réactivité du serveur', () {
|
||||
test('l’indexation ne gèle pas la boucle d’événements', () async {
|
||||
// Le serveur n'a qu'un isolate : décoder et parser un dump de
|
||||
// plusieurs milliers de chaînes dessus bloquait tout le reste pendant
|
||||
// des secondes — y compris le relais `turbo.ts` des flux en cours,
|
||||
// d'où des coupures systématiques au démarrage de la lecture.
|
||||
final now = DateTime.now().toUtc();
|
||||
String stamp(DateTime d) {
|
||||
String two(int v) => v.toString().padLeft(2, '0');
|
||||
return '${d.year}${two(d.month)}${two(d.day)}'
|
||||
'${two(d.hour)}${two(d.minute)}${two(d.second)} +0000';
|
||||
}
|
||||
|
||||
// Volume d'un vrai dump de panneau : ~7000 chaînes, résumés de
|
||||
// plusieurs phrases.
|
||||
final desc = 'Résumé du programme, avec assez de texte pour peser '
|
||||
'comme un vrai guide. ' * 4;
|
||||
final xml = StringBuffer('<?xml version="1.0"?><tv>');
|
||||
for (var c = 0; c < 7000; c++) {
|
||||
xml.write('<channel id="Chaine.$c.fr">'
|
||||
'<display-name>FR - CHAINE $c</display-name></channel>');
|
||||
for (var p = 0; p < 30; p++) {
|
||||
final start = now.add(Duration(minutes: 30 * p));
|
||||
final stop = start.add(const Duration(minutes: 30));
|
||||
xml.write('<programme start="${stamp(start)}" '
|
||||
'stop="${stamp(stop)}" channel="Chaine.$c.fr">'
|
||||
'<title>Programme $p</title><desc>$desc</desc>'
|
||||
'</programme>');
|
||||
}
|
||||
}
|
||||
xml.write('</tv>');
|
||||
final body = xml.toString();
|
||||
|
||||
final service = XmltvEpgService(
|
||||
sourceUrls: const ['http://dump.example/epg.xml'],
|
||||
client: MockClient((_) async => http.Response(body, 200)),
|
||||
);
|
||||
|
||||
// Un « battement » toutes les 10 ms : le plus long écart observé est
|
||||
// le temps pendant lequel la boucle d'événements est restée bloquée.
|
||||
var last = DateTime.now();
|
||||
var worstGap = Duration.zero;
|
||||
final heartbeat = Timer.periodic(const Duration(milliseconds: 10), (_) {
|
||||
final now = DateTime.now();
|
||||
final gap = now.difference(last);
|
||||
if (gap > worstGap) worstGap = gap;
|
||||
last = now;
|
||||
});
|
||||
|
||||
await service.ensureFresh();
|
||||
heartbeat.cancel();
|
||||
// Le dernier blocage n'est suivi d'aucun battement : le compter aussi.
|
||||
final tail = DateTime.now().difference(last);
|
||||
if (tail > worstGap) worstGap = tail;
|
||||
|
||||
expect(service.channelCount, greaterThanOrEqualTo(7000));
|
||||
expect(
|
||||
worstGap,
|
||||
lessThan(const Duration(milliseconds: 250)),
|
||||
reason: 'boucle d’événements gelée ${worstGap.inMilliseconds} ms',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('horizon', () {
|
||||
test('écarte les programmes au-delà de l’horizon d’indexation', () {
|
||||
// Un dump national couvre sept jours : tout garder ferait grossir
|
||||
// l'index pour un guide qui n'affiche que le programme courant.
|
||||
final service = XmltvEpgService(
|
||||
sourceUrls: const [],
|
||||
horizon: const Duration(hours: 48),
|
||||
);
|
||||
final far = DateTime.now().toUtc().add(const Duration(days: 5));
|
||||
|
||||
expect(
|
||||
service.parseForTest(_fixture(far, far.add(const Duration(hours: 1)))),
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
|
||||
/// Versionnage des assets non hachés de la build web.
|
||||
///
|
||||
/// POURQUOI : `main.dart.js`, `flutter_bootstrap.js` et `xf-player-core.js`
|
||||
/// gardent le même nom d'une build à l'autre. Un reverse proxy qui met les
|
||||
/// `.js` en cache (option « Cache Assets » de Nginx Proxy Manager, constatée
|
||||
/// en prod : cache jusqu'à 12 h, même sur un rechargement forcé) servait donc
|
||||
/// l'ANCIEN lecteur et l'ANCIENNE app avec le NOUVEAU serveur après chaque
|
||||
/// déploiement. En ajoutant `?v=<empreinte du contenu>` aux références,
|
||||
/// chaque version a sa propre URL : aucun cache intermédiaire ne peut la
|
||||
/// confondre avec la précédente.
|
||||
|
||||
/// Empreinte courte et stable de [bytes].
|
||||
String contentVersion(List<int> bytes) =>
|
||||
md5.convert(bytes).toString().substring(0, 12);
|
||||
|
||||
/// Ajoute `?v=<version>` à chaque référence entre guillemets à un asset de
|
||||
/// [versions] (`"main.dart.js"` → `"main.dart.js?v=…"`).
|
||||
///
|
||||
/// Seules les références exactes sont réécrites : `vendor/x.js` ou une
|
||||
/// référence déjà versionnée restent intactes.
|
||||
String versionAssetRefs(String content, Map<String, String> versions) {
|
||||
var out = content;
|
||||
versions.forEach((asset, version) {
|
||||
final pattern = RegExp('(["\'])${RegExp.escape(asset)}\\1');
|
||||
out = out.replaceAllMapped(
|
||||
pattern,
|
||||
(m) => '${m[1]}$asset?v=$version${m[1]}',
|
||||
);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Contenus réécrits, indexés par chemin relatif (`index.html`…), prêts à
|
||||
/// être servis à la place des fichiers d'origine.
|
||||
///
|
||||
/// Calculé une fois au démarrage : la build web est figée dans l'image.
|
||||
Map<String, String> buildVersionedEntryPoints(String webPath) {
|
||||
String? read(String name) {
|
||||
final file = File('$webPath/$name');
|
||||
return file.existsSync() ? file.readAsStringSync() : null;
|
||||
}
|
||||
|
||||
String? versionOf(String name) {
|
||||
final file = File('$webPath/$name');
|
||||
return file.existsSync() ? contentVersion(file.readAsBytesSync()) : null;
|
||||
}
|
||||
|
||||
final rewritten = <String, String>{};
|
||||
|
||||
// Le bootstrap référence main.dart.js : sa version dépend donc de celle
|
||||
// de main.dart.js, d'où une version calculée APRÈS réécriture.
|
||||
final bootstrap = read('flutter_bootstrap.js');
|
||||
final mainVersion = versionOf('main.dart.js');
|
||||
if (bootstrap != null && mainVersion != null) {
|
||||
final versioned =
|
||||
versionAssetRefs(bootstrap, {'main.dart.js': mainVersion});
|
||||
rewritten['flutter_bootstrap.js'] = versioned;
|
||||
final index = read('index.html');
|
||||
if (index != null) {
|
||||
rewritten['index.html'] = versionAssetRefs(index, {
|
||||
'flutter_bootstrap.js': contentVersion(utf8.encode(versioned)),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
final playerAssets = <String, String>{
|
||||
for (final name in const ['xf-player-core.js', 'xf-player.css'])
|
||||
if (versionOf(name) case final v?) name: v,
|
||||
};
|
||||
for (final page in const [
|
||||
'player.html',
|
||||
'player_lite.html',
|
||||
'player_mobile.html',
|
||||
]) {
|
||||
final html = read(page);
|
||||
if (html != null) rewritten[page] = versionAssetRefs(html, playerAssets);
|
||||
}
|
||||
return rewritten;
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
/// Redacts IPTV credentials from URLs before they reach logs.
|
||||
///
|
||||
/// Xtream Codes embeds credentials both in query strings
|
||||
/// (`?username=u&password=p`) and in path segments
|
||||
/// (`/live/<user>/<pass>/<id>.ts`, `/movie/...`, `/series/...`).
|
||||
class LogRedactor {
|
||||
static final RegExp _queryCreds = RegExp(
|
||||
r'(username|password)=[^&\s]*',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
static final RegExp _pathCreds = RegExp(
|
||||
r'/(live|movie|series)/[^/\s]+/[^/\s]+/',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
/// Returns [url] with credentials replaced by `***`.
|
||||
static String redactUrl(String url) {
|
||||
var redacted = url.replaceAllMapped(
|
||||
_queryCreds,
|
||||
(m) => '${m.group(1)}=***',
|
||||
);
|
||||
redacted = redacted.replaceAllMapped(
|
||||
_pathCreds,
|
||||
(m) => '/${m.group(1)}/***/***/',
|
||||
);
|
||||
return redacted;
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
import 'dart:io';
|
||||
|
||||
/// Inspection des fichiers média locaux (enregistrements) via ffprobe.
|
||||
///
|
||||
/// Les résultats sont mémorisés tant que le fichier ne change pas : une
|
||||
/// liste d'enregistrements est rafraîchie toutes les 5 s pendant une capture,
|
||||
/// et la playlist de lecture est rechargée en continu — sans cache, ffprobe
|
||||
/// tournerait en boucle.
|
||||
class MediaProbe {
|
||||
MediaProbe._();
|
||||
|
||||
static final Map<String, _CacheEntry> _cache = {};
|
||||
|
||||
/// Binaire ffprobe : l'image Docker l'installe dans `/usr/local/bin`
|
||||
/// à côté de FFmpeg ; `FFPROBE_PATH` permet de le surcharger.
|
||||
static String ffprobePath() {
|
||||
final fromEnv = Platform.environment['FFPROBE_PATH'];
|
||||
if (fromEnv != null && fromEnv.isNotEmpty) return fromEnv;
|
||||
if (Platform.isLinux && File('/usr/local/bin/ffprobe').existsSync()) {
|
||||
return '/usr/local/bin/ffprobe';
|
||||
}
|
||||
return 'ffprobe';
|
||||
}
|
||||
|
||||
/// Durée du média en secondes, ou `null` si elle n'est pas mesurable
|
||||
/// (ffprobe absent, fichier introuvable, conteneur sans durée).
|
||||
static Future<double?> duration(String path) async {
|
||||
final value = await _probe(
|
||||
path,
|
||||
field: 'duration',
|
||||
args: const [
|
||||
'-show_entries', 'format=duration',
|
||||
],
|
||||
);
|
||||
final seconds = double.tryParse(value ?? '');
|
||||
if (seconds == null || !seconds.isFinite || seconds <= 0) return null;
|
||||
return seconds;
|
||||
}
|
||||
|
||||
/// Codec de la première piste vidéo (`h264`, `hevc`, `mpeg2video`…),
|
||||
/// ou `null` si indéterminable.
|
||||
static Future<String?> videoCodec(String path) async {
|
||||
final value = await _probe(
|
||||
path,
|
||||
field: 'videoCodec',
|
||||
args: const [
|
||||
'-select_streams', 'v:0',
|
||||
'-show_entries', 'stream=codec_name',
|
||||
],
|
||||
);
|
||||
return (value == null || value.isEmpty) ? null : value.toLowerCase();
|
||||
}
|
||||
|
||||
/// Exécute ffprobe une fois par (fichier, champ) et mémorise le résultat
|
||||
/// tant que taille et date de modification sont inchangées.
|
||||
static Future<String?> _probe(
|
||||
String path, {
|
||||
required String field,
|
||||
required List<String> args,
|
||||
}) async {
|
||||
FileStat stat;
|
||||
try {
|
||||
stat = File(path).statSync();
|
||||
if (stat.type == FileSystemEntityType.notFound) return null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final key = '$field@$path';
|
||||
final cached = _cache[key];
|
||||
if (cached != null && cached.matches(stat)) return cached.value;
|
||||
|
||||
try {
|
||||
final result = await Process.run(ffprobePath(), [
|
||||
'-v', 'error',
|
||||
...args,
|
||||
'-of', 'default=noprint_wrappers=1:nokey=1',
|
||||
path,
|
||||
]);
|
||||
// ffprobe imprime une ligne par flux : seule la première nous intéresse.
|
||||
final output = (result.stdout as String).trim().split('\n').first.trim();
|
||||
if (output.isEmpty || output == 'N/A') return null;
|
||||
_cache[key] = _CacheEntry(stat, output);
|
||||
return output;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _CacheEntry {
|
||||
final int size;
|
||||
final int mtimeMs;
|
||||
final String value;
|
||||
|
||||
_CacheEntry(FileStat stat, this.value)
|
||||
: size = stat.size,
|
||||
mtimeMs = stat.modified.millisecondsSinceEpoch;
|
||||
|
||||
bool matches(FileStat stat) =>
|
||||
stat.size == size &&
|
||||
stat.modified.millisecondsSinceEpoch == mtimeMs;
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'package:bcrypt/bcrypt.dart';
|
||||
import 'package:crypto/crypto.dart';
|
||||
|
||||
class PasswordHasher {
|
||||
/// Cost factor 10: ~100-300ms per hash in pure Dart, acceptable for login frequency.
|
||||
static const int _bcryptRounds = 10;
|
||||
|
||||
/// Legacy hashes are unsalted SHA-256 hex digests (64 hex chars).
|
||||
static final RegExp _legacyPattern = RegExp(r'^[0-9a-f]{64}$');
|
||||
|
||||
/// Hash a password using bcrypt (salt embedded in the result).
|
||||
static String hash(String password) {
|
||||
return BCrypt.hashpw(password, BCrypt.gensalt(logRounds: _bcryptRounds));
|
||||
}
|
||||
|
||||
/// Verify a password against a stored hash.
|
||||
/// Supports legacy unsalted SHA-256 hashes for accounts created before
|
||||
/// the bcrypt migration; those are rehashed lazily on successful login.
|
||||
static bool verify(String password, String hash) {
|
||||
if (isLegacy(hash)) {
|
||||
final digest = sha256.convert(utf8.encode(password)).toString();
|
||||
return digest == hash;
|
||||
}
|
||||
try {
|
||||
return BCrypt.checkpw(password, hash);
|
||||
} catch (_) {
|
||||
// Malformed hash (neither legacy SHA-256 nor valid bcrypt)
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// True if the stored hash uses the legacy unsalted SHA-256 scheme.
|
||||
static bool isLegacy(String hash) => _legacyPattern.hasMatch(hash);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
/// Path validation helpers to prevent directory traversal.
|
||||
class SafePath {
|
||||
/// Resolves [candidate] and returns it only if it stays inside [baseDir].
|
||||
/// Returns null when the path escapes the base directory (e.g. via `..`
|
||||
/// segments or an absolute path pointing elsewhere).
|
||||
static String? resolveWithin(String baseDir, String candidate) {
|
||||
final base = p.normalize(p.absolute(baseDir));
|
||||
final resolved = p.normalize(
|
||||
p.isAbsolute(candidate) ? candidate : p.join(base, candidate),
|
||||
);
|
||||
if (resolved == base || p.isWithin(base, resolved)) {
|
||||
return resolved;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
/// Relaie [source] vers le client en conservant la contre-pression.
|
||||
///
|
||||
/// POURQUOI : un `StreamController` nu ne transmet pas la pause de son
|
||||
/// abonné à la source. Quand le navigateur lit plus lentement que FFmpeg ne
|
||||
/// produit — réseau domestique, onglet en arrière-plan —, le serveur empilait
|
||||
/// donc les paquets en mémoire sans jamais ralentir le producteur : la
|
||||
/// consommation grimpe et la lecture dérive derrière le direct, ce qui se voit
|
||||
/// à l'écran comme des saccades.
|
||||
///
|
||||
/// [onStop] est appelé une seule fois, quand le client se déconnecte ou que la
|
||||
/// source se termine : c'est là qu'on tue le processus FFmpeg, sinon il en
|
||||
/// reste un par zapping.
|
||||
Stream<List<int>> pipeWithBackpressure(
|
||||
Stream<List<int>> source, {
|
||||
required void Function() onStop,
|
||||
}) {
|
||||
late final StreamController<List<int>> controller;
|
||||
late final StreamSubscription<List<int>> subscription;
|
||||
var stopped = false;
|
||||
|
||||
void stop() {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
onStop();
|
||||
}
|
||||
|
||||
controller = StreamController<List<int>>(
|
||||
onPause: () => subscription.pause(),
|
||||
onResume: () => subscription.resume(),
|
||||
onCancel: () {
|
||||
final cancelled = subscription.cancel();
|
||||
stop();
|
||||
return cancelled;
|
||||
},
|
||||
);
|
||||
|
||||
subscription = source.listen(
|
||||
controller.add,
|
||||
onError: controller.addError,
|
||||
onDone: () {
|
||||
controller.close();
|
||||
stop();
|
||||
},
|
||||
);
|
||||
|
||||
return controller.stream;
|
||||
}
|
||||
Reference in new issue
Block a user