using System; using System.Globalization; using System.Net.Http; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using ytLive.Helpers; namespace ytLive.Services; /// /// TASK 39·2: the two numbers YouTube only publishes through the **Analytics** API — long-form watch /// hours (trailing 12 months) and Shorts-feed views (trailing 90 days). /// This is the one place the app needs a scope beyond auth/youtube: the Analytics API /// requires yt-analytics.readonly, which means a one-time Google re-consent. Until that scope is /// granted, the drawer says so instead of inventing numbers. /// Values are estimates: Analytics lags, and YouTube's own validity filtering decides what counts /// towards eligibility. /// public interface IWatchMetricsSource { /// Estimated public long-form watch hours over the trailing 12 months, or null when the /// Analytics scope is not granted / the read failed. Task GetLongFormWatchHoursAsync(CancellationToken cancellationToken = default); /// Shorts-feed views over the trailing 90 days, or null. Task GetShortsViewsAsync(CancellationToken cancellationToken = default); } public sealed class YouTubeAnalyticsService : IWatchMetricsSource { private const string ApiBase = "https://youtubeanalytics.googleapis.com/v2/reports"; private readonly YouTubeAuthService _auth; private readonly HttpClient _http; private readonly Func _now; public YouTubeAnalyticsService(YouTubeAuthService auth, HttpClient? http = null, Func? now = null) { _auth = auth; _http = http ?? new HttpClient(); _now = now ?? (() => DateTime.UtcNow); } public async Task GetLongFormWatchHoursAsync(CancellationToken cancellationToken = default) { // Minutes watched for long-form videos over the trailing year → hours. var minutes = await QueryTotalAsync( "estimatedMinutesWatched", "creatorContentType==video", 365, cancellationToken).ConfigureAwait(false); return minutes is null ? null : (long)Math.Round(minutes.Value / 60.0); } public async Task GetShortsViewsAsync(CancellationToken cancellationToken = default) => await QueryTotalAsync("views", "creatorContentType==shorts", 90, cancellationToken) .ConfigureAwait(false); private async Task QueryTotalAsync( string metric, string filter, int days, CancellationToken cancellationToken) { if (!_auth.HasAnalyticsScope) return null; if (_auth.CurrentChannel?.AccessToken is not { } token) return null; var end = _now().Date; var start = end.AddDays(-days); var url = $"{ApiBase}?ids=channel==MINE" + $"&startDate={start.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)}" + $"&endDate={end.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)}" + $"&metrics={Uri.EscapeDataString(metric)}" + $"&filters={Uri.EscapeDataString(filter)}"; try { _http.DefaultRequestHeaders.Authorization = new("Bearer", token); var response = await _http.GetAsync(url, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { AppLog.Write($"Analytics query failed ({metric}): {(int)response.StatusCode}"); return null; } var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); return ParseTotal(json); } catch (Exception ex) { AppLog.Write($"Analytics query failed ({metric}): {ex.Message}"); return null; } } /// Pure: the Analytics API answers with columnHeaders + rows; a channel with no data in the /// window returns no rows at all, which must read as "no data" rather than zero. internal static long? ParseTotal(string json) { using var document = JsonDocument.Parse(json); if (!document.RootElement.TryGetProperty("rows", out var rows)) return null; if (rows.ValueKind != JsonValueKind.Array || rows.GetArrayLength() == 0) return null; var row = rows[0]; if (row.ValueKind != JsonValueKind.Array || row.GetArrayLength() == 0) return null; var value = row[0]; return value.ValueKind == JsonValueKind.Number && value.TryGetInt64(out var total) ? total : null; } }