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