YouTubeApiService.cs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335
  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,brandingSettings&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,brandingSettings&forHandle={Uri.EscapeDataString(cleanHandle)}";
  25. return await FetchChannelAsync(url, ct);
  26. }
  27. public async Task<IReadOnlyList<YouTubeChannelInfo>> GetChannelsByIdsAsync(IReadOnlyList<string> channelIds, CancellationToken ct)
  28. {
  29. if (channelIds.Count == 0)
  30. {
  31. return [];
  32. }
  33. try
  34. {
  35. var ids = string.Join(",", channelIds.Select(Uri.EscapeDataString));
  36. var client = httpClientFactory.CreateClient("YouTubeApi");
  37. var url = $"{BaseUrl}/channels?part=snippet,statistics,brandingSettings&id={ids}";
  38. var response = await client.GetAsync(url, ct);
  39. if (!response.IsSuccessStatusCode)
  40. {
  41. var errorBody = await response.Content.ReadAsStringAsync(ct);
  42. logger.LogWarning("[YouTube] channels.list (batch) failed: {StatusCode} — {Body}", response.StatusCode, errorBody);
  43. return [];
  44. }
  45. var json = await response.Content.ReadAsStringAsync(ct);
  46. var doc = JsonSerializer.Deserialize<JsonElement>(json);
  47. var items = doc.GetProperty("items");
  48. return items.EnumerateArray().Select(ParseChannelInfo).ToList();
  49. }
  50. catch (Exception ex)
  51. {
  52. logger.LogError(ex, "[YouTube] GetChannelsByIds error");
  53. return [];
  54. }
  55. }
  56. public async Task<YouTubeLiveStreamInfo?> GetLiveStreamInfoAsync(string videoId, CancellationToken ct)
  57. {
  58. try
  59. {
  60. var client = httpClientFactory.CreateClient("YouTubeApi");
  61. var url = $"{BaseUrl}/videos?part=snippet,liveStreamingDetails&id={Uri.EscapeDataString(videoId)}";
  62. var response = await client.GetAsync(url, ct);
  63. if (!response.IsSuccessStatusCode)
  64. {
  65. logger.LogWarning("[YouTube] videos.list failed: {StatusCode}", response.StatusCode);
  66. return null;
  67. }
  68. var json = await response.Content.ReadAsStringAsync(ct);
  69. var doc = JsonSerializer.Deserialize<JsonElement>(json);
  70. var items = doc.GetProperty("items");
  71. if (items.GetArrayLength() == 0)
  72. {
  73. return null;
  74. }
  75. var item = items[0];
  76. var snippet = item.GetProperty("snippet");
  77. var liveBroadcastContent = snippet.TryGetProperty("liveBroadcastContent", out var lbcProp)
  78. ? lbcProp.GetString() ?? "none"
  79. : "none";
  80. string? activeLiveChatId = null;
  81. DateTime? scheduledStart = null;
  82. DateTime? actualStart = null;
  83. if (item.TryGetProperty("liveStreamingDetails", out var lsd))
  84. {
  85. activeLiveChatId = lsd.TryGetProperty("activeLiveChatId", out var chatIdProp)
  86. ? chatIdProp.GetString()
  87. : null;
  88. scheduledStart = lsd.TryGetProperty("scheduledStartTime", out var ssProp)
  89. ? DateTime.TryParse(ssProp.GetString(), out var ss) ? ss : null
  90. : null;
  91. actualStart = lsd.TryGetProperty("actualStartTime", out var asProp)
  92. ? DateTime.TryParse(asProp.GetString(), out var ast) ? ast : null
  93. : null;
  94. }
  95. return new YouTubeLiveStreamInfo(
  96. videoId,
  97. snippet.GetProperty("title").GetString() ?? "",
  98. snippet.GetProperty("channelId").GetString() ?? "",
  99. liveBroadcastContent,
  100. activeLiveChatId,
  101. scheduledStart,
  102. actualStart
  103. );
  104. }
  105. catch (Exception ex)
  106. {
  107. logger.LogError(ex, "[YouTube] GetLiveStreamInfo error for videoId={VideoId}", videoId);
  108. return null;
  109. }
  110. }
  111. // ── 사용자별 (OAuth Access Token) ────────────────────────────────
  112. public async Task<bool> IsSubscribedAsync(string accessToken, string channelId, CancellationToken ct)
  113. {
  114. try
  115. {
  116. var client = CreateAuthClient(accessToken);
  117. var url = $"{BaseUrl}/subscriptions?part=id&mine=true&forChannelId={Uri.EscapeDataString(channelId)}";
  118. var response = await client.GetAsync(url, ct);
  119. if (!response.IsSuccessStatusCode)
  120. {
  121. logger.LogWarning("[YouTube] subscriptions.list failed: {StatusCode}", response.StatusCode);
  122. return false;
  123. }
  124. var json = await response.Content.ReadAsStringAsync(ct);
  125. var doc = JsonSerializer.Deserialize<JsonElement>(json);
  126. var totalResults = doc.GetProperty("pageInfo").GetProperty("totalResults").GetInt32();
  127. return totalResults > 0;
  128. }
  129. catch (Exception ex)
  130. {
  131. logger.LogError(ex, "[YouTube] IsSubscribed error for channelId={ChannelId}", channelId);
  132. return false;
  133. }
  134. }
  135. public async Task<YouTubeMembershipStatus> CheckMembershipAsync(string accessToken, string channelId, CancellationToken ct)
  136. {
  137. try
  138. {
  139. // YouTube Members API는 채널 소유자만 호출 가능
  140. // 대안: 사용자의 membershipsLevels 조회 또는 채널 소유자 토큰으로 members.list 호출
  141. // 여기서는 사용자 관점에서 "내 멤버십" 조회를 시도
  142. var client = CreateAuthClient(accessToken);
  143. var url = $"{BaseUrl}/members?part=snippet&mode=list_members&maxResults=1&filterByMemberChannelId={Uri.EscapeDataString(channelId)}";
  144. var response = await client.GetAsync(url, ct);
  145. if (!response.IsSuccessStatusCode)
  146. {
  147. // 403 = 채널 소유자가 아님 → 멤버십 확인 불가
  148. if ((int)response.StatusCode == 403)
  149. {
  150. logger.LogInformation("[YouTube] Members API requires channel owner token for channelId={ChannelId}", channelId);
  151. return new YouTubeMembershipStatus(false, null, null);
  152. }
  153. logger.LogWarning("[YouTube] members.list failed: {StatusCode}", response.StatusCode);
  154. return new YouTubeMembershipStatus(false, null, null);
  155. }
  156. var json = await response.Content.ReadAsStringAsync(ct);
  157. var doc = JsonSerializer.Deserialize<JsonElement>(json);
  158. var items = doc.GetProperty("items");
  159. if (items.GetArrayLength() == 0)
  160. {
  161. return new YouTubeMembershipStatus(false, null, null);
  162. }
  163. var snippet = items[0].GetProperty("snippet");
  164. var memberLevel = snippet.TryGetProperty("membershipsDetails", out var md)
  165. && md.TryGetProperty("highestAccessibleLevel", out var level)
  166. ? level.GetString()
  167. : null;
  168. var memberSince = snippet.TryGetProperty("memberSince", out var msProp)
  169. ? DateTime.TryParse(msProp.GetString(), out var ms) ? ms : (DateTime?)null
  170. : null;
  171. return new YouTubeMembershipStatus(true, memberLevel, memberSince);
  172. }
  173. catch (Exception ex)
  174. {
  175. logger.LogError(ex, "[YouTube] CheckMembership error for channelId={ChannelId}", channelId);
  176. return new YouTubeMembershipStatus(false, null, null);
  177. }
  178. }
  179. public async Task<YouTubeChannelInfo?> GetMyChannelAsync(string accessToken, CancellationToken ct)
  180. {
  181. try
  182. {
  183. var client = CreateAuthClient(accessToken);
  184. var url = $"{BaseUrl}/channels?part=snippet,statistics,brandingSettings&mine=true";
  185. var response = await client.GetAsync(url, ct);
  186. if (!response.IsSuccessStatusCode)
  187. {
  188. var errorBody = await response.Content.ReadAsStringAsync(ct);
  189. logger.LogWarning("[YouTube] GetMyChannel failed: {StatusCode} — {Body}", response.StatusCode, errorBody);
  190. return null;
  191. }
  192. var json = await response.Content.ReadAsStringAsync(ct);
  193. var doc = JsonSerializer.Deserialize<JsonElement>(json);
  194. var items = doc.GetProperty("items");
  195. if (items.GetArrayLength() == 0)
  196. {
  197. logger.LogWarning("[YouTube] GetMyChannel: no channel found for authenticated user");
  198. return null;
  199. }
  200. return ParseChannelInfo(items[0]);
  201. }
  202. catch (Exception ex)
  203. {
  204. logger.LogError(ex, "[YouTube] GetMyChannel error");
  205. return null;
  206. }
  207. }
  208. // ── Private ──────────────────────────────────────────────────────
  209. private async Task<YouTubeChannelInfo?> FetchChannelAsync(string url, CancellationToken ct)
  210. {
  211. try
  212. {
  213. var client = httpClientFactory.CreateClient("YouTubeApi");
  214. var response = await client.GetAsync(url, ct);
  215. if (!response.IsSuccessStatusCode)
  216. {
  217. var errorBody = await response.Content.ReadAsStringAsync(ct);
  218. logger.LogWarning("[YouTube] channels.list failed: {StatusCode} — {Body}", response.StatusCode, errorBody);
  219. return null;
  220. }
  221. var json = await response.Content.ReadAsStringAsync(ct);
  222. var doc = JsonSerializer.Deserialize<JsonElement>(json);
  223. var items = doc.GetProperty("items");
  224. if (items.GetArrayLength() == 0)
  225. {
  226. return null;
  227. }
  228. var item = items[0];
  229. return ParseChannelInfo(item);
  230. }
  231. catch (Exception ex)
  232. {
  233. logger.LogError(ex, "[YouTube] FetchChannel error");
  234. return null;
  235. }
  236. }
  237. private static YouTubeChannelInfo ParseChannelInfo(JsonElement item)
  238. {
  239. var snippet = item.GetProperty("snippet");
  240. var statistics = item.GetProperty("statistics");
  241. // brandingSettings: 배너 + 이메일
  242. string? bannerUrl = null;
  243. string? email = null;
  244. if (item.TryGetProperty("brandingSettings", out var bs))
  245. {
  246. if (bs.TryGetProperty("image", out var img)
  247. && img.TryGetProperty("bannerExternalUrl", out var bannerProp))
  248. {
  249. bannerUrl = bannerProp.GetString();
  250. }
  251. if (bs.TryGetProperty("channel", out var bsCh)
  252. && bsCh.TryGetProperty("email", out var emailProp))
  253. {
  254. email = emailProp.GetString();
  255. }
  256. }
  257. // snippet.publishedAt: YouTube 채널 가입일
  258. DateTime? publishedAt = null;
  259. if (snippet.TryGetProperty("publishedAt", out var paProp)
  260. && DateTime.TryParse(paProp.GetString(), out var pa))
  261. {
  262. publishedAt = pa;
  263. }
  264. return new YouTubeChannelInfo(
  265. item.GetProperty("id").GetString()!,
  266. snippet.GetProperty("title").GetString() ?? "",
  267. snippet.TryGetProperty("description", out var descProp) ? descProp.GetString() ?? "" : "",
  268. snippet.GetProperty("thumbnails").GetProperty("default").GetProperty("url").GetString() ?? "",
  269. bannerUrl,
  270. snippet.TryGetProperty("customUrl", out var cuProp) ? cuProp.GetString() : null,
  271. statistics.TryGetProperty("subscriberCount", out var scProp) && long.TryParse(scProp.GetString(), out var sc) ? sc : 0,
  272. statistics.TryGetProperty("videoCount", out var vcProp) && long.TryParse(vcProp.GetString(), out var vc) ? vc : 0,
  273. statistics.TryGetProperty("viewCount", out var vwProp) && long.TryParse(vwProp.GetString(), out var vw) ? vw : 0,
  274. email,
  275. publishedAt
  276. );
  277. }
  278. private HttpClient CreateAuthClient(string accessToken)
  279. {
  280. var client = httpClientFactory.CreateClient("YouTubeApi");
  281. client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken);
  282. return client;
  283. }
  284. }