From 098b4f59dd2c1e2e029ed27b14b46022460b1df3 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Fri, 15 May 2026 13:23:38 +0200 Subject: [PATCH] fix(bmx): serve orion at the registry-advertised path, drop the /bmx/ prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The BMX registry advertises orion at `{BMX_SERVER}/core02/svc-bmx-adapter-orion/prod/orion` — no `/bmx/` prefix. That matches the upstream Bose capture in pkg/service/handlers/static/bmx_services_ustream.json. But our router nested both orion routes inside the `/bmx/` chi group, so the speaker asked `/core02/.../prod/orion/token` and our service routed `/bmx/core02/.../prod/orion/token` — pure path mismatch. The legacy preset URLs in issue #218 (LOCAL_INTERNET_RADIO presets pointing at `https://content.api.bose.io/core02/svc-bmx-adapter-orion/prod/orion/station?data=...`) also dead-ended for the same reason. Three changes: - Move `POST /core02/svc-bmx-adapter-orion/prod/orion/token` from the `/bmx/` group to top level so it matches what the registry hands the speaker. - Add the missing `GET /core02/svc-bmx-adapter-orion/prod/orion/station` that takes `data` as a query string. The handler reuses bmx.PlayCustomStream — base64-decode the JSON blob (streamUrl/ imageUrl/name) and rewrap it into the standard BmxPlaybackResponse shape, exactly the way soundcork's reference impl handles it (soundcork main.py:786, bmx.py:720). No auth check on this endpoint: `data` is the speaker's own preset payload, there's nothing privileged to gate, and the upstream behaviour treats it the same way. - Drop the local-invention `POST /bmx/orion/v1/playback/station/{data}` route. Nothing advertised it, nothing real-world called it, and keeping it as a "convenience alias" would have left a misleading duplicate next to the canonical path. TuneIn's `/bmx/tunein/...` routes stay where they are — TuneIn's upstream baseUrl genuinely is `{BMX_SERVER}/bmx/tunein`, so the chi group prefix is correct for that one. Router snapshot regenerated; TestOrionPlayback flipped from POST `/bmx/orion/v1/playback/station/{data}` to GET `/core02/...station?data=...` (no auth header); the orion entry in TestBMXUnauthorized's table is removed (the endpoint isn't authed anymore, by design). Refs #218. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/soundtouch-service/main.go | 11 ++++++++-- .../testdata/router_routes.txt | 4 ++-- pkg/service/handlers/handlers_bmx.go | 20 ++++++++++++------- pkg/service/handlers/handlers_bmx_test.go | 15 +++++++++++--- pkg/service/handlers/main_test.go | 7 ++++++- 5 files changed, 42 insertions(+), 15 deletions(-) diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go index 231e4b5..9608d5a 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -909,10 +909,17 @@ func setupRouter(server *handlers.Server) *chi.Mux { r.Delete("/v1/favorite/{stationID}", server.HandleTuneInDeleteFavorite) }) - r.Post("/orion/v1/playback/station/{data}", server.HandleOrionPlayback) - r.Post("/core02/svc-bmx-adapter-orion/prod/orion/token", server.HandleOrionToken) }) + // Orion (LOCAL_INTERNET_RADIO) lives at the top level — the BMX registry + // advertises baseUrl `{BMX_SERVER}/core02/svc-bmx-adapter-orion/prod/orion` + // (no `/bmx/` prefix; verified against the upstream capture in + // pkg/service/handlers/static/bmx_services_ustream.json), so speakers + // reach the token + station endpoints at exactly these paths under + // either DNS-interception or URL-flip migration. + r.Post("/core02/svc-bmx-adapter-orion/prod/orion/token", server.HandleOrionToken) + r.Get("/core02/svc-bmx-adapter-orion/prod/orion/station", server.HandleOrionPlayback) + r.Get("/custom/v1/playback/{encodedURL}", server.HandleCustomPlayback) r.Route("/streaming", func(r chi.Router) { diff --git a/cmd/soundtouch-service/testdata/router_routes.txt b/cmd/soundtouch-service/testdata/router_routes.txt index bc81325..ad937ac 100644 --- a/cmd/soundtouch-service/testdata/router_routes.txt +++ b/cmd/soundtouch-service/testdata/router_routes.txt @@ -30,6 +30,7 @@ GET /bmx/tunein/v1/playback/episodes/{podcastID} handlers.( GET /bmx/tunein/v1/playback/station/{stationID} handlers.(*Server).HandleTuneInPlayback-fm GET /bmx/tunein/v1/search handlers.(*Server).HandleTuneInSearch-fm GET /ced/* handlers.(*Server).HandleCedStatic +GET /core02/svc-bmx-adapter-orion/prod/orion/station handlers.(*Server).HandleOrionPlayback-fm GET /custom/v1/playback/{encodedURL} handlers.(*Server).HandleCustomPlayback-fm GET /customer/account/{account} handlers.(*Server).HandleMargeAccountProfile-fm GET /docs/* handlers.(*Server).HandleDocs-fm @@ -97,11 +98,10 @@ POST /accounts/{account}/group handlers.( POST /accounts/{account}/group/ handlers.(*Server).HandleMargeAddGroup-fm POST /accounts/{account}/group/{groupId} handlers.(*Server).HandleMargeModifyGroup-fm POST /alexa/certificate handlers.(*Server).HandleAlexaCertificate-fm -POST /bmx/core02/svc-bmx-adapter-orion/prod/orion/token handlers.(*Server).HandleOrionToken-fm -POST /bmx/orion/v1/playback/station/{data} handlers.(*Server).HandleOrionPlayback-fm POST /bmx/tunein/v1/favorite/{stationID} handlers.(*Server).HandleTuneInFavorite-fm POST /bmx/tunein/v1/report handlers.(*Server).HandleTuneInReport-fm POST /bmx/tunein/v1/token handlers.(*Server).HandleTuneInToken-fm +POST /core02/svc-bmx-adapter-orion/prod/orion/token handlers.(*Server).HandleOrionToken-fm POST /customer/account/{account} handlers.(*Server).HandleMargeUpdateAccountProfile-fm POST /customer/account/{account}/password handlers.(*Server).HandleMargeChangePassword-fm POST /mgmt/accounts/{accountId}/language handlers.(*Server).HandleMgmtUpdateAccountLanguage-fm diff --git a/pkg/service/handlers/handlers_bmx.go b/pkg/service/handlers/handlers_bmx.go index 90e864c..3d78fac 100644 --- a/pkg/service/handlers/handlers_bmx.go +++ b/pkg/service/handlers/handlers_bmx.go @@ -173,14 +173,20 @@ func (s *Server) HandleOrionToken(w http.ResponseWriter, _ *http.Request) { } } -// HandleOrionPlayback returns Orion playback information. +// HandleOrionPlayback returns Orion playback information for the +// /core02/svc-bmx-adapter-orion/prod/orion/station?data=... endpoint +// the speaker reaches by following its stored LOCAL_INTERNET_RADIO +// preset's `location` attribute. The `data` query string is the +// base64-encoded JSON blob (streamUrl/imageUrl/name) that the speaker +// constructed when the preset was first saved; we just decode and +// rewrap it into the Bose BmxPlaybackResponse shape via +// bmx.PlayCustomStream. +// +// No auth check: the data is the speaker's own input, nothing +// privileged is being protected, and soundcork's reference impl +// behaves the same way. func (s *Server) HandleOrionPlayback(w http.ResponseWriter, r *http.Request) { - if r.Header.Get("Authorization") == "" { - s.writeBMXUnauthorized(w) - return - } - - data := chi.URLParam(r, "data") + data := r.URL.Query().Get("data") resp, err := bmx.PlayCustomStream(data) if err != nil { diff --git a/pkg/service/handlers/handlers_bmx_test.go b/pkg/service/handlers/handlers_bmx_test.go index 949b6c6..b7be322 100644 --- a/pkg/service/handlers/handlers_bmx_test.go +++ b/pkg/service/handlers/handlers_bmx_test.go @@ -87,8 +87,13 @@ func TestOrionPlayback(t *testing.T) { // Base64 encoded: {"streamUrl": "http://example.com/stream", "imageUrl": "http://example.com/img.jpg", "name": "Test Orion"} data := "eyJzdHJlYW1VcmwiOiAiaHR0cDovL2V4YW1wbGUuY29tL3N0cmVhbSIsICJpbWFnZVVybCI6ICJodHRwOi8vZXhhbXBsZS5jb20vaW1nLmpwZyIsICJuYW1lIjogIlRlc3QgT3Jpb24ifQ==" - req, _ := http.NewRequest("POST", ts.URL+"/bmx/orion/v1/playback/station/"+data, nil) - req.Header.Set("Authorization", "Bearer mock-token") + // Speakers reach this endpoint by following the `location` attribute + // stored in a LOCAL_INTERNET_RADIO preset's contentItem — a GET to + // the upstream path with `data` as a query string. The data is + // already base64-URL-safe; passing it raw mirrors what the speaker + // emits (Go's url package re-encodes any `=` padding for transport). + req, _ := http.NewRequest("GET", + ts.URL+"/core02/svc-bmx-adapter-orion/prod/orion/station?data="+url.QueryEscape(data), nil) res, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) @@ -165,7 +170,11 @@ func TestBMXUnauthorized(t *testing.T) { {"GET", "/bmx/tunein/v1/playback/station/s123"}, {"GET", "/bmx/tunein/v1/playback/episodes/p123"}, {"GET", "/bmx/tunein/v1/playback/episode/p123"}, - {"POST", "/bmx/orion/v1/playback/station/data"}, + // Note: /core02/.../prod/orion/station is intentionally NOT in this + // list — orion playback takes its `data` blob from the speaker's + // own preset payload, there's no privileged material to gate, and + // soundcork's reference impl makes the same call (no auth on the + // station endpoint). See HandleOrionPlayback's doc comment. } for _, tc := range paths { diff --git a/pkg/service/handlers/main_test.go b/pkg/service/handlers/main_test.go index 6deb796..87bcb19 100644 --- a/pkg/service/handlers/main_test.go +++ b/pkg/service/handlers/main_test.go @@ -32,9 +32,14 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server) r.Get("/tunein/v1/navigate", server.HandleTuneInNavigate) r.Get("/tunein/v1/navigate/*", server.HandleTuneInNavigate) r.Get("/tunein/v1/search", server.HandleTuneInSearch) - r.Post("/orion/v1/playback/station/{data}", server.HandleOrionPlayback) }) + // Orion lives at the top level — see the matching note in + // cmd/soundtouch-service/main.go. Mirrored here so the test router + // exercises the same paths the production router does. + r.Post("/core02/svc-bmx-adapter-orion/prod/orion/token", server.HandleOrionToken) + r.Get("/core02/svc-bmx-adapter-orion/prod/orion/station", server.HandleOrionPlayback) + r.Get("/custom/v1/playback/{encodedURL}", server.HandleCustomPlayback) streamingRoutes := func(r chi.Router) {