| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103 |
- 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<Handler> logger
- ) : ICommandHandler<Command>
- {
- 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);
- }
- }
|