# YouTube Live API — design constraints (do not violate) Go-live ordering and binding, development/test privacy safeguards and release visibility, 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 policy (creator clarification 2026-11-04):** force Private during development and test runs so unfinished captures cannot accidentally publish. This is a safeguard for those environments, not a permanent product lock. The released creator flow offers Private, Unlisted, or Public, defaults to Private, and supports changing visibility while live. A PRIVATE badge is shown whenever the current broadcast is private. - **Release visibility flow (TASK 9, Day 2):** persist a default visibility (Private initially); ON-AIR opens a preflight with that default selected and an explicit “Start Broadcast — [scope]” action. Apply the selected scope when creating a broadcast and through `YouTubeStreamService.UpdateBroadcast` for live changes. Development/test runs stay forced Private. - **Implementation status:** current code still forces Private for all broadcasts. Do not mistake that description of today's code for the target Release behavior; the selectable flow is queued, not implemented. `UpdateBroadcast` currently has no production caller; Day 2 wires it for live visibility changes, while development/test runs remain Private. - **Test mode remains Private.** `BeginTestStream` forces `StreamVisibility = "Private"`, so retain that safeguard after normal Release go-live offers a choice. The existing v1 decision to hide the separate TEST button remains tracked with the release flow. - **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, and made-for-kids; release visibility is the Day 2 flow above. Scheduling ships as **TASK 34** (drawer ☑ Scheduled + adoption at Start). The old **Advanced tab is permanently out of product**: latency locked `low`, DVR/record-from-start locked on, embed/projection/captions fixed at sane defaults, invisible. `categoryId` is video metadata, not a `liveBroadcast` field; category selection, tag updates through the video API, and thumbnail upload remain follow-ups. 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 **2s until YouTube confirms `lifeCycleStatus=live`**, then every **30s** (`DispatcherTimer` `_healthPollTimer`, first poll immediately after the frame pump starts); the top bar turns green only on that confirmed `live` state. It 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)** — Test remains forced Private even when normal Release go-live offers a visibility choice, 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. ## Auth & session (YouTube OAuth) - **Publishing status, not phone verification, is what kills sessions.** An OAuth project in **Testing** with user type *External* expires every authorization **and its refresh token after 7 days** — so every user re-signs in weekly. **Publish app** (→ *In production*) removes that expiry. It does **not** require Google's verification review, but an unverified production app shows the "unverified app" warning and carries a **100-new-user cap for the lifetime of the project, which cannot be reset** — so publish when strangers are expected, not while testing. Verification review only removes the warning and the cap. Source: , and `commercial.md` → Entitlement is unrelated. - **A channel must be verified before it can stream.** YouTube's feature tiers: *standard* (nothing) → *intermediate* (**phone verification**: >15 min uploads, **live streaming on a computer**, custom thumbnails) → *advanced* (channel history **or** ID/video verification: higher daily limits, clickable description links, chapters, pin comments, monetization application). Live streaming is **intermediate**, not advanced — a user without it gets a live-streaming-disabled error from the API. Map that error in the go-live path to one sentence: *"Verify your channel at youtube.com/verify, then try again — it takes a minute."* A raw Google error here reads as "the app is broken". Source: . - `Helpers/OAuthCredentials.cs` holds the real ClientId/ClientSecret. Auth is complete and the session **persists via Windows DPAPI** (`Helpers/TokenStore.cs` → `%APPDATA%\ytLlive\ytLlive.auth`, CurrentUser scope), reloaded best-effort at startup with a proactive refresh of a near-expiry access token. - Sign-in / Change Account lives **inside the Start Stream dialog** — a two-state flow, no separate Connect button. Sign-out is **explicit only**: `StopStream()` does NOT clear the session ("stopping a recording leaves the creator signed in", 2026-08-29). - `YouTubeAuthService` takes an optional `HttpClient` + `sessionChanged` + `openBrowser` callback (test seams + save hook; services are constructed in `MainViewModel`). - **Change Account must never sign out first** (fixed 2026-09-27): `SignOutYouTubeAsync` → `TokenStore.Clear()` **deletes** `ytLlive.auth`, so cancelling in the Google chooser left the creator logged out of a working account. It is now just `await SignInAsync()` — success overwrites the token through the `sessionChanged` hook; cancel/failure leaves the session untouched. - **The OAuth flow releases its loopback port on retry:** an abandoned browser attempt holds `localhost:8765` for its 5-minute timeout, so `AuthenticateAsync` cancels and awaits the abandoned attempt before binding, serialised by a `SemaphoreSlim` (`_authGate`) — without the gate, two overlapping calls both see a free port and race to bind it. - Test seam: `YouTubeAuthService.SetSession()` is public — inject a channel with a future `TokenExpiry` so `EnsureToken()` passes offline, paired with `HttpClient(new StubHandler())`.