Handler.cs 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103
  1. using Application.Abstractions.Data;
  2. using Application.Abstractions.Hub;
  3. using Application.Abstractions.Messaging;
  4. using Application.Abstractions.YouTube;
  5. using Domain.Entities.Members;
  6. using Domain.Entities.Members.ValueObject;
  7. using Microsoft.EntityFrameworkCore;
  8. using Microsoft.Extensions.Logging;
  9. namespace Application.Features.Api.YouTube.PubSubNotify;
  10. internal sealed class Handler(
  11. IAppDbContext db,
  12. IYouTubeApiService youTubeApi,
  13. IYouTubeLiveChatService liveChatService,
  14. IYouTubeLiveStateStore liveStateStore,
  15. IYouTubePubSubService pubSubService,
  16. IChannelStatusBroadcaster statusBroadcaster,
  17. ILogger<Handler> logger
  18. ) : ICommandHandler<Command>
  19. {
  20. public async Task Handle(Command request, CancellationToken ct)
  21. {
  22. if (string.IsNullOrWhiteSpace(request.VideoID) || string.IsNullOrWhiteSpace(request.YouTubeChannelID))
  23. {
  24. logger.LogWarning("[PubSub] Missing videoId or channelId");
  25. return;
  26. }
  27. // 중복 처리 방지
  28. var firstTime = await pubSubService.TryMarkProcessedAsync(request.VideoID, ct);
  29. if (!firstTime)
  30. {
  31. logger.LogDebug("[PubSub] Duplicate notify ignored: videoId={VideoId}", request.VideoID);
  32. return;
  33. }
  34. // 라이브 정보 조회
  35. var liveInfo = await youTubeApi.GetLiveStreamInfoAsync(request.VideoID, ct);
  36. if (liveInfo is null)
  37. {
  38. logger.LogInformation("[PubSub] Video not found (may be private/deleted): videoId={VideoId}", request.VideoID);
  39. return;
  40. }
  41. // 라이브 방송이 아니면 종료 (VOD/upcoming 무시 — actualStartTime 필수)
  42. if (!liveInfo.IsLive || liveInfo.ActualStartTime is null)
  43. {
  44. logger.LogDebug(
  45. "[PubSub] Skip — not live: videoId={VideoId}, content={Content}",
  46. request.VideoID, liveInfo.LiveBroadcastContent
  47. );
  48. return;
  49. }
  50. // 내부 Channel 조회 (YouTube channel ID → 내부 Channel.ID)
  51. var channel = await db.Channel
  52. .FirstOrDefaultAsync(c => c.YouTubeChannelID == request.YouTubeChannelID && c.IsActive, ct);
  53. if (channel is null)
  54. {
  55. logger.LogWarning("[PubSub] Internal channel not found for YouTubeChannelID={YtChannelId}", request.YouTubeChannelID);
  56. return;
  57. }
  58. // 중복 BroadcastSession 체크
  59. var existing = await db.BroadcastSession
  60. .FirstOrDefaultAsync(b => b.Platform == BroadcastPlatform.YouTube && b.VideoID == request.VideoID, ct);
  61. if (existing is null)
  62. {
  63. var session = BroadcastSession.Create(channel.ID, BroadcastPlatform.YouTube, request.VideoID);
  64. session.UpdateStats(liveInfo.Title, liveInfo.ActualStartTime, null, 0, 0);
  65. db.BroadcastSession.Add(session);
  66. await db.SaveChangesAsync(ct);
  67. logger.LogInformation(
  68. "[PubSub] BroadcastSession created: channelID={ChannelId}, videoId={VideoId}, title={Title}",
  69. channel.ID, request.VideoID, liveInfo.Title
  70. );
  71. }
  72. else
  73. {
  74. logger.LogDebug("[PubSub] BroadcastSession already exists: videoId={VideoId}", request.VideoID);
  75. }
  76. // Redis 라이브 상태 저장 (watch 페이지에서 조회용)
  77. await liveStateStore.SetLiveAsync(request.YouTubeChannelID, liveInfo);
  78. // SignalR 브로드캐스트 — ChannelSidebar / WatchView 가 실시간 반영
  79. await statusBroadcaster.BroadcastAsync(channel.SID, isLive: true, viewerCount: 0, videoId: request.VideoID, ct);
  80. // ⚠️ liveChatMessages.list = 5 unit/call (가장 비싼 일반 read). 5초 폴링 시 시간당 3,600 unit → 라이브 3시간이면 일일 quota 10K 다 소모.
  81. // antooza 채팅은 YouTube iframe 으로 대체되어 backend 채팅 메시지 수집 불필요.
  82. // quota 증액 승인 후 재활성화 (memory: plan_dpot_chat_reactivate_after_quota.md).
  83. // 강제 활성화하려면 환경변수 등으로 toggle 추가 필요.
  84. // await liveChatService.StartAsync(request.VideoID, ct);
  85. _ = liveChatService; // 의존성은 유지 (DI 깨지지 않게)
  86. // Feed Polling 중복 감지 방지 — "마지막 처리된 videoId" 기록
  87. await pubSubService.SetLastProcessedVideoIdAsync(request.YouTubeChannelID, request.VideoID, ct);
  88. }
  89. }