22 KiB
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.
✅ SOLVED 2026-10-04 — the broadcasts never went live because they were never BOUND.
The bug, in one line:
contentDetails.boundStreamIdonliveBroadcasts.insertdoes not bind the broadcast. Binding requires an explicitPOST 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@variableEvery field is real, and together they are conclusive:
bound=NONEwithautoStart=true. Both live in the samecontentDetailsobject 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, notready. 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 →
lifeCycleStatusstuck atcreated→enableAutoStarthas no bound stream to fire from → never live → close-out 403sinvalidTransitionbecausecreated → completeis not a legal edge (onlyready/liveare).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 fromPrepareAndStartLiveAsyncimmediately afterCreateBroadcastand 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).boundStreamIdwas removed from the insert body and thestreamIdparameter removed fromCreateBroadcast— a parameter that looks meaningful but cannot affect the outcome is what made the misdiagnosis possible. The old testCreateBroadcast_With_StreamId_Binds_Reusable_Stream_At_Insert, which asserted the disproven behaviour and therefore laundered a guess into a guarantee, is replaced byCreateBroadcast_Does_Not_Claim_To_Bind_And_Omits_BoundStreamIdplusBindBroadcastAsync_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.BindFailureMessagemaps each to its own text;liveBroadcastBindingNotAllowedmatters 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 withenableAutoStart=true,enableAutoStop=true,enableMonitorStream=false,selfDeclaredMadeForKids=false,latencyPreference=low. The encoder starting brings YouTube live.enableMonitorStream=falseis 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.insertat 14:17:04.577, ffmpegOutput #0, flvtortmp://a.rtmp.youtube.com/live2/5f6z-…at 14:17:05.686, and at 14:18:56.473 ffmpeg's own muxing summary readsvideo: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 leftready, and close-out earned 403invalidTransitionagain. 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") andenableAutoStart("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. ⇒LogBroadcastDiagnosticsAsyncrides the existing stream-health poll (no second timer) and writes one greppable line:lifeCycleStatus,privacyStatus,recordingStatus,boundStreamIdcompared against the stream id we are actually pushing (BoundMatches→yes/MISMATCH/NO-BINDING/unknown),boundStreamLastUpdateTimeMs,enableAutoStart/Stop,liveChatIdpresence, the stream's ownstatus.streamStatus+activeLiveChatId, and the CDN settings we declared. It is self-limiting —_broadcastSeenLivelatches onlive/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 byBroadcastBindingTests.BoundMatches_ComparesTheBroadcastAgainstTheStreamWePush, which pinsunknownfor 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.listreturns a list wrapper, so the broadcast lives atitems[0]. The original code descended intoitemsfor the stream but read the broadcast's response root, so every broadcast field came back?— andBoundMatchesthen dutifully reported a confidentboundMatches=NO-BINDINGfrom a parse that had never happened. Three lines of fabricated alarm. Two fixes, both load-bearing:FirstItem()descends intoitems[0]for both halves (and returnsdefaultwhen there is genuinely no item), and the line now printsboundMatches=unreadwhen the broadcast could not be read, so an absent parse can never be reported as a verdict. Guarded byBroadcastBindingTests.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=activeabout 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.
- ⚠️ The first version of this diagnostic LIED, and the first real run caught it (2026-10-04).
- 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.UpdateBroadcastfor 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.
UpdateBroadcastcurrently has no production caller; Day 2 wires it for live visibility changes, while development/test runs remain 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.
- Test mode remains Private.
BeginTestStreamforcesStreamVisibility = "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.categoryIdis video metadata, not aliveBroadcastfield; category selection, tag updates through the video API, and thumbnail upload remain follow-ups. Monetization enablement (if ever needed) rides the reward-events chain vialiveBroadcasts.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 beforeFramePump.StartAsync, which reads it once). Failure →StreamStatus.Error, never a crash;StopStreamclears_currentBroadcastId. - Variable reusable stream (shipped 2026-08-16) —
GetOrCreateReusableStreamAsynclistsliveStreams?mine=trueand reuses the existingcdn.isReusablestream, inserting once per channel (cdn.resolution=variable,cdn.frameRate=variable,isReusable=true) only on first use; the ingestion URL is cached viaLayoutStoreSettings (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)pollsliveStreams.list?part=status; render nothing ongood/ok/noData, surface a banner only onconfigurationIssues[]withwarning/errorseverity. The decision is the pureServices/StreamHealthReporter.BannerFor(null text = no banner; error beats warning). The VM polls every 2s until YouTube confirmslifeCycleStatus=live, then every 30s (DispatcherTimer_healthPollTimer, first poll immediately after the frame pump starts); the top bar turns green only on that confirmedlivestate. It clears on End viaResetHealth; poll failures log only. Parsing (fix 2026-09-22): the real API nestsstatus.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/configurationIssuesand tolerates the old flat-string shape. UI = a full-width banner strip under the top bar,HealthIssueBannertext +HealthIssueBackground(amber#b8860bwarning / dark red#8f1f1ferror), hidden byNotNullToVis. 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.EndBroadcastAsyncPOSTsliveBroadcasts/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 403invalidTransition— 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-flightsliveBroadcasts.list→status.lifeCycleStatusand only transitions fromlive/testing, skipping silently otherwise (enableAutoStopfinishes 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+enableDvrdefault 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, noBroadcastForm.CaptureGoLive"last live" stamp, no recording).IsTestStreamflips the face: gold top bar + glow (#9c6f1c), a TEST badge, and the End button reads "End Test";StopStreamclears 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 inMainViewModel.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 inMainWindowhad 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 issnippet.liveChatId—contentDetailshas NO liveChatId property — andGetBroadcastLiveChatIdAsync(broadcastId, maxAttempts=10, delayMs=2000)reads part=snippet and polls with a bounded retry, andPrepareAndStartLiveAsyncstarts 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 caughtliveChatId=presentwhilelifeCycleStatus=created— not live, not evenready. The officialGetLiveChatId.javasample testsbroadcastStatus == activebefore 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 neverreadyat all, they werecreatedand 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 REALliveChat/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'sMessageReceivedseam, markedChatMessage.IsSimulated. API fact (researched + hard constraint):liveChat/messages.insertonly createstextMessageEvents — 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 includesnippet.type="textMessageEvent"alongsideliveChatIdandtextMessageDetails.messageText— omitting type returns400 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). Scopesyoutube+youtube.force-sslalready 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:
https://support.google.com/cloud/answer/15549945, 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: https://support.google.com/youtube/answer/9890437.
Helpers/OAuthCredentials.csholds 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). YouTubeAuthServicetakes an optionalHttpClient+sessionChanged+openBrowsercallback (test seams + save hook; services are constructed inMainViewModel).- Change Account must never sign out first (fixed 2026-09-27):
SignOutYouTubeAsync→TokenStore.Clear()deletesytLlive.auth, so cancelling in the Google chooser left the creator logged out of a working account. It is now justawait SignInAsync()— success overwrites the token through thesessionChangedhook; cancel/failure leaves the session untouched. - The OAuth flow releases its loopback port on retry: an abandoned browser attempt holds
localhost:8765for its 5-minute timeout, soAuthenticateAsynccancels and awaits the abandoned attempt before binding, serialised by aSemaphoreSlim(_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 futureTokenExpirysoEnsureToken()passes offline, paired withHttpClient(new StubHandler()).