Four things derived this session superseded what the plan said twenty-four hours earlier,
and the docs are corrected where each one died:
**Licensing needs no server.** Polar's customer-portal endpoints authenticate on the key
itself plus the public organization_id; the org access token is only needed to create
products. Channel binding rides in `conditions: {channel_id}`, re-checked on every
validate — so the "claim page" (a hosted OAuth flow that wrote the UC id onto the key)
and the key database it implied are deleted. Device management, key rotation and
subscription auto-revocation are Polar's; four states, one lease, no callback endpoint.
**Signing is deferred until two subscribers.** It never removes the day-one SmartScreen
warning (EV's instant-bypass died in 2024), so buying it before revenue buys only the
publisher line. Renamed to Azure Artifact Signing, $9.99/mo Basic, and it requires a
paid Azure subscription. Year-one fixed cost drops from ~$135 to ~$15.
**The Store account is free.** Microsoft waived the $19 individual fee and removed the
$99 company fee in May 2026, which makes the Store the only zero-cost route to a
Microsoft-signed install — the free answer to SmartScreen, noted beside the paid one.
**Support is a filter, not a promise.** Email, best effort, no SLA, refunds on request
without argument; no training service, no helpdesk. A repeated question becomes a
training video on @llamacasty, which is content and monetization instead of a ticket.
GitHub issues on the releases repo are the deliberate friction.
Also settled: binaries live in a second Gitea repo (`llamacasty-releases`) push-mirrored
to a public GitHub repo — issues and assets public, code private, one publish direction
(a git mirror carries refs, not GitHub Release assets); `@llamacasty` is
standard-verified with intermediate features, so live streaming, >15 min uploads and
custom thumbnails work today and no SIM or ID is needed; and OAuth comes out of Testing
mode by publishing the app (100-user lifetime cap, no 7-day token death).
TASKS.md now opens with the four-day surge slice — artifact shape, output gate,
entitlement seam, real-machine day — because those are what the first public release
waits on. HANDOFF.md rewritten. MyMistakes.md gains the Polar recipe and the
"verify means three different things" entry. MARCOM.md gains the support→content loop
and the surface roles.
294 lines
26 KiB
Markdown
294 lines
26 KiB
Markdown
# 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`](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 `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())`.
|