fix(stream): TEST-tab chat — resolve liveChatId from snippet, poll until the broadcast is live

The open creator report ('I still cannot post a chat message in the TEST tab',
feedback 'Chat polling couldn't start — Mock Chat Input is disabled') was a single
cause: the liveChatId never resolved. Two YouTube API facts:
1. The id lives in snippet.liveChatId — contentDetails has no such property
   (TASK 44 read part=contentDetails: could never resolve).
   https://developers.google.com/youtube/v3/live/docs/liveBroadcasts
2. It only exists once the broadcast is LIVE — the official sample lists
   broadcastStatus=active, and our fetch ran before the frame pump pushed RTMP
   (enableAutoStart flips ready→live). A ready-state list legitimately returns
   no id.
   https://github.com/youtube/api-samples/blob/master/java/src/main/java/com/google/api/services/samples/youtube/cmdline/live/GetLiveChatId.java

Fix: GetBroadcastLiveChatIdAsync reads part=snippet and polls with a bounded
retry (10x/2s); PrepareAndStartLiveAsync starts the frame pump FIRST, then
resolves the id. Chat stays non-fatal. Docs ai.md/TASKS.md/HANDOFF.md +
MyMistakes recipe updated same commit. TASK 44.

Good Dog: ONE integration test (GetBroadcastLiveChatIdAsync_Polls_Snippet_...
) drives a broadcast that gains its id mid-retry and asserts every request used
part=snippet. Gate: clean build 0 warnings, vstest 318/318.
This commit is contained in:
2026-09-25 08:06:02 -07:00
parent d4aa588da0
commit 94f015044a
8 changed files with 219 additions and 83 deletions
+33 -45
View File
@@ -1,52 +1,41 @@
# HANDOFF — 2026-09-24 (TASK 43 shipped: native events & alerts — creator said "go") # HANDOFF — 2026-09-25 (TASK 44 shipped: TEST-tab chat fix — liveChatId now resolves)
## Branch / Commit State ## Branch / Commit State
`main` HEAD = new commit for TASK 43 (native events & six-animation alert box); previous: `main` HEAD = new commit for TASK 44 (TEST-tab chat fix); previous: TASK 43 commit (native
`3a4e17c` (account zone world-independent), `cb75066` (YPP 403), `d72949e` (top-bar feedback), events & alerts), `ac6e67a` (end close-out). Repo is ~11 commits ahead of pushed `197ee81` —
`ac6e67a` (end close-out). Repo is ~10 commits ahead of pushed `197ee81` — **push is the **push is the creator's call** (sub-milestone decision, not per-commit).
creator's call** (sub-milestone decision, not per-commit).
**Working tree CLEAN after this unit's commit.** **Working tree CLEAN after this unit's commit.**
## ⚠️ The unit that just shipped (this turn, creator said "go") ## ⚠️ The unit that just shipped (the open TEST-tab chat report, diagnosed + fixed)
The creator asked (2026-09-23) whether the app could use **native YouTube events instead of The HANDOFF-open creator report — *"I still cannot post a chat message in the TEST tab"* —
StreamElements**. Research pinned the API facts; creator rulings locked the product shape. One is **RESOLVED** (one cause, one scoped unit, TASK 44 in `TASKS/task-44-test-chat-fix.md`):
combined work unit ("go"), task filed as **TASK 43** (`TASKS/task-43-native-alerts.md`):
1. **Chat parity half** — `YouTubeChatService` now decodes **all six** `liveChat/messages` - **The feedback line** "Chat polling couldn't start — Mock Chat Input is disabled" is
event types into `ChatMessage.Kind` (the four previously-empty event rows are real now) and `TestSessionViewModel.BeginTest` running with a **null liveChatId**.
re-arms its poll on the server's `pollingIntervalMillis` (streamList semantics; clamp - **Root cause (two API facts, cited in commit + MyMistakes):** (1) `liveChatId` lives in
1000–6000ms, `maxResults=2000`). **`snippet.liveChatId`** — `contentDetails` has no such property, and the old code read
2. **Alerts half** — new **`SourceType.AlertBox`** ("Stream Alerts", one per layout, `part=contentDetails` (can NEVER resolve); (2) YouTube only populates it once the broadcast
`CanAddAlerts` gate mirroring chat): an OBS-style celebration zone, idle = transparent. is **live** (official `GetLiveChatId.java` lists `broadcastStatus=active`), and the fetch
`Services/AlertRenderer.cs` draws **six DISTINCT branded animations** (SuperChat slide-up/ ran before the frame pump pushed RTMP (enableAutoStart flips ready→live).
shine/count-up; SuperSticker scale-pop; NewMember drop-in/flash; MemberGift slide-left/chip-fan; - **Fix:** `GetBroadcastLiveChatIdAsync(broadcastId, maxAttempts=10, delayMs=2000)` now reads
GiftReceived confetti; MemberMilestone rise/growth-bar), each card carrying the "made with `part=snippet` and polls with a bounded retry; `PrepareAndStartLiveAsync` starts the frame
LlamaCasty!" brand line (free product placement per broadcast). `Services/AlertOverlayLayer.cs` pump FIRST, then resolves the id. Chat stays non-fatal. TEST drawer wiring unchanged
is a true component (Commit-G pattern): queue (cap 10) + 33ms ticker + cache-first (`BeginTest` opens the dock once the id resolves → Mock Chat Input becomes sendable).
`RenderFrame` + `UpdatePreview`; **`Advance(double)` = deterministic test clock**.
3. **Creator rulings baked in:** no menus/polls (asked once, held); free-sub mention = chat row
only, **no sub alert** (YouTube emits none); **no viewer count** (vetoed — demotivating,
weaponizable); six events = six unique animations, not a recolored template.
**Good Dog:** `ytLive.Tests/AlertLayerTests.cs` (RealApp STA, real WPF raster) — ParsePage **Good Dog:** ONE integration test in `YouTubeStreamServiceTests.cs`
classifies all six kinds + cadence fields; playback never enqueues a `None` (sub-mention) row, (`GetBroadcastLiveChatIdAsync_Polls_Snippet_Until_Id_Appears`) — a fake broadcast that starts
plays six pairwise-distinct moving frames, drains to idle. **Gate: clean build 0 warnings, without a liveChatId and gains it on a later poll; asserts the retry resolves it, polled ≥3x,
316/317** — the ONE failure (`LayerReorderPersistenceTests.RealMouseDrag…`) repros on the CLEAN and every request asked for snippet (never contentDetails). **Gate: clean build 0 warnings,
tree (proved via `git stash` + rebuild + isolated run): a real-mouse-drag test that no-ops while full vstest 318/318 passed** (the LayerReorder real-mouse-drag test passed this run too).
a game/fullscreen window steals the mouse (the HANDOFF-documented environmental class).
**NOT a regression — do not chase it in-app; close games and rerun if in doubt.**
## Follow-ups queued (NOT done in this unit) ## Follow-ups queued (NOT done in this unit)
- **Creator report still open — not yet diagnosed:** "I still cannot post a chat message in the
TEST tab." The TEST drawer's Mock Chat Input posts a REAL `liveChat/messages.insert`; the fix
needs its own scoped unit.
- **TASK 3 item 20 persistence half** — the canonical `RewardEvents` SQLite table + - **TASK 3 item 20 persistence half** — the canonical `RewardEvents` SQLite table +
`superChatEvents.list` (30-day) backfill + session-report rollup (the shipped alert path parses `superChatEvents.list` (30-day) backfill + session-report rollup (events still parse to
events to `ChatMessage` in memory; the report reads that table once it exists). `ChatMessage` in memory only).
- TASK 3 item 16 (Text source) still open; TASK 40 units A/C/D queued; TASK 32–36 queued. - TASK 3 item 16 (Text source) still open; TASK 40 units A/C/D queued; TASK 32–36 queued.
## Around the task (carried facts) ## Around the task (carried facts)
@@ -58,20 +47,19 @@ a game/fullscreen window steals the mouse (the HANDOFF-documented environmental
must become one-time before launch. `MARCOM.md`/`MONETIZATION.md` gitignored — never commit. must become one-time before launch. `MARCOM.md`/`MONETIZATION.md` gitignored — never commit.
- Latent bug the YPP 403 masked: statistics are JSON **strings** — always the ValueKind-first - Latent bug the YPP 403 masked: statistics are JSON **strings** — always the ValueKind-first
`ReadInt64` (MyMistakes recipe), never `GetInt64`. `ReadInt64` (MyMistakes recipe), never `GetInt64`.
- New facts recorded: the chat feed IS the event feed (six types, no sub event); streamList is a - **API fact (TASK 44):** a liveChatId fetch at insert time (lifecycleStatus `ready`) returns
connection-semantics description of that same endpoint (pollingIntervalMillis drives cadence). nothing by design — missing liveChatId ≈ "broadcast not live yet", not an auth failure,
until ~20s post push. Debug with that lens.
- Every committed change needs a **close + relaunch** of the running app to be seen — running - Every committed change needs a **close + relaunch** of the running app to be seen — running
builds are live; verify.sh can't run while the exe is locked. builds are live; verify.sh can't run while the exe is locked.
## Next step ## Next step
1. **Creator verifies the alerts unit on the next run**: Add → Stream Alerts, then (Test Stream 1. **Creator verifies the TEST-tab chat fix on the next run**: Test Stream → the drawer should
drawer) Simulated Super Chat / New Member — confirm the zone stays transparent at idle and each open with a liveChatId line and Mock Chat Input enabled ~as soon as the broadcast goes live;
event plays its own animation on the canvas and preview; a real Super Chat during the next live send a message and confirm it round-trips through the ~2s poll and renders on the overlay.
run confirms the real path. 2. Push decision is the creator's (repo ~11 ahead). If pushed, note the commit id in this file.
2. Push decision is the creator's (repo ~10 ahead). If pushed, note the commit id in this file. 3. When scoped: TASK 3 item 20 (RewardEvents persistence half) draws next.
3. When scoped: diagnose the TEST-tab chat posting report ("I still cannot post a chat message in
the TEST tab") as its own Good-Dog unit.
## Critical working rules (unchanged, still binding) ## Critical working rules (unchanged, still binding)
+12
View File
@@ -28,6 +28,18 @@ calling it; a leaked 403→parse chain means your "whole request failed" symptom
paper over a second crash that only appears once the 403 is fixed (test the full paper over a second crash that only appears once the 403 is fixed (test the full
happy path, not just the error path). happy path, not just the error path).
### liveChatId LIVES IN SNIPPET AND ONLY EXISTS ONCE THE BROADCAST IS LIVE (RECIPE)
YouTube Data API v3 `liveBroadcasts`. `liveChatId` is in **`snippet.liveChatId`** —
`contentDetails` has NO such property. AND it is only populated once the broadcast is
**live** (the official `GetLiveChatId.java` sample lists `broadcastStatus=active`);
a fetch right after insert (lifecycleStatus `ready`) returns nothing. (2026-09-25: this
cost a whole investigation — the TEST-tab "Chat polling couldn't start — Mock Chat Input
is disabled" report — because the code read `part=contentDetails` + ran before the frame
pump pushed RTMP.) Fix = read part=snippet + bounded retry AFTER the encoder starts
(`GetBroadcastLiveChatIdAsync(broadcastId, maxAttempts=10, delayMs=2000)`). Debug lens:
missing liveChatId ≈ "broadcast not live yet", NOT an auth failure.
### CHANNELS.LIST auditDetails PART 403s THE WHOLE REQUEST WITHOUT A PARTNER SCOPE (RECIPE) ### CHANNELS.LIST auditDetails PART 403s THE WHOLE REQUEST WITHOUT A PARTNER SCOPE (RECIPE)
YouTube Data API v3 `channels.list` rejects the ENTIRE request with YouTube Data API v3 `channels.list` rejects the ENTIRE request with
+28 -7
View File
@@ -339,16 +339,37 @@ public class YouTubeStreamService
return $"YouTube rejected the message ({(int)response.StatusCode})"; return $"YouTube rejected the message ({(int)response.StatusCode})";
} }
/// <summary>Returns the liveChatId for a broadcast by fetching its contentDetails. /// <summary>Resolves the broadcast's liveChatId (needed to poll chat). Two facts
/// The liveChatId is needed to poll chat messages. Called after broadcast creation /// pin this down (2026-09-25, from the official liveBroadcasts reference + the
/// in PrepareAndStartLiveAsync.</summary> /// GetLiveChatId.java sample):
public async Task<string?> GetBroadcastLiveChatIdAsync(string broadcastId) /// 1. It lives under <c>snippet.liveChatId</c> — <c>contentDetails</c> has no such
/// property, so part=contentDetails could NEVER resolve it (the "Chat polling
/// couldn't start" report).
/// 2. YouTube only populates it once the broadcast is <c>live</c> (the sample lists
/// broadcastStatus=active) — fetching right after insert (lifecycleStatus
/// <c>ready</c>) returns no id. So this polls with a bounded retry, which the
/// caller must run AFTER the encoder starts pushing RTMP (enableAutoStart flips
/// the broadcast to live). Returns the id or null after the retries exhaust;
/// never throws.</summary>
public async Task<string?> GetBroadcastLiveChatIdAsync(
string broadcastId, int maxAttempts = 10, int delayMs = 2000)
{
for (var attempt = 0; attempt < maxAttempts; attempt++)
{
if (attempt > 0) await Task.Delay(delayMs);
var liveChatId = await FetchLiveChatIdAsync(broadcastId);
if (liveChatId != null) return liveChatId;
}
return null;
}
private async Task<string?> FetchLiveChatIdAsync(string broadcastId)
{ {
if (!await EnsureToken()) return null; if (!await EnsureToken()) return null;
_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}/liveBroadcasts?part=contentDetails&id={broadcastId}"); $"{ApiBase}/liveBroadcasts?part=snippet&id={broadcastId}");
if (!response.IsSuccessStatusCode) return null; if (!response.IsSuccessStatusCode) return null;
var json = await response.Content.ReadAsStringAsync(); var json = await response.Content.ReadAsStringAsync();
@@ -356,8 +377,8 @@ public class YouTubeStreamService
var items = data.GetProperty("items"); var items = data.GetProperty("items");
if (items.GetArrayLength() == 0) return null; if (items.GetArrayLength() == 0) return null;
var contentDetails = items[0].GetProperty("contentDetails"); var snippet = items[0].GetProperty("snippet");
if (contentDetails.TryGetProperty("liveChatId", out var liveChatId)) if (snippet.TryGetProperty("liveChatId", out var liveChatId))
return liveChatId.GetString(); return liveChatId.GetString();
return null; return null;
+8
View File
@@ -59,6 +59,7 @@
| 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) |
| 43 | Native events & alerts: chat parity (all six event types + server poll cadence) + six-animation native alert box | ✅ Done (2026-09-24) | [`TASKS/task-43-native-alerts.md`](TASKS/task-43-native-alerts.md) | | 43 | Native events & alerts: chat parity (all six event types + server poll cadence) + six-animation native alert box | ✅ Done (2026-09-24) | [`TASKS/task-43-native-alerts.md`](TASKS/task-43-native-alerts.md) |
| 44 | TEST-tab chat fix: resolve liveChatId from snippet (not contentDetails) + poll until the broadcast is live | ✅ Done (2026-09-25); full suite 318/318 | [`TASKS/task-44-test-chat-fix.md`](TASKS/task-44-test-chat-fix.md) |
--- ---
@@ -110,6 +111,13 @@
`AlertOverlayLayer` component; 33ms ticker, deterministic `Advance` test clock; idle = `AlertOverlayLayer` component; 33ms ticker, deterministic `Advance` test clock; idle =
transparent). Creator rulings baked in: free-sub mention = chat row only (no sub alert — YouTube transparent). Creator rulings baked in: free-sub mention = chat row only (no sub alert — YouTube
emits none), NO viewer count, every alert card carries the "made with LlamaCasty!" brand line. emits none), NO viewer count, every alert card carries the "made with LlamaCasty!" brand line.
- **TASK 44 — TEST-tab chat fix** — **DONE 2026-09-25** (`TASKS/task-44-test-chat-fix.md`):
the open creator report ("I still cannot post a chat message in the TEST tab") was a single
cause: `GetBroadcastLiveChatIdAsync` read `contentDetails.liveChatId` (lifeChatId lives in
**`snippet`**) AND ran before the broadcast was live (RTMP push flips ready→live via
enableAutoStart; YouTube only populates the id on live broadcasts — official GetLiveChatId.java
sample lists broadcastStatus=active). Fixed by reading `snippet.liveChatId` and polling with a
bounded retry after the frame pump starts; chat stays non-fatal.
--- ---
+58
View File
@@ -0,0 +1,58 @@
# TASK 44 — TEST-tab chat fix: resolve the liveChatId (snippet + live-timing)
> Catalog: [`TASKS.md`](../TASKS.md). Status: ✅ **SHIPPED 2026-09-25** — the open
> creator report from HANDOFF ("I still cannot post a chat message in the TEST tab")
> diagnosed and fixed as its own scoped unit.
## Provenance
- **2026-09-24, HANDOFF** filed the creator report as open: "I still cannot post a chat
message in the TEST tab." The TEST drawer's Mock Chat Input posts a real
`liveChat/messages.insert`; the drawer only enables it when a liveChatId is resolved.
- **2026-09-25 diagnosis:** the reported feedback line "Chat polling couldn't start —
Mock Chat Input is disabled" is `TestSessionViewModel.BeginTest` running with a **null
liveChatId**. That id comes from `YouTubeStreamService.GetBroadcastLiveChatIdAsync`,
and two API facts (from the official liveBroadcasts reference + the
`GetLiveChatId.java` sample) pin why it never resolved:
1. **It lives under `snippet.liveChatId`** — `contentDetails` has no such property at
all. The code requested `part=contentDetails` and read `contentDetails.liveChatId`,
a path that can NEVER resolve.
2. **YouTube only populates it once the broadcast is `live`** (the official sample
lists `broadcastStatus=active`). The fetch ran right after broadcast insert
(lifecycleStatus `ready`) AND before `_framePump.StartAsync()` pushed the RTMP
stream that `enableAutoStart` needs to flip the broadcast to live — so even the
correct property would have been empty at fetch time.
## What shipped
- `Services/YouTubeStreamService.cs`:
- `GetBroadcastLiveChatIdAsync(broadcastId, maxAttempts = 10, delayMs = 2000)` now
requests **`part=snippet`** and reads **`snippet.liveChatId`**, and **polls with a
bounded retry** until the id appears or the attempts exhaust (never throws). The
default retry (~20s) gives the encoder time to connect and the broadcast to go live.
- New private `FetchLiveChatIdAsync` holds the single read; the public method owns
the retry loop.
- `ViewModels/MainViewModel.Streaming.Operations.cs`:
- `PrepareAndStartLiveAsync` **starts the frame pump FIRST**, then resolves the
liveChatId — the reorder is required for the id to ever exist. Chat remains
non-fatal: failure logs and the stream continues.
- TEST drawer wiring preserved: `TestSession.BeginTest(broadcastId, liveChatId)`
still opens the dock once the id resolves → Mock Chat Input becomes sendable.
- Docs: `ai.md` (Test Stream section), `TASKS.md` (row 44), `HANDOFF.md` rewritten.
## Validation (Good Dog: ONE integration test)
`ytLive.Tests/YouTubeStreamServiceTests.cs`:
`GetBroadcastLiveChatIdAsync_Polls_Snippet_Until_Id_Appears` — a fake broadcast that
starts WITHOUT a liveChatId (not yet live) and gains it on a later poll; asserts the
retry eventually resolves `Cg0LIVECHAT42`, that it polled ≥3 times, and that every
request asked for **snippet** (never contentDetails) — the exact seam that was broken.
**Gate: clean build 0 warnings; full vstest 318/318 passed.**
## Reference
- https://developers.google.com/youtube/v3/live/docs/liveBroadcasts (snippet.liveChatId;
contentDetails has no liveChatId)
- https://github.com/youtube/api-samples/blob/master/java/src/main/java/com/google/api/services/samples/youtube/cmdline/live/GetLiveChatId.java
(sample lists broadcastStatus=active — id only present on live broadcasts)
@@ -183,36 +183,6 @@ public partial class MainViewModel : ViewModelBase
_notifications.Warning("Complete your broadcast details", _notifications.Warning("Complete your broadcast details",
"Your title and description are empty — fill them in from the Text panel on the right."); "Your title and description are empty — fill them in from the Text panel on the right.");
} }
// Start chat polling (TASK 9.4): fetch the liveChatId from the broadcast's
// contentDetails, then start the poll loop. Chat is non-fatal — if it fails,
// the stream continues without on-panel chat.
string? liveChatId = null;
try
{
liveChatId = await _youtubeStream.GetBroadcastLiveChatIdAsync(_currentBroadcastId);
if (liveChatId != null)
{
_youtubeChat.Start(liveChatId);
AppLog.Write($"Chat polling started: {liveChatId}");
}
else
{
AppLog.Write("Could not retrieve liveChatId; chat polling disabled");
}
}
catch (Exception chatEx)
{
AppLog.Write($"Chat start failed (non-fatal): {chatEx.Message}");
}
// TASK 41: a test broadcast hands its session to the Test drawer once
// the liveChatId resolves — Mock Chat Input + simulated events need it.
if (IsTestStream && _currentBroadcastId != null)
{
TestSession.BeginTest(_currentBroadcastId, liveChatId);
AppLog.Write($"Test session dock open for {_currentBroadcastId}");
}
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -222,8 +192,40 @@ public partial class MainViewModel : ViewModelBase
return; return;
} }
// The pump is what pushes RTMP; enableAutoStart flips the broadcast ready→live
// only once frames flow. The liveChatId (snippet.liveChatId) does not exist
// before that (2026-09-25 diagnosis), so chat resolution runs AFTER the pump
// starts. Chat is non-fatal — if it fails, the stream continues without chat.
await _framePump.StartAsync(); // never throws; failures log + surface via Failed await _framePump.StartAsync(); // never throws; failures log + surface via Failed
string? liveChatId = null;
try
{
if (_currentBroadcastId != null)
liveChatId = await _youtubeStream.GetBroadcastLiveChatIdAsync(_currentBroadcastId);
if (liveChatId != null)
{
_youtubeChat.Start(liveChatId);
AppLog.Write($"Chat polling started: {liveChatId}");
}
else
{
AppLog.Write("Could not retrieve liveChatId; chat polling disabled");
}
}
catch (Exception chatEx)
{
AppLog.Write($"Chat start failed (non-fatal): {chatEx.Message}");
}
// TASK 41: a test broadcast hands its session to the Test drawer once
// the liveChatId resolves — Mock Chat Input + simulated events need it.
if (IsTestStream && _currentBroadcastId != null)
{
TestSession.BeginTest(_currentBroadcastId, liveChatId);
AppLog.Write($"Test session dock open for {_currentBroadcastId}");
}
// Health polling (TASK 5 item 3): poll immediately, then every 30s while // Health polling (TASK 5 item 3): poll immediately, then every 30s while
// live. Report-by-exception — a banner appears only on warning/error. // live. Report-by-exception — a banner appears only on warning/error.
_healthPollTimer.Start(); _healthPollTimer.Start();
+9
View File
@@ -1560,6 +1560,15 @@ These are the hard facts behind every decision. Full list in `TASKS.md`.
`StopStream` clears it in every path (including pump-failure rollback). Once the liveChatId `StopStream` clears it in every path (including pump-failure rollback). Once the liveChatId
resolves, `TestSession.BeginTest(broadcastId, liveChatId)` opens the **TEST drawer** (third resolves, `TestSession.BeginTest(broadcastId, liveChatId)` opens the **TEST drawer** (third
right-rail pull-out, three-way one-open-at-a-time exclusivity wired in `MainViewModel.Ypp.cs`). right-rail pull-out, three-way one-open-at-a-time exclusivity wired in `MainViewModel.Ypp.cs`).
**liveChatId resolution (fixed 2026-09-25, TASK 44):** the id is `snippet.liveChatId` —
`contentDetails` has NO liveChatId property — and YouTube only populates it once the broadcast
is LIVE (the official GetLiveChatId.java sample lists broadcastStatus=active). So
`GetBroadcastLiveChatIdAsync(broadcastId, maxAttempts=10, delayMs=2000)` reads
part=snippet and polls with a bounded retry, and `PrepareAndStartLiveAsync` starts the frame
pump FIRST (the RTMP push is what flips ready→live via enableAutoStart) before resolving it.
Chrome-debugging note: a fetch at broadcast-insert time (lifecycleStatus ready) legitimately
returns no id — missing liveChatId is the ready state, not an auth failure, until ~20s post
push.
Drawer tooling: **Mock Chat Input** = a REAL `liveChat/messages.insert` (`YouTubeStreamService.InsertChatMessageAsync`) Drawer tooling: **Mock Chat Input** = a REAL `liveChat/messages.insert` (`YouTubeStreamService.InsertChatMessageAsync`)
that round-trips through the real ~2s poll and renders through the live overlay path; that round-trips through the real ~2s poll and renders through the live overlay path;
**simulated Subscriber / New Member / Super Chat** = `YouTubeChatService.InjectSimulatedMessage`, **simulated Subscriber / New Member / Super Chat** = `YouTubeChatService.InjectSimulatedMessage`,
+39 -1
View File
@@ -294,4 +294,42 @@ public class YouTubeStreamServiceTests
Assert.Contains("403", closeOutError); Assert.Contains("403", closeOutError);
Assert.StartsWith("POST ", handler.Requests[^1]); Assert.StartsWith("POST ", handler.Requests[^1]);
} }
}
/// <summary>The ONE integration test for the 2026-09-25 TEST-tab chat fix:
/// "Chat polling couldn't start — Mock Chat Input is disabled" because the
/// liveChatId never resolved. Two facts pin it: (1) the id lives in
/// <c>snippet.liveChatId</c> — part=contentDetails could NEVER return it, and
/// (2) YouTube only populates it once the broadcast is live, so a single fetch
/// right after insert (lifecycleStatus ready) comes back empty. The fix reads
/// part=snippet AND polls with a bounded retry — this test drives a fake
/// broadcast that starts WITHOUT the id (broadcast not yet live) and gains it on
/// a later poll, proving the retry resolves it and that every request asked for
/// snippet (never contentDetails).</summary>
[Fact]
public async Task GetBroadcastLiveChatIdAsync_Polls_Snippet_Until_Id_Appears()
{
var polls = 0;
var handler = new RecordingHandler
{
ResponseSelector = request =>
{
polls++;
// First polls: broadcast exists but not yet live → no liveChatId.
return polls < 3
? """{"items":[{"id":"BC-42","snippet":{"title":"t"}}]}"""
: """{"items":[{"id":"BC-42","snippet":{"title":"t","liveChatId":"Cg0LIVECHAT42"}}]}""";
}
};
var service = new YouTubeStreamService(CreateAuthed(), new HttpClient(handler));
var chatId = await service.GetBroadcastLiveChatIdAsync("BC-42", maxAttempts: 5, delayMs: 25);
Assert.Equal("Cg0LIVECHAT42", chatId);
Assert.True(polls >= 3, $"expected the retry to poll until the id appears (polled {polls}x)");
Assert.All(handler.Requests, r =>
{
Assert.Contains("part=snippet", r);
Assert.DoesNotContain("part=contentDetails", r);
});
}
}