Files
LlamaCasty/TASKS/task-39-ypp-journey-tracker.md
gramps cb750660c3 fix(ypp): refresh 403'd on every real call — drop the auditDetails part (needs a partner scope)
Creator: 'when I attempt to refresh my YPP page, I get an error about not being
able to reach YouTube. Seriously?' Real log: channels.list failed (403) x3.

Root cause #1 (the 403): channels.list?mine=true&part=statistics,auditDetails,
contentDetails returns 403 insufficientPermissions when the token lacks the
youtubepartner-channel-audit scope — which the auditDetails part ALONE requires,
per the docs ('A request that retrieves the auditDetails part ... must provide an
authorization token that contains the youtubepartner-channel-audit scope'). That
scope is MCN partner tooling with a 2-week token-revocation rule; the app must not
hold it. TASK-39's 'current scopes suffice, no re-consent' slice-1 claim was wrong
for this part; mock-fake tests never touched the real API, so it shipped green and
403'd every refresh since 2026-09-22.
https://developers.google.com/youtube/v3/docs/channels/list

Fix: part=statistics,contentDetails only; standing flags removed from
ChannelStatsService -> YppStatSnapshot surface -> YppTrackerViewModel -> drawer,
replaced by an honest deep-link row ('Channel standing isn't exposed to YouTube
apps — check the Earn page'). YppSnapshot standing columns stay (schema-stable,
always false). channels.list failures now log the response BODY — the bare code
could not name insufficientPermissions, which is what made this undiagnosable.

Root cause #2 (found by the new Good Dog, masked by the 403): statistics come back
as JSON STRINGS ('350'); raw GetInt64() throws. Tolerant ReadInt64 (ValueKind-first;
JsonElement.TryGetInt64 THROWS on strings — type-in, not try-type).

Good Dog: ChannelStatsServiceTests.CaptureCurrent_RequestsNoAuditDetails_AndStillParsesTheSnapshot
(URL asserts no auditDetails + snapshot parses); YppPullOutTests fixture updated.
Recipes for both 403/scope and statistics-strings entered in MyMistakes.md.

Also shipped in the same commit (shared PreviewPane.xaml + ai.md): the audio-sync
status dot removal from the #77 feedback round (creator: 'what is the point of the
status light? Lose it') — IntToSyncBrushConverter deleted with it.

verify.sh gate: 0 warnings, 316/316 pass, scope-check clean.
2026-09-23 16:45:56 -07:00

7.4 KiB

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).
  • Channel standing is NOT readable by ordinary apps either — see the scope correction below; the slice-1 claim that channels.list auditDetails gave it "no re-consent" was WRONG and silently 403'd every real refresh until 2026-09-23.
  • subscriberCount is rounded to 3 significant figures by Google.

⚠️ Scope correction (2026-09-23, creator: "YPP refresh — can't reach YouTube. Seriously?")

The refresh 403'd on EVERY real call since slice 1 shipped: channels.list?mine=true &part=statistics,auditDetails,contentDetails returns 403 insufficientPermissions because the auditDetails part alone requires the youtubepartner-channel-audit scope — which no ordinary-creator app should hold (it's the MCN content-partner audit privilege, and the token has a two-week-revocation rule attached). The mock-based test passed; the real API never did. Reference: https://developers.google.com/youtube/v3/docs/channels/list ("A request that retrieves the auditDetails part … must provide … youtubepartner-channel-audit").

Fix: dropped auditDetails from the part list (now statistics,contentDetails); standing flags removed from the snapshot → VM → drawer, replaced by an honest deep-link row ("Channel standing isn't exposed to YouTube apps — check the Earn page"). The standing columns in the YppSnapshot SQLite table stay (schema-stable; always false for the new API path). channels.list failures now log the response body, so the DIAGNOSIS that cost this hunt (bare status code, no reason) can't hide again. New Good Dog: ChannelStatsServiceTests.CaptureCurrent_RequestsNoAuditDetails_ AndStillParsesTheSnapshot — guards the URL never carries auditDetails and the snapshot still parses. The same Good Dog then caught a SECOND latent bug the 403 had masked: statistics.* comes back as JSON strings (subscriberCount "350"), and the raw GetInt64() read throws — now via the tolerant ReadInt64 (ValueKind-first; JsonElement.TryGetInt64 throws on strings). Recipe in MyMistakes.

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,contentDetails (subs/views/videos + uploads-playlist id; auditDetails REMOVED 2026-09-23, see the scope correction) 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), standing info line (deep-link advice, honest about the API gap since 2026-09-23) + 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.
  • 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)

  • YPP white tab appears below Stream Settings on the Live screen right edge.
  • Clicking it slides out the YPP drawer; opening one drawer closes the other; outside-click collapses whichever is open.
  • Signed out → "Sign in to YouTube…" hint. Signed in → subs (Tier 1 & Tier 2), uploads/90d, standing-guidance row, manual 2FA/AdSense, education text, refresh + last-updated.
  • Every refresh appends a snapshot to SQLite (history ready for slice 2).
  • 0 warnings / 0 errors; 313/313 tests pass (one new Good Dog integration test).