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:
MichaelandClaude Opus 5.5 committed 2026-10-13 00:29:57 +02:00
1 parent f82c70ae05
commit 3ea6fa0ae3
109 files changed
+14008 -11047

No files matched your search

-81
View File
@@ -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);
}
}
-163
View File
@@ -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;
}
}
-473
View File
@@ -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});
}
-72
View File
@@ -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();
}
}
-70
View File
@@ -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);
}
-191
View File
@@ -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'},
);
}
}
}
-334
View File
@@ -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'},
);
}
};
}
}
-43
View File
@@ -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]}',
);
}
-290
View File
@@ -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é'});
}
}
-155
View File
@@ -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'},
);
}
}
}
-63
View File
@@ -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;
}
}
-129
View File
@@ -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
-133
View File
@@ -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};
}
}
-180
View File
@@ -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'},
);
}
}
}
-78
View File
@@ -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'},
);
}
}
}
-795
View File
@@ -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();
}
}
-106
View File
@@ -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;
}
-248
View File
@@ -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;
};
};
}
-67
View File
@@ -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(),
};
}
}
-52
View File
@@ -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';
}
-100
View File
@@ -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,
);
}
}
-37
View File
@@ -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);
}
-31
View File
@@ -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(),
};
}
}
-477
View File
@@ -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"
-25
View File
@@ -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
View File
@@ -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);
};
};
}
-109
View File
@@ -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);
}
}
-293
View File
@@ -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;
}
}
}
-43
View File
@@ -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);
}
-239
View File
@@ -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;
}
-126
View File
@@ -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);
}
-970
View File
@@ -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',
);
}
}
}
-238
View File
@@ -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,
});
}
-183
View File
@@ -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();
}
}
}
-534
View File
@@ -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,
};
}
-57
View File
@@ -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);
});
});
}
-47
View File
@@ -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));
});
});
}
-329
View File
@@ -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);
});
});
}
-76
View File
@@ -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);
});
});
}
-33
View File
@@ -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);
});
});
}
-128
View File
@@ -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);
});
});
}
-135
View File
@@ -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);
});
});
}
-37
View File
@@ -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);
});
});
}
-47
View File
@@ -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')));
});
});
}
-245
View File
@@ -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));
});
});
}
-69
View File
@@ -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);
});
});
}
-40
View File
@@ -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,
);
});
});
}
-29
View File
@@ -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);
});
});
}
-77
View File
@@ -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);
});
});
}
-62
View File
@@ -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')));
});
}
-164
View File
@@ -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,
);
});
});
}
-23
View File
@@ -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'));
});
});
}
-352
View File
@@ -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,
);
});
});
}
-84
View File
@@ -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;
}
-29
View File
@@ -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;
}
}
-103
View File
@@ -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;
}
-35
View File
@@ -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);
}
-18
View File
@@ -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;
}
}
-49
View File
@@ -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;
}