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;
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> GetConcurrentViewersAsync(IReadOnlyList videoIds, CancellationToken ct)
{
if (videoIds.Count == 0)
{
return new Dictionary();
}
var result = new Dictionary(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(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> 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(json);
var items = doc.GetProperty("items");
var videoIds = new List(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> GetVideoStatsBatchAsync(IReadOnlyList 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(json);
var items = doc.GetProperty("items");
var results = new List(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 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;
}
}