5using Microsoft.Extensions.Hosting;
6using Microsoft.Extensions.Logging;
7using Microsoft.Extensions.Options;
8using System.Collections.Concurrent;
9using System.Diagnostics;
11using System.Text.RegularExpressions;
33 private static readonly Regex
MediaSeqRegex =
new(
@"#EXT-X-MEDIA-SEQUENCE:(\d+)", RegexOptions.Compiled);
63 private readonly ILogger<FfmpegHlsStreamService>
_logger;
99 private readonly Dictionary<string, Process>
_processes =
new();
117 private readonly Dictionary<string, DateTime>
_lastWrites =
new();
146 private readonly SemaphoreSlim
_sync =
new(1, 1);
218 ILogger<FfmpegHlsStreamService> logger,
221 IOptions<FfmpegOptions> options)
223 _logger = logger ??
throw new ArgumentNullException(nameof(logger));
224 _repository = repository ??
throw new ArgumentNullException(nameof(repository));
225 _runtimeState = runtimeState ??
throw new ArgumentNullException(nameof(runtimeState));
226 _options = options?.Value ??
throw new ArgumentNullException(nameof(options));
253 protected override async Task
ExecuteAsync(CancellationToken stoppingToken)
257 if (
_options.StartOnApplicationStartup)
262 while (!stoppingToken.IsCancellationRequested)
268 catch (OperationCanceledException)
274 _logger.LogError(ex,
"WatchdogAsync 오류 발생 — 서비스 계속 실행.");
280 TimeSpan.FromSeconds(Math.Max(1,
_options.WatchdogIntervalSeconds)),
283 catch (OperationCanceledException)
314 public override async Task
StopAsync(CancellationToken cancellationToken)
317 await base.StopAsync(cancellationToken);
339 bool acquired =
false;
342 acquired =
_sync.Wait(TimeSpan.FromSeconds(2));
344 foreach (
string cameraId
in _processes.Keys.ToArray())
364 try { cameraLock.Dispose(); }
catch { }
398 public async Task
StartAllAsync(CancellationToken cancellationToken =
default)
432 public async Task
StopAllAsync(CancellationToken cancellationToken =
default)
437 Task[] tasks = cameras
438 .Select(camera =>
StopAsync(camera.
Id, cancellationToken))
441 await Task.WhenAll(tasks);
476 public async Task
StartAsync(
string cameraId, CancellationToken cancellationToken =
default)
479 await cameraLock.WaitAsync(cancellationToken);
486 cameraLock.Release();
522 public async Task
StopAsync(
string cameraId, CancellationToken cancellationToken =
default)
525 await cameraLock.WaitAsync(cancellationToken);
532 cameraLock.Release();
581 throw new InvalidOperationException($
"Camera '{cameraId}' was not found.");
586 Process? toStop =
null;
587 await
_sync.WaitAsync(cancellationToken);
590 if (
_processes.TryGetValue(camera.
Id, out Process? existing))
592 if (!existing.HasExited)
601 try { existing.Dispose(); }
catch { }
610 if (toStop is not
null)
613 await Task.Delay(TimeSpan.FromMilliseconds(700), cancellationToken);
618 await
_sync.WaitAsync(cancellationToken);
666 Process? toStop =
null;
667 await
_sync.WaitAsync(cancellationToken);
670 if (
_processes.TryGetValue(cameraId, out toStop))
681 if (toStop is not
null)
684 await Task.Delay(TimeSpan.FromMilliseconds(700), cancellationToken);
689 await
_sync.WaitAsync(cancellationToken);
732 if (cancellationToken.IsCancellationRequested)
743 Process? deadProcess =
null;
744 bool needRestart =
false;
746 await
_sync.WaitAsync(cancellationToken);
749 if (!
_processes.TryGetValue(camera.
Id, out Process? process) || process.HasExited)
753 _runtimeState.IncrementRestart(camera.
Id, $
"FFmpeg process exited. Restarting: {camera.Name}");
767 if (!File.Exists(m3u8Path))
774 DateTime lastWrite =
_lastWrites.GetValueOrDefault(camera.
Id, DateTime.MinValue);
776 if (
sequence > lastSequence || writeTime > lastWrite)
779 _lastWrites[camera.
Id] = writeTime > lastWrite ? writeTime : lastWrite;
784 double idleSeconds = (DateTime.UtcNow - lastWrite.ToUniversalTime()).TotalSeconds;
785 int idleLimit = Math.Max(
789 if (idleSeconds >= idleLimit)
791 _runtimeState.IncrementRestart(camera.
Id, $
"HLS idle timeout. Restarting: {camera.Name}");
792 deadProcess = process;
803 if (deadProcess is not
null)
813 TimeSpan.FromSeconds(Math.Max(0,
_options.RestartDelaySeconds)),
816 catch (OperationCanceledException)
821 await
_sync.WaitAsync(cancellationToken);
858 if (
string.IsNullOrWhiteSpace(camera.
RtspUrl) || camera.
RtspUrl.Contains(
"CHANGE_ME", StringComparison.OrdinalIgnoreCase))
875 ProcessStartInfo startInfo =
new()
878 Arguments = arguments,
879 UseShellExecute =
false,
880 CreateNoWindow =
true,
881 RedirectStandardError =
true,
882 RedirectStandardOutput =
true,
883 RedirectStandardInput =
true
886 Process process =
new() { StartInfo = startInfo, EnableRaisingEvents =
true };
887 process.Exited += (_, _) =>
898 process.ErrorDataReceived += (_, args) =>
900 if (
string.IsNullOrWhiteSpace(args.Data))
905 _logger.LogWarning(
"[ffmpeg:{CameraId}] {Line}", camera.
Id, args.Data);
907 process.OutputDataReceived += (_, _) => { };
916 process.BeginErrorReadLine();
917 process.BeginOutputReadLine();
920 _logger.LogInformation(
"Started FFmpeg HLS for camera {CameraId}.", camera.
Id);
925 _logger.LogError(ex,
"Failed to start FFmpeg for camera {CameraId}.", camera.
Id);
974 if (process.HasExited)
981 bool gracefulOk =
false;
984 if (process.StartInfo.RedirectStandardInput && !process.HasExited)
986 await process.StandardInput.WriteAsync(
'q');
987 await process.StandardInput.FlushAsync(cancellationToken);
988 process.StandardInput.Close();
991 using CancellationTokenSource gracefulCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
996 await process.WaitForExitAsync(gracefulCts.Token);
999 catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
1004 catch (Exception ex)
1006 _logger.LogDebug(ex,
"Graceful stop attempt failed for {CameraId}.", cameraId);
1010 if (!gracefulOk && !process.HasExited)
1014 process.Kill(entireProcessTree:
true);
1016 catch (Exception ex)
1018 _logger.LogWarning(ex,
"Kill failed for {CameraId}.", cameraId);
1023 using CancellationTokenSource killCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
1025 await process.WaitForExitAsync(killCts.Token);
1027 catch (OperationCanceledException)
1029 _logger.LogWarning(
"FFmpeg did not exit within timeout for {CameraId}.", cameraId);
1035 try { process.Dispose(); }
catch { }
1057 if (!
_processes.TryGetValue(cameraId, out Process? process))
1064 if (!process.HasExited)
1066 process.Kill(entireProcessTree:
true);
1067 process.WaitForExit(1000);
1070 catch (Exception ex)
1072 _logger.LogWarning(ex,
"Failed to force-kill FFmpeg process for {CameraId}.", cameraId);
1076 try { process.Dispose(); }
catch { }
1137 bool isDshow = camera.
RtspUrl.StartsWith(
"dshow://", StringComparison.OrdinalIgnoreCase);
1140 string sessionStamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString(System.Globalization.CultureInfo.InvariantCulture);
1142 string videoCodec = (_options.VideoCodec ??
"libx264").ToLowerInvariant();
1143 string audioCodec = (_options.AudioCodec ??
"an").ToLowerInvariant();
1144 int segmentSeconds = Math.Max(1,
_options.HlsSegmentSeconds);
1145 int listSize = Math.Max(4,
_options.HlsListSize);
1146 int videoFps = Math.Max(1,
_options.VideoFps);
1147 int keyFrameInterval = segmentSeconds * videoFps;
1151 string inputDevice = isDshow
1155 List<string> parts =
new() {
"-hide_banner",
"-loglevel warning" };
1159 parts.Add(
"-f dshow");
1163 parts.Add(
"-rtsp_transport tcp");
1164 parts.Add(
"-rtsp_flags prefer_tcp");
1165 parts.Add(
"-analyzeduration 2000000");
1166 parts.Add(
"-probesize 2000000");
1169 parts.Add(
"-fflags +genpts+discardcorrupt+nobuffer");
1170 parts.Add(
"-flags low_delay");
1172 parts.Add(inputDevice);
1173 parts.Add(
"-map 0:v:0");
1174 parts.Add(
"-map 0:a?");
1178 if (videoCodec ==
"copy")
1180 parts.Add(
"-c:v copy");
1184 parts.Add(
"-c:v libx264");
1185 parts.Add(
"-preset ultrafast");
1186 parts.Add(
"-tune zerolatency");
1187 parts.Add(
"-profile:v baseline");
1188 parts.Add(
"-level:v 3.1");
1189 parts.Add(
"-pix_fmt yuv420p");
1192 parts.Add($
"-vf {Quote($"scale=
'min({_options.VideoMaxWidth},iw)':-2,fps={videoFps}
")}");
1196 parts.Add($
"-r {videoFps}");
1198 parts.Add($
"-b:v {_options.VideoBitrate}");
1199 parts.Add($
"-maxrate {_options.VideoMaxRate}");
1200 parts.Add($
"-bufsize {_options.VideoBufferSize}");
1201 parts.Add($
"-g {keyFrameInterval}");
1202 parts.Add($
"-keyint_min {keyFrameInterval}");
1203 parts.Add(
"-sc_threshold 0");
1205 parts.Add($
"-force_key_frames {Quote($"expr:gte(t,n_forced*{segmentSeconds})
")}");
1208 if (audioCodec ==
"an")
1214 parts.Add($
"-c:a {audioCodec}");
1215 parts.Add($
"-b:a {_options.AudioBitrate}");
1216 parts.Add($
"-ar {_options.AudioRate}");
1217 parts.Add($
"-ac {_options.AudioChannels}");
1220 parts.Add(
"-muxdelay 0");
1221 parts.Add(
"-muxpreload 0");
1222 parts.Add(
"-f hls");
1223 parts.Add(
"-hls_segment_type mpegts");
1224 parts.Add(
"-hls_allow_cache 0");
1225 parts.Add($
"-hls_time {segmentSeconds}");
1226 parts.Add($
"-hls_list_size {listSize}");
1231 parts.Add(
"-hls_flags omit_endlist+independent_segments+temp_file");
1232 parts.Add($
"-hls_segment_filename {segmentPath}");
1233 parts.Add(playlistPath);
1235 return string.Join(
' ', parts);
1257 if (File.Exists(path))
1296 DateTime writeTime = File.GetLastWriteTimeUtc(path);
1299 string text = File.ReadAllText(path);
1301 long sequence = match.Success &&
long.TryParse(match.Groups[1].Value, out
long value) ? value : -1;
1306 return (-1, writeTime);
1328 return Path.IsPathRooted(
_options.OutputRoot)
1329 ? _options.OutputRoot
1330 : Path.Combine(AppContext.BaseDirectory,
_options.OutputRoot);
1410 Directory.CreateDirectory(directory);
1413 string preparingPlaylist =
string.Join(Environment.NewLine,
1416 "#EXT-X-TARGETDURATION:1",
1417 "#EXT-X-MEDIA-SEQUENCE:0",
1422 File.WriteAllText(playlistPath, preparingPlaylist);
1424 catch (IOException ex)
1426 _logger.LogDebug(ex,
"Playlist reset skipped because the file is currently locked: {CameraId}.", cameraId);
1428 catch (UnauthorizedAccessException ex)
1430 _logger.LogDebug(ex,
"Playlist reset skipped because access was denied: {CameraId}.", cameraId);
1461 if (!Directory.Exists(directory))
1466 DateTime thresholdUtc = DateTime.UtcNow - minimumAge;
1468 foreach (
string file
in Directory.EnumerateFiles(directory,
"seg_*.ts"))
1472 if (File.GetLastWriteTimeUtc(file) > thresholdUtc)
1483 catch (UnauthorizedAccessException)
1514 private static string Quote(
string value)
1516 return $
"\"{value}\"";
FfmpegHlsStreamService(ILogger< FfmpegHlsStreamService > logger, IVmsCameraRepository repository, ICameraRuntimeStateService runtimeState, IOptions< FfmpegOptions > options)
readonly ICameraRuntimeStateService _runtimeState
async Task ShutdownProcessAsync(string cameraId, Process process, CancellationToken cancellationToken)
string BuildArguments(CameraDevice camera)
readonly Dictionary< string, Process > _processes
readonly? ChildProcessJob _processJob
void CleanupOldSegmentFiles(string cameraId, TimeSpan minimumAge)
void PreparePlaylistForNewSession(string cameraId)
static readonly TimeSpan KillWaitTimeout
SemaphoreSlim GetCameraOperationLock(string cameraId)
static string Quote(string value)
static readonly TimeSpan GracefulExitTimeout
async Task StopAsync(string cameraId, CancellationToken cancellationToken=default)
void InitWatchStats(string cameraId)
readonly IVmsCameraRepository _repository
void ForceKillProcessUnsafe(string cameraId)
override async Task StopAsync(CancellationToken cancellationToken)
readonly Dictionary< string, long > _lastSequences
async Task StartAllAsync(CancellationToken cancellationToken=default)
static long DateTime writeTime ReadSequenceAndWriteTime(string path)
async Task StartAsync(string cameraId, CancellationToken cancellationToken=default)
async Task StartInternalAsync(string cameraId, CancellationToken cancellationToken)
async Task WatchdogAsync(CancellationToken cancellationToken)
override async Task ExecuteAsync(CancellationToken stoppingToken)
readonly ILogger< FfmpegHlsStreamService > _logger
string GetPlaylistPath(string cameraId)
string GetCameraOutputDirectory(string cameraId)
readonly FfmpegOptions _options
async Task StopInternalAsync(string cameraId, CancellationToken cancellationToken)
async Task StopAllAsync(CancellationToken cancellationToken=default)
readonly SemaphoreSlim _sync
readonly Dictionary< string, DateTime > _lastWrites
void StartProcessUnsafe(CameraDevice camera)
readonly ConcurrentDictionary< string, byte > _intentionalStops
readonly ConcurrentDictionary< string, SemaphoreSlim > _cameraOperationLocks
static readonly Regex MediaSeqRegex