From 94f015044a37ab5cc768bd05ba7f6006b9d943c8 Mon Sep 17 00:00:00 2001 From: gramps Date: Fri, 25 Sep 2026 08:06:02 -0700 Subject: [PATCH] =?UTF-8?q?fix(stream):=20TEST-tab=20chat=20=E2=80=94=20re?= =?UTF-8?q?solve=20liveChatId=20from=20snippet,=20poll=20until=20the=20bro?= =?UTF-8?q?adcast=20is=20live?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- HANDOFF.md | 78 ++++++++----------- MyMistakes.md | 12 +++ Services/YouTubeStreamService.cs | 35 +++++++-- TASKS.md | 8 ++ TASKS/task-44-test-chat-fix.md | 58 ++++++++++++++ .../MainViewModel.Streaming.Operations.cs | 62 ++++++++------- ai.md | 9 +++ ytLive.Tests/YouTubeStreamServiceTests.cs | 40 +++++++++- 8 files changed, 219 insertions(+), 83 deletions(-) create mode 100644 TASKS/task-44-test-chat-fix.md diff --git a/HANDOFF.md b/HANDOFF.md index aee3010..1df16da 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -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 -`main` HEAD = new commit for TASK 43 (native events & six-animation alert box); previous: -`3a4e17c` (account zone world-independent), `cb75066` (YPP 403), `d72949e` (top-bar feedback), -`ac6e67a` (end close-out). Repo is ~10 commits ahead of pushed `197ee81` — **push is the -creator's call** (sub-milestone decision, not per-commit). +`main` HEAD = new commit for TASK 44 (TEST-tab chat fix); previous: TASK 43 commit (native +events & alerts), `ac6e67a` (end close-out). Repo is ~11 commits ahead of pushed `197ee81` — +**push is the creator's call** (sub-milestone decision, not per-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 -StreamElements**. Research pinned the API facts; creator rulings locked the product shape. One -combined work unit ("go"), task filed as **TASK 43** (`TASKS/task-43-native-alerts.md`): +The HANDOFF-open creator report — *"I still cannot post a chat message in the TEST tab"* — +is **RESOLVED** (one cause, one scoped unit, TASK 44 in `TASKS/task-44-test-chat-fix.md`): -1. **Chat parity half** — `YouTubeChatService` now decodes **all six** `liveChat/messages` - event types into `ChatMessage.Kind` (the four previously-empty event rows are real now) and - re-arms its poll on the server's `pollingIntervalMillis` (streamList semantics; clamp - 1000–6000ms, `maxResults=2000`). -2. **Alerts half** — new **`SourceType.AlertBox`** ("Stream Alerts", one per layout, - `CanAddAlerts` gate mirroring chat): an OBS-style celebration zone, idle = transparent. - `Services/AlertRenderer.cs` draws **six DISTINCT branded animations** (SuperChat slide-up/ - shine/count-up; SuperSticker scale-pop; NewMember drop-in/flash; MemberGift slide-left/chip-fan; - GiftReceived confetti; MemberMilestone rise/growth-bar), each card carrying the "made with - LlamaCasty!" brand line (free product placement per broadcast). `Services/AlertOverlayLayer.cs` - is a true component (Commit-G pattern): queue (cap 10) + 33ms ticker + cache-first - `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. +- **The feedback line** "Chat polling couldn't start — Mock Chat Input is disabled" is + `TestSessionViewModel.BeginTest` running with a **null liveChatId**. +- **Root cause (two API facts, cited in commit + MyMistakes):** (1) `liveChatId` lives in + **`snippet.liveChatId`** — `contentDetails` has no such property, and the old code read + `part=contentDetails` (can NEVER resolve); (2) YouTube only populates it once the broadcast + is **live** (official `GetLiveChatId.java` lists `broadcastStatus=active`), and the fetch + ran before the frame pump pushed RTMP (enableAutoStart flips ready→live). +- **Fix:** `GetBroadcastLiveChatIdAsync(broadcastId, maxAttempts=10, delayMs=2000)` now reads + `part=snippet` and polls with a bounded retry; `PrepareAndStartLiveAsync` starts the frame + pump FIRST, then resolves the id. Chat stays non-fatal. TEST drawer wiring unchanged + (`BeginTest` opens the dock once the id resolves → Mock Chat Input becomes sendable). -**Good Dog:** `ytLive.Tests/AlertLayerTests.cs` (RealApp STA, real WPF raster) — ParsePage -classifies all six kinds + cadence fields; playback never enqueues a `None` (sub-mention) row, -plays six pairwise-distinct moving frames, drains to idle. **Gate: clean build 0 warnings, -316/317** — the ONE failure (`LayerReorderPersistenceTests.RealMouseDrag…`) repros on the CLEAN -tree (proved via `git stash` + rebuild + isolated run): a real-mouse-drag test that no-ops while -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.** +**Good Dog:** ONE integration test in `YouTubeStreamServiceTests.cs` +(`GetBroadcastLiveChatIdAsync_Polls_Snippet_Until_Id_Appears`) — a fake broadcast that starts +without a liveChatId and gains it on a later poll; asserts the retry resolves it, polled ≥3x, +and every request asked for snippet (never contentDetails). **Gate: clean build 0 warnings, +full vstest 318/318 passed** (the LayerReorder real-mouse-drag test passed this run too). ## 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 + - `superChatEvents.list` (30-day) backfill + session-report rollup (the shipped alert path parses - events to `ChatMessage` in memory; the report reads that table once it exists). + `superChatEvents.list` (30-day) backfill + session-report rollup (events still parse to + `ChatMessage` in memory only). - TASK 3 item 16 (Text source) still open; TASK 40 units A/C/D queued; TASK 32–36 queued. ## 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. - Latent bug the YPP 403 masked: statistics are JSON **strings** — always the ValueKind-first `ReadInt64` (MyMistakes recipe), never `GetInt64`. -- New facts recorded: the chat feed IS the event feed (six types, no sub event); streamList is a - connection-semantics description of that same endpoint (pollingIntervalMillis drives cadence). +- **API fact (TASK 44):** a liveChatId fetch at insert time (lifecycleStatus `ready`) returns + 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 builds are live; verify.sh can't run while the exe is locked. ## Next step -1. **Creator verifies the alerts unit on the next run**: Add → Stream Alerts, then (Test Stream - drawer) Simulated Super Chat / New Member — confirm the zone stays transparent at idle and each - event plays its own animation on the canvas and preview; a real Super Chat during the next live - run confirms the real path. -2. Push decision is the creator's (repo ~10 ahead). If pushed, note the commit id in this file. -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. +1. **Creator verifies the TEST-tab chat fix on the next run**: Test Stream → the drawer should + open with a liveChatId line and Mock Chat Input enabled ~as soon as the broadcast goes live; + send a message and confirm it round-trips through the ~2s poll and renders on the overlay. +2. Push decision is the creator's (repo ~11 ahead). If pushed, note the commit id in this file. +3. When scoped: TASK 3 item 20 (RewardEvents persistence half) draws next. ## Critical working rules (unchanged, still binding) diff --git a/MyMistakes.md b/MyMistakes.md index 329d313..897f6ae 100644 --- a/MyMistakes.md +++ b/MyMistakes.md @@ -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 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) YouTube Data API v3 `channels.list` rejects the ENTIRE request with diff --git a/Services/YouTubeStreamService.cs b/Services/YouTubeStreamService.cs index af1990c..78bcc41 100644 --- a/Services/YouTubeStreamService.cs +++ b/Services/YouTubeStreamService.cs @@ -339,16 +339,37 @@ public class YouTubeStreamService return $"YouTube rejected the message ({(int)response.StatusCode})"; } - /// Returns the liveChatId for a broadcast by fetching its contentDetails. - /// The liveChatId is needed to poll chat messages. Called after broadcast creation - /// in PrepareAndStartLiveAsync. - public async Task GetBroadcastLiveChatIdAsync(string broadcastId) + /// Resolves the broadcast's liveChatId (needed to poll chat). Two facts + /// pin this down (2026-09-25, from the official liveBroadcasts reference + the + /// GetLiveChatId.java sample): + /// 1. It lives under snippet.liveChatId — contentDetails 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 live (the sample lists + /// broadcastStatus=active) — fetching right after insert (lifecycleStatus + /// ready) 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. + public async Task 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 FetchLiveChatIdAsync(string broadcastId) { if (!await EnsureToken()) return null; _http.DefaultRequestHeaders.Authorization = new("Bearer", _auth.CurrentChannel!.AccessToken); var response = await _http.GetAsync( - $"{ApiBase}/liveBroadcasts?part=contentDetails&id={broadcastId}"); + $"{ApiBase}/liveBroadcasts?part=snippet&id={broadcastId}"); if (!response.IsSuccessStatusCode) return null; var json = await response.Content.ReadAsStringAsync(); @@ -356,8 +377,8 @@ public class YouTubeStreamService var items = data.GetProperty("items"); if (items.GetArrayLength() == 0) return null; - var contentDetails = items[0].GetProperty("contentDetails"); - if (contentDetails.TryGetProperty("liveChatId", out var liveChatId)) + var snippet = items[0].GetProperty("snippet"); + if (snippet.TryGetProperty("liveChatId", out var liveChatId)) return liveChatId.GetString(); return null; diff --git a/TASKS.md b/TASKS.md index 2b4ce19..527ba08 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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) | | 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) | +| 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 = 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. +- **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. --- diff --git a/TASKS/task-44-test-chat-fix.md b/TASKS/task-44-test-chat-fix.md new file mode 100644 index 0000000..c0b91c6 --- /dev/null +++ b/TASKS/task-44-test-chat-fix.md @@ -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) \ No newline at end of file diff --git a/ViewModels/MainViewModel.Streaming.Operations.cs b/ViewModels/MainViewModel.Streaming.Operations.cs index f874f4a..3b13755 100644 --- a/ViewModels/MainViewModel.Streaming.Operations.cs +++ b/ViewModels/MainViewModel.Streaming.Operations.cs @@ -183,36 +183,6 @@ public partial class MainViewModel : ViewModelBase _notifications.Warning("Complete your broadcast details", "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) { @@ -222,8 +192,40 @@ public partial class MainViewModel : ViewModelBase 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 + 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 // live. Report-by-exception — a banner appears only on warning/error. _healthPollTimer.Start(); diff --git a/ai.md b/ai.md index 8f87960..506383e 100644 --- a/ai.md +++ b/ai.md @@ -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 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`). + **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`) that round-trips through the real ~2s poll and renders through the live overlay path; **simulated Subscriber / New Member / Super Chat** = `YouTubeChatService.InjectSimulatedMessage`, diff --git a/ytLive.Tests/YouTubeStreamServiceTests.cs b/ytLive.Tests/YouTubeStreamServiceTests.cs index 24069ef..08afae2 100644 --- a/ytLive.Tests/YouTubeStreamServiceTests.cs +++ b/ytLive.Tests/YouTubeStreamServiceTests.cs @@ -294,4 +294,42 @@ public class YouTubeStreamServiceTests Assert.Contains("403", closeOutError); Assert.StartsWith("POST ", handler.Requests[^1]); } -} \ No newline at end of file + + /// 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 + /// snippet.liveChatId — 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). + [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); + }); + } +}