using System.Diagnostics; using System.Windows.Input; using ytLive.Helpers; using ytLive.Models; namespace ytLive.Services; /// /// 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). /// public sealed class YppTrackerViewModel : ViewModelBase { private const double BarMaxWidth = 220; private readonly Func _store; private readonly Func _stats; private readonly Func _isConnected; private readonly Func _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 _hasSnapshot; private bool _twoFactorEnabled; private bool _adsenseLinked; private DateTime? _lastRefreshedAtUtc; public YppTrackerViewModel( Func store, Func stats, Func isConnected, Func 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; /// True once a snapshot has successfully captured at least once this /// session (drives the standing detail text's "sign in to load" state). 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 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."; /// Account sign-in/out changed (fires from MainViewModel): reset the /// signed-out state and refresh when a session now exists. public void OnAccountChanged() { OnPropertyChanged(nameof(IsSignedOut)); OnPropertyChanged(nameof(DisplayName)); if (_hasSnapshot && !_isConnected()) { _hasSnapshot = false; OnPropertyChanged(nameof(HasSnapshot)); } if (_isConnected()) _ = EnsureLoadedAsync(); } /// 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. 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; _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 }); } }