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
+175 -20
View File
@@ -413,13 +413,16 @@
</StackPanel>
</Grid>
<!-- ═══ BROADCAST TEXT PULL-OUT (always visible) ═══
White "Stream Settings" tab on the preview's right edge; clicking it
toggles the broadcast-metadata form, and any click outside
the form collapses it. Edits persist locally immediately;
Update pushes liveBroadcasts.update. -->
<!-- ═══ RIGHT-EDGE PULL-OUTS (always visible) ═══
White "Stream Settings" + "YPP" tabs stacked on the preview's right
edge; each toggles its own drawer (the same animated-width pattern).
Only one drawer is open at a time — IsDrawerOpen exclusivity is
VM-enforced (opening one collapses the other). Any click outside
both collapses whichever is open (MainWindow code-behind). -->
<Grid Grid.Row="0">
<StackPanel x:Name="TextPullOutHost" Orientation="Horizontal" HorizontalAlignment="Right" VerticalAlignment="Stretch">
<StackPanel x:Name="TextPullOutHost" Orientation="Horizontal"
HorizontalAlignment="Right" VerticalAlignment="Stretch">
<!-- The broadcast-metadata drawer -->
<Border Background="#16213e" ClipToBounds="True">
<Border.Style>
<Style TargetType="Border">
@@ -509,21 +512,173 @@
</StackPanel>
</Grid>
</Border>
<!-- The white tab -->
<Border Width="30" VerticalAlignment="Center" Background="#ffffff"
CornerRadius="4,0,0,4" Padding="0,10" Cursor="Hand"
ToolTip="Broadcast text fields">
<Border.InputBindings>
<MouseBinding Gesture="LeftClick"
Command="{Binding BroadcastForm.ToggleDrawerCommand}"/>
</Border.InputBindings>
<TextBlock Text="Stream Settings" Foreground="#e94560" FontWeight="Bold" FontSize="15"
HorizontalAlignment="Center">
<TextBlock.LayoutTransform>
<RotateTransform Angle="90"/>
</TextBlock.LayoutTransform>
</TextBlock>
<!-- The YPP-progress drawer (below Stream Settings on the rail) -->
<Border Background="#16213e" ClipToBounds="True">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Width" Value="0"/>
<Style.Triggers>
<DataTrigger Binding="{Binding Ypp.IsDrawerOpen}" Value="True">
<DataTrigger.EnterActions>
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Storyboard.TargetProperty="Width"
To="320" Duration="0:0:0.2">
<DoubleAnimation.EasingFunction>
<CubicEase EasingMode="EaseOut"/>
</DoubleAnimation.EasingFunction>
</DoubleAnimation>
</Storyboard>
</BeginStoryboard>
</DataTrigger.EnterActions>
<DataTrigger.ExitActions>
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Storyboard.TargetProperty="Width"
To="0" Duration="0:0:0.2">
<DoubleAnimation.EasingFunction>
<CubicEase EasingMode="EaseOut"/>
</DoubleAnimation.EasingFunction>
</DoubleAnimation>
</Storyboard>
</BeginStoryboard>
</DataTrigger.ExitActions>
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<Grid Width="320" Margin="14,10,14,12">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid Grid.Row="0" Margin="0,0,0,8">
<TextBlock Text="YPP PROGRESS" Style="{StaticResource SectionHeader}"
HorizontalAlignment="Left"/>
<Button HorizontalAlignment="Right" VerticalAlignment="Top"
Background="Transparent" BorderThickness="0" Cursor="Hand"
Command="{Binding Ypp.CloseDrawerCommand}"
ToolTip="Close">
<Button.Template>
<ControlTemplate TargetType="Button">
<ContentPresenter/>
</ControlTemplate>
</Button.Template>
<TextBlock Text="✕" Foreground="#a0a0b0" FontSize="14" FontWeight="Bold"/>
</Button>
</Grid>
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
<StackPanel>
<TextBlock Text="Sign in to YouTube to see your Partner Program progress and eligibility checklist here."
Foreground="#d0d0e0" FontSize="13" TextWrapping="Wrap"
Visibility="{Binding Ypp.IsSignedOut, Converter={StaticResource BoolToVis}}"/>
<TextBlock Text="Refreshing from YouTube…" Foreground="#a0a0b0" FontSize="12"
Margin="0,0,0,4"
Visibility="{Binding Ypp.IsLoading, Converter={StaticResource BoolToVis}}"/>
<TextBlock Text="{Binding Ypp.LoadError}" Foreground="#ef4444" FontSize="11"
TextWrapping="Wrap" Margin="0,0,0,4"
Visibility="{Binding Ypp.LoadError, Converter={StaticResource NotNullToVis}}"/>
<StackPanel Visibility="{Binding Ypp.IsSignedOut, Converter={StaticResource InverseBoolToVis}}">
<TextBlock Text="{Binding Ypp.DisplayName}" Foreground="#e0e0e0" FontSize="13"
FontWeight="SemiBold" Margin="0,0,0,10"/>
<TextBlock Text="SUBSCRIBERS — TIER 1 · FAN FUNDING" Style="{StaticResource YtLabel}" Margin="0,0,0,2"/>
<TextBlock Text="{Binding Ypp.Tier1SubsText}" Foreground="#e0e0e0" FontSize="13"/>
<Border Height="8" Background="#2a2a40" CornerRadius="4" Margin="0,4,0,10">
<Border Height="8" Background="#e94560" CornerRadius="4" HorizontalAlignment="Left"
Width="{Binding Ypp.Tier1SubsBarWidth}"/>
</Border>
<TextBlock Text="SUBSCRIBERS — TIER 2 · AD REVENUE" Style="{StaticResource YtLabel}" Margin="0,0,0,2"/>
<TextBlock Text="{Binding Ypp.Tier2SubsText}" Foreground="#e0e0e0" FontSize="13"/>
<Border Height="8" Background="#2a2a40" CornerRadius="4" Margin="0,4,0,10">
<Border Height="8" Background="#e94560" CornerRadius="4" HorizontalAlignment="Left"
Width="{Binding Ypp.Tier2SubsBarWidth}"/>
</Border>
<TextBlock Text="PUBLIC UPLOADS (90 DAYS)" Style="{StaticResource YtLabel}" Margin="0,0,0,2"/>
<TextBlock Text="{Binding Ypp.UploadsText}" Foreground="#e0e0e0" FontSize="13"/>
<Border Height="8" Background="#2a2a40" CornerRadius="4" Margin="0,4,0,6">
<Border Height="8" Background="#e94560" CornerRadius="4" HorizontalAlignment="Left"
Width="{Binding Ypp.UploadsBarWidth}"/>
</Border>
<TextBlock Text="LONG-FORM WATCH HOURS + SHORTS VIEWS" Style="{StaticResource YtLabel}" Margin="0,10,0,2"/>
<TextBlock Text="These arrive with the extended analytics update — subscriber + upload + standing progress is live now."
Foreground="#666677" FontSize="12" TextWrapping="Wrap"/>
<TextBlock Text="COMPLIANCE CHECKLIST" Style="{StaticResource YtLabel}" Margin="0,14,0,4"/>
<CheckBox Content="Channel in good standing (live from YouTube)" Foreground="#e0e0e0" FontSize="12"
IsEnabled="False" Margin="0,2,0,0"
IsChecked="{Binding Ypp.OverallGoodStanding, Mode=OneWay}"/>
<TextBlock Text="{Binding Ypp.StandingDetailText}" Foreground="#888899" FontSize="11"
TextWrapping="Wrap" Margin="0,4,0,8"/>
<CheckBox Content="Two-Step Verification switched on" Foreground="#e0e0e0" FontSize="12"
Margin="0,2,0,0"
IsChecked="{Binding Ypp.TwoFactorEnabled}"/>
<Button Background="Transparent" BorderThickness="0" Cursor="Hand" HorizontalAlignment="Left"
Command="{Binding Ypp.OpenTwoStepCommand}" ToolTip="Open Google's 2-Step Verification page">
<Button.Template>
<ControlTemplate TargetType="Button">
<ContentPresenter/>
</ControlTemplate>
</Button.Template>
<TextBlock Text="Set up on Google →" Foreground="#5aa7ff" FontSize="12" Margin="0,0,0,6"/>
</Button>
<CheckBox Content="AdSense account linked" Foreground="#e0e0e0" FontSize="12"
Margin="0,2,0,0"
IsChecked="{Binding Ypp.AdSenseLinked}"/>
<Button Background="Transparent" BorderThickness="0" Cursor="Hand" HorizontalAlignment="Left"
Command="{Binding Ypp.OpenEarnCommand}" ToolTip="Open the YouTube Earn page">
<Button.Template>
<ControlTemplate TargetType="Button">
<ContentPresenter/>
</ControlTemplate>
</Button.Template>
<TextBlock Text="How to link on YouTube →" Foreground="#5aa7ff" FontSize="12" Margin="0,0,0,6"/>
</Button>
<TextBlock Text="WHAT COUNTS" Style="{StaticResource YtLabel}" Margin="0,10,0,4"/>
<TextBlock Text="{Binding Ypp.WhatCountsText}" Foreground="#a0a0b0" FontSize="12"
TextWrapping="Wrap" Margin="0,0,0,6"/>
</StackPanel>
</StackPanel>
</ScrollViewer>
<StackPanel Grid.Row="2" Margin="0,10,0,0">
<Button Style="{StaticResource YtButton}" Content="Refresh from YouTube"
Command="{Binding Ypp.RefreshCommand}"/>
<TextBlock Text="{Binding Ypp.LastUpdatedText}" Foreground="#888899" FontSize="11" Margin="0,6,0,0"/>
</StackPanel>
</Grid>
</Border>
<!-- The stacked white tabs -->
<StackPanel Orientation="Vertical" HorizontalAlignment="Right" VerticalAlignment="Stretch">
<Border Width="30" VerticalAlignment="Center" Background="#ffffff"
CornerRadius="4,0,0,4" Padding="0,10" Cursor="Hand" Margin="0,4,0,0"
ToolTip="Broadcast text fields">
<Border.InputBindings>
<MouseBinding Gesture="LeftClick"
Command="{Binding BroadcastForm.ToggleDrawerCommand}"/>
</Border.InputBindings>
<TextBlock Text="Stream Settings" Foreground="#e94560" FontWeight="Bold" FontSize="15"
HorizontalAlignment="Center">
<TextBlock.LayoutTransform>
<RotateTransform Angle="90"/>
</TextBlock.LayoutTransform>
</TextBlock>
</Border>
<Border Width="30" VerticalAlignment="Center" Background="#ffffff"
CornerRadius="4,0,0,4" Padding="0,10" Cursor="Hand" Margin="0,4,0,0"
ToolTip="YouTube Partner Program progress">
<Border.InputBindings>
<MouseBinding Gesture="LeftClick"
Command="{Binding Ypp.ToggleDrawerCommand}"/>
</Border.InputBindings>
<TextBlock Text="YPP" Foreground="#e94560" FontWeight="Bold" FontSize="15"
HorizontalAlignment="Center">
<TextBlock.LayoutTransform>
<RotateTransform Angle="90"/>
</TextBlock.LayoutTransform>
</TextBlock>
</Border>
</StackPanel>
</StackPanel>
</Grid>
+63 -84
View File
@@ -1,110 +1,89 @@
# HANDOFF — 2026-09-21 (pricing docs UNCOMMITTED + TASK 38 Capture Window… implemented, UNCOMMITTED)
# HANDOFF — 2026-09-22 (TASK 39 YPP journey tracker slice 1 implemented, UNCOMMITTED)
## Branch / Commit State
`main` HEAD = **`523c17b` — docs(handoff): rewrite for pushed 0c55645** (last code commit is
`0c55645`, ON-AIR armable signed-out — PUSHED). **Working tree DIRTY — TWO uncommitted work units:**
`main` HEAD = **`4e0915a` — feat(backdrop): Capture Window… in-app window pin (TASK 38, Good Dog
ONE test)** (PUSHED `523c17b..4e0915a`). **Working tree DIRTY — ONE uncommitted work unit:**
**Unit A — pricing docs session (started 2026-09-21, still uncommitted):**
**Unit — TASK 39 slice 1 (YPP journey tracker, current-scope data), this session:**
```
M Distribution.md
M README.md
M TASKS.md ← now also carries this session's TASK 38 row
M TASKS/task-10-monetization.md
M ai.md ← now also carries this session's Capture Window… bullet
M Controls/PreviewPane.xaml
M MainWindow.xaml.cs
M Services/LayoutStore.Migrations.cs
M ViewModels/MainViewModel.cs
M ViewModels/MainViewModel.Account.cs
?? Models/YppStatSnapshot.cs
?? Models/YppThresholds.cs
?? Services/ChannelStatsService.cs
?? Services/LayoutStore.Ypp.cs
?? Services/YppTrackerViewModel.cs
?? ViewModels/MainViewModel.Ypp.cs
?? ytLive.Tests/YppPullOutTests.cs
?? TASKS/task-39-ypp-journey-tracker.md
M TASKS.md
M ai.md
```
`MARCOM.md` and `MONETIZATION.md` were also edited but are **gitignored (confidential)** — never
committed.
**Unit B — TASK 38 "Capture Window…" backdrop (this session, code + docs, uncommitted):**
```
M Controls/LeftPanel.xaml
M Controls/LeftPanel.xaml.cs
M Controls/PreviewPane.xaml
M ViewModels/MainViewModel.Background.cs
M ViewModels/MainViewModel.cs
?? Services/Win32WindowEnumerator.cs
?? ViewModels/MainViewModel.WindowBackdrop.cs
?? ytLive.Tests/WindowBackdropIntegrationTests.cs
M ytLive.Tests/PillRadioTests.cs ← out-of-scope prerequisite fix (below)
?? Models/WindowInfo.cs
?? Services/IWindowEnumerator.cs
?? TASKS/task-38-window-backdrop.md
```
NOTE: `Models/WindowInfo.cs` and `Services/IWindowEnumerator.cs` show as `??` yet were already in
the working tree before this session (referenced by ai.md) — they are new/untracked, so the seams
grew out of existing code expecting them.
Build (via temp OutDir): **0 warnings / 0 errors. Full suite 313/313 pass** (312 + the new
`YppPullOutTests`).
## ⚠️ Open action items / landmines
- **Polar product `d105dfa1-497e-423b-8cd4-e0ee2e3abbc0` is still a `$99/yr` subscription** — must be
re-created as **one-time** ($29 founder / $49 list) before launch. Flagged in `ai.md`,
`TASKS/task-10-monetization.md`, `Distribution.md`. `LLAMA50` discount changed 12mo→`once`;
`LLAMAFOUNDER` unchanged (100% off, 50 uses, `once`).
- **PillRadioTests corruption fixed out-of-scope:** `ytLive.Tests/PillRadioTests.cs:105` had a
COMMITTED stray token (`…PrimaryStartButtonLabel soil;` CS1003) that blocked the ENTIRE test
project compile (no tests could run at all). Fixed by deleting the stray word — ONLY change to
that file. It's committed upstream in `0c55645`; upstream history should get a fix commit, the
working-tree file is clean of it now. Flagged so no one re-hunts it.
- **Stale "behind the picker" fact**: ai.md §Screen backdrop capture previously claimed window
capture (`window:<hwnd>`) is picker-only; corrected to note the in-app submenu + session-scoped
transience (2026-09-21).
- **The app is RUNNING on the desktop** (`ytLive` pid 8696 was seen locking `bin\net8.0…\ytLive.dll`)
— normal `dotnet build` to `bin` fails with MSB3021/MSB3027 until it's closed. Sessions verified
the build with `-p:OutDir=C:\Users\gramp\AppData\Local\Temp\ytLive-build39\ -p:UseAppHost=false`
and ran vstest against the temp dll. Close the app before committing/final verification.
- **`subscriberCount` is rounded to 3 significant figures** by Google — near-threshold ETA/bar
reads are coarse; documented in `TASK 39` doc + the drawer's `WhatCounts` text.
- **LayerReorderPersistenceTests.RealMouseDrag…** flaked once pre-TASK-38 (fullscreen game on the
desktop); unrelated, re-ran green.
- **Slice 2 (Analytics) needs Google re-consent** — `yt-analytics.readonly` added to
`YouTubeAuthService.Scope` (line 18) triggers it; do NOT merge with slice-1 work.
- Polar product `d105dfa1…` still `$99/yr` — must become one-time before launch (carried; see
`TASKS/task-10-monetization.md`). `MARCOM.md`/`MONETIZATION.md` are gitignored — never commit.
## What this session did (TASK 38 — "Capture Window…", creator ruling 2026-09-21)
## What this session did (TASK 39 slice 1 — creator ruling 2026-09-21, plan 2026-09-22)
In-app window-picker submenu on the Live backdrop context menus (PreviewPane + layer-row). Picking a
window pins it as the full-bleed layer-0 backdrop via `RedesignateBackgroundAsync("window:0x…")` —
same capture path as game/desktop, `window:` already resolves in `ScreenCaptureSourceFactory`. Full
rulings + shipped items in `TASKS/task-38-window-backdrop.md`.
Add the **"YPP"** pull-out below **Stream Settings** on the Live screen's right edge. Current-OAuth-
scope data (no re-consent) shown in-app: subscriber bars for both tiers (500 f.funding / 1,000 ad —
the 1,000/4,000h/10M→8,000h/20M doubling on **2027-02-01** handled as versioned data in
`YppThresholds`), 3-uploads/90d bar, live standing from `auditDetails`, self-reported 2FA/AdSense
checkboxes + deep links, and the "what counts" education text. Every refresh appends a `YppSnapshot`
(SQLite v10) so slice 2's velocity/ETA has history from day one.
- New: `Win32WindowEnumerator` (EnumWindows, filtered like Win32FullScreenDetector),
`ViewModels/MainViewModel.WindowBackdrop.cs` (`OpenWindows`, `RefreshWindowsCommand`,
`CaptureWindowCommand`, `IsTransientCaptureKey`=`window:`+`picker:`, `IsAliveWindowPin`,
`OnScreenCaptureFailed`).
- Heals: `ReacquireScreenCaptures` strips transient keys before auto-detection (**session-scoped,
HWND-recycle hazard**); `OnFullscreenMonitorChanged` respects a live window pin (**pin wins while
alive**); `CaptureFailed` → auto re-acquire for transient keys (window death = no dead end).
- Ctor order matters: `_windowEnumerator = WindowEnumeratorOverride ?? new Win32WindowEnumerator()`
is assigned BEFORE `RefreshWindowsCommand`/`CaptureWindowCommand`/`RefreshOpenWindows()`.
- LeftPanel row-menu bindings use Window-anchored `RelativeSource` (ContextMenu DataContext is the
row Source, not the VM) — mirrors the existing PlacementTarget.Tag pattern.
- **ONE Good Dog integration test** (`WindowBackdropIntegrationTests`): seam feeds the submenu; a
persisted `window:` key heals away on reload; pinning an un-capturable window falls back to auto.
NOTE: an earlier draft asserted the auto-fallback is non-empty — WRONG in the full suite, where
ambient desktop state can legitimately yield `null` (static art). Final test accepts null as valid.
- **Builds 0 warnings / 0 errors.** Full suite: **312/312 pass.** One wild-lasting detail: tests use
the Windows dotnet host and MUST be rebuilt before vstest (stale-dll false failures).
## Pricing decision (2026-09-21) — record, do not re-derive
One-time perpetual license: **$29 founder** (launch→first 90 days) → **$49 list** at GA. Buy once,
own forever; `IsPremium` never lapses (renewal/lapse code path dead). Free tier = full app,
watermark-only. Driver = accessibility. Full reasoning in `MONETIZATION.md` (§Pricing, "Why these
numbers"). $49 anchor, **$29 floor — never below**. Docs cascaded (unit A above).
- `ChannelStatsService` (`channels.list statistics,auditDetails,contentDetails` +
`playlistItems.list`, virtual `CaptureCurrentAsync` = test seam); `YppTrackerViewModel` mirrors
`LiveBroadcastFormViewModel` (drawer state + commands); `MainViewModel.Ypp.cs` wires `Ypp`,
`ChannelStatsFactoryOverride`, and **one-open-at-a-time exclusivity** (both directions);
account hooks in `MainViewModel.Account.cs`.
- `PreviewPane.xaml` — `TextPullOutHost` is now a drawer bank: [broadcast drawer][ypp drawer]
[stacked white-tab column]; same animated-width pattern (To=320, 0.2s CubicEase). Outside-click
collapse in `MainWindow.xaml.cs` covers both. `IsClickInsideDrawer` unchanged.
- **Good Dog = ONE integration test** (`YppPullOutTests`): connected-VM snapshot capture +
persistence + checklist round-trip + drawer toggles; real-window exclusivity both ways.
## Next step
1. User reviews/commits this work unit (code + `ai.md`/`TASKS.md`/`TASKS/task-38…`/`HANDOFF.md` in
the SAME commit, per rules). **No push unless it's a milestone.**
2. Then the pricing docs unit (5 dirty files) per `TASKS/task-10-monetization.md`.
3. Then TASK 10 implementation (perpetual-key model — renewal/lapse removal).
1. Close the running ytLive, rebuild in place, run full suite again (313/313).
2. `./scripts/scope-check.sh "<declared file list>"` audit.
3. User reviews/commits (code + memory in SAME commit, per rules). No push unless the user says.
4. Then TASK 39 **slice 2** (Analytics scope re-consent → watch-hours/Shorts + velocity/ETA +
sparkline) per `TASKS/task-39-ypp-journey-tracker.md`.
## Critical working rules (unchanged, still binding)
- **Good Dog = ONE integration test per change.**
- **Scope lock:** declare the exact file list BEFORE editing; run `scripts/scope-check.sh` with the
declared list before commit. No "while I'm here" edits. (Exception this session: the PillRadioTests
syntax-error fix — required before ANY test could compile; declared at the time.)
- **Scope lock:** declare the exact file list before editing; run `scripts/scope-check.sh` before
commit. No "while I'm here" edits.
- **Run from WSL** always uses the Windows dotnet host (`/mnt/c/Program Files/dotnet/dotnet.exe`)
with **quoted** paths; test filter runs via `dotnet vstest <dll> --filter …`.
- **ai.md + code in the SAME commit**; rewrite HANDOFF.md at session end or interruption.
with quoted paths and `--TestCaseFilter` for filtered vstest runs.
- **ai.md + code in the SAME commit**; rewrite HANDOFF.md at session end.
## Where the machine facts live
- `%APPDATA%\ytLive\ytLive.db` = real layout DB (`layout.db` is a 0-byte legacy file). sqlite3 at
`/root/android-sdk/platform-tools/sqlite3`.
`/root/android-sdk/platform-tools/sqlite3`. Schema is now **user_version = 10**.
- ffmpeg/ffprobe: `/mnt/c/Program Files/Krita (x64)/bin/` with Windows paths.
- `ai.md` §Local recording (TASK 18, ≈lines 605-640) = authoritative pill/state-model doc.
- `ai.md` §Screen backdrop capture = authoritative backdrop/capture-key/heal doc (incl. the new
TASK 38 bullet).
- `ai.md` §Screen backdrop capture = authoritative backdrop/capture-key/heal doc (incl. TASK 38).
- `ai.md` §Monetization awareness = authoritative monetization roadmap (incl. the TASK 39
journey-tracker state). YPP thresholds/date doubling live there + `Models/YppThresholds.cs`.
- Business/marcom facts: `MONETIZATION.md` + `MARCOM.md` (both gitignored).
+6 -2
View File
@@ -142,8 +142,12 @@ public partial class MainWindow : Window
{
if (e.OriginalSource is not DependencyObject original) return;
var form = _viewModel.BroadcastForm;
if (form.IsDrawerOpen && !_previewPane.IsClickInsideDrawer(original))
form.CloseDrawerCommand.Execute(null);
var ypp = _viewModel.Ypp;
if (!_previewPane.IsClickInsideDrawer(original))
{
if (form.IsDrawerOpen) form.CloseDrawerCommand.Execute(null);
if (ypp.IsDrawerOpen) ypp.CloseDrawerCommand.Execute(null);
}
if (_previewPane.IsClickInsidePreview(original)) return;
if (_leftPanel.IsClickInside(original)) return;
_viewModel.SelectedElement = null;
+17
View File
@@ -0,0 +1,17 @@
namespace ytLive.Models;
/// <summary>One captured snapshot of a connected channel's YPP-relevant numbers
/// (TASK 39). Appended on every refresh and archived in SQLite so a later slice
/// can compute velocity/(de)acceleration/ETA from the history.</summary>
public sealed class YppStatSnapshot
{
public DateTime CapturedAtUtc { get; set; }
public long SubscriberCount { get; set; }
public long VideoCount { get; set; }
public long TotalViews { get; set; }
public int UploadsLast90Days { get; set; }
public bool CommunityGuidelinesGoodStanding { get; set; }
public bool CopyrightStrikesGoodStanding { get; set; }
public bool ContentIdClaimsGoodStanding { get; set; }
public bool OverallGoodStanding { get; set; }
}
+31
View File
@@ -0,0 +1,31 @@
namespace ytLive.Models;
/// <summary>YPP eligibility thresholds as versioned, date-aware DATA — never
/// constants (ai.md "Journey tracker"). Tier 1 (fan funding) is time-invariant;
/// Tier 2 (ad revenue) doubles its long-form-hours and Shorts-view bars for NEW
/// applicants after 2027-02-01. Hardcoding the pre-2027 numbers would show the
/// wrong ETA once the doubling lands, so callers resolve the active Tier 2
/// threshold through <see cref="Tier2For"/>.</summary>
public static class YppThresholds
{
public sealed record TierThreshold(string Name, long Subscribers, long WatchHours, long ShortsViews);
/// <summary>The date the Tier-2 ad-revenue bars double for new applicants.</summary>
public static readonly DateTime Tier2DoublingDate = new(2027, 2, 1);
/// <summary>Fan-funding tier (before the full ad/YouTube Premium revenue tier).</summary>
public static TierThreshold Tier1 { get; } = new("Fan funding", 500, 3_000, 3_000_000);
/// <summary>The Tier-2 bar for a given point in time — 4,000h / 10M Shorts
/// before the doubling date, 8,000h / 20M Shorts on or after it.</summary>
public static TierThreshold Tier2For(DateTime when) => when >= Tier2DoublingDate
? new TierThreshold("Ad revenue", 1_000, 8_000, 20_000_000)
: new TierThreshold("Ad revenue", 1_000, 4_000, 10_000_000);
/// <summary>The currently-active Tier-2 threshold (resolved against today).</summary>
public static TierThreshold Tier2Now => Tier2For(DateTime.UtcNow);
/// <summary>Valid public long-form uploads required in the trailing 90 days
/// to keep each tier route eligible.</summary>
public const int UploadsRequiredPer90Days = 3;
}
+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 });
}
}
+1
View File
@@ -54,6 +54,7 @@
| 36 | Gold pass | ☐ Queued (2026-09-01) | [`TASKS/task-36-gold-pass.md`](TASKS/task-36-gold-pass.md) |
| 37 | Defaults vs current layout split | ☐ Queued (2026-09-20) | [`TASKS/task-37-defaults-current-split.md`](TASKS/task-37-defaults-current-split.md) |
| 38 | Capture Window… backdrop (in-app window picker) | ✅ Done — shipped 2026-09-21 | [`TASKS/task-38-window-backdrop.md`](TASKS/task-38-window-backdrop.md) |
| 39 | YPP journey tracker (slice 1: current-scope data) | 🔶 Slice 1 ✅ — 2026-09-22; slice 2 (Analytics + ETA) queued | [`TASKS/task-39-ypp-journey-tracker.md`](TASKS/task-39-ypp-journey-tracker.md) |
---
+84
View File
@@ -0,0 +1,84 @@
# TASK 39 — YPP journey tracker (slice 1: current-scope data)
**Status:** ✅ Slice 1 Done — shipped 2026-09-22 (Good Dog: ONE integration test)
## Idea (creator ruling 2026-09-21, feasibility signed off 2026-09-22)
Track the connected creator's YouTube Partner Program progress inside the app: a
**"YPP"** tab on the Live screen's right edge, just below **Stream Settings**, sliding out the
same way. Everything the creator needs about YPP eligibility and their position ships in-app —
no leaving the app for definitions or numbers. The user also holds the YPP ruleset (studio
"Earn" tab): 500 subs / 3 valid public uploads-90d for fan funding; 1,000 subs for full ad /
Premium revenue; valid public long-form watch hours over trailing 12 months; valid public
Shorts views (Shorts-feed origin) over 90 days; 0 active community-guidelines strikes; 2FA on;
linked AdSense; **long-form bar doubles to 8,000h on 2027-02-01** (see ai.md "Journey tracker").
**Slice split (user chose slice-1-then-slice-2):** slice 1 uses ONLY the current OAuth scopes
— no re-consent; slice 2 adds the Analytics API (`yt-analytics.readonly`, one-time Google
re-consent) behind the reserved `IChannelStatsProvider` seam for watch-hours/Shorts-views +
velocity/ETA. Slice 1 still writes SQLite snapshots on every refresh, so slice 2 has history
from day one.
## Honest limits (told to the user before building)
- No public API exposes the Earn-tab counters or the "valid public" filtering. Numbers are
**API-derived and labeled close-to-but-not-identical to Studio**.
- **2FA and AdSense linkage are not readable from ANY YouTube API** → in-app self-reported
checkboxes with deep links (Google's 2-Step page / the Earn page). Standing IS readable from
`channels.list auditDetails` (overall + the three sub-flags) — live, no re-consent.
- `subscriberCount` is rounded to 3 significant figures by Google.
## What shipped
- `Models/YppThresholds.cs` — thresholds as **versioned date-aware data** (never constants, per
ai.md): Tier1 (500 / 3,000h / 3M Shorts), `Tier2For(when)` returns 4,000h/10M before
**2027-02-01** and 8,000h/20M from then; `UploadsRequiredPer90Days = 3`.
- `Models/YppStatSnapshot.cs` + `Services/LayoutStore.Ypp.cs` + schema **v9 → v10**
(`YppSnapshot` table): append-only snapshot history; manual checklist (`Ypp.TwoFactorEnabled` /
`Ypp.AdSenseLinked`); last-refresh-UTC gate (the once-daily gate a quota-thin analytics slice
will need is baked in now).
- `Services/ChannelStatsService.cs` — `channels.list?mine=true&part=statistics,auditDetails,
contentDetails` (subs/views/videos + uploads-playlist id + standing flags) then
`playlistItems.list` (recent 50) counts uploads in the trailing 90 days. Virtual
`CaptureCurrentAsync` = the test seam. No new scope.
- `Services/YppTrackerViewModel.cs` — drawer state + `Toggle/CloseDrawerCommand` + `RefreshCommand`
(mirrors `LiveBroadcastFormViewModel`), plus: signed-out/loading/error states, tier progress
bars (subs Tier-1 & Tier-2, uploads), live standing checkbox + detail, manual 2FA/AdSense
checkboxes, `WhatCountsText` education, deep links, `EnsureLoadedAsync` (once/day gate) +
explicit refresh, `OnAccountChanged()` hook.
- `ViewModels/MainViewModel.Ypp.cs` + ctor — `Ypp` property, `ChannelStatsFactoryOverride` test
seam (mirrors `CameraEnumeratorOverride`), and **one-open-at-a-time exclusivity** wired both
directions (opening YPP closes Stream Settings and vice-versa). Sign-in/sign-out/restored-session
hooks in `MainViewModel.Account.cs`.
- UI (`Controls/PreviewPane.xaml`) — `TextPullOutHost` is now one drawer bank: the broadcast
drawer, then the YPP drawer, then a **stacked white-tab column** (Stream Settings above YPP).
Same animated-width pattern; only one open at a time. `MainWindow.xaml.cs` outside-click
collapse covers both drawers. `IsClickInsideDrawer` (PreviewPane.cs) unchanged — covers the rail.
- ONE integration test — `ytLive.Tests/YppPullOutTests.cs` (RealApp + temp DB): (1) connected
VM captures a fake snapshot → progress props, snapshot persisted, checklist round-trip, drawer
toggles; (2) real MainWindow enforces drawer exclusivity both ways.
- Slice-2 placeholder rows (long-form hours / Shorts views) render a clear "arrives with the
extended analytics update" state in the drawer.
## Deep links (verified stable endpoints; the exact Studio "Earn" tab path is a slice-2 nicety)
- Earn: `https://www.youtube.com/earn`
- 2FA: `https://myaccount.google.com/signinoptions/two-step-verification`
- AdSense: `https://support.google.com/youtube/answer/72851`
## Slice 2 (outline, queued)
`yt-analytics.readonly` (+ re-consent) → `YouTubeAnalyticsService` behind `IChannelStatsProvider`
(`ai.md`): long-form `estimatedMinutesWatched` (12-mo), Shorts-feed views (90-d), then velocity /
(de)acceleration / ETA from the `YppSnapshot` history + a sparkline. Own Good Dog landing + task
doc; the once-daily refresh gate and history table are already in place.
## Acceptance (slice 1)
- [x] YPP white tab appears below Stream Settings on the Live screen right edge.
- [x] Clicking it slides out the YPP drawer; opening one drawer closes the other; outside-click
collapses whichever is open.
- [x] Signed out → "Sign in to YouTube…" hint. Signed in → subs (Tier 1 & Tier 2), uploads/90d,
live standing, manual 2FA/AdSense, education text, refresh + last-updated.
- [x] Every refresh appends a snapshot to SQLite (history ready for slice 2).
- [x] 0 warnings / 0 errors; **313/313 tests pass** (one new Good Dog integration test).
+3
View File
@@ -73,6 +73,7 @@ public partial class MainViewModel
IsConnected = true;
SyncConnectedAccount();
Ypp.OnAccountChanged();
}
catch (Exception ex)
{
@@ -103,6 +104,7 @@ public partial class MainViewModel
IsConnected = true;
SyncConnectedAccount();
Ypp.OnAccountChanged();
return channel;
}
catch (Exception ex)
@@ -120,6 +122,7 @@ public partial class MainViewModel
IsConnected = false;
SyncConnectedAccount();
NotifySocialsChanged();
Ypp.OnAccountChanged();
AppLog.Write("Socials: signed out of YouTube");
await Task.CompletedTask;
}
+36
View File
@@ -0,0 +1,36 @@
using ytLive.Services;
namespace ytLive.ViewModels;
public partial class MainViewModel
{
/// <summary>Test seam (mirrors CameraEnumeratorOverride): tests inject a fake
/// stats service before constructing the VM so refresh never hits the network.</summary>
internal static Func<ChannelStatsService>? ChannelStatsFactoryOverride { get; set; }
/// <summary>The YPP pull-out backing VM — the drawer below Stream Settings
/// on the Live screen's right edge (TASK 39).</summary>
public Services.YppTrackerViewModel Ypp { get; private set; } = null!;
private void InitYpp()
{
Ypp = new YppTrackerViewModel(
() => _layoutStore,
() => ChannelStatsFactoryOverride?.Invoke() ?? new ChannelStatsService(_youtubeAuth),
() => IsConnected,
() => _youtubeAuth.CurrentChannel?.DisplayName);
// One drawer at a time: the two pull-outs share the preview's right edge,
// so opening one collapses the other (both directions).
BroadcastForm.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(LiveBroadcastFormViewModel.IsDrawerOpen) && BroadcastForm.IsDrawerOpen)
Ypp.IsDrawerOpen = false;
};
Ypp.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(YppTrackerViewModel.IsDrawerOpen) && Ypp.IsDrawerOpen)
BroadcastForm.IsDrawerOpen = false;
};
}
}
+1
View File
@@ -242,6 +242,7 @@ public partial class MainViewModel : ViewModelBase
() => _youtubeStream,
() => CurrentBroadcastId,
() => IsLive);
InitYpp();
// Restore the last picked mic so the mixer's first capture attaches to
// the same, already-vetted device (green dot) or reports it missing
+15 -10
View File
@@ -1439,16 +1439,21 @@ removal above). It has three layers, all free — this is the product's differen
reward totals (new members, Super Chat $, stickers $, gifts, milestones). This is the in-app
replacement for the emailed external "stream activity report" — the incumbents (Streamlabs et al.)
email near-useless post-stream summaries; we track the same data natively and maintain it.
- **Journey tracker.** On sign-in/startup, pull current `channels.list?mine=true&part=statistics`
(`subscriberCount`/`viewCount`/`videoCount` — the current OAuth scopes suffice). Compute position
toward each YPP tier threshold and project an ETA curve from the creator's own
per-stream/per-day velocity (subs + watch hours). **YPP thresholds are versioned, date-aware DATA,
never constants** — the Tier-2 bar doubles for new applicants on **2027-02-01** (long-form 4,000 →
8,000 qualified hrs / 365d; Shorts 10M → 20M / 90d), while the Tier-1 fan-funding bar (500 subs /
3,000 hrs / 3M Shorts) is unchanged. Hardcoding the pre-2027 numbers would ship a wrong ETA.
Analytics-API scopes (`yt-analytics.readonly`, additive `yt-analytics-monetary.readonly`) are an
additive capability behind a seam (`IChannelStatsProvider`), not a v1 blocker — current-scope data
is the v1 baseline; the Analytics scopes would require an OAuth re-consent, deferred.
- **Journey tracker (TASK 39, slice 1 shipped 2026-09-22).** A **"YPP"** tab below the
Stream Settings pull-out (Live screen right edge) shows the connected creator's position
toward each YPP tier threshold. Slice 1 = current-scope data only: `channels.list?mine=true
&part=statistics,auditDetails,contentDetails` (subs/views/videos + uploads-playlist id +
live standing flags) + `playlistItems.list` for 90-day public uploads. Every refresh appends
a `YppSnapshot` to SQLite (schema v10) — the history a later slice needs for velocity/ETA.
**YPP thresholds are versioned, date-aware DATA, never constants** — the Tier-2 bar doubles
for new applicants on **2027-02-01** (long-form 4,000 → 8,000 qualified hrs / 365d; Shorts
10M → 20M / 90d), while the Tier-1 fan-funding bar (500 subs / 3,000 hrs / 3M Shorts) is
unchanged. Hardcoding the pre-2027 numbers would ship the wrong ETA. Watch-hours / Shorts /
velocity / ETA are **slice 2**: Analytics-API scopes (`yt-analytics.readonly`, additive
`yt-analytics-monetary.readonly`) behind the `IChannelStatsProvider` seam (additive
capability, requires an OAuth re-consent). Compliance items no API exposes (2FA, AdSense
linkage) are in-app self-reported checkboxes with deep links; standing checks are live from
`auditDetails`. Slice 2's once-daily refresh gate + snapshot history were baked into slice 1.
**What was rejected:** always-on watermark (obscurable — replaced by the flash), hard stream-time
cutoffs (the worst dead end — a stream dying mid-broadcast reads as broken, and YouTube streams
+142
View File
@@ -0,0 +1,142 @@
using System;
using System.IO;
using System.Threading.Tasks;
using Microsoft.Data.Sqlite;
using Xunit;
using ytLive.Models;
using ytLive.Services;
using ytLive.ViewModels;
namespace ytLive.Tests;
/// <summary>
/// TASK 39 Good Dog: the YPP pull-out, one integration test, real-app + temp-DB
/// pattern (mirrors BroadcastPullOutTests / WindowBackdropIntegrationTests).
/// Two sections under one [Fact]: (1) a connected channel captures a snapshot
/// through the VM — progress props populate, the snapshot persists to SQLite,
/// the manual checklist round-trips the Settings store, and the drawer commands
/// toggle; (2) a real MainWindow enforces one-open-at-a-time exclusivity between
/// the Stream Settings and YPP drawers.
/// </summary>
[Collection("RealApp")]
public sealed class YppPullOutTests
{
private readonly RealAppHost _app;
public YppPullOutTests(RealAppHost app) => _app = app;
/// <summary>Returns canned data with no network — the fake bypasses every API call.</summary>
private sealed class FakeChannelStats : ChannelStatsService
{
public FakeChannelStats(YppStatSnapshot snapshot)
: base(new YouTubeAuthService("test-client", "test-secret")) => _snapshot = snapshot;
private readonly YppStatSnapshot _snapshot;
public override Task<YppStatSnapshot?> CaptureCurrentAsync()
=> Task.FromResult<YppStatSnapshot?>(_snapshot);
}
[Fact]
public void Ypp_LoadsSnapshot_PersistsChecklist_And_DrawerExcludesBroadcast()
{
_app.Run(Run);
}
private void Run()
{
var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-ypp-{Guid.NewGuid():N}.db");
MainViewModel.LayoutPathOverride = tempDb;
MainWindow? window = default;
try
{
using (var schema = new LayoutStore(tempDb)) { }
SqliteConnection.ClearAllPools();
// ─── Section 1: connected VM captures + persists ───
var snapshot = new YppStatSnapshot
{
CapturedAtUtc = DateTime.UtcNow,
SubscriberCount = 350,
VideoCount = 42,
TotalViews = 12_345,
UploadsLast90Days = 2,
CommunityGuidelinesGoodStanding = true,
CopyrightStrikesGoodStanding = true,
ContentIdClaimsGoodStanding = true,
OverallGoodStanding = true,
};
var vm = new YppTrackerViewModel(
() => new LayoutStore(tempDb),
() => new FakeChannelStats(snapshot),
() => true,
() => "Test Channel");
vm.IsDrawerOpen = true;
Assert.True(vm.IsDrawerOpen);
vm.IsDrawerOpen = false;
vm.RefreshAsync().GetAwaiter().GetResult();
Assert.True(vm.HasSnapshot);
Assert.Equal(350, vm.SubscriberCount);
Assert.Equal(2, vm.UploadsLast90Days);
Assert.True(vm.OverallGoodStanding);
Assert.Equal("Test Channel", vm.DisplayName);
Assert.Equal("350 / 500", vm.Tier1SubsText);
Assert.Equal("350 / 1,000", vm.Tier2SubsText);
Assert.Equal(0.7, vm.Tier1SubsProgress, 3);
Assert.Equal(154, vm.Tier1SubsBarWidth, 0);
Assert.True(vm.Tier2SubsBarWidth < vm.Tier1SubsBarWidth);
Assert.StartsWith("Updated today", vm.LastUpdatedText);
// The snapshot persisted so a later analytics slice has history.
using (var reader = new LayoutStore(tempDb))
{
var history = reader.LoadYppSnapshots();
Assert.Single(history);
Assert.Equal(350, history[0].SubscriberCount);
Assert.Equal(2, history[0].UploadsLast90Days);
}
// The self-reported checklist round-trips the Settings store.
vm.TwoFactorEnabled = true;
vm.AdSenseLinked = false;
using (var reader = new LayoutStore(tempDb))
{
var (twoFactor, adsense) = reader.LoadYppChecklist();
Assert.True(twoFactor);
Assert.False(adsense);
}
// ─── Section 2: real window enforces one-open-at-a-time ───
window = new MainWindow();
var model = (MainViewModel)window.DataContext;
Assert.False(model.BroadcastForm.IsDrawerOpen);
Assert.False(model.Ypp.IsDrawerOpen);
model.Ypp.ToggleDrawerCommand.Execute(null);
Assert.True(model.Ypp.IsDrawerOpen);
Assert.False(model.BroadcastForm.IsDrawerOpen, "opening YPP must leave Stream Settings closed");
model.BroadcastForm.ToggleDrawerCommand.Execute(null);
Assert.True(model.BroadcastForm.IsDrawerOpen);
Assert.False(model.Ypp.IsDrawerOpen, "opening Stream Settings must close YPP");
model.BroadcastForm.ToggleDrawerCommand.Execute(null);
Assert.False(model.BroadcastForm.IsDrawerOpen);
model.Ypp.ToggleDrawerCommand.Execute(null);
Assert.True(model.Ypp.IsDrawerOpen);
model.Ypp.CloseDrawerCommand.Execute(null);
Assert.False(model.Ypp.IsDrawerOpen);
}
finally
{
window?.Close();
MainViewModel.LayoutPathOverride = null;
SqliteConnection.ClearAllPools();
try { File.Delete(tempDb); } catch { /* best-effort cleanup */ }
}
}
}