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:
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user