From f290f5a7fc5654bc70e8d042c29fa3f6052786da Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Mon, 15 Dec 2025 13:01:24 +0100 Subject: [PATCH] feat: Add initial IPTV player with web-based playback and streaming API. --- bin/api/hls_handler.dart | 289 -------- bin/api/streaming_handler.dart | 225 ++++++ bin/server.dart | 644 ++---------------- .../iptv/providers/settings_provider.dart | 15 +- lib/features/iptv/screens/player_screen.dart | 10 +- .../iptv/services/xtream_service.dart | 49 +- web/player.html | 562 ++------------- 7 files changed, 354 insertions(+), 1440 deletions(-) delete mode 100644 bin/api/hls_handler.dart create mode 100644 bin/api/streaming_handler.dart diff --git a/bin/api/hls_handler.dart b/bin/api/hls_handler.dart deleted file mode 100644 index e91e99c..0000000 --- a/bin/api/hls_handler.dart +++ /dev/null @@ -1,289 +0,0 @@ -import 'dart:io'; -import 'dart:async'; -import 'dart:convert'; -import 'package:shelf/shelf.dart'; - -/// Active FFmpeg processes and their temp directories -final Map _activeStreams = {}; -final Map _streamDirs = {}; - -/// Create handler for HLS file serving -/// -/// Serves playlist.m3u8 and segment files created by FFmpeg -Handler createHlsFileHandler() { - return (Request request) async { - final path = request.url.path; - - // Only handle /api/hls/* requests - if (!path.startsWith('api/hls/')) { - return Response.notFound('Not found'); - } - - try { - // Parse path: /api/hls/{streamId}/{filename} - final pathParts = path.split('/'); - if (pathParts.length < 4) { - return Response.badRequest(body: 'Invalid HLS path'); - } - - final streamId = pathParts[2]; - final filename = pathParts[3]; - - // Get temp directory for this stream - final tempDir = Directory('${Directory.systemTemp.path}/hls_$streamId'); - - if (!await tempDir.exists()) { - return Response.notFound('Stream not found'); - } - - // Construct file path - final filePath = '${tempDir.path}/$filename'; - final file = File(filePath); - - if (!await file.exists()) { - return Response.notFound('File not found'); - } - - // Determine content type - String contentType; - if (filename.endsWith('.m3u8')) { - contentType = 'application/vnd.apple.mpegurl'; - } else if (filename.endsWith('.ts')) { - contentType = 'video/MP2T'; - } else { - contentType = 'application/octet-stream'; - } - - // Read and return file - final fileBytes = await file.readAsBytes(); - - return Response.ok( - fileBytes, - headers: { - 'Content-Type': contentType, - 'Access-Control-Allow-Origin': '*', - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', - }, - ); - - } catch (e, stackTrace) { - print('HLS file serving error: $e'); - print(stackTrace); - return Response.internalServerError( - body: jsonEncode({'error': 'File serving error', 'message': e.toString()}), - headers: {'content-type': 'application/json'}, - ); - } - }; -} - -/// Create handler for FFmpeg HLS transcoding -/// -/// Starts FFmpeg process that creates HLS segments for VOD -Handler createStreamInitHandler() { - return (Request request) async { - final path = request.url.path; - - // Only handle /api/stream/* requests - if (!path.startsWith('api/stream/')) { - return Response.notFound('Not found'); - } - - try { - // Extract stream ID - final pathParts = path.split('/'); - if (pathParts.length < 3) { - return Response.badRequest(body: 'Invalid stream path'); - } - - final streamId = pathParts[2]; - - // Get parameters - final iptvUrl = request.url.queryParameters['url']; - final quality = request.url.queryParameters['quality'] ?? 'high'; - - if (iptvUrl == null || iptvUrl.isEmpty) { - return Response.badRequest(body: 'Missing url parameter'); - } - - print('Starting HLS Stream for ID: $streamId'); - print('Source: $iptvUrl'); - - // Create temp directory for HLS segments - final tempDir = Directory('${Directory.systemTemp.path}/hls_$streamId'); - if (await tempDir.exists()) { - await tempDir.delete(recursive: true); - } - await tempDir.create(recursive: true); - _streamDirs[streamId] = tempDir; - - final playlistPath = '${tempDir.path}/playlist.m3u8'; - - // FFmpeg HLS arguments - final ffmpegArgs = [ - '-hide_banner', - '-loglevel', 'error', - '-user_agent', 'VLC/3.0.18 LibVLC/3.0.18', - '-headers', 'Accept: */*\r\nConnection: keep-alive\r\n', - - // Input resilience - '-reconnect', '1', - '-reconnect_streamed', '1', - '-reconnect_delay_max', '10', - '-rw_timeout', '15000000', - - '-analyzeduration', '10000000', - '-probesize', '5000000', - - '-i', iptvUrl, - - // Output format: HLS - '-f', 'hls', - '-hls_time', '4', - '-hls_list_size', '0', // Keep all segments in playlist - '-hls_playlist_type', 'event', // Allow appending new segments, keep old ones - '-hls_segment_type', 'mpegts', - '-hls_segment_filename', '${tempDir.path}/segment_%03d.ts', - ]; - - // Video encoding: Always transcode for HLS timestamp compatibility - // Using ultrafast preset for Live TV to minimize latency - final isLiveStream = iptvUrl.endsWith('.ts') || iptvUrl.contains('/live/'); - - if (isLiveStream) { - // LIVE TV: Transcode to H.264 for browser compatibility - // Source may be HEVC/H.265 which browsers don't support - // Using superfast preset for minimal CPU usage - ffmpegArgs.addAll([ - '-c:v', 'libx264', - '-preset', 'superfast', // Fast encoding to prevent buffering - '-tune', 'zerolatency', // Minimize latency - '-profile:v', 'baseline', // Maximum browser compatibility - '-level', '3.1', - '-pix_fmt', 'yuv420p', - '-g', '30', // Keyframe every second at 30fps - '-b:v', '2500k', // 2.5Mbps - balance quality/speed - '-maxrate', '3000k', - '-bufsize', '5000k', - ]); - } else { - // VOD: Use ultrafast preset to prevent buffer stalls - // FFmpeg must transcode FASTER than realtime to keep the buffer full - ffmpegArgs.addAll([ - '-c:v', 'libx264', - '-preset', 'ultrafast', // CRITICAL: Fast encoding to prevent buffer stalls - '-tune', 'fastdecode', // Optimize for fast decoding in browser - '-profile:v', 'main', // Good compatibility, better than baseline for VOD - '-level', '4.0', - '-pix_fmt', 'yuv420p', - '-g', '48', // Keyframe every 2s at 24fps - '-threads', '0', // Use all available CPU cores - ]); - - if (quality == 'low') { - ffmpegArgs.addAll([ - '-vf', 'scale=-2:480', - '-b:v', '1000k', - '-maxrate', '1200k', - '-bufsize', '2000k', - ]); - } else { - // High quality but still fast enough to prevent stalls - ffmpegArgs.addAll([ - '-b:v', '2500k', // Reduced from 6000k for faster encoding - '-maxrate', '3000k', // Reduced from 8000k - '-bufsize', '5000k', // Reduced from 12000k - '-crf', '23', // Slightly lower quality but much faster - ]); - } - } - - // Audio: Always transcode to AAC - ffmpegArgs.addAll([ - '-c:a', 'aac', - '-b:a', '192k', - '-ar', '48000', - '-ac', '2', - ]); - - ffmpegArgs.add(playlistPath); - - // Start FFmpeg process - final process = await Process.start('ffmpeg', ffmpegArgs); - _activeStreams[streamId] = process; - - // Log stderr - process.stderr.transform(utf8.decoder).listen((data) { - if (data.contains('Error') || data.contains('error')) { - print('FFmpeg Error [$streamId]: $data'); - } - }); - - // Wait for playlist creation - var attempts = 0; - while (!await File(playlistPath).exists() && attempts < 50) { - await Future.delayed(const Duration(milliseconds: 100)); - attempts++; - } - - if (!await File(playlistPath).exists()) { - process.kill(); - _activeStreams.remove(streamId); - _streamDirs.remove(streamId); - await tempDir.delete(recursive: true); - return Response.internalServerError( - body: 'FFmpeg failed to create playlist', - ); - } - - // CRITICAL: Wait for first segment to exist AND HAVE DATA > 5MB to be safe? - // No, 10KB is enough for header + some data - attempts = 0; - final firstSegment = File('${tempDir.path}/segment_000.ts'); - while (attempts < 100) { - if (await firstSegment.exists()) { - final len = await firstSegment.length(); - if (len > 10240) break; // Wait for > 10KB - } - await Future.delayed(const Duration(milliseconds: 100)); - attempts++; - } - - if (!await firstSegment.exists()) { - print('Warning: First segment not created yet for $streamId'); - } else { - print('First segment ready for $streamId (${await firstSegment.length()} bytes)'); - } - - print('HLS playlist created for $streamId'); - - // Cleanup on process exit - process.exitCode.then((_) { - print('FFmpeg finished for $streamId'); - _activeStreams.remove(streamId); - final dir = _streamDirs.remove(streamId); - dir?.delete(recursive: true).catchError((e) { - print('Cleanup error for $streamId: $e'); - }); - }); - - // Return playlist URL - return Response.ok( - jsonEncode({'playlist': '/api/hls/$streamId/playlist.m3u8'}), - headers: { - 'Content-Type': 'application/json', - 'Access-Control-Allow-Origin': '*', - }, - ); - - } catch (e, stackTrace) { - print('Stream init error: $e'); - print(stackTrace); - return Response.internalServerError( - body: jsonEncode({'error': e.toString()}), - headers: {'content-type': 'application/json'}, - ); - } - }; -} diff --git a/bin/api/streaming_handler.dart b/bin/api/streaming_handler.dart new file mode 100644 index 0000000..9fe4325 --- /dev/null +++ b/bin/api/streaming_handler.dart @@ -0,0 +1,225 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; +import 'package:shelf/shelf.dart'; +import 'package:shelf_router/shelf_router.dart'; +import '../../lib/core/models/playlist_config.dart'; + +/// Active FFmpeg processes for VOD transcoding +final Map _vodProcesses = {}; + +/// Directory for temporary HLS segments +final Directory _hlsTempDir = Directory('/tmp/hls_vod'); + +/// Initialize streaming subsystem +Future initStreaming() async { + if (!_hlsTempDir.existsSync()) { + await _hlsTempDir.create(recursive: true); + } +} + +/// Helper to get current playlist configuration (mocked for now, implies single user) +/// In a real scenario, this should come from the request session/context +PlaylistConfig? _getCurrentPlaylist(Request request) { + // TODO: Retrieve from DB/Session based on Auth + // For now, we assume the frontend sends enough info or we look up the active one + return null; // Implemented later in server.dart injection +} + +// ========================================== +// 1. LIVE TV HANDLER (Direct MPEG-TS Proxy) +// ========================================== + +Handler createLiveStreamHandler(Future Function(Request) getPlaylist) { + final router = Router(); + + // Route: /api/live/{streamId}.ts + router.get('/.ts', (Request request, String streamId) async { + final playlist = await getPlaylist(request); + if (playlist == null) { + return Response.internalServerError(body: 'No playlist configured'); + } + + final targetUrl = Uri.parse('${playlist.dns}/live/${playlist.username}/${playlist.password}/$streamId.ts'); + print('[Live] Proxying: $targetUrl'); + + try { + final client = HttpClient(); + client.connectionTimeout = const Duration(seconds: 10); // Connection timeout + client.idleTimeout = const Duration(hours: 6); // Keep alive long time + + final req = await client.getUrl(targetUrl); + req.headers.set('User-Agent', 'VLC/3.0.18 LibVLC/3.0.18'); // Simulate VLC + req.headers.set('Connection', 'keep-alive'); + + final response = await req.close(); + + if (response.statusCode != 200) { + return Response(response.statusCode, body: 'Upstream error: ${response.statusCode}'); + } + + print('[Live] Connected to upstream. Streaming...'); + + // Pipe the response stream to the client + // We use a StreamController to handle cancellation + final controller = StreamController>(); + + response.listen( + (data) => controller.add(data), + onError: (e) => controller.addError(e), + onDone: () => controller.close(), + cancelOnError: true, + ); + + // Cleanup when client disconnects + controller.onCancel = () { + print('[Live] Client disconnected: $streamId'); + // response.detachSocket() if possible, or just let GC handle it + }; + + return Response.ok( + controller.stream, + headers: { + 'Content-Type': 'video/mp2t', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'Access-Control-Allow-Origin': '*', + }, + ); + + } catch (e) { + print('[Live] Error: $e'); + return Response.internalServerError(body: 'Live stream error: $e'); + } + }); + + return router; +} + +// ========================================== +// 2. VOD HANDLER (FFmpeg HLS Transcoding) +// ========================================== + +// ========================================== +// 2. VOD HANDLER (FFmpeg HLS Transcoding) +// ========================================== + +Handler createVodStreamHandler(Future Function(Request) getPlaylist) { + final router = Router(); + + // Route: /api/vod/{streamId}/playlist.m3u8 + router.get('//playlist.m3u8', (Request request, String streamId) async { + final playlist = await getPlaylist(request); + if (playlist == null) return Response.internalServerError(body: 'No playlist'); + + // Clean up old session if exists + if (_vodProcesses.containsKey(streamId)) { + print('[VOD] Killing previous session for $streamId'); + _vodProcesses[streamId]?.kill(); + _vodProcesses.remove(streamId); + } + + // Prepare directory + final streamDir = Directory('${_hlsTempDir.path}/$streamId'); + if (streamDir.existsSync()) { + streamDir.deleteSync(recursive: true); + } + streamDir.createSync(recursive: true); + + // Build Upstream URL (Try MKV first, then generic) + // Note: We don't know the extension here easily unless passed. + // Xtream usually allows access via streamId.mkv or streamId.mp4 regardless of actual file + // We'll trust the ID. + final targetUrl = '${playlist.dns}/movie/${playlist.username}/${playlist.password}/$streamId.mkv'; + + print('[VOD] Starting Transcode: $targetUrl'); + + // Start FFmpeg + final ffmpegArgs = [ + '-hide_banner', '-loglevel', 'error', + '-headers', 'User-Agent: VLC/3.0.18 LibVLC/3.0.18\r\n', + '-i', targetUrl, + + // Video: H.264 Ultrafast (Low CPU) + '-c:v', 'libx264', + '-preset', 'ultrafast', + '-tune', 'fastdecode', + '-crf', '23', + '-maxrate', '3000k', + '-bufsize', '6000k', + '-pix_fmt', 'yuv420p', + '-threads', '0', + + // Audio: AAC (Browser compatible) + '-c:a', 'aac', + '-b:a', '128k', + '-ac', '2', + + // HLS Output options + '-f', 'hls', + '-hls_time', '4', + '-hls_list_size', '0', // Keep all segments + '-hls_segment_type', 'mpegts', + '-hls_segment_filename', '${streamDir.path}/segment_%03d.ts', + '-start_number', '0', + + // Write playlist to stdout or file? File is easier for static serving + '${streamDir.path}/playlist.m3u8' + ]; + + Process.start('ffmpeg', ffmpegArgs).then((process) { + _vodProcesses[streamId] = process; + + process.stderr.transform(utf8.decoder).listen((data) { + if (data.contains('Error')) print('[FFmpeg $streamId] $data'); + }); + + process.exitCode.then((code) { + print('[VOD] FFmpeg exited with code $code'); + _vodProcesses.remove(streamId); + }); + }); + + // Wait for playlist to appear (max 10s) + final playlistFile = File('${streamDir.path}/playlist.m3u8'); + int retries = 0; + while (!playlistFile.existsSync() && retries < 20) { + 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', // Correct HLS Mime + 'Access-Control-Allow-Origin': '*', + 'Cache-Control': 'no-cache', + }, + ); + }); + + // Route: /api/vod/{streamId}/segment_{n}.ts (Serve segments) + router.get('//', (Request request, String streamId, String segment) async { + final file = File('${_hlsTempDir.path}/$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', // Cache segments + }, + ); + }); + + return router; +} diff --git a/bin/server.dart b/bin/server.dart index b7d1d25..351b98d 100644 --- a/bin/server.dart +++ b/bin/server.dart @@ -9,16 +9,16 @@ import 'package:http/http.dart' as http; import 'package:args/args.dart'; import 'database/database.dart'; import 'models/user.dart'; -import 'api/auth_handler.dart'; -import 'api/users_handler.dart'; +import 'models/playlist.dart'; +import 'package:xtremflow/core/models/playlist_config.dart'; import 'api/auth_handler.dart'; import 'api/users_handler.dart'; import 'api/playlists_handler.dart'; import 'api/settings_handler.dart'; +import 'api/streaming_handler.dart'; import 'middleware/auth_middleware.dart'; import 'middleware/security_middleware.dart'; import 'services/cleanup_service.dart'; -import 'api/hls_handler.dart'; void main(List args) async { // Parse command line arguments @@ -35,6 +35,9 @@ void main(List args) async { await db.init(); await db.seedAdmin(); + // Initialize Streaming Subsystem + await initStreaming(); + // Create API handlers final authHandler = AuthHandler(db); final playlistsHandler = PlaylistsHandler(db); @@ -43,26 +46,24 @@ void main(List args) async { // Setup router final apiRouter = Router() - // Auth endpoints (no auth middleware) - full path including /api/ + // Auth endpoints ..mount('/api/auth', authHandler.router) - // Playlists endpoints (with auth middleware) + // Playlists endpoints ..mount('/api/playlists', Pipeline() .addMiddleware(authMiddleware(db)) .addHandler(playlistsHandler.router.call)) - // Users endpoints (with auth middleware) + // Users endpoints ..mount('/api/users', Pipeline() .addMiddleware(authMiddleware(db)) .addHandler(usersHandler.router.call)) - // Settings endpoints (with auth middleware) + // Settings endpoints ..mount('/api/settings', Pipeline() .addMiddleware(authMiddleware(db)) .addHandler(settingsHandler.router.call)); // Initialize Cleanup Service final cleanupService = CleanupService(); - // Target system temp directory (often used by FFmpeg/Dart) cleanupService.addTarget(Directory.systemTemp); - // Target local logs/cache if they exist cleanupService.addTarget(Directory('/app/data/logs')); cleanupService.addTarget(Directory('/app/data/tmp')); @@ -89,9 +90,8 @@ void main(List args) async { }); return router(request); - }),); + })); - // Create handlers // Create static handler final baseStaticHandler = createStaticHandler( webPath, @@ -107,8 +107,8 @@ void main(List args) async { final path = request.url.path; if (path.isEmpty || path == 'index.html' || - path.endsWith('.js') || // Disable cache for ALL JS files (main.dart.js, bootstrap, etc) - path.endsWith('.json')) { // version.json etc + path.endsWith('.js') || + path.endsWith('.json')) { return response.change(headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', 'Pragma': 'no-cache', @@ -116,30 +116,58 @@ void main(List args) async { }); } - // Allow aggressive caching for hashed assets (images, compiled JS) - // Flutter web builds usually hash main.dart.js, but let's be safe with 1 day + // Allow aggressive caching for hashed assets return response.change(headers: { 'Cache-Control': 'public, max-age=86400', }); }; - // Main handler with API proxy and FFmpeg streaming + // Helper to get playlist from request + Future _getPlaylist(Request request) async { + Playlist? playlist; + + final user = request.context['user'] as User?; + if (user != null) { + final playlists = db.getPlaylists(user.id); + if (playlists.isNotEmpty) playlist = playlists.first; + } else { + // Fallback: Try to find a playlist for any user if no specific user is logged in + final users = db.getAllUsers(); + if (users.isNotEmpty) { + final playlists = db.getPlaylists(users[0].id); + if (playlists.isNotEmpty) playlist = playlists.first; + } + } + + if (playlist != null) { + return PlaylistConfig( + id: playlist.id, + name: playlist.name, + dns: playlist.serverUrl, + username: playlist.username, + password: playlist.password, + createdAt: playlist.createdAt, + isActive: true, + ); + } + return null; + } + + // Main handler with API proxy and NEW Streaming Handlers final handler = Cascade() .add(_createApiHandler(apiRouter)) - .add(createStreamInitHandler()) // FFmpeg HLS transcoding - .add(createHlsFileHandler()) // Serve HLS files (.m3u8, .ts) - .add(_createXtreamProxyHandler()) + .add(_createXtreamProxyHandler()) /* Keep for general API calls */ + .add(createLiveStreamHandler(_getPlaylist)) + .add(createVodStreamHandler(_getPlaylist)) .add(staticHandler) .handler; - - // Add middleware final pipeline = Pipeline() .addMiddleware(logRequests()) - .addMiddleware(securityHeadersMiddleware()) // Basic Headers - .addMiddleware(honeypotMiddleware()) // Trap Bots - .addMiddleware(rateLimitMiddleware()) // Anti-DoS + .addMiddleware(securityHeadersMiddleware()) + .addMiddleware(honeypotMiddleware()) + .addMiddleware(rateLimitMiddleware()) .addMiddleware(_corsMiddleware()) .addHandler(handler); @@ -154,7 +182,6 @@ void main(List args) async { print('Serving static files from: $webPath'); print('REST API available at: /api/auth/* and /api/playlists/*'); print('Xtream proxy available at: /api/xtream/*'); - print('FFmpeg streaming available at: /api/stream/*'); // Clean expired sessions periodically (every hour) Timer.periodic(const Duration(hours: 1), (_) { @@ -163,7 +190,6 @@ void main(List args) async { }); } -/// Create API handler /// Create API handler Handler _createApiHandler(Router apiRouter) { return (Request request) async { @@ -172,8 +198,8 @@ Handler _createApiHandler(Router apiRouter) { // Only handle /api/* requests (excluding special handlers) if (path.startsWith('api/') && !path.startsWith('api/xtream/') && - !path.startsWith('api/stream/') && - !path.startsWith('api/hls/')) { + !path.startsWith('api/live/') && + !path.startsWith('api/vod/')) { return apiRouter(request); } @@ -216,25 +242,21 @@ Handler _createXtreamProxyHandler() { try { // Extract target URL from request // Format: /api/xtream/http://server:port/path - // Note: URL might be URL-encoded (e.g., http%3A%2F%2F...) String apiPath = path.substring('api/xtream/'.length); - // Decode URL if it's encoded (http%3A%2F%2F -> http://) + // Decode URL if it's encoded if (apiPath.startsWith('http%3A') || apiPath.startsWith('https%3A')) { apiPath = Uri.decodeComponent(apiPath); } - // The client will send the full URL after /api/xtream/ if (!apiPath.startsWith('http://') && !apiPath.startsWith('https://')) { return Response.badRequest( body: 'Invalid API URL. Expected format: /api/xtream/http://...', ); } - // Reconstruct the full target URL with query parameters String fullUrl = apiPath; if (request.url.query.isNotEmpty) { - // If the target URL already has query params, append with & if (fullUrl.contains('?')) { fullUrl = '$fullUrl&${request.url.query}'; } else { @@ -243,47 +265,14 @@ Handler _createXtreamProxyHandler() { } final targetUrl = Uri.parse(fullUrl); - final baseUrl = '${targetUrl.scheme}://${targetUrl.host}${targetUrl.hasPort ? ':${targetUrl.port}' : ''}'; - - // Check if this is a video file that needs streaming - final lowerPath = targetUrl.path.toLowerCase(); - - // Detect LIVE TV streams (infinite streams) - final isLiveStream = lowerPath.contains('/live/') && lowerPath.endsWith('.ts'); - - // Detect VOD files (finite, seekable) - final isVodFile = !isLiveStream && ( - lowerPath.endsWith('.mp4') || - lowerPath.endsWith('.mkv') || - lowerPath.endsWith('.avi') || - lowerPath.endsWith('.ts') || - lowerPath.endsWith('.m4v') || - lowerPath.contains('/movie/') || - lowerPath.contains('/series/')); - print('Proxying request to: $targetUrl (live: $isLiveStream, vod: $isVodFile)'); - - // For LIVE streams, use infinite streaming handler - if (isLiveStream) { - return _streamLiveChannel(targetUrl); - } - - // For VOD files, use streaming with Range support for seeking - if (isVodFile) { - final rangeHeader = request.headers['range']; - return _streamVideoFile(targetUrl, rangeHeader); - } - - // Headers to simulate a legitimate IPTV client (VLC/Kodi style) final proxyHeaders = { 'User-Agent': 'VLC/3.0.18 LibVLC/3.0.18', 'Accept': '*/*', 'Accept-Encoding': 'identity', 'Connection': 'keep-alive', - 'Icy-MetaData': '1', }; - // Use simple http.get/post for non-video content http.Response response; if (request.method == 'GET') { response = await http.get(targetUrl, headers: proxyHeaders).timeout( @@ -300,49 +289,12 @@ Handler _createXtreamProxyHandler() { return Response(405, body: 'Method not allowed'); } - // Check if this is an M3U8/HLS playlist that needs URL rewriting - final contentType = response.headers['content-type'] ?? ''; - final isM3u8 = fullUrl.endsWith('.m3u8') || - contentType.contains('application/vnd.apple.mpegurl') || - contentType.contains('application/x-mpegurl') || - contentType.contains('audio/mpegurl'); - - if (isM3u8 && response.statusCode == 200) { - // Rewrite URLs in M3U8 playlist to go through proxy - final rewrittenBody = _rewriteM3u8Urls( - response.body, - baseUrl, - targetUrl.path, - request.requestedUri.origin, - ); - - print('Rewrote M3U8 playlist URLs for: $targetUrl'); - - return Response( - response.statusCode, - body: rewrittenBody, - headers: { - 'content-type': 'application/vnd.apple.mpegurl', - 'access-control-allow-origin': '*', - }, - ); - } - return Response( response.statusCode, body: response.bodyBytes, headers: { 'content-type': response.headers['content-type'] ?? 'application/json', 'access-control-allow-origin': '*', - // Forward cache headers from upstream - if (response.headers.containsKey('cache-control')) - 'cache-control': response.headers['cache-control']!, - if (response.headers.containsKey('expires')) - 'expires': response.headers['expires']!, - if (response.headers.containsKey('etag')) - 'etag': response.headers['etag']!, - if (response.headers.containsKey('last-modified')) - 'last-modified': response.headers['last-modified']!, }, ); } catch (e, stackTrace) { @@ -355,489 +307,3 @@ Handler _createXtreamProxyHandler() { } }; } - -/// Stream video file using HttpClient with Range support for seeking -Future _streamVideoFile(Uri targetUrl, String? rangeHeader) async { - final maxRedirects = 5; - var currentUrl = targetUrl; - final cookies = {}; // Cookie Jar for redirects - - print('Stream Request: $targetUrl'); - if (rangeHeader != null) print('Client Requested Range: $rangeHeader'); - - for (var i = 0; i < maxRedirects; i++) { - try { - final client = HttpClient(); - // Increase timeouts for large video files - client.connectionTimeout = const Duration(seconds: 60); - client.idleTimeout = const Duration(minutes: 5); - - final req = await client.getUrl(currentUrl); - req.followRedirects = false; // We handle redirects manually - - req.headers.set('User-Agent', 'VLC/3.0.18 LibVLC/3.0.18'); - req.headers.set('Accept', '*/*'); - req.headers.set('Connection', 'keep-alive'); - req.headers.set('Accept-Encoding', 'identity'); - - // Apply collected cookies - if (cookies.isNotEmpty) { - final cookieHeader = cookies.entries.map((e) => '${e.key}=${e.value}').join('; '); - req.headers.set(HttpHeaders.cookieHeader, cookieHeader); - } - - // Forward request headers - if (rangeHeader != null && rangeHeader.isNotEmpty) { - if (!rangeHeader.startsWith('bytes=')) { - rangeHeader = 'bytes=$rangeHeader'; - } - req.headers.set(HttpHeaders.rangeHeader, rangeHeader); - } - - client.autoUncompress = false; - - final response = await req.close(); - print('Upstream Response: ${response.statusCode} (Redirect $i)'); - - // Collect Cookies from response - response.cookies.forEach((cookie) { - cookies[cookie.name] = cookie.value; - }); - - // Handle Redirects (301, 302, 303, 307, 308) - if (response.statusCode >= 300 && response.statusCode < 400) { - final location = response.headers.value(HttpHeaders.locationHeader); - if (location != null) { - print('Redirecting stream to: $location'); - currentUrl = Uri.parse(location); - await response.drain(); // Drain body to release connection - continue; // Loop to next request - } - } - - // Get content type - final contentType = response.headers.contentType?.mimeType ?? 'video/mp4'; - - // Build response headers - final responseHeaders = { - 'content-type': contentType, - 'access-control-allow-origin': '*', - 'accept-ranges': 'bytes', - 'cache-control': 'no-cache', - 'connection': 'keep-alive', - }; - - // Checks for 206 vs 200 - if (response.statusCode == 200 && rangeHeader != null) { - print('WARNING: Upstream ignored Range header and returned 200. Seeking might fail.'); - } else if (response.statusCode == 206) { - print('SUCCESS: Upstream returned 206 Partial Content.'); - } - - // Add Content-Length if available - if (response.contentLength > 0) { - responseHeaders['content-length'] = response.contentLength.toString(); - } - - // Add Content-Range if this is a partial response (206) - final contentRange = response.headers.value(HttpHeaders.contentRangeHeader); - if (contentRange != null) { - responseHeaders[HttpHeaders.contentRangeHeader] = contentRange; - } - - // Propagate Accept-Ranges - responseHeaders[HttpHeaders.acceptRangesHeader] = 'bytes'; - - // Return appropriate status code - return Response( - response.statusCode, - body: response, - headers: responseHeaders, - ); - - } catch (e) { - print('Video streaming exception: $e'); - if (i == maxRedirects - 1) { - return Response.internalServerError( - body: jsonEncode({'error': 'Video streaming error', 'message': e.toString()}), - headers: {'content-type': 'application/json'}, - ); - } - } - } - - return Response.internalServerError(body: 'Too many redirects'); -} - -/// Stream LIVE TV channel with persistent connection (no Content-Length) -/// -/// Unlike VOD streaming, live streams are infinite and should not close -/// the connection until the client disconnects. -Future _streamLiveChannel(Uri targetUrl) async { - final maxRedirects = 5; - var currentUrl = targetUrl; - final cookies = {}; - - print('Live Stream Request: $targetUrl'); - - for (var i = 0; i < maxRedirects; i++) { - try { - final client = HttpClient(); - // CRITICAL: Very long timeouts for live streaming - client.connectionTimeout = const Duration(minutes: 2); - client.idleTimeout = const Duration(hours: 2); // Keep alive for 2 hours - - final req = await client.getUrl(currentUrl); - req.followRedirects = false; - req.persistentConnection = true; // Keep connection alive - - // IPTV-compatible headers - req.headers.set('User-Agent', 'VLC/3.0.18 LibVLC/3.0.18'); - req.headers.set('Accept', '*/*'); - req.headers.set('Connection', 'keep-alive'); - req.headers.set('Accept-Encoding', 'identity'); - req.headers.set('Icy-MetaData', '1'); // IPTV metadata support - - // Apply cookies from redirects - if (cookies.isNotEmpty) { - final cookieHeader = cookies.entries.map((e) => '${e.key}=${e.value}').join('; '); - req.headers.set(HttpHeaders.cookieHeader, cookieHeader); - } - - client.autoUncompress = false; - - final response = await req.close(); - print('Live Stream Response: ${response.statusCode} from $currentUrl'); - - // Collect cookies - response.cookies.forEach((cookie) { - cookies[cookie.name] = cookie.value; - }); - - // Handle redirects - if (response.statusCode >= 300 && response.statusCode < 400) { - final location = response.headers.value(HttpHeaders.locationHeader); - if (location != null) { - print('Live stream redirecting to: $location'); - currentUrl = Uri.parse(location); - await response.drain(); - continue; - } - } - - // Get content type - final contentType = response.headers.contentType?.mimeType ?? 'video/MP2T'; - - print('Live stream connected: $contentType'); - - // Use StreamController to pipe the live stream to the client - // This keeps the connection open and actively streams data - final controller = StreamController>(); - - // Listen to the upstream response and forward data to the client - response.listen( - (data) { - if (!controller.isClosed) { - controller.add(data); - } - }, - onError: (error) { - print('Live stream error: $error'); - if (!controller.isClosed) { - controller.addError(error); - } - }, - onDone: () { - print('Live stream upstream ended'); - if (!controller.isClosed) { - controller.close(); - } - }, - cancelOnError: false, - ); - - // When client disconnects, close the controller - controller.onCancel = () { - print('Live stream client disconnected'); - // Note: The response stream will be garbage collected - }; - - // Response headers for live streaming (NO Content-Length!) - final responseHeaders = { - 'content-type': contentType, - 'access-control-allow-origin': '*', - 'cache-control': 'no-cache, no-store', - 'connection': 'keep-alive', - // Don't set transfer-encoding or content-length for raw binary streams - }; - - // Return streaming response - return Response.ok( - controller.stream, - headers: responseHeaders, - ); - - } catch (e) { - print('Live streaming exception: $e'); - if (i == maxRedirects - 1) { - return Response.internalServerError( - body: jsonEncode({'error': 'Live streaming error', 'message': e.toString()}), - headers: {'content-type': 'application/json'}, - ); - } - } - } - - return Response.internalServerError(body: 'Too many redirects'); -} - -/// Rewrite URLs in M3U8 playlist to go through the proxy -/// -/// Handles both relative and absolute URLs in HLS playlists -String _rewriteM3u8Urls(String m3u8Content, String baseUrl, String originalPath, String proxyOrigin) { - final lines = m3u8Content.split('\n'); - final rewrittenLines = []; - - // Get directory path for relative URL resolution - final pathSegments = originalPath.split('/'); - pathSegments.removeLast(); // Remove filename - final basePath = pathSegments.join('/'); - - for (final line in lines) { - final trimmedLine = line.trim(); - - // Skip empty lines and comments (except URI in comments) - if (trimmedLine.isEmpty) { - rewrittenLines.add(line); - continue; - } - - // Handle lines that contain URLs (not starting with #, or EXT-X-KEY/EXT-X-MAP with URI) - if (!trimmedLine.startsWith('#')) { - // This is a segment URL - final rewrittenUrl = _rewriteUrl(trimmedLine, baseUrl, basePath, proxyOrigin); - rewrittenLines.add(rewrittenUrl); - } else if (trimmedLine.contains('URI="')) { - // Handle EXT-X-KEY, EXT-X-MAP, etc. with URI attribute - final rewrittenLine = trimmedLine.replaceAllMapped( - RegExp(r'URI="([^"]+)"'), - (match) { - final uri = match.group(1)!; - final rewrittenUri = _rewriteUrl(uri, baseUrl, basePath, proxyOrigin); - return 'URI="$rewrittenUri"'; - }, - ); - rewrittenLines.add(rewrittenLine); - } else { - rewrittenLines.add(line); - } - } - - return rewrittenLines.join('\n'); -} - -/// Rewrite a single URL to go through the proxy -String _rewriteUrl(String url, String baseUrl, String basePath, String proxyOrigin) { - String fullUrl; - - if (url.startsWith('http://') || url.startsWith('https://')) { - // Already absolute URL - fullUrl = url; - } else if (url.startsWith('/')) { - // Absolute path, relative to server root - fullUrl = '$baseUrl$url'; - } else { - // Relative path, relative to current directory - fullUrl = '$baseUrl$basePath/$url'; - } - - // Wrap with proxy - return '$proxyOrigin/api/xtream/$fullUrl'; -} - -// ============================================ -// FFmpeg Stream Transcoding Handler -// ============================================ - -/// Active FFmpeg processes by stream ID -final Map _activeStreams = {}; - -/// Create handler for FFmpeg streaming -/// -/// This endpoint starts an FFmpeg process that connects to the IPTV server -/// with proper headers and transcodes the stream to local HLS files. -Handler _createStreamHandler() { - return (Request request) async { - final path = request.url.path; - - // Only handle /api/stream/* requests - if (!path.startsWith('api/stream/')) { - return Response.notFound('Not found'); - } - - try { - // Extract stream ID - final pathParts = path.split('/'); - if (pathParts.length < 3) { - return Response.badRequest(body: 'Invalid stream path'); - } - - final streamId = pathParts[2]; - - // Get parameters - final iptvUrl = request.url.queryParameters['url']; - final quality = request.url.queryParameters['quality'] ?? 'high'; - final start = request.url.queryParameters['start']; // Start time in seconds - - if (iptvUrl == null || iptvUrl.isEmpty) { - return Response.badRequest(body: 'Missing url parameter'); - } - - print('Starting Direct Stream for ID: $streamId'); - print('Source: $iptvUrl'); - - // Setup FFmpeg arguments for fMP4 piping - // Determine if it's VOD or Live - final isVod = streamId.startsWith('vod_'); // Or use extension check - - final ffmpegArgs = [ - '-hide_banner', - '-loglevel', 'error', // Minimize logs, only errors - '-user_agent', 'VLC/3.0.18 LibVLC/3.0.18', - '-headers', 'Accept: */*\r\nConnection: keep-alive\r\n', - - // Input resilience flags - '-reconnect', '1', - '-reconnect_streamed', '1', - '-reconnect_delay_max', '10', - '-reconnect_on_network_error', '1', - '-reconnect_on_http_error', '4xx,5xx', - '-rw_timeout', '15000000', // 15s timeout - - // Probing settings to ensure stream detection - '-analyzeduration', '20000000', // 20s - '-probesize', '10000000', // 10MB - - '-i', iptvUrl, - - // Output format: MPEG-TS for streaming (compatible with mpegts.js) - '-f', 'mpegts', - ]; - - // Add seeking support if start time is provided - if (start != null && start.isNotEmpty) { - // Insert -ss before -i for faster seeking (input seeking) - final inputIndex = ffmpegArgs.indexOf('-i'); - if (inputIndex != -1) { - ffmpegArgs.insertAll(inputIndex, ['-ss', start]); - } - } - - // Video: Transcode based on quality - // High = Copy (efficient), Low/Medium = Transcode to H.264 (compatible) - if (quality == 'high') { - ffmpegArgs.addAll(['-c:v', 'copy']); - } else { - // Transcode to H.264 (Baseline profile for max mobile compatibility) - ffmpegArgs.addAll([ - '-c:v', 'libx264', - '-preset', 'ultrafast', // Critical for live streaming - '-tune', 'zerolatency', - '-profile:v', 'baseline', - '-level', '3.0', - '-pix_fmt', 'yuv420p', // Ensure standard pixel format for web - '-g', '60', // Keyframe interval (2s at 30fps) for seeking - ]); - - if (quality == 'low') { - // Low: ~480p, 800k - ffmpegArgs.addAll([ - '-vf', 'scale=-2:480', - '-b:v', '800k', - '-maxrate', '800k', - '-bufsize', '1600k', - ]); - } else { - // Medium: Original res (or cap at 720p?), 2500k - ffmpegArgs.addAll([ - '-b:v', '2500k', - '-maxrate', '2500k', - '-bufsize', '5000k', - ]); - } - } - - // Audio: Transcode to AAC is SAFEST for browsers - // (Many IPTV streams typically use MP2/AC3 which browsers hate) - ffmpegArgs.addAll([ - '-c:a', 'aac', - '-b:a', '128k', - '-ar', '44100', - '-ac', '2', - - // Fix for MPEG-TS timestamps - '-fflags', '+genpts', - - // Output to stdout pipe - 'pipe:1', - ]); - - // Start the process - final process = await Process.start('ffmpeg', ffmpegArgs); - - // Manage process lifecycle - // We use a StreamController to pipe stdout to the response - // AND detect when the client disconnects to kill the process. - final controller = StreamController>(); - - // Pipe stdout to controller - process.stdout.listen( - (data) { - if (!controller.isClosed) controller.add(data); - }, - onError: (e) { - print('FFmpeg stdout error: $e'); - if (!controller.isClosed) controller.addError(e); - }, - onDone: () { - print('FFmpeg process finished for $streamId'); - if (!controller.isClosed) controller.close(); - }, - ); - - // Log stderr for debug - process.stderr.transform(utf8.decoder).listen((data) { - if (data.contains('Error') || data.contains('error')) { - print('FFmpeg Error [$streamId]: $data'); - } - }); - - // CLEANUP: When the HTTP client disconnects, the stream subscription - // will be cancelled. We MUST kill the FFmpeg process then. - controller.onCancel = () { - print('Client disconnected for $streamId. Killing FFmpeg...'); - process.kill(); - }; - - return Response.ok( - controller.stream, - headers: { - 'Content-Type': 'video/MP2T', - 'Access-Control-Allow-Origin': '*', - 'Cache-Control': 'no-cache, no-store', - 'Connection': 'keep-alive', - }, - ); - - } catch (e, stackTrace) { - print('Stream setup error: $e'); - print(stackTrace); - return Response.internalServerError( - body: jsonEncode({'error': 'Stream setup error', 'message': e.toString()}), - headers: {'content-type': 'application/json'}, - ); - } - }; -} - - diff --git a/lib/features/iptv/providers/settings_provider.dart b/lib/features/iptv/providers/settings_provider.dart index c4325fb..eb3733b 100644 --- a/lib/features/iptv/providers/settings_provider.dart +++ b/lib/features/iptv/providers/settings_provider.dart @@ -62,7 +62,6 @@ class _SettingsKeys { static const String showClock = 'show_clock'; static const String preferredAspectRatio = 'aspect_ratio'; static const String playerType = 'player_type'; - static const String forceVodTranscoding = 'force_vod_transcoding'; } /// Settings state for IPTV preferences with persistence @@ -86,8 +85,6 @@ class IptvSettings { final String preferredAspectRatio; final PlayerType playerType; - // VOD Settings - final bool forceVodTranscoding; const IptvSettings({ // Filters @@ -106,8 +103,6 @@ class IptvSettings { this.showClock = false, this.preferredAspectRatio = 'contain', this.playerType = PlayerType.standard, - // VOD defaults - true by default to avoid 509 errors from IPTV servers - this.forceVodTranscoding = true, }); IptvSettings copyWith({ @@ -124,7 +119,6 @@ class IptvSettings { bool? showClock, String? preferredAspectRatio, PlayerType? playerType, - bool? forceVodTranscoding, }) { return IptvSettings( liveTvCategoryFilter: liveTvCategoryFilter ?? this.liveTvCategoryFilter, @@ -140,7 +134,6 @@ class IptvSettings { showClock: showClock ?? this.showClock, preferredAspectRatio: preferredAspectRatio ?? this.preferredAspectRatio, playerType: playerType ?? this.playerType, - forceVodTranscoding: forceVodTranscoding ?? this.forceVodTranscoding, ); } @@ -337,7 +330,7 @@ class IptvSettingsNotifier extends StateNotifier { _SettingsKeys.showClock: state.showClock, _SettingsKeys.preferredAspectRatio: state.preferredAspectRatio, _SettingsKeys.playerType: state.playerType.index, - _SettingsKeys.forceVodTranscoding: state.forceVodTranscoding, + _SettingsKeys.playerType: state.playerType.index, }; print('[SettingsProvider] _saveToApi: Saving settings...'); @@ -432,12 +425,6 @@ class IptvSettingsNotifier extends StateNotifier { // Legacy methods void setCategoryFilter(String filter) => setLiveTvFilter(filter); void clearCategoryFilter() => clearLiveTvFilter(); - - // VOD Settings - void setForceVodTranscoding(bool value) { - state = state.copyWith(forceVodTranscoding: value); - _saveToApi(); - } } /// Provider for IPTV settings diff --git a/lib/features/iptv/screens/player_screen.dart b/lib/features/iptv/screens/player_screen.dart index 1635f05..798c38e 100644 --- a/lib/features/iptv/screens/player_screen.dart +++ b/lib/features/iptv/screens/player_screen.dart @@ -99,20 +99,18 @@ class _PlayerScreenState extends ConsumerState { _viewId = 'iptv-player-$currentStreamId-${DateTime.now().millisecondsSinceEpoch}'; + final service = ref.read(xtreamServiceProvider(widget.playlist)); String streamUrl = ''; - // Get settings for VOD transcoding preference - final playerSettings = ref.read(iptvSettingsProvider); - final forceTranscoding = playerSettings.forceVodTranscoding; - // Generate Stream URL based on type + // NOTE: Logic is now handled by the backend routes /api/live/ and /api/vod/ if (widget.streamType == StreamType.live) { streamUrl = service.getLiveStreamUrl(currentStreamId); } else if (widget.streamType == StreamType.vod) { - streamUrl = service.getVodStreamUrl(currentStreamId, widget.containerExtension, forceTranscoding: forceTranscoding); + streamUrl = service.getVodStreamUrl(currentStreamId, widget.containerExtension); } else if (widget.streamType == StreamType.series) { - streamUrl = service.getSeriesStreamUrl(currentStreamId, widget.containerExtension, forceTranscoding: forceTranscoding); + streamUrl = service.getSeriesStreamUrl(currentStreamId, widget.containerExtension); } // Store URL for Lite Player diff --git a/lib/features/iptv/services/xtream_service.dart b/lib/features/iptv/services/xtream_service.dart index 81f21f7..48a0d70 100644 --- a/lib/features/iptv/services/xtream_service.dart +++ b/lib/features/iptv/services/xtream_service.dart @@ -58,57 +58,30 @@ class XtreamService { } /// Generate stream URL for live TV - /// - /// Returns the proxy URL for direct MPEG-TS streaming. - /// Player uses mpegts.js for playback. String getLiveStreamUrl(String streamId) { if (_currentPlaylist == null) throw Exception('No playlist configured'); - // Direct .ts URL for MPEG-TS streaming - final iptvUrl = '${_currentPlaylist!.dns}/live/${_currentPlaylist!.username}/${_currentPlaylist!.password}/$streamId.ts'; - - // Use proxy for direct streaming - return _wrapWithProxy(iptvUrl); + // Point to new dedicated Live API + final baseUrl = html.window.location.origin; + return '$baseUrl/api/live/$streamId.ts'; } /// Generate stream URL for VOD (movies) - /// - /// [forceTranscoding] - If true, uses FFmpeg to transcode audio (guaranteed sound) - /// If false, uses direct URL (enables seeking but may lack audio on AC3/DTS) - String getVodStreamUrl(String streamId, String containerExtension, {bool forceTranscoding = false}) { + String getVodStreamUrl(String streamId, String containerExtension) { if (_currentPlaylist == null) throw Exception('No playlist configured'); - final url = '${_currentPlaylist!.dns}/movie/${_currentPlaylist!.username}/${_currentPlaylist!.password}/$streamId.$containerExtension'; - - if (forceTranscoding) { - // Use FFmpeg transcoding for guaranteed audio (no seeking) - final baseUrl = html.window.location.origin; - final encodedUrl = Uri.encodeComponent(url); - return '$baseUrl/api/stream/$streamId?url=$encodedUrl'; - } else { - // Direct URL for seeking (audio only if AAC/MP3) - return _wrapWithProxy(url); - } + // Point to new dedicated VOD API (HLS playlist) + final baseUrl = html.window.location.origin; + return '$baseUrl/api/vod/$streamId/playlist.m3u8'; } /// Generate stream URL for series episodes - /// - /// [forceTranscoding] - If true, uses FFmpeg to transcode audio (guaranteed sound) - /// If false, uses direct URL (enables seeking but may lack audio on AC3/DTS) - String getSeriesStreamUrl(String streamId, String containerExtension, {bool forceTranscoding = false}) { + String getSeriesStreamUrl(String streamId, String containerExtension) { if (_currentPlaylist == null) throw Exception('No playlist configured'); - final url = '${_currentPlaylist!.dns}/series/${_currentPlaylist!.username}/${_currentPlaylist!.password}/$streamId.$containerExtension'; - - if (forceTranscoding) { - // Use FFmpeg transcoding for guaranteed audio (no seeking) - final baseUrl = html.window.location.origin; - final encodedUrl = Uri.encodeComponent(url); - return '$baseUrl/api/stream/$streamId?url=$encodedUrl'; - } else { - // Direct URL for seeking (audio only if AAC/MP3) - return _wrapWithProxy(url); - } + // Point to new dedicated VOD API (HLS playlist) + final baseUrl = html.window.location.origin; + return '$baseUrl/api/vod/$streamId/playlist.m3u8'; } /// Authenticate and get server info diff --git a/web/player.html b/web/player.html index 87d5673..6e71610 100644 --- a/web/player.html +++ b/web/player.html @@ -1,544 +1,98 @@ - - IPTV Player - + XtremFlow Player + - - -
- - -
-
-
Loading stream...
-
-
-
⚠
-
Failed to load stream
-
-
+ + + + - - -