DreamineVMS 1.0.0.0
Windows PC의 RTSP·USB 카메라 영상을 HLS로 변환해 원격 CCTV Viewer에 전달하는 데스크톱 에이전트입니다.
로딩중...
검색중...
일치하는것 없음
HlsSegmentPusherService.cs
이 파일의 문서화 페이지로 가기
2using Microsoft.Extensions.Configuration;
3using Microsoft.Extensions.Hosting;
4using Microsoft.Extensions.Logging;
5using System.IO;
6
8
17public sealed class HlsSegmentPusherService : BackgroundService
18{
27 private readonly AgentApiClient _api;
45 private readonly IConfiguration _config;
54 private readonly ILogger<HlsSegmentPusherService> _logger;
63 private readonly SemaphoreSlim _loginLock = new(1, 1);
64
106 AgentApiClient api,
107 IVmsCameraRepository repository,
108 IConfiguration config,
109 ILogger<HlsSegmentPusherService> logger)
110 {
111 _api = api;
112 _repository = repository;
113 _config = config;
114 _logger = logger;
115 }
116
141 protected override async Task ExecuteAsync(CancellationToken stoppingToken)
142 {
143 // 자격증명이 설정될 때까지 대기 (앱 실행 후 Agent Settings에서 입력해도 동작)
144 while (!stoppingToken.IsCancellationRequested)
145 {
146 if (!string.IsNullOrWhiteSpace(_config["Agent:Email"]) &&
147 !string.IsNullOrWhiteSpace(_config["Agent:Password"]))
148 break;
149
150 _logger.LogInformation("[Agent] Agent:Email/Password 미설정 — 30초 후 재확인.");
151 await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
152 }
153
154 if (stoppingToken.IsCancellationRequested) return;
155
156 var outputRoot = GetOutputRoot();
157 _logger.LogInformation("[Agent] 세그먼트 업로드 시작. 루트: {Root}", outputRoot);
158
159 // 카메라 목록을 동적으로 관리하는 메인 루프
160 var runningCameras = new Dictionary<string, CancellationTokenSource>();
161
162 while (!stoppingToken.IsCancellationRequested)
163 {
164 await EnsureLoggedInAsync(stoppingToken);
165 if (_api.Token is null) { await Task.Delay(5000, stoppingToken); continue; }
166
167 await SyncCamerasAsync();
168
169 // 활성 카메라 목록과 비교해서 추가/제거
170 var currentIds = _repository.GetAll()
171 .Where(c => c.Enabled && !c.IsDirectHls)
172 .Select(c => c.Id)
173 .ToHashSet();
174
175 // 제거된 카메라 루프 취소
176 foreach (var id in runningCameras.Keys.Except(currentIds).ToList())
177 {
178 runningCameras[id].Cancel();
179 runningCameras.Remove(id);
180 _logger.LogInformation("[Agent] 카메라 루프 중지: {Id}", id);
181 }
182
183 // 새 카메라 루프 시작
184 foreach (var id in currentIds.Except(runningCameras.Keys))
185 {
186 var cts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
187 runningCameras[id] = cts;
188 _ = Task.Run(() => RunCameraLoopAsync(id, outputRoot, cts.Token), cts.Token);
189 _logger.LogInformation("[Agent] 카메라 루프 시작: {Id}", id);
190 }
191
192 await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
193 }
194
195 // 종료 시 모든 루프 취소
196 foreach (var cts in runningCameras.Values) cts.Cancel();
197 }
198
239 private async Task RunCameraLoopAsync(string cameraId, string outputRoot, CancellationToken ct)
240 {
241 var pushed = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
242 var camDir = Path.Combine(outputRoot, cameraId);
243 byte[]? lastPushedM3u8 = null;
244
245 while (!ct.IsCancellationRequested)
246 {
247 try
248 {
249 if (_api.Token is null)
250 {
251 await EnsureLoggedInAsync(ct);
252 if (_api.Token is null)
253 {
254 await Task.Delay(2000, ct);
255 continue;
256 }
257
258 await SyncCamerasAsync();
259 }
260
261 if (!Directory.Exists(camDir))
262 {
263 await Task.Delay(1000, ct);
264 continue;
265 }
266
267 var m3u8Path = Path.Combine(camDir, "index.m3u8");
268 if (!File.Exists(m3u8Path))
269 {
270 await Task.Delay(500, ct);
271 continue;
272 }
273
274 byte[] m3u8Bytes;
275 List<string> refs;
276 try
277 {
278 m3u8Bytes = await File.ReadAllBytesAsync(m3u8Path, ct);
279 refs = ParseSegmentsFromM3u8(m3u8Bytes);
280 }
281 catch (IOException)
282 {
283 await Task.Delay(100, ct);
284 continue;
285 }
286
287 if (refs.Count == 0)
288 {
289 await Task.Delay(500, ct);
290 continue;
291 }
292
293 bool allRefsPushed = true;
294 foreach (string filename in refs)
295 {
296 if (ct.IsCancellationRequested || _api.Token is null)
297 {
298 allRefsPushed = false;
299 break;
300 }
301
302 if (pushed.Contains(filename))
303 {
304 continue;
305 }
306
307 string segmentPath = Path.Combine(camDir, filename);
308 if (!File.Exists(segmentPath))
309 {
310 allRefsPushed = false;
311 break;
312 }
313
314 bool ok = await TryPushFileAsync(cameraId, segmentPath, filename);
315 if (ok)
316 {
317 pushed.Add(filename);
318 }
319 else
320 {
321 allRefsPushed = false;
322 break;
323 }
324 }
325
326 if (allRefsPushed && !m3u8Bytes.AsSpan().SequenceEqual(lastPushedM3u8))
327 {
328 bool ok = await _api.PushSegmentAsync(cameraId, "index.m3u8", m3u8Bytes);
329 if (ok)
330 {
331 lastPushedM3u8 = m3u8Bytes;
332 }
333 }
334
335 if (pushed.Count > 200)
336 {
337 pushed.RemoveWhere(filename => !refs.Contains(filename, StringComparer.OrdinalIgnoreCase));
338 }
339 }
340 catch (OperationCanceledException) { break; }
341 catch (Exception ex)
342 {
343 _logger.LogWarning(ex, "[Agent] [{Cam}] 스캔 중 오류", cameraId);
344 }
345
346 await Task.Delay(200, ct);
347 }
348 }
349
374 private async Task LoginWithRetryAsync(CancellationToken ct)
375 {
376 while (!ct.IsCancellationRequested && _api.Token is null)
377 {
378 var (ok, err) = await _api.LoginWithStoredCredentialsAsync();
379 if (ok) { _logger.LogInformation("[Agent] 로그인 성공."); return; }
380 _logger.LogWarning("[Agent] 로그인 실패: {Err}. 10초 후 재시도.", err);
381 await Task.Delay(TimeSpan.FromSeconds(10), ct);
382 }
383 }
384
409 private async Task EnsureLoggedInAsync(CancellationToken ct)
410 {
411 if (_api.Token is not null)
412 {
413 return;
414 }
415
416 await _loginLock.WaitAsync(ct);
417 try
418 {
419 if (_api.Token is not null)
420 {
421 return;
422 }
423
424 await LoginWithRetryAsync(ct);
425 }
426 finally
427 {
428 _loginLock.Release();
429 }
430 }
431
448 private async Task SyncCamerasAsync()
449 {
450 var localCams = _repository.GetAll()
451 .Where(c => c.Enabled && !c.IsDirectHls)
452 .Select(c => new AgentCameraInfo
453 {
454 Id = c.Id, Name = c.Name, Host = c.Host,
455 RtspUrl = c.RtspUrl, AutoReconnect = c.AutoReconnect, IsPublic = c.IsPublic
456 }).ToList();
457
458 var ok = await _api.SyncCamerasAsync(localCams);
459 _logger.LogInformation("[Agent] 카메라 {Count}개 서버 동기화 {Result}.",
460 localCams.Count, ok ? "성공" : "실패");
461 }
462
503 private async Task<bool> TryPushFileAsync(string cameraId, string filePath, string filename)
504 {
505 try
506 {
507 var data = await File.ReadAllBytesAsync(filePath);
508 return await _api.PushSegmentAsync(cameraId, filename, data);
509 }
510 catch (Exception ex)
511 {
512 _logger.LogWarning(ex, "[Agent] [{Cam}] 세그먼트 파일 읽기 실패: {File}", cameraId, filename);
513 return false;
514 }
515 }
516
541 private static List<string> ParseSegmentsFromM3u8(byte[] bytes)
542 {
543 try
544 {
545 return System.Text.Encoding.UTF8.GetString(bytes)
546 .Split('\n')
547 .Where(l => l.TrimEnd().EndsWith(".ts", StringComparison.OrdinalIgnoreCase))
548 .Select(l => Path.GetFileName(l.Trim()))
549 .Where(f => !string.IsNullOrEmpty(f))
550 .ToList()!;
551 }
552 catch { return []; }
553 }
554
571 private string GetOutputRoot()
572 {
573 var root = _config["Ffmpeg:OutputRoot"] ?? "hls";
574 return Path.IsPathRooted(root) ? root : Path.Combine(AppContext.BaseDirectory, root);
575 }
576}
readonly ILogger< HlsSegmentPusherService > _logger
async Task RunCameraLoopAsync(string cameraId, string outputRoot, CancellationToken ct)
async Task< bool > TryPushFileAsync(string cameraId, string filePath, string filename)
override async Task ExecuteAsync(CancellationToken stoppingToken)
static List< string > ParseSegmentsFromM3u8(byte[] bytes)
HlsSegmentPusherService(AgentApiClient api, IVmsCameraRepository repository, IConfiguration config, ILogger< HlsSegmentPusherService > logger)