YouTubeLiveChatService.cs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342
  1. using System.Collections.Concurrent;
  2. using System.Text.Json;
  3. using Application.Abstractions.YouTube;
  4. using Microsoft.Extensions.Hosting;
  5. using Microsoft.Extensions.Logging;
  6. namespace Infrastructure.YouTube;
  7. /// <summary>
  8. /// YouTube 라이브 채팅 수집 서비스
  9. ///
  10. /// 동작 흐름:
  11. /// 1. StartAsync(videoId) 호출 → videos.list로 liveChatId 획득 (1 unit)
  12. /// 2. liveChatMessages.list 폴링 시작 (pollingIntervalMillis 준수)
  13. /// 3. 새 메시지 → OnMessageReceived 이벤트 발행 → ChatHub에서 브로드캐스트
  14. /// 4. 방송 종료 감지 → 자동 정리
  15. ///
  16. /// 향후 gRPC streamList로 전환 시 이 클래스 내부만 교체하면 됨
  17. /// (인터페이스 IYouTubeLiveChatService는 동일하게 유지)
  18. /// </summary>
  19. internal sealed class YouTubeLiveChatService(
  20. IYouTubeApiService youTubeApiService,
  21. IHttpClientFactory httpClientFactory,
  22. ILogger<YouTubeLiveChatService> logger
  23. ) : BackgroundService, IYouTubeLiveChatService
  24. {
  25. private const string BaseUrl = "https://www.googleapis.com/youtube/v3";
  26. private const int DefaultPollingMs = 10_000; // 10초 (API 응답 없으면 기본값)
  27. private const int MaxPollingMs = 30_000; // 최대 30초
  28. // liveChatId → CancellationTokenSource
  29. private readonly ConcurrentDictionary<string, ChatMonitorContext> _monitors = new();
  30. public event Func<YouTubeChatMessage, Task>? OnMessageReceived;
  31. // ── IYouTubeLiveChatService ──────────────────────────────────────
  32. public async Task<bool> StartAsync(string videoId, CancellationToken ct)
  33. {
  34. // videos.list로 liveChatId 획득 (~1 unit)
  35. var liveInfo = await youTubeApiService.GetLiveStreamInfoAsync(videoId, ct);
  36. if (liveInfo is null)
  37. {
  38. logger.LogWarning("[LiveChat] 영상 정보를 가져올 수 없음: videoId={VideoId}", videoId);
  39. return false;
  40. }
  41. if (!liveInfo.IsLive || string.IsNullOrEmpty(liveInfo.ActiveLiveChatId))
  42. {
  43. logger.LogWarning("[LiveChat] 라이브 방송이 아니거나 채팅이 비활성: videoId={VideoId}, status={Status}",
  44. videoId, liveInfo.LiveBroadcastContent);
  45. return false;
  46. }
  47. var liveChatId = liveInfo.ActiveLiveChatId;
  48. if (_monitors.ContainsKey(liveChatId))
  49. {
  50. logger.LogInformation("[LiveChat] 이미 모니터링 중: liveChatId={LiveChatId}", liveChatId);
  51. return true;
  52. }
  53. var cts = new CancellationTokenSource();
  54. var context = new ChatMonitorContext(videoId, liveChatId, liveInfo.ChannelId, cts);
  55. if (_monitors.TryAdd(liveChatId, context))
  56. {
  57. // 백그라운드에서 폴링 시작
  58. _ = Task.Run(() => PollChatMessagesAsync(context), CancellationToken.None);
  59. logger.LogInformation("[LiveChat] 모니터링 시작: videoId={VideoId}, liveChatId={LiveChatId}", videoId, liveChatId);
  60. return true;
  61. }
  62. return false;
  63. }
  64. public Task StopAsync(string liveChatId)
  65. {
  66. if (_monitors.TryRemove(liveChatId, out var context))
  67. {
  68. context.Cts.Cancel();
  69. context.Cts.Dispose();
  70. logger.LogInformation("[LiveChat] 모니터링 중지: liveChatId={LiveChatId}", liveChatId);
  71. }
  72. return Task.CompletedTask;
  73. }
  74. public bool IsMonitoring(string liveChatId) => _monitors.ContainsKey(liveChatId);
  75. public IReadOnlyList<string> GetActiveChatIds() => _monitors.Keys.ToList().AsReadOnly();
  76. // ── BackgroundService ────────────────────────────────────────────
  77. protected override Task ExecuteAsync(CancellationToken stoppingToken)
  78. {
  79. // 서비스 시작만 해둠 — 실제 폴링은 StartAsync 호출 시 개별 Task로 실행
  80. logger.LogInformation("[LiveChat] YouTubeLiveChatService 준비 완료");
  81. return Task.CompletedTask;
  82. }
  83. public override async Task StopAsync(CancellationToken cancellationToken)
  84. {
  85. logger.LogInformation("[LiveChat] 서비스 종료 — 모든 모니터 중지");
  86. foreach (var (liveChatId, context) in _monitors)
  87. {
  88. context.Cts.Cancel();
  89. context.Cts.Dispose();
  90. }
  91. _monitors.Clear();
  92. await base.StopAsync(cancellationToken);
  93. }
  94. // ── 폴링 루프 ───────────────────────────────────────────────────
  95. private async Task PollChatMessagesAsync(ChatMonitorContext context)
  96. {
  97. var ct = context.Cts.Token;
  98. string? pageToken = null;
  99. var seenMessageIds = new HashSet<string>();
  100. try
  101. {
  102. while (!ct.IsCancellationRequested)
  103. {
  104. var (messages, nextPageToken, pollingIntervalMs) = await FetchMessagesAsync(context.LiveChatId, pageToken, ct);
  105. if (messages is not null)
  106. {
  107. foreach (var msg in messages)
  108. {
  109. // 중복 제거
  110. if (!seenMessageIds.Add(msg.MessageId))
  111. {
  112. continue;
  113. }
  114. // 메모리 절약: 최근 2000개만 유지
  115. if (seenMessageIds.Count > 2000)
  116. {
  117. seenMessageIds.Clear();
  118. seenMessageIds.Add(msg.MessageId);
  119. }
  120. try
  121. {
  122. if (OnMessageReceived is not null)
  123. {
  124. await OnMessageReceived.Invoke(msg);
  125. }
  126. }
  127. catch (Exception ex)
  128. {
  129. logger.LogError(ex, "[LiveChat] OnMessageReceived handler error");
  130. }
  131. }
  132. }
  133. pageToken = nextPageToken;
  134. // pollingIntervalMillis 준수 (할당량 절약 핵심)
  135. var delay = Math.Clamp(pollingIntervalMs, 1000, MaxPollingMs);
  136. await Task.Delay(delay, ct);
  137. }
  138. }
  139. catch (OperationCanceledException)
  140. {
  141. // 정상 종료
  142. }
  143. catch (Exception ex)
  144. {
  145. logger.LogError(ex, "[LiveChat] 폴링 루프 오류: liveChatId={LiveChatId}", context.LiveChatId);
  146. }
  147. finally
  148. {
  149. _monitors.TryRemove(context.LiveChatId, out _);
  150. logger.LogInformation("[LiveChat] 폴링 종료: liveChatId={LiveChatId}", context.LiveChatId);
  151. }
  152. }
  153. private async Task<(List<YouTubeChatMessage>? Messages, string? NextPageToken, int PollingIntervalMs)>
  154. FetchMessagesAsync(string liveChatId, string? pageToken, CancellationToken ct)
  155. {
  156. try
  157. {
  158. var client = httpClientFactory.CreateClient("YouTubeApi");
  159. var url = $"{BaseUrl}/liveChat/messages?liveChatId={Uri.EscapeDataString(liveChatId)}&part=snippet,authorDetails&maxResults=500";
  160. if (!string.IsNullOrEmpty(pageToken))
  161. {
  162. url += $"&pageToken={Uri.EscapeDataString(pageToken)}";
  163. }
  164. var response = await client.GetAsync(url, ct);
  165. if (!response.IsSuccessStatusCode)
  166. {
  167. var statusCode = (int)response.StatusCode;
  168. // 403: 채팅 종료 또는 권한 없음
  169. if (statusCode is 403 or 404)
  170. {
  171. logger.LogInformation("[LiveChat] 채팅 종료 또는 접근 불가 ({StatusCode}): liveChatId={LiveChatId}",
  172. statusCode, liveChatId);
  173. // 모니터링 자동 중지
  174. if (_monitors.TryRemove(liveChatId, out var ctx))
  175. {
  176. ctx.Cts.Cancel();
  177. }
  178. return (null, null, MaxPollingMs);
  179. }
  180. logger.LogWarning("[LiveChat] API 오류: {StatusCode}", statusCode);
  181. return (null, pageToken, DefaultPollingMs);
  182. }
  183. var json = await response.Content.ReadAsStringAsync(ct);
  184. var doc = JsonSerializer.Deserialize<JsonElement>(json);
  185. var nextPageToken = doc.TryGetProperty("nextPageToken", out var nptProp) ? nptProp.GetString() : null;
  186. var pollingIntervalMs = doc.TryGetProperty("pollingIntervalMillis", out var piProp) ? piProp.GetInt32() : DefaultPollingMs;
  187. // offlineAt → 방송 종료
  188. if (doc.TryGetProperty("offlineAt", out _))
  189. {
  190. logger.LogInformation("[LiveChat] 방송 종료 감지: liveChatId={LiveChatId}", liveChatId);
  191. if (_monitors.TryRemove(liveChatId, out var ctx))
  192. {
  193. ctx.Cts.Cancel();
  194. }
  195. return (null, null, MaxPollingMs);
  196. }
  197. var items = doc.GetProperty("items");
  198. var messages = new List<YouTubeChatMessage>();
  199. foreach (var item in items.EnumerateArray())
  200. {
  201. var msg = ParseChatMessage(item, liveChatId);
  202. if (msg is not null)
  203. {
  204. messages.Add(msg);
  205. }
  206. }
  207. return (messages, nextPageToken, pollingIntervalMs);
  208. }
  209. catch (OperationCanceledException)
  210. {
  211. throw;
  212. }
  213. catch (Exception ex)
  214. {
  215. logger.LogError(ex, "[LiveChat] FetchMessages 오류: liveChatId={LiveChatId}", liveChatId);
  216. return (null, pageToken, DefaultPollingMs);
  217. }
  218. }
  219. private static YouTubeChatMessage? ParseChatMessage(JsonElement item, string liveChatId)
  220. {
  221. try
  222. {
  223. var id = item.GetProperty("id").GetString()!;
  224. var snippet = item.GetProperty("snippet");
  225. var author = item.GetProperty("authorDetails");
  226. var typeStr = snippet.GetProperty("type").GetString() ?? "textMessageEvent";
  227. var messageType = typeStr switch
  228. {
  229. "textMessageEvent" => YouTubeChatMessageType.TextMessage,
  230. "superChatEvent" => YouTubeChatMessageType.SuperChat,
  231. "superStickerEvent" => YouTubeChatMessageType.SuperSticker,
  232. "newSponsorEvent" => YouTubeChatMessageType.NewSponsor,
  233. "memberMilestoneChatEvent" => YouTubeChatMessageType.MemberMilestone,
  234. "giftMembershipEvent" => YouTubeChatMessageType.GiftMembership,
  235. _ => YouTubeChatMessageType.TextMessage
  236. };
  237. var displayMessage = snippet.TryGetProperty("displayMessage", out var dmProp)
  238. ? dmProp.GetString() ?? ""
  239. : "";
  240. // textMessageEvent의 경우 textMessageDetails에서 메시지 추출
  241. if (messageType == YouTubeChatMessageType.TextMessage
  242. && snippet.TryGetProperty("textMessageDetails", out var tmd)
  243. && tmd.TryGetProperty("messageText", out var mtProp))
  244. {
  245. displayMessage = mtProp.GetString() ?? displayMessage;
  246. }
  247. var publishedAt = snippet.TryGetProperty("publishedAt", out var paProp)
  248. && DateTime.TryParse(paProp.GetString(), out var pa)
  249. ? pa
  250. : DateTime.UtcNow;
  251. // SuperChat 금액
  252. decimal? superChatAmount = null;
  253. string? superChatCurrency = null;
  254. if (messageType == YouTubeChatMessageType.SuperChat
  255. && snippet.TryGetProperty("superChatDetails", out var scd))
  256. {
  257. superChatAmount = scd.TryGetProperty("amountMicros", out var amProp)
  258. && long.TryParse(amProp.GetString(), out var micros)
  259. ? micros / 1_000_000m
  260. : null;
  261. superChatCurrency = scd.TryGetProperty("currency", out var curProp) ? curProp.GetString() : null;
  262. }
  263. return new YouTubeChatMessage(
  264. id,
  265. liveChatId,
  266. author.GetProperty("channelId").GetString() ?? "",
  267. author.GetProperty("displayName").GetString() ?? "",
  268. author.TryGetProperty("profileImageUrl", out var imgProp) ? imgProp.GetString() ?? "" : "",
  269. displayMessage,
  270. publishedAt,
  271. messageType,
  272. superChatAmount,
  273. superChatCurrency
  274. );
  275. }
  276. catch
  277. {
  278. return null;
  279. }
  280. }
  281. // ── 내부 모니터 컨텍스트 ─────────────────────────────────────────
  282. private sealed record ChatMonitorContext(
  283. string VideoId,
  284. string LiveChatId,
  285. string ChannelId,
  286. CancellationTokenSource Cts
  287. );
  288. }