Bind broadcasts explicitly; the week-long RTMP dead end was never a bind
root cause (proven, not inferred): contentDetails.boundStreamId on liveBroadcasts.insert does NOT bind the broadcast. Our diagnostic reads lifeCycle=created privacy=private autoStart=true bound=NONE boundMatches=NO-BINDING | streamStatus=active created is Google's "incomplete settings" state, so enableAutoStart has no bound stream to fire from; close-out then 403s invalidTransition because created -> complete is not a legal edge. A reusable stream accepts bytes for a broadcast that isn't watching it, so a perfectly healthy 99MB RTMP session is exactly what an unbound broadcast looks like -- every observable pointed away from the binding. Google's migration guide states the rule outright: a liveBroadcast must be bound to exactly one stream before it can start, and is not bound at creation. That was in the docs read during the original design and was not applied. Shipped: - BindBroadcastAsync -> POST liveBroadcasts/bind?part=id&id&streamId (no body), called from PrepareAndStartLiveAsync right after CreateBroadcast, before the pump. Failure is fatal to the session: an unbound broadcast can never go live, so a live-looking UI over one is a lie. - boundStreamId removed from the insert body, and the streamId parameter removed from CreateBroadcast. A parameter that looks meaningful but cannot affect the outcome is what made the misdiagnosis possible. - Replaced CreateBroadcast_With_StreamId_Binds_Reusable_Stream_At_Insert, which asserted the disproven behaviour and therefore laundered a guess into a guarantee, with tests for the omitted field and the real bind endpoint. Error messages (creator ruling 2026-10-04): bind failures are not all "check the sign-in". Six documented reasons exist and only one is auth; BindFailureMessage gives each its own text, and the fallback passes the raw reason through verbatim so an unmapped reason stays greppable. liveBroadcastBindingNotAllowed matters most -- it means our ordering is wrong, and a generic re-auth message would hide a bug in our own code behind a plausible excuse. See MyMistakes.md. ai.md: also corrects the TASK 44 claim that liveChatId is only populated once the broadcast is live. The diagnostic shows it present while lifecycle is created. That claim was inferred from a sample's active-status guard, which is not a spec. Suite 395/395, 0 warnings.
This commit is contained in:
@@ -79,25 +79,60 @@ public class YouTubeStreamServiceTests
|
||||
Assert.Null(handler.LastBody); // never reached the API
|
||||
}
|
||||
|
||||
// TASK 5: a reusable stream, when given, binds at broadcast insert via
|
||||
// contentDetails.boundStreamId — no separate bind round-trip. One-click
|
||||
// go-live also completes the v3 flags (no monitor stream, low latency).
|
||||
// TASK 5: one-click go-live completes the v3 flags (no monitor stream, low latency).
|
||||
//
|
||||
// ⛔ REWRITTEN 2026-10-04. This used to be
|
||||
// `CreateBroadcast_With_StreamId_Binds_Reusable_Stream_At_Insert`, and it asserted
|
||||
// `"boundStreamId":"S456"` in the insert body — i.e. it encoded, as a passing test,
|
||||
// the exact assumption that cost a week of misdiagnosis. The assumption is now
|
||||
// DISPROVEN: the insert is accepted, its sibling fields take effect, and the created
|
||||
// broadcast still comes back `bound=NONE`, stuck in lifeCycleStatus `created`, never
|
||||
// live, with close-out 403ing `invalidTransition`. A test that pins disproven
|
||||
// behaviour is worse than no test — it launders a guess into a guarantee.
|
||||
//
|
||||
// So the guarantee is inverted: assert the insert does NOT claim to bind, and assert
|
||||
// the real binding call targets the documented bind endpoint with both ids.
|
||||
[Fact]
|
||||
public async Task CreateBroadcast_With_StreamId_Binds_Reusable_Stream_At_Insert()
|
||||
public async Task CreateBroadcast_Does_Not_Claim_To_Bind_And_Omits_BoundStreamId()
|
||||
{
|
||||
var handler = new RecordingHandler { ResponseBody = """{"id":"BC123","snippet":{"title":"t"}}""" };
|
||||
var service = new YouTubeStreamService(CreateAuthed(), new HttpClient(handler));
|
||||
|
||||
var id = await service.CreateBroadcast("Test Stream", "A description", DateTime.UtcNow, "S456");
|
||||
var id = await service.CreateBroadcast("Test Stream", "A description", DateTime.UtcNow);
|
||||
|
||||
Assert.Equal("BC123", id);
|
||||
Assert.NotNull(handler.LastBody);
|
||||
Assert.Contains("\"boundStreamId\":\"S456\"", handler.LastBody);
|
||||
// The trap: boundStreamId reads as a binding and is not one.
|
||||
Assert.DoesNotContain("boundStreamId", handler.LastBody);
|
||||
// The flags that DO take effect must still be sent.
|
||||
Assert.Contains("\"enableMonitorStream\":false", handler.LastBody);
|
||||
Assert.Contains("\"latencyPreference\":\"low\"", handler.LastBody);
|
||||
Assert.Contains("\"privacyStatus\":\"private\"", handler.LastBody);
|
||||
}
|
||||
|
||||
/// <summary>The call that actually binds — the one whose absence is why nothing ever
|
||||
/// went live. Google's documented shape is
|
||||
/// `POST liveBroadcasts/bind?part=id&id=…&streamId=…` with NO request body (an empty
|
||||
/// body would be rejected). The parameter is `id`, not `broadcastId` — a plausible
|
||||
/// near-miss that returns 400 `idRequired`.</summary>
|
||||
[Fact]
|
||||
public async Task BindBroadcastAsync_Posts_To_Bind_Endpoint_With_Both_Ids_And_No_Body()
|
||||
{
|
||||
var handler = new RecordingHandler { ResponseBody = """{"id":"BC123"}""" };
|
||||
var service = new YouTubeStreamService(CreateAuthed(), new HttpClient(handler));
|
||||
|
||||
var error = await service.BindBroadcastAsync("BC123", "S456");
|
||||
|
||||
Assert.Null(error);
|
||||
var request = Assert.Single(handler.Requests);
|
||||
Assert.StartsWith("POST ", request);
|
||||
Assert.Contains("/liveBroadcasts/bind", request);
|
||||
Assert.Contains("part=id", request);
|
||||
Assert.Contains("id=BC123", request);
|
||||
Assert.Contains("streamId=S456", request);
|
||||
Assert.True(string.IsNullOrEmpty(handler.LastBody), "bind takes no request body");
|
||||
}
|
||||
|
||||
// TASK 5 design decision 2: reuse the existing cdn.isReusable stream instead
|
||||
// of creating a new one per broadcast.
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user