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;
}
}