From 966311f560244b32883edac761312a5efe5d96c1 Mon Sep 17 00:00:00 2001 From: Michael Date: Sun, 8 Mar 2026 19:26:54 +0100 Subject: [PATCH] feat(recording): add ffmpeg logs writing and viewing in UI --- bin/api/recordings_api.dart | 27 +++++++++ bin/services/recording_scheduler.dart | 26 +++++++- lib/features/iptv/widgets/recordings_tab.dart | 59 +++++++++++++++++++ 3 files changed, 110 insertions(+), 2 deletions(-) diff --git a/bin/api/recordings_api.dart b/bin/api/recordings_api.dart index b08c1fc..9d19fe3 100644 --- a/bin/api/recordings_api.dart +++ b/bin/api/recordings_api.dart @@ -1,3 +1,4 @@ +import 'dart:io'; import 'dart:convert'; import 'package:shelf/shelf.dart'; import 'package:shelf_router/shelf_router.dart'; @@ -85,6 +86,32 @@ class RecordingsApi { ); }); + // Consulter les logs d'un enregistrement + router.get('//log', (Request request, String id) async { + final recording = _db.getRecordingById(id); + + if (recording == null) { + return Response.notFound(json.encode({'error': 'Enregistrement non trouvé'})); + } + + if (recording.filePath == null) { + return Response.notFound(json.encode({'error': 'Aucun fichier ni log associé pour le moment.'})); + } + + final logFilePath = recording.filePath!.replaceAll('.mp4', '.log'); + final logFile = File(logFilePath); + + if (!await logFile.exists()) { + return Response.notFound(json.encode({'error': 'Le fichier de log est introuvable.'})); + } + + final logs = await logFile.readAsString(); + return Response.ok( + json.encode({'logs': logs}), + headers: {'Content-Type': 'application/json'}, + ); + }); + return router; } } diff --git a/bin/services/recording_scheduler.dart b/bin/services/recording_scheduler.dart index 9710e10..bc57fec 100644 --- a/bin/services/recording_scheduler.dart +++ b/bin/services/recording_scheduler.dart @@ -118,21 +118,43 @@ class RecordingScheduler { _ffmpegProcess = await Process.start('ffmpeg', args); + // Création d'un fichier de log dédié pour FFmpeg + final logFilePath = filePath.replaceAll('.mp4', '.log'); + final logFile = File(logFilePath); + final logSink = logFile.openWrite(); + logSink.writeln('[${DateTime.now()}] Démarrage de l\'enregistrement : ${recording.title}'); + logSink.writeln('URL source: ${recording.streamUrl}'); + logSink.writeln('Fichier destination: $filePath'); + logSink.writeln('Commande: ffmpeg ${args.join(' ')}\n'); + + // Rediriger la sortie de ffmpeg vers ce fichier + _ffmpegProcess!.stdout.listen((event) { + logSink.add(event); + }); + _ffmpegProcess!.stderr.listen((event) { + logSink.add(event); // FFmpeg écrit beaucoup d'infos (dont la progression) sur stderr + }); + // Enregistrer le chemin du fichier dans la BDD _db.updateRecordingStatus(recording.id, 'recording', filePath: filePath); // Écouter de manière asynchrone la fin du processus FFmpeg - _ffmpegProcess!.exitCode.then((exitCode) { + _ffmpegProcess!.exitCode.then((exitCode) async { if (_currentRecording?.id == recording.id) { + logSink.writeln('\n[${DateTime.now()}] Processus FFmpeg terminé avec le code $exitCode'); + await logSink.close(); + if (exitCode == 0 || exitCode == 255) { // 255 est souvent renvoyé lors d'un arrêt forcé (SIGKILL/SIGTERM) qui est attendu print('[RecordingScheduler] Enregistrement terminé avec succès (${recording.title})'); _db.updateRecordingStatus(recording.id, 'completed'); } else { print('[RecordingScheduler] Erreur FFmpeg (code: $exitCode) pour l\'enregistrement ${recording.title}'); - _db.updateRecordingStatus(recording.id, 'failed', errorReason: 'Erreur FFmpeg code $exitCode'); + _db.updateRecordingStatus(recording.id, 'failed', errorReason: 'Erreur FFmpeg code $exitCode. Voir logs.'); } _currentRecording = null; _ffmpegProcess = null; + } else { + await logSink.close(); } }); diff --git a/lib/features/iptv/widgets/recordings_tab.dart b/lib/features/iptv/widgets/recordings_tab.dart index b35c66f..b298c33 100644 --- a/lib/features/iptv/widgets/recordings_tab.dart +++ b/lib/features/iptv/widgets/recordings_tab.dart @@ -71,6 +71,59 @@ class _RecordingsTabState extends State { } } + Future _showLogs(String id, String title) async { + try { + final response = await http.get(Uri.parse('/api/recordings/$id/log')); + + if (response.statusCode == 200) { + final data = json.decode(response.body); + final logs = data['logs'] ?? 'Aucun log trouvé.'; + + if (mounted) { + showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: const Color(0xFF1E1E1E), + title: Text('Journaux pour "$title"', style: const TextStyle(color: Colors.white)), + content: SizedBox( + width: double.maxFinite, + height: 400, + child: SingleChildScrollView( + child: Text( + logs, + style: GoogleFonts.firaCode( + fontSize: 12, + color: Colors.greenAccent, + ), + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Fermer', style: TextStyle(color: Colors.blueAccent)), + ), + ], + ), + ); + } + } else { + if (mounted) { + final data = json.decode(response.body); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(data['error'] ?? 'Erreur lors de la récupération des logs'), backgroundColor: Colors.orangeAccent), + ); + } + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Impossible de récupérer les logs: $e'), backgroundColor: Colors.red), + ); + } + } + } + Color _getStatusColor(String status) { switch (status) { case 'recording': @@ -203,8 +256,14 @@ class _RecordingsTabState extends State { ), ), const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.description_outlined, color: Colors.blueAccent), + tooltip: 'Voir les logs', + onPressed: () => _showLogs(rec['id'], rec['title'] ?? 'Inconnu'), + ), IconButton( icon: const Icon(Icons.delete_outline, color: Colors.white54), + tooltip: 'Supprimer', onPressed: () => _deleteRecording(rec['id']), ), ],