| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553 |
- using System.Text.Json;
- using Application.Abstractions.YouTube;
- using Microsoft.Extensions.Logging;
- namespace Infrastructure.YouTube;
- /// <summary>
- /// YouTube Data API v3 — 채널 정보, 구독/멤버십 확인
- /// API Key(공개 정보)와 OAuth Access Token(사용자별)을 분리하여 사용
- /// </summary>
- internal sealed class YouTubeApiService(
- IHttpClientFactory httpClientFactory,
- ILogger<YouTubeApiService> logger
- ) : IYouTubeApiService
- {
- private const string BaseUrl = "https://www.googleapis.com/youtube/v3";
- // ── 공개 정보 (API Key) ──────────────────────────────────────────
- public async Task<YouTubeChannelInfo?> GetChannelByIdAsync(string channelId, CancellationToken ct)
- {
- var url = $"{BaseUrl}/channels?part=snippet,statistics,brandingSettings&id={Uri.EscapeDataString(channelId)}";
- return await FetchChannelAsync(url, ct);
- }
- public async Task<YouTubeChannelInfo?> GetChannelByHandleAsync(string handle, CancellationToken ct)
- {
- var cleanHandle = handle.TrimStart('@');
- var url = $"{BaseUrl}/channels?part=snippet,statistics,brandingSettings&forHandle={Uri.EscapeDataString(cleanHandle)}";
- return await FetchChannelAsync(url, ct);
- }
- public async Task<IReadOnlyList<YouTubeChannelInfo>> GetChannelsByIdsAsync(IReadOnlyList<string> channelIds, CancellationToken ct)
- {
- if (channelIds.Count == 0)
- {
- return [];
- }
- try
- {
- var ids = string.Join(",", channelIds.Select(Uri.EscapeDataString));
- var client = httpClientFactory.CreateClient("YouTubeApi");
- var url = $"{BaseUrl}/channels?part=snippet,statistics,brandingSettings&id={ids}";
- var response = await client.GetAsync(url, ct);
- if (!response.IsSuccessStatusCode)
- {
- var errorBody = await response.Content.ReadAsStringAsync(ct);
- logger.LogWarning("[YouTube] channels.list (batch) failed: {StatusCode} — {Body}", response.StatusCode, errorBody);
- return [];
- }
- var json = await response.Content.ReadAsStringAsync(ct);
- var doc = JsonSerializer.Deserialize<JsonElement>(json);
- var items = doc.GetProperty("items");
- return items.EnumerateArray().Select(ParseChannelInfo).ToList();
- }
- catch (Exception ex)
- {
- logger.LogError(ex, "[YouTube] GetChannelsByIds error");
- return [];
- }
- }
- public async Task<YouTubeLiveStreamInfo?> GetLiveStreamInfoAsync(string videoId, CancellationToken ct)
- {
- try
- {
- var client = httpClientFactory.CreateClient("YouTubeApi");
- var url = $"{BaseUrl}/videos?part=snippet,liveStreamingDetails&id={Uri.EscapeDataString(videoId)}";
- var response = await client.GetAsync(url, ct);
- if (!response.IsSuccessStatusCode)
- {
- logger.LogWarning("[YouTube] videos.list failed: {StatusCode}", response.StatusCode);
- return null;
- }
- var json = await response.Content.ReadAsStringAsync(ct);
- var doc = JsonSerializer.Deserialize<JsonElement>(json);
- var items = doc.GetProperty("items");
- if (items.GetArrayLength() == 0)
- {
- return null;
- }
- var item = items[0];
- var snippet = item.GetProperty("snippet");
- var liveBroadcastContent = snippet.TryGetProperty("liveBroadcastContent", out var lbcProp)
- ? lbcProp.GetString() ?? "none"
- : "none";
- string? activeLiveChatId = null;
- DateTime? scheduledStart = null;
- DateTime? actualStart = null;
- int concurrentViewers = 0;
- if (item.TryGetProperty("liveStreamingDetails", out var lsd))
- {
- activeLiveChatId = lsd.TryGetProperty("activeLiveChatId", out var chatIdProp)
- ? chatIdProp.GetString()
- : null;
- scheduledStart = lsd.TryGetProperty("scheduledStartTime", out var ssProp)
- ? DateTime.TryParse(ssProp.GetString(), out var ss) ? ss : null
- : null;
- actualStart = lsd.TryGetProperty("actualStartTime", out var asProp)
- ? DateTime.TryParse(asProp.GetString(), out var ast) ? ast : null
- : null;
- if (lsd.TryGetProperty("concurrentViewers", out var cvProp)
- && int.TryParse(cvProp.GetString(), out var cv))
- {
- concurrentViewers = cv;
- }
- }
- return new YouTubeLiveStreamInfo(
- videoId,
- snippet.GetProperty("title").GetString() ?? "",
- snippet.GetProperty("channelId").GetString() ?? "",
- liveBroadcastContent,
- activeLiveChatId,
- scheduledStart,
- actualStart,
- concurrentViewers,
- concurrentViewers > 0 ? DateTime.UtcNow : null
- );
- }
- catch (Exception ex)
- {
- logger.LogError(ex, "[YouTube] GetLiveStreamInfo error for videoId={VideoId}", videoId);
- return null;
- }
- }
- public async Task<IReadOnlyDictionary<string, int>> GetConcurrentViewersAsync(IReadOnlyList<string> videoIds, CancellationToken ct)
- {
- if (videoIds.Count == 0)
- {
- return new Dictionary<string, int>();
- }
- var result = new Dictionary<string, int>(videoIds.Count);
- try
- {
- var client = httpClientFactory.CreateClient("YouTubeApi");
- // YouTube Data API v3 — videos.list 는 id 파라미터에 최대 50개까지 묶을 수 있음 (1 unit/call)
- for (int i = 0; i < videoIds.Count; i += 50)
- {
- if (ct.IsCancellationRequested)
- {
- break;
- }
- var chunk = videoIds.Skip(i).Take(50).ToList();
- var ids = string.Join(",", chunk.Select(Uri.EscapeDataString));
- var url = $"{BaseUrl}/videos?part=liveStreamingDetails&id={ids}&maxResults=50";
- var response = await client.GetAsync(url, ct);
- if (!response.IsSuccessStatusCode)
- {
- var errorBody = await response.Content.ReadAsStringAsync(ct);
- logger.LogWarning("[YouTube] videos.list (concurrent viewers) failed: {StatusCode} — {Body}", response.StatusCode, errorBody);
- continue;
- }
- var json = await response.Content.ReadAsStringAsync(ct);
- var doc = JsonSerializer.Deserialize<JsonElement>(json);
- var items = doc.GetProperty("items");
- foreach (var item in items.EnumerateArray())
- {
- var videoId = item.GetProperty("id").GetString();
- if (string.IsNullOrEmpty(videoId))
- {
- continue;
- }
- if (!item.TryGetProperty("liveStreamingDetails", out var lsd))
- {
- continue;
- }
- // concurrentViewers 는 라이브 중이 아니면 응답에 없음
- if (!lsd.TryGetProperty("concurrentViewers", out var cvProp))
- {
- continue;
- }
- if (int.TryParse(cvProp.GetString(), out var cv))
- {
- result[videoId] = cv;
- }
- }
- }
- return result;
- }
- catch (Exception ex)
- {
- logger.LogError(ex, "[YouTube] GetConcurrentViewers error");
- return result;
- }
- }
- public async Task<IReadOnlyList<string>> GetRecentUploadsAsync(string channelId, int maxResults, CancellationToken ct)
- {
- if (string.IsNullOrWhiteSpace(channelId))
- {
- return [];
- }
- try
- {
- // uploads 플레이리스트 ID = channelId의 2번째 문자를 U에서 UU로 바꾼 값 (YouTube 관례)
- // channelId가 UC로 시작하면 UU... 형태로 변환
- if (channelId.Length < 2 || !channelId.StartsWith("UC"))
- {
- logger.LogWarning("[YouTube] Invalid channelId for uploads playlist: {ChannelId}", channelId);
- return [];
- }
- var uploadsPlaylistId = "UU" + channelId[2..];
- var client = httpClientFactory.CreateClient("YouTubeApi");
- var url = $"{BaseUrl}/playlistItems?part=contentDetails&playlistId={Uri.EscapeDataString(uploadsPlaylistId)}&maxResults={Math.Clamp(maxResults, 1, 50)}";
- var response = await client.GetAsync(url, ct);
- if (!response.IsSuccessStatusCode)
- {
- var errorBody = await response.Content.ReadAsStringAsync(ct);
- logger.LogWarning("[YouTube] playlistItems.list failed for channel {ChannelId}: {StatusCode} — {Body}", channelId, response.StatusCode, errorBody);
- return [];
- }
- var json = await response.Content.ReadAsStringAsync(ct);
- var doc = JsonSerializer.Deserialize<JsonElement>(json);
- var items = doc.GetProperty("items");
- var videoIds = new List<string>(items.GetArrayLength());
- foreach (var item in items.EnumerateArray())
- {
- if (item.TryGetProperty("contentDetails", out var cd) && cd.TryGetProperty("videoId", out var vid))
- {
- var videoId = vid.GetString();
- if (!string.IsNullOrWhiteSpace(videoId))
- {
- videoIds.Add(videoId);
- }
- }
- }
- return videoIds;
- }
- catch (Exception ex)
- {
- logger.LogError(ex, "[YouTube] GetRecentUploads error for channel {ChannelId}", channelId);
- return [];
- }
- }
- public async Task<IReadOnlyList<YouTubeVideoStats>> GetVideoStatsBatchAsync(IReadOnlyList<string> videoIds, CancellationToken ct)
- {
- if (videoIds.Count == 0)
- {
- return [];
- }
- try
- {
- var ids = string.Join(",", videoIds.Take(50).Select(Uri.EscapeDataString));
- var client = httpClientFactory.CreateClient("YouTubeApi");
- var url = $"{BaseUrl}/videos?part=snippet,liveStreamingDetails,statistics&id={ids}";
- var response = await client.GetAsync(url, ct);
- if (!response.IsSuccessStatusCode)
- {
- var errorBody = await response.Content.ReadAsStringAsync(ct);
- logger.LogWarning("[YouTube] videos.list (batch stats) failed: {StatusCode} — {Body}", response.StatusCode, errorBody);
- return [];
- }
- var json = await response.Content.ReadAsStringAsync(ct);
- var doc = JsonSerializer.Deserialize<JsonElement>(json);
- var items = doc.GetProperty("items");
- var results = new List<YouTubeVideoStats>(items.GetArrayLength());
- foreach (var item in items.EnumerateArray())
- {
- var videoId = item.GetProperty("id").GetString() ?? "";
- var snippet = item.GetProperty("snippet");
- var title = snippet.TryGetProperty("title", out var titleProp) ? titleProp.GetString() ?? "" : "";
- var channelId = snippet.TryGetProperty("channelId", out var chProp) ? chProp.GetString() ?? "" : "";
- DateTime? actualStart = null;
- DateTime? actualEnd = null;
- var isLiveStream = false;
- if (item.TryGetProperty("liveStreamingDetails", out var lsd))
- {
- isLiveStream = true;
- if (lsd.TryGetProperty("actualStartTime", out var asProp)
- && DateTime.TryParse(asProp.GetString(), out var ast))
- {
- actualStart = ast.ToUniversalTime();
- }
- if (lsd.TryGetProperty("actualEndTime", out var aeProp)
- && DateTime.TryParse(aeProp.GetString(), out var aet))
- {
- actualEnd = aet.ToUniversalTime();
- }
- }
- long viewCount = 0;
- long likeCount = 0;
- if (item.TryGetProperty("statistics", out var stats))
- {
- if (stats.TryGetProperty("viewCount", out var vcProp) && long.TryParse(vcProp.GetString(), out var vc))
- {
- viewCount = vc;
- }
- if (stats.TryGetProperty("likeCount", out var lcProp) && long.TryParse(lcProp.GetString(), out var lc))
- {
- likeCount = lc;
- }
- }
- results.Add(new YouTubeVideoStats(videoId, title, channelId, actualStart, actualEnd, viewCount, likeCount, isLiveStream));
- }
- return results;
- }
- catch (Exception ex)
- {
- logger.LogError(ex, "[YouTube] GetVideoStatsBatch error");
- return [];
- }
- }
- // ── 사용자별 (OAuth Access Token) ────────────────────────────────
- public async Task<bool> IsSubscribedAsync(string accessToken, string channelId, CancellationToken ct)
- {
- try
- {
- var client = CreateAuthClient(accessToken);
- var url = $"{BaseUrl}/subscriptions?part=id&mine=true&forChannelId={Uri.EscapeDataString(channelId)}";
- var response = await client.GetAsync(url, ct);
- if (!response.IsSuccessStatusCode)
- {
- logger.LogWarning("[YouTube] subscriptions.list failed: {StatusCode}", response.StatusCode);
- return false;
- }
- var json = await response.Content.ReadAsStringAsync(ct);
- var doc = JsonSerializer.Deserialize<JsonElement>(json);
- var totalResults = doc.GetProperty("pageInfo").GetProperty("totalResults").GetInt32();
- return totalResults > 0;
- }
- catch (Exception ex)
- {
- logger.LogError(ex, "[YouTube] IsSubscribed error for channelId={ChannelId}", channelId);
- return false;
- }
- }
- public async Task<YouTubeMembershipStatus> CheckMembershipAsync(string accessToken, string channelId, CancellationToken ct)
- {
- try
- {
- // YouTube Members API는 채널 소유자만 호출 가능
- // 대안: 사용자의 membershipsLevels 조회 또는 채널 소유자 토큰으로 members.list 호출
- // 여기서는 사용자 관점에서 "내 멤버십" 조회를 시도
- var client = CreateAuthClient(accessToken);
- var url = $"{BaseUrl}/members?part=snippet&mode=list_members&maxResults=1&filterByMemberChannelId={Uri.EscapeDataString(channelId)}";
- var response = await client.GetAsync(url, ct);
- if (!response.IsSuccessStatusCode)
- {
- // 403 = 채널 소유자가 아님 → 멤버십 확인 불가
- if ((int)response.StatusCode == 403)
- {
- logger.LogInformation("[YouTube] Members API requires channel owner token for channelId={ChannelId}", channelId);
- return new YouTubeMembershipStatus(false, null, null);
- }
- logger.LogWarning("[YouTube] members.list failed: {StatusCode}", response.StatusCode);
- return new YouTubeMembershipStatus(false, null, null);
- }
- var json = await response.Content.ReadAsStringAsync(ct);
- var doc = JsonSerializer.Deserialize<JsonElement>(json);
- var items = doc.GetProperty("items");
- if (items.GetArrayLength() == 0)
- {
- return new YouTubeMembershipStatus(false, null, null);
- }
- var snippet = items[0].GetProperty("snippet");
- var memberLevel = snippet.TryGetProperty("membershipsDetails", out var md)
- && md.TryGetProperty("highestAccessibleLevel", out var level)
- ? level.GetString()
- : null;
- var memberSince = snippet.TryGetProperty("memberSince", out var msProp)
- ? DateTime.TryParse(msProp.GetString(), out var ms) ? ms : (DateTime?)null
- : null;
- return new YouTubeMembershipStatus(true, memberLevel, memberSince);
- }
- catch (Exception ex)
- {
- logger.LogError(ex, "[YouTube] CheckMembership error for channelId={ChannelId}", channelId);
- return new YouTubeMembershipStatus(false, null, null);
- }
- }
- public async Task<YouTubeChannelInfo?> GetMyChannelAsync(string accessToken, CancellationToken ct)
- {
- try
- {
- var client = CreateAuthClient(accessToken);
- var url = $"{BaseUrl}/channels?part=snippet,statistics,brandingSettings&mine=true";
- var response = await client.GetAsync(url, ct);
- if (!response.IsSuccessStatusCode)
- {
- var errorBody = await response.Content.ReadAsStringAsync(ct);
- logger.LogWarning("[YouTube] GetMyChannel failed: {StatusCode} — {Body}", response.StatusCode, errorBody);
- return null;
- }
- var json = await response.Content.ReadAsStringAsync(ct);
- var doc = JsonSerializer.Deserialize<JsonElement>(json);
- var items = doc.GetProperty("items");
- if (items.GetArrayLength() == 0)
- {
- logger.LogWarning("[YouTube] GetMyChannel: no channel found for authenticated user");
- return null;
- }
- return ParseChannelInfo(items[0]);
- }
- catch (Exception ex)
- {
- logger.LogError(ex, "[YouTube] GetMyChannel error");
- return null;
- }
- }
- // ── Private ──────────────────────────────────────────────────────
- private async Task<YouTubeChannelInfo?> FetchChannelAsync(string url, CancellationToken ct)
- {
- try
- {
- var client = httpClientFactory.CreateClient("YouTubeApi");
- var response = await client.GetAsync(url, ct);
- if (!response.IsSuccessStatusCode)
- {
- var errorBody = await response.Content.ReadAsStringAsync(ct);
- logger.LogWarning("[YouTube] channels.list failed: {StatusCode} — {Body}", response.StatusCode, errorBody);
- return null;
- }
- var json = await response.Content.ReadAsStringAsync(ct);
- var doc = JsonSerializer.Deserialize<JsonElement>(json);
- var items = doc.GetProperty("items");
- if (items.GetArrayLength() == 0)
- {
- return null;
- }
- var item = items[0];
- return ParseChannelInfo(item);
- }
- catch (Exception ex)
- {
- logger.LogError(ex, "[YouTube] FetchChannel error");
- return null;
- }
- }
- private static YouTubeChannelInfo ParseChannelInfo(JsonElement item)
- {
- var snippet = item.GetProperty("snippet");
- var statistics = item.GetProperty("statistics");
- // brandingSettings: 배너 + 이메일
- string? bannerUrl = null;
- string? email = null;
- if (item.TryGetProperty("brandingSettings", out var bs))
- {
- if (bs.TryGetProperty("image", out var img)
- && img.TryGetProperty("bannerExternalUrl", out var bannerProp))
- {
- bannerUrl = bannerProp.GetString();
- }
- if (bs.TryGetProperty("channel", out var bsCh)
- && bsCh.TryGetProperty("email", out var emailProp))
- {
- email = emailProp.GetString();
- }
- }
- // snippet.publishedAt: YouTube 채널 가입일
- DateTime? publishedAt = null;
- if (snippet.TryGetProperty("publishedAt", out var paProp)
- && DateTime.TryParse(paProp.GetString(), out var pa))
- {
- publishedAt = pa;
- }
- return new YouTubeChannelInfo(
- item.GetProperty("id").GetString()!,
- snippet.GetProperty("title").GetString() ?? "",
- snippet.TryGetProperty("description", out var descProp) ? descProp.GetString() ?? "" : "",
- snippet.GetProperty("thumbnails").GetProperty("default").GetProperty("url").GetString() ?? "",
- bannerUrl,
- snippet.TryGetProperty("customUrl", out var cuProp) ? cuProp.GetString() : null,
- statistics.TryGetProperty("subscriberCount", out var scProp) && long.TryParse(scProp.GetString(), out var sc) ? sc : 0,
- statistics.TryGetProperty("videoCount", out var vcProp) && long.TryParse(vcProp.GetString(), out var vc) ? vc : 0,
- statistics.TryGetProperty("viewCount", out var vwProp) && long.TryParse(vwProp.GetString(), out var vw) ? vw : 0,
- email,
- publishedAt
- );
- }
- private HttpClient CreateAuthClient(string accessToken)
- {
- var client = httpClientFactory.CreateClient("YouTubeApi");
- client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken);
- return client;
- }
- }
|