YouTubeFeedPollingService.cs 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
  1. using Application.Abstractions.Data;
  2. using Application.Abstractions.YouTube;
  3. using Application.Abstractions.Messaging;
  4. using Microsoft.EntityFrameworkCore;
  5. using Microsoft.Extensions.DependencyInjection;
  6. using Microsoft.Extensions.Hosting;
  7. using Microsoft.Extensions.Logging;
  8. using Microsoft.Extensions.Options;
  9. using SharedKernel;
  10. namespace Infrastructure.YouTube;
  11. /// <summary>
  12. /// YouTube 공개 Atom Feed를 주기 폴링하여 신규 비디오를 감지.
  13. /// - YouTube PubSub이 라이브 이벤트를 누락하는 문제 보완
  14. /// - Feed 다운로드 자체는 0 unit (공개 XML)
  15. /// - 신규 videoId 감지 시 기존 PubSubNotify.Command 재사용 (downstream 파이프라인 통일)
  16. /// - 첫 실행 시 기존 영상들은 bootstrap으로 "마지막 처리"에만 기록 (무더기 처리 방지)
  17. /// </summary>
  18. internal sealed class YouTubeFeedPollingService(
  19. IServiceScopeFactory scopeFactory,
  20. IYouTubeFeedPoller feedPoller,
  21. IYouTubePubSubService pubSubService,
  22. IOptions<AppSettings> settings,
  23. ILogger<YouTubeFeedPollingService> logger
  24. ) : BackgroundService
  25. {
  26. private static readonly TimeSpan InitialDelay = TimeSpan.FromSeconds(60);
  27. private static readonly TimeSpan InterChannelDelay = TimeSpan.FromMilliseconds(500);
  28. protected override async Task ExecuteAsync(CancellationToken stoppingToken)
  29. {
  30. await Task.Delay(InitialDelay, stoppingToken);
  31. var intervalMinutes = settings.Value.YouTube.FeedPollingIntervalMinutes;
  32. if (intervalMinutes <= 0)
  33. {
  34. intervalMinutes = 3;
  35. }
  36. var interval = TimeSpan.FromMinutes(intervalMinutes);
  37. logger.LogInformation("[FeedPoller] 서비스 시작 — 폴링 주기: {Interval}분", intervalMinutes);
  38. var bootstrapped = false;
  39. while (!stoppingToken.IsCancellationRequested)
  40. {
  41. try
  42. {
  43. await PollOnceAsync(bootstrapped, stoppingToken);
  44. bootstrapped = true;
  45. }
  46. catch (OperationCanceledException)
  47. {
  48. break;
  49. }
  50. catch (Exception ex)
  51. {
  52. logger.LogError(ex, "[FeedPoller] Loop 예외");
  53. }
  54. try
  55. {
  56. await Task.Delay(interval, stoppingToken);
  57. }
  58. catch (OperationCanceledException)
  59. {
  60. break;
  61. }
  62. }
  63. }
  64. private async Task PollOnceAsync(bool bootstrapped, CancellationToken ct)
  65. {
  66. using var scope = scopeFactory.CreateScope();
  67. var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
  68. var mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
  69. var channels = await db.Channel.AsNoTracking()
  70. .Where(c => c.IsActive && c.YouTubeChannelID != null && c.YouTubeChannelID != "")
  71. .Select(c => c.YouTubeChannelID!)
  72. .ToListAsync(ct);
  73. if (channels.Count == 0)
  74. {
  75. logger.LogDebug("[FeedPoller] 활성 채널 없음");
  76. return;
  77. }
  78. logger.LogDebug("[FeedPoller] Loop start — {Count} channels, bootstrapped={Bootstrapped}",
  79. channels.Count, bootstrapped);
  80. var detected = 0;
  81. var skipped = 0;
  82. foreach (var ytChannelId in channels)
  83. {
  84. if (ct.IsCancellationRequested)
  85. {
  86. break;
  87. }
  88. try
  89. {
  90. // 신규 감지 여부와 무관하게 "이 채널을 지금 폴링했음"을 기록
  91. await pubSubService.SetLastPolledAtAsync(ytChannelId, DateTime.UtcNow, ct);
  92. var latest = await feedPoller.GetLatestVideoIdAsync(ytChannelId, ct);
  93. if (string.IsNullOrEmpty(latest))
  94. {
  95. continue;
  96. }
  97. var lastProcessed = await pubSubService.GetLastProcessedVideoIdAsync(ytChannelId, ct);
  98. if (lastProcessed == latest)
  99. {
  100. skipped++;
  101. continue;
  102. }
  103. if (!bootstrapped && lastProcessed is null)
  104. {
  105. // 첫 루프 + Redis에 기록 없음 = 배포 직후 상태
  106. // 기존 영상을 "신규"로 오인하지 않도록 기록만 하고 trigger skip
  107. await pubSubService.SetLastProcessedVideoIdAsync(ytChannelId, latest, ct);
  108. logger.LogInformation("[FeedPoller] Bootstrap — channelId={ChannelId}, videoId={VideoId} recorded without trigger",
  109. ytChannelId, latest);
  110. continue;
  111. }
  112. // 신규 videoId 감지 → PubSubNotify 파이프라인 재사용
  113. logger.LogInformation("[FeedPoller] 신규 비디오 감지 — channelId={ChannelId}, videoId={VideoId} (prev={Prev})",
  114. ytChannelId, latest, lastProcessed ?? "(none)");
  115. await mediator.Send(new Application.Features.Api.YouTube.PubSubNotify.Command(
  116. latest, ytChannelId, string.Empty, DateTime.UtcNow, DateTime.UtcNow
  117. ), ct);
  118. // Handler 내부에서도 SetLastProcessed 호출하지만 여기서도 명시적으로 업데이트
  119. // (Handler가 라이브 아니라고 판단해 일찍 return해도 이 videoId는 더 안 봄)
  120. await pubSubService.SetLastProcessedVideoIdAsync(ytChannelId, latest, ct);
  121. detected++;
  122. }
  123. catch (OperationCanceledException)
  124. {
  125. throw;
  126. }
  127. catch (Exception ex)
  128. {
  129. logger.LogError(ex, "[FeedPoller] 채널 처리 예외 — channelId={ChannelId}", ytChannelId);
  130. }
  131. await Task.Delay(InterChannelDelay, ct);
  132. }
  133. logger.LogInformation("[FeedPoller] Loop 완료 — total={Total}, detected={Detected}, skipped={Skipped}",
  134. channels.Count, detected, skipped);
  135. }
  136. }