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);