using System.Net.Http; using System.Text.Json; using ytLive.Models; using AppLog = ytLive.Helpers.AppLog; namespace ytLive.Services; /// /// Pulls a connected channel's YPP-relevant numbers from the YouTube Data API /// (TASK 39, slice 1 — the current OAuth scopes suffice, no re-consent): /// subscriptions/views/videos + audit standing via channels.list, and the count /// of public uploads in the trailing 90 days via playlistItems.list on the /// uploads playlist. Long-form watch hours and Shorts-feed views need the /// Analytics API (a later slice behind the IChannelStatsProvider seam). /// public class ChannelStatsService { private readonly YouTubeAuthService _auth; private readonly HttpClient _http; private const string ApiBase = "https://www.googleapis.com/youtube/v3"; public ChannelStatsService(YouTubeAuthService auth, HttpClient? http = null) { _auth = auth; _http = http ?? new HttpClient(); } private async Task EnsureToken() { if (_auth.CurrentChannel == null) return false; if (_auth.CurrentChannel.TokenExpiry <= DateTime.UtcNow.AddMinutes(5)) return await _auth.RefreshToken(); return true; } /// Captures the channel's current stats into a snapshot, or null when /// there's no session, a request fails, or the API returns nothing. Never throws. public virtual async Task CaptureCurrentAsync() { if (!await EnsureToken()) return null; _http.DefaultRequestHeaders.Authorization = new("Bearer", _auth.CurrentChannel!.AccessToken); var response = await _http.GetAsync( $"{ApiBase}/channels?mine=true&part=statistics,auditDetails,contentDetails"); if (!response.IsSuccessStatusCode) { AppLog.Write($"YPP stats: channels.list failed ({(int)response.StatusCode})"); return null; } var json = JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync()); if (!json.TryGetProperty("items", out var items) || items.GetArrayLength() == 0) return null; var channel = items[0]; var statistics = channel.GetProperty("statistics"); var uploadsPlaylistId = channel.TryGetProperty("contentDetails", out var contentDetails) && contentDetails.TryGetProperty("relatedPlaylists", out var related) && related.TryGetProperty("uploads", out var uploads) ? uploads.GetString() : null; var uploadsLast90Days = uploadsPlaylistId != null ? await CountUploadsLast90DaysAsync(uploadsPlaylistId) : 0; return new YppStatSnapshot { CapturedAtUtc = DateTime.UtcNow, SubscriberCount = statistics.TryGetProperty("subscriberCount", out var subs) ? subs.GetInt64() : 0, VideoCount = statistics.TryGetProperty("videoCount", out var videos) ? videos.GetInt64() : 0, TotalViews = statistics.TryGetProperty("viewCount", out var views) ? views.GetInt64() : 0, UploadsLast90Days = uploadsLast90Days, CommunityGuidelinesGoodStanding = ReadStandingFlag(channel, "communityGuidelinesGoodStanding"), CopyrightStrikesGoodStanding = ReadStandingFlag(channel, "copyrightStrikesGoodStanding"), ContentIdClaimsGoodStanding = ReadStandingFlag(channel, "contentIdClaimsGoodStanding"), OverallGoodStanding = ReadStandingFlag(channel, "overallGoodStanding"), }; } /// Counts uploads published within the trailing 90 days, scanning the /// most recent 50 from the uploads playlist (generous beyond the 3/90-day YPP /// requirement; the YPP tab labels it as the recent-50 window). private async Task CountUploadsLast90DaysAsync(string uploadsPlaylistId) { var response = await _http.GetAsync( $"{ApiBase}/playlistItems?part=snippet&playlistId={Uri.EscapeDataString(uploadsPlaylistId)}&maxResults=50"); if (!response.IsSuccessStatusCode) { AppLog.Write($"YPP stats: playlistItems.list failed ({(int)response.StatusCode})"); return 0; } var json = JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync()); if (!json.TryGetProperty("items", out var items)) return 0; var cutoff = DateTime.UtcNow.AddDays(-90); var count = 0; foreach (var item in items.EnumerateArray()) { if (!item.TryGetProperty("snippet", out var snippet) || !snippet.TryGetProperty("publishedAt", out var published) || !DateTime.TryParse(published.GetString(), out var publishedAt)) { continue; } if (publishedAt.ToUniversalTime() >= cutoff) count++; } return count; } private static bool ReadStandingFlag(JsonElement channel, string property) => channel.TryGetProperty("auditDetails", out var audit) && audit.TryGetProperty(property, out var flag) && flag.GetBoolean(); }