mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-11 17:30:00 +02:00
feat: implement streaming handler with FFmpeg-based live and VOD HLS transcoding support
This commit is contained in:
1 parent
f08fccc0d1
commit
e641d04a3a
3 files changed
+102
-30
No files matched your search
@@ -562,51 +562,113 @@ Handler createRecordingStreamHandler(
|
|||||||
}) {
|
}) {
|
||||||
final router = Router();
|
final router = Router();
|
||||||
|
|
||||||
router.get('/<streamId>.mkv', (Request request, String streamId) async {
|
router.get('/<streamId>/playlist.m3u8', (Request request, String streamId) async {
|
||||||
final recording = db.getRecordingById(streamId);
|
final recording = db.getRecordingById(streamId);
|
||||||
if (recording == null || recording.filePath == null) {
|
if (recording == null || recording.filePath == null) {
|
||||||
return Response.notFound('Recording or file not found');
|
return Response.notFound('Recording or file not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
final targetUrl = recording.filePath!;
|
final targetUrl = recording.filePath!;
|
||||||
final file = File(targetUrl);
|
if (!File(targetUrl).existsSync()) {
|
||||||
if (!file.existsSync()) {
|
|
||||||
return Response.notFound('Physical recording file not found');
|
return Response.notFound('Physical recording file not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
final fileStat = file.statSync();
|
final streamDir = Directory('${_hlsTempDir.path}/rec_$streamId');
|
||||||
final fileSize = fileStat.size;
|
|
||||||
final rangeHeader = request.headers['range'];
|
|
||||||
|
|
||||||
if (rangeHeader != null && rangeHeader.startsWith('bytes=')) {
|
if (_vodProcesses.containsKey('rec_$streamId')) {
|
||||||
final parts = rangeHeader.substring(6).split('-');
|
final existingProcess = _vodProcesses['rec_$streamId']!;
|
||||||
var start = int.tryParse(parts[0]) ?? 0;
|
final playlistFile = File('${streamDir.path}/playlist.m3u8');
|
||||||
var end = (parts.length > 1 && parts[1].isNotEmpty) ? int.tryParse(parts[1]) : null;
|
|
||||||
end ??= fileSize - 1;
|
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 (start >= fileSize || end >= fileSize || start > end) {
|
if (!_vodProcesses.containsKey('rec_$streamId')) {
|
||||||
return Response(416, headers: {'Content-Range': 'bytes */$fileSize'});
|
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', '3000k', '-maxrate', '3500k', '-bufsize', '6000k',
|
||||||
|
'-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',
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
final contentLength = end - start + 1;
|
ffmpegArgs.addAll([
|
||||||
final stream = file.openRead(start, end + 1);
|
'-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', 'vod', '-hls_allow_cache', '1',
|
||||||
|
'-hls_flags', 'independent_segments', '-hls_segment_type', 'mpegts',
|
||||||
|
'-hls_segment_filename', 'segment_%03d.ts', '-start_number', '0',
|
||||||
|
'playlist.m3u8',
|
||||||
|
]);
|
||||||
|
|
||||||
return Response(206, body: stream, headers: {
|
Process.start(_getFFmpegPath(), ffmpegArgs, workingDirectory: streamDir.path).then((process) {
|
||||||
'Content-Type': 'video/x-matroska',
|
_vodProcesses['rec_$streamId'] = process;
|
||||||
'Accept-Ranges': 'bytes',
|
process.stderr.transform(utf8.decoder).listen((data) => print('[FFmpeg Rec $streamId] $data'));
|
||||||
'Content-Length': contentLength.toString(),
|
process.exitCode.then((code) {
|
||||||
'Content-Range': 'bytes $start-$end/$fileSize',
|
_vodProcesses.remove('rec_$streamId');
|
||||||
'Access-Control-Allow-Origin': '*',
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
return Response.ok(
|
||||||
file.openRead(),
|
file.openRead(),
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'video/x-matroska',
|
'Content-Type': 'video/mp2t',
|
||||||
'Accept-Ranges': 'bytes',
|
|
||||||
'Content-Length': fileSize.toString(),
|
|
||||||
'Access-Control-Allow-Origin': '*',
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Cache-Control': 'max-age=3600',
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
|||||||
widget.containerExtension,
|
widget.containerExtension,
|
||||||
);
|
);
|
||||||
} else if (widget.streamType == StreamType.recording) {
|
} else if (widget.streamType == StreamType.recording) {
|
||||||
streamUrl = '${service.backendBaseUrl}/api/recordings/stream/$currentStreamId.mkv';
|
streamUrl = '${service.backendBaseUrl}/api/recordings/stream/$currentStreamId/playlist.m3u8';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store URL for Lite Player
|
// Store URL for Lite Player
|
||||||
@@ -166,6 +166,10 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
|||||||
var playerSrc =
|
var playerSrc =
|
||||||
'$playerFile?url=$encodedUrl&type=$streamTypeParam&turbo=true&v=$cacheBuster';
|
'$playerFile?url=$encodedUrl&type=$streamTypeParam&turbo=true&v=$cacheBuster';
|
||||||
|
|
||||||
|
if (widget.streamType == StreamType.recording) {
|
||||||
|
playerSrc += '&is_recording=true';
|
||||||
|
}
|
||||||
|
|
||||||
if (startTimeOverride != null) {
|
if (startTimeOverride != null) {
|
||||||
playerSrc += '&t=$startTimeOverride';
|
playerSrc += '&t=$startTimeOverride';
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-5
@@ -251,21 +251,27 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isTurbo = urlParams.get('turbo') === 'true';
|
const isTurbo = urlParams.get('turbo') === 'true';
|
||||||
|
const isRecording = urlParams.get('is_recording') === 'true';
|
||||||
|
|
||||||
const hls = new Hls({
|
const hlsOptions = {
|
||||||
maxBufferLength: 120,
|
maxBufferLength: 120,
|
||||||
enableWorker: true,
|
enableWorker: true,
|
||||||
liveSyncDurationCount: 10, // Buffer-First: 10 segments safety margin
|
|
||||||
liveMaxLatencyDurationCount: 20,
|
|
||||||
nudgeOffset: 0.8, // More aggressive jump over corrupted frames
|
nudgeOffset: 0.8, // More aggressive jump over corrupted frames
|
||||||
nudgeMaxRetries: 30,
|
nudgeMaxRetries: 30,
|
||||||
maxLiveSyncPlaybackRate: 1.1, // Slower catch-up to avoid chaos
|
maxLiveSyncPlaybackRate: 1.1, // Slower catch-up to avoid chaos
|
||||||
// Stabilization
|
|
||||||
initialLiveManifestSize: 3, // Wait for more segments before starting
|
initialLiveManifestSize: 3, // Wait for more segments before starting
|
||||||
abrEwmaDefaultEstimate: 1000000,
|
abrEwmaDefaultEstimate: 1000000,
|
||||||
manifestLoadingRetryDelay: 1000,
|
manifestLoadingRetryDelay: 1000,
|
||||||
levelLoadingRetryDelay: 1000
|
levelLoadingRetryDelay: 1000
|
||||||
});
|
};
|
||||||
|
|
||||||
|
// Limit live sync only for actual LIVE IPTV, not recordings
|
||||||
|
if (!isRecording && streamType === 'live') {
|
||||||
|
hlsOptions.liveSyncDurationCount = 10;
|
||||||
|
hlsOptions.liveMaxLatencyDurationCount = 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hls = new Hls(hlsOptions);
|
||||||
window.hlsInstance = hls;
|
window.hlsInstance = hls;
|
||||||
hls.loadSource(url);
|
hls.loadSource(url);
|
||||||
hls.attachMedia(video);
|
hls.attachMedia(video);
|
||||||
|
|||||||
Reference in new issue
Block a user