feat(twitch): Enhanced Broadcasting — the negotiated config, the VOD audio track, and the GPU gate nobody expected (#326) - #328
Conversation
… 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
There was a problem hiding this comment.
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/multitrackwith a client forGetClientConfiguration, 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.
| resp, err := c.http().Do(httpReq) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("ask Twitch for a multitrack configuration: %s", scrub(err.Error(), streamKey)) | ||
| } |
| 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 |
|
Decision on the open question: annotate-only, confirmed. A rendition divergence reports and does not block. 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
That confirms three things at once: a supported GPU is mandatory with no software path, 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 |
…broadcasting # Conflicts: # CHANGELOG.md
|



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: theGetClientConfigurationclient, the configuration model, the verdict, and the endpoint resolution. No existing file changes behaviour;internal/oauth/capabilities.gois 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/GetClientConfigurationon 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 isstatus.result. And the trap underneath the trap: on success thestatusobject 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.authenticationis 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: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}]}—
bthe aggregate bitrate,tthe video tracks,athe audio tracks, the second carrying"v":1for VOD and"t":1for 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 — andTestTheLiveEndpointMintsAStreamKeyThatIsNotTheOneItWasGivenexists because a fixture could never have caught that.Two of the three "what is not known" items in #326, answered:
audio_configurations.vodpopulated for all accounts or only some? It is populated, and it depends on nothing butpreferences.vod_track_audio— not the account, not a token. Sendingtruereturns one AAC track attrack_id1; sendingfalsereturns an empty list.maximum_video_tracks: 1returns 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:
status.html_en_usgpufieldvendor_id: 0There 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
Askin 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_bitrateof 2500 kbps was simply ignored and the ladder still totalled 9000 — the difference is reported byReconcileand never silently applied, following the rule already written intoservices.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.Fetchreturns is scrubbed, on every path — including the ones it did not construct: a*url.Errorcarrying 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.TestTheStreamKeyGoesInTheBodyAndNeverInTheURLpins it.gitleaks protect --stagedis clean. It flagged one of my own synthetic test literals for looking too much like a real key — I renamed the literal tothis-value-must-never-appear-in-a-lograther 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):TestTheLiveEndpointRefusesWithHTTP200AndAnEmptyLadder— the central claim, asked as the GPU-less host this will most often run on.TestTheLiveEndpointGrantsAVODAudioTrackAlongsideASingleVideoTrack— the two Implement Enhanced Broadcasting (IVS Multitrack Video): negotiated config, global-contribute ingest, and the VOD audio track #326 unknowns, plus that the ladder still follows the canvas.TestTheLiveEndpointMintsAStreamKeyThatIsNotTheOneItWasGiven— the fact no fixture can stand in for.They do not skip.
internal/multitrackis not ininternal/testenv/testdata/skips.json, so at.Skipwould fail the ratchet — which is the right pressure. An unreachable endpoint logs and returns, andPOLYEMESIS_REQUIRE_NET=1turns that into a failure, the shapePOLYEMESIS_REQUIRE_FFMPEGestablished. No counts changed and not.Skipwas 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,clientConfigIdmerging, redaction, and everyReconciledivergence.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
/tmpfile backup, confirmgit diff --statclean. 24 mutations, 24 killed. The harness asserted=== RUN <name>appeared in the output, so a mistyped-runpattern 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— shorteningkeyPlaceholderfrom/{stream_key}to{stream_key}— survived, because thestrings.TrimRightinResolveabsorbs 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.copyProblemsrefuses 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:
Targetinstead ofdb.Destination.Target().Resolvereturns 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.routing.Profilemix totrack_id1 in the FFmpeg output.internal/routingalready compiles a per-destination mix, so the mixing half exists; the muxing half is the-mapwork inffmpeg.DestinationArgs, and it needs the E-RTMP multitrack path that onlyinternal/rtmpservercurrently uses on ingest.encoder_configurations—typeis an OBS encoder id (obs_nvenc_h264_tex) andsettingsis 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.Capabilitiesis 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 -lclean ·go vet ./...clean ·go build ./...clean ·go test ./...full suite passing ·gitleaks protect --stagedclean · no new shell, so noshellcheck· nointernal/oauth/capabilities.gochange, so no UI/docs drift guards · not.Skipadded,skips.jsonuntouched.https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX