242 lines
22 KiB
Markdown
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())`.
|