diff --git a/bin/api/streaming_handler.dart b/bin/api/streaming_handler.dart index 18e3961..549b6f4 100644 --- a/bin/api/streaming_handler.dart +++ b/bin/api/streaming_handler.dart @@ -5,20 +5,22 @@ import 'dart:math'; import 'package:shelf/shelf.dart'; import 'package:shelf_router/shelf_router.dart'; import 'package:http/http.dart' as http; -import 'package:xtremflow/core/models/playlist_config.dart'; +import '../models/playlist_config.dart'; /// Active FFmpeg processes for VOD transcoding final Map _vodProcesses = {}; /// Directory for temporary HLS segments -final Directory _hlsTempDir = Directory('${Directory.systemTemp.path}/xtremflow_streams'); +final Directory _hlsTempDir = + Directory('${Directory.systemTemp.path}/xtremflow_streams'); /// Helper to resolve FFmpeg path (SYSTEM PATH vs Portable) String _getFFmpegPath() { if (Platform.isWindows) { if (File('ffmpeg.exe').existsSync()) return 'ffmpeg.exe'; if (File('bin/ffmpeg.exe').existsSync()) return 'bin/ffmpeg.exe'; - if (File('ffmpeg/bin/ffmpeg.exe').existsSync()) return 'ffmpeg/bin/ffmpeg.exe'; + if (File('ffmpeg/bin/ffmpeg.exe').existsSync()) + return 'ffmpeg/bin/ffmpeg.exe'; } return 'ffmpeg'; // Default to PATH } @@ -42,13 +44,14 @@ PlaylistConfig? _getCurrentPlaylist(Request request) { // 1. LIVE TV HANDLER (Direct MPEG-TS Proxy) // ========================================== -Handler createLiveStreamHandler(Future Function(Request) getPlaylist) { +Handler createLiveStreamHandler( + Future Function(Request) getPlaylist) { 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 .ts extension if present // Remove extension if present (handle both .ts and .m3u8 just in case) if (streamId.endsWith('.ts')) { @@ -58,13 +61,14 @@ Handler createLiveStreamHandler(Future Function(Request) getPla } final playlist = await getPlaylist(request); - + if (playlist == null) { print('[Live] Error: No playlist found'); return Response.internalServerError(body: 'No playlist configured'); } - - final targetUrl = '${playlist.dns}/live/${playlist.username}/${playlist.password}/$streamId.ts'; + + final targetUrl = + '${playlist.dns}/live/${playlist.username}/${playlist.password}/$streamId.ts'; print('[Live] Streaming via FFmpeg: $targetUrl'); // Start FFmpeg to proxy and stabilize the stream @@ -72,7 +76,7 @@ Handler createLiveStreamHandler(Future Function(Request) getPla final ffmpegArgs = [ '-hide_banner', '-loglevel', 'error', '-headers', 'User-Agent: VLC/3.0.18 LibVLC/3.0.18\r\n', - + // Robustness flags (Same as VOD) '-reconnect', '1', '-reconnect_at_eof', '1', @@ -81,9 +85,9 @@ Handler createLiveStreamHandler(Future Function(Request) getPla '-rw_timeout', '15000000', '-analyzeduration', '5000000', // Faster probe for live '-probesize', '5000000', - + '-i', targetUrl, - + '-c', 'copy', // Direct stream copy (Very low CPU) '-f', 'mpegts', 'pipe:1', // Output to stdout @@ -92,7 +96,7 @@ Handler createLiveStreamHandler(Future Function(Request) getPla try { final ffmpegPath = _getFFmpegPath(); final process = await Process.start(ffmpegPath, ffmpegArgs); - + // Cleanup handling: Kill FFmpeg when client disconnects final controller = StreamController>( onCancel: () { @@ -100,20 +104,20 @@ Handler createLiveStreamHandler(Future Function(Request) getPla process.kill(); }, ); - + // Pipe stdout to controller process.stdout.listen( (data) => controller.add(data), onDone: () { - print('[Live] FFmpeg stream ended for $streamId'); - controller.close(); + print('[Live] FFmpeg stream ended for $streamId'); + controller.close(); }, onError: (e) { - print('[Live] FFmpeg stream error: $e'); - controller.addError(e); + print('[Live] FFmpeg stream error: $e'); + controller.addError(e); }, ); - + // Log errors process.stderr.transform(utf8.decoder).listen((data) { print('[FFmpeg Live $streamId] $data'); @@ -146,20 +150,23 @@ Handler createLiveStreamHandler(Future Function(Request) getPla final _lastLogTime = {}; -Handler createVodStreamHandler(Future Function(Request) getPlaylist) { +Handler createVodStreamHandler( + Future Function(Request) getPlaylist) { final router = Router(); // Route: /api/vod/{streamId}/playlist.m3u8 - router.get('//playlist.m3u8', (Request request, String streamId) async { + router.get('//playlist.m3u8', + (Request request, String streamId) async { final playlist = await getPlaylist(request); - if (playlist == null) return Response.internalServerError(body: 'No playlist'); + if (playlist == null) + return Response.internalServerError(body: 'No playlist'); final streamDir = Directory('${_hlsTempDir.path}/$streamId'); - + // Check if existing session is healthy if (_vodProcesses.containsKey(streamId)) { final existingProcess = _vodProcesses[streamId]!; - + // Check if process is still running by checking if playlist exists and has content final playlistFile = File('${streamDir.path}/playlist.m3u8'); if (playlistFile.existsSync()) { @@ -168,9 +175,10 @@ Handler createVodStreamHandler(Future Function(Request) getPlay if (content.contains('.ts')) { // Only log once per second to avoid spam final now = DateTime.now().millisecondsSinceEpoch; - if (!_lastLogTime.containsKey(streamId) || (now - _lastLogTime[streamId]! > 2000)) { - print('[VOD] Reusing existing session for $streamId'); - _lastLogTime[streamId] = now; + if (!_lastLogTime.containsKey(streamId) || + (now - _lastLogTime[streamId]! > 2000)) { + print('[VOD] Reusing existing session for $streamId'); + _lastLogTime[streamId] = now; } // Serve existing playlist return Response.ok( @@ -183,7 +191,7 @@ Handler createVodStreamHandler(Future Function(Request) getPlay ); } } - + // Playlist missing or empty - process likely failed, clean up print('[VOD] Cleaning up stale session for $streamId'); try { @@ -192,7 +200,7 @@ Handler createVodStreamHandler(Future Function(Request) getPlay // Process may already be dead } _vodProcesses.remove(streamId); - + // Clean up directory if (streamDir.existsSync()) { try { @@ -203,82 +211,84 @@ Handler createVodStreamHandler(Future Function(Request) getPlay } } - // Build Upstream URL based on content type // Check for type parameter in request URL (?type=series or ?type=movie) final contentType = request.url.queryParameters['type'] ?? 'movie'; final basePath = contentType == 'series' ? 'series' : 'movie'; - + // Only start FFmpeg if not already running if (!_vodProcesses.containsKey(streamId)) { - // Prepare directory (Only for new session) - if (streamDir.existsSync()) { - streamDir.deleteSync(recursive: true); - } - streamDir.createSync(recursive: true); + // Prepare directory (Only for new session) + if (streamDir.existsSync()) { + streamDir.deleteSync(recursive: true); + } + streamDir.createSync(recursive: true); - final targetUrl = '${playlist.dns}/$basePath/${playlist.username}/${playlist.password}/$streamId.mkv'; - print('[VOD] Starting Transcode ($contentType): $targetUrl'); + final targetUrl = + '${playlist.dns}/$basePath/${playlist.username}/${playlist.password}/$streamId.mkv'; + print('[VOD] Starting Transcode ($contentType): $targetUrl'); - // Start FFmpeg - final ffmpegArgs = [ - '-hide_banner', '-loglevel', 'error', - '-headers', 'User-Agent: VLC/3.0.18 LibVLC/3.0.18\r\n', - - // Robustness for network streams - '-reconnect', '1', - '-reconnect_at_eof', '1', - '-reconnect_streamed', '1', - '-reconnect_delay_max', '5', - '-rw_timeout', '15000000', - '-analyzeduration', '2000000', // Reduced from 10M to 2M for faster start - '-probesize', '2000000', // Reduced from 10M to 2M for faster start - - '-i', targetUrl, - - // Video: H.264 Ultrafast (Low CPU) - '-c:v', 'libx264', - '-preset', 'ultrafast', - '-tune', 'zerolatency', // Optimize for low latency startup - '-tune', 'fastdecode', - '-crf', '23', - '-maxrate', '3000k', - '-bufsize', '6000k', - '-pix_fmt', 'yuv420p', - '-threads', '0', + // Start FFmpeg + final ffmpegArgs = [ + '-hide_banner', '-loglevel', 'error', + '-headers', 'User-Agent: VLC/3.0.18 LibVLC/3.0.18\r\n', - // Audio: AAC (Browser compatible, improved quality) - '-c:a', 'aac', - '-b:a', '192k', - '-ar', '48000', - - // Advanced Audio Filtering: - // 1. "pan": Downmix 5.1/7.1 to Stereo with center mix (dialogue) boosted. - // 2. "dynaudnorm": Dynamic Audio Normalizer to boost quiet dialogue and limit loud explosions (Night Mode effect). - '-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', + // Robustness for network streams + '-reconnect', '1', + '-reconnect_at_eof', '1', + '-reconnect_streamed', '1', + '-reconnect_delay_max', '5', + '-rw_timeout', '15000000', + '-analyzeduration', + '2000000', // Reduced from 10M to 2M for faster start + '-probesize', '2000000', // Reduced from 10M to 2M for faster start + '-i', targetUrl, - '-f', 'hls', - '-hls_time', '4', - '-hls_list_size', '0', - '-hls_playlist_type', 'event', // Event type allows immediate playback while transcoding - '-hls_allow_cache', '1', - '-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', - ]; + // Video: H.264 Ultrafast (Low CPU) + '-c:v', 'libx264', + '-preset', 'ultrafast', + '-tune', 'zerolatency', // Optimize for low latency startup + '-tune', 'fastdecode', + '-crf', '23', + '-maxrate', '3000k', + '-bufsize', '6000k', + '-pix_fmt', 'yuv420p', + '-threads', '0', - final ffmpegPath = _getFFmpegPath(); - Process.start(ffmpegPath, ffmpegArgs).then((process) { + // Audio: AAC (Browser compatible, improved quality) + '-c:a', 'aac', + '-b:a', '192k', + '-ar', '48000', + + // Advanced Audio Filtering: + // 1. "pan": Downmix 5.1/7.1 to Stereo with center mix (dialogue) boosted. + // 2. "dynaudnorm": Dynamic Audio Normalizer to boost quiet dialogue and limit loud explosions (Night Mode effect). + '-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', // Event type allows immediate playback while transcoding + '-hls_allow_cache', '1', + '-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', + ]; + + final ffmpegPath = _getFFmpegPath(); + Process.start(ffmpegPath, ffmpegArgs).then((process) { _vodProcesses[streamId] = process; - + process.stderr.transform(utf8.decoder).listen((data) { // Log only major errors or startup info to keep logs clean(er) // or keep verbose if debugging - print('[FFmpeg $streamId] $data'); + print('[FFmpeg $streamId] $data'); }); process.exitCode.then((code) { @@ -297,7 +307,8 @@ Handler createVodStreamHandler(Future Function(Request) getPlay } if (!playlistFile.existsSync()) { - return Response.internalServerError(body: 'Timeout waiting for transcoder'); + return Response.internalServerError( + body: 'Timeout waiting for transcoder'); } return Response.ok( @@ -311,9 +322,10 @@ Handler createVodStreamHandler(Future Function(Request) getPlay }); // Route: /api/vod/{streamId}/segment_{n}.ts (Serve segments) - router.get('//', (Request request, String streamId, String segment) async { + 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'); } diff --git a/bin/models/playlist_config.dart b/bin/models/playlist_config.dart new file mode 100644 index 0000000..b63248c --- /dev/null +++ b/bin/models/playlist_config.dart @@ -0,0 +1,52 @@ +/// Pure Dart model for Xtream Codes playlist configuration +/// This is a server-side copy without Hive/Equatable dependencies +class PlaylistConfig { + final String id; + final String name; + + /// Xtream server URL (without trailing slash) + final String dns; + + final String username; + + /// Xtream password + final String password; + + final DateTime createdAt; + + final bool isActive; + + const PlaylistConfig({ + required this.id, + required this.name, + required this.dns, + required this.username, + required this.password, + required this.createdAt, + this.isActive = true, + }); + + /// Creates a copy of this playlist with updated fields + PlaylistConfig copyWith({ + String? id, + String? name, + String? dns, + String? username, + String? password, + DateTime? createdAt, + bool? isActive, + }) { + return PlaylistConfig( + id: id ?? this.id, + name: name ?? this.name, + dns: dns ?? this.dns, + username: username ?? this.username, + password: password ?? this.password, + createdAt: createdAt ?? this.createdAt, + isActive: isActive ?? this.isActive, + ); + } + + /// Generate Xtream API base URL + String get apiBaseUrl => '$dns/player_api.php'; +} diff --git a/bin/server.dart b/bin/server.dart index fb6012a..26b6890 100644 --- a/bin/server.dart +++ b/bin/server.dart @@ -10,7 +10,7 @@ import 'package:args/args.dart'; import 'database/database.dart'; import 'models/user.dart'; import 'models/playlist.dart'; -import 'package:xtremflow/core/models/playlist_config.dart'; +import 'models/playlist_config.dart'; import 'api/auth_handler.dart'; import 'api/users_handler.dart'; import 'api/playlists_handler.dart'; @@ -25,7 +25,7 @@ void main(List args) async { final parser = ArgParser() ..addOption('port', abbr: 'p', defaultsTo: '8089') ..addOption('path', defaultsTo: '/app/web'); - + final result = parser.parse(args); final port = int.parse(result['port']); final webPath = result['path']; @@ -49,48 +49,61 @@ void main(List args) async { // Auth endpoints ..mount('/api/auth', authHandler.router) // Playlists endpoints - ..mount('/api/playlists', const Pipeline() - .addMiddleware(authMiddleware(db)) - .addHandler(playlistsHandler.router.call),) + ..mount( + '/api/playlists', + const Pipeline() + .addMiddleware(authMiddleware(db)) + .addHandler(playlistsHandler.router.call), + ) // Users endpoints - ..mount('/api/users', const Pipeline() - .addMiddleware(authMiddleware(db)) - .addHandler(usersHandler.router.call),) + ..mount( + '/api/users', + const Pipeline() + .addMiddleware(authMiddleware(db)) + .addHandler(usersHandler.router.call), + ) // Settings endpoints - ..mount('/api/settings', const Pipeline() - .addMiddleware(authMiddleware(db)) - .addHandler(settingsHandler.router.call),); + ..mount( + '/api/settings', + const Pipeline() + .addMiddleware(authMiddleware(db)) + .addHandler(settingsHandler.router.call), + ); // Initialize Cleanup Service final cleanupService = CleanupService(); cleanupService.addTarget(Directory.systemTemp); cleanupService.addTarget(Directory('/app/data/logs')); cleanupService.addTarget(Directory('/app/data/tmp')); - + cleanupService.start(); // Admin Routes (protected) - apiRouter.mount('/api/admin', const Pipeline() - .addMiddleware(authMiddleware(db)) - .addHandler((Request request) { - final router = Router(); - - // POST /api/admin/purge - router.post('/purge', (Request req) async { - final user = req.context['user'] as User?; - if (user == null || !user.isAdmin) { - return Response.forbidden(jsonEncode({'error': 'Admin access required'})); - } - - final result = await cleanupService.runCleanup(); - return Response.ok( - jsonEncode(result), - headers: {'content-type': 'application/json'}, - ); - }); - - return router(request); - }),); + apiRouter.mount( + '/api/admin', + const Pipeline() + .addMiddleware(authMiddleware(db)) + .addHandler((Request request) { + final router = Router(); + + // POST /api/admin/purge + router.post('/purge', (Request req) async { + final user = req.context['user'] as User?; + if (user == null || !user.isAdmin) { + return Response.forbidden( + jsonEncode({'error': 'Admin access required'})); + } + + final result = await cleanupService.runCleanup(); + return Response.ok( + jsonEncode(result), + headers: {'content-type': 'application/json'}, + ); + }); + + return router(request); + }), + ); // Create static handler final baseStaticHandler = createStaticHandler( @@ -102,55 +115,59 @@ void main(List args) async { // Wrap static handler to enforce cache policies FutureOr staticHandler(Request request) async { final response = await baseStaticHandler(request); - + // Disable cache for entry points to ensure updates are seen immediately final path = request.url.path; - if (path.isEmpty || - path == 'index.html' || - path.endsWith('.js') || + if (path.isEmpty || + path == 'index.html' || + path.endsWith('.js') || path.endsWith('.json')) { - return response.change(headers: { - 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', - 'Pragma': 'no-cache', - 'Expires': '0', - },); + return response.change( + headers: { + 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', + 'Pragma': 'no-cache', + 'Expires': '0', + }, + ); } - + // Allow aggressive caching for hashed assets - return response.change(headers: { - 'Cache-Control': 'public, max-age=86400', - },); + return response.change( + headers: { + 'Cache-Control': 'public, max-age=86400', + }, + ); } // 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; - } - } + Playlist? playlist; - 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; + 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; } // Create Streaming Router (Mount handlers on correct paths) @@ -160,20 +177,20 @@ void main(List args) async { // Main handler with API proxy and NEW Streaming Handlers final handler = Cascade() - .add(apiRouter.call) /* Standard API endpoints */ - .add(_createXtreamProxyHandler()) /* Keep for general API calls */ - .add(streamingRouter.call) /* Streaming endpoints */ - .add(staticHandler) - .handler; + .add(apiRouter.call) /* Standard API endpoints */ + .add(_createXtreamProxyHandler()) /* Keep for general API calls */ + .add(streamingRouter.call) /* Streaming endpoints */ + .add(staticHandler) + .handler; // Add middleware final pipeline = const Pipeline() - .addMiddleware(logRequests()) - .addMiddleware(securityHeadersMiddleware()) - .addMiddleware(honeypotMiddleware()) - .addMiddleware(rateLimitMiddleware()) - .addMiddleware(_corsMiddleware()) - .addHandler(handler); + .addMiddleware(logRequests()) + .addMiddleware(securityHeadersMiddleware()) + .addMiddleware(honeypotMiddleware()) + .addMiddleware(rateLimitMiddleware()) + .addMiddleware(_corsMiddleware()) + .addHandler(handler); // Start server final server = await shelf_io.serve( @@ -186,7 +203,7 @@ 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/*'); - + // Clean expired sessions periodically (every hour) Timer.periodic(const Duration(hours: 1), (_) { db.cleanExpiredSessions(); @@ -194,8 +211,6 @@ void main(List args) async { }); } - - /// CORS middleware to allow cross-origin requests Middleware _corsMiddleware() { return (Handler handler) { @@ -232,12 +247,12 @@ Handler _createXtreamProxyHandler() { // Extract target URL from request // Format: /api/xtream/http://server:port/path String apiPath = path.substring('api/xtream/'.length); - + // Decode URL if it's encoded if (apiPath.startsWith('http%3A') || apiPath.startsWith('https%3A')) { apiPath = Uri.decodeComponent(apiPath); } - + if (!apiPath.startsWith('http://') && !apiPath.startsWith('https://')) { return Response.badRequest( body: 'Invalid API URL. Expected format: /api/xtream/http://...', @@ -252,7 +267,7 @@ Handler _createXtreamProxyHandler() { fullUrl = '$fullUrl?${request.url.query}'; } } - + final targetUrl = Uri.parse(fullUrl); final proxyHeaders = { @@ -265,15 +280,17 @@ Handler _createXtreamProxyHandler() { http.Response response; if (request.method == 'GET') { response = await http.get(targetUrl, headers: proxyHeaders).timeout( - const Duration(seconds: 30), - onTimeout: () => http.Response('Request timeout', 504), - ); + const Duration(seconds: 30), + onTimeout: () => http.Response('Request timeout', 504), + ); } else if (request.method == 'POST') { final body = await request.readAsString(); - response = await http.post(targetUrl, headers: proxyHeaders, body: body).timeout( - const Duration(seconds: 30), - onTimeout: () => http.Response('Request timeout', 504), - ); + response = await http + .post(targetUrl, headers: proxyHeaders, body: body) + .timeout( + const Duration(seconds: 30), + onTimeout: () => http.Response('Request timeout', 504), + ); } else { return Response(405, body: 'Method not allowed'); } @@ -282,7 +299,8 @@ Handler _createXtreamProxyHandler() { response.statusCode, body: response.bodyBytes, headers: { - 'content-type': response.headers['content-type'] ?? 'application/json', + 'content-type': + response.headers['content-type'] ?? 'application/json', 'access-control-allow-origin': '*', }, );