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:
2026-10-04 15:27:01 -07:00
parent 8fbc3701d2
commit 07a088a23f
5 changed files with 285 additions and 24 deletions
@@ -166,7 +166,7 @@ public partial class MainViewModel : ViewModelBase
_layoutStore.SaveReusableStream(stream);
_currentBroadcastId = await _youtubeStream.CreateBroadcast(
StreamTitle, StreamDescription, DateTime.UtcNow, stream.Id);
StreamTitle, StreamDescription, DateTime.UtcNow);
_broadcastSeenLive = false;
if (_currentBroadcastId == null)
{
@@ -178,6 +178,31 @@ public partial class MainViewModel : ViewModelBase
AppLog.Write($"Broadcast created: {_currentBroadcastId}");
OnPropertyChanged(nameof(CurrentBroadcastId));
// ⛔ THE BINDING — the call whose absence is why nothing ever went live
// (2026-10-04). Sending `contentDetails.boundStreamId` on the insert does NOT
// bind: the insert is accepted, its sibling fields take effect, and the
// broadcast still comes back `bound=NONE`, sits in lifeCycleStatus `created`
// forever ("incomplete settings"), so `enableAutoStart` has no bound stream to
// fire from and close-out 403s `invalidTransition`. Meanwhile ffmpeg happily
// pushes ~99MB into the reusable stream, because a reusable stream accepts
// bytes for a broadcast that is not watching — which is why every symptom
// pointed away from the binding.
//
// Failure here is fatal to the session, not cosmetic: an unbound broadcast can
// never go live, so continuing would present a live-looking UI over a broadcast
// that is quietly never going to be seen.
var bindError = await _youtubeStream.BindBroadcastAsync(_currentBroadcastId, stream.Id);
if (bindError != null)
{
AppLog.Write($"Stream binding failed: {bindError}");
// Show the real reason. "Check the sign-in" would be wrong for five of the
// six documented bind failures, and would hide our own ordering bug behind
// a plausible excuse — see BindFailureMessage.
_notifications.Error("Couldn't go live", bindError);
StopStream(); // full rollback
return;
}
if (string.IsNullOrWhiteSpace(StreamTitle) && string.IsNullOrWhiteSpace(StreamDescription))
{
BroadcastForm.IsDrawerOpen = true;