Files
LlamaCasty/docs/ai/youtube-api.md
T
gramps a311c8feb1 docs(ai): partition ai.md into five subsystem files (verbatim moves)
ai.md 1,308 → 228 lines. No prose rewritten: every block was moved as-is so
nothing could be lost in translation, then replaced by a pointer.

New files:
- docs/ai/environment.md — LAN/SSH + Gitea push recipe, Windows dotnet
  build/run, test-suite map, hermetic real-MainWindow test rule
- docs/ai/product.md — brand/naming, AI transparency, GPL licensing
  prohibitions, free-forever monetization, auth/account assumptions,
  packaging & distribution
- docs/ai/youtube-api.md — go-live ordering, the broadcast-binding fix, the
  private-only lock, health parsing, end-stream pre-flight, Test mode,
  chat insert shape
- docs/ai/overlays.md — social bar, native alert box (animations, clip,
  ticker), chat box conventions
- docs/ai/ui-surface.md — control surface, scene strip, preview rail,
  hotkeys, broadcast drawer, toasts, transitions, brand logo, launch
  geometry, theme/properties conventions

ai.md keeps the response/derivation rules, the architecture table, the four
existing invariant stubs, and one pointer per moved subsystem.
docs/ai/index.md routes every file with a "read when" column.

References falsified by the move were fixed in the same commit: AGENTS.md
build note → environment.md → Run, and the "Related" footers in
frame-pipeline.md / output.md / capture.md now point at the owning files.

Docs-only unit: scope-check run, no build (no source touched).
2026-10-29 11:05:53 -07:00

23 KiB

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.

✅ 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 textMessageEvents — 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.