mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-11 17:30:00 +02:00
feat: add Xtream API data models, IPTV player screen, and server entry point
This commit is contained in:
1 parent
77dde5d7fd
commit
2517ac777a
3 files changed
+98
-300
No files matched your search
+86
-276
@@ -52,7 +52,7 @@ void main(List<String> args) async {
|
||||
.add(_createApiHandler(apiRouter))
|
||||
.add(_createStreamHandler()) // FFmpeg streaming endpoint
|
||||
.add(_createXtreamProxyHandler())
|
||||
.add(_createHlsHandler()) // Serve generated HLS files
|
||||
// .add(_createHlsHandler()) // REMOVED: HLS obsolete with direct stream
|
||||
.add(staticHandler)
|
||||
.handler;
|
||||
|
||||
@@ -396,7 +396,7 @@ Handler _createStreamHandler() {
|
||||
}
|
||||
|
||||
try {
|
||||
// Extract stream ID from path: /api/stream/{id}
|
||||
// Extract stream ID
|
||||
final pathParts = path.split('/');
|
||||
if (pathParts.length < 3) {
|
||||
return Response.badRequest(body: 'Invalid stream path');
|
||||
@@ -404,303 +404,113 @@ Handler _createStreamHandler() {
|
||||
|
||||
final streamId = pathParts[2];
|
||||
|
||||
// Get the IPTV URL from query parameter
|
||||
// Get parameters
|
||||
final iptvUrl = request.url.queryParameters['url'];
|
||||
if (iptvUrl == null || iptvUrl.isEmpty) {
|
||||
return Response.badRequest(body: 'Missing url parameter');
|
||||
}
|
||||
|
||||
// Get streaming settings from query parameters
|
||||
final quality = request.url.queryParameters['quality'] ?? 'medium';
|
||||
final buffer = request.url.queryParameters['buffer'] ?? 'medium';
|
||||
final timeout = request.url.queryParameters['timeout'] ?? 'medium';
|
||||
final mode = request.url.queryParameters['mode'] ?? 'auto'; // direct, transcode, auto
|
||||
print('Starting Direct Stream for ID: $streamId');
|
||||
print('Source: $iptvUrl');
|
||||
|
||||
// Map quality to bitrate/CRF
|
||||
final (int bitrate, int crf) = switch (quality) {
|
||||
'low' => (1500, 26),
|
||||
'high' => (5000, 20),
|
||||
_ => (3000, 23), // medium
|
||||
};
|
||||
|
||||
// Map buffer to segment duration and buffer size
|
||||
final (int segmentDuration, int bufferSize) = switch (buffer) {
|
||||
'low' => (2, 4000),
|
||||
'high' => (6, 12000),
|
||||
_ => (4, 8000), // medium
|
||||
};
|
||||
|
||||
// Map timeout to seconds
|
||||
final int timeoutSeconds = switch (timeout) {
|
||||
'short' => 15,
|
||||
'long' => 60,
|
||||
_ => 30, // medium
|
||||
};
|
||||
|
||||
print('Starting FFmpeg stream for ID: $streamId (quality=$quality, buffer=$buffer)');
|
||||
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
|
||||
// Transcode to H.264/AAC for browser compatibility (HEVC not supported in Chrome)
|
||||
// Using fast preset for lower CPU usage
|
||||
// Added resilience flags for corrupted/discontinuous streams
|
||||
//
|
||||
// Check if this is a VOD (file) or live stream
|
||||
final isVod = streamId.startsWith('vod_');
|
||||
// Setup FFmpeg arguments for fMP4 piping
|
||||
// Determine if it's VOD or Live
|
||||
final isVod = streamId.startsWith('vod_'); // Or use extension check
|
||||
|
||||
final ffmpegArgs = <String>[
|
||||
'-y', // Overwrite output files
|
||||
'-loglevel', 'warning', // Reduce verbose output
|
||||
'-err_detect', 'ignore_err', // Ignore decoding errors
|
||||
'-fflags', '+genpts+discardcorrupt+nobuffer', // Generate PTS, discard corrupt, reduce buffering
|
||||
'-flags', 'low_delay', // Low delay mode
|
||||
'-thread_queue_size', '4096', // Prevent queue overflow on slow CPU
|
||||
'-analyzeduration', isVod ? '10000000' : '1000000', // 10s for VOD, 1s for live (faster start)
|
||||
'-probesize', isVod ? '5000000' : '500000', // 5MB for VOD, 500KB for live (faster start)
|
||||
'-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
|
||||
|
||||
'-i', iptvUrl,
|
||||
|
||||
// Output format: fragmented MP4 (fMP4) for streaming
|
||||
'-f', 'mp4',
|
||||
'-movflags', 'frag_keyframe+empty_moov+default_base_moof',
|
||||
|
||||
// Video: Copy is BEST for performance (0% CPU)
|
||||
// Browsers support H.264 well.
|
||||
'-c:v', 'copy',
|
||||
|
||||
// Audio: Transcode to AAC is SAFEST for browsers
|
||||
// (Many IPTV streams typically use MP2/AC3 which browsers hate)
|
||||
'-c:a', 'aac',
|
||||
'-b:a', '128k',
|
||||
'-ar', '44100',
|
||||
'-ac', '2',
|
||||
|
||||
// Fix for MPEG-TS timestamps
|
||||
'-fflags', '+genpts',
|
||||
|
||||
// Output to stdout pipe
|
||||
'pipe:1'
|
||||
];
|
||||
|
||||
// Add reconnect flags and longer timeout for live streams (not VOD)
|
||||
if (!isVod) {
|
||||
ffmpegArgs.addAll([
|
||||
'-reconnect', '1',
|
||||
'-reconnect_streamed', '1',
|
||||
'-reconnect_delay_max', '10', // Increased from 5s
|
||||
'-reconnect_on_network_error', '1',
|
||||
'-reconnect_on_http_error', '4xx,5xx',
|
||||
'-rw_timeout', '${timeoutSeconds * 1000000}', // Read/write timeout in microseconds
|
||||
'-stimeout', '${timeoutSeconds * 1000000}', // Socket timeout
|
||||
]);
|
||||
}
|
||||
|
||||
ffmpegArgs.addAll(['-i', iptvUrl]);
|
||||
|
||||
// Check mode: direct (copy) vs transcode
|
||||
final usePassthrough = mode == 'direct';
|
||||
|
||||
if (usePassthrough) {
|
||||
// PASSTHROUGH MODE: Copy video, but transcode audio to AAC
|
||||
// Most IPTV streams use AC3/MP3 audio which browsers can't play in HLS
|
||||
// Video copy = 0% CPU, audio transcode = ~5% CPU (still very light)
|
||||
ffmpegArgs.addAll([
|
||||
'-c:v', 'copy', // Copy video stream as-is (H.264)
|
||||
'-c:a', 'aac', // Transcode audio to AAC (browser compatible)
|
||||
'-b:a', '128k', // Audio bitrate
|
||||
'-ar', '44100', // Sample rate
|
||||
'-ac', '2', // Stereo
|
||||
'-bsf:v', 'h264_mp4toannexb', // Convert to Annex B format for HLS
|
||||
]);
|
||||
} else {
|
||||
// TRANSCODE MODE: Re-encode for browser compatibility
|
||||
ffmpegArgs.addAll([
|
||||
// Video: transcode to H.264 for browser compatibility
|
||||
'-c:v', 'libx264',
|
||||
'-preset', 'ultrafast', // Fastest encoding, lowest CPU (was: veryfast)
|
||||
'-tune', 'zerolatency', // Low latency for live streaming
|
||||
'-profile:v', 'main', // Main profile for better quality HD
|
||||
'-level', '4.0', // Higher level for HD content
|
||||
'-pix_fmt', 'yuv420p', // Required for browser compatibility
|
||||
'-crf', '$crf', // Quality-based encoding from settings
|
||||
'-b:v', '${bitrate}k', // Video bitrate from settings
|
||||
'-maxrate', '${(bitrate * 1.5).round()}k', // Allow larger bursts
|
||||
'-bufsize', '${bufferSize}k', // Buffer size from settings
|
||||
]);
|
||||
}
|
||||
|
||||
// Add timestamp handling for live streams
|
||||
if (!isVod) {
|
||||
ffmpegArgs.addAll([
|
||||
'-fps_mode', 'passthrough', // Pass through timestamps as-is
|
||||
]);
|
||||
} else {
|
||||
ffmpegArgs.addAll([
|
||||
'-fps_mode', 'cfr', // Constant frame rate for VOD
|
||||
'-g', '48', // Keyframe every 2 seconds at 24fps
|
||||
'-keyint_min', '48',
|
||||
]);
|
||||
}
|
||||
|
||||
// Audio handling for transcode mode
|
||||
if (!usePassthrough) {
|
||||
ffmpegArgs.addAll([
|
||||
'-c:a', 'aac',
|
||||
'-b:a', '128k',
|
||||
'-ar', '44100',
|
||||
'-ac', '2', // Force stereo
|
||||
]);
|
||||
}
|
||||
|
||||
ffmpegArgs.addAll([
|
||||
'-avoid_negative_ts', 'make_zero', // Handle negative timestamps
|
||||
'-max_muxing_queue_size', '1024', // Prevent queue overflow
|
||||
// HLS output settings - optimized for stability
|
||||
'-f', 'hls',
|
||||
'-hls_time', isVod ? '4' : '$segmentDuration', // Segment duration from settings for live
|
||||
'-hls_list_size', isVod ? '0' : '30', // Keep all for VOD, 30 for live (more buffer to prevent 404s)
|
||||
'-hls_flags', isVod ? 'independent_segments' : 'append_list+omit_endlist', // Simplified flags
|
||||
'-hls_start_number_source', 'epoch', // Use epoch for consistent segment naming
|
||||
'-hls_segment_filename', '${outputDir.path}/segment_%05d.ts', // 5-digit numbering
|
||||
outputPath,
|
||||
]);
|
||||
|
||||
// Start the process
|
||||
final process = await Process.start('ffmpeg', ffmpegArgs);
|
||||
|
||||
_activeStreams[streamId] = process;
|
||||
|
||||
// Collect FFmpeg stderr for error reporting
|
||||
final stderrBuffer = StringBuffer();
|
||||
process.stderr.transform(utf8.decoder).listen((data) {
|
||||
stderrBuffer.write(data);
|
||||
// Print warnings and errors
|
||||
if (data.contains('Error') || data.contains('error') || data.contains('Warning')) {
|
||||
print('FFmpeg [$streamId]: $data');
|
||||
}
|
||||
});
|
||||
|
||||
process.exitCode.then((code) {
|
||||
print('FFmpeg process [$streamId] exited with code: $code');
|
||||
if (code != 0) {
|
||||
print('FFmpeg stderr: ${stderrBuffer.toString().substring(0, stderrBuffer.length.clamp(0, 500))}');
|
||||
}
|
||||
_activeStreams.remove(streamId);
|
||||
});
|
||||
|
||||
// Wait for FFmpeg to create the initial playlist with retries
|
||||
// Different sources may take varying times to buffer and produce first segment
|
||||
final playlistFile = File(outputPath);
|
||||
bool playlistReady = false;
|
||||
const maxWaitSeconds = 15; // Maximum wait time for first segment
|
||||
const checkIntervalMs = 500; // Check every 500ms
|
||||
|
||||
for (int waited = 0; waited < maxWaitSeconds * 1000; waited += checkIntervalMs) {
|
||||
await Future.delayed(const Duration(milliseconds: checkIntervalMs));
|
||||
|
||||
// Check if process died early
|
||||
if (!_activeStreams.containsKey(streamId)) {
|
||||
final stderr = stderrBuffer.toString();
|
||||
print('FFmpeg process died early for $streamId: $stderr');
|
||||
return Response.internalServerError(
|
||||
body: jsonEncode({
|
||||
'error': 'FFmpeg process died early',
|
||||
'details': stderr.length > 500 ? stderr.substring(0, 500) : stderr,
|
||||
}),
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}
|
||||
|
||||
// Check if playlist file exists and has content
|
||||
if (await playlistFile.exists()) {
|
||||
final content = await playlistFile.readAsString();
|
||||
if (content.contains('#EXTINF')) {
|
||||
playlistReady = true;
|
||||
print('FFmpeg ready for $streamId after ${waited}ms');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!playlistReady) {
|
||||
// FFmpeg failed - get error message
|
||||
final stderr = stderrBuffer.toString();
|
||||
print('FFmpeg failed to create playlist for $streamId after ${maxWaitSeconds}s');
|
||||
print('FFmpeg stderr: $stderr');
|
||||
process.kill();
|
||||
_activeStreams.remove(streamId);
|
||||
return Response.internalServerError(
|
||||
body: jsonEncode({
|
||||
'error': 'FFmpeg failed to start stream (timeout)',
|
||||
'details': stderr.length > 500 ? stderr.substring(0, 500) : stderr,
|
||||
}),
|
||||
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': '*',
|
||||
// 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<List<int>>();
|
||||
|
||||
// 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/mp4',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Cache-Control': 'no-cache, no-store',
|
||||
'Connection': 'keep-alive',
|
||||
},
|
||||
);
|
||||
|
||||
} catch (e, stackTrace) {
|
||||
print('Stream error: $e');
|
||||
print('Stream setup error: $e');
|
||||
print(stackTrace);
|
||||
return Response.internalServerError(
|
||||
body: jsonEncode({'error': 'Stream error', 'message': e.toString()}),
|
||||
body: jsonEncode({'error': 'Stream setup 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');
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -263,15 +263,13 @@ class ShortEPG {
|
||||
|
||||
final now = DateTime.now();
|
||||
|
||||
// Helper to decode Base64 encoded title
|
||||
// Helper to decode Base64 encoded title with proper UTF-8 support
|
||||
String decodeTitle(String? encodedTitle) {
|
||||
if (encodedTitle == null || encodedTitle.isEmpty) return '';
|
||||
try {
|
||||
// Try Base64 decode
|
||||
final decoded = String.fromCharCodes(
|
||||
base64Decode(encodedTitle),
|
||||
);
|
||||
return decoded;
|
||||
// Try Base64 decode with UTF-8 encoding
|
||||
final bytes = base64Decode(encodedTitle);
|
||||
return utf8.decode(bytes, allowMalformed: true);
|
||||
} catch (_) {
|
||||
// Not Base64, return as-is
|
||||
return encodedTitle;
|
||||
|
||||
@@ -136,29 +136,19 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
||||
};
|
||||
final modeParam = settings.modeString; // direct, transcode, or auto
|
||||
|
||||
final streamEndpoint = '$baseUrl/api/stream/${widget.streamId}?url=$encodedUrl&quality=$qualityParam&buffer=$bufferParam&timeout=$timeoutParam&mode=$modeParam';
|
||||
// Construct the stream URL directly using fMP4 format
|
||||
// We add &ext=.mp4 to force the web player to use Direct Play instead of HLS/MPEGTS
|
||||
final streamEndpoint = '$baseUrl/api/stream/${widget.streamId}?url=$encodedUrl&quality=$qualityParam&buffer=$bufferParam&timeout=$timeoutParam&mode=$modeParam&ext=.mp4';
|
||||
|
||||
debugPrint('PlayerScreen: Starting FFmpeg stream: $streamEndpoint');
|
||||
debugPrint('PlayerScreen: Starting Direct 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');
|
||||
// Use the stream endpoint directly as the video source
|
||||
// The server now pipelines fMP4 directly to this URL
|
||||
hlsUrl = streamEndpoint;
|
||||
|
||||
} else {
|
||||
// For VOD and Series - use direct proxy streaming with Range support
|
||||
// This allows seeking via HTTP Range requests
|
||||
// This allows seeking via Range requests
|
||||
final streamUrl = widget.streamType == StreamType.vod
|
||||
? xtreamService.getVodStreamUrl(widget.streamId, widget.containerExtension)
|
||||
: xtreamService.getSeriesStreamUrl(widget.streamId, widget.containerExtension);
|
||||
|
||||
Reference in new issue
Block a user