Skip to content

feat(twitch): Enhanced Broadcasting — the negotiated config, the VOD audio track, and the GPU gate nobody expected (#326) - #328

Merged
rainmanjam merged 2 commits into
mainfrom
feat/twitch-enhanced-broadcasting
Aug 14, 2026
Merged

feat(twitch): Enhanced Broadcasting — the negotiated config, the VOD audio track, and the GPU gate nobody expected (#326)#328
rainmanjam merged 2 commits into
mainfrom
feat/twitch-enhanced-broadcasting

Conversation

@rainmanjam

Copy link
Copy Markdown
Owner

Closes part of #326. Nothing publishes through this yet — what is and is not built is enumerated below, and the boundary is where it is on purpose.

New package internal/multitrack: the GetClientConfiguration client, the configuration model, the verdict, and the endpoint resolution. No existing file changes behaviour; internal/oauth/capabilities.go is untouched, so no drift guards are in play.

What the live endpoint actually said

Every fact below came from a real POST to https://ingest.twitch.tv/api/v3/GetClientConfiguration on 2026-08-13, with no credential of any kind. That the endpoint answers unauthenticated is what made all of this testable.

A refusal arrives as HTTP 200. Every response observed — valid, invalid, unsupported hardware, unparseable schema version — was 200. The verdict is status.result. And the trap underneath the trap: on success the status object is absent entirely, rather than present saying "success". So a client that reads the status code has read the wrong field, and a client that treats "no status" as an error has read it backwards.

authentication is the stream key, not an OAuth token. #326 expected this to need a connected account. It does not. And the field is not the decoration it looks like on a refusal, where it is a plain echo of what was sent — on a successful negotiation Twitch mints a new 312-character key:

v1_<64 hex signature>_<8 hex>_<hex-encoded manifest>_<the original key>

whose manifest hex decodes to the ladder just agreed:

{"v":1,"b":4820,"t":[{"w":1280,"h":720,"b":4500,"c0":1}],
 "a":[{"b":160},{"b":160,"v":1,"t":1}]}

b the aggregate bitrate, t the video tracks, a the audio tracks, the second carrying "v":1 for VOD and "t":1 for its track id. The negotiated configuration travels to the ingest inside the key. A client that published with the operator's own key would connect and send a stream the ingest never agreed the shape of. I initially read this field as an echo — it looks exactly like one until you get a successful negotiation — and TestTheLiveEndpointMintsAStreamKeyThatIsNotTheOneItWasGiven exists because a fixture could never have caught that.

Two of the three "what is not known" items in #326, answered:

  • Is audio_configurations.vod populated for all accounts or only some? It is populated, and it depends on nothing but preferences.vod_track_audio — not the account, not a token. Sending true returns one AAC track at track_id 1; sending false returns an empty list.
  • Does Enhanced Broadcasting require the multi-rendition video path? No. maximum_video_tracks: 1 returns exactly one rendition and both audio tracks. One video track plus a live and a VOD audio track is a configuration Twitch will issue — which is the only reason this is reachable for polyemesis, which publishes one video track to an RTMP destination.

(The third — rate limits — I did not establish. Several dozen requests over an hour drew no throttling, which is not the same as knowing the limit.)

And one thing #326 did not ask, which changes the shape of the feature: Twitch refuses a client with no supported GPU. By name, each of these measured:

sent status.html_en_us
no gpu field "did not send GPU Information which is required by GetClientConfiguration"
vendor_id: 0 "sent an invalid value for GPU Vendor ID (0)"
Intel UHD 770 "Your GPU is not currently supported by Twitch Enhanced Broadcasting"
AMD RX 7900, old driver "Your GPU driver version is not supported… Upgrade to Mesa 24.0.0 or newer"
NVIDIA RTX 3080 (no status object — full config returned)

There is no software-encoder path through this endpoint. A headless polyemesis host encoding with libx264 has nothing to send that Twitch will accept, so the fallback to the ordinary ingest is the normal outcome on that host, not the exceptional one. That reorders the priorities in #326's scope list: item 5 is load-bearing, not a safety net.

How the negotiated config reconciles with the operator's rendition

This is the product decision #326 raises, so it is written up on Ask in the source rather than left implicit.

The operator's settings are the input to the negotiation, not something it overrides. That is not a diplomatic compromise, it is what the endpoint does, and it was measured: a 1920x1080@30 canvas returns a 1080/720/360 ladder; a 1280x720@60 canvas returns 720/480/360. The ladder is derived from the canvas the client says it is producing, so an operator who picks 720p gets a 720p negotiation. Their choice is honoured by being asked in the first place.

Where Twitch's answer differs anyway — and it does; a maximum_aggregate_bitrate of 2500 kbps was simply ignored and the ladder still totalled 9000 — the difference is reported by Reconcile and never silently applied, following the rule already written into services.URLProblem: "Offered rather than applied: silently rewriting what somebody typed is how you get a bug report that says 'it changed my URL'."

So: the operator's rendition decides what we ask for; Twitch decides what it will accept; any gap is shown rather than resolved on their behalf. I'd welcome a second opinion on the one case that is genuinely arguable — whether a rendition divergence should be able to block the multitrack path rather than just annotate it.

Security

Both directions carry a credential. The request body is the stream key by definition, and the response echoes or mints one — so a response body is as dangerous to log as a request body, which is not obvious and is how this would have gone wrong.

  • Config.Redacted() is the only shape of a configuration fit to print. It reallocates the endpoint slice rather than aliasing it, so redacting for a log cannot reach back and blank the key the caller is about to publish with.
  • Every error Fetch returns is scrubbed, on every path — including the ones it did not construct: a *url.Error carrying the request URL, a decode error carrying a body fragment. That is the a refused destination writes its stream key to server.log on every retry #310 / test(automod): a suite that talks to a real endpoint, and the endpoint's own key was in server.log #324 class arriving from the response side.
  • The key goes in the body, never the URL. TestTheStreamKeyGoesInTheBodyAndNeverInTheURL pins it.
  • No key reaches argv, a file, or a log. The live tests send a synthetic key that belongs to nobody; there is no credential in the tree.
  • gitleaks protect --staged is clean. It flagged one of my own synthetic test literals for looking too much like a real key — I renamed the literal to this-value-must-never-appear-in-a-log rather than allowlisting it, since the rule was right.

A note for whoever wires this up: the minted key must be registered as a secret in its own right. Scrubbing the operator's original key from a log leaves the signature and the manifest behind, because the original is only the last segment of the minted one.

Verified against the live API vs. against fixtures

Live (three tests in live_test.go, which really do make an HTTP request every run — verified with -v, not assumed):

They do not skip. internal/multitrack is not in internal/testenv/testdata/skips.json, so a t.Skip would fail the ratchet — which is the right pressure. An unreachable endpoint logs and returns, and POLYEMESIS_REQUIRE_NET=1 turns that into a failure, the shape POLYEMESIS_REQUIRE_FFMPEG established. No counts changed and no t.Skip was added. The honest caveat: offline, those three tests assert nothing and say so in their output.

Fixtures (testdata/*.json, both pasted verbatim from live calls, config_ids as Twitch minted them): the parsing, the verdict table, endpoint resolution, clientConfigId merging, redaction, and every Reconcile divergence.

Not verified at all, and named as such: anything requiring a real Twitch account. Whether a real key changes the ladder, whether an account flag gates the VOD track, and — the big one — whether the second track survives to playback, which is #320's question and still needs a live channel and an HLS URL.

Mutations run

Every test was mutation-verified: break the behaviour, watch the specific named test fail, restore from a /tmp file backup, confirm git diff --stat clean. 24 mutations, 24 killed. The harness asserted === RUN <name> appeared in the output, so a mistyped -run pattern could not read as a pass. Each mutation is recorded in the doc comment of the test it kills.

One is worth calling out because it did not go as written. My first mutation for TestResolveSplitsTheTemplateWherePolyemesisSplitsAPublishURL — shortening keyPlaceholder from /{stream_key} to {stream_key}survived, because the strings.TrimRight in Resolve absorbs the leftover slash. That is the constant being robust rather than the test being weak, but I had already written the mutation into the doc comment before running it. I replaced it with one that does kill the test (returning the whole template as the server — the naive implementation this split exists instead of), and the comment now records both: the mutation that works, and the more obvious one that does not and why. I would rather that be visible than tidy.

What I did not build

internal/ffmpeg's RTMP egress is deliberately single-track today — db.AudioEncoding.copyProblems refuses a copied multitrack RTMP destination in as many words, citing #141 as the open measurement. Lifting that is a real change to the encoder path with its own measurement to do, and doing it thinly inside this PR would have produced a publish path that looked finished and had never sent two tracks to anything.

So, scoped out and not started:

  1. Engine wiring — calling this at go-live and publishing through Target instead of db.Destination.Target(). Resolve returns exactly the {URL, Key} pair that composition needs, so the wiring is small; the decision about when to attempt a negotiation (per broadcast? cached per destination?) is not.
  2. The second audio encoder — routing a second routing.Profile mix to track_id 1 in the FFmpeg output. internal/routing already compiles a per-destination mix, so the mixing half exists; the muxing half is the -map work in ffmpeg.DestinationArgs, and it needs the E-RTMP multitrack path that only internal/rtmpserver currently uses on ingest.
  3. Honouring encoder_configurationstype is an OBS encoder id (obs_nvenc_h264_tex) and settings is OBS's property bag (keyint_sec, rate_control, multipass). Nothing here maps them to FFmpeg, because a table guessed from one observed encoder would look authoritative and not be. The values are parsed and exposed; translating them is its own task.
  4. Hardware detectionCapabilities is supplied by the caller. Reading a GPU's PCI vendor id is per-platform work with its own failure modes, and a package that guessed would send a plausible inventory that was not this machine's. Given the GPU findings above, this is the gate on whether the feature is reachable on a given host at all, and it deserves its own issue.

Items 1–3 are the rest of #326 and I'd suggest they stay on it. Item 4 is new and falls out of the GPU finding.

Checks

gofmt -l clean · go vet ./... clean · go build ./... clean · go test ./... full suite passing · gitleaks protect --staged clean · no new shell, so no shellcheck · no internal/oauth/capabilities.go change, so no UI/docs drift guards · no t.Skip added, skips.json untouched.

https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX

… refuses a GPU-less host (#326)

Twitch answers GetClientConfiguration with HTTP 200 whether it is agreeing
or refusing; the verdict is status.result, and on success the status object
is absent rather than present saying "success". A client that reads the
status code reads the wrong field, so Config.Verdict reads the right one --
and refuses a configuration with an empty ladder whatever the status says,
because every measured refusal came back empty and on a response carrying
no status at all the emptiness is the only signal left.

Three things the issue recorded as unknown, now measured against the live
endpoint with no credential:

  - audio_configurations.vod is populated, and depends on nothing but
    preferences.vod_track_audio -- not on the account, not on a token.
  - a multi-rendition video ladder is NOT a precondition of the second audio
    track: maximum_video_tracks 1 returns one rendition and both audio
    tracks, which is what makes this reachable for polyemesis at all.
  - `authentication` is the stream key, not an OAuth token. On a successful
    negotiation Twitch mints a new 312-character key with the agreed ladder
    hex-encoded and signed inside it and the original key as its last
    segment. Publishing with the operator's own key would connect and send a
    stream the ingest never agreed the shape of.

And one the issue did not ask: Twitch refuses a client with no supported
GPU, by name. There is no software-encoder path, so on a headless host the
fallback to the ordinary ingest is the normal outcome, not the exceptional
one.

Both directions carry a credential, so Config.Redacted is the only shape fit
to print and every error the client returns is scrubbed -- the class of
defect in #310 and #324, arriving here from the response side as well.

Nothing publishes through this yet. The engine wiring and the second audio
encoder are scoped out, honestly, in the PR.

Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
Copilot AI lite review requested due to automatic review settings August 13, 2026 22:37

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new internal/multitrack package that implements Twitch Enhanced Broadcasting (IVS Multitrack Video) configuration negotiation: request construction, response decoding, verdicting, endpoint/key resolution (including clientConfigId merging), and redaction/scrubbing safeguards. This is foundational work only (no publishing path wired yet), plus fixtures and live tests to pin measured endpoint behavior.

Changes:

  • Introduces internal/multitrack with a client for GetClientConfiguration, response model, verdict logic, and endpoint/key resolution.
  • Adds fixture-based tests and “live” network tests to validate measured Twitch behaviors (HTTP 200 refusals, VOD track negotiation, minted key behavior).
  • Documents the feature and measured behaviors in CHANGELOG.md.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
internal/multitrack/multitrack.go Response/request models, settings parsing, config redaction, and shared helpers/constants.
internal/multitrack/client.go HTTP client implementation and verdict interpretation.
internal/multitrack/request.go “Ask” model, request building, and reconciliation reporting for divergences.
internal/multitrack/endpoint.go Ingest endpoint selection and safe {stream_key} template resolution into {URL, Key} with clientConfigId.
internal/multitrack/client_test.go Unit tests for Fetch, verdict behavior, scrubbing/redaction, and fixture decoding.
internal/multitrack/request_test.go Unit tests for request construction, reconciliation, and settings parsing.
internal/multitrack/endpoint_test.go Unit tests for endpoint selection, template splitting, configId merging, and target redaction.
internal/multitrack/live_test.go Live endpoint tests (network-dependent behavior) validating measured protocol properties.
internal/multitrack/testdata/refused-no-gpu.json Fixture for a refusal response (HTTP 200 + status.result=error + empty ladder).
internal/multitrack/testdata/negotiated-one-rendition.json Fixture for a successful negotiation with one video rendition and live+VOD audio tracks.
CHANGELOG.md Changelog entry documenting the new multitrack negotiation package and its measured behaviors.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +83 to +86
resp, err := c.http().Do(httpReq)
if err != nil {
return nil, fmt.Errorf("ask Twitch for a multitrack configuration: %s", scrub(err.Error(), streamKey))
}
Comment on lines +106 to +110
var cfg Config
if err := json.Unmarshal(raw, &cfg); err != nil {
return nil, fmt.Errorf("decode the multitrack configuration response: %s", scrub(err.Error(), streamKey))
}
return &cfg, nil
@rainmanjam

Copy link
Copy Markdown
Owner Author

Decision on the open question: annotate-only, confirmed.

A rendition divergence reports and does not block. Reconcile stays as built — returning []Divergence, never applying — which is the services.URLProblem rule already written down elsewhere in the tree.

The reasoning, so it does not get re-argued: blocking would let an optional VOD track veto a working broadcast. That is the same trade refused for the pathless-URL warning in #313, where 11 of OBS's 540 ingest URLs genuinely have no application path and refusing outright would have broken a real setup. A warning that can stop a stream is not a warning.


Separately — the GPU finding is verified independently, and it is the most consequential thing in this PR. Re-run against the live endpoint from here, same schema version, varying only capabilities.gpu:

GPU sent result
none (headless server) errordid not send GPU Information which is required
Intel UHD 630 errorYour GPU is not currently supported
NVIDIA RTX 4080 successencoders=1, live=1, vod=1

That confirms three things at once: a supported GPU is mandatory with no software path, maximum_video_tracks: 1 is accepted so multi-rendition video is not a precondition, and the VOD track is provisioned purely by vod_track_audio: true.

The implication for this project is worth stating plainly in the PR body if it is not already: polyemesis is built to be installed on the operator's own server, and a rented VPS has no GPU. The staging box reports Intel Core Processor (Haswell, no TSX) — a QEMU model — and would be refused by name. Fallback to the ordinary ingest is the normal path here, not the exceptional one, which is the right way round for whoever wires this up.

@sonarqubecloud

Copy link
Copy Markdown

@rainmanjam
rainmanjam merged commit c7db212 into main Aug 14, 2026
27 checks passed
@rainmanjam
rainmanjam deleted the feat/twitch-enhanced-broadcasting branch August 14, 2026 04:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants