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:
@@ -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
@@ -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.
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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).
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user