From 5771236c43a1b0108e864ac333ed343c31fe50c1 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Sat, 6 Dec 2025 11:49:39 +0100 Subject: [PATCH] feat: add Dart server for API, Xtream proxy, and FFmpeg streaming, updating Dockerfile for ffmpeg support. --- Dockerfile | 7 +- bin/server.dart | 177 +++++++++++++++++- lib/features/iptv/screens/player_screen.dart | 153 ++++++++++----- .../iptv/services/xtream_service.dart | 15 +- 4 files changed, 297 insertions(+), 55 deletions(-) diff --git a/Dockerfile b/Dockerfile index 7696eba..a2dd068 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,8 +36,11 @@ FROM dart:stable WORKDIR /app -# Install SQLite3 library for FFI -RUN apt-get update && apt-get install -y sqlite3 libsqlite3-dev && rm -rf /var/lib/apt/lists/* +# Install SQLite3 library for FFI and FFmpeg for IPTV stream transcoding +RUN apt-get update && apt-get install -y sqlite3 libsqlite3-dev ffmpeg && rm -rf /var/lib/apt/lists/* + +# Create directory for HLS stream output +RUN mkdir -p /tmp/streams && chmod 777 /tmp/streams # Copy entire bin directory (API, database, etc.) COPY bin/ ./bin/ diff --git a/bin/server.dart b/bin/server.dart index ff3f259..187c73d 100644 --- a/bin/server.dart +++ b/bin/server.dart @@ -47,10 +47,12 @@ void main(List args) async { listDirectories: false, ); - // Main handler with API proxy + // Main handler with API proxy and FFmpeg streaming final handler = Cascade() .add(_createApiHandler(apiRouter)) + .add(_createStreamHandler()) // FFmpeg streaming endpoint .add(_createXtreamProxyHandler()) + .add(_createHlsHandler()) // Serve generated HLS files .add(staticHandler) .handler; @@ -71,6 +73,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/*'); + print('FFmpeg streaming available at: /api/stream/*'); // Clean expired sessions periodically (every hour) Timer.periodic(const Duration(hours: 1), (_) { @@ -292,3 +295,175 @@ String _rewriteUrl(String url, String baseUrl, String basePath, String proxyOrig 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 from path: /api/stream/{id} + final pathParts = path.split('/'); + if (pathParts.length < 3) { + return Response.badRequest(body: 'Invalid stream path'); + } + + final streamId = pathParts[2]; + + // Get the IPTV URL from query parameter + final iptvUrl = request.url.queryParameters['url']; + if (iptvUrl == null || iptvUrl.isEmpty) { + return Response.badRequest(body: 'Missing url parameter'); + } + + print('Starting FFmpeg stream for ID: $streamId'); + print('IPTV URL: $iptvUrl'); + + // Create output directory for this stream + final outputDir = Directory('/tmp/streams/$streamId'); + if (!await outputDir.exists()) { + await outputDir.create(recursive: true); + } + + // Clean old files + await for (final file in outputDir.list()) { + await file.delete(); + } + + final outputPath = '${outputDir.path}/stream.m3u8'; + + // Kill existing FFmpeg process for this stream if any + if (_activeStreams.containsKey(streamId)) { + _activeStreams[streamId]?.kill(); + _activeStreams.remove(streamId); + } + + // Start FFmpeg process with VLC User-Agent + // Using -c copy for efficient passthrough (no transcoding = low CPU) + final process = await Process.start('ffmpeg', [ + '-y', // Overwrite output files + '-user_agent', 'VLC/3.0.18 LibVLC/3.0.18', + '-headers', 'Accept: */*\r\nConnection: keep-alive\r\n', + '-i', iptvUrl, + '-c', 'copy', // Copy codec (no transcoding) + '-f', 'hls', + '-hls_time', '2', // 2 second segments + '-hls_list_size', '5', // Keep only last 5 segments + '-hls_flags', 'delete_segments+append_list', + '-hls_segment_filename', '${outputDir.path}/segment_%d.ts', + outputPath, + ]); + + _activeStreams[streamId] = process; + + // Log FFmpeg output for debugging + process.stderr.transform(utf8.decoder).listen((data) { + // Only print errors, not the verbose output + if (data.contains('Error') || data.contains('error')) { + print('FFmpeg error [$streamId]: $data'); + } + }); + + process.exitCode.then((code) { + print('FFmpeg process [$streamId] exited with code: $code'); + _activeStreams.remove(streamId); + }); + + // Wait a moment for FFmpeg to create the initial playlist + await Future.delayed(const Duration(seconds: 3)); + + // Check if stream started successfully + final playlistFile = File(outputPath); + if (!await playlistFile.exists()) { + // FFmpeg might have failed - check stderr + process.kill(); + _activeStreams.remove(streamId); + return Response.internalServerError( + body: jsonEncode({'error': 'FFmpeg failed to start stream'}), + headers: {'content-type': 'application/json'}, + ); + } + + // Return the local HLS URL + return Response.ok( + jsonEncode({ + 'status': 'started', + 'streamId': streamId, + 'hlsUrl': '/hls/$streamId/stream.m3u8', + }), + headers: { + 'content-type': 'application/json', + 'access-control-allow-origin': '*', + }, + ); + } catch (e, stackTrace) { + print('Stream error: $e'); + print(stackTrace); + return Response.internalServerError( + body: jsonEncode({'error': 'Stream error', 'message': e.toString()}), + headers: {'content-type': 'application/json'}, + ); + } + }; +} + +/// Create handler for serving HLS files generated by FFmpeg +Handler _createHlsHandler() { + return (Request request) async { + final path = request.url.path; + + // Only handle /hls/* requests + if (!path.startsWith('hls/')) { + return Response.notFound('Not found'); + } + + try { + // Extract file path: /hls/{streamId}/{filename} + final relativePath = path.substring('hls/'.length); + final filePath = '/tmp/streams/$relativePath'; + + final file = File(filePath); + if (!await file.exists()) { + return Response.notFound('HLS file not found'); + } + + // Determine content type + String contentType; + if (filePath.endsWith('.m3u8')) { + contentType = 'application/vnd.apple.mpegurl'; + } else if (filePath.endsWith('.ts')) { + contentType = 'video/MP2T'; + } else { + contentType = 'application/octet-stream'; + } + + final bytes = await file.readAsBytes(); + + return Response.ok( + bytes, + headers: { + 'content-type': contentType, + 'access-control-allow-origin': '*', + 'cache-control': 'no-cache', + }, + ); + } catch (e) { + print('HLS serve error: $e'); + return Response.internalServerError(body: 'Error serving HLS file'); + } + }; +} diff --git a/lib/features/iptv/screens/player_screen.dart b/lib/features/iptv/screens/player_screen.dart index 57b53e9..1ae9d38 100644 --- a/lib/features/iptv/screens/player_screen.dart +++ b/lib/features/iptv/screens/player_screen.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'dart:html' as html; import 'dart:ui_web' as ui_web; +import 'dart:convert'; +import 'package:http/http.dart' as http; import '../providers/xtream_provider.dart'; import '../widgets/epg_overlay.dart'; import '../../../core/models/playlist_config.dart'; @@ -9,7 +11,7 @@ import '../../../core/models/playlist_config.dart'; /// Stream type enum for player enum StreamType { live, vod, series } -/// Video player screen using HTML5 player with mpegts.js for TS streams +/// Video player screen using FFmpeg transcoding for live streams class PlayerScreen extends ConsumerStatefulWidget { final String streamId; final String title; @@ -31,10 +33,12 @@ class PlayerScreen extends ConsumerStatefulWidget { } class _PlayerScreenState extends ConsumerState { - String? _streamUrl; + String? _hlsUrl; String? _errorMessage; + String? _statusMessage; late String _viewId; bool _isInitialized = false; + bool _isLoading = true; @override void initState() { @@ -43,8 +47,13 @@ class _PlayerScreenState extends ConsumerState { _initializePlayer(); } - void _initializePlayer() { + Future _initializePlayer() async { try { + setState(() { + _isLoading = true; + _statusMessage = 'Validating playlist...'; + }); + // Validate playlist before proceeding if (widget.playlist.dns.isEmpty || widget.playlist.username.isEmpty || @@ -53,35 +62,57 @@ class _PlayerScreenState extends ConsumerState { } final xtreamService = ref.read(xtreamServiceProvider(widget.playlist)); - - // Explicitly ensure playlist is set (defensive programming) xtreamService.setPlaylist(widget.playlist); - // Generate stream URL based on type - late String streamUrl; - switch (widget.streamType) { - case StreamType.live: - streamUrl = xtreamService.getLiveStreamUrl(widget.streamId); - break; - case StreamType.vod: - streamUrl = xtreamService.getVodStreamUrl( - widget.streamId, - widget.containerExtension, - ); - break; - case StreamType.series: - streamUrl = xtreamService.getSeriesStreamUrl( - widget.streamId, - widget.containerExtension, - ); - break; + String hlsUrl; + + if (widget.streamType == StreamType.live) { + // For live streams, use FFmpeg transcoding + setState(() { + _statusMessage = 'Starting stream transcoding...'; + }); + + // Build the FFmpeg endpoint URL + final baseUrl = html.window.location.origin; + final iptvUrl = '${widget.playlist.dns}/live/${widget.playlist.username}/${widget.playlist.password}/${widget.streamId}.ts'; + final encodedUrl = Uri.encodeComponent(iptvUrl); + final streamEndpoint = '$baseUrl/api/stream/${widget.streamId}?url=$encodedUrl'; + + debugPrint('PlayerScreen: Starting FFmpeg stream: $streamEndpoint'); + + // Call the FFmpeg streaming endpoint + final response = await http.get(Uri.parse(streamEndpoint)); + + if (response.statusCode != 200) { + throw Exception('Failed to start stream: ${response.body}'); + } + + final data = jsonDecode(response.body); + if (data['status'] != 'started') { + throw Exception('Stream failed to start: ${data['error'] ?? 'Unknown error'}'); + } + + // Get the local HLS URL + hlsUrl = '$baseUrl${data['hlsUrl']}'; + debugPrint('PlayerScreen: FFmpeg HLS URL: $hlsUrl'); + + } else { + // For VOD and Series, use direct proxy (they usually work) + final streamUrl = widget.streamType == StreamType.vod + ? xtreamService.getVodStreamUrl(widget.streamId, widget.containerExtension) + : xtreamService.getSeriesStreamUrl(widget.streamId, widget.containerExtension); + hlsUrl = streamUrl; } - debugPrint('PlayerScreen: Initializing stream URL: $streamUrl'); + setState(() { + _statusMessage = 'Loading player...'; + }); // Register the HTML view with an iframe pointing to our player.html - final encodedUrl = Uri.encodeComponent(streamUrl); - final playerUrl = 'player.html?url=$encodedUrl'; + final encodedHlsUrl = Uri.encodeComponent(hlsUrl); + final playerUrl = 'player.html?url=$encodedHlsUrl'; + + debugPrint('PlayerScreen: Player URL: $playerUrl'); // Register platform view factory // ignore: undefined_prefixed_name @@ -100,11 +131,14 @@ class _PlayerScreenState extends ConsumerState { ); setState(() { - _streamUrl = streamUrl; + _hlsUrl = hlsUrl; _isInitialized = true; + _isLoading = false; }); } catch (e) { + debugPrint('PlayerScreen error: $e'); setState(() { + _isLoading = false; _errorMessage = e.toString(); }); } @@ -147,30 +181,55 @@ class _PlayerScreenState extends ConsumerState { textAlign: TextAlign.center, ), ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: () { + setState(() { + _errorMessage = null; + _isLoading = true; + }); + _initializePlayer(); + }, + child: const Text('Retry'), + ), ], ), ) - : !_isInitialized - ? const Center( - child: CircularProgressIndicator(), - ) - : Stack( - children: [ - // HTML5 video player via iframe - HtmlElementView(viewType: _viewId), - // EPG overlay for live streams - if (widget.streamType == StreamType.live) - Positioned( - bottom: 80, - left: 0, - right: 0, - child: EpgOverlay( - streamId: widget.streamId, - playlist: widget.playlist, - ), + : _isLoading + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 16), + Text( + _statusMessage ?? 'Loading...', + style: const TextStyle(color: Colors.white70), ), - ], - ), + ], + ), + ) + : !_isInitialized + ? const Center( + child: CircularProgressIndicator(), + ) + : Stack( + children: [ + // HTML5 video player via iframe + HtmlElementView(viewType: _viewId), + // EPG overlay for live streams + if (widget.streamType == StreamType.live) + Positioned( + bottom: 80, + left: 0, + right: 0, + child: EpgOverlay( + streamId: widget.streamId, + playlist: widget.playlist, + ), + ), + ], + ), ); } } diff --git a/lib/features/iptv/services/xtream_service.dart b/lib/features/iptv/services/xtream_service.dart index 6e04c0e..1a7f4b8 100644 --- a/lib/features/iptv/services/xtream_service.dart +++ b/lib/features/iptv/services/xtream_service.dart @@ -52,15 +52,20 @@ class XtreamService { _currentPlaylist = playlist; } - /// Generate stream URL for live TV + /// Generate stream URL for live TV via FFmpeg transcoding /// - /// Uses .m3u8 format for HLS streaming (compatible with hls.js player) + /// Returns the start URL for FFmpeg transcoding endpoint. + /// The endpoint will start FFmpeg and return the local HLS URL. String getLiveStreamUrl(String streamId) { if (_currentPlaylist == null) throw Exception('No playlist configured'); - // Use .m3u8 format for HLS streaming - the player.html handles this via hls.js - final url = '${_currentPlaylist!.dns}/live/${_currentPlaylist!.username}/${_currentPlaylist!.password}/$streamId.m3u8'; - return _wrapWithProxy(url); + // Use .ts format for direct MPEG-TS streaming (FFmpeg handles this better) + final iptvUrl = '${_currentPlaylist!.dns}/live/${_currentPlaylist!.username}/${_currentPlaylist!.password}/$streamId.ts'; + + // Use FFmpeg transcoding endpoint + final baseUrl = html.window.location.origin; + final encodedUrl = Uri.encodeComponent(iptvUrl); + return '$baseUrl/api/stream/$streamId?url=$encodedUrl'; } /// Generate stream URL for VOD (movies)