Files
LlamaCasty/Services/YppTrackerViewModel.cs
T
gramps a1d861608f 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.
2026-11-05 16:41:09 -08:00

462 lines
18 KiB
C#

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Windows.Input;
using ytLive.Helpers;
using ytLive.Models;
namespace ytLive.Services;
/// <summary>
/// Backing VM for the YPP pull-out (the "YPP" tab below Stream Settings on the
/// Live screen's right edge). Shows the connected channel's current position
/// toward the YPP tier thresholds (subscribers + 90-day uploads via the Data
/// API), a compliance checklist (2FA/AdSense self-reported — channel standing is
/// NOT exposed to ordinary apps, so the drawer deep-links to the Earn page for it)
/// and the "what counts" education the creator would otherwise have to leave the
/// app to find. Snapshots are appended to SQLite on every refresh so a later
/// analytics slice can compute velocity/ETA from history.
/// Drawer open/close lives here (mirrors LiveBroadcastFormViewModel).
/// </summary>
public sealed class YppTrackerViewModel : ViewModelBase
{
private const double BarMaxWidth = 220;
private readonly Func<LayoutStore> _store;
private readonly Func<ChannelStatsService> _stats;
private readonly Func<bool> _isConnected;
private readonly Func<string?> _displayName;
// TASK 3·20 — the session report is DERIVED, never stored: ask YouTube for the channel's recent
// reward events and roll them up for the window that starts when this session did.
private readonly Func<ISuperChatEventSource> _rewardEvents;
private readonly Func<string?> _channelId;
private readonly Func<DateTime?> _sessionStartedAtUtc;
// TASK 39·2 — the two numbers only the Analytics API publishes. Null source (or a missing scope)
// means the drawer says so rather than inventing values.
private readonly Func<IWatchMetricsSource?> _watchMetrics;
private bool _isDrawerOpen;
private bool _isLoading;
private bool _isRefreshing;
private string? _loadError;
private string _displayNameText = string.Empty;
private long _subscriberCount;
private long _videoCount;
private long _totalViews;
private int _uploadsLast90Days;
private bool _hasSnapshot;
private bool _twoFactorEnabled;
private bool _adsenseLinked;
private DateTime? _lastRefreshedAtUtc;
public YppTrackerViewModel(
Func<LayoutStore> store,
Func<ChannelStatsService> stats,
Func<bool> isConnected,
Func<string?> displayName,
Func<ISuperChatEventSource>? rewardEvents = null,
Func<string?>? channelId = null,
Func<DateTime?>? sessionStartedAtUtc = null,
Func<IWatchMetricsSource?>? watchMetrics = null)
{
_store = store;
_stats = stats;
_isConnected = isConnected;
_displayName = displayName;
_rewardEvents = rewardEvents ?? (() => null!);
_channelId = channelId ?? (() => null);
_sessionStartedAtUtc = sessionStartedAtUtc ?? (() => null);
_watchMetrics = watchMetrics ?? (() => null);
RefreshCommand = new RelayCommand(_ => _ = RefreshAsync());
ToggleDrawerCommand = new RelayCommand(_ => IsDrawerOpen = !IsDrawerOpen);
CloseDrawerCommand = new RelayCommand(_ => IsDrawerOpen = false);
OpenEarnCommand = new RelayCommand(_ => OpenUrl(EarnUrl));
OpenTwoStepCommand = new RelayCommand(_ => OpenUrl(TwoStepVerificationUrl));
OpenAdsenseCommand = new RelayCommand(_ => OpenUrl(AdsenseGuideUrl));
(_twoFactorEnabled, _adsenseLinked) = _store().LoadYppChecklist();
_showMonetaryMetrics = _store().LoadShowMonetaryMetrics();
_lastRefreshedAtUtc = _store().LoadYppLastRefreshedAtUtc();
}
// Deep links — the parts of the checklist YouTube does NOT expose to any API
// resolve in the browser; everything API-derived stays in-app.
private const string EarnUrl = "https://www.youtube.com/earn";
private const string TwoStepVerificationUrl = "https://myaccount.google.com/signinoptions/two-step-verification";
private const string AdsenseGuideUrl = "https://support.google.com/youtube/answer/72851";
public RelayCommand RefreshCommand { get; }
public RelayCommand ToggleDrawerCommand { get; }
public RelayCommand CloseDrawerCommand { get; }
public RelayCommand OpenEarnCommand { get; }
public RelayCommand OpenTwoStepCommand { get; }
public RelayCommand OpenAdsenseCommand { get; }
public bool IsDrawerOpen
{
get => _isDrawerOpen;
set
{
if (SetProperty(ref _isDrawerOpen, value) && value)
_ = EnsureLoadedAsync();
}
}
public bool IsLoading
{
get => _isLoading;
private set => SetProperty(ref _isLoading, value);
}
public bool IsSignedOut => !_isConnected();
private SessionRewardSummary _sessionRewards = SessionRewardSummary.Empty;
/// <summary>This session's reward events, derived on demand. Never persisted.</summary>
public SessionRewardSummary SessionRewards
{
get => _sessionRewards;
private set
{
_sessionRewards = value;
OnPropertyChanged(nameof(SessionRewards));
OnPropertyChanged(nameof(HasSessionRewards));
OnPropertyChanged(nameof(SessionCountsText));
OnPropertyChanged(nameof(SessionMoneyLines));
OnPropertyChanged(nameof(HasSessionMoney));
}
}
public bool HasSessionRewards => _sessionRewards.HasEvents;
/// <summary>Counts are always neutral — "3 Super Chats" says nothing about money, and most
/// creators have none.</summary>
public string SessionCountsText
{
get
{
if (!_sessionRewards.HasEvents) return string.Empty;
var parts = new List<string>();
if (_sessionRewards.SuperChatCount > 0) parts.Add($"{_sessionRewards.SuperChatCount} Super Chat{(_sessionRewards.SuperChatCount == 1 ? "" : "s")}");
if (_sessionRewards.SuperStickerCount > 0) parts.Add($"{_sessionRewards.SuperStickerCount} Super Sticker{(_sessionRewards.SuperStickerCount == 1 ? "" : "s")}");
return string.Join(" · ", parts);
}
}
/// <summary>Money is shown only when the creator asked for it AND there is something to show.
/// A creator who never monetized never sees a zero (creator ruling 2026-11-05).</summary>
public IReadOnlyList<string> SessionMoneyLines
=> ShowMonetaryMetrics ? _sessionRewards.MoneyLines() : Array.Empty<string>();
public bool HasSessionMoney => SessionMoneyLines.Count > 0;
private bool _showMonetaryMetrics;
/// <summary>"Show monetary metrics" — lives in this drawer, persisted, OFF by default. Turning it on
/// is also what would justify requesting the monetary Analytics scope; while it is off the app never
/// asks Google for money data.</summary>
public bool ShowMonetaryMetrics
{
get => _showMonetaryMetrics;
set
{
if (!SetProperty(ref _showMonetaryMetrics, value)) return;
_store().SaveShowMonetaryMetrics(value);
OnPropertyChanged(nameof(SessionMoneyLines));
OnPropertyChanged(nameof(HasSessionMoney));
}
}
public string? LoadError
{
get => _loadError;
private set => SetProperty(ref _loadError, value);
}
public string DisplayName => _displayNameText;
// ─── Live numbers (from the Data API snapshot) ───
public long SubscriberCount => _subscriberCount;
public long VideoCount => _videoCount;
public long TotalViews => _totalViews;
public int UploadsLast90Days => _uploadsLast90Days;
/// <summary>True once a snapshot has successfully captured at least once this
/// session (drives the standing detail text's "sign in to load" state).</summary>
public bool HasSnapshot => _hasSnapshot;
// ─── Manual checklist (2FA / AdSense — no API exposes them) ───
public bool TwoFactorEnabled
{
get => _twoFactorEnabled;
set
{
if (SetProperty(ref _twoFactorEnabled, value))
PersistChecklist();
}
}
public bool AdSenseLinked
{
get => _adsenseLinked;
set
{
if (SetProperty(ref _adsenseLinked, value))
PersistChecklist();
}
}
// ─── Progress rows (subscribers is fixed; uploads is a 3/90d bar) ───
public double Tier1SubsProgress => Progress(SubscriberCount, YppThresholds.Tier1.Subscribers);
public double Tier2SubsProgress => Progress(SubscriberCount, YppThresholds.Tier2Now.Subscribers);
public double UploadsProgress => Progress(UploadsLast90Days, YppThresholds.UploadsRequiredPer90Days);
private long? _watchHours;
private long? _shortsViews;
/// <summary>The three eligibility metrics, with the threshold that applies today (the long-form bar
/// doubles on 1 Feb 2027 — the date is data, not a code change).</summary>
public YppProgress Eligibility => new(
SubscriberCount,
YppThresholds.Tier2Now.Subscribers,
_watchHours ?? 0,
YppThresholds.Tier2Now.WatchHours,
_shortsViews ?? 0,
YppThresholds.Tier2Now.ShortsViews);
/// <summary>True once the Analytics scope is granted and both numbers came back. Without it the two
/// view bars cannot be drawn honestly, so the drawer says what is missing instead.</summary>
public bool HasWatchMetrics => _watchHours.HasValue && _shortsViews.HasValue;
public string WatchMetricsMissingText =>
"Watch hours and Shorts views need YouTube Analytics access — one-time consent, granted from the "
+ "button below. Until then those two bars stay empty.";
public double SubscribersBarWidth => Eligibility.SubscribersFraction * BarMaxWidth;
public double WatchHoursBarWidth => Eligibility.WatchHoursFraction * BarMaxWidth;
public double ShortsViewsBarWidth => Eligibility.ShortsViewsFraction * BarMaxWidth;
public string SubscribersText => Eligibility.SubscribersText;
public string WatchHoursText => Eligibility.WatchHoursText;
public string ShortsViewsText => Eligibility.ShortsViewsText;
public string CriteriaMetText => Eligibility.CriteriaMetText;
public string ThresholdsAsOfText =>
$"Thresholds as published {YppThresholds.AsOfUtc:d MMMM yyyy} · estimates, can lag.";
public double Tier1SubsBarWidth => Progress(SubscriberCount, YppThresholds.Tier1.Subscribers) * BarMaxWidth;
public double Tier2SubsBarWidth => Progress(SubscriberCount, YppThresholds.Tier2Now.Subscribers) * BarMaxWidth;
public double UploadsBarWidth => Progress(UploadsLast90Days, YppThresholds.UploadsRequiredPer90Days) * BarMaxWidth;
public string Tier1SubsText => $"{SubscriberCount:N0} / {YppThresholds.Tier1.Subscribers:N0}";
public string Tier2SubsText => $"{SubscriberCount:N0} / {YppThresholds.Tier2Now.Subscribers:N0}";
public string UploadsText => $"{UploadsLast90Days} / {YppThresholds.UploadsRequiredPer90Days} in the last 90 days";
public string StandingDetailText
{
get
{
if (!HasSnapshot) return "Sign in to load your channel's progress from YouTube.";
return "Channel standing isn't exposed to YouTube apps — check the Earn page for your live status.";
}
}
public string LastUpdatedText
{
get
{
if (_lastRefreshedAtUtc == null) return "Not refreshed yet";
var local = _lastRefreshedAtUtc.Value.ToLocalTime();
return local.Date == DateTime.Today
? $"Updated today at {local:HH:mm}"
: $"Updated {local:g}";
}
}
public string WhatCountsText { get; } =
"Long-form watch hours: valid public VODs and livestreams watched in the trailing 12 " +
"months — Shorts-feed views don't count, and private, unlisted, deleted or ad-campaign " +
"time is excluded. Shorts views: valid public Shorts viewed in the Shorts feed over 90 " +
"days. Uploads: valid public long-form videos posted in the last 90 days. Zero active " +
"Community Guidelines strikes, 2-Step Verification switched on, and a linked AdSense " +
"account complete the checklist. Figures here are API-derived and close to — but not " +
"identical to — YouTube Studio's Earn tab.";
/// <summary>Account sign-in/out changed (fires from MainViewModel): reset the
/// signed-out state and refresh when a session now exists.</summary>
public void OnAccountChanged()
{
OnPropertyChanged(nameof(IsSignedOut));
OnPropertyChanged(nameof(DisplayName));
if (_hasSnapshot && !_isConnected())
{
_hasSnapshot = false;
OnPropertyChanged(nameof(HasSnapshot));
}
if (_isConnected())
_ = EnsureLoadedAsync();
}
/// <summary>Loads once per session/day (quota-honest: the analytics slice that
/// gives watch-hours/shorts has a thin targeted-query budget, so this gate is
/// baked in now). A manual "Refresh" always goes to the wire.</summary>
public async Task EnsureLoadedAsync()
{
if (!_isConnected() || _hasSnapshot) return;
var last = _store().LoadYppLastRefreshedAtUtc();
if (last != null && DateTime.UtcNow - last.Value < TimeSpan.FromHours(24)) return;
await RefreshAsync();
}
/// <summary>Asks Analytics for the two view metrics. Missing scope → nulls, and the drawer explains
/// what to do; nothing is guessed.</summary>
private async Task RefreshWatchMetricsAsync()
{
var source = _watchMetrics();
if (source == null)
{
_watchHours = null;
_shortsViews = null;
RaiseProgress();
return;
}
try
{
_watchHours = await source.GetLongFormWatchHoursAsync();
_shortsViews = await source.GetShortsViewsAsync();
}
catch (Exception ex)
{
AppLog.Write($"Watch metrics unavailable: {ex.Message}");
_watchHours = null;
_shortsViews = null;
}
RaiseProgress();
}
private void RaiseProgress()
{
OnPropertyChanged(nameof(Eligibility));
OnPropertyChanged(nameof(HasWatchMetrics));
OnPropertyChanged(nameof(SubscribersBarWidth));
OnPropertyChanged(nameof(WatchHoursBarWidth));
OnPropertyChanged(nameof(ShortsViewsBarWidth));
OnPropertyChanged(nameof(SubscribersText));
OnPropertyChanged(nameof(WatchHoursText));
OnPropertyChanged(nameof(ShortsViewsText));
OnPropertyChanged(nameof(CriteriaMetText));
OnPropertyChanged(nameof(ThresholdsAsOfText));
}
/// <summary>Asks YouTube for the channel's recent reward events and keeps only this session's.
/// Nothing is written down: the report is a view over YouTube's data, not a local ledger.</summary>
private async Task RefreshSessionRewardsAsync()
{
var started = _sessionStartedAtUtc();
var channel = _channelId();
var source = _rewardEvents();
if (started == null || string.IsNullOrWhiteSpace(channel) || source == null)
{
SessionRewards = SessionRewardSummary.Empty;
return;
}
try
{
var events = await source.GetRecentAsync(channel);
SessionRewards = SessionRewardSummary.From(events, started.Value);
}
catch (Exception ex)
{
AppLog.Write($"Session rewards unavailable: {ex.Message}");
SessionRewards = SessionRewardSummary.Empty;
}
}
public async Task RefreshAsync()
{
if (_isRefreshing) return;
_isRefreshing = true;
IsLoading = true;
await RefreshSessionRewardsAsync();
await RefreshWatchMetricsAsync();
LoadError = null;
try
{
if (!_isConnected())
{
_lastRefreshedAtUtc = null;
OnPropertyChanged(nameof(LastUpdatedText));
return;
}
var snapshot = await _stats().CaptureCurrentAsync();
if (snapshot == null)
{
LoadError = "Couldn't reach YouTube — try again.";
return;
}
ApplySnapshot(snapshot);
_store().SaveYppSnapshot(snapshot);
_lastRefreshedAtUtc = snapshot.CapturedAtUtc;
_store().SaveYppLastRefreshedAtUtc(snapshot.CapturedAtUtc);
OnPropertyChanged(nameof(LastUpdatedText));
}
catch (Exception ex)
{
AppLog.Write($"YPP refresh failed: {ex}");
LoadError = "Refresh failed — try again.";
}
finally
{
_isRefreshing = false;
IsLoading = false;
}
}
private void ApplySnapshot(YppStatSnapshot snapshot)
{
_displayNameText = _displayName() ?? string.Empty;
_subscriberCount = snapshot.SubscriberCount;
_videoCount = snapshot.VideoCount;
_totalViews = snapshot.TotalViews;
_uploadsLast90Days = snapshot.UploadsLast90Days;
_hasSnapshot = true;
OnPropertyChanged(nameof(DisplayName));
OnPropertyChanged(nameof(SubscriberCount));
OnPropertyChanged(nameof(VideoCount));
OnPropertyChanged(nameof(TotalViews));
OnPropertyChanged(nameof(UploadsLast90Days));
OnPropertyChanged(nameof(HasSnapshot));
OnPropertyChanged(nameof(Tier1SubsProgress));
OnPropertyChanged(nameof(Tier2SubsProgress));
OnPropertyChanged(nameof(UploadsProgress));
OnPropertyChanged(nameof(Tier1SubsBarWidth));
OnPropertyChanged(nameof(Tier2SubsBarWidth));
OnPropertyChanged(nameof(UploadsBarWidth));
OnPropertyChanged(nameof(Tier1SubsText));
OnPropertyChanged(nameof(Tier2SubsText));
OnPropertyChanged(nameof(UploadsText));
OnPropertyChanged(nameof(StandingDetailText));
}
private void PersistChecklist() => _store().SaveYppChecklist(TwoFactorEnabled, AdSenseLinked);
private static double Progress(long current, long target)
=> target <= 0 ? 0 : Math.Clamp((double)current / target, 0, 1);
private static void OpenUrl(string url)
{
Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
}
}