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
+28 -7
View File
@@ -339,16 +339,37 @@ public class YouTubeStreamService
return $"YouTube rejected the message ({(int)response.StatusCode})";
}
/// <summary>Returns the liveChatId for a broadcast by fetching its contentDetails.
/// The liveChatId is needed to poll chat messages. Called after broadcast creation
/// in PrepareAndStartLiveAsync.</summary>
public async Task<string?> GetBroadcastLiveChatIdAsync(string broadcastId)
/// <summary>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 <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;
_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;