Files
LlamaCasty/docs/ai/youtube-api.md
T

242 lines
22 KiB
Markdown

# 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:
<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.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())`.