YouTubeLiveChatService.cs 13 KB

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