diff --git a/bin/api/streaming_handler.dart b/bin/api/streaming_handler.dart index 6cce76d..99aa41d 100644 --- a/bin/api/streaming_handler.dart +++ b/bin/api/streaming_handler.dart @@ -562,51 +562,113 @@ Handler createRecordingStreamHandler( }) { final router = Router(); - router.get('/.mkv', (Request request, String streamId) async { + 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!; - final file = File(targetUrl); - if (!file.existsSync()) { + if (!File(targetUrl).existsSync()) { return Response.notFound('Physical recording file not found'); } - final fileStat = file.statSync(); - final fileSize = fileStat.size; - final rangeHeader = request.headers['range']; + final streamDir = Directory('${_hlsTempDir.path}/rec_$streamId'); - if (rangeHeader != null && rangeHeader.startsWith('bytes=')) { - final parts = rangeHeader.substring(6).split('-'); - var start = int.tryParse(parts[0]) ?? 0; - var end = (parts.length > 1 && parts[1].isNotEmpty) ? int.tryParse(parts[1]) : null; - end ??= fileSize - 1; + 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 (start >= fileSize || end >= fileSize || start > end) { - return Response(416, headers: {'Content-Range': 'bytes */$fileSize'}); + 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', '3000k', '-maxrate', '3500k', '-bufsize', '6000k', + '-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', + ]); } - final contentLength = end - start + 1; - final stream = file.openRead(start, end + 1); + 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', 'vod', '-hls_allow_cache', '1', + '-hls_flags', 'independent_segments', '-hls_segment_type', 'mpegts', + '-hls_segment_filename', 'segment_%03d.ts', '-start_number', '0', + 'playlist.m3u8', + ]); - return Response(206, body: stream, headers: { - 'Content-Type': 'video/x-matroska', - 'Accept-Ranges': 'bytes', - 'Content-Length': contentLength.toString(), - 'Content-Range': 'bytes $start-$end/$fileSize', - 'Access-Control-Allow-Origin': '*', + Process.start(_getFFmpegPath(), 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) { + _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/x-matroska', - 'Accept-Ranges': 'bytes', - 'Content-Length': fileSize.toString(), + 'Content-Type': 'video/mp2t', 'Access-Control-Allow-Origin': '*', + 'Cache-Control': 'max-age=3600', }, ); }); diff --git a/lib/features/iptv/screens/player_screen.dart b/lib/features/iptv/screens/player_screen.dart index eba8565..7cb1e48 100644 --- a/lib/features/iptv/screens/player_screen.dart +++ b/lib/features/iptv/screens/player_screen.dart @@ -128,7 +128,7 @@ class _PlayerScreenState extends ConsumerState { widget.containerExtension, ); } else if (widget.streamType == StreamType.recording) { - streamUrl = '${service.backendBaseUrl}/api/recordings/stream/$currentStreamId.mkv'; + streamUrl = '${service.backendBaseUrl}/api/recordings/stream/$currentStreamId/playlist.m3u8'; } // Store URL for Lite Player @@ -166,6 +166,10 @@ class _PlayerScreenState extends ConsumerState { var playerSrc = '$playerFile?url=$encodedUrl&type=$streamTypeParam&turbo=true&v=$cacheBuster'; + if (widget.streamType == StreamType.recording) { + playerSrc += '&is_recording=true'; + } + if (startTimeOverride != null) { playerSrc += '&t=$startTimeOverride'; } diff --git a/web/player.html b/web/player.html index bf41a13..d985313 100644 --- a/web/player.html +++ b/web/player.html @@ -251,21 +251,27 @@ } const isTurbo = urlParams.get('turbo') === 'true'; + const isRecording = urlParams.get('is_recording') === 'true'; - const hls = new Hls({ + const hlsOptions = { maxBufferLength: 120, enableWorker: true, - liveSyncDurationCount: 10, // Buffer-First: 10 segments safety margin - liveMaxLatencyDurationCount: 20, nudgeOffset: 0.8, // More aggressive jump over corrupted frames nudgeMaxRetries: 30, maxLiveSyncPlaybackRate: 1.1, // Slower catch-up to avoid chaos - // Stabilization initialLiveManifestSize: 3, // Wait for more segments before starting abrEwmaDefaultEstimate: 1000000, manifestLoadingRetryDelay: 1000, levelLoadingRetryDelay: 1000 - }); + }; + + // Limit live sync only for actual LIVE IPTV, not recordings + if (!isRecording && streamType === 'live') { + hlsOptions.liveSyncDurationCount = 10; + hlsOptions.liveMaxLatencyDurationCount = 20; + } + + const hls = new Hls(hlsOptions); window.hlsInstance = hls; hls.loadSource(url); hls.attachMedia(video);