using Application.Abstractions.Data; using Application.Abstractions.YouTube; using Application.Abstractions.Messaging; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using SharedKernel; namespace Infrastructure.YouTube; /// /// YouTube 공개 Atom Feed를 주기 폴링하여 신규 비디오를 감지. /// - YouTube PubSub이 라이브 이벤트를 누락하는 문제 보완 /// - Feed 다운로드 자체는 0 unit (공개 XML) /// - 신규 videoId 감지 시 기존 PubSubNotify.Command 재사용 (downstream 파이프라인 통일) /// - 첫 실행 시 기존 영상들은 bootstrap으로 "마지막 처리"에만 기록 (무더기 처리 방지) /// internal sealed class YouTubeFeedPollingService( IServiceScopeFactory scopeFactory, IYouTubeFeedPoller feedPoller, IYouTubePubSubService pubSubService, IOptions settings, ILogger logger ) : BackgroundService { private static readonly TimeSpan InitialDelay = TimeSpan.FromSeconds(60); private static readonly TimeSpan InterChannelDelay = TimeSpan.FromMilliseconds(500); protected override async Task ExecuteAsync(CancellationToken stoppingToken) { await Task.Delay(InitialDelay, stoppingToken); var intervalMinutes = settings.Value.YouTube.FeedPollingIntervalMinutes; if (intervalMinutes <= 0) { intervalMinutes = 3; } var interval = TimeSpan.FromMinutes(intervalMinutes); logger.LogInformation("[FeedPoller] 서비스 시작 — 폴링 주기: {Interval}분", intervalMinutes); var bootstrapped = false; while (!stoppingToken.IsCancellationRequested) { try { await PollOnceAsync(bootstrapped, stoppingToken); bootstrapped = true; } catch (OperationCanceledException) { break; } catch (Exception ex) { logger.LogError(ex, "[FeedPoller] Loop 예외"); } try { await Task.Delay(interval, stoppingToken); } catch (OperationCanceledException) { break; } } } private async Task PollOnceAsync(bool bootstrapped, CancellationToken ct) { using var scope = scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var mediator = scope.ServiceProvider.GetRequiredService(); var channels = await db.Channel.AsNoTracking() .Where(c => c.IsActive && c.YouTubeChannelID != null && c.YouTubeChannelID != "") .Select(c => c.YouTubeChannelID!) .ToListAsync(ct); if (channels.Count == 0) { logger.LogDebug("[FeedPoller] 활성 채널 없음"); return; } logger.LogDebug("[FeedPoller] Loop start — {Count} channels, bootstrapped={Bootstrapped}", channels.Count, bootstrapped); var detected = 0; var skipped = 0; foreach (var ytChannelId in channels) { if (ct.IsCancellationRequested) { break; } try { // 신규 감지 여부와 무관하게 "이 채널을 지금 폴링했음"을 기록 await pubSubService.SetLastPolledAtAsync(ytChannelId, DateTime.UtcNow, ct); var latest = await feedPoller.GetLatestVideoIdAsync(ytChannelId, ct); if (string.IsNullOrEmpty(latest)) { continue; } var lastProcessed = await pubSubService.GetLastProcessedVideoIdAsync(ytChannelId, ct); if (lastProcessed == latest) { skipped++; continue; } if (!bootstrapped && lastProcessed is null) { // 첫 루프 + Redis에 기록 없음 = 배포 직후 상태 // 기존 영상을 "신규"로 오인하지 않도록 기록만 하고 trigger skip await pubSubService.SetLastProcessedVideoIdAsync(ytChannelId, latest, ct); logger.LogInformation("[FeedPoller] Bootstrap — channelId={ChannelId}, videoId={VideoId} recorded without trigger", ytChannelId, latest); continue; } // 신규 videoId 감지 → PubSubNotify 파이프라인 재사용 logger.LogInformation("[FeedPoller] 신규 비디오 감지 — channelId={ChannelId}, videoId={VideoId} (prev={Prev})", ytChannelId, latest, lastProcessed ?? "(none)"); await mediator.Send(new Application.Features.Api.YouTube.PubSubNotify.Command( latest, ytChannelId, string.Empty, DateTime.UtcNow, DateTime.UtcNow ), ct); // Handler 내부에서도 SetLastProcessed 호출하지만 여기서도 명시적으로 업데이트 // (Handler가 라이브 아니라고 판단해 일찍 return해도 이 videoId는 더 안 봄) await pubSubService.SetLastProcessedVideoIdAsync(ytChannelId, latest, ct); detected++; } catch (OperationCanceledException) { throw; } catch (Exception ex) { logger.LogError(ex, "[FeedPoller] 채널 처리 예외 — channelId={ChannelId}", ytChannelId); } await Task.Delay(InterChannelDelay, ct); } logger.LogInformation("[FeedPoller] Loop 완료 — total={Total}, detected={Detected}, skipped={Skipped}", channels.Count, detected, skipped); } }