YouTubeApiService.cs 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553
  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. int concurrentViewers = 0;
  84. if (item.TryGetProperty("liveStreamingDetails", out var lsd))
  85. {
  86. activeLiveChatId = lsd.TryGetProperty("activeLiveChatId", out var chatIdProp)
  87. ? chatIdProp.GetString()
  88. : null;
  89. scheduledStart = lsd.TryGetProperty("scheduledStartTime", out var ssProp)
  90. ? DateTime.TryParse(ssProp.GetString(), out var ss) ? ss : null
  91. : null;
  92. actualStart = lsd.TryGetProperty("actualStartTime", out var asProp)
  93. ? DateTime.TryParse(asProp.GetString(), out var ast) ? ast : null
  94. : null;
  95. if (lsd.TryGetProperty("concurrentViewers", out var cvProp)
  96. && int.TryParse(cvProp.GetString(), out var cv))
  97. {
  98. concurrentViewers = cv;
  99. }
  100. }
  101. return new YouTubeLiveStreamInfo(
  102. videoId,
  103. snippet.GetProperty("title").GetString() ?? "",
  104. snippet.GetProperty("channelId").GetString() ?? "",
  105. liveBroadcastContent,
  106. activeLiveChatId,
  107. scheduledStart,
  108. actualStart,
  109. concurrentViewers,
  110. concurrentViewers > 0 ? DateTime.UtcNow : null
  111. );
  112. }
  113. catch (Exception ex)
  114. {
  115. logger.LogError(ex, "[YouTube] GetLiveStreamInfo error for videoId={VideoId}", videoId);
  116. return null;
  117. }
  118. }
  119. public async Task<IReadOnlyDictionary<string, int>> GetConcurrentViewersAsync(IReadOnlyList<string> videoIds, CancellationToken ct)
  120. {
  121. if (videoIds.Count == 0)
  122. {
  123. return new Dictionary<string, int>();
  124. }
  125. var result = new Dictionary<string, int>(videoIds.Count);
  126. try
  127. {
  128. var client = httpClientFactory.CreateClient("YouTubeApi");
  129. // YouTube Data API v3 — videos.list 는 id 파라미터에 최대 50개까지 묶을 수 있음 (1 unit/call)
  130. for (int i = 0; i < videoIds.Count; i += 50)
  131. {
  132. if (ct.IsCancellationRequested)
  133. {
  134. break;
  135. }
  136. var chunk = videoIds.Skip(i).Take(50).ToList();
  137. var ids = string.Join(",", chunk.Select(Uri.EscapeDataString));
  138. var url = $"{BaseUrl}/videos?part=liveStreamingDetails&id={ids}&maxResults=50";
  139. var response = await client.GetAsync(url, ct);
  140. if (!response.IsSuccessStatusCode)
  141. {
  142. var errorBody = await response.Content.ReadAsStringAsync(ct);
  143. logger.LogWarning("[YouTube] videos.list (concurrent viewers) failed: {StatusCode} — {Body}", response.StatusCode, errorBody);
  144. continue;
  145. }
  146. var json = await response.Content.ReadAsStringAsync(ct);
  147. var doc = JsonSerializer.Deserialize<JsonElement>(json);
  148. var items = doc.GetProperty("items");
  149. foreach (var item in items.EnumerateArray())
  150. {
  151. var videoId = item.GetProperty("id").GetString();
  152. if (string.IsNullOrEmpty(videoId))
  153. {
  154. continue;
  155. }
  156. if (!item.TryGetProperty("liveStreamingDetails", out var lsd))
  157. {
  158. continue;
  159. }
  160. // concurrentViewers 는 라이브 중이 아니면 응답에 없음
  161. if (!lsd.TryGetProperty("concurrentViewers", out var cvProp))
  162. {
  163. continue;
  164. }
  165. if (int.TryParse(cvProp.GetString(), out var cv))
  166. {
  167. result[videoId] = cv;
  168. }
  169. }
  170. }
  171. return result;
  172. }
  173. catch (Exception ex)
  174. {
  175. logger.LogError(ex, "[YouTube] GetConcurrentViewers error");
  176. return result;
  177. }
  178. }
  179. public async Task<IReadOnlyList<string>> GetRecentUploadsAsync(string channelId, int maxResults, CancellationToken ct)
  180. {
  181. if (string.IsNullOrWhiteSpace(channelId))
  182. {
  183. return [];
  184. }
  185. try
  186. {
  187. // uploads 플레이리스트 ID = channelId의 2번째 문자를 U에서 UU로 바꾼 값 (YouTube 관례)
  188. // channelId가 UC로 시작하면 UU... 형태로 변환
  189. if (channelId.Length < 2 || !channelId.StartsWith("UC"))
  190. {
  191. logger.LogWarning("[YouTube] Invalid channelId for uploads playlist: {ChannelId}", channelId);
  192. return [];
  193. }
  194. var uploadsPlaylistId = "UU" + channelId[2..];
  195. var client = httpClientFactory.CreateClient("YouTubeApi");
  196. var url = $"{BaseUrl}/playlistItems?part=contentDetails&playlistId={Uri.EscapeDataString(uploadsPlaylistId)}&maxResults={Math.Clamp(maxResults, 1, 50)}";
  197. var response = await client.GetAsync(url, ct);
  198. if (!response.IsSuccessStatusCode)
  199. {
  200. var errorBody = await response.Content.ReadAsStringAsync(ct);
  201. logger.LogWarning("[YouTube] playlistItems.list failed for channel {ChannelId}: {StatusCode} — {Body}", channelId, response.StatusCode, errorBody);
  202. return [];
  203. }
  204. var json = await response.Content.ReadAsStringAsync(ct);
  205. var doc = JsonSerializer.Deserialize<JsonElement>(json);
  206. var items = doc.GetProperty("items");
  207. var videoIds = new List<string>(items.GetArrayLength());
  208. foreach (var item in items.EnumerateArray())
  209. {
  210. if (item.TryGetProperty("contentDetails", out var cd) && cd.TryGetProperty("videoId", out var vid))
  211. {
  212. var videoId = vid.GetString();
  213. if (!string.IsNullOrWhiteSpace(videoId))
  214. {
  215. videoIds.Add(videoId);
  216. }
  217. }
  218. }
  219. return videoIds;
  220. }
  221. catch (Exception ex)
  222. {
  223. logger.LogError(ex, "[YouTube] GetRecentUploads error for channel {ChannelId}", channelId);
  224. return [];
  225. }
  226. }
  227. public async Task<IReadOnlyList<YouTubeVideoStats>> GetVideoStatsBatchAsync(IReadOnlyList<string> videoIds, CancellationToken ct)
  228. {
  229. if (videoIds.Count == 0)
  230. {
  231. return [];
  232. }
  233. try
  234. {
  235. var ids = string.Join(",", videoIds.Take(50).Select(Uri.EscapeDataString));
  236. var client = httpClientFactory.CreateClient("YouTubeApi");
  237. var url = $"{BaseUrl}/videos?part=snippet,liveStreamingDetails,statistics&id={ids}";
  238. var response = await client.GetAsync(url, ct);
  239. if (!response.IsSuccessStatusCode)
  240. {
  241. var errorBody = await response.Content.ReadAsStringAsync(ct);
  242. logger.LogWarning("[YouTube] videos.list (batch stats) failed: {StatusCode} — {Body}", response.StatusCode, errorBody);
  243. return [];
  244. }
  245. var json = await response.Content.ReadAsStringAsync(ct);
  246. var doc = JsonSerializer.Deserialize<JsonElement>(json);
  247. var items = doc.GetProperty("items");
  248. var results = new List<YouTubeVideoStats>(items.GetArrayLength());
  249. foreach (var item in items.EnumerateArray())
  250. {
  251. var videoId = item.GetProperty("id").GetString() ?? "";
  252. var snippet = item.GetProperty("snippet");
  253. var title = snippet.TryGetProperty("title", out var titleProp) ? titleProp.GetString() ?? "" : "";
  254. var channelId = snippet.TryGetProperty("channelId", out var chProp) ? chProp.GetString() ?? "" : "";
  255. DateTime? actualStart = null;
  256. DateTime? actualEnd = null;
  257. var isLiveStream = false;
  258. if (item.TryGetProperty("liveStreamingDetails", out var lsd))
  259. {
  260. isLiveStream = true;
  261. if (lsd.TryGetProperty("actualStartTime", out var asProp)
  262. && DateTime.TryParse(asProp.GetString(), out var ast))
  263. {
  264. actualStart = ast.ToUniversalTime();
  265. }
  266. if (lsd.TryGetProperty("actualEndTime", out var aeProp)
  267. && DateTime.TryParse(aeProp.GetString(), out var aet))
  268. {
  269. actualEnd = aet.ToUniversalTime();
  270. }
  271. }
  272. long viewCount = 0;
  273. long likeCount = 0;
  274. if (item.TryGetProperty("statistics", out var stats))
  275. {
  276. if (stats.TryGetProperty("viewCount", out var vcProp) && long.TryParse(vcProp.GetString(), out var vc))
  277. {
  278. viewCount = vc;
  279. }
  280. if (stats.TryGetProperty("likeCount", out var lcProp) && long.TryParse(lcProp.GetString(), out var lc))
  281. {
  282. likeCount = lc;
  283. }
  284. }
  285. results.Add(new YouTubeVideoStats(videoId, title, channelId, actualStart, actualEnd, viewCount, likeCount, isLiveStream));
  286. }
  287. return results;
  288. }
  289. catch (Exception ex)
  290. {
  291. logger.LogError(ex, "[YouTube] GetVideoStatsBatch error");
  292. return [];
  293. }
  294. }
  295. // ── 사용자별 (OAuth Access Token) ────────────────────────────────
  296. public async Task<bool> IsSubscribedAsync(string accessToken, string channelId, CancellationToken ct)
  297. {
  298. try
  299. {
  300. var client = CreateAuthClient(accessToken);
  301. var url = $"{BaseUrl}/subscriptions?part=id&mine=true&forChannelId={Uri.EscapeDataString(channelId)}";
  302. var response = await client.GetAsync(url, ct);
  303. if (!response.IsSuccessStatusCode)
  304. {
  305. logger.LogWarning("[YouTube] subscriptions.list failed: {StatusCode}", response.StatusCode);
  306. return false;
  307. }
  308. var json = await response.Content.ReadAsStringAsync(ct);
  309. var doc = JsonSerializer.Deserialize<JsonElement>(json);
  310. var totalResults = doc.GetProperty("pageInfo").GetProperty("totalResults").GetInt32();
  311. return totalResults > 0;
  312. }
  313. catch (Exception ex)
  314. {
  315. logger.LogError(ex, "[YouTube] IsSubscribed error for channelId={ChannelId}", channelId);
  316. return false;
  317. }
  318. }
  319. public async Task<YouTubeMembershipStatus> CheckMembershipAsync(string accessToken, string channelId, CancellationToken ct)
  320. {
  321. try
  322. {
  323. // YouTube Members API는 채널 소유자만 호출 가능
  324. // 대안: 사용자의 membershipsLevels 조회 또는 채널 소유자 토큰으로 members.list 호출
  325. // 여기서는 사용자 관점에서 "내 멤버십" 조회를 시도
  326. var client = CreateAuthClient(accessToken);
  327. var url = $"{BaseUrl}/members?part=snippet&mode=list_members&maxResults=1&filterByMemberChannelId={Uri.EscapeDataString(channelId)}";
  328. var response = await client.GetAsync(url, ct);
  329. if (!response.IsSuccessStatusCode)
  330. {
  331. // 403 = 채널 소유자가 아님 → 멤버십 확인 불가
  332. if ((int)response.StatusCode == 403)
  333. {
  334. logger.LogInformation("[YouTube] Members API requires channel owner token for channelId={ChannelId}", channelId);
  335. return new YouTubeMembershipStatus(false, null, null);
  336. }
  337. logger.LogWarning("[YouTube] members.list failed: {StatusCode}", response.StatusCode);
  338. return new YouTubeMembershipStatus(false, null, null);
  339. }
  340. var json = await response.Content.ReadAsStringAsync(ct);
  341. var doc = JsonSerializer.Deserialize<JsonElement>(json);
  342. var items = doc.GetProperty("items");
  343. if (items.GetArrayLength() == 0)
  344. {
  345. return new YouTubeMembershipStatus(false, null, null);
  346. }
  347. var snippet = items[0].GetProperty("snippet");
  348. var memberLevel = snippet.TryGetProperty("membershipsDetails", out var md)
  349. && md.TryGetProperty("highestAccessibleLevel", out var level)
  350. ? level.GetString()
  351. : null;
  352. var memberSince = snippet.TryGetProperty("memberSince", out var msProp)
  353. ? DateTime.TryParse(msProp.GetString(), out var ms) ? ms : (DateTime?)null
  354. : null;
  355. return new YouTubeMembershipStatus(true, memberLevel, memberSince);
  356. }
  357. catch (Exception ex)
  358. {
  359. logger.LogError(ex, "[YouTube] CheckMembership error for channelId={ChannelId}", channelId);
  360. return new YouTubeMembershipStatus(false, null, null);
  361. }
  362. }
  363. public async Task<YouTubeChannelInfo?> GetMyChannelAsync(string accessToken, CancellationToken ct)
  364. {
  365. try
  366. {
  367. var client = CreateAuthClient(accessToken);
  368. var url = $"{BaseUrl}/channels?part=snippet,statistics,brandingSettings&mine=true";
  369. var response = await client.GetAsync(url, ct);
  370. if (!response.IsSuccessStatusCode)
  371. {
  372. var errorBody = await response.Content.ReadAsStringAsync(ct);
  373. logger.LogWarning("[YouTube] GetMyChannel failed: {StatusCode} — {Body}", response.StatusCode, errorBody);
  374. return null;
  375. }
  376. var json = await response.Content.ReadAsStringAsync(ct);
  377. var doc = JsonSerializer.Deserialize<JsonElement>(json);
  378. var items = doc.GetProperty("items");
  379. if (items.GetArrayLength() == 0)
  380. {
  381. logger.LogWarning("[YouTube] GetMyChannel: no channel found for authenticated user");
  382. return null;
  383. }
  384. return ParseChannelInfo(items[0]);
  385. }
  386. catch (Exception ex)
  387. {
  388. logger.LogError(ex, "[YouTube] GetMyChannel error");
  389. return null;
  390. }
  391. }
  392. // ── Private ──────────────────────────────────────────────────────
  393. private async Task<YouTubeChannelInfo?> FetchChannelAsync(string url, CancellationToken ct)
  394. {
  395. try
  396. {
  397. var client = httpClientFactory.CreateClient("YouTubeApi");
  398. var response = await client.GetAsync(url, ct);
  399. if (!response.IsSuccessStatusCode)
  400. {
  401. var errorBody = await response.Content.ReadAsStringAsync(ct);
  402. logger.LogWarning("[YouTube] channels.list failed: {StatusCode} — {Body}", response.StatusCode, errorBody);
  403. return null;
  404. }
  405. var json = await response.Content.ReadAsStringAsync(ct);
  406. var doc = JsonSerializer.Deserialize<JsonElement>(json);
  407. var items = doc.GetProperty("items");
  408. if (items.GetArrayLength() == 0)
  409. {
  410. return null;
  411. }
  412. var item = items[0];
  413. return ParseChannelInfo(item);
  414. }
  415. catch (Exception ex)
  416. {
  417. logger.LogError(ex, "[YouTube] FetchChannel error");
  418. return null;
  419. }
  420. }
  421. private static YouTubeChannelInfo ParseChannelInfo(JsonElement item)
  422. {
  423. var snippet = item.GetProperty("snippet");
  424. var statistics = item.GetProperty("statistics");
  425. // brandingSettings: 배너 + 이메일
  426. string? bannerUrl = null;
  427. string? email = null;
  428. if (item.TryGetProperty("brandingSettings", out var bs))
  429. {
  430. if (bs.TryGetProperty("image", out var img)
  431. && img.TryGetProperty("bannerExternalUrl", out var bannerProp))
  432. {
  433. bannerUrl = bannerProp.GetString();
  434. }
  435. if (bs.TryGetProperty("channel", out var bsCh)
  436. && bsCh.TryGetProperty("email", out var emailProp))
  437. {
  438. email = emailProp.GetString();
  439. }
  440. }
  441. // snippet.publishedAt: YouTube 채널 가입일
  442. DateTime? publishedAt = null;
  443. if (snippet.TryGetProperty("publishedAt", out var paProp)
  444. && DateTime.TryParse(paProp.GetString(), out var pa))
  445. {
  446. publishedAt = pa;
  447. }
  448. return new YouTubeChannelInfo(
  449. item.GetProperty("id").GetString()!,
  450. snippet.GetProperty("title").GetString() ?? "",
  451. snippet.TryGetProperty("description", out var descProp) ? descProp.GetString() ?? "" : "",
  452. snippet.GetProperty("thumbnails").GetProperty("default").GetProperty("url").GetString() ?? "",
  453. bannerUrl,
  454. snippet.TryGetProperty("customUrl", out var cuProp) ? cuProp.GetString() : null,
  455. statistics.TryGetProperty("subscriberCount", out var scProp) && long.TryParse(scProp.GetString(), out var sc) ? sc : 0,
  456. statistics.TryGetProperty("videoCount", out var vcProp) && long.TryParse(vcProp.GetString(), out var vc) ? vc : 0,
  457. statistics.TryGetProperty("viewCount", out var vwProp) && long.TryParse(vwProp.GetString(), out var vw) ? vw : 0,
  458. email,
  459. publishedAt
  460. );
  461. }
  462. private HttpClient CreateAuthClient(string accessToken)
  463. {
  464. var client = httpClientFactory.CreateClient("YouTubeApi");
  465. client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken);
  466. return client;
  467. }
  468. }