YouTubeApiService.cs 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233
  1. using System.Text.Json;
  2. using Application.Abstractions.YouTube;
  3. using Microsoft.Extensions.Logging;
  4. namespace Infrastructure.YouTube;
  5. /// <summary>
  6. /// YouTube Data API v3 — 채널 정보, 구독/멤버십 확인
  7. /// API Key(공개 정보)와 OAuth Access Token(사용자별)을 분리하여 사용
  8. /// </summary>
  9. internal sealed class YouTubeApiService(
  10. IHttpClientFactory httpClientFactory,
  11. ILogger<YouTubeApiService> logger
  12. ) : IYouTubeApiService
  13. {
  14. private const string BaseUrl = "https://www.googleapis.com/youtube/v3";
  15. // ── 공개 정보 (API Key) ──────────────────────────────────────────
  16. public async Task<YouTubeChannelInfo?> GetChannelByIdAsync(string channelId, CancellationToken ct)
  17. {
  18. var url = $"{BaseUrl}/channels?part=snippet,statistics&id={Uri.EscapeDataString(channelId)}";
  19. return await FetchChannelAsync(url, ct);
  20. }
  21. public async Task<YouTubeChannelInfo?> GetChannelByHandleAsync(string handle, CancellationToken ct)
  22. {
  23. var cleanHandle = handle.TrimStart('@');
  24. var url = $"{BaseUrl}/channels?part=snippet,statistics&forHandle={Uri.EscapeDataString(cleanHandle)}";
  25. return await FetchChannelAsync(url, ct);
  26. }
  27. public async Task<YouTubeLiveStreamInfo?> GetLiveStreamInfoAsync(string videoId, CancellationToken ct)
  28. {
  29. try
  30. {
  31. var client = httpClientFactory.CreateClient("YouTubeApi");
  32. var url = $"{BaseUrl}/videos?part=snippet,liveStreamingDetails&id={Uri.EscapeDataString(videoId)}";
  33. var response = await client.GetAsync(url, ct);
  34. if (!response.IsSuccessStatusCode)
  35. {
  36. logger.LogWarning("[YouTube] videos.list failed: {StatusCode}", response.StatusCode);
  37. return null;
  38. }
  39. var json = await response.Content.ReadAsStringAsync(ct);
  40. var doc = JsonSerializer.Deserialize<JsonElement>(json);
  41. var items = doc.GetProperty("items");
  42. if (items.GetArrayLength() == 0)
  43. {
  44. return null;
  45. }
  46. var item = items[0];
  47. var snippet = item.GetProperty("snippet");
  48. var liveBroadcastContent = snippet.TryGetProperty("liveBroadcastContent", out var lbcProp)
  49. ? lbcProp.GetString() ?? "none"
  50. : "none";
  51. string? activeLiveChatId = null;
  52. DateTime? scheduledStart = null;
  53. DateTime? actualStart = null;
  54. if (item.TryGetProperty("liveStreamingDetails", out var lsd))
  55. {
  56. activeLiveChatId = lsd.TryGetProperty("activeLiveChatId", out var chatIdProp)
  57. ? chatIdProp.GetString()
  58. : null;
  59. scheduledStart = lsd.TryGetProperty("scheduledStartTime", out var ssProp)
  60. ? DateTime.TryParse(ssProp.GetString(), out var ss) ? ss : null
  61. : null;
  62. actualStart = lsd.TryGetProperty("actualStartTime", out var asProp)
  63. ? DateTime.TryParse(asProp.GetString(), out var ast) ? ast : null
  64. : null;
  65. }
  66. return new YouTubeLiveStreamInfo(
  67. videoId,
  68. snippet.GetProperty("title").GetString() ?? "",
  69. snippet.GetProperty("channelId").GetString() ?? "",
  70. liveBroadcastContent,
  71. activeLiveChatId,
  72. scheduledStart,
  73. actualStart
  74. );
  75. }
  76. catch (Exception ex)
  77. {
  78. logger.LogError(ex, "[YouTube] GetLiveStreamInfo error for videoId={VideoId}", videoId);
  79. return null;
  80. }
  81. }
  82. // ── 사용자별 (OAuth Access Token) ────────────────────────────────
  83. public async Task<bool> IsSubscribedAsync(string accessToken, string channelId, CancellationToken ct)
  84. {
  85. try
  86. {
  87. var client = CreateAuthClient(accessToken);
  88. var url = $"{BaseUrl}/subscriptions?part=id&mine=true&forChannelId={Uri.EscapeDataString(channelId)}";
  89. var response = await client.GetAsync(url, ct);
  90. if (!response.IsSuccessStatusCode)
  91. {
  92. logger.LogWarning("[YouTube] subscriptions.list failed: {StatusCode}", response.StatusCode);
  93. return false;
  94. }
  95. var json = await response.Content.ReadAsStringAsync(ct);
  96. var doc = JsonSerializer.Deserialize<JsonElement>(json);
  97. var totalResults = doc.GetProperty("pageInfo").GetProperty("totalResults").GetInt32();
  98. return totalResults > 0;
  99. }
  100. catch (Exception ex)
  101. {
  102. logger.LogError(ex, "[YouTube] IsSubscribed error for channelId={ChannelId}", channelId);
  103. return false;
  104. }
  105. }
  106. public async Task<YouTubeMembershipStatus> CheckMembershipAsync(string accessToken, string channelId, CancellationToken ct)
  107. {
  108. try
  109. {
  110. // YouTube Members API는 채널 소유자만 호출 가능
  111. // 대안: 사용자의 membershipsLevels 조회 또는 채널 소유자 토큰으로 members.list 호출
  112. // 여기서는 사용자 관점에서 "내 멤버십" 조회를 시도
  113. var client = CreateAuthClient(accessToken);
  114. var url = $"{BaseUrl}/members?part=snippet&mode=list_members&maxResults=1&filterByMemberChannelId={Uri.EscapeDataString(channelId)}";
  115. var response = await client.GetAsync(url, ct);
  116. if (!response.IsSuccessStatusCode)
  117. {
  118. // 403 = 채널 소유자가 아님 → 멤버십 확인 불가
  119. if ((int)response.StatusCode == 403)
  120. {
  121. logger.LogInformation("[YouTube] Members API requires channel owner token for channelId={ChannelId}", channelId);
  122. return new YouTubeMembershipStatus(false, null, null);
  123. }
  124. logger.LogWarning("[YouTube] members.list failed: {StatusCode}", response.StatusCode);
  125. return new YouTubeMembershipStatus(false, null, null);
  126. }
  127. var json = await response.Content.ReadAsStringAsync(ct);
  128. var doc = JsonSerializer.Deserialize<JsonElement>(json);
  129. var items = doc.GetProperty("items");
  130. if (items.GetArrayLength() == 0)
  131. {
  132. return new YouTubeMembershipStatus(false, null, null);
  133. }
  134. var snippet = items[0].GetProperty("snippet");
  135. var memberLevel = snippet.TryGetProperty("membershipsDetails", out var md)
  136. && md.TryGetProperty("highestAccessibleLevel", out var level)
  137. ? level.GetString()
  138. : null;
  139. var memberSince = snippet.TryGetProperty("memberSince", out var msProp)
  140. ? DateTime.TryParse(msProp.GetString(), out var ms) ? ms : (DateTime?)null
  141. : null;
  142. return new YouTubeMembershipStatus(true, memberLevel, memberSince);
  143. }
  144. catch (Exception ex)
  145. {
  146. logger.LogError(ex, "[YouTube] CheckMembership error for channelId={ChannelId}", channelId);
  147. return new YouTubeMembershipStatus(false, null, null);
  148. }
  149. }
  150. // ── Private ──────────────────────────────────────────────────────
  151. private async Task<YouTubeChannelInfo?> FetchChannelAsync(string url, CancellationToken ct)
  152. {
  153. try
  154. {
  155. var client = httpClientFactory.CreateClient("YouTubeApi");
  156. var response = await client.GetAsync(url, ct);
  157. if (!response.IsSuccessStatusCode)
  158. {
  159. var errorBody = await response.Content.ReadAsStringAsync(ct);
  160. logger.LogWarning("[YouTube] channels.list failed: {StatusCode} — {Body}", response.StatusCode, errorBody);
  161. return null;
  162. }
  163. var json = await response.Content.ReadAsStringAsync(ct);
  164. var doc = JsonSerializer.Deserialize<JsonElement>(json);
  165. var items = doc.GetProperty("items");
  166. if (items.GetArrayLength() == 0)
  167. {
  168. return null;
  169. }
  170. var item = items[0];
  171. var snippet = item.GetProperty("snippet");
  172. var statistics = item.GetProperty("statistics");
  173. return new YouTubeChannelInfo(
  174. item.GetProperty("id").GetString()!,
  175. snippet.GetProperty("title").GetString() ?? "",
  176. snippet.TryGetProperty("description", out var descProp) ? descProp.GetString() ?? "" : "",
  177. snippet.GetProperty("thumbnails").GetProperty("default").GetProperty("url").GetString() ?? "",
  178. snippet.TryGetProperty("customUrl", out var cuProp) ? cuProp.GetString() : null,
  179. statistics.TryGetProperty("subscriberCount", out var scProp) && long.TryParse(scProp.GetString(), out var sc) ? sc : 0,
  180. statistics.TryGetProperty("videoCount", out var vcProp) && long.TryParse(vcProp.GetString(), out var vc) ? vc : 0,
  181. statistics.TryGetProperty("viewCount", out var vwProp) && long.TryParse(vwProp.GetString(), out var vw) ? vw : 0
  182. );
  183. }
  184. catch (Exception ex)
  185. {
  186. logger.LogError(ex, "[YouTube] FetchChannel error");
  187. return null;
  188. }
  189. }
  190. private HttpClient CreateAuthClient(string accessToken)
  191. {
  192. var client = httpClientFactory.CreateClient("YouTubeApi");
  193. client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken);
  194. return client;
  195. }
  196. }