using Application.Abstractions.Data; using Application.Abstractions.Hub; using Application.Abstractions.Messaging; using Application.Abstractions.YouTube; using Domain.Entities.Members; using Domain.Entities.Members.ValueObject; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; namespace Application.Features.Api.YouTube.PubSubNotify; internal sealed class Handler( IAppDbContext db, IYouTubeApiService youTubeApi, IYouTubeLiveChatService liveChatService, IYouTubeLiveStateStore liveStateStore, IYouTubePubSubService pubSubService, IChannelStatusBroadcaster statusBroadcaster, ILogger logger ) : ICommandHandler { public async Task Handle(Command request, CancellationToken ct) { if (string.IsNullOrWhiteSpace(request.VideoID) || string.IsNullOrWhiteSpace(request.YouTubeChannelID)) { logger.LogWarning("[PubSub] Missing videoId or channelId"); return; } // 중복 처리 방지 var firstTime = await pubSubService.TryMarkProcessedAsync(request.VideoID, ct); if (!firstTime) { logger.LogDebug("[PubSub] Duplicate notify ignored: videoId={VideoId}", request.VideoID); return; } // 라이브 정보 조회 var liveInfo = await youTubeApi.GetLiveStreamInfoAsync(request.VideoID, ct); if (liveInfo is null) { logger.LogInformation("[PubSub] Video not found (may be private/deleted): videoId={VideoId}", request.VideoID); return; } // 라이브 방송이 아니면 종료 (VOD/upcoming 무시 — actualStartTime 필수) if (!liveInfo.IsLive || liveInfo.ActualStartTime is null) { logger.LogDebug( "[PubSub] Skip — not live: videoId={VideoId}, content={Content}", request.VideoID, liveInfo.LiveBroadcastContent ); return; } // 내부 Channel 조회 (YouTube channel ID → 내부 Channel.ID) var channel = await db.Channel .FirstOrDefaultAsync(c => c.YouTubeChannelID == request.YouTubeChannelID && c.IsActive, ct); if (channel is null) { logger.LogWarning("[PubSub] Internal channel not found for YouTubeChannelID={YtChannelId}", request.YouTubeChannelID); return; } // 중복 BroadcastSession 체크 var existing = await db.BroadcastSession .FirstOrDefaultAsync(b => b.Platform == BroadcastPlatform.YouTube && b.VideoID == request.VideoID, ct); if (existing is null) { var session = BroadcastSession.Create(channel.ID, BroadcastPlatform.YouTube, request.VideoID); session.UpdateStats(liveInfo.Title, liveInfo.ActualStartTime, null, 0, 0); db.BroadcastSession.Add(session); await db.SaveChangesAsync(ct); logger.LogInformation( "[PubSub] BroadcastSession created: channelID={ChannelId}, videoId={VideoId}, title={Title}", channel.ID, request.VideoID, liveInfo.Title ); } else { logger.LogDebug("[PubSub] BroadcastSession already exists: videoId={VideoId}", request.VideoID); } // Redis 라이브 상태 저장 (watch 페이지에서 조회용) await liveStateStore.SetLiveAsync(request.YouTubeChannelID, liveInfo); // SignalR 브로드캐스트 — ChannelSidebar / WatchView 가 실시간 반영 await statusBroadcaster.BroadcastAsync(channel.SID, isLive: true, viewerCount: 0, videoId: request.VideoID, ct); // ⚠️ liveChatMessages.list = 5 unit/call (가장 비싼 일반 read). 5초 폴링 시 시간당 3,600 unit → 라이브 3시간이면 일일 quota 10K 다 소모. // antooza 채팅은 YouTube iframe 으로 대체되어 backend 채팅 메시지 수집 불필요. // quota 증액 승인 후 재활성화 (memory: plan_dpot_chat_reactivate_after_quota.md). // 강제 활성화하려면 환경변수 등으로 toggle 추가 필요. // await liveChatService.StartAsync(request.VideoID, ct); _ = liveChatService; // 의존성은 유지 (DI 깨지지 않게) // Feed Polling 중복 감지 방지 — "마지막 처리된 videoId" 기록 await pubSubService.SetLastProcessedVideoIdAsync(request.YouTubeChannelID, request.VideoID, ct); } }