feat(ypp): the three eligibility bars, with dated thresholds (TASK 39·2)
YouTube moves the goalposts, so the app stops modelling them as its own truth and shows progress against a threshold table that carries the date it was read. - `Models/YppProgress.cs` — pure: three metrics, per-criterion met, and `MeetsPublishedRule`, which encodes the real shape of the rule (subscribers AND either view route). The drawer says "x of 3 criteria met — YouTube reviews eligibility" and spells out the either/or, so "2 of 3" can't be misread. - `Services/YouTubeAnalyticsService.cs` — the two numbers only Analytics publishes: long-form watch hours (trailing 12 months, `creatorContentType==video`) and Shorts-feed views (90 days, `==shorts`). No rows means NO DATA, not zero. - **Thresholds are data with a publication date** (`YppThresholds.AsOfUtc`, shown in the drawer) and a test fails once that reading is more than 180 days old. The 2027-02-01 doubling (4,000h → 8,000h, 10M → 20M Shorts views) resolves from the date, not from an edit. - **No Analytics scope → no invented numbers:** the drawer explains the missing consent instead of drawing two empty bars as zero. Money stays behind 3·20's "Show monetary metrics" toggle. - Fan-funding tier (500 subs) demoted to a single muted line, since the three bars are the ad-revenue path. Tests: `YppProgressTests` — either/or eligibility, subscribers alone insufficient, Shorts as a qualifying route, bars clamp at 100%, the doubling date, the staleness guard, Analytics parsing (no rows = no data), and compact value formatting. FAST gate: 567 passed, 0 failed, 0 warnings.
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// <para>This is the one place the app needs a scope beyond <c>auth/youtube</c>: the Analytics API
|
||||
/// requires <c>yt-analytics.readonly</c>, which means a one-time Google re-consent. Until that scope is
|
||||
/// granted, the drawer says so instead of inventing numbers.</para>
|
||||
/// <para>Values are estimates: Analytics lags, and YouTube's own validity filtering decides what counts
|
||||
/// towards eligibility.</para>
|
||||
/// </summary>
|
||||
public interface IWatchMetricsSource
|
||||
{
|
||||
/// <summary>Estimated public long-form watch hours over the trailing 12 months, or null when the
|
||||
/// Analytics scope is not granted / the read failed.</summary>
|
||||
Task<long?> GetLongFormWatchHoursAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Shorts-feed views over the trailing 90 days, or null.</summary>
|
||||
Task<long?> 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<DateTime> _now;
|
||||
|
||||
public YouTubeAnalyticsService(YouTubeAuthService auth, HttpClient? http = null, Func<DateTime>? now = null)
|
||||
{
|
||||
_auth = auth;
|
||||
_http = http ?? new HttpClient();
|
||||
_now = now ?? (() => DateTime.UtcNow);
|
||||
}
|
||||
|
||||
public async Task<long?> 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<long?> GetShortsViewsAsync(CancellationToken cancellationToken = default)
|
||||
=> await QueryTotalAsync("views", "creatorContentType==shorts", 90, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
private async Task<long?> 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user