using System.Text.Json; using Application.Abstractions.YouTube; using Microsoft.Extensions.Logging; namespace Infrastructure.YouTube; /// /// YouTube Data API v3 — 채널 정보, 구독/멤버십 확인 /// API Key(공개 정보)와 OAuth Access Token(사용자별)을 분리하여 사용 /// internal sealed class YouTubeApiService( IHttpClientFactory httpClientFactory, ILogger logger ) : IYouTubeApiService { private const string BaseUrl = "https://www.googleapis.com/youtube/v3"; // ── 공개 정보 (API Key) ────────────────────────────────────────── public async Task 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 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> GetChannelsByIdsAsync(IReadOnlyList 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(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 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(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; 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; } return new YouTubeLiveStreamInfo( videoId, snippet.GetProperty("title").GetString() ?? "", snippet.GetProperty("channelId").GetString() ?? "", liveBroadcastContent, activeLiveChatId, scheduledStart, actualStart ); } catch (Exception ex) { logger.LogError(ex, "[YouTube] GetLiveStreamInfo error for videoId={VideoId}", videoId); return null; } } // ── 사용자별 (OAuth Access Token) ──────────────────────────────── public async Task 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(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 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(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 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(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 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(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; } }