From b7df2c6c33530987d5781dbf03aecd2642be04dd Mon Sep 17 00:00:00 2001 From: Shannon Atkinson Date: Thu, 13 Aug 2026 15:36:13 -0700 Subject: [PATCH] feat(twitch): Enhanced Broadcasting negotiates a VOD audio track, and 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 --- CHANGELOG.md | 48 ++ internal/multitrack/client.go | 235 +++++++++ internal/multitrack/client_test.go | 459 ++++++++++++++++++ internal/multitrack/endpoint.go | 186 +++++++ internal/multitrack/endpoint_test.go | 304 ++++++++++++ internal/multitrack/live_test.go | 267 ++++++++++ internal/multitrack/multitrack.go | 453 +++++++++++++++++ internal/multitrack/request.go | 269 ++++++++++ internal/multitrack/request_test.go | 288 +++++++++++ .../testdata/negotiated-one-rendition.json | 72 +++ .../multitrack/testdata/refused-no-gpu.json | 27 ++ 11 files changed, 2608 insertions(+) create mode 100644 internal/multitrack/client.go create mode 100644 internal/multitrack/client_test.go create mode 100644 internal/multitrack/endpoint.go create mode 100644 internal/multitrack/endpoint_test.go create mode 100644 internal/multitrack/live_test.go create mode 100644 internal/multitrack/multitrack.go create mode 100644 internal/multitrack/request.go create mode 100644 internal/multitrack/request_test.go create mode 100644 internal/multitrack/testdata/negotiated-one-rendition.json create mode 100644 internal/multitrack/testdata/refused-no-gpu.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 059e4298..0e718c7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,54 @@ its first tagged release. a rename, a routing change — deliberately leaves the sealed bytes alone rather than destroying a key the right key file would have recovered. +### Added + +- **`internal/multitrack` speaks Twitch Enhanced Broadcasting**, the negotiated + configuration behind what Amazon calls IVS Multitrack Video — the one path a + platform has published that takes a *second* audio track and says what it is + for. It fetches a configuration from Twitch, reads the verdict, and resolves + the ingest endpoint and stream key it hands back. Nothing publishes through it + yet; see below for what is deliberately not built. + + Four things were measured against the live endpoint rather than assumed, and + each one changes what the code has to do: + + - **A refusal arrives as HTTP 200.** Every response — valid, invalid, + unsupported hardware, unparseable schema version — was `200`, with the + verdict in `status.result`. A successful negotiation omits the `status` + object entirely rather than saying `"success"`, so a client that reads the + status code, or that treats an absent status as an error, has read the + wrong field. + - **`authentication` is the stream key, not an OAuth token.** This does not + depend on a connected account, which is what the issue expected. Better: + on a *successful* negotiation Twitch mints a new 312-character key that + carries the agreed ladder inside it, hex-encoded and signed, with the + operator's original key as its last segment. Publishing with the operator's + own key instead would connect and send a stream the ingest never agreed the + shape of. + - **A second audio track does not require a multi-rendition video ladder.** + Asking for `maximum_video_tracks: 1` returns exactly one rendition *and* + both audio tracks — live on track 0, VOD on track 1. That is what makes the + feature reachable at all for polyemesis, which publishes one video track to + an RTMP destination. + - **Twitch refuses a client with no supported GPU**, by name: no GPU + information, a vendor ID of zero, an unrecognised vendor, an out-of-date + driver. There is no software-encoder path through this endpoint, so on a + headless host encoding with libx264 the fallback to the ordinary ingest is + the *normal* outcome and not the exceptional one. + + The operator's own settings are **the input to the negotiation, not something + it overrides** — the returned ladder is derived from the canvas the client + says it is producing, so an operator who picks 720p gets a 720p negotiation. + Where Twitch's answer differs anyway (a `maximum_aggregate_bitrate` ceiling + was simply ignored), the difference is reported and never silently applied, + following the rule already written into `services.URLProblem`. + + Both the request and the response carry a credential, so neither may be + logged as it stands: `Config.Redacted` is the only shape of a configuration + fit to print, and every error the client returns is scrubbed of the key. + ([#326](https://github.com/rainmanjam/polyemesis/issues/326)) + ## [0.7.0] — 2026-08-12 ### Security diff --git a/internal/multitrack/client.go b/internal/multitrack/client.go new file mode 100644 index 00000000..e3c995ac --- /dev/null +++ b/internal/multitrack/client.go @@ -0,0 +1,235 @@ +package multitrack + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// Client fetches a negotiated configuration. The zero value talks to the real +// Twitch endpoint over the default HTTP client, which is what production wants. +type Client struct { + // HTTP is the transport. Nil means defaultHTTP, whose timeout is the point: + // this call happens at go-live, between the operator pressing the button and + // anything reaching a viewer, so a hung platform must not hold the broadcast + // open indefinitely. obs-studio allows five seconds for the same call. + HTTP *http.Client + // BaseURL overrides ConfigURL. It is the ONLY seam here and it exists for + // tests, in the shape internal/oauth/endpoints.go settled on: one field that + // moves every call this type makes, because a partially redirected client is + // one that looks stubbed and is not. Nothing at runtime sets it. + BaseURL string +} + +// defaultTimeout is deliberately short. See Client.HTTP. +const defaultTimeout = 10 * time.Second + +var defaultHTTP = &http.Client{Timeout: defaultTimeout} + +func (c *Client) http() *http.Client { + if c.HTTP != nil { + return c.HTTP + } + return defaultHTTP +} + +func (c *Client) url() string { + if c.BaseURL != "" { + return c.BaseURL + } + return ConfigURL +} + +// Fetch negotiates a configuration. +// +// The stream key is a separate argument rather than a field on req that the +// caller fills in, and that is not ceremony. It is the one value in this +// exchange that must never be logged, and having exactly one function put it +// into the body means there is exactly one place to audit. It also gives Fetch +// the literal it needs in order to scrub the key out of every error it returns +// -- including the ones it did not construct, like a transport error carrying a +// URL, or a JSON decode error carrying a fragment of the response. +// +// A non-nil Config with a Refused verdict is a SUCCESSFUL call: Twitch answered +// and said no. The error return is for "no answer at all". Callers distinguish +// them because the two demand different things -- a refusal is reported to the +// operator and the ordinary ingest is used; a transport failure is the same +// outcome but is not the operator's to fix. +func (c *Client) Fetch(ctx context.Context, streamKey string, req Request) (*Config, error) { + req.Authentication = streamKey + req.Service = ServiceIVS + req.SchemaVersion = SchemaVersion + + body, err := json.Marshal(req) + if err != nil { + // Cannot carry the key in practice -- json.Marshal fails on unsupported + // types, not on values -- but scrubbed anyway, because "cannot in + // practice" is what every one of these leaks was before it happened. + return nil, fmt.Errorf("build multitrack configuration request: %s", scrub(err.Error(), streamKey)) + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url(), bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("build multitrack configuration request: %s", scrub(err.Error(), streamKey)) + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Accept", "application/json") + + resp, err := c.http().Do(httpReq) + if err != nil { + return nil, fmt.Errorf("ask Twitch for a multitrack configuration: %s", scrub(err.Error(), streamKey)) + } + defer resp.Body.Close() + + // Bounded. The measured responses are around 1-3 KB; a megabyte is four + // hundred times that and still cannot exhaust a go-live handler. + raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, fmt.Errorf("read the multitrack configuration response: %s", scrub(err.Error(), streamKey)) + } + + // A non-200 is still checked, even though the whole point of this package is + // that 200 is not the verdict. The two statements are not in tension: 200 + // does not mean yes, but a 5xx means Twitch never got as far as forming an + // opinion, and decoding one as a Config would produce a zero-valued + // negotiation that Verdict would then have to reason about. + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("Twitch returned %d to the multitrack configuration request: %s", + resp.StatusCode, scrub(snippet(raw), 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 +} + +// snippet bounds an error message. Borrowed in spirit from oauth.snippet; the +// body it truncates has already been established to contain a stream key, so +// the caller scrubs whatever comes back. +func snippet(b []byte) string { + s := strings.TrimSpace(string(b)) + if len(s) > 300 { + return s[:300] + "..." + } + return s +} + +// ---------------------------------------------------------------- the verdict + +// Verdict is what to DO with a configuration, which is not the same question as +// what Twitch's status field says. Three answers, because there are three +// different things a caller has to do. +type Verdict string + +const ( + // Negotiated: publish to this configuration. + Negotiated Verdict = "negotiated" + // Advisory: publish to this configuration, and show the operator what Twitch + // said. obs-studio puts a modal here and offers to abort; polyemesis has no + // operator standing at the machine when a scheduled broadcast starts, so it + // proceeds and reports. + Advisory Verdict = "advisory" + // Refused: do NOT publish to this configuration. Fall back to the ordinary + // ingest and say why. This is the common answer on a host with no supported + // GPU, which is most polyemesis hosts. + Refused Verdict = "refused" +) + +// Verdict reads the configuration and says what to do with it, with a sentence +// for the operator. The sentence is always populated for Advisory and Refused +// and is always empty for Negotiated -- there is nothing to say about a +// negotiation that worked. +// +// The mapping follows obs-studio's HandleGoLiveApiErrors, which is the only +// published interpretation of these values, with one addition and one +// substitution: +// +// - ADDITION: a configuration with no video renditions or no live audio track +// is Refused whatever the status field says. obs-studio reaches the same +// outcome further downstream, by throwing out of create_encoders when a +// list is empty. Deciding it here rather than there is what stops "status +// was absent, therefore success" from ever being the last word: EVERY +// measured refusal came back with empty lists, and on a response that +// somehow carried no status at all the empty lists are the only signal +// left. A guard that could pass while the thing it names is broken is the +// failure mode; this one cannot, because the emptiness IS the breakage. +// +// - SUBSTITUTION: obs-studio treats StatusResult::Error as fatal to the +// broadcast. Here it is Refused, which is fatal to the MULTITRACK PATH +// only. That is issue #326's scope item 5 and it is the right call for a +// server: refusing to go live at all because an optional second audio track +// could not be negotiated would trade a missing VOD mix for a missing +// broadcast. +func (c *Config) Verdict() (Verdict, string) { + if c == nil { + return Refused, "Twitch returned no multitrack configuration." + } + + verdict := Negotiated + advice := "" + + if c.Status != nil { + switch c.Status.Result { + case StatusError: + return Refused, c.explain("Twitch declined to configure Enhanced Broadcasting") + case "", StatusSuccess: + // The absent case and the explicit-success case are the same case. + // A successful negotiation omits the status object entirely -- that + // is measured, not assumed -- so the zero StatusResult has to mean + // success or every good response would be read as a refusal. + case StatusWarning: + // A warning with nothing in the ladder is a refusal wearing a softer + // word, and obs-studio treats it as fatal for that reason. The + // emptiness check below reaches the same verdict, so this case only + // has to set the advice. + verdict, advice = Advisory, c.explain("Twitch configured Enhanced Broadcasting with a warning") + default: + // A result string this build does not know. Proceeding with a note + // rather than refusing: obs-studio does the same, and a client that + // refused on an unrecognised value would break the moment Twitch + // added one. + verdict = Advisory + advice = fmt.Sprintf("Twitch returned an unrecognised status %q for Enhanced Broadcasting; "+ + "continuing with the configuration it sent.", c.Status.Result) + } + } + + // The addition. Deliberately after the switch, so it overrides Advisory too. + if len(c.EncoderConfigurations) == 0 { + return Refused, joinAdvice(advice, + "Twitch returned no video renditions, so there is nothing to publish to the multitrack ingest.") + } + if len(c.AudioConfigurations.Live) == 0 { + return Refused, joinAdvice(advice, + "Twitch returned no live audio track, so there is nothing to publish to the multitrack ingest.") + } + return verdict, advice +} + +// explain renders Twitch's own sentence, prefixed with ours so an operator +// reading a log knows which half is whose. The HTML is left as Twitch sent it +// rather than stripped: what arrives is a sentence with the occasional anchor, +// and a tag-stripper that got it wrong would silently eat the URL of the help +// page the sentence exists to point at. +func (c *Config) explain(prefix string) string { + if c.Status == nil || c.Status.HTMLEnUS == "" { + // Twitch is not obliged to send a reason and has a field for it that is + // optional. Saying so beats an empty string, which reads as a bug. + return prefix + ", and gave no reason." + } + return prefix + ": " + c.Status.HTMLEnUS +} + +func joinAdvice(existing, added string) string { + if existing == "" { + return added + } + return existing + " " + added +} diff --git a/internal/multitrack/client_test.go b/internal/multitrack/client_test.go new file mode 100644 index 00000000..2b457630 --- /dev/null +++ b/internal/multitrack/client_test.go @@ -0,0 +1,459 @@ +package multitrack + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +// loadFixture reads a response body captured VERBATIM from the live endpoint. +// +// Both fixtures in testdata were produced by an actual POST to +// ingest.twitch.tv on 2026-08-13 and pasted unedited; the config_id values are +// the ones Twitch minted for those calls. That provenance is the only thing +// that makes them worth asserting against -- a fixture somebody wrote by hand +// from a struct definition proves the struct definition. +func loadFixture(t *testing.T, name string) *Config { + t.Helper() + raw, err := os.ReadFile(filepath.Join("testdata", name)) + if err != nil { + t.Fatalf("read fixture %s: %v", name, err) + } + var cfg Config + if err := json.Unmarshal(raw, &cfg); err != nil { + t.Fatalf("decode fixture %s: %v", name, err) + } + return &cfg +} + +// serve stands up a stub at Client.BaseURL. It returns the request body the +// client sent, so a test can assert on what went out as well as what came back. +func serve(t *testing.T, status int, body string) (*Client, *[]byte) { + t.Helper() + var sent []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sent, _ = readAll(r) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(srv.Close) + return &Client{HTTP: srv.Client(), BaseURL: srv.URL}, &sent +} + +func readAll(r *http.Request) ([]byte, error) { + defer r.Body.Close() + buf := make([]byte, 0, 4096) + tmp := make([]byte, 1024) + for { + n, err := r.Body.Read(tmp) + buf = append(buf, tmp[:n]...) + if err != nil { + return buf, nil + } + } +} + +// TestAnHTTP200CarryingAStatusErrorIsARefusal is the central claim of this +// package: the HTTP status code is not the verdict. +// +// The body is the no-GPU refusal captured from the live endpoint, served with +// the 200 Twitch really sends. A client that looked at the status code would +// call this a success and publish to a configuration with nothing in it. +// +// Proven able to fail against the committed tree by changing the StatusError +// case in Config.Verdict (client.go) from `return Refused, ...` to +// `verdict, advice = Advisory, ...`, which made the test report +// "verdict = advisory, want refused". Restored from a /tmp copy; git diff +// --stat clean. +func TestAnHTTP200CarryingAStatusErrorIsARefusal(t *testing.T) { + cfg := loadFixture(t, "refused-no-gpu.json") + + // The premise. If the fixture ever stops being a 200-shaped refusal this + // test is asserting about something else, and would go on passing. + if cfg.Status == nil || cfg.Status.Result != StatusError { + t.Fatalf("fixture is not a status-error response: %+v", cfg.Status) + } + + verdict, advice := cfg.Verdict() + if verdict != Refused { + t.Errorf("verdict = %s, want %s", verdict, Refused) + } + // The refusal has to carry Twitch's own sentence, because it is the only + // explanation of the refusal that exists -- there is no error code. + if !strings.Contains(advice, "did not send GPU Information") { + t.Errorf("advice does not quote Twitch's reason: %q", advice) + } +} + +// TestAConfigWithNoRenditionsIsRefusedWhateverTheStatusSays covers the case the +// status field cannot: a response that says nothing wrong and contains nothing +// to publish. Without this, "status absent means success" would be the last +// word, and an empty ladder would be Negotiated. +// +// Proven able to fail against the committed tree by deleting the +// `if len(c.EncoderConfigurations) == 0` block from Config.Verdict +// (client.go), which made the test report "verdict = negotiated, want refused" +// for the no-status subtest. Restored from a /tmp copy; git diff --stat clean. +func TestAConfigWithNoRenditionsIsRefusedWhateverTheStatusSays(t *testing.T) { + live := []AudioEncoderConfig{{Codec: "aac", TrackID: 0, Channels: 2}} + rendition := []VideoEncoderConfig{{Type: "obs_nvenc_h264_tex", Width: 1920, Height: 1080}} + + for _, tc := range []struct { + name string + cfg Config + want Verdict + detail string + }{ + { + name: "no status and no renditions", + cfg: Config{ + AudioConfigurations: AudioConfigurations{Live: live}, + }, + want: Refused, + detail: "no video renditions", + }, + { + name: "explicit success but no renditions", + cfg: Config{ + Status: &Status{Result: StatusSuccess}, + AudioConfigurations: AudioConfigurations{Live: live}, + }, + want: Refused, + detail: "no video renditions", + }, + { + name: "renditions but no live audio track", + cfg: Config{ + EncoderConfigurations: rendition, + }, + want: Refused, + detail: "no live audio track", + }, + { + name: "a warning with a usable ladder is advisory, not fatal", + cfg: Config{ + Status: &Status{Result: StatusWarning, HTMLEnUS: "your driver is old"}, + EncoderConfigurations: rendition, + AudioConfigurations: AudioConfigurations{Live: live}, + }, + want: Advisory, + detail: "your driver is old", + }, + { + name: "a status this build does not know is advisory, not fatal", + cfg: Config{ + Status: &Status{Result: "someNewThing"}, + EncoderConfigurations: rendition, + AudioConfigurations: AudioConfigurations{Live: live}, + }, + want: Advisory, + detail: "someNewThing", + }, + { + name: "a complete configuration is negotiated with nothing to say", + cfg: Config{ + EncoderConfigurations: rendition, + AudioConfigurations: AudioConfigurations{Live: live}, + }, + want: Negotiated, + }, + } { + t.Run(tc.name, func(t *testing.T) { + verdict, advice := tc.cfg.Verdict() + if verdict != tc.want { + t.Errorf("verdict = %s, want %s (advice %q)", verdict, tc.want, advice) + } + if tc.detail == "" { + if advice != "" { + t.Errorf("advice = %q, want empty for a clean negotiation", advice) + } + return + } + if !strings.Contains(advice, tc.detail) { + t.Errorf("advice = %q, want it to mention %q", advice, tc.detail) + } + }) + } +} + +// TestTheStreamKeyGoesInTheBodyAndNeverInTheURL pins the one placement decision +// that would be invisible if it were wrong. A key in a query string reaches +// every proxy log between here and Twitch, and reaches OUR logs too, because +// *url.Error carries the request URL -- which is exactly how a key got into +// server.log in #310. +// +// Proven able to fail against the committed tree by changing Client.Fetch +// (client.go) to build the request against `c.url()+"?authentication="+ +// streamKey`, which made the test report "the request URL carries the stream +// key". Restored from a /tmp copy; git diff --stat clean. +func TestTheStreamKeyGoesInTheBodyAndNeverInTheURL(t *testing.T) { + const key = "live_424242_thisisthekeyandmustnotescape" + + var gotURL string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotURL = r.URL.String() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"meta":{},"encoder_configurations":[],"audio_configurations":{"live":[]}}`)) + })) + defer srv.Close() + c := &Client{HTTP: srv.Client(), BaseURL: srv.URL} + + if _, err := c.Fetch(context.Background(), key, NewRequest(Ask{Version: "test"})); err != nil { + t.Fatalf("Fetch: %v", err) + } + if strings.Contains(gotURL, key) { + t.Errorf("the request URL carries the stream key: %q", gotURL) + } + if strings.Contains(gotURL, "authentication") { + t.Errorf("the request URL carries an authentication parameter: %q", gotURL) + } +} + +// TestTheStreamKeyIsScrubbedFromEveryErrorFetchCanReturn walks each error path +// rather than sampling one, because the leak in #310 was on a path nobody had +// walked. A path added later without a scrub fails here. +// +// Proven able to fail against the committed tree by removing the `scrub(...)` +// wrapper from the transport-error return in Client.Fetch (client.go) -- the +// `unreachable host` subtest then reported "error text contains the stream +// key", because *url.Error had rendered the whole URL including the key the +// mutated code had put there. Restored from a /tmp copy; git diff --stat clean. +func TestTheStreamKeyIsScrubbedFromEveryErrorFetchCanReturn(t *testing.T) { + const key = "live_999_averydistinctivestreamkeyvalue" + + for _, tc := range []struct { + name string + // build returns a client whose Fetch will fail, and it is given the key + // so a stub can plant it in whatever it sends back. + build func(t *testing.T) *Client + }{ + { + name: "a 5xx whose body quotes the key back", + build: func(t *testing.T) *Client { + c, _ := serve(t, http.StatusInternalServerError, + `{"error":"we could not handle authentication `+key+`"}`) + return c + }, + }, + { + name: "a 200 whose body is not JSON at all", + build: func(t *testing.T) *Client { + c, _ := serve(t, http.StatusOK, `nope `+key+``) + return c + }, + }, + { + name: "an unreachable host", + build: func(t *testing.T) *Client { + // A server that is closed before use, so Do fails in the + // transport and returns a *url.Error carrying the URL. + srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + client := srv.Client() + base := srv.URL + srv.Close() + return &Client{HTTP: client, BaseURL: base} + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + c := tc.build(t) + _, err := c.Fetch(context.Background(), key, NewRequest(Ask{Version: "test"})) + if err == nil { + t.Fatal("Fetch succeeded; this case is supposed to fail, so the test below proves nothing") + } + if strings.Contains(err.Error(), key) { + t.Errorf("error text contains the stream key: %v", err) + } + if !strings.Contains(err.Error(), redactedPlaceholder) && + strings.Contains(tc.name, "quotes the key back") { + t.Errorf("the key was removed but left no trace it had been there: %v", err) + } + }) + } +} + +// TestRedactedRemovesTheKeyTwitchSendsBackWithoutTouchingTheOriginal is the +// other half of the leak defence. The response carries a key too, and the +// obvious way to log a config -- marshal it -- publishes it. +// +// The aliasing half matters as much as the redaction half: a Redacted that +// shared the endpoint slice would blank the key in the config the caller is +// about to publish with, turning a logging call into a broadcast failure. +// +// Proven able to fail against the committed tree by replacing the +// make+copy of out.IngestEndpoints in Config.Redacted (multitrack.go) with +// `out.IngestEndpoints = c.IngestEndpoints`, which made the test report +// "Redacted() reached back and blanked the caller's config". Restored from a +// /tmp copy; git diff --stat clean. +func TestRedactedRemovesTheKeyTwitchSendsBackWithoutTouchingTheOriginal(t *testing.T) { + const minted = "v1_sig_manifesthex_live_424242_theoriginalkey" + cfg := &Config{ + Status: &Status{Result: StatusError, HTMLEnUS: "no"}, + IngestEndpoints: []IngestEndpoint{ + {Protocol: "RTMPS", URLTemplate: "rtmps://h/app/{stream_key}", Authentication: minted}, + }, + } + + red := cfg.Redacted() + if red.IngestEndpoints[0].Authentication != redactedPlaceholder { + t.Errorf("Redacted() left the key in place: %q", red.IngestEndpoints[0].Authentication) + } + if cfg.IngestEndpoints[0].Authentication != minted { + t.Error("Redacted() reached back and blanked the caller's config") + } + + // The realistic failure is not reading the field, it is marshalling the + // whole thing into a log line, so assert on that. + blob, err := json.Marshal(red) + if err != nil { + t.Fatalf("marshal redacted config: %v", err) + } + if strings.Contains(string(blob), minted) { + t.Errorf("a marshalled redacted config still contains the key: %s", blob) + } +} + +// TestFetchAlwaysSendsTheServiceAndSchemaVersionTwitchRequires guards the two +// fields whose absence Twitch answers with a refusal naming them. They are set +// by Fetch rather than trusted from the caller's Request, so a caller that +// builds a Request by hand cannot omit them. +// +// Proven able to fail against the committed tree by deleting the +// `req.SchemaVersion = SchemaVersion` line from Client.Fetch (client.go), +// which made the test report `schema_version = "", want "2025-01-25"`. +// Restored from a /tmp copy; git diff --stat clean. +func TestFetchAlwaysSendsTheServiceAndSchemaVersionTwitchRequires(t *testing.T) { + c, sent := serve(t, http.StatusOK, + `{"meta":{},"encoder_configurations":[],"audio_configurations":{"live":[]}}`) + + // A Request built by hand with both fields deliberately wrong. + req := Request{Service: "WRONG", SchemaVersion: "1999-01-01"} + if _, err := c.Fetch(context.Background(), "k", req); err != nil { + t.Fatalf("Fetch: %v", err) + } + + var body struct { + Service string `json:"service"` + SchemaVersion string `json:"schema_version"` + Authentication string `json:"authentication"` + } + if err := json.Unmarshal(*sent, &body); err != nil { + t.Fatalf("decode what the client sent: %v", err) + } + if body.Service != ServiceIVS { + t.Errorf("service = %q, want %q", body.Service, ServiceIVS) + } + if body.SchemaVersion != SchemaVersion { + t.Errorf("schema_version = %q, want %q", body.SchemaVersion, SchemaVersion) + } + if body.Authentication != "k" { + t.Errorf("authentication = %q, want the stream key Fetch was given", body.Authentication) + } +} + +// TestTheLiveFixtureDecodesIntoEveryFieldTheFeatureDependsOn asserts the +// negotiated fixture parses into the values the rest of the package acts on. It +// is the one test that would catch a struct tag typo, which is otherwise +// silent: a mistyped tag yields a zero value, not an error. +// +// Proven able to fail against the committed tree by changing the json tag on +// AudioConfigurations.VOD (multitrack.go) from `vod` to `vod_tracks`, which +// made the test report "VOD audio tracks = 0, want 1". Restored from a /tmp +// copy; git diff --stat clean. +func TestTheLiveFixtureDecodesIntoEveryFieldTheFeatureDependsOn(t *testing.T) { + cfg := loadFixture(t, "negotiated-one-rendition.json") + + if got := len(cfg.EncoderConfigurations); got != 1 { + t.Fatalf("renditions = %d, want 1", got) + } + if got := len(cfg.AudioConfigurations.Live); got != 1 { + t.Fatalf("live audio tracks = %d, want 1", got) + } + // The whole feature. One video track, TWO audio tracks, the second marked + // for VOD -- which is the configuration issue #326 recorded as not known to + // be obtainable. + if got := len(cfg.AudioConfigurations.VOD); got != 1 { + t.Fatalf("VOD audio tracks = %d, want 1", got) + } + if got, want := cfg.AudioConfigurations.Live[0].TrackID, uint32(0); got != want { + t.Errorf("live track id = %d, want %d", got, want) + } + if got, want := cfg.AudioConfigurations.VOD[0].TrackID, uint32(1); got != want { + t.Errorf("VOD track id = %d, want %d", got, want) + } + if got, ok := cfg.AudioConfigurations.VOD[0].BitrateKbps(); !ok || got != 160 { + t.Errorf("VOD bitrate = %d (ok=%v), want 160", got, ok) + } + if got, ok := cfg.EncoderConfigurations[0].BitrateKbps(); !ok || got != 6000 { + t.Errorf("video bitrate = %d (ok=%v), want 6000", got, ok) + } + if cfg.EncoderConfigurations[0].Framerate == nil || + cfg.EncoderConfigurations[0].Framerate.Numerator != 30 { + t.Errorf("framerate = %+v, want 30/1", cfg.EncoderConfigurations[0].Framerate) + } + if cfg.Meta.ConfigID == "" { + t.Error("config_id is empty; the publish could not be correlated to this negotiation") + } + if v, _ := cfg.Verdict(); v != Negotiated { + t.Errorf("verdict = %s, want %s", v, Negotiated) + } +} + +// TestABodyWithAnUnexpectedInterpolationShapeStillDecodes covers the reason +// BitrateInterpolationPoints is a json.RawMessage. If it were []int, a shape +// change in one field nothing reads would fail the unmarshal of the whole +// config and lose the negotiation. +// +// Proven able to fail against the committed tree by changing +// VideoEncoderConfig.BitrateInterpolationPoints (multitrack.go) from +// json.RawMessage to []int, which made the test report "decode: json: cannot +// unmarshal object into Go struct field ... of type int". Restored from a /tmp +// copy; git diff --stat clean. +func TestABodyWithAnUnexpectedInterpolationShapeStillDecodes(t *testing.T) { + body := `{"meta":{"config_id":"c"}, + "ingest_endpoints":[{"protocol":"RTMPS","url_template":"rtmps://h/app/{stream_key}"}], + "encoder_configurations":[{"type":"x","width":1920,"height":1080, + "bitrate_interpolation_points":[{"at":0,"kbps":3960}],"settings":{"bitrate":6000}}], + "audio_configurations":{"live":[{"codec":"aac","track_id":0,"channels":2}],"vod":[]}}` + + var cfg Config + if err := json.Unmarshal([]byte(body), &cfg); err != nil { + t.Fatalf("decode: %v", err) + } + if v, _ := cfg.Verdict(); v != Negotiated { + t.Errorf("verdict = %s, want %s -- an unreadable field lost the whole negotiation", v, Negotiated) + } +} + +// TestAContextCancellationIsReportedAsAFailureNotARefusal keeps the two +// outcomes apart. A refusal is Twitch's answer and is the operator's to read; a +// cancelled call is not an answer at all, and reporting it as a refusal would +// tell the operator that Twitch declined something it was never asked. +// +// Proven able to fail against the committed tree by changing Client.Fetch +// (client.go) to `return &Config{}, nil` on the transport error path, which +// made the test report "Fetch returned no error for a cancelled context". +// Restored from a /tmp copy; git diff --stat clean. +func TestAContextCancellationIsReportedAsAFailureNotARefusal(t *testing.T) { + c, _ := serve(t, http.StatusOK, `{}`) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + cfg, err := c.Fetch(ctx, "k", NewRequest(Ask{Version: "test"})) + if err == nil { + t.Fatal("Fetch returned no error for a cancelled context") + } + if cfg != nil { + t.Errorf("Fetch returned a config alongside its error: %+v", cfg) + } + if !errors.Is(err, context.Canceled) && !strings.Contains(err.Error(), "context canceled") { + t.Errorf("error does not identify the cancellation: %v", err) + } +} diff --git a/internal/multitrack/endpoint.go b/internal/multitrack/endpoint.go new file mode 100644 index 00000000..25c8f16f --- /dev/null +++ b/internal/multitrack/endpoint.go @@ -0,0 +1,186 @@ +package multitrack + +import ( + "errors" + "fmt" + "net/url" + "strings" +) + +// keyPlaceholder is the token Twitch leaves in url_template where the stream +// key goes. Measured on every response, in both the RTMP and the RTMPS entry: +// +// rtmps://ingest.global-contribute.live-video.net/app/{stream_key} +const keyPlaceholder = "/{stream_key}" + +// Target is a publish destination, split the way polyemesis splits one. +// +// URL is the server and Key is the stream name, and they are separate because +// db.Destination.Target composes exactly this pair -- TrimRight(URL, "/") + "/" +// + Key -- when it builds what FFmpeg opens. Returning a single joined string +// would force the caller to take it apart again, and taking a publish URL apart +// is precisely the operation services.AnalyseURL exists because people get +// wrong. +// +// A NOTE ON THE OTHER CONVENTION IN THIS REPO, because getting it backwards has +// cost a whole verdict before: scripts/probe_platform_ertmp_multitrack.go puts +// the stream key in the URL FRAGMENT, and it is right to. That is gortmplib's +// interface -- its splitURL reads the key out of the fragment and treats the +// whole path as the RTMP app -- and gortmplib is what the probe publishes with. +// polyemesis's destinations publish with FFmpeg, which takes the last path +// segment as the stream name. Same protocol, two libraries, two spellings. This +// type is the FFmpeg one because db.Destination.Target is. +type Target struct { + // URL is the server, application path included and no trailing slash: + // "rtmps://ingest.global-contribute.live-video.net/app". + URL string + // Key is the stream key with the clientConfigId query parameter appended. + // IT IS A SECRET. It is never logged; Redacted covers the Config, and this + // is the value that Config protects. + Key string +} + +// Redacted renders the target for a log line. The server half is not secret and +// is the half an operator needs to see, since "which host am I publishing to" +// is the question this whole feature turns on. +func (t Target) Redacted() string { return t.URL + "/" + redactedPlaceholder } + +// ErrNoUsableEndpoint is returned when the configuration carries no ingest +// endpoint this client can publish to. It is a sentinel because the caller's +// response is the same as for a Refused verdict -- fall back to the ordinary +// ingest and say so -- and telling the two apart in a switch beats matching on +// a message. +var ErrNoUsableEndpoint = errors.New("no usable multitrack ingest endpoint") + +// Resolve turns the negotiated endpoints and the operator's stream key into +// something publishable. +// +// It does NOT do a string substitution of {stream_key}, which is the obvious +// implementation and the wrong one: the result would be a single URL, and +// polyemesis needs the server and the key apart in order to keep the key out of +// the value it logs, out of argv, and out of the signature it hashes a +// destination by. obs-studio splits at the same point for the same reason -- +// create_service cuts the template at "/{stream_key}" and sets the server and +// the key as separate service properties. +// +// The clientConfigId query parameter is the part that is easy to miss and not +// optional. It rides on the KEY, not on the server, and it is how the ingest +// knows which negotiated ladder is arriving on this connection. A publish +// without it is a publish Twitch cannot match to the configuration it just +// issued. +func (c *Config) Resolve(streamKey string) (Target, error) { + if c == nil { + return Target{}, ErrNoUsableEndpoint + } + + ep, err := c.pickEndpoint() + if err != nil { + return Target{}, err + } + + // THE NEGOTIATED KEY WINS, and on a successful negotiation there always is + // one. It is not the operator's key: it is a signed value carrying the + // agreed ladder inside it, with the operator's key as its final segment (see + // IngestEndpoint.Authentication). Publishing with the operator's key instead + // would send a stream the ingest never agreed the shape of -- which is the + // single easiest way to implement this feature so that it looks finished and + // silently is not. + key := streamKey + if ep.Authentication != "" { + key = ep.Authentication + } + if key == "" { + return Target{}, fmt.Errorf("%w: neither the destination nor Twitch supplied a stream key", + ErrNoUsableEndpoint) + } + + server, ok := strings.CutSuffix(ep.URLTemplate, keyPlaceholder) + if !ok { + // REFUSED RATHER THAN PUBLISHED AS-IS. obs-studio leaves an unmatched + // template alone, which would have polyemesis publish to a path with a + // literal "{stream_key}" in it. A template we do not recognise is a + // template whose meaning we do not know, and the failure mode of + // guessing is a connection to somewhere the operator did not choose -- + // the same failure the services registry was written to prevent. + return Target{}, fmt.Errorf("%w: Twitch's ingest template %q does not end in %q, so where the "+ + "stream key belongs in it is not established", ErrNoUsableEndpoint, ep.URLTemplate, keyPlaceholder) + } + server = strings.TrimRight(server, "/") + + u, err := url.Parse(server) + if err != nil { + return Target{}, fmt.Errorf("%w: Twitch's ingest template is not a URL: %v", ErrNoUsableEndpoint, err) + } + // Checked even though pickEndpoint already filtered on the protocol field, + // because the protocol field and the scheme are two different statements and + // nothing makes them agree. This is the one that decides what goes on the + // wire. + if u.Scheme != "rtmp" && u.Scheme != "rtmps" { + return Target{}, fmt.Errorf("%w: Twitch's ingest template is %s://, which is not an RTMP publish URL", + ErrNoUsableEndpoint, u.Scheme) + } + if u.Host == "" { + return Target{}, fmt.Errorf("%w: Twitch's ingest template names no host", ErrNoUsableEndpoint) + } + + return Target{URL: server, Key: withConfigID(key, c.Meta.ConfigID)}, nil +} + +// pickEndpoint prefers RTMPS. +// +// Not a preference about style. The stream key travels in the RTMP connect as +// the stream name, so on plain RTMP it crosses the network unencrypted -- and +// Twitch offers both, listing RTMP FIRST on every measured response. Taking the +// first entry, which is the obvious loop, picks the cleartext one every time. +func (c *Config) pickEndpoint() (IngestEndpoint, error) { + var fallback *IngestEndpoint + for i := range c.IngestEndpoints { + switch strings.ToUpper(strings.TrimSpace(c.IngestEndpoints[i].Protocol)) { + case "RTMPS": + return c.IngestEndpoints[i], nil + case "RTMP": + if fallback == nil { + fallback = &c.IngestEndpoints[i] + } + } + } + if fallback != nil { + return *fallback, nil + } + return IngestEndpoint{}, fmt.Errorf("%w: Twitch listed %d ingest endpoints and none of them speaks RTMP", + ErrNoUsableEndpoint, len(c.IngestEndpoints)) +} + +// withConfigID appends clientConfigId to a stream key, preserving any query the +// key already carries. +// +// Twitch stream keys really do carry query parameters -- "?bandwidthtest=true" +// is the documented one, and it changes what the ingest does with the stream -- +// so a naive key+"?clientConfigId=..." would produce two question marks and +// lose the operator's parameter. obs-studio merges them for the same reason. +// +// The key itself is never re-encoded: only the query half goes through +// url.Values, and the two are joined by hand. Percent-encoding a stream key +// would change the credential, which is the defect #306 landed on from the +// other direction. +func withConfigID(key, configID string) string { + if configID == "" { + // Nothing to add. Not an error: a response with no config_id is one + // Twitch cannot be expecting a correlated publish for either. + return key + } + base, rawQuery, hasQuery := strings.Cut(key, "?") + q := url.Values{} + if hasQuery { + // A key whose query does not parse is left with its query intact rather + // than dropped -- it is the operator's value and we do not understand it + // well enough to discard it. + parsed, err := url.ParseQuery(rawQuery) + if err != nil { + return key + "&clientConfigId=" + url.QueryEscape(configID) + } + q = parsed + } + q.Set("clientConfigId", configID) + return base + "?" + q.Encode() +} diff --git a/internal/multitrack/endpoint_test.go b/internal/multitrack/endpoint_test.go new file mode 100644 index 00000000..778216a4 --- /dev/null +++ b/internal/multitrack/endpoint_test.go @@ -0,0 +1,304 @@ +package multitrack + +import ( + "errors" + "net/url" + "strings" + "testing" +) + +// TestResolveSplitsTheTemplateWherePolyemesisSplitsAPublishURL pins the exact +// pair db.Destination.Target composes, because that composition is what FFmpeg +// eventually opens and getting the split wrong produces a URL that connects and +// then fails -- the failure mode internal/services was written about. +// +// Proven able to fail against the committed tree by returning the whole +// template as the server -- `Target{URL: ep.URLTemplate, ...}` in Config.Resolve +// (endpoint.go), which is the naive implementation this split exists instead of +// -- making the test report "the placeholder survived into the publish URL: +// rtmps://.../app/{stream_key}/live_424242_operatorkey?clientConfigId=...". +// Restored from a /tmp copy; git diff --stat clean. +// +// Worth recording what did NOT kill it: shortening keyPlaceholder to +// "{stream_key}" changes nothing, because the strings.TrimRight in Resolve +// absorbs the slash left behind. That is the constant being robust rather than +// the test being weak -- but it is why the mutation above is the one written +// down, and not the one that looks more obvious. +func TestResolveSplitsTheTemplateWherePolyemesisSplitsAPublishURL(t *testing.T) { + cfg := loadFixture(t, "negotiated-one-rendition.json") + + target, err := cfg.Resolve("live_424242_operatorkey") + if err != nil { + t.Fatalf("Resolve: %v", err) + } + + const wantURL = "rtmps://ingest.global-contribute.live-video.net/app" + if target.URL != wantURL { + t.Errorf("URL = %q, want %q", target.URL, wantURL) + } + // The composition db.Destination.Target performs, reproduced here so the + // assertion is about the string that actually reaches FFmpeg rather than + // about the halves. + joined := strings.TrimRight(target.URL, "/") + "/" + target.Key + if strings.Contains(joined, "{stream_key}") { + t.Errorf("the placeholder survived into the publish URL: %q", joined) + } + if !strings.HasPrefix(joined, wantURL+"/live_424242_operatorkey?") { + t.Errorf("composed publish URL = %q, want it to start with the server, the key, then a query", joined) + } +} + +// TestResolveCarriesTheConfigIDOnTheKeySoTheIngestCanMatchTheNegotiation is the +// step that is easy to leave out and impossible to notice: without +// clientConfigId the publish arrives at the right host with the right key and +// no way for Twitch to tell which ladder it agreed to. +// +// Proven able to fail against the committed tree by making withConfigID +// (endpoint.go) return `key` unchanged, which made the test report +// "clientConfigId is missing from the stream key". Restored from a /tmp copy; +// git diff --stat clean. +func TestResolveCarriesTheConfigIDOnTheKeySoTheIngestCanMatchTheNegotiation(t *testing.T) { + cfg := loadFixture(t, "negotiated-one-rendition.json") + + target, err := cfg.Resolve("live_1_key") + if err != nil { + t.Fatalf("Resolve: %v", err) + } + _, rawQuery, ok := strings.Cut(target.Key, "?") + if !ok { + t.Fatalf("clientConfigId is missing from the stream key: %q", target.Key) + } + q, err := url.ParseQuery(rawQuery) + if err != nil { + t.Fatalf("the query appended to the key does not parse: %v", err) + } + if got := q.Get("clientConfigId"); got != cfg.Meta.ConfigID { + t.Errorf("clientConfigId = %q, want %q", got, cfg.Meta.ConfigID) + } + // It rides on the KEY, not on the server. A clientConfigId on the server + // would make the RTMP app name wrong. + if strings.Contains(target.URL, "clientConfigId") { + t.Errorf("clientConfigId landed on the server URL: %q", target.URL) + } +} + +// TestAQueryAlreadyOnTheStreamKeyIsKeptAlongsideTheConfigID covers the real +// Twitch parameter "?bandwidthtest=true", which changes what the ingest does +// with the stream. Concatenating clientConfigId naively would produce two +// question marks and lose it. +// +// Proven able to fail against the committed tree by changing withConfigID +// (endpoint.go) to `return key + "?clientConfigId=" + configID`, which made the +// test report `bandwidthtest = "", want "true"`. Restored from a /tmp copy; +// git diff --stat clean. +func TestAQueryAlreadyOnTheStreamKeyIsKeptAlongsideTheConfigID(t *testing.T) { + cfg := &Config{ + Meta: Meta{ConfigID: "cfg-1"}, + IngestEndpoints: []IngestEndpoint{{Protocol: "RTMPS", URLTemplate: "rtmps://h/app/{stream_key}"}}, + } + + target, err := cfg.Resolve("live_1_key?bandwidthtest=true") + if err != nil { + t.Fatalf("Resolve: %v", err) + } + base, rawQuery, _ := strings.Cut(target.Key, "?") + if base != "live_1_key" { + t.Errorf("the key itself was rewritten: %q", base) + } + if strings.Count(target.Key, "?") != 1 { + t.Errorf("the key carries more than one question mark: %q", target.Key) + } + q, err := url.ParseQuery(rawQuery) + if err != nil { + t.Fatalf("parse query: %v", err) + } + if got := q.Get("bandwidthtest"); got != "true" { + t.Errorf("bandwidthtest = %q, want %q -- the operator's parameter was dropped", got, "true") + } + if got := q.Get("clientConfigId"); got != "cfg-1" { + t.Errorf("clientConfigId = %q, want %q", got, "cfg-1") + } +} + +// TestResolvePrefersRTMPSEvenThoughTwitchListsRTMPFirst guards a one-line +// decision with a real consequence. The stream key travels as the RTMP stream +// name, so plain RTMP puts it on the wire in the clear -- and Twitch lists the +// cleartext endpoint FIRST on every measured response, so the obvious loop +// picks it every time. +// +// Proven able to fail against the committed tree by changing pickEndpoint +// (endpoint.go) to return the first RTMP-or-RTMPS entry it sees, which made the +// test report "scheme = rtmp, want rtmps". Restored from a /tmp copy; git diff +// --stat clean. +func TestResolvePrefersRTMPSEvenThoughTwitchListsRTMPFirst(t *testing.T) { + cfg := loadFixture(t, "negotiated-one-rendition.json") + + // The premise: this test is only meaningful if the fixture really does put + // RTMP first. If Twitch reorders them one day, this says so rather than + // passing for the wrong reason. + if len(cfg.IngestEndpoints) < 2 || !strings.EqualFold(cfg.IngestEndpoints[0].Protocol, "RTMP") { + t.Fatalf("fixture no longer lists plain RTMP first, so this test proves nothing: %+v", + cfg.IngestEndpoints) + } + + target, err := cfg.Resolve("k") + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if scheme, _, _ := strings.Cut(target.URL, ":"); scheme != "rtmps" { + t.Errorf("scheme = %s, want rtmps", scheme) + } + + // And RTMP is still used when it is all there is, because refusing would + // turn a working-but-cleartext ingest into no ingest. + only := &Config{ + Meta: Meta{ConfigID: "c"}, + IngestEndpoints: []IngestEndpoint{{Protocol: "RTMP", URLTemplate: "rtmp://h/app/{stream_key}"}}, + } + got, err := only.Resolve("k") + if err != nil { + t.Fatalf("Resolve with only RTMP available: %v", err) + } + if !strings.HasPrefix(got.URL, "rtmp://") { + t.Errorf("URL = %q, want the plain RTMP endpoint when it is the only one", got.URL) + } +} + +// TestResolveHonoursTheKeyTwitchMintsRatherThanTheOperatorsOwn is the one that +// would be silently wrong. On a successful negotiation Twitch returns a signed +// key carrying the agreed ladder; publishing with the operator's original key +// instead connects, and sends a stream the ingest never agreed the shape of. +// +// Proven able to fail against the committed tree by deleting the +// `if ep.Authentication != ""` block from Config.Resolve (endpoint.go), which +// made the test report that the operator's key was used. Restored from a /tmp +// copy; git diff --stat clean. +func TestResolveHonoursTheKeyTwitchMintsRatherThanTheOperatorsOwn(t *testing.T) { + // Shaped like the real thing -- v1____ -- but short, and short on purpose: a 312-character + // credential-shaped literal in the tree is what .gitleaks.toml is for. + const minted = "v1_sig_salt_7b2276223a317d_live_1_operatorkey" + + cfg := &Config{ + Meta: Meta{ConfigID: "cfg-1"}, + IngestEndpoints: []IngestEndpoint{ + {Protocol: "RTMPS", URLTemplate: "rtmps://h/app/{stream_key}", Authentication: minted}, + }, + } + + target, err := cfg.Resolve("live_1_operatorkey") + if err != nil { + t.Fatalf("Resolve: %v", err) + } + base, _, _ := strings.Cut(target.Key, "?") + if base != minted { + t.Errorf("published key = %q, want the key Twitch minted (%q)", base, minted) + } +} + +// TestResolveRefusesATemplateItDoesNotUnderstandRatherThanGuessing. obs-studio +// leaves an unmatched template alone, which would have polyemesis publish to a +// path containing a literal "{stream_key}". A publish to somewhere the operator +// did not choose is worse than no publish, and the fallback exists. +// +// Proven able to fail against the committed tree by changing the CutSuffix +// failure branch in Config.Resolve (endpoint.go) to `server = ep.URLTemplate` +// instead of returning an error, which made the "template with no placeholder" +// subtest report "Resolve succeeded". Restored from a /tmp copy; git diff +// --stat clean. +func TestResolveRefusesATemplateItDoesNotUnderstandRatherThanGuessing(t *testing.T) { + for _, tc := range []struct { + name string + endpoints []IngestEndpoint + key string + want string + }{ + { + name: "template with no placeholder", + endpoints: []IngestEndpoint{{Protocol: "RTMPS", URLTemplate: "rtmps://h/app"}}, + key: "k", + want: "does not end in", + }, + { + name: "placeholder somewhere other than the end", + endpoints: []IngestEndpoint{{Protocol: "RTMPS", URLTemplate: "rtmps://h/{stream_key}/app"}}, + key: "k", + want: "does not end in", + }, + { + name: "an https endpoint is not a publish URL", + endpoints: []IngestEndpoint{{Protocol: "RTMPS", URLTemplate: "https://h/app/{stream_key}"}}, + key: "k", + want: "not an RTMP publish URL", + }, + { + name: "no host", + endpoints: []IngestEndpoint{{Protocol: "RTMPS", URLTemplate: "rtmps:///app/{stream_key}"}}, + key: "k", + want: "names no host", + }, + { + name: "a protocol nothing here speaks", + endpoints: []IngestEndpoint{{Protocol: "WHIP", URLTemplate: "https://h/whip/{stream_key}"}}, + key: "k", + want: "none of them speaks RTMP", + }, + { + name: "no endpoints at all", + endpoints: nil, + key: "k", + want: "none of them speaks RTMP", + }, + { + name: "neither side supplied a key", + endpoints: []IngestEndpoint{{Protocol: "RTMPS", URLTemplate: "rtmps://h/app/{stream_key}"}}, + key: "", + want: "neither the destination nor Twitch supplied a stream key", + }, + } { + t.Run(tc.name, func(t *testing.T) { + cfg := &Config{Meta: Meta{ConfigID: "c"}, IngestEndpoints: tc.endpoints} + target, err := cfg.Resolve(tc.key) + if err == nil { + t.Fatalf("Resolve succeeded and returned %+v", target) + } + // The sentinel is what a caller switches on to take the fallback, so + // every one of these has to carry it. + if !errors.Is(err, ErrNoUsableEndpoint) { + t.Errorf("error does not wrap ErrNoUsableEndpoint: %v", err) + } + if !strings.Contains(err.Error(), tc.want) { + t.Errorf("error = %v, want it to mention %q", err, tc.want) + } + }) + } +} + +// TestTheRedactedTargetShowsTheHostAndHidesTheKey. Which host a broadcast is +// publishing to is the question this whole feature turns on, so it has to be +// printable; the key never is. +// +// Proven able to fail against the committed tree by changing Target.Redacted +// (endpoint.go) to `return t.URL + "/" + t.Key`, which made the test report +// "the redacted target contains the key". Restored from a /tmp copy; git diff +// --stat clean. +func TestTheRedactedTargetShowsTheHostAndHidesTheKey(t *testing.T) { + cfg := loadFixture(t, "negotiated-one-rendition.json") + // A needle, not a key-shaped literal. The assertion below only needs a + // string distinctive enough that finding it in the output means the redactor + // missed it -- and a realistic-looking one buys nothing except a finding in + // gitleaks, which is right to flag it and which this repo runs in CI. + const key = "this-value-must-never-appear-in-a-log" + + target, err := cfg.Resolve(key) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + red := target.Redacted() + if strings.Contains(red, key) { + t.Errorf("the redacted target contains the key: %q", red) + } + if !strings.Contains(red, "ingest.global-contribute.live-video.net") { + t.Errorf("the redacted target hides the host, which is the part worth printing: %q", red) + } +} diff --git a/internal/multitrack/live_test.go b/internal/multitrack/live_test.go new file mode 100644 index 00000000..df8f9bbd --- /dev/null +++ b/internal/multitrack/live_test.go @@ -0,0 +1,267 @@ +package multitrack + +import ( + "context" + "os" + "strings" + "testing" + "time" +) + +// The live tests. Everything else in this package is checked against fixtures, +// and a fixture cannot tell you that the far end still behaves the way the +// fixture was captured from -- it can only tell you the parser still parses the +// bytes somebody pasted in. These two go and ask. +// +// THEY DO NOT SKIP. internal/multitrack does not appear in +// internal/testenv/testdata/skips.json, so a t.Skip here fails the ratchet, and +// that is the right pressure: a skip is how a network test becomes a green tick +// that means nothing. Instead, an unreachable endpoint LOGS AND RETURNS, and +// setting POLYEMESIS_REQUIRE_NET=1 turns that into a failure. That is the shape +// internal/ffmpeg's POLYEMESIS_REQUIRE_FFMPEG established, and it is honest in +// both directions: no network, no verdict, and a machine that is supposed to +// have network says so. +// +// THEY SEND NO CREDENTIAL. Every fact these assert was established with an +// empty `authentication`, which the endpoint accepts. That is itself the most +// surprising thing measured here and it is what makes this testable at all -- +// see the file's second test. + +// liveTimeout is generous relative to production's 10s, because a CI runner's +// first TLS handshake to a new host is not the thing under test. +const liveTimeout = 20 * time.Second + +// unreachable reports a transport failure the way this file has agreed to. +// Returns true if the test should stop. +func unreachable(t *testing.T, err error) bool { + t.Helper() + if err == nil { + return false + } + if os.Getenv("POLYEMESIS_REQUIRE_NET") == "1" { + t.Fatalf("POLYEMESIS_REQUIRE_NET=1 and Twitch's configuration endpoint could not be reached: %v", err) + } + t.Logf("NOT VERIFIED THIS RUN: Twitch's configuration endpoint could not be reached (%v). "+ + "Set POLYEMESIS_REQUIRE_NET=1 to make this a failure.", err) + return true +} + +// supportedGPU is the inventory Twitch was measured to accept. It is fabricated +// hardware and it is fabricated on purpose: this test is about the protocol, +// not about the machine it runs on, and a CI runner has no GPU at all. The +// values are a real NVIDIA PCI vendor/device pair because Twitch validates the +// vendor ID against a list it does not publish -- 0, an Intel iGPU and an +// unrecognised vendor were each refused by name. +var supportedGPU = GPU{ + Model: "NVIDIA GeForce RTX 3080", + VendorID: 4318, // 0x10DE + DeviceID: 8712, + DedicatedVideoMemory: 10 << 30, + SharedSystemMemory: 16 << 30, + DriverVersion: "551.86", +} + +func liveHardware(gpu []GPU) Capabilities { + return Capabilities{ + CPU: CPU{PhysicalCores: 8, LogicalCores: 16}, + Memory: Memory{Total: 32 << 30, Free: 8 << 30}, + System: System{Version: "6.8", Name: "Linux", Release: "6.8.0", Bits: 64}, + GPU: gpu, + } +} + +// TestTheLiveEndpointRefusesWithHTTP200AndAnEmptyLadder is the claim this whole +// package is built around, checked against the real endpoint rather than +// against a recording of it. +// +// It asks with NO GPU, which is exactly the polyemesis host this feature will +// most often run on: a headless server encoding with libx264. Twitch's answer +// is a refusal, and the assertion is that the refusal arrives as a 200 -- so +// Client.Fetch returns no error -- with the verdict in status.result and an +// empty ladder underneath it. +// +// Proven able to fail against the committed tree by changing the StatusError +// case in Config.Verdict (client.go) to `return Negotiated, ""`, which made +// this report "verdict = negotiated, want refused" against the live endpoint. +// Restored from a /tmp copy; git diff --stat clean. +func TestTheLiveEndpointRefusesWithHTTP200AndAnEmptyLadder(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), liveTimeout) + defer cancel() + + cfg, err := (&Client{}).Fetch(ctx, "", NewRequest(Ask{ + Version: "test", + Canvas: canvas1080p30, + VODAudio: true, + Hardware: liveHardware(nil), // no GPU: the refusal under test + })) + if unreachable(t, err) { + return + } + if cfg == nil { + t.Fatal("Fetch returned no config and no error") + } + + // The point. Fetch did not error, which means the HTTP status was 2xx -- + // and the answer is still no. + if cfg.Status == nil { + t.Fatalf("Twitch returned no status object for a GPU-less request; it used to refuse. "+ + "Renditions: %d", len(cfg.EncoderConfigurations)) + } + if cfg.Status.Result != StatusError { + t.Errorf("status.result = %q, want %q", cfg.Status.Result, StatusError) + } + verdict, advice := cfg.Verdict() + if verdict != Refused { + t.Errorf("verdict = %s, want %s", verdict, Refused) + } + if advice == "" { + t.Error("a refusal carried no explanation for the operator") + } + t.Logf("live refusal, verbatim: %s", advice) + + // The refusal still names the ingest host, which is why the fallback can + // report where it would have gone. + if len(cfg.IngestEndpoints) == 0 { + t.Error("a refusal carried no ingest endpoints") + } +} + +// TestTheLiveEndpointGrantsAVODAudioTrackAlongsideASingleVideoTrack answers the +// two things issue #326 recorded as NOT KNOWN, against the live endpoint. +// +// 1. Is audio_configurations.vod populated at all, or only for some accounts? +// It is populated, and it depends on nothing but the request: this call +// carries an EMPTY stream key and no token. +// +// 2. Does Enhanced Broadcasting require the multi-rendition video path? It +// does not. This asks for maximum_video_tracks 1 and gets one rendition +// back, with both audio tracks. One video track plus a live and a VOD audio +// track is a configuration Twitch will issue. +// +// That second point is what makes the feature reachable for polyemesis at all, +// which publishes one video track to an RTMP destination. +// +// Proven able to fail against the committed tree by changing NewRequest +// (request.go) to set `VODTrackAudio: false` unconditionally, which made this +// report "Twitch granted 0 VOD audio tracks" against the live endpoint -- +// confirming the assertion tracks the request and is not reading a field that +// is always populated. Restored from a /tmp copy; git diff --stat clean. +func TestTheLiveEndpointGrantsAVODAudioTrackAlongsideASingleVideoTrack(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), liveTimeout) + defer cancel() + + ask := Ask{ + Version: "test", + Canvas: canvas1080p30, + VODAudio: true, + MaxVideoTracks: 1, + Hardware: liveHardware([]GPU{supportedGPU}), + } + cfg, err := (&Client{}).Fetch(ctx, "", NewRequest(ask)) + if unreachable(t, err) { + return + } + + verdict, advice := cfg.Verdict() + if verdict == Refused { + // Not a silent pass. Twitch tightening its hardware allowlist is a real + // possibility and it would change what this package can promise, so it + // has to be loud. + t.Fatalf("Twitch refused a request it previously granted: %s", advice) + } + + if got := len(cfg.EncoderConfigurations); got != 1 { + t.Errorf("Twitch returned %d video renditions for maximum_video_tracks=1, want 1", got) + } + if got := len(cfg.AudioConfigurations.Live); got != 1 { + t.Errorf("Twitch granted %d live audio tracks, want 1", got) + } + if got := len(cfg.AudioConfigurations.VOD); got != 1 { + t.Fatalf("Twitch granted %d VOD audio tracks, want 1 -- this is the whole feature", got) + } + // The VOD track has to be a DIFFERENT track from the live one, or there is + // no second mix to route anywhere. + if live, vod := cfg.AudioConfigurations.Live[0], cfg.AudioConfigurations.VOD[0]; live.TrackID == vod.TrackID { + t.Errorf("the live and VOD tracks share track id %d, so there is only one track", live.TrackID) + } + t.Logf("live negotiation: %d rendition(s), live track %d, VOD track %d", + len(cfg.EncoderConfigurations), + cfg.AudioConfigurations.Live[0].TrackID, cfg.AudioConfigurations.VOD[0].TrackID) + + // And the ladder really does follow the canvas that was asked for, which is + // the evidence behind the reconciliation model on Ask: the operator's + // rendition is an INPUT to the negotiation. + if top := cfg.EncoderConfigurations[0]; top.Width != canvas1080p30.Width || + top.Height != canvas1080p30.Height { + t.Errorf("top rendition is %dx%d for a %dx%d canvas; the ladder no longer follows the canvas", + top.Width, top.Height, canvas1080p30.Width, canvas1080p30.Height) + } +} + +// TestTheLiveEndpointMintsAStreamKeyThatIsNotTheOneItWasGiven is the assertion +// no fixture can stand in for, and the one that would have caught the reading +// this package first had. +// +// On a refusal, ingest_endpoints[].authentication is a plain echo of the key +// that was sent -- which makes it look like decoration. On a SUCCESSFUL +// negotiation it is a signed value carrying the agreed ladder, with the +// original key as its last segment. Resolve has to publish with THAT, and a +// hand-written fixture would only ever prove that Resolve prefers whatever the +// fixture's author put in the field. +// +// The key sent here is synthetic and belongs to nobody. It is a literal in the +// test rather than an environment variable on purpose: a real key would make +// this test's behaviour depend on a credential, and the fact being established +// -- that the minted key differs from the key sent -- needs no real one. +// +// Proven able to fail against the committed tree by deleting the +// `if ep.Authentication != ""` block from Config.Resolve (endpoint.go), which +// made this report "Resolve published with the key that was sent, not the one +// Twitch minted" against the live endpoint. Restored from a /tmp copy; git diff +// --stat clean. +func TestTheLiveEndpointMintsAStreamKeyThatIsNotTheOneItWasGiven(t *testing.T) { + const sent = "live_000000000_SYNTHETICKEYNOTREAL0000000000" + + ctx, cancel := context.WithTimeout(context.Background(), liveTimeout) + defer cancel() + + cfg, err := (&Client{}).Fetch(ctx, sent, NewRequest(Ask{ + Version: "test", + Canvas: canvas1080p30, + VODAudio: true, + MaxVideoTracks: 1, + Hardware: liveHardware([]GPU{supportedGPU}), + })) + if unreachable(t, err) { + return + } + if verdict, advice := cfg.Verdict(); verdict == Refused { + t.Fatalf("Twitch refused a request it previously granted: %s", advice) + } + + target, err := cfg.Resolve(sent) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + published, _, _ := strings.Cut(target.Key, "?") + + if published == sent { + t.Fatal("Resolve published with the key that was sent, not the one Twitch minted") + } + // The minted key is specified to embed the original, and that is what makes + // it safe to hand Twitch's own value straight back: it is still this + // operator's stream. + if !strings.HasSuffix(published, sent) { + t.Errorf("the minted key does not end in the key that was sent, so it is not this stream's key") + } + if !strings.HasPrefix(published, "v1_") { + t.Errorf("the minted key does not have the v1_ shape this package documents") + } + t.Logf("Twitch minted a %d-character key from the %d-character one it was sent", + len(published), len(sent)) + + // And it must not be printable. This is the value that would reach a log. + if red := target.Redacted(); strings.Contains(red, published) || strings.Contains(red, sent) { + t.Errorf("the redacted target leaks the minted key: %q", red) + } +} diff --git a/internal/multitrack/multitrack.go b/internal/multitrack/multitrack.go new file mode 100644 index 00000000..a975d152 --- /dev/null +++ b/internal/multitrack/multitrack.go @@ -0,0 +1,453 @@ +// Package multitrack speaks Twitch Enhanced Broadcasting, which Amazon -- whose +// IVS runs it -- calls Multitrack Video, and which Twitch's own error text calls +// both names within a single response body. +// +// WHAT THIS IS FOR. polyemesis publishes one AAC stereo track to an RTMP +// destination, because that is what RTMP ingests were measured to take (see +// db.AudioEncoding.copyProblems, which refuses a copied multitrack RTMP +// destination in those words). Enhanced Broadcasting is the one path a platform +// has published that takes a SECOND audio track and says what it is for: a VOD +// mix, separate from the live mix, which is the ask in issue #141. +// +// It is reached by asking for it. A client POSTs its hardware, its canvas and +// its preferences to +// +// https://ingest.twitch.tv/api/v3/GetClientConfiguration +// +// and Twitch answers with the ingest endpoint, the video renditions and the +// audio tracks it wants. That is the whole shape of the feature and it is why +// this package exists as a negotiation rather than as a constant: nothing here +// is knowable ahead of the call. +// +// THREE THINGS MEASURED AGAINST THE LIVE ENDPOINT, each of which shapes the +// code below and none of which is an assumption: +// +// 1. A REFUSAL ARRIVES AS HTTP 200. Every response observed -- valid, invalid, +// unsupported hardware, unparseable schema version -- was 200. 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 has +// read the wrong field; see Config.Verdict. +// +// 2. THE INGEST IS A DIFFERENT HOST. ingest.global-contribute.live-video.net, +// not live.twitch.tv. Everything else polyemesis knows about publishing to +// Twitch -- oauth.twitchIngestURL, the services registry -- is about the +// other host and stays true of it. +// +// 3. THE REQUEST AND THE RESPONSE BOTH CARRY A STREAM KEY. `authentication` +// in the request IS the stream key -- not an OAuth token, which is what the +// issue expected -- and the response carries one back in +// ingest_endpoints[].authentication. On a REFUSAL that value is a plain +// echo of what was sent. On a SUCCESSFUL negotiation it is something else +// entirely: a 312-character signed key that embeds the negotiated ladder +// and ends with the original key. See IngestEndpoint.Authentication. Either +// way a response body is as sensitive as a request body and neither may be +// logged as it stands; Config.Redacted exists for that and is the only +// shape of a Config fit to print. +// +// A FOURTH, which is the reason the fallback matters more than it looks: +// Twitch refuses a client with no supported GPU. Measured refusals include "did +// not send GPU Information", "Your GPU is not currently supported" (an Intel +// iGPU, and a vendor ID it did not recognise) and "Your GPU driver version is +// not supported". A headless polyemesis host encoding with libx264 has nothing +// to send that Twitch will accept, so on that host the fallback to the ordinary +// ingest is the NORMAL path, not the exceptional one. It has to be quiet, +// correct, and it has to say what happened. +package multitrack + +import ( + "bytes" + "encoding/json" + "strings" +) + +// ConfigURL is where OBS's services.json points Twitch's +// multitrack_video_configuration_url, and the only endpoint this package talks +// to. It is a const rather than a setting for the reason endpoints.go gives one +// hostname over: a configurable platform host is a partially-redirected +// provider waiting to happen. Client.BaseURL is the test seam. +const ConfigURL = "https://ingest.twitch.tv/api/v3/GetClientConfiguration" + +// SchemaVersion is the contract version this package encodes, and it is a +// version Twitch has to recognise: sending one it does not know is answered +// with `status.result: "error"` naming the version back, which was the first +// response this package was ever measured against. Taken from OBS's +// constructGoLivePost, which is the only published statement of a valid value. +// +// Bump it only alongside a re-read of the response types -- the schema version +// is what selects the RESPONSE shape, so changing it without checking the +// fields is how a config silently loses a track. +const SchemaVersion = "2025-01-25" + +// ServiceIVS is the `service` discriminator. Twitch's own responses echo +// "IVS" in meta.service regardless of what was sent -- a request naming +// "NOTIVS" came back meta.service "IVS" -- so this is not a field the far end +// validates today. Sent correctly anyway, because a field that is ignored now +// is not a field that is ignored later. +const ServiceIVS = "IVS" + +// ---------------------------------------------------------------- the response + +// Config is a GetClientConfiguration response. Field names and optionality +// follow obsproject/obs-studio frontend/utility/models/multitrack-video.hpp, +// which is Twitch's own client and therefore the only normative statement of +// this wire format that exists. +type Config struct { + Meta Meta `json:"meta"` + Status *Status `json:"status,omitempty"` + // IngestEndpoints is where to publish. Both an RTMP and an RTMPS entry were + // returned on every measured response, in that order -- which is exactly + // why Resolve does not take the first one. + IngestEndpoints []IngestEndpoint `json:"ingest_endpoints"` + // EncoderConfigurations is the video ladder Twitch chose. It is EMPTY on + // every refusal, which is what makes an empty ladder meaningful on its own + // -- see Verdict. + EncoderConfigurations []VideoEncoderConfig `json:"encoder_configurations"` + AudioConfigurations AudioConfigurations `json:"audio_configurations"` +} + +// Meta identifies the negotiation. ConfigID is not decoration: it goes back to +// Twitch on the publish, as a `clientConfigId` query parameter on the stream +// key, and that is how the ingest knows which negotiated ladder is arriving. +// Resolve does that; nothing else should. +type Meta struct { + Service string `json:"service"` + SchemaVersion string `json:"schema_version"` + ConfigID string `json:"config_id"` + // RequiredEncodeResourceEstimatePercent is Twitch's estimate of how much of + // the client's encode capacity the returned ladder will use. Observed 0 on + // every refusal and 12 on a one-rendition 1080p30 NVENC negotiation. Carried + // because it is the only figure in the response that speaks to whether the + // machine can actually do what was negotiated; nothing acts on it yet. + RequiredEncodeResourceEstimatePercent int `json:"required_encode_resource_estimate_percent,omitempty"` +} + +// StatusResult is the verdict field. The zero value is the ABSENT case, not an +// error case: a successful negotiation omits the whole status object. +type StatusResult string + +const ( + // StatusSuccess and the rest are the values obs-studio enumerates. Only + // StatusError has been observed from the live endpoint; the others are + // handled because obs-studio handles them, and because the failure mode of + // an unhandled one is to be read as the zero value and treated as success. + StatusSuccess StatusResult = "success" + StatusWarning StatusResult = "warning" + StatusError StatusResult = "error" +) + +// Status is Twitch's verdict on the request, and HTMLEnUS is the only +// explanation of a refusal that exists -- there is no error code. It is HTML +// and it is English; both are Twitch's choice, and the field name says so. +type Status struct { + Result StatusResult `json:"result"` + // HTMLEnUS carries the operator-facing sentence. Real examples, verbatim: + // "Your GPU is not currently supported by Twitch Enhanced Broadcasting" and + // "The schema_version (1999-01-01) being used by your broadcast software + // (obs-studio) is invalid or no longer supported". It quotes fields from the + // request back, which is why it is scrubbed before it is shown anywhere. + HTMLEnUS string `json:"html_en_us,omitempty"` +} + +// IngestEndpoint is one publish target. URLTemplate carries a literal +// "{stream_key}" placeholder rather than a key -- see Resolve for what is done +// with it, and why not simply substituting it is the right call. +type IngestEndpoint struct { + Protocol string `json:"protocol"` + URLTemplate string `json:"url_template"` + // Authentication, when present, REPLACES the stream key that was sent, and + // on a successful negotiation it is REQUIRED, not advisory. + // + // This was nearly read the wrong way round, so the measurement is written + // down. On a REFUSED request the field is a plain echo -- send a key, the + // same key comes back, send an empty string and the field is absent -- which + // makes it look like decoration. On a SUCCESSFUL negotiation it is a + // 312-character minted credential of the form + // + // v1_<64 hex signature>_<8 hex>__ + // + // where the manifest hex decodes to the ladder that was 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, and + // the second audio entry carrying "v":1 for VOD and "t":1 for its track id. + // So the negotiated configuration travels to the ingest INSIDE the key. A + // client that published with the operator's original key would be publishing + // a ladder the ingest had never agreed to. + // + // It is, for that reason, a secret twice over: it is a credential in its own + // right AND it has the operator's original key as its last segment. Scrubbing + // the original key out of a log leaves the signature and the manifest behind, + // so this value has to be registered as a secret in its own right rather than + // assumed covered. + Authentication string `json:"authentication,omitempty"` +} + +// Framerate is a rational, matching OBS's media_frames_per_second. +type Framerate struct { + Numerator uint32 `json:"numerator"` + Denominator uint32 `json:"denominator"` +} + +// VideoEncoderConfig is one rendition Twitch wants. +// +// Type is an OBS ENCODER ID -- "obs_nvenc_h264_tex" on every measured response +// -- not a codec name. That is a real obstacle for polyemesis, which encodes +// with FFmpeg and has no such identifier, and it is why nothing here maps Type +// to an FFmpeg encoder: a mapping guessed from one observed value would be a +// table that looks authoritative and is not. Settings is likewise OBS's +// property bag, keyed by OBS property names ("keyint_sec", "rate_control", +// "multipass"), and translating it is the scoped-out half of this work. +type VideoEncoderConfig struct { + Type string `json:"type"` + Width uint32 `json:"width"` + Height uint32 `json:"height"` + Framerate *Framerate `json:"framerate,omitempty"` + GPUScaleType string `json:"gpu_scale_type,omitempty"` + Colorspace string `json:"colorspace,omitempty"` + Range string `json:"range,omitempty"` + Format string `json:"format,omitempty"` + // BitrateInterpolationPoints stays RAW on purpose. obs-studio types it as + // free-form JSON, and the live endpoint returned a flat array of four + // integers. Decoding it into []int would make any other shape Twitch chooses + // -- an array of objects, say -- fail the unmarshal of the ENTIRE Config, + // turning a field nothing reads into a total loss of the negotiation. A + // json.RawMessage cannot fail. + BitrateInterpolationPoints json.RawMessage `json:"bitrate_interpolation_points,omitempty"` + Settings Settings `json:"settings,omitempty"` + CanvasIndex uint32 `json:"canvas_index"` +} + +// AudioEncoderConfig is one audio track. TrackID is the position it occupies in +// the published stream: live audio came back as track_id 0 and the VOD track as +// track_id 1, which is the numbering the second mix has to land on. +type AudioEncoderConfig struct { + Codec string `json:"codec"` + TrackID uint32 `json:"track_id"` + Channels uint32 `json:"channels"` + Settings Settings `json:"settings,omitempty"` +} + +// AudioConfigurations splits the tracks by what they are for. This split is the +// entire point of the feature for polyemesis. +type AudioConfigurations struct { + Live []AudioEncoderConfig `json:"live"` + // VOD is populated purely from the request's preferences.vod_track_audio. + // Measured: asking with vod_track_audio true returns one aac track at + // track_id 1; asking with it false returns an empty list. It does NOT depend + // on the account, on a token, or on anything Twitch knows about the channel + // -- which is the open question issue #326 recorded as unknown, answered. + VOD []AudioEncoderConfig `json:"vod"` +} + +// Settings is an encoder property bag. It is a map rather than a struct because +// its keys are OBS encoder property names, which differ per encoder Type, and a +// struct would silently drop the ones it had not heard of. +type Settings map[string]json.RawMessage + +// Int reads a numeric setting. The bool is false for absent and for +// not-a-number alike, because both mean the same thing to a caller: this +// setting cannot be used, so do not use it. Numbers arrive as JSON numbers and +// may legitimately be written 160 or 160.0, so both decode. +func (s Settings) Int(key string) (int, bool) { + raw, ok := s[key] + if !ok { + return 0, false + } + // JSON null is checked BEFORE the unmarshal, not after, because + // json.Unmarshal(null) into a float64 succeeds and leaves the target at its + // zero value. Without this, a setting Twitch explicitly nulled would read as + // a stated bitrate of 0 -- and Reconcile adds these up. + if string(bytes.TrimSpace(raw)) == "null" { + return 0, false + } + var f float64 + if err := json.Unmarshal(raw, &f); err != nil { + return 0, false + } + return int(f), true +} + +// BitrateKbps is the one setting every measured audio track carried, named for +// the unit Twitch sends it in. The live and VOD tracks both came back at 160. +func (a AudioEncoderConfig) BitrateKbps() (int, bool) { return a.Settings.Int("bitrate") } + +// BitrateKbps is the same for video; measured 6000, 2500 and 500 across a +// three-rendition 1080p30 ladder. +func (v VideoEncoderConfig) BitrateKbps() (int, bool) { return v.Settings.Int("bitrate") } + +// ---------------------------------------------------------------- the request + +// Request is the POST body. It mirrors obs-studio's GoLiveApi::PostData, +// because there is no other specification of it and the far end validates +// fields that no documentation mentions. +type Request struct { + Service string `json:"service"` + SchemaVersion string `json:"schema_version"` + // Authentication is THE STREAM KEY. Named as Twitch names it, and flagged + // here because the name does not say so: this field is a credential, it is + // what makes the whole Request unloggable, and it is why Client.Fetch takes + // the key separately and never accepts a pre-built body. + Authentication string `json:"authentication"` + Client ClientInfo `json:"client"` + Capabilities Capabilities `json:"capabilities"` + Preferences Preferences `json:"preferences"` +} + +// ClientInfo names the broadcast software. Twitch quotes Name back inside +// status.html_en_us -- "Your broadcast software (polyemesis) did not send GPU +// Information" -- which is the only reason to send an honest one: an operator +// reading that sentence should see the program they are actually running. +// +// SupportedCodecs was not observed to change the answer: asking with only "av1" +// still returned an h264 ladder. Sent regardless, since a request that lies +// about what it can encode has no defence when that stops being true. +type ClientInfo struct { + Name string `json:"name"` + Version string `json:"version"` + SupportedCodecs []string `json:"supported_codecs"` +} + +// Capabilities is the hardware inventory. GPU IS MANDATORY AND IS VALIDATED: +// omit it and Twitch refuses; send vendor_id 0 and it refuses naming the value; +// send a vendor it does not recognise and it refuses; send an AMD card with an +// old driver and it refuses naming the Mesa version to upgrade to. There is no +// software-encoder path through this endpoint. +// +// Nothing in this package MEASURES any of it. That is deliberate and it is the +// honest boundary: 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-looking +// inventory that was not this machine's. The caller supplies it or the call is +// refused, and a refusal here is survivable -- see Verdict. +type Capabilities struct { + CPU CPU `json:"cpu"` + Memory Memory `json:"memory"` + System System `json:"system"` + GPU []GPU `json:"gpu,omitempty"` +} + +type CPU struct { + PhysicalCores int32 `json:"physical_cores"` + LogicalCores int32 `json:"logical_cores"` + Speed uint32 `json:"speed,omitempty"` + Name string `json:"name,omitempty"` +} + +type Memory struct { + Total uint64 `json:"total"` + Free uint64 `json:"free"` +} + +type System struct { + Version string `json:"version"` + Name string `json:"name"` + Build int `json:"build"` + Release string `json:"release"` + Revision string `json:"revision"` + Bits int `json:"bits"` + ARM bool `json:"arm"` + ARMEmulation bool `json:"armEmulation"` +} + +// GPU is one adapter. VendorID is the PCI vendor ID as a decimal integer -- +// 4318 for NVIDIA (0x10DE), 4098 for AMD (0x1002), 32902 for Intel (0x8086) -- +// and Twitch validates it against a list it does not publish. +type GPU struct { + Model string `json:"model"` + VendorID uint32 `json:"vendor_id"` + DeviceID uint32 `json:"device_id"` + DedicatedVideoMemory uint64 `json:"dedicated_video_memory"` + SharedSystemMemory uint64 `json:"shared_system_memory"` + DriverVersion string `json:"driver_version,omitempty"` +} + +// Canvas is the composition the client is producing. This is the field the +// negotiated ladder is derived FROM, which is the answer to how a negotiated +// config reconciles with an operator's own rendition choice -- see NewRequest. +type Canvas struct { + Width uint32 `json:"width"` + Height uint32 `json:"height"` + CanvasWidth uint32 `json:"canvas_width"` + CanvasHeight uint32 `json:"canvas_height"` + Framerate Framerate `json:"framerate"` +} + +// Preferences is what the client asks for. Twitch is free to ignore any of it +// and demonstrably ignores some -- a MaximumAggregateBitrate of 2500 kbps still +// returned a ladder totalling 9000 -- so nothing here may be treated as a +// guarantee about the response. +type Preferences struct { + MaximumAggregateBitrate uint64 `json:"maximum_aggregate_bitrate,omitempty"` + // MaximumVideoTracks caps the ladder, and unlike the bitrate ceiling it IS + // honoured: asking for 1 returned exactly one rendition, 2 returned two, 3 + // returned three -- each still alongside BOTH audio tracks. That measurement + // answers the second thing issue #326 recorded as unknown: multi-rendition + // video is NOT a precondition of the second audio track. One video track + // plus a live and a VOD audio track is a configuration Twitch will hand out. + MaximumVideoTracks uint32 `json:"maximum_video_tracks,omitempty"` + // VODTrackAudio is the switch that produces AudioConfigurations.VOD. It is + // the single most important field in this struct for polyemesis. + VODTrackAudio bool `json:"vod_track_audio"` + CompositionGPUIndex uint32 `json:"composition_gpu_index,omitempty"` + AudioSamplesPerSec uint32 `json:"audio_samples_per_sec"` + AudioChannels uint32 `json:"audio_channels"` + AudioMaxBufferingMS uint32 `json:"audio_max_buffering_ms"` + AudioFixedBuffering bool `json:"audio_fixed_buffering"` + Canvases []Canvas `json:"canvases"` +} + +// ---------------------------------------------------------------- redaction + +// redactedPlaceholder is what a stream key is replaced BY. It is deliberately +// not empty: a log line reading `authentication: ""` is indistinguishable from +// one where the field was genuinely absent, and telling those apart is the +// whole reason to look at a redacted config. +const redactedPlaceholder = "" + +// Redacted returns a copy of the config safe to log or to put in an issue. +// +// It exists because Twitch echoes the stream key back in +// ingest_endpoints[].authentication, so the naive thing -- marshal the response +// and log it -- publishes the credential. That is the exact shape of the defect +// in #310 (a refused destination wrote its key to server.log) and #324 (the +// automod endpoint key), and the exact shape this repo has now paid for twice. +// +// The copy is deep enough to matter: IngestEndpoints is reallocated rather than +// aliased, so redacting does not reach back into the caller's Config and blank +// the key it is about to publish with. +func (c *Config) Redacted() *Config { + if c == nil { + return nil + } + out := *c + if c.Status != nil { + s := *c.Status + out.Status = &s + } + out.IngestEndpoints = make([]IngestEndpoint, len(c.IngestEndpoints)) + copy(out.IngestEndpoints, c.IngestEndpoints) + for i := range out.IngestEndpoints { + if out.IngestEndpoints[i].Authentication != "" { + out.IngestEndpoints[i].Authentication = redactedPlaceholder + } + } + return &out +} + +// scrub removes secrets from a string about to be shown to somebody. +// +// Every error this package returns goes through it. A *url.Error carries the +// request URL, an unmarshal error can carry a fragment of the body, and +// status.html_en_us quotes request fields back -- three routes by which a key +// that was never meant to be printed becomes a line in a log. Empty secrets are +// skipped, or every message would be shredded into placeholders. +func scrub(s string, secrets ...string) string { + for _, sec := range secrets { + if sec == "" { + continue + } + s = strings.ReplaceAll(s, sec, redactedPlaceholder) + } + return s +} diff --git a/internal/multitrack/request.go b/internal/multitrack/request.go new file mode 100644 index 00000000..b0494353 --- /dev/null +++ b/internal/multitrack/request.go @@ -0,0 +1,269 @@ +package multitrack + +import "fmt" + +// ClientName is what polyemesis calls itself to Twitch. Twitch quotes it back +// inside status.html_en_us, so an operator reading "Your broadcast software +// (polyemesis) did not send GPU Information" is reading about the program they +// are actually running. Claiming to be obs-studio would make that sentence a +// lie, and would make polyemesis invisible in whatever Twitch counts. +const ClientName = "polyemesis" + +// Ask is what polyemesis wants, expressed in polyemesis's own terms. NewRequest +// turns it into the wire Request. +// +// ------------------------------------------------------------------ +// HOW A NEGOTIATED CONFIG RECONCILES WITH THE OPERATOR'S OWN SETTINGS +// ------------------------------------------------------------------ +// +// This is the question issue #326 raises and it is a product decision, so it is +// written down here rather than left implicit in the code. +// +// THE OPERATOR'S SETTINGS ARE THE INPUT TO THE NEGOTIATION, NOT SOMETHING IT +// OVERRIDES. That is not a compromise position, it is what the endpoint +// actually does, and it was measured: +// +// - Asking with a 1920x1080@30 canvas returned a 1080p/720p/360p ladder. +// Asking with 1280x720@60 returned 720p/480p/360p. The renditions are +// 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. +// +// - Asking for maximum_video_tracks 1 returned exactly one rendition -- and +// still returned BOTH audio tracks. So polyemesis, which publishes a single +// video track to an RTMP destination, does not have to pretend otherwise to +// get the VOD audio track. It asks for one and gets one. +// +// Where Twitch's answer nonetheless differs from what was asked -- and it does; +// a maximum_aggregate_bitrate of 2500 kbps was simply ignored -- the difference +// is REPORTED, by Reconcile, and never silently applied. That follows the house +// 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 contract is: the operator's rendition decides what we ASK for; Twitch +// decides what it will ACCEPT; and any gap between the two is shown to the +// operator rather than resolved on their behalf. +type Ask struct { + // Version is polyemesis's own version string, sent as client.version. + Version string + // Canvas is the composition the operator configured -- their rendition, in + // this package's terms. It is the field the returned ladder is derived from. + Canvas Canvas + // VODAudio asks for the second audio track. This is the switch that + // populates AudioConfigurations.VOD and it is the whole reason polyemesis + // makes this call at all. + VODAudio bool + // MaxVideoTracks caps the ladder. Zero means DefaultMaxVideoTracks, which is + // 1, because one video track is what polyemesis publishes to an RTMP + // destination today -- see db.AudioEncoding.copyProblems. Asking for more + // renditions than can be published would negotiate a configuration that + // cannot then be honoured, and Twitch would be right to expect all of them. + MaxVideoTracks uint32 + // MaxAggregateBitrateKbps is the operator's ceiling across the whole ladder, + // in the unit the rest of polyemesis states bitrates in. Zero omits it. + // Twitch was observed to ignore this; it is sent because saying nothing is + // worse than saying something that is ignored, and because Reconcile can + // only report an overrun it asked about. + MaxAggregateBitrateKbps int + // AudioSampleRate and AudioChannels describe the mix polyemesis will feed + // in. Zero means the defaults below. + AudioSampleRate uint32 + AudioChannels uint32 + // SupportedCodecs is what polyemesis can encode. Empty means + // DefaultSupportedCodecs. + SupportedCodecs []string + // Hardware is this machine's inventory, and IT HAS TO BE REAL. Twitch + // validates the GPU: no GPU, a vendor ID of zero, an unrecognised vendor and + // an out-of-date driver were each refused, by name, in testing. Nothing in + // this package measures hardware -- see Capabilities for why not -- so a + // caller that cannot supply a supported GPU should expect Refused and should + // take the fallback. That is a supported outcome, not a bug. + Hardware Capabilities +} + +// Defaults. Named rather than inlined so a test can assert on the value and a +// reader can see there is a decision here. +const ( + // DefaultMaxVideoTracks is 1 because polyemesis publishes one video track to + // an RTMP destination. + DefaultMaxVideoTracks = 1 + // DefaultAudioSampleRate matches routing.Profile's compiled output, whose + // graph ends in aresample=48000. + DefaultAudioSampleRate = 48000 + // DefaultAudioChannels matches routing.OutChannels: destinations are stereo. + DefaultAudioChannels = 2 + // defaultAudioMaxBufferingMS is obs-studio's own default. Twitch reads it + // and nothing observed depends on it; sending obs-studio's value is the only + // defensible choice for a field whose meaning is not published. + defaultAudioMaxBufferingMS = 960 +) + +// DefaultSupportedCodecs is what polyemesis will encode video as. h264 alone, +// because that is what every RTMP destination in the services registry accepts +// and what the FFmpeg encoder path builds today. Claiming av1 would invite a +// ladder polyemesis cannot produce. +func DefaultSupportedCodecs() []string { return []string{"h264"} } + +// NewRequest builds the wire body. It deliberately does NOT take the stream +// key: Client.Fetch puts that in, so that exactly one function in this package +// ever handles it. +func NewRequest(a Ask) Request { + codecs := a.SupportedCodecs + if len(codecs) == 0 { + codecs = DefaultSupportedCodecs() + } + tracks := a.MaxVideoTracks + if tracks == 0 { + tracks = DefaultMaxVideoTracks + } + rate := a.AudioSampleRate + if rate == 0 { + rate = DefaultAudioSampleRate + } + channels := a.AudioChannels + if channels == 0 { + channels = DefaultAudioChannels + } + + prefs := Preferences{ + MaximumVideoTracks: tracks, + VODTrackAudio: a.VODAudio, + AudioSamplesPerSec: rate, + AudioChannels: channels, + AudioMaxBufferingMS: defaultAudioMaxBufferingMS, + Canvases: []Canvas{a.Canvas}, + } + if a.MaxAggregateBitrateKbps > 0 { + // Twitch's field is bits per second; polyemesis states bitrates in kbps + // everywhere else (db.Destination.AudioBitrate, services.Recommended). + // The conversion lives here, once, rather than at every call site -- + // which is how a ceiling ends up a thousand times too low. + prefs.MaximumAggregateBitrate = uint64(a.MaxAggregateBitrateKbps) * 1000 + } + + return Request{ + Service: ServiceIVS, + SchemaVersion: SchemaVersion, + Client: ClientInfo{ + Name: ClientName, + Version: a.Version, + SupportedCodecs: codecs, + }, + Capabilities: a.Hardware, + Preferences: prefs, + } +} + +// ---------------------------------------------------------------- divergence + +// Divergence is one place where what Twitch returned is not what was asked for. +// +// Shaped after services.URLProblem on purpose, including the reason: these are +// advisory findings written for an operator, and the point of having a type for +// them is that they get SHOWN rather than acted on. Nothing in this package +// changes a setting because of one. +type Divergence struct { + // Field names the thing to blame, in the operator's vocabulary where one + // exists. + Field string + // Detail is written for a person, not for a log parser. + Detail string +} + +// Reconcile reports where the negotiated configuration departs from the Ask. +// +// It is called on a configuration that has already passed Verdict; a refusal +// has nothing to reconcile. An empty result means Twitch agreed to what was +// asked, which was the common case in testing and is not something to celebrate +// in a log line. +func Reconcile(a Ask, c *Config) []Divergence { + if c == nil { + return nil + } + var out []Divergence + add := func(field, format string, args ...any) { + out = append(out, Divergence{Field: field, Detail: fmt.Sprintf(format, args...)}) + } + + wantTracks := a.MaxVideoTracks + if wantTracks == 0 { + wantTracks = DefaultMaxVideoTracks + } + if got := uint32(len(c.EncoderConfigurations)); got > wantTracks { + add("renditions", "Twitch negotiated %d video renditions but polyemesis asked for at most %d "+ + "and can publish %d. The extra renditions will not be sent.", got, wantTracks, wantTracks) + } + + // The FIRST rendition is the one polyemesis would publish, because Twitch + // returns the ladder highest-first on every measured response. Comparing the + // whole ladder against one canvas would be comparing the wrong things: the + // lower rungs are SUPPOSED to be smaller. + if len(c.EncoderConfigurations) > 0 && a.Canvas.Width > 0 && a.Canvas.Height > 0 { + top := c.EncoderConfigurations[0] + if top.Width != a.Canvas.Width || top.Height != a.Canvas.Height { + add("rendition", "this destination is configured for %dx%d, but Twitch's Enhanced Broadcasting "+ + "configuration asks for %dx%d. The operator's size is what polyemesis will encode; "+ + "Twitch may transcode or refuse it.", + a.Canvas.Width, a.Canvas.Height, top.Width, top.Height) + } + if top.Framerate != nil && a.Canvas.Framerate.Denominator != 0 && top.Framerate.Denominator != 0 { + want := float64(a.Canvas.Framerate.Numerator) / float64(a.Canvas.Framerate.Denominator) + got := float64(top.Framerate.Numerator) / float64(top.Framerate.Denominator) + if want != got { + add("fps", "this destination is configured for %.3g fps, but Twitch's Enhanced Broadcasting "+ + "configuration asks for %.3g fps.", want, got) + } + } + } + + if a.MaxAggregateBitrateKbps > 0 { + total := 0 + for _, e := range c.EncoderConfigurations { + if kbps, ok := e.BitrateKbps(); ok { + total += kbps + } + } + for _, e := range c.AudioConfigurations.Live { + if kbps, ok := e.BitrateKbps(); ok { + total += kbps + } + } + for _, e := range c.AudioConfigurations.VOD { + if kbps, ok := e.BitrateKbps(); ok { + total += kbps + } + } + if total > a.MaxAggregateBitrateKbps { + add("bitrate", "Twitch's Enhanced Broadcasting configuration totals %d kbps across every track, "+ + "above the %d kbps ceiling this destination asked for. Twitch does not treat that ceiling "+ + "as binding.", total, a.MaxAggregateBitrateKbps) + } + } + + // The two that matter most to issue #141, stated in both directions. Asking + // for the VOD track and not getting it is the failure the whole feature + // turns on; getting one nobody asked for means a mix would have to be routed + // to it that polyemesis has not been told to produce. + switch { + case a.VODAudio && len(c.AudioConfigurations.VOD) == 0: + add("vodAudio", "this destination asked for a separate VOD audio track and Twitch's configuration "+ + "contains none, so the VOD will carry the live mix.") + case !a.VODAudio && len(c.AudioConfigurations.VOD) > 0: + add("vodAudio", "Twitch's configuration contains a VOD audio track that this destination did not "+ + "ask for. It will be left unfed.") + } + + wantChannels := a.AudioChannels + if wantChannels == 0 { + wantChannels = DefaultAudioChannels + } + for _, track := range append(append([]AudioEncoderConfig{}, c.AudioConfigurations.Live...), + c.AudioConfigurations.VOD...) { + if track.Channels != 0 && track.Channels != wantChannels { + add("audioChannels", "Twitch asks for %d audio channels on track %d; polyemesis mixes %d.", + track.Channels, track.TrackID, wantChannels) + } + } + return out +} diff --git a/internal/multitrack/request_test.go b/internal/multitrack/request_test.go new file mode 100644 index 00000000..f736adc9 --- /dev/null +++ b/internal/multitrack/request_test.go @@ -0,0 +1,288 @@ +package multitrack + +import ( + "strings" + "testing" +) + +// canvas1080p30 is the shape the negotiated fixture was requested with, so a +// Reconcile against that fixture with this Ask should find nothing. +var canvas1080p30 = Canvas{ + Width: 1920, Height: 1080, CanvasWidth: 1920, CanvasHeight: 1080, + Framerate: Framerate{Numerator: 30, Denominator: 1}, +} + +// TestNewRequestAsksForOneVideoTrackAndTheVODAudioTrack pins the two +// preferences that make this feature reachable for polyemesis at all. +// +// One video track because that is what an RTMP destination publishes here -- +// asking for three would negotiate a ladder that cannot then be sent. And +// vod_track_audio because it is the switch that produces the second audio +// track; measured, a request with it false comes back with an empty VOD list. +// +// Proven able to fail against the committed tree by changing +// DefaultMaxVideoTracks (request.go) from 1 to 3, which made the test report +// "MaximumVideoTracks = 3, want 1". Restored from a /tmp copy; git diff --stat +// clean. +func TestNewRequestAsksForOneVideoTrackAndTheVODAudioTrack(t *testing.T) { + req := NewRequest(Ask{Version: "1.2.3", Canvas: canvas1080p30, VODAudio: true}) + + if req.Preferences.MaximumVideoTracks != 1 { + t.Errorf("MaximumVideoTracks = %d, want 1", req.Preferences.MaximumVideoTracks) + } + if !req.Preferences.VODTrackAudio { + t.Error("VODTrackAudio is false; no second audio track will be negotiated") + } + if got := len(req.Preferences.Canvases); got != 1 { + t.Fatalf("canvases = %d, want 1", got) + } + if req.Preferences.Canvases[0] != canvas1080p30 { + t.Errorf("canvas = %+v, want the operator's %+v", req.Preferences.Canvases[0], canvas1080p30) + } + if req.Client.Name != ClientName { + t.Errorf("client name = %q, want %q -- Twitch quotes this back to the operator", + req.Client.Name, ClientName) + } + if req.Client.Version != "1.2.3" { + t.Errorf("client version = %q, want the version it was given", req.Client.Version) + } + if req.Preferences.AudioSamplesPerSec != DefaultAudioSampleRate { + t.Errorf("audio rate = %d, want %d", req.Preferences.AudioSamplesPerSec, DefaultAudioSampleRate) + } + if req.Preferences.AudioChannels != DefaultAudioChannels { + t.Errorf("audio channels = %d, want %d", req.Preferences.AudioChannels, DefaultAudioChannels) + } +} + +// TestTheBitrateCeilingIsConvertedFromKbpsToBitsPerSecond guards a unit +// mismatch that would be invisible: polyemesis states bitrates in kbps +// everywhere, Twitch's field is bits per second, and a ceiling sent a thousand +// times too low is a ceiling that reads as "500 kbps aggregate" to the far end. +// +// Proven able to fail against the committed tree by removing the `* 1000` from +// NewRequest (request.go), which made the test report +// "MaximumAggregateBitrate = 8500, want 8500000". Restored from a /tmp copy; +// git diff --stat clean. +func TestTheBitrateCeilingIsConvertedFromKbpsToBitsPerSecond(t *testing.T) { + req := NewRequest(Ask{Canvas: canvas1080p30, MaxAggregateBitrateKbps: 8500}) + if got, want := req.Preferences.MaximumAggregateBitrate, uint64(8_500_000); got != want { + t.Errorf("MaximumAggregateBitrate = %d, want %d", got, want) + } + + // Zero omits the field rather than sending a ceiling of nothing, which would + // be a request for an empty ladder. + none := NewRequest(Ask{Canvas: canvas1080p30}) + if none.Preferences.MaximumAggregateBitrate != 0 { + t.Errorf("MaximumAggregateBitrate = %d, want it omitted when no ceiling was asked for", + none.Preferences.MaximumAggregateBitrate) + } +} + +// TestReconcileFindsNothingWhenTwitchAgreedWithWhatWasAsked is the calibration +// half of the divergence tests. Without it, a Reconcile that returned a +// finding for everything would still satisfy every test below. +// +// Proven able to fail against the committed tree by changing the rendition-size +// comparison in Reconcile (request.go) from `!=` to `==`, which made the test +// report "Reconcile found 1 divergence against a configuration that matched +// the ask". Restored from a /tmp copy; git diff --stat clean. +func TestReconcileFindsNothingWhenTwitchAgreedWithWhatWasAsked(t *testing.T) { + cfg := loadFixture(t, "negotiated-one-rendition.json") + ask := Ask{Canvas: canvas1080p30, VODAudio: true, MaxVideoTracks: 1} + + if got := Reconcile(ask, cfg); len(got) != 0 { + t.Errorf("Reconcile found %d divergence(s) against a configuration that matched the ask: %+v", + len(got), got) + } +} + +// TestReconcileReportsADivergenceRatherThanApplyingIt covers each way Twitch's +// answer can differ from the operator's settings. Every one of these is +// REPORTED: nothing here rewrites a setting, which is the product decision +// written up on Ask. +// +// Proven able to fail against the committed tree by making Reconcile +// (request.go) `return nil` as its first statement, which made every subtest +// report "Reconcile found no divergence". Restored from a /tmp copy; git diff +// --stat clean. +func TestReconcileReportsADivergenceRatherThanApplyingIt(t *testing.T) { + live := []AudioEncoderConfig{{Codec: "aac", TrackID: 0, Channels: 2, Settings: kbps(160)}} + vod := []AudioEncoderConfig{{Codec: "aac", TrackID: 1, Channels: 2, Settings: kbps(160)}} + rendition := func(w, h uint32, b int, fps uint32) VideoEncoderConfig { + return VideoEncoderConfig{ + Type: "obs_nvenc_h264_tex", Width: w, Height: h, Settings: kbps(b), + Framerate: &Framerate{Numerator: fps, Denominator: 1}, + } + } + + for _, tc := range []struct { + name string + ask Ask + cfg Config + field string + want string + }{ + { + name: "Twitch sent more renditions than polyemesis can publish", + ask: Ask{Canvas: canvas1080p30, VODAudio: true, MaxVideoTracks: 1}, + cfg: Config{ + EncoderConfigurations: []VideoEncoderConfig{ + rendition(1920, 1080, 6000, 30), rendition(1280, 720, 2500, 30), + }, + AudioConfigurations: AudioConfigurations{Live: live, VOD: vod}, + }, + field: "renditions", + want: "will not be sent", + }, + { + name: "Twitch chose a different size from the operator's rendition", + ask: Ask{Canvas: canvas1080p30, VODAudio: true, MaxVideoTracks: 1}, + cfg: Config{ + EncoderConfigurations: []VideoEncoderConfig{rendition(1280, 720, 4500, 30)}, + AudioConfigurations: AudioConfigurations{Live: live, VOD: vod}, + }, + field: "rendition", + want: "configured for 1920x1080", + }, + { + name: "Twitch chose a different frame rate", + ask: Ask{Canvas: canvas1080p30, VODAudio: true, MaxVideoTracks: 1}, + cfg: Config{ + EncoderConfigurations: []VideoEncoderConfig{rendition(1920, 1080, 6000, 60)}, + AudioConfigurations: AudioConfigurations{Live: live, VOD: vod}, + }, + field: "fps", + want: "60 fps", + }, + { + name: "the ladder exceeds the ceiling the destination asked for", + ask: Ask{Canvas: canvas1080p30, VODAudio: true, MaxVideoTracks: 1, + MaxAggregateBitrateKbps: 2500}, + cfg: Config{ + EncoderConfigurations: []VideoEncoderConfig{rendition(1920, 1080, 6000, 30)}, + AudioConfigurations: AudioConfigurations{Live: live, VOD: vod}, + }, + field: "bitrate", + want: "does not treat that ceiling as binding", + }, + { + name: "the VOD track was asked for and not granted", + ask: Ask{Canvas: canvas1080p30, VODAudio: true, MaxVideoTracks: 1}, + cfg: Config{ + EncoderConfigurations: []VideoEncoderConfig{rendition(1920, 1080, 6000, 30)}, + AudioConfigurations: AudioConfigurations{Live: live}, + }, + field: "vodAudio", + want: "the VOD will carry the live mix", + }, + { + name: "a VOD track arrived that nobody asked for", + ask: Ask{Canvas: canvas1080p30, MaxVideoTracks: 1}, + cfg: Config{ + EncoderConfigurations: []VideoEncoderConfig{rendition(1920, 1080, 6000, 30)}, + AudioConfigurations: AudioConfigurations{Live: live, VOD: vod}, + }, + field: "vodAudio", + want: "left unfed", + }, + { + name: "Twitch wants a channel count polyemesis does not mix", + ask: Ask{Canvas: canvas1080p30, VODAudio: true, MaxVideoTracks: 1}, + cfg: Config{ + EncoderConfigurations: []VideoEncoderConfig{rendition(1920, 1080, 6000, 30)}, + AudioConfigurations: AudioConfigurations{ + Live: []AudioEncoderConfig{{Codec: "aac", TrackID: 0, Channels: 6, Settings: kbps(160)}}, + VOD: vod, + }, + }, + field: "audioChannels", + want: "polyemesis mixes 2", + }, + } { + t.Run(tc.name, func(t *testing.T) { + got := Reconcile(tc.ask, &tc.cfg) + if len(got) == 0 { + t.Fatal("Reconcile found no divergence") + } + var matched *Divergence + for i := range got { + if got[i].Field == tc.field { + matched = &got[i] + break + } + } + if matched == nil { + t.Fatalf("no divergence on field %q; got %+v", tc.field, got) + } + if !strings.Contains(matched.Detail, tc.want) { + t.Errorf("detail = %q, want it to mention %q", matched.Detail, tc.want) + } + // The whole contract: reporting, not applying. The configuration + // Reconcile was handed must come back untouched. + if tc.cfg.AudioConfigurations.Live[0].Channels == 0 { + t.Error("Reconcile mutated the configuration it was asked to report on") + } + }) + } +} + +// kbps builds the one settings shape every measured encoder configuration +// carried. +func kbps(v int) Settings { + return Settings{"bitrate": []byte(itoa(v))} +} + +func itoa(v int) string { + if v == 0 { + return "0" + } + var buf [20]byte + i := len(buf) + for v > 0 { + i-- + buf[i] = byte('0' + v%10) + v /= 10 + } + return string(buf[i:]) +} + +// TestSettingsIntRefusesWhatItCannotRead. A setting that is absent and one that +// is a string have to be told apart from a setting that is genuinely zero, +// because a bitrate of 0 and a bitrate nobody stated are different facts and +// Reconcile adds them up. +// +// Proven able to fail against the committed tree by changing Settings.Int +// (multitrack.go) to `return int(f), true` on the unmarshal-error path, which +// made the "a string" subtest report "ok = true, want false". Restored from a +// /tmp copy; git diff --stat clean. +func TestSettingsIntRefusesWhatItCannotRead(t *testing.T) { + s := Settings{ + "whole": []byte(`160`), + "decimal": []byte(`160.0`), + "astring": []byte(`"160"`), + "null": []byte(`null`), + "zero": []byte(`0`), + } + for _, tc := range []struct { + key string + want int + wantOK bool + }{ + {"whole", 160, true}, + {"decimal", 160, true}, + {"astring", 0, false}, + {"null", 0, false}, + {"zero", 0, true}, + {"absent", 0, false}, + } { + t.Run(tc.key, func(t *testing.T) { + got, ok := s.Int(tc.key) + if ok != tc.wantOK { + t.Errorf("ok = %v, want %v", ok, tc.wantOK) + } + if got != tc.want { + t.Errorf("value = %d, want %d", got, tc.want) + } + }) + } +} diff --git a/internal/multitrack/testdata/negotiated-one-rendition.json b/internal/multitrack/testdata/negotiated-one-rendition.json new file mode 100644 index 00000000..f9e4395c --- /dev/null +++ b/internal/multitrack/testdata/negotiated-one-rendition.json @@ -0,0 +1,72 @@ +{ + "meta": { + "schema_version": "2025-01-25", + "service": "IVS", + "config_id": "49456f79-a985-4011-941f-3cde9897a0c6", + "required_encode_resource_estimate_percent": 12 + }, + "ingest_endpoints": [ + { + "protocol": "RTMP", + "url_template": "rtmp://ingest.global-contribute.live-video.net/app/{stream_key}" + }, + { + "protocol": "RTMPS", + "url_template": "rtmps://ingest.global-contribute.live-video.net/app/{stream_key}" + } + ], + "encoder_configurations": [ + { + "type": "obs_nvenc_h264_tex", + "bitrate_interpolation_points": [ + 0, + 3960, + 4800, + 6000 + ], + "framerate": { + "numerator": 30, + "denominator": 1 + }, + "gpu_scale_type": "OBS_SCALE_BICUBIC", + "width": 1920, + "height": 1080, + "canvas_index": 0, + "settings": { + "bitrate": 6000, + "bf": 3, + "keyint_sec": 2, + "lookahead": true, + "preset": "p6", + "profile": "high", + "adaptive_quantization": true, + "rate_control": "CBR", + "tune": "hq", + "multipass": "qres", + "opts": "lookaheadDepth=2" + } + } + ], + "audio_configurations": { + "live": [ + { + "codec": "aac", + "track_id": 0, + "channels": 2, + "settings": { + "bitrate": 160 + } + } + ], + "vod": [ + { + "codec": "aac", + "track_id": 1, + "channels": 2, + "settings": { + "bitrate": 160 + } + } + ] + } +} diff --git a/internal/multitrack/testdata/refused-no-gpu.json b/internal/multitrack/testdata/refused-no-gpu.json new file mode 100644 index 00000000..377ed992 --- /dev/null +++ b/internal/multitrack/testdata/refused-no-gpu.json @@ -0,0 +1,27 @@ +{ + "meta": { + "schema_version": "2025-01-25", + "service": "IVS", + "config_id": "3080dd39-6867-437d-a249-21ad798d06fe", + "required_encode_resource_estimate_percent": 0 + }, + "status": { + "result": "error", + "html_en_us": "Your broadcast software (polyemesis) did not send GPU Information which is required by GetClientConfiguration provided by Twitch Enhanced Broadcasting. Try installing or updating the driver for your GPU." + }, + "ingest_endpoints": [ + { + "protocol": "RTMP", + "url_template": "rtmp://ingest.global-contribute.live-video.net/app/{stream_key}" + }, + { + "protocol": "RTMPS", + "url_template": "rtmps://ingest.global-contribute.live-video.net/app/{stream_key}" + } + ], + "encoder_configurations": [], + "audio_configurations": { + "live": [], + "vod": [] + } +}