diff --git a/bin/api/streaming_handler.dart b/bin/api/streaming_handler.dart index 52ecf07..8f7c8a0 100644 --- a/bin/api/streaming_handler.dart +++ b/bin/api/streaming_handler.dart @@ -5,6 +5,7 @@ import 'dart:math'; import 'package:shelf/shelf.dart'; import 'package:shelf_router/shelf_router.dart'; import 'package:http/http.dart' as http; +import '../database/database.dart'; import '../models/playlist_config.dart'; /// Active FFmpeg processes for VOD transcoding @@ -550,3 +551,131 @@ Handler createVodStreamHandler( return router; } + +// ========================================== +// 3. RECORDING HANDLER (FFmpeg HLS Transcoding) +// ========================================== + +Handler createRecordingStreamHandler( + AppDatabase db, { + bool Function()? isGpuEnabled, +}) { + final router = Router(); + + // Route: /api/recordings/stream/{streamId}/playlist.m3u8 + router.get('//playlist.m3u8', (Request request, String streamId) async { + final recording = db.getRecordingById(streamId); + if (recording == null || recording.filePath == null) { + return Response.notFound('Recording or file not found'); + } + + final targetUrl = recording.filePath!; + if (!File(targetUrl).existsSync()) { + return Response.notFound('Physical recording file not found'); + } + + final streamDir = Directory('${_hlsTempDir.path}/rec_$streamId'); + + // Check if existing session is healthy + if (_vodProcesses.containsKey('rec_$streamId')) { + final existingProcess = _vodProcesses['rec_$streamId']!; + final playlistFile = File('${streamDir.path}/playlist.m3u8'); + + if (playlistFile.existsSync() && playlistFile.readAsStringSync().contains('.ts')) { + return Response.ok( + playlistFile.openRead(), + headers: { + 'Content-Type': 'application/vnd.apple.mpegurl', + 'Access-Control-Allow-Origin': '*', + 'Cache-Control': 'no-cache', + }, + ); + } + + try { existingProcess.kill(); } catch (_) {} + _vodProcesses.remove('rec_$streamId'); + if (streamDir.existsSync()) streamDir.deleteSync(recursive: true); + } + + if (!_vodProcesses.containsKey('rec_$streamId')) { + if (streamDir.existsSync()) streamDir.deleteSync(recursive: true); + streamDir.createSync(recursive: true); + + final useNvidiaGpu = isGpuEnabled?.call() ?? _isNvidiaGpuEnabled(); + + final ffmpegArgs = [ + '-hide_banner', '-loglevel', 'warning', + if (useNvidiaGpu) ...['-hwaccel', 'cuda'], + '-i', targetUrl, + ]; + + if (useNvidiaGpu) { + ffmpegArgs.addAll([ + '-c:v', 'h264_nvenc', '-preset', 'p4', '-tune', 'hq', + '-rc', 'cbr', '-b:v', '4000k', '-maxrate', '4500k', '-bufsize', '8000k', + '-g', '48', '-bf', '2', '-pix_fmt', 'yuv420p', + ]); + } else { + ffmpegArgs.addAll([ + '-c:v', 'libx264', '-preset', 'medium', '-crf', '18', + '-maxrate', '12000k', '-bufsize', '24000k', '-pix_fmt', 'yuv420p', + '-g', '48', '-threads', '0', + ]); + } + + ffmpegArgs.addAll([ + '-c:a', 'aac', '-b:a', '192k', '-ar', '48000', + '-af', 'pan=stereo|FL=1.0*FL+0.707*FC+0.5*BL+0.5*SL+0.5*LFE|FR=1.0*FR+0.707*FC+0.5*BR+0.5*SR+0.5*LFE,dynaudnorm=f=150:g=15', + '-f', 'hls', '-hls_time', '4', '-hls_list_size', '0', + '-hls_playlist_type', 'event', '-hls_allow_cache', '1', + '-hls_flags', 'independent_segments', '-hls_segment_type', 'mpegts', + '-hls_segment_filename', 'segment_%03d.ts', '-start_number', '0', + 'playlist.m3u8', + ]); + + final ffmpegPath = _getFFmpegPath(); + Process.start(ffmpegPath, ffmpegArgs, workingDirectory: streamDir.path).then((process) { + _vodProcesses['rec_$streamId'] = process; + process.stderr.transform(utf8.decoder).listen((data) => print('[FFmpeg Rec $streamId] $data')); + process.exitCode.then((code) { + print('[Recording Stream] FFmpeg exited with code $code'); + _vodProcesses.remove('rec_$streamId'); + }); + }); + } + + final playlistFile = File('${streamDir.path}/playlist.m3u8'); + int retries = 0; + while (retries < 60) { + if (playlistFile.existsSync() && playlistFile.readAsStringSync().contains('.ts')) break; + await Future.delayed(const Duration(milliseconds: 500)); + retries++; + } + + if (!playlistFile.existsSync()) return Response.internalServerError(body: 'Timeout waiting for transcoder'); + + return Response.ok( + playlistFile.openRead(), + headers: { + 'Content-Type': 'application/vnd.apple.mpegurl', + 'Access-Control-Allow-Origin': '*', + 'Cache-Control': 'no-cache', + }, + ); + }); + + router.get('//', (Request request, String streamId, String segment) async { + final file = File('${_hlsTempDir.path}/rec_$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=3600', + }, + ); + }); + + return router; +} diff --git a/bin/server.dart b/bin/server.dart index 49ffb8d..b290655 100644 --- a/bin/server.dart +++ b/bin/server.dart @@ -243,6 +243,13 @@ void main(List args) async { 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) diff --git a/lib/features/iptv/screens/player_screen.dart b/lib/features/iptv/screens/player_screen.dart index 5f12ff8..9aab57e 100644 --- a/lib/features/iptv/screens/player_screen.dart +++ b/lib/features/iptv/screens/player_screen.dart @@ -14,7 +14,7 @@ import '../../../core/models/playlist_config.dart'; import '../../../core/widgets/glass_container.dart'; import '../../../core/theme/app_colors.dart'; -enum StreamType { live, vod, series } +enum StreamType { live, vod, series, recording } class PlayerScreen extends ConsumerStatefulWidget { final String streamId; @@ -127,6 +127,8 @@ class _PlayerScreenState extends ConsumerState { currentStreamId, widget.containerExtension, ); + } else if (widget.streamType == StreamType.recording) { + streamUrl = '${service.backendBaseUrl}/api/recordings/stream/$currentStreamId/playlist.m3u8'; } // Store URL for Lite Player diff --git a/lib/features/iptv/widgets/recordings_tab.dart b/lib/features/iptv/widgets/recordings_tab.dart index e1d558c..df5eeb9 100644 --- a/lib/features/iptv/widgets/recordings_tab.dart +++ b/lib/features/iptv/widgets/recordings_tab.dart @@ -779,12 +779,12 @@ class _RecordingsListViewState extends State<_RecordingsListView> { } Future _playRecording(BuildContext context, Map rec) async { - final streamUrl = rec['stream_url'] as String? ?? ''; + final recordingId = rec['id'] as String?; final title = rec['title'] as String? ?? 'Enregistrement'; - if (streamUrl.isEmpty) { + if (recordingId == null || recordingId.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('URL du flux indisponible')), + const SnackBar(content: Text('ID de lecture indisponible')), ); return; } @@ -793,10 +793,10 @@ class _RecordingsListViewState extends State<_RecordingsListView> { Navigator.of(context).push( MaterialPageRoute( builder: (ctx) => PlayerScreen( - streamId: streamUrl, + streamId: recordingId, title: title, playlist: widget.playlist, - streamType: StreamType.vod, + streamType: StreamType.recording, containerExtension: 'ts', ), ),