From bd7645922d9bd5e7d0d48e99d7132e96ba2b5059 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Mon, 9 Mar 2026 13:41:56 +0100 Subject: [PATCH] feat: add Xtream API proxy with SSRF protection and streaming handler with FFmpeg integration. --- bin/api/proxy_handler.dart | 44 ++-- bin/api/streaming_handler.dart | 237 +++++++----------- .../iptv/services/xtream_service.dart | 3 +- task.md | 1 + 4 files changed, 113 insertions(+), 172 deletions(-) diff --git a/bin/api/proxy_handler.dart b/bin/api/proxy_handler.dart index f832851..7f47e84 100644 --- a/bin/api/proxy_handler.dart +++ b/bin/api/proxy_handler.dart @@ -120,47 +120,51 @@ class ProxyHandler { } } - final proxyHeaders = { + final proxyHeaders = { 'User-Agent': 'VLC/3.0.18 LibVLC/3.0.18', 'Accept': '*/*', - 'Accept-Encoding': 'identity', // Explicitly request non-chunked - 'Connection': 'close', // Don't keep-alive to avoid chunked issues + 'Accept-Encoding': 'identity', + 'Connection': 'close', }; + // Forward Range header if present + if (request.headers['range'] != null) { + proxyHeaders['range'] = request.headers['range']!; + } + final client = http.Client(); try { - // Use simple GET/POST instead of streaming to avoid chunked encoding issues - http.Response response; + final proxyRequest = http.Request(request.method, targetUrl); + proxyRequest.headers.addAll(proxyHeaders); + proxyRequest.followRedirects = true; + if (request.method == 'POST') { final bodyBytes = await request.read().toList(); - final body = bodyBytes.expand((i) => i).toList(); - response = - await client.post(targetUrl, headers: proxyHeaders, body: body); - } else { - response = await client.get(targetUrl, headers: proxyHeaders); + proxyRequest.bodyBytes = bodyBytes.expand((i) => i).toList(); } - // Build response headers + final response = await client.send(proxyRequest); + + // Build response headers from source response final responseHeaders = { 'access-control-allow-origin': '*', }; - if (response.headers['content-type'] != null) { - responseHeaders['content-type'] = response.headers['content-type']!; - } - - if (response.headers['content-length'] != null) { - responseHeaders['content-length'] = - response.headers['content-length']!; + // Forward specific safe headers + for (final header in _allowedHeaders) { + if (response.headers.containsKey(header)) { + responseHeaders[header] = response.headers[header]!; + } } return Response( response.statusCode, - body: response.bodyBytes, + body: response.stream, headers: responseHeaders, ); - } finally { + } catch (e) { client.close(); + rethrow; } } catch (e) { print('[ProxyHandler] error on $path: $e'); diff --git a/bin/api/streaming_handler.dart b/bin/api/streaming_handler.dart index db43c93..78cebe8 100644 --- a/bin/api/streaming_handler.dart +++ b/bin/api/streaming_handler.dart @@ -117,172 +117,107 @@ PlaylistConfig? _getCurrentPlaylist(Request request) { // 1. LIVE TV HANDLER (Direct MPEG-TS Proxy) // ========================================== +/// Active FFmpeg processes for Live HLS transcoding +final Map _liveProcesses = {}; + Handler createLiveStreamHandler( Future Function(Request) getPlaylist, { bool Function()? isGpuEnabled, }) { final router = Router(); - // Route: /api/live/{streamId} (Catch all pattern to debug) - router.get('/', (Request request, String streamId) async { - print('[Live] Incoming request for streamId: $streamId'); - - // Remove extension if present FIRST (handle both .ts and .m3u8) - if (streamId.endsWith('.ts')) { - streamId = streamId.substring(0, streamId.length - 3); - } else if (streamId.endsWith('.m3u8')) { - streamId = streamId.substring(0, streamId.length - 5); - } - - // Validate streamId to prevent Path Traversal (AFTER extension removal) - if (!RegExp(r'^[a-zA-Z0-9_-]+$').hasMatch(streamId)) { - print('[Live] Invalid streamId: $streamId'); - return Response.badRequest(body: 'Invalid stream ID'); - } - + // Route: /api/live/{streamId}.m3u8 (Master playlist) + router.get('/.m3u8', (Request request, String streamId) async { final playlist = await getPlaylist(request); + if (playlist == null) return Response.forbidden('No playlist'); - if (playlist == null) { - print('[Live] Error: No playlist found'); - return Response.internalServerError(body: 'No playlist configured'); + final streamDir = Directory('${_hlsTempDir.path}/live_$streamId'); + + // Start FFmpeg if not already running for this live stream + if (!_liveProcesses.containsKey(streamId)) { + if (streamDir.existsSync()) streamDir.deleteSync(recursive: true); + streamDir.createSync(recursive: true); + + final targetUrl = + '${playlist.dns}/live/${playlist.username}/${playlist.password}/$streamId.ts'; + print('[Live HLS] Starting for $streamId: $targetUrl'); + + final useNvidiaGpu = isGpuEnabled?.call() ?? _isNvidiaGpuEnabled(); + final ffmpegArgs = [ + '-hide_banner', '-loglevel', 'warning', + if (useNvidiaGpu) ...['-hwaccel', 'cuda'], + '-headers', 'User-Agent: VLC/3.0.18 LibVLC/3.0.18\r\n', + '-reconnect', '1', '-reconnect_streamed', '1', + '-reconnect_delay_max', '10', + '-i', targetUrl, + // Video + if (useNvidiaGpu) ...[ + '-c:v', + 'h264_nvenc', + '-preset', + 'p1', + '-tune', + 'll', + '-b:v', + '3500k', + '-maxrate', + '4000k', + '-bufsize', + '7000k', + ] else ...[ + '-c:v', + 'copy', + ], + // Audio + '-c:a', 'aac', '-b:a', '128k', '-ac', '2', + // HLS Sliding Window + '-f', 'hls', + '-hls_time', '2', // Small segments for low latency + '-hls_list_size', '5', // 10 seconds of history + '-hls_flags', 'delete_segments+independent_segments', + '-hls_segment_type', 'mpegts', + '-hls_segment_filename', '${streamDir.path}/seg_%03d.ts', + '${streamDir.path}/playlist.m3u8', + ]; + + final process = await Process.start(_getFFmpegPath(), ffmpegArgs); + _liveProcesses[streamId] = process; + + process.stderr + .transform(utf8.decoder) + .listen((d) => print('[Live HLS $streamId] $d')); + process.exitCode.then((_) => _liveProcesses.remove(streamId)); } - // Use original URL - let FFmpeg handle HTTP redirects natively - // Manual redirect resolution consumes one-time tokens from IPTV servers - final targetUrl = - '${playlist.dns}/live/${playlist.username}/${playlist.password}/$streamId.ts'; - print('[Live] Streaming via FFmpeg: $targetUrl'); - - // Check if NVIDIA GPU is enabled (from database setting or env var fallback) - final useNvidiaGpu = isGpuEnabled?.call() ?? _isNvidiaGpuEnabled(); - - // Log GPU status - if (useNvidiaGpu) { - print('[Live] Using NVIDIA GPU acceleration (NVDEC)'); + // Wait for playlist + final file = File('${streamDir.path}/playlist.m3u8'); + int retries = 0; + while (!file.existsSync() && retries < 40) { + await Future.delayed(const Duration(milliseconds: 500)); + retries++; } - // Build FFmpeg arguments - final ffmpegArgs = [ - '-hide_banner', - '-loglevel', - 'warning', // Changed from error to see more info - ]; + if (!file.existsSync()) + return Response.internalServerError(body: 'Timeout starting live HLS'); - // NVIDIA GPU Hardware Acceleration (NVDEC for decoding) - if (useNvidiaGpu) { - ffmpegArgs.addAll([ - '-hwaccel', 'cuda', // Use NVIDIA CUDA for decoding - // Note: Don't use hwaccel_output_format cuda - it can cause issues with some codecs - ]); - } + return Response.ok(file.openRead(), headers: { + 'Content-Type': 'application/vnd.apple.mpegurl', + 'Access-Control-Allow-Origin': '*', + 'Cache-Control': 'no-cache', + }); + }); - // Input headers and robustness flags - ffmpegArgs.addAll([ - '-headers', - 'User-Agent: VLC/3.0.18 LibVLC/3.0.18\r\nAccept: */*\r\nConnection: keep-alive\r\nReferer: ${playlist.dns}/\r\n', - '-reconnect', - '1', - '-reconnect_at_eof', - '1', - '-reconnect_streamed', - '1', - '-reconnect_delay_max', - '10', // Increased from 5 - '-rw_timeout', - '30000000', // Increased from 15s to 30s - '-timeout', - '30000000', // Add explicit timeout - '-fflags', - '+nobuffer+genpts+discardcorrupt', // Added discardcorrupt for robustness - '-analyzeduration', - '5000000', // Increased for GPU decode - '-probesize', - '10000000', // Increased for GPU decode - '-i', - targetUrl, - ]); + // Route: /api/live/{streamId}/{segment} + router.get('//', + (Request request, String streamId, String segment) async { + final file = File('${_hlsTempDir.path}/live_$streamId/$segment'); + if (!file.existsSync()) return Response.notFound('Segment not found'); - // Output encoding options - if (useNvidiaGpu) { - // With GPU: Transcode using NVIDIA NVENC for hardware acceleration - print('[Live] Transcoding video with h264_nvenc (GPU)'); - ffmpegArgs.addAll([ - // Video encoding - '-c:v', 'h264_nvenc', // Use NVIDIA NVENC encoder - '-preset', 'p1', // Fastest preset for live (p1=fastest) - '-tune', 'll', // Low latency tuning for live streams - '-rc', 'cbr', // Constant bitrate for stable streaming - '-b:v', '4000k', // Fixed video bitrate - '-maxrate', '4500k', // Max bitrate - '-bufsize', '8000k', // Buffer for stability - '-g', '50', // Keyframe every 2 seconds (at 25fps) - '-bf', '0', // No B-frames for lower latency - // Audio encoding with resampling to fix timestamp gaps - '-af', - 'aresample=async=1000:first_pts=0', // Auto-correct audio timestamps - '-c:a', 'aac', // Audio to AAC (browser compatible) - '-b:a', '128k', // Audio bitrate - '-ac', '2', - '-ar', '48000', // 48kHz sample rate - // Timestamp handling - '-copyts', // Copy timestamps - '-start_at_zero', // Start at 0 - '-max_muxing_queue_size', '4096', // Large muxing queue - ]); - } else { - // Without GPU: Standard CPU processing (copy video) - ffmpegArgs.addAll([ - '-c:v', 'copy', // Video: Direct copy (Very low CPU) - '-c:a', 'aac', // Audio: Transcode to AAC - '-b:a', '192k', - '-ac', '2', - ]); - } - - ffmpegArgs.addAll(['-f', 'mpegts', 'pipe:1']); - - try { - final ffmpegPath = _getFFmpegPath(); - final process = await Process.start(ffmpegPath, ffmpegArgs); - - // Cleanup handling: Kill FFmpeg when client disconnects - final controller = StreamController>( - onCancel: () { - print('[Live] Client disconnected, killing FFmpeg for $streamId'); - process.kill(); - }, - ); - - // Pipe stdout to controller - process.stdout.listen( - (data) => controller.add(data), - onDone: () { - print('[Live] FFmpeg stream ended for $streamId'); - controller.close(); - }, - onError: (e) { - print('[Live] FFmpeg stream error: $e'); - controller.addError(e); - }, - ); - - // Log errors - process.stderr.transform(utf8.decoder).listen((data) { - print('[FFmpeg Live $streamId] $data'); - }); - - return Response.ok( - controller.stream, - headers: { - 'Content-Type': 'video/mp2t', - 'Access-Control-Allow-Origin': '*', - 'Cache-Control': 'no-cache', - }, - ); - } catch (e) { - print('[Live] Failed to start FFmpeg: $e'); - return Response.internalServerError(body: 'Stream start error: $e'); - } + return Response.ok(file.openRead(), headers: { + 'Content-Type': 'video/mp2t', + 'Access-Control-Allow-Origin': '*', + 'Cache-Control': 'max-age=60', + }); }); return router; diff --git a/lib/features/iptv/services/xtream_service.dart b/lib/features/iptv/services/xtream_service.dart index 8060240..0d696a0 100644 --- a/lib/features/iptv/services/xtream_service.dart +++ b/lib/features/iptv/services/xtream_service.dart @@ -78,7 +78,8 @@ class XtreamService { /// Generate stream URL for live TV String getLiveStreamUrl(String streamId) { if (_currentPlaylist == null) throw Exception('No playlist configured'); - return '$_backendBaseUrl/api/live/$streamId.ts'; + // Use HLS (.m3u8) for all live streams to ensure iOS compatibility + return '$_backendBaseUrl/api/live/$streamId.m3u8'; } /// Generate stream URL for VOD (movies) diff --git a/task.md b/task.md index 084adab..bdf8f80 100644 --- a/task.md +++ b/task.md @@ -20,6 +20,7 @@ Refonte du Guide TV pour inclure les catégories et corriger l'EPG. - [x] Correction du lecteur Mobile (LitePlayerView) - [x] Alignement UI Mobile (Tri, EPG, URLs) - [x] Correction Connectivité Mobile (Auto-origin + Manuel Override) +- [x] Compatibilité Streaming iOS (HLS Live + Proxy Streaming) ## Progress Log