# YouTube Live API — design constraints (do not violate) Go-live ordering and binding, the deliberate private-only lock, health parsing, end-stream pre-flight, Test mode and chat-insert rules. Read before touching go-live, broadcasts, health, or the chat service. The native alert box built on these events lives in [`overlays.md`](overlays.md). > ## ✅ 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 `enableAutoStart=true`, `enableAutoStop=true`, `enableMonitorStream=false`, `selfDeclaredMadeForKids=false`, `latencyPreference=low`. The encoder starting brings YouTube live. `enableMonitorStream=false` is what lets us skip the testing stage. - **⛔ CORRECTION (2026-10-04): the push is provably healthy — stop investigating it.** The long test run settles it: `liveBroadcasts.insert` at 14:17:04.577, ffmpeg `Output #0, flv` to `rtmp://a.rtmp.youtube.com/live2/5f6z-…` at 14:17:05.686, and at 14:18:56.473 ffmpeg's own muxing summary reads **`video:101229KiB audio:1419KiB`**, `muxing overhead: 0.202949%` — ~99 MB of real video plus audio over 112s, which is exactly what 8000+128 kb/s should produce. **No socket error, no 4xx from ingest, no reconnect.** The broadcast *still* never left `ready`, and close-out earned 403 `invalidTransition` again. An earlier 8s run behaved identically, so "YouTube needed longer" is disproven — 112s is longer. The break is between *ingest accepted* and *broadcast told to start*, nowhere in the RTMP path. - **The binding is the prime suspect, and the app was blind to it.** `contentDetails.boundStreamId` ("the live stream bound to the broadcast") and `enableAutoStart` ("start automatically when you start streaming video **on the bound live stream**") are both valid on insert, with no documented restriction against a reusable stream. But YouTube's migration guide carries the danger: **"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."** And this failure mode is *invisible from the RTMP side*, because **a reusable stream accepts bytes for an unbound broadcast** — ingest succeeding proves nothing. ⇒ `LogBroadcastDiagnosticsAsync` rides the **existing** stream-health poll (no second timer) and writes one greppable line: `lifeCycleStatus`, `privacyStatus`, `recordingStatus`, `boundStreamId` **compared against the stream id we are actually pushing** (`BoundMatches` → `yes`/`MISMATCH`/`NO-BINDING`/`unknown`), `boundStreamLastUpdateTimeMs`, `enableAutoStart/Stop`, `liveChatId` presence, the stream's own `status.streamStatus` + `activeLiveChatId`, and the CDN settings we declared. It is **self-limiting** — `_broadcastSeenLive` latches on `live`/`liveStarting`, so the line shouts only while the broadcast is stuck and goes quiet once healthy. **The next run reads this line; it is the whole experiment.** Guarded by `BroadcastBindingTests.BoundMatches_ComparesTheBroadcastAgainstTheStreamWePush`, which pins `unknown` for a missing expectation so an absent stream id can never be misreported as a match. - **⚠️ The first version of this diagnostic LIED, and the first real run caught it (2026-10-04).** `liveBroadcasts.list` returns a **list wrapper**, so the broadcast lives at `items[0]`. The original code descended into `items` for the *stream* but read the *broadcast's* response root, so every broadcast field came back `?` — and `BoundMatches` then dutifully reported a confident **`boundMatches=NO-BINDING`** from a parse that had never happened. Three lines of fabricated alarm. Two fixes, both load-bearing: **`FirstItem()`** descends into `items[0]` for both halves (and returns `default` when there is genuinely no item), and the line now prints **`boundMatches=unread`** when the broadcast could not be read, so an absent parse can never be reported as a verdict. Guarded by `BroadcastBindingTests.FirstItem_DescendsIntoTheListWrapper_AndReportsAbsenceHonestly`. **Generalisation: a diagnostic must degrade to "I don't know", never to a confident wrong answer** — otherwise one level of JSON nesting manufactures a bug that isn't there. - **What the one run that *did* work proved:** the stream half was correct, and it shows `streamStatus=ready` → **`streamStatus=active`** about 30s into the push. So the ingest does register as active server-side, which is a genuine step forward — combined with the 99MB muxed in the earlier run, the stream half of the system is now verified healthy. The broadcast half is still unobserved pending a re-run. - **Visibility picker (TASK 9 item 6) — DELIBERATE TEST-PHASE LOCK, not drift (2026-09-01).** The "always Private" enforcement in `YouTubeStreamService.CreateBroadcast` stays while the creator runs multi-month real-world testing: non-private test streams would clutter the channel with VODs highlighting where the product breaks. `recordFromStart`/DVR stay ON — Private VODs are invisible to subscribers and serve as post-mortem review tapes; bulk-delete pre-GA. The unlock is a deliberate final-pass item in **TASK 36 (gold pass)**, wired with the dialog selection — never opportunistic. The PRIVATE badge keeps showing when the stream is actually private. - **⛔⛔ STOP. The always-Private lock is a DECISION, not a bug, not drift, not an open question. (Creator ruling, restated 2026-10-04 after being asked about it AGAIN.)** `privacyStatus = "private"` hardcoded in `YouTubeStreamService.CreateBroadcast` is **exactly right as written. Do not change it, do not "unlock" it, and — this is the part I keep getting wrong — do not raise it as a question, an observation, a suggestion, or a "heads-up".** The creator has now answered this more than once. - **What it becomes:** the hardcoded lock is replaced by a real, user-selectable visibility option in the Go-Live dialog — **at v1, not before.** Not at 1.0-candidate, not at GA prep, not opportunistically during any other unit. - **Why it exists:** pre-v1 test streams would otherwise publish VODs of a product still breaking, in public, to subscribers. Private keeps those tapes usable for post-mortem review while staying invisible to the channel's audience. - **Do not let these look like symptoms of the one they resemble.** All are working as designed: `privacy=private` in the broadcast diag line; the PRIVATE badge; `recordFromStart`/DVR ON; and the absence of any delete call in the codebase (there is no delete feature, by design at this stage — "can we delete the test VODs" is a *retired* question, answered 2026-10-04: `videos.delete` accepts `youtube.force-ssl` which we already hold, private is a visibility setting not a deletion lock, and anyway a broadcast that never reaches `live` never becomes a VOD at all). - **`UpdateBroadcast` is NOT the unlock — and it is also currently DEAD CODE. (Fact corrected 2026-10-04: an earlier version of this note said `CreateBroadcast:112` / `UpdateBroadcastMetadataAsync`, both wrong.)** The method that can change a live broadcast's privacy is `YouTubeStreamService.UpdateBroadcast(broadcastId, meta)`, which sends `privacyStatus = meta.Visibility ?? "private"`. **It has no production caller** — the only caller in the repo is `BroadcastPullOutTests.cs:120`. So `CreateBroadcast` hardcoding private is not merely backed up by convention, it is the *only* thing enforcing it. - **⇒ Is a Go Live video private? YES — verified 2026-10-04, and this answers a standing creator doubt.** Three independent hardcodes (`CreateBroadcast` → `"private"`; `GoLiveViewModel.Visibility => "Private"`; `BeginTestStream` → `StreamVisibility = "Private"`) plus one unreachable method. There is no path by which a broadcast this app creates can become non-private today. The creator's actual concern — "do not populate my channel with videos showing the app at its worst" — is fully met. - **⛔ THE LANDMINE, and the reason the above matters:** `LiveBroadcastFormViewModel` still exposes `VisibilityOptions = {private, unlisted, public}` and `UpdateBroadcast` would happily honour it. **The v1 visibility option is the first thing that will ever wire `UpdateBroadcast` up — so v1 is exactly when this guarantee ends, by the creator's own choice.** When that happens, private stops being guaranteed and becomes a default the user can change. That is intended at v1; it is a disaster before v1. Do not wire `UpdateBroadcast` into any production path before v1, and do not "fix" the dead-code smell by calling it. - **⛔ The TEST button is HIDDEN at v1 — it exists only because of the lock above (creator ruling 2026-10-04).** This was **absent from the map entirely** until that date, which is how the dry-run framing below came to be the only recorded one. Both are true, and the second is the load-bearing one: - **Its real job is being the guaranteed-private path.** `BeginTestStream` hard-sets `StreamVisibility = "Private"` (`MainViewModel.Streaming.Operations.cs:105`), so Test can never publish. That is *why* it exists for the whole pre-v1 phase — a dry run you cannot leak. - **At v1 it is redundant and gets hidden**, because once visibility is a real dialog option the user picks Private themselves and a separate "safe" button no longer earns its place. Hiding it is part of the v1 visibility work, not a separate cleanup. - **The pre-existing "dry run" framing is now known to be incomplete** (`ShowTestButton`'s own comment calls it "a dry-run of the livestream"). Do not read that comment as the reason the button must persist, and do not re-derive the button's purpose from it. If a dry run is still wanted at v1 it must be re-justified on its own merits — that question is **open, and is for v1, not now.** - **It is a computed predicate, not a setting** — `ShowTestButton => ShowStreamActions && IsConnected` (`MainViewModel.Streaming.cs:182`), feeding both `CanStartTest` and the XAML visibility. So the v1 hide is a one-line predicate change, not a settings migration. - **Not a v1 blocker:** the Test route is the *same* pipeline as Go Live — `BeginTestStream` → `StartStreamingSession(alsoRecord: false)` → `PrepareAndStartLiveAsync` — so Test and Go Live create identical broadcasts (`boundStreamId`, `enableAutoStart`). Test differs only in never recording (`alsoRecord: false`) and forcing Private. **This is why Test is the correct way to run any diagnostic stream**, and why a Test result is evidence about Go Live. - **Full broadcast form (TASK 9 item 7) — RESCOPED 2026-09-01** — the core editable fields ship (since 2026-08-24) as the always-visible **Text drawer**: title, description, tags, visibility, made-for-kids, live-editable; scheduling ships as **TASK 34** (drawer ☑ Scheduled + adoption at Start). The old **Advanced tab is permanently out of product** (the 10% margin — see TASKS.md → "Out of product"): latency locked `low`, DVR/record-from-start locked on, embed/projection/CC/region fixed at sane defaults, invisible — every exposed field is a support ticket. `categoryId` is removed from `CreateBroadcast` (not a `liveBroadcast` field, silently ignored). Monetization enablement (if ever needed) rides the reward-events chain via `liveBroadcasts.update`, not a form field. Go-live order (TASK 9, shipped 2026-08-16): `BeginGoLive` → `PrepareAndStartLiveAsync` — ensure the reusable stream (`GetOrCreateReusableStreamAsync`, cache it), create the broadcast bound to it (`CreateBroadcast(..., stream.Id)` → `boundStreamId`), THEN start the pump (the URL must exist before `FramePump.StartAsync`, which reads it once). Failure → `StreamStatus.Error`, never a crash; `StopStream` clears `_currentBroadcastId`. - **Variable reusable stream** (shipped 2026-08-16) — `GetOrCreateReusableStreamAsync` lists `liveStreams?mine=true` and reuses the existing `cdn.isReusable` stream, inserting once per channel (`cdn.resolution=variable`, `cdn.frameRate=variable`, `isReusable=true`) only on first use; the ingestion URL is cached via `LayoutStore` Settings (`SaveReusableStream`/`LoadReusableStream`) and bound to each broadcast at insert (`boundStreamId`). Any quality tier works without recreating the stream, and auto step-down rides the same property — we drop bitrate/resolution on the fly, zero API calls — **but the deciding governor is not built**: the old present tense there was a map-lie, corrected 2026-09-01; step-down is **TASK 33** (v1 scope). - **Quality is greyed out while live** — resolution/frameRate/ingestionType are immutable after stream creation; editing title/description/privacy is fine at any time. - **Report-by-exception health (SHIPPED 2026-08-16, TASK 9 item 3)** — `YouTubeStreamService.GetStreamHealthAsync(streamId)` polls `liveStreams.list?part=status`; render nothing on `good`/`ok`/`noData`, surface a banner only on `configurationIssues[]` with `warning`/`error` severity. The decision is the pure `Services/StreamHealthReporter.BannerFor` (null text = no banner; error beats warning). The VM polls every **30s while live** (`DispatcherTimer` `_healthPollTimer`, first poll right after go-live) and clears on End via `ResetHealth`; poll failures log only. **Parsing (fix 2026-09-22):** the real API nests `status.healthStatus = {status, lastUpdateTimeSeconds, configurationIssues[]}` — reading it as a string crashed every poll (`requires an element of type 'String'`, seen 2026-09-22 on both test sessions); the parse now reads the nested `.status`/`configurationIssues` and tolerates the old flat-string shape. UI = a full-width banner strip under the top bar, `HealthIssueBanner` text + `HealthIssueBackground` (amber `#b8860b` warning / dark red `#8f1f1f` error), hidden by `NotNullToVis`. The design's bottom-strip YouTube logo + green/red dot (clickable → dialog) is still queued. - **One dialog, three states** — not connected / connected-offline (all editable) / live (title + description + visibility editable; quality + account greyed out). Both entry points (Start Stream button + bottom strip) open it; prefilled from saved session profile. - **End stream (SHIPPED 2026-09-01; pre-flight hardened 2026-09-22)** — stop encoder → `YouTubeStreamService.EndBroadcastAsync` POSTs `liveBroadcasts/transition?broadcastStatus=complete&id=…&part=status` (AFTER RTMP EOF, so no frames post-date the end — VOD finalizes immediately instead of ~1min of frozen "stream offline"). **2026-09-22: every end logged 403 `invalidTransition`** — a blind complete races enableAutoStop/YouTube's auto-complete (the old doc's "already fired" guess was wrong; it's a broadcast state race). The call now pre-flights `liveBroadcasts.list→status.lifeCycleStatus` and only transitions from `live`/`testing`, skipping silently otherwise (`enableAutoStop` finishes every skip — never throws, never toasts a finished session). Inconclusive pre-check still posts (old behavior, backstopped). Record-only stops make ZERO API calls (guard: `wasLive && _currentBroadcastId != null`). - **Encoder compliance** — keyframes ≤ 4s (gopSizeLong), closed GOP, H.264, AAC/MP3 @ 44.1/48kHz, mono/stereo only. YouTube flags violations via health status. - **Broadcast ID == Video ID** — one ID tracks status, health, and the auto-created VOD (`recordFromStart` + `enableDvr` default true). - **Test Stream mode (TASK 41, SHIPPED 2026-09-22)** — every broadcast is private-only anyway (see the visibility lock above), so "Test" is a **session variant over the real go-live pipeline**, not a new mode: `BeginTestStream` → `StartStreamingSession(alsoRecord:false)` (the dialog path was extracted into this shared boot — no Go Live dialog for a Test, no `BroadcastForm.CaptureGoLive` "last live" stamp, no recording). `IsTestStream` flips the face: gold top bar + glow (`#9c6f1c`), a **TEST** badge, and the End button reads **"End Test"**; `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`; 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; ⚠️ 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`, which raises the poller's `MessageReceived` seam, marked `ChatMessage.IsSimulated`. **API fact (researched + hard constraint): `liveChat/messages.insert` only creates `textMessageEvent`s — SuperChats / memberships / sponsorships cannot be inserted via any API**; simulated non-text events are local-only by design and never reach YouTube. **Insert body shape (fixed 2026-09-25, TASK 45):** the body MUST include `snippet.type` = `"textMessageEvent"` alongside `liveChatId` and `textMessageDetails.messageText` — omitting type returns `400 MISSING_REQUIRED_FIELD` (found live on the next Test Stream; the TASK 41 test had asserted body presence but not type, so it stayed false-green). Scopes `youtube` + `youtube.force-ssl` already cover insert. **Real stinger/TTS alert widgets are DONE (TASK 43, shipped 2026-09-24) as the native alert box** — see the next section; Text source (TASK 3 item 16) and the RewardEvent persistence (item 20) remain open.