mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-23 15:08:07 +02:00
feat: implement HLS live streaming and VOD transcoding handler with FFmpeg and NVIDIA GPU support
This commit is contained in:
1 parent
5e7e86e4d8
commit
e4301077b0
4 files changed
+144
-6
No files matched your search
@@ -5,6 +5,7 @@ import 'dart:math';
|
|||||||
import 'package:shelf/shelf.dart';
|
import 'package:shelf/shelf.dart';
|
||||||
import 'package:shelf_router/shelf_router.dart';
|
import 'package:shelf_router/shelf_router.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
import '../database/database.dart';
|
||||||
import '../models/playlist_config.dart';
|
import '../models/playlist_config.dart';
|
||||||
|
|
||||||
/// Active FFmpeg processes for VOD transcoding
|
/// Active FFmpeg processes for VOD transcoding
|
||||||
@@ -550,3 +551,131 @@ Handler createVodStreamHandler(
|
|||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 3. RECORDING HANDLER (FFmpeg HLS Transcoding)
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
Handler createRecordingStreamHandler(
|
||||||
|
AppDatabase db, {
|
||||||
|
bool Function()? isGpuEnabled,
|
||||||
|
}) {
|
||||||
|
final router = Router();
|
||||||
|
|
||||||
|
// Route: /api/recordings/stream/{streamId}/playlist.m3u8
|
||||||
|
router.get('/<streamId>/playlist.m3u8', (Request request, String streamId) async {
|
||||||
|
final recording = db.getRecordingById(streamId);
|
||||||
|
if (recording == null || recording.filePath == null) {
|
||||||
|
return Response.notFound('Recording or file not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
final targetUrl = recording.filePath!;
|
||||||
|
if (!File(targetUrl).existsSync()) {
|
||||||
|
return Response.notFound('Physical recording file not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
final streamDir = Directory('${_hlsTempDir.path}/rec_$streamId');
|
||||||
|
|
||||||
|
// Check if existing session is healthy
|
||||||
|
if (_vodProcesses.containsKey('rec_$streamId')) {
|
||||||
|
final existingProcess = _vodProcesses['rec_$streamId']!;
|
||||||
|
final playlistFile = File('${streamDir.path}/playlist.m3u8');
|
||||||
|
|
||||||
|
if (playlistFile.existsSync() && playlistFile.readAsStringSync().contains('.ts')) {
|
||||||
|
return Response.ok(
|
||||||
|
playlistFile.openRead(),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/vnd.apple.mpegurl',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try { existingProcess.kill(); } catch (_) {}
|
||||||
|
_vodProcesses.remove('rec_$streamId');
|
||||||
|
if (streamDir.existsSync()) streamDir.deleteSync(recursive: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_vodProcesses.containsKey('rec_$streamId')) {
|
||||||
|
if (streamDir.existsSync()) streamDir.deleteSync(recursive: true);
|
||||||
|
streamDir.createSync(recursive: true);
|
||||||
|
|
||||||
|
final useNvidiaGpu = isGpuEnabled?.call() ?? _isNvidiaGpuEnabled();
|
||||||
|
|
||||||
|
final ffmpegArgs = <String>[
|
||||||
|
'-hide_banner', '-loglevel', 'warning',
|
||||||
|
if (useNvidiaGpu) ...['-hwaccel', 'cuda'],
|
||||||
|
'-i', targetUrl,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (useNvidiaGpu) {
|
||||||
|
ffmpegArgs.addAll([
|
||||||
|
'-c:v', 'h264_nvenc', '-preset', 'p4', '-tune', 'hq',
|
||||||
|
'-rc', 'cbr', '-b:v', '4000k', '-maxrate', '4500k', '-bufsize', '8000k',
|
||||||
|
'-g', '48', '-bf', '2', '-pix_fmt', 'yuv420p',
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
ffmpegArgs.addAll([
|
||||||
|
'-c:v', 'libx264', '-preset', 'medium', '-crf', '18',
|
||||||
|
'-maxrate', '12000k', '-bufsize', '24000k', '-pix_fmt', 'yuv420p',
|
||||||
|
'-g', '48', '-threads', '0',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
ffmpegArgs.addAll([
|
||||||
|
'-c:a', 'aac', '-b:a', '192k', '-ar', '48000',
|
||||||
|
'-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', '-hls_allow_cache', '1',
|
||||||
|
'-hls_flags', 'independent_segments', '-hls_segment_type', 'mpegts',
|
||||||
|
'-hls_segment_filename', 'segment_%03d.ts', '-start_number', '0',
|
||||||
|
'playlist.m3u8',
|
||||||
|
]);
|
||||||
|
|
||||||
|
final ffmpegPath = _getFFmpegPath();
|
||||||
|
Process.start(ffmpegPath, ffmpegArgs, workingDirectory: streamDir.path).then((process) {
|
||||||
|
_vodProcesses['rec_$streamId'] = process;
|
||||||
|
process.stderr.transform(utf8.decoder).listen((data) => print('[FFmpeg Rec $streamId] $data'));
|
||||||
|
process.exitCode.then((code) {
|
||||||
|
print('[Recording Stream] FFmpeg exited with code $code');
|
||||||
|
_vodProcesses.remove('rec_$streamId');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
final playlistFile = File('${streamDir.path}/playlist.m3u8');
|
||||||
|
int retries = 0;
|
||||||
|
while (retries < 60) {
|
||||||
|
if (playlistFile.existsSync() && playlistFile.readAsStringSync().contains('.ts')) break;
|
||||||
|
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',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/<streamId>/<segment>', (Request request, String streamId, String segment) async {
|
||||||
|
final file = File('${_hlsTempDir.path}/rec_$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',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
@@ -243,6 +243,13 @@ void main(List<String> args) async {
|
|||||||
getPlaylist,
|
getPlaylist,
|
||||||
isGpuEnabled: db.isNvidiaGpuEnabled,
|
isGpuEnabled: db.isNvidiaGpuEnabled,
|
||||||
),
|
),
|
||||||
|
)
|
||||||
|
..mount(
|
||||||
|
'/api/recordings/stream',
|
||||||
|
createRecordingStreamHandler(
|
||||||
|
db,
|
||||||
|
isGpuEnabled: db.isNvidiaGpuEnabled,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Proxy Handler (auth is now handled INSIDE the handler, after path check)
|
// Proxy Handler (auth is now handled INSIDE the handler, after path check)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import '../../../core/models/playlist_config.dart';
|
|||||||
import '../../../core/widgets/glass_container.dart';
|
import '../../../core/widgets/glass_container.dart';
|
||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
|
|
||||||
enum StreamType { live, vod, series }
|
enum StreamType { live, vod, series, recording }
|
||||||
|
|
||||||
class PlayerScreen extends ConsumerStatefulWidget {
|
class PlayerScreen extends ConsumerStatefulWidget {
|
||||||
final String streamId;
|
final String streamId;
|
||||||
@@ -127,6 +127,8 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
|||||||
currentStreamId,
|
currentStreamId,
|
||||||
widget.containerExtension,
|
widget.containerExtension,
|
||||||
);
|
);
|
||||||
|
} else if (widget.streamType == StreamType.recording) {
|
||||||
|
streamUrl = '${service.backendBaseUrl}/api/recordings/stream/$currentStreamId/playlist.m3u8';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store URL for Lite Player
|
// Store URL for Lite Player
|
||||||
|
|||||||
@@ -779,12 +779,12 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _playRecording(BuildContext context, Map<String, dynamic> rec) async {
|
Future<void> _playRecording(BuildContext context, Map<String, dynamic> rec) async {
|
||||||
final streamUrl = rec['stream_url'] as String? ?? '';
|
final recordingId = rec['id'] as String?;
|
||||||
final title = rec['title'] as String? ?? 'Enregistrement';
|
final title = rec['title'] as String? ?? 'Enregistrement';
|
||||||
|
|
||||||
if (streamUrl.isEmpty) {
|
if (recordingId == null || recordingId.isEmpty) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('URL du flux indisponible')),
|
const SnackBar(content: Text('ID de lecture indisponible')),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -793,10 +793,10 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
|
|||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (ctx) => PlayerScreen(
|
builder: (ctx) => PlayerScreen(
|
||||||
streamId: streamUrl,
|
streamId: recordingId,
|
||||||
title: title,
|
title: title,
|
||||||
playlist: widget.playlist,
|
playlist: widget.playlist,
|
||||||
streamType: StreamType.vod,
|
streamType: StreamType.recording,
|
||||||
containerExtension: 'ts',
|
containerExtension: 'ts',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in new issue
Block a user