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): subscriptions/views/videos 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). /// /// NOTE (root cause, 2026-09-23): channels.list must NOT request the /// `auditDetails` part — that part alone requires the /// `youtubepartner-channel-audit` scope the app doesn't (and mustn't) hold, and /// the whole request 403s `insufficientPermissions` over it. Standing flags are /// not exposed to ordinary apps; the drawer deep-links to YouTube Studio instead. /// 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,contentDetails"); if (!response.IsSuccessStatusCode) { // Log the response body: YouTube's error.reason names the failure // (insufficientPermissions / quotaExceeded / …) — the bare code was // undiagnosable for the 2026-09-23 auditDetails 403. var errorBody = await response.Content.ReadAsStringAsync(); AppLog.Write($"YPP stats: channels.list failed ({(int)response.StatusCode}): {errorBody}"); 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 = ReadInt64(statistics, "subscriberCount"), VideoCount = ReadInt64(statistics, "videoCount"), TotalViews = ReadInt64(statistics, "viewCount"), UploadsLast90Days = uploadsLast90Days, }; } /// YouTube serializes statistics as JSON STRINGS ("350"); a tolerant /// read that also accepts a number (the real 403 hid this parse bug until the /// auditDetails removal surfaced the string shape in 2026-09-23's Good Dog). /// NOTE: JsonElement.TryGetInt64 THROWS on a non-Number token — ValueKind must /// be checked first (try-type-in, not try-catch). private static long ReadInt64(JsonElement parent, string name) { if (!parent.TryGetProperty(name, out var value)) return 0; if (value.ValueKind == JsonValueKind.Number && value.TryGetInt64(out var n)) return n; if (value.ValueKind == JsonValueKind.String && long.TryParse(value.GetString(), out n)) return n; return 0; } /// 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; } }