a9d5f29084
Current-OAuth-scope data, no re-consent: subscriber bars for both tiers (500 fan-funding / 1,000 ad-revenue; the 2027-02-01 doubling is versioned date-aware data in YppThresholds), 3-uploads/90d, live standing from channels.list auditDetails, self-reported 2FA/AdSense checkboxes + deep links (Google 2-Step / youtube.com/earn), and the "what counts" education. Every refresh appends a YppSnapshot (SQLite v10) so the analytics slice has history from day one. - ChannelStatsService (channels.list statistics,auditDetails,contentDetails + playlistItems.list, virtual CaptureCurrentAsync = test seam); YppTrackerViewModel mirrors LiveBroadcastFormViewModel. - MainViewModel.Ypp.cs: Ypp property, ChannelStatsFactoryOverride seam, one-open-at-a-time drawer exclusivity (both directions); account sign-in/out/restore hooks in Account.cs. - PreviewPane.xaml: TextPullOutHost is now a drawer bank [broadcast][ypp][stacked white tabs]; outside-click collapse covers both drawers. MainWindow.xaml.cs updated. - Wait - watchers: the whole feature already reviewed against YouTube's own docs (YPP Earn tab + API availability) before building — see TASKS/task-39-ypp-journey-tracker.md. - ONE integration test: YppPullOutTests (snapshot capture/persist + checklist round-trip + drawer exclusivity). 313/313 pass, 0 warnings. - Memory in same commit: TASKS.md row, ai.md Journey-tracker bullet (roadmap→reality), HANDOFF.md. Slice 2 (Analytics yt-analytics.readonly re-consent → watch-hours/Shorts + velocity/ETA + sparkline) is queued behind this, not merged.
303 lines
12 KiB
C#
303 lines
12 KiB
C#
using System.Diagnostics;
|
|
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 (live standing from auditDetails + self-reported
|
|
/// 2FA/AdSense), 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;
|
|
|
|
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 _communityGuidelinesGoodStanding;
|
|
private bool _copyrightStrikesGoodStanding;
|
|
private bool _contentIdClaimsGoodStanding;
|
|
private bool _overallGoodStanding;
|
|
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)
|
|
{
|
|
_store = store;
|
|
_stats = stats;
|
|
_isConnected = isConnected;
|
|
_displayName = displayName;
|
|
|
|
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();
|
|
_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();
|
|
|
|
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;
|
|
public bool CommunityGuidelinesGoodStanding => _communityGuidelinesGoodStanding;
|
|
public bool CopyrightStrikesGoodStanding => _copyrightStrikesGoodStanding;
|
|
public bool ContentIdClaimsGoodStanding => _contentIdClaimsGoodStanding;
|
|
public bool OverallGoodStanding => _overallGoodStanding;
|
|
|
|
/// <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);
|
|
|
|
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 standing from YouTube.";
|
|
var flags = new System.Collections.Generic.List<string>();
|
|
if (!CommunityGuidelinesGoodStanding) flags.Add("a community-guidelines flag");
|
|
if (!CopyrightStrikesGoodStanding) flags.Add("a copyright strike");
|
|
if (!ContentIdClaimsGoodStanding) flags.Add("a content-ID claim");
|
|
return flags.Count == 0
|
|
? "Good standing — no live flags."
|
|
: $"FLAGGED ({string.Join(", ", flags)}). Check YouTube Studio for details.";
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
public async Task RefreshAsync()
|
|
{
|
|
if (_isRefreshing) return;
|
|
_isRefreshing = true;
|
|
IsLoading = true;
|
|
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;
|
|
_communityGuidelinesGoodStanding = snapshot.CommunityGuidelinesGoodStanding;
|
|
_copyrightStrikesGoodStanding = snapshot.CopyrightStrikesGoodStanding;
|
|
_contentIdClaimsGoodStanding = snapshot.ContentIdClaimsGoodStanding;
|
|
_overallGoodStanding = snapshot.OverallGoodStanding;
|
|
_hasSnapshot = true;
|
|
|
|
OnPropertyChanged(nameof(DisplayName));
|
|
OnPropertyChanged(nameof(SubscriberCount));
|
|
OnPropertyChanged(nameof(VideoCount));
|
|
OnPropertyChanged(nameof(TotalViews));
|
|
OnPropertyChanged(nameof(UploadsLast90Days));
|
|
OnPropertyChanged(nameof(CommunityGuidelinesGoodStanding));
|
|
OnPropertyChanged(nameof(CopyrightStrikesGoodStanding));
|
|
OnPropertyChanged(nameof(ContentIdClaimsGoodStanding));
|
|
OnPropertyChanged(nameof(OverallGoodStanding));
|
|
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 });
|
|
}
|
|
} |