mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-11 17:30:00 +02:00
feat: add mobile-optimized IPTV player with HLS and MpegTS support
This commit is contained in:
1 parent
3c9a9c7be7
commit
fa6ae90d8d
38 files changed
+1280
-895
No files matched your search
@@ -29,8 +29,9 @@ class EpgApi {
|
||||
final playlist = await _getPlaylist(request);
|
||||
if (playlist == null) {
|
||||
return Response.forbidden(
|
||||
json.encode({'error': 'Playlist non trouvée'}),
|
||||
headers: {'Content-Type': 'application/json'});
|
||||
json.encode({'error': 'Playlist non trouvée'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
|
||||
final dns = playlist.dns;
|
||||
@@ -43,7 +44,7 @@ class EpgApi {
|
||||
await http.get(Uri.parse(url)).timeout(const Duration(seconds: 60));
|
||||
Map<String, dynamic> epgData = {
|
||||
'channel_id': channelId,
|
||||
'programmes': []
|
||||
'programmes': [],
|
||||
};
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
@@ -72,8 +73,10 @@ class EpgApi {
|
||||
expiresAt: DateTime.now().add(const Duration(minutes: 30)),
|
||||
);
|
||||
|
||||
return Response.ok(jsonStr,
|
||||
headers: {'Content-Type': 'application/json', 'X-Cache': 'MISS'});
|
||||
return Response.ok(
|
||||
jsonStr,
|
||||
headers: {'Content-Type': 'application/json', 'X-Cache': 'MISS'},
|
||||
);
|
||||
} catch (e) {
|
||||
return Response.internalServerError(
|
||||
body: json.encode({'error': 'Erreur lors de la récupération EPG: $e'}),
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:shelf/shelf.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../models/playlist.dart';
|
||||
import '../models/playlist_config.dart';
|
||||
import '../database/database.dart';
|
||||
|
||||
@@ -34,8 +33,9 @@ class ProxyHandler {
|
||||
Future<PlaylistConfig?> _getCachedPlaylist(Request request) async {
|
||||
// Basic caching to avoid DB overhead on every video segment
|
||||
final now = DateTime.now();
|
||||
final cacheKey = 'global_playlist'; // Currently app has one primary playlist per user/global
|
||||
|
||||
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;
|
||||
@@ -134,7 +134,8 @@ class ProxyHandler {
|
||||
final playlist = await _getCachedPlaylist(request);
|
||||
if (playlist == null) {
|
||||
return Response.forbidden(
|
||||
'No active playlist configuration found to validate request');
|
||||
'No active playlist configuration found to validate request',
|
||||
);
|
||||
}
|
||||
|
||||
final targetHost = targetUrl.host.toLowerCase();
|
||||
@@ -142,9 +143,11 @@ class ProxyHandler {
|
||||
|
||||
if (targetHost != allowedHost) {
|
||||
print(
|
||||
'[Proxy] Blocked SSRF attempt to $targetHost (Allowed: $allowedHost)');
|
||||
'[Proxy] Blocked SSRF attempt to $targetHost (Allowed: $allowedHost)',
|
||||
);
|
||||
return Response.forbidden(
|
||||
'Access to this domain is forbidden by policy');
|
||||
'Access to this domain is forbidden by policy',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,14 +166,14 @@ class ProxyHandler {
|
||||
try {
|
||||
print('[Proxy] Forwarding to: $targetUrl');
|
||||
final proxyRequest = http.Request(request.method, targetUrl);
|
||||
|
||||
|
||||
// Forward safe request headers
|
||||
for (final header in _allowedRequestHeaders) {
|
||||
if (request.headers.containsKey(header)) {
|
||||
proxyHeaders[header] = request.headers[header]!;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
proxyRequest.headers.addAll(proxyHeaders);
|
||||
proxyRequest.followRedirects = true;
|
||||
|
||||
@@ -215,7 +218,8 @@ class ProxyHandler {
|
||||
targetUrl?.path.endsWith('.jpg') == true) {
|
||||
return Response.ok(
|
||||
base64Decode(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='),
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=',
|
||||
),
|
||||
headers: {'content-type': 'image/png'},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,19 +36,25 @@ class SeasonPassesApi {
|
||||
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'});
|
||||
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'});
|
||||
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'});
|
||||
return Response(
|
||||
400,
|
||||
body: json.encode({'error': 'stream_url est requis'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
|
||||
// Récupérer l'utilisateur depuis le contexte
|
||||
@@ -57,13 +63,18 @@ class SeasonPassesApi {
|
||||
|
||||
// Vérifier si un season pass identique existe déjà
|
||||
final existing = _db.getAllSeasonPasses();
|
||||
final duplicate = existing.any((p) =>
|
||||
(p['show_title'] as String).toLowerCase() == showTitle.toLowerCase() &&
|
||||
p['channel_id'] == channelId);
|
||||
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'});
|
||||
return Response(
|
||||
409,
|
||||
body: json.encode({'error': 'Un Season Pass identique existe déjà'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
|
||||
final pass = _db.createSeasonPass(
|
||||
@@ -74,9 +85,11 @@ class SeasonPassesApi {
|
||||
);
|
||||
|
||||
print('[SeasonPass] Créé: "$showTitle" sur chaîne $channelId');
|
||||
return Response(201,
|
||||
body: json.encode(pass),
|
||||
headers: {'Content-Type': 'application/json'});
|
||||
return Response(
|
||||
201,
|
||||
body: json.encode(pass),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
} catch (e) {
|
||||
return Response.internalServerError(
|
||||
body: json.encode({'error': 'Erreur: $e'}),
|
||||
|
||||
@@ -153,17 +153,20 @@ Handler createLiveStreamHandler(
|
||||
'-i', targetUrl,
|
||||
// Video
|
||||
if (useNvidiaGpu) ...[
|
||||
'-c:v', 'h264_nvenc', '-preset', 'p4', '-tune', 'hq', // Better quality preset for NVENC
|
||||
'-b:v', '8000k', '-maxrate', '12000k', '-bufsize', '16000k', // Quality native / high bitrate
|
||||
'-g', '50',
|
||||
'-c:v', 'h264_nvenc', '-preset', 'p4', '-tune',
|
||||
'hq', // Better quality preset for NVENC
|
||||
'-b:v', '8000k', '-maxrate', '12000k', '-bufsize',
|
||||
'16000k', // Quality native / high bitrate
|
||||
'-g', '50',
|
||||
] else ...[
|
||||
'-c:v', 'libx264', '-preset', 'medium', '-tune', 'zerolatency', // Better detail than ultrafast
|
||||
'-c:v', 'libx264', '-preset', 'medium', '-tune',
|
||||
'zerolatency', // Better detail than ultrafast
|
||||
'-b:v', '6000k', '-maxrate', '8000k', '-bufsize', '12000k',
|
||||
'-g', '50',
|
||||
'-g', '50',
|
||||
],
|
||||
// Audio: High quality + Sync filter
|
||||
'-c:a', 'aac', '-b:a', '192k', '-ac', '2', '-ar', '48000',
|
||||
'-af', 'aresample=async=1',
|
||||
'-af', 'aresample=async=1',
|
||||
// HLS Sliding Window
|
||||
'-f', 'hls',
|
||||
'-hls_time', '2',
|
||||
@@ -201,15 +204,19 @@ Handler createLiveStreamHandler(
|
||||
retries++;
|
||||
}
|
||||
|
||||
if (!file.existsSync())
|
||||
if (!file.existsSync()) {
|
||||
return Response.internalServerError(body: 'Timeout starting live HLS');
|
||||
}
|
||||
|
||||
return Response.ok(file.openRead(), headers: {
|
||||
'Content-Type': 'application/vnd.apple.mpegurl',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Cache-Control': 'no-cache',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
});
|
||||
return Response.ok(
|
||||
file.openRead(),
|
||||
headers: {
|
||||
'Content-Type': 'application/vnd.apple.mpegurl',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Cache-Control': 'no-cache',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// Route: /api/live/{streamId}.ts (Direct Proxy for internal recordings or raw playback)
|
||||
@@ -245,11 +252,14 @@ Handler createLiveStreamHandler(
|
||||
final file = File('${_hlsTempDir.path}/live_$streamId/$segment');
|
||||
if (!file.existsSync()) return Response.notFound('Segment not found');
|
||||
|
||||
return Response.ok(file.openRead(), headers: {
|
||||
'Content-Type': 'video/mp2t',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Cache-Control': 'max-age=60',
|
||||
});
|
||||
return Response.ok(
|
||||
file.openRead(),
|
||||
headers: {
|
||||
'Content-Type': 'video/mp2t',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Cache-Control': 'max-age=60',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
return router;
|
||||
|
||||
@@ -469,7 +469,7 @@ class AppDatabase {
|
||||
startTime.toIso8601String(),
|
||||
endTime.toIso8601String(),
|
||||
now,
|
||||
now
|
||||
now,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -489,7 +489,8 @@ class AppDatabase {
|
||||
|
||||
/// Lister tous les enregistrements (pour le Scheduler et l'admin)
|
||||
List<Recording> getAllRecordings() {
|
||||
final result = _db.select('SELECT * FROM tv_recordings ORDER BY start_time ASC');
|
||||
final result =
|
||||
_db.select('SELECT * FROM tv_recordings ORDER BY start_time ASC');
|
||||
return result.map((row) => Recording.fromMap(row)).toList();
|
||||
}
|
||||
|
||||
@@ -510,7 +511,8 @@ class AppDatabase {
|
||||
}
|
||||
|
||||
/// Mettre à jour le statut et éventuellement le chemin d'un enregistrement
|
||||
void updateRecordingStatus(String id, String status, {String? filePath, String? errorReason}) {
|
||||
void updateRecordingStatus(String id, String status,
|
||||
{String? filePath, String? errorReason}) {
|
||||
final now = DateTime.now().toIso8601String();
|
||||
_db.execute(
|
||||
'''
|
||||
@@ -542,12 +544,21 @@ class AppDatabase {
|
||||
'INSERT INTO season_passes (id, user_id, show_title, channel_id, stream_url, created_at) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[id, userId, showTitle, channelId, streamUrl, now],
|
||||
);
|
||||
return {'id': id, 'user_id': userId, 'show_title': showTitle, 'channel_id': channelId, 'stream_url': streamUrl, 'enabled': 1, 'created_at': now};
|
||||
return {
|
||||
'id': id,
|
||||
'user_id': userId,
|
||||
'show_title': showTitle,
|
||||
'channel_id': channelId,
|
||||
'stream_url': streamUrl,
|
||||
'enabled': 1,
|
||||
'created_at': now
|
||||
};
|
||||
}
|
||||
|
||||
/// Lister tous les Season Passes
|
||||
List<Map<String, dynamic>> getAllSeasonPasses() {
|
||||
final result = _db.select('SELECT * FROM season_passes WHERE enabled = 1 ORDER BY created_at DESC');
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
+6
-4
@@ -46,7 +46,7 @@ void main(List<String> args) async {
|
||||
|
||||
// Injecter la config playlist dans le scheduler pour les Season Passes
|
||||
// (on prend la playlist du premier utilisateur disponible)
|
||||
Future<void> _injectPlaylistToScheduler() async {
|
||||
Future<void> injectPlaylistToScheduler() async {
|
||||
final users = db.getAllUsers();
|
||||
if (users.isNotEmpty) {
|
||||
final playlists = db.getPlaylists(users[0].id);
|
||||
@@ -61,7 +61,7 @@ void main(List<String> args) async {
|
||||
}
|
||||
|
||||
// Injecter après 5s pour attendre l'initialisation complète
|
||||
Future.delayed(const Duration(seconds: 5), _injectPlaylistToScheduler);
|
||||
Future.delayed(const Duration(seconds: 5), injectPlaylistToScheduler);
|
||||
|
||||
// Initialize Streaming Subsystem
|
||||
await initStreaming();
|
||||
@@ -84,14 +84,16 @@ void main(List<String> args) async {
|
||||
if (users.isNotEmpty) {
|
||||
final playlists = db.getPlaylists(users[0].id);
|
||||
print(
|
||||
'[getPlaylist] User ${users[0].username} has ${playlists.length} playlists');
|
||||
'[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})');
|
||||
'[getPlaylist] Returning playlist: ${playlist.name} (DNS: ${playlist.serverUrl})',
|
||||
);
|
||||
return PlaylistConfig(
|
||||
id: playlist.id,
|
||||
name: playlist.name,
|
||||
|
||||
@@ -25,11 +25,14 @@ class RecordingScheduler {
|
||||
RecordingScheduler(this._db);
|
||||
|
||||
void start() {
|
||||
print('[RecordingScheduler] Démarrage du planificateur d\'enregistrements TV');
|
||||
print(
|
||||
'[RecordingScheduler] Démarrage du planificateur d\'enregistrements TV');
|
||||
// Vérifier toutes les 10 secondes (pour un démarrage quasi-immédiat)
|
||||
_timer = Timer.periodic(const Duration(seconds: 10), (_) => _checkAndRunRecordings());
|
||||
_timer = Timer.periodic(
|
||||
const Duration(seconds: 10), (_) => _checkAndRunRecordings());
|
||||
// Season Passes : vérifier toutes les 4 heures
|
||||
_seasonPassTimer = Timer.periodic(const Duration(hours: 4), (_) => _checkSeasonPasses());
|
||||
_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)
|
||||
@@ -50,47 +53,58 @@ class RecordingScheduler {
|
||||
try {
|
||||
// Toujours comparer en UTC pour éviter les problèmes de fuseau horaire
|
||||
final now = DateTime.now().toUtc();
|
||||
|
||||
|
||||
final recordings = _db.getAllRecordings();
|
||||
print('[RecordingScheduler] VÉRIFICATION: now=$now recordings=${recordings.length}');
|
||||
print(
|
||||
'[RecordingScheduler] VÉRIFICATION: now=$now recordings=${recordings.length}');
|
||||
|
||||
// Si un enregistrement est en cours, vérifier s'il doit s'arrêter
|
||||
if (_currentRecording != null) {
|
||||
if (now.isAfter(_currentRecording!.endTime.toUtc())) {
|
||||
print('[RecordingScheduler] Fin de l\'enregistrement : ${_currentRecording!.title}');
|
||||
print(
|
||||
'[RecordingScheduler] Fin de l\'enregistrement : ${_currentRecording!.title}');
|
||||
await _stopCurrentRecording();
|
||||
}
|
||||
}
|
||||
|
||||
// Rechercher les enregistrements planifiés
|
||||
for (final recording in recordings) {
|
||||
// Nettoyer les enregistrements bloqués "recording" suite à un crash serveur
|
||||
if (recording.status == 'recording' && _currentRecording?.id != recording.id) {
|
||||
print('[RecordingScheduler] Enregistrement orphelin détecté: ${recording.id}');
|
||||
_db.updateRecordingStatus(recording.id, 'failed', errorReason: 'Interruption inattendue du serveur');
|
||||
continue;
|
||||
// Nettoyer les enregistrements bloqués "recording" suite à un crash serveur
|
||||
if (recording.status == 'recording' &&
|
||||
_currentRecording?.id != recording.id) {
|
||||
print(
|
||||
'[RecordingScheduler] Enregistrement orphelin détecté: ${recording.id}');
|
||||
_db.updateRecordingStatus(recording.id, 'failed',
|
||||
errorReason: 'Interruption inattendue du serveur');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (recording.status == 'scheduled') {
|
||||
final startUtc = recording.startTime.toUtc();
|
||||
final endUtc = recording.endTime.toUtc();
|
||||
print('[RecordingScheduler] "${recording.title}" start=$startUtc end=$endUtc now=$now isAfterStart=${now.isAfter(startUtc)} isBeforeEnd=${now.isBefore(endUtc)}');
|
||||
print(
|
||||
'[RecordingScheduler] "${recording.title}" start=$startUtc end=$endUtc now=$now isAfterStart=${now.isAfter(startUtc)} isBeforeEnd=${now.isBefore(endUtc)}');
|
||||
|
||||
// Si l'enregistrement est planifié, qu'il est temps de démarrer et qu'il n'est pas déjà fini
|
||||
if (now.isAfter(startUtc) && now.isBefore(endUtc)) {
|
||||
if (_currentRecording != null) {
|
||||
print('[RecordingScheduler] Conflit: "${recording.title}" ne peut pas démarrer, "${_currentRecording!.title}" est en cours.');
|
||||
_db.updateRecordingStatus(recording.id, 'failed', errorReason: 'Un autre enregistrement était déjà en cours.');
|
||||
print(
|
||||
'[RecordingScheduler] Conflit: "${recording.title}" ne peut pas démarrer, "${_currentRecording!.title}" est en cours.');
|
||||
_db.updateRecordingStatus(recording.id, 'failed',
|
||||
errorReason: 'Un autre enregistrement était déjà en cours.');
|
||||
continue;
|
||||
}
|
||||
print('[RecordingScheduler] *** DÉMARRAGE DE L\'ENREGISTREMENT : ${recording.title} ***');
|
||||
print(
|
||||
'[RecordingScheduler] *** DÉMARRAGE DE L\'ENREGISTREMENT : ${recording.title} ***');
|
||||
await _startRecording(recording);
|
||||
}
|
||||
|
||||
|
||||
// Si l'enregistrement est planifié mais que la date de fin est dépassée (loupé)
|
||||
if (now.isAfter(endUtc)) {
|
||||
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');
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -111,7 +125,8 @@ class RecordingScheduler {
|
||||
final password = playlistPassword;
|
||||
|
||||
if (dns == null || username == null || password == null) {
|
||||
print('[SeasonPass] Config playlist non disponible, vérification annulée');
|
||||
print(
|
||||
'[SeasonPass] Config playlist non disponible, vérification annulée');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -128,29 +143,36 @@ class RecordingScheduler {
|
||||
final url = '$dns/player_api.php?username=$username&password=$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));
|
||||
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>? ?? [];
|
||||
final listings =
|
||||
(raw is Map ? raw['epg_listings'] : raw) as List<dynamic>? ?? [];
|
||||
|
||||
for (final item in listings) {
|
||||
String title = item['title'] as String? ?? '';
|
||||
try { title = utf8.decode(base64Decode(title)); } catch (_) {}
|
||||
try {
|
||||
title = utf8.decode(base64Decode(title));
|
||||
} catch (_) {}
|
||||
|
||||
// Vérifier si le titre correspond au Season Pass (insensible casse, recherche partielle)
|
||||
if (!title.toLowerCase().contains(showTitle.toLowerCase())) continue;
|
||||
|
||||
// Parser les heures de début/fin
|
||||
final startStr = item['start'] as String? ?? '';
|
||||
final endStr = item['stop'] as String? ?? item['end'] 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; }
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ne pas créer pour les programmes déjà terminés
|
||||
if (endTime.isBefore(DateTime.now().toUtc())) continue;
|
||||
@@ -170,7 +192,8 @@ class RecordingScheduler {
|
||||
startTime: startTime,
|
||||
endTime: endTime,
|
||||
);
|
||||
print('[SeasonPass] ✓ Planifié automatiquement: "$title" le ${startTime.toLocal()}');
|
||||
print(
|
||||
'[SeasonPass] ✓ Planifié automatiquement: "$title" le ${startTime.toLocal()}');
|
||||
}
|
||||
} catch (e) {
|
||||
print('[SeasonPass] Erreur pour le pass "${pass['show_title']}": $e');
|
||||
@@ -195,8 +218,13 @@ class RecordingScheduler {
|
||||
await _checkDiskSpaceAndRotate(recordingsDir);
|
||||
|
||||
// Génération d'un nom de fichier unique et sûr
|
||||
final safeTitle = recording.title.replaceAll(RegExp(r'[^a-zA-Z0-9_\-]'), '_');
|
||||
final dateStr = recording.startTime.toUtc().toIso8601String().replaceAll(':', '').split('.')[0];
|
||||
final safeTitle =
|
||||
recording.title.replaceAll(RegExp(r'[^a-zA-Z0-9_\-]'), '_');
|
||||
final dateStr = recording.startTime
|
||||
.toUtc()
|
||||
.toIso8601String()
|
||||
.replaceAll(':', '')
|
||||
.split('.')[0];
|
||||
final fileName = '${safeTitle}_$dateStr.mkv';
|
||||
final filePath = p.join(recordingsDir.path, fileName);
|
||||
final logFilePath = filePath.replaceAll('.mkv', '.log');
|
||||
@@ -210,10 +238,13 @@ class RecordingScheduler {
|
||||
|
||||
final args = [
|
||||
'-y',
|
||||
'-i', streamUrl,
|
||||
'-c', 'copy',
|
||||
'-t', '${recording.endTime.difference(DateTime.now()).inSeconds}',
|
||||
filePath
|
||||
'-i',
|
||||
streamUrl,
|
||||
'-c',
|
||||
'copy',
|
||||
'-t',
|
||||
'${recording.endTime.difference(DateTime.now()).inSeconds}',
|
||||
filePath,
|
||||
];
|
||||
|
||||
// Créer le fichier de log de façon sécurisée (openWrite peut lancer une exception)
|
||||
@@ -225,9 +256,11 @@ class RecordingScheduler {
|
||||
logSink.writeln('Destination: $filePath');
|
||||
} catch (logError) {
|
||||
// Si on ne peut pas créer le log, on continue quand même (l'enregistrement reste prioritaire)
|
||||
print('[RecordingScheduler] AVERTISSEMENT: Impossible de créer le fichier log ($logFilePath): $logError');
|
||||
print(
|
||||
'[RecordingScheduler] AVERTISSEMENT: Impossible de créer le fichier log ($logFilePath): $logError');
|
||||
// On note la filePath dans la DB quand même pour pouvoir retourner le statut
|
||||
_db.updateRecordingStatus(recording.id, 'recording', filePath: filePath);
|
||||
_db.updateRecordingStatus(recording.id, 'recording',
|
||||
filePath: filePath);
|
||||
}
|
||||
|
||||
// Trouver ffmpeg
|
||||
@@ -251,15 +284,19 @@ class RecordingScheduler {
|
||||
// Écouter la fin du processus FFmpeg de manière asynchrone
|
||||
_ffmpegProcess!.exitCode.then((exitCode) async {
|
||||
if (_currentRecording?.id == recording.id) {
|
||||
logSink?.writeln('\n[${DateTime.now()}] FFmpeg terminé avec le code $exitCode');
|
||||
logSink?.writeln(
|
||||
'\n[${DateTime.now()}] FFmpeg terminé avec le code $exitCode');
|
||||
await logSink?.close();
|
||||
|
||||
if (exitCode == 0 || exitCode == 255) {
|
||||
print('[RecordingScheduler] Enregistrement terminé: ${recording.title}');
|
||||
print(
|
||||
'[RecordingScheduler] Enregistrement terminé: ${recording.title}');
|
||||
_db.updateRecordingStatus(recording.id, 'completed');
|
||||
} else {
|
||||
print('[RecordingScheduler] Erreur FFmpeg (code: $exitCode) pour ${recording.title}');
|
||||
_db.updateRecordingStatus(recording.id, 'failed', errorReason: 'Erreur FFmpeg code $exitCode. Voir logs.');
|
||||
print(
|
||||
'[RecordingScheduler] Erreur FFmpeg (code: $exitCode) pour ${recording.title}');
|
||||
_db.updateRecordingStatus(recording.id, 'failed',
|
||||
errorReason: 'Erreur FFmpeg code $exitCode. Voir logs.');
|
||||
}
|
||||
_currentRecording = null;
|
||||
_ffmpegProcess = null;
|
||||
@@ -267,11 +304,11 @@ class RecordingScheduler {
|
||||
await logSink?.close();
|
||||
}
|
||||
});
|
||||
|
||||
} catch (e, st) {
|
||||
// Attraper TOUTES les exceptions pour éviter de crasher le serveur
|
||||
print('[RecordingScheduler] ERREUR dans _startRecording: $e\n$st');
|
||||
_db.updateRecordingStatus(recording.id, 'failed', errorReason: 'Erreur au lancement: $e');
|
||||
_db.updateRecordingStatus(recording.id, 'failed',
|
||||
errorReason: 'Erreur au lancement: $e');
|
||||
_currentRecording = null;
|
||||
_ffmpegProcess = null;
|
||||
}
|
||||
@@ -280,7 +317,8 @@ class RecordingScheduler {
|
||||
/// Arrêter un enregistrement en cours (appelé depuis l'API)
|
||||
Future<bool> stopRecording(String id) async {
|
||||
if (_ffmpegProcess != null && _currentRecording?.id == id) {
|
||||
print('[RecordingScheduler] Arrêt demandé pour: ${_currentRecording!.title}');
|
||||
print(
|
||||
'[RecordingScheduler] Arrêt demandé pour: ${_currentRecording!.title}');
|
||||
_ffmpegProcess!.kill(ProcessSignal.sigterm);
|
||||
_db.updateRecordingStatus(id, 'completed');
|
||||
_ffmpegProcess = null;
|
||||
@@ -301,25 +339,30 @@ class RecordingScheduler {
|
||||
}
|
||||
|
||||
Future<void> _checkDiskSpaceAndRotate(Directory dir) async {
|
||||
// Cette fonction pourrait invoquer une commande système `df` ou simplement lister les fichiers
|
||||
// Cette fonction pourrait invoquer une commande système `df` ou simplement lister les fichiers
|
||||
// et supprimer les plus anciens si un quota (ex: max 20 Go) est atteint.
|
||||
// Pour l'implémentation initiale, nous pouvons lister et supprimer si plus de X fichiers
|
||||
try {
|
||||
final maxFiles = 50; // Nombre max d'enregistrements (exemple simpliste)
|
||||
const maxFiles = 50; // Nombre max d'enregistrements (exemple simpliste)
|
||||
final files = dir.listSync().whereType<File>().toList();
|
||||
|
||||
|
||||
if (files.length > maxFiles) {
|
||||
print('[RecordingScheduler] Rotation de l\'espace disque : suppression des anciens enregistrements');
|
||||
files.sort((a, b) => a.statSync().modified.compareTo(b.statSync().modified)); // Du plus vieux au plus récent
|
||||
|
||||
print(
|
||||
'[RecordingScheduler] Rotation de l\'espace disque : suppression des anciens enregistrements');
|
||||
files.sort((a, b) => a
|
||||
.statSync()
|
||||
.modified
|
||||
.compareTo(b.statSync().modified)); // Du plus vieux au plus récent
|
||||
|
||||
// Supprimer les plus anciens pour revenir sous la limite
|
||||
final filesToDelete = files.take(files.length - maxFiles);
|
||||
for (var file in filesToDelete) {
|
||||
file.deleteSync();
|
||||
file.deleteSync();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
print('[RecordingScheduler] Erreur lors de la rotation de l\'espace disque : $e');
|
||||
print(
|
||||
'[RecordingScheduler] Erreur lors de la rotation de l\'espace disque : $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,16 +2,15 @@ import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import '../database/database.dart';
|
||||
import '../models/recording.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
|
||||
@@ -56,9 +55,12 @@ class SimpleRecorder {
|
||||
try {
|
||||
final ffmpeg = await Process.start('ffmpeg', [
|
||||
'-y',
|
||||
'-i', streamUrl,
|
||||
'-c', 'copy',
|
||||
'-t', '${duration.inSeconds}',
|
||||
'-i',
|
||||
streamUrl,
|
||||
'-c',
|
||||
'copy',
|
||||
'-t',
|
||||
'${duration.inSeconds}',
|
||||
filepath,
|
||||
]);
|
||||
|
||||
@@ -78,7 +80,8 @@ class SimpleRecorder {
|
||||
_active.remove(channelId);
|
||||
print('✅ Recording done: $title');
|
||||
}).catchError((e) {
|
||||
db.updateRecordingStatus(recordingId, 'failed', errorReason: 'FFmpeg error');
|
||||
db.updateRecordingStatus(recordingId, 'failed',
|
||||
errorReason: 'FFmpeg error');
|
||||
_active.remove(channelId);
|
||||
});
|
||||
|
||||
@@ -99,7 +102,7 @@ class SimpleRecorder {
|
||||
required DateTime endTime,
|
||||
}) async {
|
||||
final recordingId = const Uuid().v4();
|
||||
|
||||
|
||||
// Create in DB with 'scheduled' status
|
||||
db.createRecording(
|
||||
userId: 'system',
|
||||
@@ -167,22 +170,23 @@ class SimpleRecorder {
|
||||
|
||||
/// 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();
|
||||
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));
|
||||
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)) {
|
||||
@@ -194,7 +198,8 @@ class SimpleRecorder {
|
||||
|
||||
String _safeName(String title, String id) {
|
||||
final safe = title.replaceAll(RegExp(r'[^a-zA-Z0-9_-]'), '_');
|
||||
final timestamp = DateTime.now().toIso8601String().replaceAll(':', '').split('.')[0];
|
||||
final timestamp =
|
||||
DateTime.now().toIso8601String().replaceAll(':', '').split('.')[0];
|
||||
return '${safe}_$timestamp.mkv';
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user