feat(ypp): YPP journey tracker slice 1 — the YPP pull-out below Stream Settings (TASK 39, Good Dog ONE test)

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.
This commit is contained in:
2026-09-22 08:07:54 -07:00
parent 4e0915a36e
commit a9d5f29084
16 changed files with 1103 additions and 117 deletions
+119
View File
@@ -0,0 +1,119 @@
using System.Net.Http;
using System.Text.Json;
using ytLive.Models;
using AppLog = ytLive.Helpers.AppLog;
namespace ytLive.Services;
/// <summary>
/// Pulls a connected channel's YPP-relevant numbers from the YouTube Data API
/// (TASK 39, slice 1 — the current OAuth scopes suffice, no re-consent):
/// subscriptions/views/videos + audit standing via channels.list, and the count
/// of public uploads in the trailing 90 days via playlistItems.list on the
/// uploads playlist. Long-form watch hours and Shorts-feed views need the
/// Analytics API (a later slice behind the IChannelStatsProvider seam).
/// </summary>
public class ChannelStatsService
{
private readonly YouTubeAuthService _auth;
private readonly HttpClient _http;
private const string ApiBase = "https://www.googleapis.com/youtube/v3";
public ChannelStatsService(YouTubeAuthService auth, HttpClient? http = null)
{
_auth = auth;
_http = http ?? new HttpClient();
}
private async Task<bool> EnsureToken()
{
if (_auth.CurrentChannel == null) return false;
if (_auth.CurrentChannel.TokenExpiry <= DateTime.UtcNow.AddMinutes(5))
return await _auth.RefreshToken();
return true;
}
/// <summary>Captures the channel's current stats into a snapshot, or null when
/// there's no session, a request fails, or the API returns nothing. Never throws.</summary>
public virtual async Task<YppStatSnapshot?> CaptureCurrentAsync()
{
if (!await EnsureToken()) return null;
_http.DefaultRequestHeaders.Authorization = new("Bearer", _auth.CurrentChannel!.AccessToken);
var response = await _http.GetAsync(
$"{ApiBase}/channels?mine=true&part=statistics,auditDetails,contentDetails");
if (!response.IsSuccessStatusCode)
{
AppLog.Write($"YPP stats: channels.list failed ({(int)response.StatusCode})");
return null;
}
var json = JsonSerializer.Deserialize<JsonElement>(await response.Content.ReadAsStringAsync());
if (!json.TryGetProperty("items", out var items) || items.GetArrayLength() == 0)
return null;
var channel = items[0];
var statistics = channel.GetProperty("statistics");
var uploadsPlaylistId = channel.TryGetProperty("contentDetails", out var contentDetails)
&& contentDetails.TryGetProperty("relatedPlaylists", out var related)
&& related.TryGetProperty("uploads", out var uploads)
? uploads.GetString()
: null;
var uploadsLast90Days = uploadsPlaylistId != null
? await CountUploadsLast90DaysAsync(uploadsPlaylistId)
: 0;
return new YppStatSnapshot
{
CapturedAtUtc = DateTime.UtcNow,
SubscriberCount = statistics.TryGetProperty("subscriberCount", out var subs) ? subs.GetInt64() : 0,
VideoCount = statistics.TryGetProperty("videoCount", out var videos) ? videos.GetInt64() : 0,
TotalViews = statistics.TryGetProperty("viewCount", out var views) ? views.GetInt64() : 0,
UploadsLast90Days = uploadsLast90Days,
CommunityGuidelinesGoodStanding = ReadStandingFlag(channel, "communityGuidelinesGoodStanding"),
CopyrightStrikesGoodStanding = ReadStandingFlag(channel, "copyrightStrikesGoodStanding"),
ContentIdClaimsGoodStanding = ReadStandingFlag(channel, "contentIdClaimsGoodStanding"),
OverallGoodStanding = ReadStandingFlag(channel, "overallGoodStanding"),
};
}
/// <summary>Counts uploads published within the trailing 90 days, scanning the
/// most recent 50 from the uploads playlist (generous beyond the 3/90-day YPP
/// requirement; the YPP tab labels it as the recent-50 window).</summary>
private async Task<int> CountUploadsLast90DaysAsync(string uploadsPlaylistId)
{
var response = await _http.GetAsync(
$"{ApiBase}/playlistItems?part=snippet&playlistId={Uri.EscapeDataString(uploadsPlaylistId)}&maxResults=50");
if (!response.IsSuccessStatusCode)
{
AppLog.Write($"YPP stats: playlistItems.list failed ({(int)response.StatusCode})");
return 0;
}
var json = JsonSerializer.Deserialize<JsonElement>(await response.Content.ReadAsStringAsync());
if (!json.TryGetProperty("items", out var items)) return 0;
var cutoff = DateTime.UtcNow.AddDays(-90);
var count = 0;
foreach (var item in items.EnumerateArray())
{
if (!item.TryGetProperty("snippet", out var snippet)
|| !snippet.TryGetProperty("publishedAt", out var published)
|| !DateTime.TryParse(published.GetString(), out var publishedAt))
{
continue;
}
if (publishedAt.ToUniversalTime() >= cutoff)
count++;
}
return count;
}
private static bool ReadStandingFlag(JsonElement channel, string property)
=> channel.TryGetProperty("auditDetails", out var audit)
&& audit.TryGetProperty(property, out var flag)
&& flag.GetBoolean();
}
+17 -1
View File
@@ -109,6 +109,22 @@ public partial class LayoutStore : IDisposable
Value TEXT NOT NULL
);
""",
// v9 → v10 (TASK 39): YPP journey-tracker snapshots, appended on every
// stats refresh. The history is the raw data a later analytics slice
// reads to compute velocity/(de)acceleration/ETA.
"""
CREATE TABLE IF NOT EXISTS YppSnapshot (
CapturedAtUtc TEXT NOT NULL PRIMARY KEY,
SubscriberCount INTEGER NOT NULL,
VideoCount INTEGER NOT NULL,
TotalViews INTEGER NOT NULL,
UploadsLast90Days INTEGER NOT NULL,
CommunityGuidelinesGoodStanding INTEGER NOT NULL,
CopyrightStrikesGoodStanding INTEGER NOT NULL,
ContentIdClaimsGoodStanding INTEGER NOT NULL,
OverallGoodStanding INTEGER NOT NULL
);
""",
};
foreach (var sql in statements)
{
@@ -126,7 +142,7 @@ public partial class LayoutStore : IDisposable
MigrateToV3();
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "PRAGMA user_version = 9;";
cmd.CommandText = "PRAGMA user_version = 10;";
cmd.ExecuteNonQuery();
}
}
+90
View File
@@ -0,0 +1,90 @@
using Microsoft.Data.Sqlite;
using ytLive.Models;
namespace ytLive.Services;
public partial class LayoutStore : IDisposable
{
// ─── YPP journey-tracker snapshots (TASK 39) ───
/// <summary>Appends one YPP stats snapshot (append-only history; velocity/ETA
/// read it in a later analytics slice).</summary>
public void SaveYppSnapshot(YppStatSnapshot snapshot)
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = """
INSERT INTO YppSnapshot (CapturedAtUtc, SubscriberCount, VideoCount, TotalViews,
UploadsLast90Days, CommunityGuidelinesGoodStanding,
CopyrightStrikesGoodStanding, ContentIdClaimsGoodStanding,
OverallGoodStanding)
VALUES ($captured, $subs, $videos, $views, $uploads, $cg, $cs, $cic, $overall);
""";
cmd.Parameters.AddWithValue("$captured", snapshot.CapturedAtUtc.ToString("o"));
cmd.Parameters.AddWithValue("$subs", snapshot.SubscriberCount);
cmd.Parameters.AddWithValue("$videos", snapshot.VideoCount);
cmd.Parameters.AddWithValue("$views", snapshot.TotalViews);
cmd.Parameters.AddWithValue("$uploads", snapshot.UploadsLast90Days);
cmd.Parameters.AddWithValue("$cg", snapshot.CommunityGuidelinesGoodStanding ? 1 : 0);
cmd.Parameters.AddWithValue("$cs", snapshot.CopyrightStrikesGoodStanding ? 1 : 0);
cmd.Parameters.AddWithValue("$cic", snapshot.ContentIdClaimsGoodStanding ? 1 : 0);
cmd.Parameters.AddWithValue("$overall", snapshot.OverallGoodStanding ? 1 : 0);
cmd.ExecuteNonQuery();
}
/// <summary>Most recent snapshots, newest first (defaults to the full history).</summary>
public List<YppStatSnapshot> LoadYppSnapshots(int limit = 365)
{
var result = new List<YppStatSnapshot>();
using var cmd = _connection.CreateCommand();
cmd.CommandText = """
SELECT CapturedAtUtc, SubscriberCount, VideoCount, TotalViews, UploadsLast90Days,
CommunityGuidelinesGoodStanding, CopyrightStrikesGoodStanding,
ContentIdClaimsGoodStanding, OverallGoodStanding
FROM YppSnapshot ORDER BY CapturedAtUtc DESC LIMIT $limit;
""";
cmd.Parameters.AddWithValue("$limit", limit);
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
result.Add(new YppStatSnapshot
{
CapturedAtUtc = DateTime.TryParse(reader.GetString(0), out var captured) ? captured : DateTime.MinValue,
SubscriberCount = reader.GetInt64(1),
VideoCount = reader.GetInt64(2),
TotalViews = reader.GetInt64(3),
UploadsLast90Days = reader.GetInt32(4),
CommunityGuidelinesGoodStanding = reader.GetInt32(5) != 0,
CopyrightStrikesGoodStanding = reader.GetInt32(6) != 0,
ContentIdClaimsGoodStanding = reader.GetInt32(7) != 0,
OverallGoodStanding = reader.GetInt32(8) != 0,
});
}
return result;
}
/// <summary>The manually self-reported YPP checklist state (2FA / AdSense are
/// not readable from any YouTube API — the creator ticks them in-app).</summary>
public (bool TwoFactorEnabled, bool AdSenseLinked) LoadYppChecklist()
{
return (GetSetting("Ypp.TwoFactorEnabled") == "1", GetSetting("Ypp.AdSenseLinked") == "1");
}
public void SaveYppChecklist(bool twoFactorEnabled, bool adsenseLinked)
{
UpsertSetting("Ypp.TwoFactorEnabled", twoFactorEnabled ? "1" : "0");
UpsertSetting("Ypp.AdSenseLinked", adsenseLinked ? "1" : "0");
}
/// <summary>UTC timestamp of the last successful stats refresh (used to gate
/// the once-daily auto-refresh against the analytics quota-thin API).</summary>
public DateTime? LoadYppLastRefreshedAtUtc()
{
var value = GetSetting("Ypp.LastRefreshedAtUtc");
return DateTime.TryParse(value, out var dt) ? dt : null;
}
public void SaveYppLastRefreshedAtUtc(DateTime utc)
{
UpsertSetting("Ypp.LastRefreshedAtUtc", utc.ToString("o"));
}
}
+303
View File
@@ -0,0 +1,303 @@
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 });
}
}