From cb750660c3c3bc4a79c54cfec8d41e9bcd10b624 Mon Sep 17 00:00:00 2001 From: gramps Date: Wed, 23 Sep 2026 16:45:56 -0700 Subject: [PATCH] =?UTF-8?q?fix(ypp):=20refresh=20403'd=20on=20every=20real?= =?UTF-8?q?=20call=20=E2=80=94=20drop=20the=20auditDetails=20part=20(needs?= =?UTF-8?q?=20a=20partner=20scope)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Controls/PreviewPane.xaml | 9 +- HANDOFF.md | 164 +++++++++++------------ Helpers/IntToSyncBrushConverter.cs | 19 --- MyMistakes.md | 29 ++++ Services/ChannelStatsService.cs | 47 ++++--- Services/YppTrackerViewModel.cs | 35 +---- TASKS.md | 2 +- TASKS/task-22-audio-sync-offset.md | 4 +- TASKS/task-39-ypp-journey-tracker.md | 41 ++++-- Themes/Controls.xaml | 1 - ai.md | 20 ++- ytLive.Tests/ChannelStatsServiceTests.cs | 84 ++++++++++++ ytLive.Tests/YppPullOutTests.cs | 5 - 13 files changed, 284 insertions(+), 176 deletions(-) delete mode 100644 Helpers/IntToSyncBrushConverter.cs create mode 100644 ytLive.Tests/ChannelStatsServiceTests.cs diff --git a/Controls/PreviewPane.xaml b/Controls/PreviewPane.xaml index 6fc2881..8097495 100644 --- a/Controls/PreviewPane.xaml +++ b/Controls/PreviewPane.xaml @@ -605,11 +605,9 @@ Foreground="#666677" FontSize="12" TextWrapping="Wrap"/> - + + TextWrapping="Wrap" Margin="0,0,0,8"/> @@ -947,9 +945,6 @@ IsEnabled="{Binding IsEditMode}" VerticalAlignment="Center" ToolTip="{Binding AudioSyncOffsetMs, StringFormat=Audio sync: {0} ms}"/> - diff --git a/HANDOFF.md b/HANDOFF.md index 35f5b2b..719cedf 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1,102 +1,102 @@ -# HANDOFF — 2026-09-22 (TASK 42 committed; health-poll + end-race fixes tracked) +# HANDOFF — 2026-09-23 (build #77 feedback round; LOG fixes all shipped; app likely running) ## Branch / Commit State -`main` HEAD = **`e69db4d`** (TASK 42 top bar) → **`18ab553`** (health-poll fix, committed). -Pushed state at `197ee81`. **Working tree DIRTY — end-of-stream race fix (one small change):** +`main` HEAD = **`d72949e`** (build-77 top-bar feedback: avatar/gear/cluster). Backlog: +`ac6e67a` (end close-out), `18ab553` (health-poll), `e69db4d` (TASK 42), `bb5dcb4` (TASK 41). +Pushed state at `197ee81` (36 commits ahead locally — push only at a sub-milestone / on the +user's say-so). + +**Working tree DIRTY — two finished hotfix work units from the live build #77 session:** ``` - M Services/YouTubeStreamService.cs (EndBroadcastAsync pre-flight) - M ytLive.Tests/YouTubeStreamServiceTests.cs (end-close-out test rewritten) - M ai.md, Services/index.md, TASKS/task-09-live-stream-management.md, MyMistakes.md - M HANDOFF.md (this rewrite) + M Controls/PreviewPane.xaml (audio-sync status dot removed + YPP standing row) + M Themes/Controls.xaml (IntToSyncBrush converter resource removed) + D Helpers/IntToSyncBrushConverter.cs (converter dead — deleted) + M Services/ChannelStatsService.cs (YPP auditDetails part dropped; 403 body logged) + M Services/YppTrackerViewModel.cs (standing flags removed; honest deep-link text) + M ytLive.Tests/YppPullOutTests.cs (fixture loses the standing flags) +?? ytLive.Tests/ChannelStatsServiceTests.cs (NEW Good Dog: no auditDetails in URL + parses) + M ai.md, TASKS/task-22, TASKS/task-39, TASKS.md, MyMistakes.md, HANDOFF.md ``` -## Shipped since last handoff +## ⚠️ The app is RUNNING, and that's the story of this round -- **TASK 42 top bar redesign** `e69db4d` — see below. -- **Health-poll parse fix** `18ab553` — `GetStreamHealthAsync` read `healthStatus` as - a string; the real API nests `status.healthStatus = {status, lastUpdateTimeSeconds, - configurationIssues[]}` (an OBJECT) → every 2026-09-22 session start logged - `requires an element of type 'String'`. Fixed to read the nested shape, tolerating the - old flat shape; the report-by-exception health banner is alive again. +Ac6e67a **could not be verified through the normal gate** because the user was *looking at the +running app* (PID 17752) while it happened. `verify.sh` (clean build 0-warnings + full suite + +scope check) **cannot run until the app is closed** — an in-place build fails the `apphost.exe` +copy MSB3021/MSB3027 while the exe is locked. Compile gate used instead: `dotnet build` reaches +the copy stage with XAML/markup compiling clean (only the 2 copy errors → code + XAML are valid). -## In flight — end-of-stream race fix (end close-out, 2026-09-22) +## What the user asked this round (live-launch feedback on #77, all XAML-only) -`startup.log` showed `Broadcast transition(complete) failed (403) invalidTransition → -enableAutoStop will finish` on ALL THREE 2026-09-22 stops (17:09 crash + both test -sessions). NOT the old "autoStop already fired" assumption (ai.md had it wrong): the -blind `transition(complete)` POST races YouTube/autoStop marking the broadcast complete -(via `enableAutoStop=true`, always set — tests included). Fix in the dirty tree: -`EndBroadcastAsync` pre-flights `liveBroadcasts.list→status.lifeCycleStatus` and only -POSTs complete from `live`/`testing`, skipping silently when already complete -(`enableAutoStop` finishes every skip); an inconclusive pre-check still posts (old -behavior, backstopped); still never throws. Cite: -https://developers.google.com/youtube/v3/live/docs/liveBroadcasts/transition (errors -table: invalidTransition = current-status problem, and complete is not gated on -streamStatus — only testing/live are). +1. **Avatar** much bigger — now 40×40 (was 26), CornerRadius 20, initial 20pt. Shows the streaming + account at a glance (113% bigger than the old 26 read). +2. **Gear** moved further from the brand — margin 6 → 18 (a couple more "places"). +3. **Uniform 32-height cluster in the top center** — segments host Border, Start/Go Live, and Test + all `Height="32"` so the REC|ON-AIR switch + its actions read as ONE family ("so the user can + intuit their purpose"). Running world (reality line) intentionally untouched. **[COMMITTED `d72949e`]** +4. **Audio-sync status light LOST** — `"AUDIO SYNC"` slider keeps its numeric tooltip; the green/ + amber 8×8 dot was "what is the point" dead weight. `IntToSyncBrushConverter` + its resource + + TASK-22's "visual feedback" decision all retired with it, both docs updated in-place. +5. **YPP refresh bug (this turn, "Seriously?")** — `channels.list?mine=true&part=statistics, + auditDetails,contentDetails` 403'd `insufficientPermissions` on EVERY real refresh since slice 1 + shipped: the **`auditDetails` part alone requires the `youtubepartner-channel-audit` scope** + (MCN partner tooling the app must never hold; the "no re-consent / scopes suffice" slice-1 claim + was wrong). Fix: part list is now `statistics,contentDetails`; standing flags dropped from + snapshot→VM→drawer in favour of an honest "not exposed to apps — check the Earn page" row; + `channels.list` failures now log the response body's `error.reason` (the bare code hid this for + days); new Good Dog `ChannelStatsServiceTests` guards no-auditDetails + still-parses — which it + then used to catch a SECOND latent bug the 403 masked (statistics are JSON strings; `GetInt64` + throws → now the tolerant `ReadInt64`, ValueKind-first). Docs: + ai.md / TASK-39 / TASKS.md / MyMistakes all corrected. Cite: + https://developers.google.com/youtube/v3/docs/channels/list -## What shipped — TASK 42: top bar redesign (2026-09-22) +## What shipped before this round (all committed) -Full record: **`TASKS/task-42-top-bar-redesign.md`**. Creator directive: "forget this one dog plan -bullshit. Our one plan is replacing the top menu bar with something usable." The old bar's six -widget grammars (sliding pills, dots, badges, buttons, hidden sign-in context menu, live-window -status) collapsed into **ONE surface rendered by the FIRST decision — Record or Stream**: +- **`ac6e67a` end-of-stream close-out fix** — every 2026-09-22 stop logged + `transition(complete) 403 invalidTransition`; the blind POST raced YouTube/autoStop marking the + broadcast complete (`enableAutoStop=true` always set). `EndBroadcastAsync` now pre-flights + `liveBroadcasts.list→status.lifeCycleStatus` and skips the POST when already complete/revoked; + inconclusive pre-check still posts (backstopped); never throws. Good Dog: + `EndBroadcast_Verifies_LifeCycle_Then_Transitions_Complete_Never_Throws` (3 halves: live→GET+POST, + complete→skip, 403→error-string). Cite: youtube liveBroadcasts/transition errors table. **Full + suite 315/315** (audio-timing flake `Mix_HonorsProviderGains…` cleared on rerun); verify.sh clean + pass predates this round's XAML-only edits. +- **`18ab553` health-poll fix** — `status.healthStatus` is an OBJECT, not a string; every session + start logged `requires an element of type 'String'`. Nested shape parsed (flat tolerated), banner + alive again. +- **TASK 42** (`e69db4d`) + **TASK 41** (`bb5dcb4`) — one-surface top bar, Test Stream drawer. -- **Mode switch:** one segmented **REC|ON-AIR** toggle (`SegmentToggle`/`SegmentLabel` styles). - Going back = one tap on the other segment. Runs idle-only; retires while running. -- **Record world:** switch + **Start Recording** — no YouTube identity at all. -- **Stream world:** switch + **Go Live** + Test + account zone (Sign In until connected; then the - avatar, right-click Change Account/Logout). **Test is a child of Stream** — procs only ON-AIR-armed - AND signed-in (`ShowTestButton`/`CanStartTest`). -- **Running:** one reality line `● REC|LIVE|TEST 00:12:34` (green/red/gold `RunningDotBrush`) + End. -- **Gear** moved up from bottom bar, ~3 wordmark letters past the brand, one click = Settings / Bug / - Feature / About menu (`GearButton_Click`). Bottom-bar gear removed. Task 40 Unit B is DONE by this. -- **Sign-in = the account-confirm step:** no Google API enumerates machine accounts; OAuth is - single-account, so Switch Account re-runs the chooser (`login_hint`+`select_account`) — login and - account-confirmation are one surface. Existing `ChangeAccountCommand`/`LogoutCommand` resurface via - the avatar. -- **PRIVATE/TEST chips gone from the bar** (dev-phase forced-private becomes an OPTION at ship). +## Around the task (carried facts) -**Good Dog** — `ytLive.Tests/TopBarModeTests.cs` (one `[Fact]`, `RealAppHost.Run`): default record -world → arm ON-AIR → sign-in/avatar/Test gating flips → one-tap flip back → direct stream status -changing + running reality values. Also folded in: TASK 41's latent **Test pipeline bug fix** — -`BeginTestStream` now arms `OnAirPillOn` explicitly (unarmed → encoder booted with zero outputs → -"At least one output is required" → forced stop cascade). The TestStreamTests gate updated to match. - -## ⚠️ Around the task -- **Full-suite run today: 314/315** (first pass) — the one abort is the pre-existing - `LayerReorderPersistenceTests.RealMouseDrag_OnTheLayerList_PersistsTheReorder`, which injects - REAL physical mouse input (`SetCursorPos` + `mouse_event`, see its own docstring) and no-ops while - another window interferes. Cause observed 2026-09-22: **Path of Exile 2 + NVIDIA Overlay were - running** (fullscreen-game window hides the test window's rows; the drag never lands → reorder - assert fails). `AudioPipelineTests.Mix_HonorsProviderGains…` also flaked one run (timing under - load); both cleared on the 315/315 rerun. Close games before running those tests; unrelated to the - recent changes. -- **Manual verification owed** (needs a real run on Windows): the app is a new build number when - next launched — check the new one-surface bar (switch ↔ worlds, gear menu, reality line). -- The app may be **RUNNING** — an in-place build fails MSB3021/MSB3027 until closed (temp-OutDir - workaround in ai.md). Close it before the manual pass. -- Full-suite note: the pre-existing real-camera startup flake did **not** occur in the 315/315 run. -- Test-env trap (recorded in the TASK): a dev machine's saved OAuth session loads synchronously and - makes `IsConnected` come back true in the VM ctor — signed-in-world tests must force - `vm.IsConnected = false/true` explicitly. -- `subscriberCount` rounded to 3 sig figs (YPP); carried. YPP slice 2 needs re-consent — do NOT - merge with other units. -- Polar product `d105dfa1…` still `$99/yr` — must become one-time before launch (carried). - `MARCOM.md`/`MONETIZATION.md` gitignored — never commit. +- RealMouseDrag test no-ops while a game/fullscreen window steals the mouse (POE 2 seen 2026-09-22) + — close games before full-suite runs. AudioPipeline timing flake similar (load-dependent). +- Test-env trap: saved OAuth session loads synchronously → force `vm.IsConnected` in signed-in tests. +- `subscriberCount` YPP slice 2 needs re-consent — do not merge with other units. Polar `$99/yr` + must become one-time before launch. `MARCOM.md`/`MONETIZATION.md` gitignored — never commit. +- The **`YppSnapshot.OverallGoodStanding` etc. columns now serialize false forever** (the API path + no longer populates them; schema kept stable for the analytics slice). Don't "restore" the + standing flags via auditDetails — see the MyMistakes recipe. +- TASK 40 App Settings round: units **A (camera) → C (defaults) → D (accent)** remain; A's + SharedReadOnly control-write question = only genuine uncertainty. ## Next step -Commit this end-of-stream fix (scope-check first, then build 0-warnings + full suite, then ONE -commit). Push only on the user's say-so. Then **TASK 40 App Settings round** is queued -(`TASKS/task-40-app-settings-round.md`) — units **A (camera) → C (defaults) → D (accent)** remain -(B/gear shipped early); Unit A's SharedReadOnly control-write question is the one genuinely -uncertain technical point — run its mitigation ladder before a third guess. +1. **Close the running app (PID 17752 holds the exe), then run the full gate** — + `./scripts/verify.sh` with the full declared scope (audio-dot unit + YPP fix): clean build + 0-warnings + full suite + scope check. Then commit BOTH units — they share PreviewPane.xaml / + ai.md, so a single hotfix commit ("build-77 polish + YPP refresh 403") is the honest shape, or + two commits if the user prefers strict splits. + + ⚠️ The YPP Good Dog (`ChannelStatsServiceTests`) and the updated `YppPullOutTests` cannot even + BUILD until the app closes (the app csproj copy fails on the locked exe) — they were written but + not yet executed. + +2. After the gate: push decisions pending (user's call). TASK 40 Unit A next. ## Critical working rules (unchanged, still binding) -- **Good Dog = ONE integration test per change.** -- **Scope lock:** declare the file list before editing; `./scripts/scope-check.sh` before commit. -- WSL builds use the Windows dotnet host (`/mnt/c/Program Files/dotnet/dotnet.exe`, quoted paths). -- One runtime model, no model switching, no compaction summaries (user directive). \ No newline at end of file +- **Good Dog = ONE integration test per change.** Scope lock + `./scripts/scope-check.sh` before commit. +- Windows dotnet host for all WSL builds (`/mnt/c/Program Files/dotnet/dotnet.exe`, quoted paths). +- 0 warnings on real clean builds (verify.sh only, never incremental). One runtime model. \ No newline at end of file diff --git a/Helpers/IntToSyncBrushConverter.cs b/Helpers/IntToSyncBrushConverter.cs deleted file mode 100644 index 4770d13..0000000 --- a/Helpers/IntToSyncBrushConverter.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Globalization; -using System.Windows.Data; -using System.Windows.Media; - -namespace ytLive.Helpers; - -/// TASK 22: the audio-sync status dot — green when the sync offset is a -/// no-op (0 ms), amber once the creator has dialled in a delay. -public class IntToSyncBrushConverter : IValueConverter -{ - private static readonly Brush Ok = new SolidColorBrush(Color.FromRgb(0x34, 0xd3, 0x99)); - private static readonly Brush Set = new SolidColorBrush(Color.FromRgb(0xf5, 0xb8, 0x4c)); - - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - => value is int i && i == 0 ? Ok : Set; - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - => throw new NotSupportedException(); -} diff --git a/MyMistakes.md b/MyMistakes.md index 96b9bea..329d313 100644 --- a/MyMistakes.md +++ b/MyMistakes.md @@ -15,6 +15,35 @@ ## 🔬 Recipes registry +### YOUTUBE STATISTICS ARE JSON STRINGS; TryGetInt64 THROWS ON STRINGS (RECIPE) + +YouTube Data API v3 returns `statistics.subscriberCount / viewCount / videoCount` as +JSON **strings** (`"350"`), not numbers. (2026-09-23: the YPP parse bug surfaced the +moment the auditDetails 403 stopped masking it.) Fix = a tolerant read that checks +`ValueKind` FIRST, then `TryGetInt64` for `Number`, then `long.TryParse(GetString())` +for `String`. Trap within the fix: **`JsonElement.TryGetInt64` THROWS +`InvalidOperationException` on any non-Number token** ("requires an element of type +'Number'") — it is try-type-in, not try-catch. Guard on `value.ValueKind` before +calling it; a leaked 403→parse chain means your "whole request failed" symptom can +paper over a second crash that only appears once the 403 is fixed (test the full +happy path, not just the error path). + +### CHANNELS.LIST auditDetails PART 403s THE WHOLE REQUEST WITHOUT A PARTNER SCOPE (RECIPE) + +YouTube Data API v3 `channels.list` rejects the ENTIRE request with +`403 insufficientPermissions` if your `part=` list includes `auditDetails` but the +token lacks `https://www.googleapis.com/auth/youtubepartner-channel-audit` — the +docs' exact words: "A request that retrieves the auditDetails part for a channel +resource must provide an authorization token that contains the +youtubepartner-channel-audit scope". That scope is MCN content-partner tooling +(with a two-week token-revocation rule); normal-creator apps should NEVER ask for +it. 2026-09-23 real-log evidence: YPP refresh 403'd three times in a row while the +mock-fake tests stayed green ("current scopes suffice, no re-consent" was wrong). +Fixes: (1) drop `auditDetails` from `part=`; (2) log the response BODY — the bare +status code could not name `insufficientPermissions`, which is what made this +failure undiagnosable for days; (3) when a feature needs data no ordinary scope +grants, deep-link to the site instead of requesting the partner privilege. + ### TRANSITION(COMPLETE) RACES AUTOSTOP: PRE-FLIGHT lifeCycleStatus (RECIPE) A blind `liveBroadcasts.transition?broadcastStatus=complete` POST can 403 diff --git a/Services/ChannelStatsService.cs b/Services/ChannelStatsService.cs index 4897ce9..f06babb 100644 --- a/Services/ChannelStatsService.cs +++ b/Services/ChannelStatsService.cs @@ -7,11 +7,16 @@ namespace ytLive.Services; /// /// 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 +/// (TASK 39, slice 1): subscriptions/views/videos 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). +/// +/// NOTE (root cause, 2026-09-23): channels.list must NOT request the +/// `auditDetails` part — that part alone requires the +/// `youtubepartner-channel-audit` scope the app doesn't (and mustn't) hold, and +/// the whole request 403s `insufficientPermissions` over it. Standing flags are +/// not exposed to ordinary apps; the drawer deep-links to YouTube Studio instead. /// public class ChannelStatsService { @@ -42,10 +47,14 @@ public class ChannelStatsService _http.DefaultRequestHeaders.Authorization = new("Bearer", _auth.CurrentChannel!.AccessToken); var response = await _http.GetAsync( - $"{ApiBase}/channels?mine=true&part=statistics,auditDetails,contentDetails"); + $"{ApiBase}/channels?mine=true&part=statistics,contentDetails"); if (!response.IsSuccessStatusCode) { - AppLog.Write($"YPP stats: channels.list failed ({(int)response.StatusCode})"); + // Log the response body: YouTube's error.reason names the failure + // (insufficientPermissions / quotaExceeded / …) — the bare code was + // undiagnosable for the 2026-09-23 auditDetails 403. + var errorBody = await response.Content.ReadAsStringAsync(); + AppLog.Write($"YPP stats: channels.list failed ({(int)response.StatusCode}): {errorBody}"); return null; } @@ -69,17 +78,26 @@ public class ChannelStatsService 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, + SubscriberCount = ReadInt64(statistics, "subscriberCount"), + VideoCount = ReadInt64(statistics, "videoCount"), + TotalViews = ReadInt64(statistics, "viewCount"), UploadsLast90Days = uploadsLast90Days, - CommunityGuidelinesGoodStanding = ReadStandingFlag(channel, "communityGuidelinesGoodStanding"), - CopyrightStrikesGoodStanding = ReadStandingFlag(channel, "copyrightStrikesGoodStanding"), - ContentIdClaimsGoodStanding = ReadStandingFlag(channel, "contentIdClaimsGoodStanding"), - OverallGoodStanding = ReadStandingFlag(channel, "overallGoodStanding"), }; } + /// YouTube serializes statistics as JSON STRINGS ("350"); a tolerant + /// read that also accepts a number (the real 403 hid this parse bug until the + /// auditDetails removal surfaced the string shape in 2026-09-23's Good Dog). + /// NOTE: JsonElement.TryGetInt64 THROWS on a non-Number token — ValueKind must + /// be checked first (try-type-in, not try-catch). + private static long ReadInt64(JsonElement parent, string name) + { + if (!parent.TryGetProperty(name, out var value)) return 0; + if (value.ValueKind == JsonValueKind.Number && value.TryGetInt64(out var n)) return n; + if (value.ValueKind == JsonValueKind.String && long.TryParse(value.GetString(), out n)) return n; + return 0; + } + /// 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). @@ -111,9 +129,4 @@ public class ChannelStatsService } return count; } - - private static bool ReadStandingFlag(JsonElement channel, string property) - => channel.TryGetProperty("auditDetails", out var audit) - && audit.TryGetProperty(property, out var flag) - && flag.GetBoolean(); } \ No newline at end of file diff --git a/Services/YppTrackerViewModel.cs b/Services/YppTrackerViewModel.cs index b817106..b86508b 100644 --- a/Services/YppTrackerViewModel.cs +++ b/Services/YppTrackerViewModel.cs @@ -9,10 +9,11 @@ namespace ytLive.Services; /// Backing VM for the YPP pull-out (the "YPP" tab below Stream Settings on the /// Live screen's right edge). Shows the connected channel's current position /// toward the YPP tier thresholds (subscribers + 90-day uploads via the Data -/// API), a compliance checklist (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. +/// API), a compliance checklist (2FA/AdSense self-reported — channel standing is +/// NOT exposed to ordinary apps, so the drawer deep-links to the Earn page for it) +/// and the "what counts" education the creator would otherwise have to leave the +/// app to find. Snapshots are appended to SQLite on every refresh so a later +/// analytics slice can compute velocity/ETA from history. /// Drawer open/close lives here (mirrors LiveBroadcastFormViewModel). /// public sealed class YppTrackerViewModel : ViewModelBase @@ -33,10 +34,6 @@ public sealed class YppTrackerViewModel : ViewModelBase 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; @@ -109,10 +106,6 @@ public sealed class YppTrackerViewModel : ViewModelBase 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; /// True once a snapshot has successfully captured at least once this /// session (drives the standing detail text's "sign in to load" state). @@ -158,14 +151,8 @@ public sealed class YppTrackerViewModel : ViewModelBase { get { - if (!HasSnapshot) return "Sign in to load your channel's standing from YouTube."; - var flags = new System.Collections.Generic.List(); - 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."; + if (!HasSnapshot) return "Sign in to load your channel's progress from YouTube."; + return "Channel standing isn't exposed to YouTube apps — check the Earn page for your live status."; } } @@ -263,10 +250,6 @@ public sealed class YppTrackerViewModel : ViewModelBase _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)); @@ -274,10 +257,6 @@ public sealed class YppTrackerViewModel : ViewModelBase 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)); diff --git a/TASKS.md b/TASKS.md index e81c69b..722b32d 100644 --- a/TASKS.md +++ b/TASKS.md @@ -54,7 +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) | +| 39 | YPP journey tracker (slice 1: current-scope data) | 🔶 Slice 1 ✅ — 2026-09-22; **refresh 403 fixed 2026-09-23** (auditDetails part dropped); slice 2 (Analytics + ETA) queued | [`TASKS/task-39-ypp-journey-tracker.md`](TASKS/task-39-ypp-journey-tracker.md) | | 40 | App Settings round: active-camera picker + config dialog, gear move, defaults, accent | ☐ Queued (2026-09-22) — plan saved | [`TASKS/task-40-app-settings-round.md`](TASKS/task-40-app-settings-round.md) | | 41 | Test Stream mode: private test broadcast + TEST drawer (mock chat input + simulated events) | ✅ Done (2026-09-22) | [`TASKS/task-41-test-stream-mode.md`](TASKS/task-41-test-stream-mode.md) | | 42 | Top bar redesign: one REC-or-Stream surface, gear up top, sign-in/account zone world-gated | ✅ Done (2026-09-22) | [`TASKS/task-42-top-bar-redesign.md`](TASKS/task-42-top-bar-redesign.md) | diff --git a/TASKS/task-22-audio-sync-offset.md b/TASKS/task-22-audio-sync-offset.md index 87e366f..cefe8bb 100644 --- a/TASKS/task-22-audio-sync-offset.md +++ b/TASKS/task-22-audio-sync-offset.md @@ -9,7 +9,7 @@ 1. ☑ `AudioSyncOffsetMs` on `MainViewModel` (global, default 0, range 0..500 ms) — positive-only: OBS's documented fix delays the audio so it lands on video when it runs ahead; advancing audio would need a video-side delay (out of the audio layer's scope, v1.1+). 2. ☑ Applied in `AudioMixer` — post-mix interleaved-stereo delay via the pure `AudioSyncDelay` line (`Services/Audio/AudioSyncDelay.cs`), fed through a `Func syncOffsetMs` seam each mix tick. -3. ☑ UI: compact "SYNC" slider (0..500) on the mic bar with a status dot (green = no-op, amber = offset set) via `IntToSyncBrushConverter`. **Provenance (recorded 2026-09-01 after a creator "I never ordered this" scare): the creator explicitly asked for OBS's delay-filter lip-sync fix BUILT NATIVELY — the feature is his, not AI drift. Its placement (preview-rail vs mic-settings/gear) remains an open design question; do not remove the capability.** +3. ☑ UI: compact "SYNC" slider (0..500) on the mic bar. **The status dot (green = no-op, amber = offset set) was REMOVED 2026-09-23 (creator, live-launch feedback: "what is the point of the status light? Lose it") — the slider's position + the numeric tooltip carry the state, and `IntToSyncBrushConverter` was deleted with it.** **Provenance (recorded 2026-09-01 after a creator "I never ordered this" scare): the creator explicitly asked for OBS's delay-filter lip-sync fix BUILT NATIVELY — the feature is his, not AI drift. Its placement (preview-rail vs mic-settings/gear) remains an open design question; do not remove the capability.** 4. ☑ Persisted in `LayoutStore.Settings` (`Audio.SyncOffsetMs`) via `LoadAudioSyncOffsetMs`/`SaveAudioSyncOffsetMs`; saved from `SaveLayoutNow`. 5. ☑ Tests: `AudioSyncDelayTests` — zero-delay identity, negative→0 clamp, >500 ms clamp to 500 ms, and 10 ms → 960 interleaved-sample shift. 6. ⚠ **Post-ship regression (found 2026-09-01, first real launch):** this task's sync-delay line `_delayedMix` was declared nullable and never initialized — the first `delayed.Length` deref NRE'd EVERY live-mix tick, silently killing all live/record audio, hanging `AudioPipelineTests`, and being mislabeled a "known failure". Fixed in the recording-verification pass (init + null-check + throttled loop errors logged with stack); `AudioPipelineTests` 25/25 green afterward. Lesson recorded in MyMistakes. @@ -33,7 +33,7 @@ > webcams). Regression test: `StartLive_NegativeOffset_AdvancesAudio_ByDroppingTheStreamHead`. - **Positive-only (delay audio)** — the physically-correct direction (audio runs ahead of the video). True "advance" needs a video-side delay and is PERMANENTLY OUT with per-source sync (TASKS.md → "Out of product" — v1.x phrasing retired 2026-09-01). - **Simple slider** — 0 to +500 ms, default 0. No numeric input needed. -- **Visual feedback** — "sync OK" status dot shows when an offset is dialled in. +- ~~**Visual feedback** — "sync OK" status dot when an offset is dialled in.~~ **RETIRED 2026-09-23 by creator** — the status light added nothing (see item 3); the numeric tooltip already states the offset. ### ❗ REQUIRED before 1.0 (creator directive 2026-09-14) diff --git a/TASKS/task-39-ypp-journey-tracker.md b/TASKS/task-39-ypp-journey-tracker.md index 43673cb..65b49a7 100644 --- a/TASKS/task-39-ypp-journey-tracker.md +++ b/TASKS/task-39-ypp-journey-tracker.md @@ -24,10 +24,34 @@ from day one. - 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. + 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 @@ -37,13 +61,14 @@ from day one. (`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/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), live standing checkbox + detail, manual 2FA/AdSense + 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 @@ -79,6 +104,6 @@ doc; the once-daily refresh gate and history table are already in place. - [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. + standing-guidance row, 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). \ No newline at end of file diff --git a/Themes/Controls.xaml b/Themes/Controls.xaml index cb42e34..18e541d 100644 --- a/Themes/Controls.xaml +++ b/Themes/Controls.xaml @@ -697,7 +697,6 @@ - diff --git a/ai.md b/ai.md index 4bf9c27..71a69a4 100644 --- a/ai.md +++ b/ai.md @@ -723,8 +723,9 @@ devices, no timers). the first `|N|` ms of the live stream head (see `AudioMixer.StartLive`; the delay line stays clamped 0..500 internally — negative bypasses it entirely). The advance is armed once at `StartLive` (eating the head mid-stream is impossible); positive is live-reactive (re-read per tick). A slider - on the mic bar (`"AUDIO SYNC"`, locked while live/recording via `IsEditMode`) + a status dot - (`IntToSyncBrushConverter`) surface it. Changing the delay flushes the line (a live change clicks + on the mic bar (`"AUDIO SYNC"`, locked while live/recording via `IsEditMode`) surfaces it + (a one-time status dot next to it was removed 2026-09-23 per creator feedback — the slider + + numeric tooltip carry the state). Changing the delay flushes the line (a live change clicks rather than smears). - **TRAX — free background music (TASK 8):** `MusicPlayer` = NAudio `MediaFoundationReader` (mp3/wav/m4a) → `VolumeWaveProvider16` at the hardcoded **0.20** bed (no slider) → `WaveOutEvent` on @@ -1447,9 +1448,15 @@ removal above). It has three layers, all free — this is the product's differen - **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 + &part=statistics,contentDetails` (subs/views/videos + uploads-playlist id) + + `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. + **The `auditDetails` part is OUT (fixed 2026-09-23):** readding it 403s the WHOLE request — + that part alone requires the `youtubepartner-channel-audit` scope no app for normal creators + should request (the standing flags sat unreadable since slice 1 shipped; the app's `youtube` + + `youtube.force-ssl` scopes were never sufficient for them — the "no re-consent" slice-1 claim + was wrong for that part). Channel standing now deep-links to the Earn page; API stats come from + `statistics,contentDetails` only. YPP/API failures log the response body's `error.reason`. **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 @@ -1457,8 +1464,9 @@ removal above). It has three layers, all free — this is the product's differen 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. + linkage) are in-app self-reported checkboxes with deep links; channel standing (exposed only + via the partner-audit scope) deep-links to the Earn page. 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 diff --git a/ytLive.Tests/ChannelStatsServiceTests.cs b/ytLive.Tests/ChannelStatsServiceTests.cs new file mode 100644 index 0000000..a9268ca --- /dev/null +++ b/ytLive.Tests/ChannelStatsServiceTests.cs @@ -0,0 +1,84 @@ +using System; +using System.Net; +using System.Text; +using System.Threading.Tasks; +using Xunit; +using ytLive.Services; + +namespace ytLive.Tests; + +/// +/// TASK 39 root-cause guard (2026-09-23): the YPP refresh's channels.list call +/// must NOT request the `auditDetails` part — that part alone requires the +/// `youtubepartner-channel-audit` scope the app doesn't (and mustn't) hold, and +/// the whole request 403s `insufficientPermissions` over it (this exact failure +/// sat in the real logs for weeks). One integration test: the snapshot still +/// parses subscribers/views/videos/uploads from `statistics,contentDetails`, and +/// the URL never mentions auditDetails. +/// +public class ChannelStatsServiceTests +{ + private sealed class RecordingHandler : HttpMessageHandler + { + public string? LastBody; + public Func? ResponseSelector; + public readonly System.Collections.Generic.List Requests = new(); + + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + Requests.Add($"{request.Method.Method} {request.RequestUri}"); + LastBody = request.Content?.ReadAsStringAsync().GetAwaiter().GetResult(); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + ResponseSelector?.Invoke(request) ?? "{}", Encoding.UTF8, "application/json"), + }); + } + } + + [Fact] + public async Task CaptureCurrent_RequestsNoAuditDetails_AndStillParsesTheSnapshot() + { + var auth = new YouTubeAuthService("test-client", "test-secret"); + auth.SetSession(new Models.YouTubeChannel + { + AccessToken = "acc-123", + TokenExpiry = DateTime.UtcNow.AddHours(1), + }); + + var handler = new RecordingHandler + { + ResponseSelector = request => + request.RequestUri!.ToString().Contains("channels") + ? """ + {"items":[{"id":"UC-1","statistics":{"subscriberCount":"350","viewCount":"12345","videoCount":"42"}, + "contentDetails":{"relatedPlaylists":{"uploads":"UPL"}}}]} + """ + : """ + {"items":[ + {"snippet":{"publishedAt":"2026-08-01T00:00:00Z"}}, + {"snippet":{"publishedAt":"2026-09-01T00:00:00Z"}}, + {"snippet":{"publishedAt":"2025-01-01T00:00:00Z"}} + ]} + """, + }; + + var service = new ChannelStatsService(auth, new System.Net.Http.HttpClient(handler)); + var snapshot = await service.CaptureCurrentAsync(); + + Assert.NotNull(snapshot); + Assert.Equal(2, handler.Requests.Count); + Assert.StartsWith("GET ", handler.Requests[0]); + Assert.Contains("channels?mine=true", handler.Requests[0]); + Assert.Contains("part=statistics,contentDetails", handler.Requests[0]); + Assert.DoesNotContain("auditDetails", handler.Requests[0]); + Assert.Contains("playlistItems", handler.Requests[1]); + + Assert.Equal(350, snapshot!.SubscriberCount); + Assert.Equal(42, snapshot.VideoCount); + Assert.Equal(12_345, snapshot.TotalViews); + Assert.Equal(2, snapshot.UploadsLast90Days); + Assert.False(DateTime.UtcNow - snapshot.CapturedAtUtc > TimeSpan.FromMinutes(1)); + } +} \ No newline at end of file diff --git a/ytLive.Tests/YppPullOutTests.cs b/ytLive.Tests/YppPullOutTests.cs index e3cd22a..eee6c64 100644 --- a/ytLive.Tests/YppPullOutTests.cs +++ b/ytLive.Tests/YppPullOutTests.cs @@ -61,10 +61,6 @@ public sealed class YppPullOutTests VideoCount = 42, TotalViews = 12_345, UploadsLast90Days = 2, - CommunityGuidelinesGoodStanding = true, - CopyrightStrikesGoodStanding = true, - ContentIdClaimsGoodStanding = true, - OverallGoodStanding = true, }; var vm = new YppTrackerViewModel( @@ -81,7 +77,6 @@ public sealed class YppPullOutTests 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);