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.
This commit is contained in:
2026-09-23 16:45:56 -07:00
parent d72949e2f9
commit cb750660c3
13 changed files with 284 additions and 176 deletions
+2 -7
View File
@@ -605,11 +605,9 @@
Foreground="#666677" FontSize="12" TextWrapping="Wrap"/>
<TextBlock Text="COMPLIANCE CHECKLIST" Style="{StaticResource YtLabel}" Margin="0,14,0,4"/>
<CheckBox Content="Channel in good standing (live from YouTube)" Foreground="#e0e0e0" FontSize="12"
IsEnabled="False" Margin="0,2,0,0"
IsChecked="{Binding Ypp.OverallGoodStanding, Mode=OneWay}"/>
<TextBlock Text="CHANNEL STANDING" Style="{StaticResource YtLabel}" Margin="0,0,0,2"/>
<TextBlock Text="{Binding Ypp.StandingDetailText}" Foreground="#888899" FontSize="11"
TextWrapping="Wrap" Margin="0,4,0,8"/>
TextWrapping="Wrap" Margin="0,0,0,8"/>
<CheckBox Content="Two-Step Verification switched on" Foreground="#e0e0e0" FontSize="12"
Margin="0,2,0,0"
IsChecked="{Binding Ypp.TwoFactorEnabled}"/>
@@ -947,9 +945,6 @@
IsEnabled="{Binding IsEditMode}"
VerticalAlignment="Center"
ToolTip="{Binding AudioSyncOffsetMs, StringFormat=Audio sync: {0} ms}"/>
<Border Width="8" Height="8" CornerRadius="4" Margin="6,0,0,0"
VerticalAlignment="Center"
Background="{Binding AudioSyncOffsetMs, Converter={StaticResource IntToSyncBrush}}"/>
</StackPanel>
</StackPanel>
</StackPanel>
+82 -82
View File
@@ -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).
- **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.
-19
View File
@@ -1,19 +0,0 @@
using System.Globalization;
using System.Windows.Data;
using System.Windows.Media;
namespace ytLive.Helpers;
/// <summary>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.</summary>
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();
}
+29
View File
@@ -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
+30 -17
View File
@@ -7,11 +7,16 @@ namespace ytLive.Services;
/// <summary>
/// Pulls a connected channel's YPP-relevant numbers from the YouTube Data API
/// (TASK 39, slice 1 — the current OAuth scopes suffice, no re-consent):
/// subscriptions/views/videos + audit standing via channels.list, and the count
/// of public uploads in the trailing 90 days via playlistItems.list on the
/// (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.
/// </summary>
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"),
};
}
/// <summary>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).</summary>
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;
}
/// <summary>Counts uploads published within the trailing 90 days, scanning the
/// most recent 50 from the uploads playlist (generous beyond the 3/90-day YPP
/// requirement; the YPP tab labels it as the recent-50 window).</summary>
@@ -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();
}
+7 -28
View File
@@ -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).
/// </summary>
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;
/// <summary>True once a snapshot has successfully captured at least once this
/// session (drives the standing detail text's "sign in to load" state).</summary>
@@ -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<string>();
if (!CommunityGuidelinesGoodStanding) flags.Add("a community-guidelines flag");
if (!CopyrightStrikesGoodStanding) flags.Add("a copyright strike");
if (!ContentIdClaimsGoodStanding) flags.Add("a content-ID claim");
return flags.Count == 0
? "Good standing — no live flags."
: $"FLAGGED ({string.Join(", ", flags)}). Check YouTube Studio for details.";
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));
+1 -1
View File
@@ -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) |
+2 -2
View File
@@ -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<int> 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)
+33 -8
View File
@@ -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).
-1
View File
@@ -697,7 +697,6 @@
<BooleanToVisibilityConverter x:Key="BoolToVis"/>
<Helpers:InverseBoolToVisibilityConverter x:Key="InverseBoolToVis"/>
<Helpers:NotNullToVisibilityConverter x:Key="NotNullToVis"/>
<Helpers:IntToSyncBrushConverter x:Key="IntToSyncBrush"/>
<Helpers:NullToVisibilityConverter x:Key="NullToVis"/>
<Helpers:HiddenBoolToVisibilityConverter x:Key="HiddenBoolToVis"/>
<Helpers:EnumToBoolConverter x:Key="EnumToBool"/>
+14 -6
View File
@@ -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
+84
View File
@@ -0,0 +1,84 @@
using System;
using System.Net;
using System.Text;
using System.Threading.Tasks;
using Xunit;
using ytLive.Services;
namespace ytLive.Tests;
/// <summary>
/// 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.
/// </summary>
public class ChannelStatsServiceTests
{
private sealed class RecordingHandler : HttpMessageHandler
{
public string? LastBody;
public Func<HttpRequestMessage, string?>? ResponseSelector;
public readonly System.Collections.Generic.List<string> Requests = new();
protected override Task<HttpResponseMessage> 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));
}
}
-5
View File
@@ -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);