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"/> Foreground="#666677" FontSize="12" TextWrapping="Wrap"/>
<TextBlock Text="COMPLIANCE CHECKLIST" Style="{StaticResource YtLabel}" Margin="0,14,0,4"/> <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" <TextBlock Text="CHANNEL STANDING" Style="{StaticResource YtLabel}" Margin="0,0,0,2"/>
IsEnabled="False" Margin="0,2,0,0"
IsChecked="{Binding Ypp.OverallGoodStanding, Mode=OneWay}"/>
<TextBlock Text="{Binding Ypp.StandingDetailText}" Foreground="#888899" FontSize="11" <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" <CheckBox Content="Two-Step Verification switched on" Foreground="#e0e0e0" FontSize="12"
Margin="0,2,0,0" Margin="0,2,0,0"
IsChecked="{Binding Ypp.TwoFactorEnabled}"/> IsChecked="{Binding Ypp.TwoFactorEnabled}"/>
@@ -947,9 +945,6 @@
IsEnabled="{Binding IsEditMode}" IsEnabled="{Binding IsEditMode}"
VerticalAlignment="Center" VerticalAlignment="Center"
ToolTip="{Binding AudioSyncOffsetMs, StringFormat=Audio sync: {0} ms}"/> 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> </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 ## Branch / Commit State
`main` HEAD = **`e69db4d`** (TASK 42 top bar) → **`18ab553`** (health-poll fix, committed). `main` HEAD = **`d72949e`** (build-77 top-bar feedback: avatar/gear/cluster). Backlog:
Pushed state at `197ee81`. **Working tree DIRTY — end-of-stream race fix (one small change):** `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 Controls/PreviewPane.xaml (audio-sync status dot removed + YPP standing row)
M ytLive.Tests/YouTubeStreamServiceTests.cs (end-close-out test rewritten) M Themes/Controls.xaml (IntToSyncBrush converter resource removed)
M ai.md, Services/index.md, TASKS/task-09-live-stream-management.md, MyMistakes.md D Helpers/IntToSyncBrushConverter.cs (converter dead — deleted)
M HANDOFF.md (this rewrite) 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. Ac6e67a **could not be verified through the normal gate** because the user was *looking at the
- **Health-poll parse fix** `18ab553` — `GetStreamHealthAsync` read `healthStatus` as running app* (PID 17752) while it happened. `verify.sh` (clean build 0-warnings + full suite +
a string; the real API nests `status.healthStatus = {status, lastUpdateTimeSeconds, scope check) **cannot run until the app is closed** — an in-place build fails the `apphost.exe`
configurationIssues[]}` (an OBJECT) → every 2026-09-22 session start logged copy MSB3021/MSB3027 while the exe is locked. Compile gate used instead: `dotnet build` reaches
`requires an element of type 'String'`. Fixed to read the nested shape, tolerating the the copy stage with XAML/markup compiling clean (only the 2 copy errors → code + XAML are valid).
old flat shape; the report-by-exception health banner is alive again.
## 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 → 1. **Avatar** much bigger — now 40×40 (was 26), CornerRadius 20, initial 20pt. Shows the streaming
enableAutoStop will finish` on ALL THREE 2026-09-22 stops (17:09 crash + both test account at a glance (113% bigger than the old 26 read).
sessions). NOT the old "autoStop already fired" assumption (ai.md had it wrong): the 2. **Gear** moved further from the brand — margin 6 → 18 (a couple more "places").
blind `transition(complete)` POST races YouTube/autoStop marking the broadcast complete 3. **Uniform 32-height cluster in the top center** — segments host Border, Start/Go Live, and Test
(via `enableAutoStop=true`, always set — tests included). Fix in the dirty tree: all `Height="32"` so the REC|ON-AIR switch + its actions read as ONE family ("so the user can
`EndBroadcastAsync` pre-flights `liveBroadcasts.list→status.lifeCycleStatus` and only intuit their purpose"). Running world (reality line) intentionally untouched. **[COMMITTED `d72949e`]**
POSTs complete from `live`/`testing`, skipping silently when already complete 4. **Audio-sync status light LOST** — `"AUDIO SYNC"` slider keeps its numeric tooltip; the green/
(`enableAutoStop` finishes every skip); an inconclusive pre-check still posts (old amber 8×8 dot was "what is the point" dead weight. `IntToSyncBrushConverter` + its resource +
behavior, backstopped); still never throws. Cite: TASK-22's "visual feedback" decision all retired with it, both docs updated in-place.
https://developers.google.com/youtube/v3/live/docs/liveBroadcasts/transition (errors 5. **YPP refresh bug (this turn, "Seriously?")** — `channels.list?mine=true&part=statistics,
table: invalidTransition = current-status problem, and complete is not gated on auditDetails,contentDetails` 403'd `insufficientPermissions` on EVERY real refresh since slice 1
streamStatus — only testing/live are). 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 - **`ac6e67a` end-of-stream close-out fix** — every 2026-09-22 stop logged
bullshit. Our one plan is replacing the top menu bar with something usable." The old bar's six `transition(complete) 403 invalidTransition`; the blind POST raced YouTube/autoStop marking the
widget grammars (sliding pills, dots, badges, buttons, hidden sign-in context menu, live-window broadcast complete (`enableAutoStop=true` always set). `EndBroadcastAsync` now pre-flights
status) collapsed into **ONE surface rendered by the FIRST decision — Record or Stream**: `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). ## Around the task (carried facts)
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).
**Good Dog** — `ytLive.Tests/TopBarModeTests.cs` (one `[Fact]`, `RealAppHost.Run`): default record - RealMouseDrag test no-ops while a game/fullscreen window steals the mouse (POE 2 seen 2026-09-22)
world → arm ON-AIR → sign-in/avatar/Test gating flips → one-tap flip back → direct stream status — close games before full-suite runs. AudioPipeline timing flake similar (load-dependent).
changing + running reality values. Also folded in: TASK 41's latent **Test pipeline bug fix** — - Test-env trap: saved OAuth session loads synchronously → force `vm.IsConnected` in signed-in tests.
`BeginTestStream` now arms `OnAirPillOn` explicitly (unarmed → encoder booted with zero outputs → - `subscriberCount` YPP slice 2 needs re-consent — do not merge with other units. Polar `$99/yr`
"At least one output is required" → forced stop cascade). The TestStreamTests gate updated to match. 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
## ⚠️ Around the task no longer populates them; schema kept stable for the analytics slice). Don't "restore" the
- **Full-suite run today: 314/315** (first pass) — the one abort is the pre-existing standing flags via auditDetails — see the MyMistakes recipe.
`LayerReorderPersistenceTests.RealMouseDrag_OnTheLayerList_PersistsTheReorder`, which injects - TASK 40 App Settings round: units **A (camera) → C (defaults) → D (accent)** remain; A's
REAL physical mouse input (`SetCursorPos` + `mouse_event`, see its own docstring) and no-ops while SharedReadOnly control-write question = only genuine uncertainty.
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.
## Next step ## Next step
Commit this end-of-stream fix (scope-check first, then build 0-warnings + full suite, then ONE 1. **Close the running app (PID 17752 holds the exe), then run the full gate** —
commit). Push only on the user's say-so. Then **TASK 40 App Settings round** is queued `./scripts/verify.sh` with the full declared scope (audio-dot unit + YPP fix): clean build
(`TASKS/task-40-app-settings-round.md`) — units **A (camera) → C (defaults) → D (accent)** remain 0-warnings + full suite + scope check. Then commit BOTH units — they share PreviewPane.xaml /
(B/gear shipped early); Unit A's SharedReadOnly control-write question is the one genuinely ai.md, so a single hotfix commit ("build-77 polish + YPP refresh 403") is the honest shape, or
uncertain technical point — run its mitigation ladder before a third guess. 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) ## Critical working rules (unchanged, still binding)
- **Good Dog = ONE integration test per change.** - **Good Dog = ONE integration test per change.** Scope lock + `./scripts/scope-check.sh` before commit.
- **Scope lock:** declare the file list before editing; `./scripts/scope-check.sh` before commit. - Windows dotnet host for all WSL builds (`/mnt/c/Program Files/dotnet/dotnet.exe`, quoted paths).
- WSL builds use the Windows dotnet host (`/mnt/c/Program Files/dotnet/dotnet.exe`, quoted paths). - 0 warnings on real clean builds (verify.sh only, never incremental). One runtime model.
- One runtime model, no model switching, no compaction summaries (user directive).
-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 ## 🔬 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) ### TRANSITION(COMPLETE) RACES AUTOSTOP: PRE-FLIGHT lifeCycleStatus (RECIPE)
A blind `liveBroadcasts.transition?broadcastStatus=complete` POST can 403 A blind `liveBroadcasts.transition?broadcastStatus=complete` POST can 403
+30 -17
View File
@@ -7,11 +7,16 @@ namespace ytLive.Services;
/// <summary> /// <summary>
/// Pulls a connected channel's YPP-relevant numbers from the YouTube Data API /// 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): /// (TASK 39, slice 1): subscriptions/views/videos via channels.list, and the
/// subscriptions/views/videos + audit standing via channels.list, and the count /// count of public uploads in the trailing 90 days via playlistItems.list on the
/// 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 /// uploads playlist. Long-form watch hours and Shorts-feed views need the
/// Analytics API (a later slice behind the IChannelStatsProvider seam). /// 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> /// </summary>
public class ChannelStatsService public class ChannelStatsService
{ {
@@ -42,10 +47,14 @@ public class ChannelStatsService
_http.DefaultRequestHeaders.Authorization = new("Bearer", _auth.CurrentChannel!.AccessToken); _http.DefaultRequestHeaders.Authorization = new("Bearer", _auth.CurrentChannel!.AccessToken);
var response = await _http.GetAsync( var response = await _http.GetAsync(
$"{ApiBase}/channels?mine=true&part=statistics,auditDetails,contentDetails"); $"{ApiBase}/channels?mine=true&part=statistics,contentDetails");
if (!response.IsSuccessStatusCode) 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; return null;
} }
@@ -69,17 +78,26 @@ public class ChannelStatsService
return new YppStatSnapshot return new YppStatSnapshot
{ {
CapturedAtUtc = DateTime.UtcNow, CapturedAtUtc = DateTime.UtcNow,
SubscriberCount = statistics.TryGetProperty("subscriberCount", out var subs) ? subs.GetInt64() : 0, SubscriberCount = ReadInt64(statistics, "subscriberCount"),
VideoCount = statistics.TryGetProperty("videoCount", out var videos) ? videos.GetInt64() : 0, VideoCount = ReadInt64(statistics, "videoCount"),
TotalViews = statistics.TryGetProperty("viewCount", out var views) ? views.GetInt64() : 0, TotalViews = ReadInt64(statistics, "viewCount"),
UploadsLast90Days = uploadsLast90Days, 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 /// <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 /// 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> /// requirement; the YPP tab labels it as the recent-50 window).</summary>
@@ -111,9 +129,4 @@ public class ChannelStatsService
} }
return count; return count;
} }
private static bool ReadStandingFlag(JsonElement channel, string property)
=> channel.TryGetProperty("auditDetails", out var audit)
&& audit.TryGetProperty(property, out var flag)
&& flag.GetBoolean();
} }
+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 /// 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 /// Live screen's right edge). Shows the connected channel's current position
/// toward the YPP tier thresholds (subscribers + 90-day uploads via the Data /// toward the YPP tier thresholds (subscribers + 90-day uploads via the Data
/// API), a compliance checklist (live standing from auditDetails + self-reported /// API), a compliance checklist (2FA/AdSense self-reported — channel standing is
/// 2FA/AdSense), and the "what counts" education the creator would otherwise /// NOT exposed to ordinary apps, so the drawer deep-links to the Earn page for it)
/// have to leave the app to find. Snapshots are appended to SQLite on every /// and the "what counts" education the creator would otherwise have to leave the
/// refresh so a later analytics slice can compute velocity/ETA from history. /// 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). /// Drawer open/close lives here (mirrors LiveBroadcastFormViewModel).
/// </summary> /// </summary>
public sealed class YppTrackerViewModel : ViewModelBase public sealed class YppTrackerViewModel : ViewModelBase
@@ -33,10 +34,6 @@ public sealed class YppTrackerViewModel : ViewModelBase
private long _videoCount; private long _videoCount;
private long _totalViews; private long _totalViews;
private int _uploadsLast90Days; private int _uploadsLast90Days;
private bool _communityGuidelinesGoodStanding;
private bool _copyrightStrikesGoodStanding;
private bool _contentIdClaimsGoodStanding;
private bool _overallGoodStanding;
private bool _hasSnapshot; private bool _hasSnapshot;
private bool _twoFactorEnabled; private bool _twoFactorEnabled;
private bool _adsenseLinked; private bool _adsenseLinked;
@@ -109,10 +106,6 @@ public sealed class YppTrackerViewModel : ViewModelBase
public long VideoCount => _videoCount; public long VideoCount => _videoCount;
public long TotalViews => _totalViews; public long TotalViews => _totalViews;
public int UploadsLast90Days => _uploadsLast90Days; 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 /// <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> /// session (drives the standing detail text's "sign in to load" state).</summary>
@@ -158,14 +151,8 @@ public sealed class YppTrackerViewModel : ViewModelBase
{ {
get get
{ {
if (!HasSnapshot) return "Sign in to load your channel's standing from YouTube."; if (!HasSnapshot) return "Sign in to load your channel's progress from YouTube.";
var flags = new System.Collections.Generic.List<string>(); return "Channel standing isn't exposed to YouTube apps — check the Earn page for your live status.";
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.";
} }
} }
@@ -263,10 +250,6 @@ public sealed class YppTrackerViewModel : ViewModelBase
_videoCount = snapshot.VideoCount; _videoCount = snapshot.VideoCount;
_totalViews = snapshot.TotalViews; _totalViews = snapshot.TotalViews;
_uploadsLast90Days = snapshot.UploadsLast90Days; _uploadsLast90Days = snapshot.UploadsLast90Days;
_communityGuidelinesGoodStanding = snapshot.CommunityGuidelinesGoodStanding;
_copyrightStrikesGoodStanding = snapshot.CopyrightStrikesGoodStanding;
_contentIdClaimsGoodStanding = snapshot.ContentIdClaimsGoodStanding;
_overallGoodStanding = snapshot.OverallGoodStanding;
_hasSnapshot = true; _hasSnapshot = true;
OnPropertyChanged(nameof(DisplayName)); OnPropertyChanged(nameof(DisplayName));
@@ -274,10 +257,6 @@ public sealed class YppTrackerViewModel : ViewModelBase
OnPropertyChanged(nameof(VideoCount)); OnPropertyChanged(nameof(VideoCount));
OnPropertyChanged(nameof(TotalViews)); OnPropertyChanged(nameof(TotalViews));
OnPropertyChanged(nameof(UploadsLast90Days)); OnPropertyChanged(nameof(UploadsLast90Days));
OnPropertyChanged(nameof(CommunityGuidelinesGoodStanding));
OnPropertyChanged(nameof(CopyrightStrikesGoodStanding));
OnPropertyChanged(nameof(ContentIdClaimsGoodStanding));
OnPropertyChanged(nameof(OverallGoodStanding));
OnPropertyChanged(nameof(HasSnapshot)); OnPropertyChanged(nameof(HasSnapshot));
OnPropertyChanged(nameof(Tier1SubsProgress)); OnPropertyChanged(nameof(Tier1SubsProgress));
OnPropertyChanged(nameof(Tier2SubsProgress)); 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) | | 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) | | 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) | | 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) | | 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) | | 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) | | 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+). 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. 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`. 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. 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. 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`. > 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). - **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. - **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) ### ❗ 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 - 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**. **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 - **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 checkboxes with deep links (Google's 2-Step page / the Earn page).
`channels.list auditDetails` (overall + the three sub-flags) — live, no re-consent. - **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. - `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 ## What shipped
- `Models/YppThresholds.cs` — thresholds as **versioned date-aware data** (never constants, per - `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` / (`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 `Ypp.AdSenseLinked`); last-refresh-UTC gate (the once-daily gate a quota-thin analytics slice
will need is baked in now). will need is baked in now).
- `Services/ChannelStatsService.cs` — `channels.list?mine=true&part=statistics,auditDetails, - `Services/ChannelStatsService.cs` — `channels.list?mine=true&part=statistics,contentDetails`
contentDetails` (subs/views/videos + uploads-playlist id + standing flags) then (subs/views/videos + uploads-playlist id; **`auditDetails` REMOVED 2026-09-23**, see the scope
`playlistItems.list` (recent 50) counts uploads in the trailing 90 days. Virtual correction) then `playlistItems.list` (recent 50) counts uploads in the trailing 90 days.
`CaptureCurrentAsync` = the test seam. No new scope. Virtual `CaptureCurrentAsync` = the test seam. No new scope.
- `Services/YppTrackerViewModel.cs` — drawer state + `Toggle/CloseDrawerCommand` + `RefreshCommand` - `Services/YppTrackerViewModel.cs` — drawer state + `Toggle/CloseDrawerCommand` + `RefreshCommand`
(mirrors `LiveBroadcastFormViewModel`), plus: signed-out/loading/error states, tier progress (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) + checkboxes, `WhatCountsText` education, deep links, `EnsureLoadedAsync` (once/day gate) +
explicit refresh, `OnAccountChanged()` hook. explicit refresh, `OnAccountChanged()` hook.
- `ViewModels/MainViewModel.Ypp.cs` + ctor — `Ypp` property, `ChannelStatsFactoryOverride` test - `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 - [x] Clicking it slides out the YPP drawer; opening one drawer closes the other; outside-click
collapses whichever is open. collapses whichever is open.
- [x] Signed out → "Sign in to YouTube…" hint. Signed in → subs (Tier 1 & Tier 2), uploads/90d, - [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] 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). - [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"/> <BooleanToVisibilityConverter x:Key="BoolToVis"/>
<Helpers:InverseBoolToVisibilityConverter x:Key="InverseBoolToVis"/> <Helpers:InverseBoolToVisibilityConverter x:Key="InverseBoolToVis"/>
<Helpers:NotNullToVisibilityConverter x:Key="NotNullToVis"/> <Helpers:NotNullToVisibilityConverter x:Key="NotNullToVis"/>
<Helpers:IntToSyncBrushConverter x:Key="IntToSyncBrush"/>
<Helpers:NullToVisibilityConverter x:Key="NullToVis"/> <Helpers:NullToVisibilityConverter x:Key="NullToVis"/>
<Helpers:HiddenBoolToVisibilityConverter x:Key="HiddenBoolToVis"/> <Helpers:HiddenBoolToVisibilityConverter x:Key="HiddenBoolToVis"/>
<Helpers:EnumToBoolConverter x:Key="EnumToBool"/> <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 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` 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 (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 on the mic bar (`"AUDIO SYNC"`, locked while live/recording via `IsEditMode`) surfaces it
(`IntToSyncBrushConverter`) surface it. Changing the delay flushes the line (a live change clicks (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). rather than smears).
- **TRAX — free background music (TASK 8):** `MusicPlayer` = NAudio `MediaFoundationReader` - **TRAX — free background music (TASK 8):** `MusicPlayer` = NAudio `MediaFoundationReader`
(mp3/wav/m4a) → `VolumeWaveProvider16` at the hardcoded **0.20** bed (no slider) → `WaveOutEvent` on (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 - **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 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 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 + &part=statistics,contentDetails` (subs/views/videos + uploads-playlist id) +
live standing flags) + `playlistItems.list` for 90-day public uploads. Every refresh appends `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. 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 **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 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 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 velocity / ETA are **slice 2**: Analytics-API scopes (`yt-analytics.readonly`, additive
`yt-analytics-monetary.readonly`) behind the `IChannelStatsProvider` seam (additive `yt-analytics-monetary.readonly`) behind the `IChannelStatsProvider` seam (additive
capability, requires an OAuth re-consent). Compliance items no API exposes (2FA, AdSense 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 linkage) are in-app self-reported checkboxes with deep links; channel standing (exposed only
`auditDetails`. Slice 2's once-daily refresh gate + snapshot history were baked into slice 1. 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 **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 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, VideoCount = 42,
TotalViews = 12_345, TotalViews = 12_345,
UploadsLast90Days = 2, UploadsLast90Days = 2,
CommunityGuidelinesGoodStanding = true,
CopyrightStrikesGoodStanding = true,
ContentIdClaimsGoodStanding = true,
OverallGoodStanding = true,
}; };
var vm = new YppTrackerViewModel( var vm = new YppTrackerViewModel(
@@ -81,7 +77,6 @@ public sealed class YppPullOutTests
Assert.True(vm.HasSnapshot); Assert.True(vm.HasSnapshot);
Assert.Equal(350, vm.SubscriberCount); Assert.Equal(350, vm.SubscriberCount);
Assert.Equal(2, vm.UploadsLast90Days); Assert.Equal(2, vm.UploadsLast90Days);
Assert.True(vm.OverallGoodStanding);
Assert.Equal("Test Channel", vm.DisplayName); Assert.Equal("Test Channel", vm.DisplayName);
Assert.Equal("350 / 500", vm.Tier1SubsText); Assert.Equal("350 / 500", vm.Tier1SubsText);
Assert.Equal("350 / 1,000", vm.Tier2SubsText); Assert.Equal("350 / 1,000", vm.Tier2SubsText);