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:
@@ -1677,6 +1677,57 @@ YouTube" button; going live is unreachable until an account is connected.
|
||||
|
||||
## YouTube Live API — design constraints (do not violate)
|
||||
|
||||
> ## ✅ SOLVED 2026-10-04 — the broadcasts never went live because they were never BOUND.
|
||||
>
|
||||
> **The bug, in one line: `contentDetails.boundStreamId` on `liveBroadcasts.insert` does not bind
|
||||
> the broadcast.** Binding requires an explicit `POST liveBroadcasts/bind?id=…&streamId=…&part=id`.
|
||||
>
|
||||
> **How it was finally proven, after a week of misdiagnosis** — the diagnostic line, once its own
|
||||
> JSON-nesting bug was fixed (`FirstItem`), read:
|
||||
> ```
|
||||
> lifeCycle=created privacy=private autoStart=true bound=NONE boundMatches=NO-BINDING
|
||||
> | streamStatus=active activeChat=none cdn=rtmp/variable@variable
|
||||
> ```
|
||||
> Every field is real, and together they are conclusive:
|
||||
> - `bound=NONE` **with** `autoStart=true`. Both live in the *same* `contentDetails` object on the
|
||||
> insert, so the request was well-formed and accepted — the API took one field and silently ignored
|
||||
> the other. That asymmetry is the proof; a malformed request would have failed outright.
|
||||
> - `lifeCycle=created`, **not** `ready`. Google: `created` = *"incomplete settings, so it is not
|
||||
> ready to transition to a live or testing status"*. An unbound broadcast is precisely incomplete.
|
||||
> - `streamStatus=active` — the ingest half was never broken. YouTube had an active stream and a
|
||||
> broadcast that was not attached to it.
|
||||
>
|
||||
> **The causal chain, complete:** no binding → `lifeCycleStatus` stuck at `created` →
|
||||
> `enableAutoStart` has no bound stream to fire from → never live → close-out 403s
|
||||
> `invalidTransition` because `created → complete` is not a legal edge (only `ready`/`live` are).
|
||||
>
|
||||
> **Why it survived a week, and the lesson worth keeping:** every observable pointed away from the
|
||||
> binding. ffmpeg pushed ~99 MB cleanly, the reusable stream went `active`, close-out returned a
|
||||
> *transition* error (which reads like a lifecycle-timing problem), and the insert returned a valid
|
||||
> broadcast ID (which reads like success). **A reusable stream accepts bytes for a broadcast that is
|
||||
> not watching it**, so a perfectly healthy RTMP session is exactly what an unbound broadcast looks
|
||||
> like. Google says it outright in the migration guide: *"a liveBroadcast must be bound to exactly one
|
||||
> stream before the live broadcast can actually start. The broadcast is not bound to any streams at
|
||||
> the time that it's created."* That sentence was in the docs I read during the original design and
|
||||
> was not applied. **Read the failure modes in the docs, not just the happy path.**
|
||||
>
|
||||
> **Shipped:** `YouTubeStreamService.BindBroadcastAsync`, called from `PrepareAndStartLiveAsync`
|
||||
> immediately after `CreateBroadcast` and **before** the pump starts. Failure is fatal to the session
|
||||
> (an unbound broadcast can never go live, so a live-looking UI over it is a lie). `boundStreamId` was
|
||||
> **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.
|
||||
> The old test `CreateBroadcast_With_StreamId_Binds_Reusable_Stream_At_Insert`, which asserted the
|
||||
> disproven behaviour and therefore *laundered a guess into a guarantee*, is replaced by
|
||||
> `CreateBroadcast_Does_Not_Claim_To_Bind_And_Omits_BoundStreamId` plus
|
||||
> `BindBroadcastAsync_Posts_To_Bind_Endpoint_With_Both_Ids_And_No_Body`.
|
||||
>
|
||||
> **Bind failures are not all "sign in again."** Google documents six reasons
|
||||
> (`insufficientLivePermissions`, `liveStreamingNotEnabled`, `liveBroadcastBindingNotAllowed`,
|
||||
> `liveStreamNotFound`, `liveBroadcastNotFound`, `internalError`, plus rate limiting) and only the
|
||||
> first is an auth problem. `BindFailureMessage` maps each to its own text; `liveBroadcastBindingNotAllowed`
|
||||
> matters most, because it means *our* ordering is wrong and a generic "check the sign-in" would hide
|
||||
> a bug in our own code behind a plausible excuse.
|
||||
|
||||
These are the hard facts behind every decision. Full list in `TASKS.md`.
|
||||
|
||||
- **One-click go-live** — never call `transition(live)`. Insert the broadcast with
|
||||
@@ -1845,15 +1896,20 @@ These are the hard facts behind every decision. Full list in `TASKS.md`.
|
||||
any left-click outside the rail closes whichever drawer is open — TEST included (fixed
|
||||
2026-09-25, TASK 46; the code-behind outside-click close in `MainWindow` had skipped
|
||||
TestSession, so only Stream Settings + YPP dismissed on an outside click).
|
||||
**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.
|
||||
**liveChatId resolution (fixed 2026-09-25, TASK 44; ⚠️ CORRECTED 2026-10-04):** the id is
|
||||
`snippet.liveChatId` — `contentDetails` has NO liveChatId property — and
|
||||
`GetBroadcastLiveChatIdAsync(broadcastId, maxAttempts=10, delayMs=2000)` reads part=snippet and
|
||||
polls with a bounded retry, and `PrepareAndStartLiveAsync` starts the frame pump FIRST before
|
||||
resolving it. **The old claim that "YouTube only populates it once the broadcast is LIVE" is
|
||||
WRONG, and it was inferred from a sample rather than from the API.** The 2026-10-04 diagnostic
|
||||
caught `liveChatId=present` while `lifeCycleStatus=created` — not live, not even `ready`. The
|
||||
official `GetLiveChatId.java` sample tests `broadcastStatus == active` before returning the id, and
|
||||
that read as a statement about *when YouTube populates the field*; it is only a guard in the
|
||||
sample. **Generalisation: a sample's defensive check is not a spec.** Also note the old note said
|
||||
the missing id meant "the ready state" — the broadcasts were never `ready` at all, they were
|
||||
`created` and unbound (see the SOLVED box above), so the terminology was wrong too.
|
||||
The bounded retry is harmless and stays, but the id is available far earlier than assumed — worth
|
||||
revisiting only if chat startup latency is ever reported as slow.
|
||||
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`,
|
||||
|
||||
Reference in New Issue
Block a user