From 2df0adf4e37353275e3b35649f9a6d07e8fc5d3b Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Sun, 17 May 2026 14:35:51 +0200 Subject: [PATCH] feat(bmx): SiriusXM live-adapter logging stub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bmx_services.json advertises SIRIUSXM_EVEREST at `{BMX_SERVER}/core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter` and bmx_services_availability.json lists it as available, so speakers that try SiriusXM hit that path. Without a route we 404'd silently and the call was invisible in our logs. - HandleSiriusXMLiveAdapter at the bare base URL returns the SIRIUSXM_EVEREST service descriptor (selected by id.name from bmx_services.json, with {BMX_SERVER}/{MEDIA_SERVER} substitution). Mirrors deborahgu/soundcork main.py:805 in shape. - HandleSiriusXMLiveAdapterSubpath catches every sub-path advertised by the descriptor's _links (/availability, /token, /navigate, /logout) plus the playback URLs the speaker discovers via navigate. Logs the request with method+path+UA+Authorization+RawQuery, then 404s — giving the next implementation pass concrete data about what the speaker actually asks for. Two helpers added to handlers_bmx.go (shared with any future BMX-segment stub): - extractBMXService(json, name) — find a service entry by id.name. - (*Server).applyBMXTemplate(content) — {BMX_SERVER}/{MEDIA_SERVER} substitution, identical to what HandleBMXRegistry does inline. Routes registered next to Orion at the top level — same convention (no /bmx/ prefix) because bmx_services.json advertises baseUrl without that prefix and speakers reach the path verbatim under either migration mode. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/soundtouch-service/main.go | 10 ++++ pkg/service/handlers/handlers_bmx.go | 56 +++++++++++++++++++ pkg/service/handlers/handlers_bmx_siriusxm.go | 53 ++++++++++++++++++ 3 files changed, 119 insertions(+) create mode 100644 pkg/service/handlers/handlers_bmx_siriusxm.go diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go index 2eac068..a952c39 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -953,6 +953,16 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) * r.Post("/core02/svc-bmx-adapter-orion/prod/orion/token", server.HandleOrionToken) r.Get("/core02/svc-bmx-adapter-orion/prod/orion/station", server.HandleOrionPlayback) + // SiriusXM lives at the top level by the same convention. bmx_services.json + // advertises baseUrl `{BMX_SERVER}/core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter` + // (no /bmx/ prefix), so speakers reach this exact path under either + // migration mode. The bare path returns the service descriptor (matches + // soundcork main.py:805); sub-paths advertised by the descriptor's _links + // (/availability, /navigate, /token, /logout) currently log + 404 so + // future implementation work has visibility into real speaker calls. + r.HandleFunc("/core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter", server.HandleSiriusXMLiveAdapter) + r.HandleFunc("/core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/*", server.HandleSiriusXMLiveAdapterSubpath) + r.Get("/custom/v1/playback/{encodedURL}", server.HandleCustomPlayback) r.Route("/streaming", func(r chi.Router) { diff --git a/pkg/service/handlers/handlers_bmx.go b/pkg/service/handlers/handlers_bmx.go index d5799ad..2368f5f 100644 --- a/pkg/service/handlers/handlers_bmx.go +++ b/pkg/service/handlers/handlers_bmx.go @@ -12,6 +12,8 @@ package handlers import ( + "encoding/json" + "fmt" "net/http" "strings" ) @@ -43,6 +45,60 @@ func (s *Server) HandleBMXServicesAvailability(w http.ResponseWriter, _ *http.Re _, _ = w.Write(bmxServicesAvailabilityJSON) } +// extractBMXService finds a single service entry in bmx_services.json by +// its `id.name` (e.g. "SIRIUSXM_EVEREST", "TUNEIN"). Returns the raw JSON +// segment for that service so callers can apply {BMX_SERVER} / {MEDIA_SERVER} +// substitution and write it back to the wire. +func extractBMXService(bmxJSON []byte, name string) (json.RawMessage, error) { + var wrapper struct { + BMXServices []json.RawMessage `json:"bmx_services"` + } + + if err := json.Unmarshal(bmxJSON, &wrapper); err != nil { + return nil, fmt.Errorf("parse bmx_services.json: %w", err) + } + + for _, raw := range wrapper.BMXServices { + var idOnly struct { + ID struct { + Name string `json:"name"` + } `json:"id"` + } + + if err := json.Unmarshal(raw, &idOnly); err != nil { + continue + } + + if idOnly.ID.Name == name { + return raw, nil + } + } + + return nil, fmt.Errorf("service %q not found in bmx_services.json", name) +} + +// applyBMXTemplate runs the same {BMX_SERVER} / {MEDIA_SERVER} substitution +// HandleBMXRegistry uses, so service-descriptor responses produced from +// sub-segments of bmx_services.json land at the same hostnames the +// registry advertises. +func (s *Server) applyBMXTemplate(content string) string { + baseURL := s.serverURL + + s.mu.RLock() + dnsEnabled := s.dnsEnabled + s.mu.RUnlock() + + bmxServer := baseURL + if dnsEnabled { + bmxServer = "https://content.api.bose.io" + } + + content = strings.ReplaceAll(content, "{BMX_SERVER}", bmxServer) + content = strings.ReplaceAll(content, "{MEDIA_SERVER}", baseURL+"/media") + + return content +} + // writeBMXUnauthorized writes the canonical 401 used by every BMX adapter // handler that requires an Authorization header (TuneIn variants, Orion // playback). Currently unused because all gate sites are temporarily diff --git a/pkg/service/handlers/handlers_bmx_siriusxm.go b/pkg/service/handlers/handlers_bmx_siriusxm.go new file mode 100644 index 0000000..a4c25fe --- /dev/null +++ b/pkg/service/handlers/handlers_bmx_siriusxm.go @@ -0,0 +1,53 @@ +// Package handlers — SiriusXM BMX adapter (logging stub). +// +// bmx_services.json declares SIRIUSXM_EVEREST at +// `{BMX_SERVER}/core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter`, +// and bmx_services_availability.json lists it as available — so speakers +// that try SiriusXM hit this path. The bare URL returns the service +// descriptor; sub-paths advertised by the descriptor's _links +// (/availability, /token, /navigate, /logout, plus the playback paths +// the speaker discovers via navigate) currently log + 404 so we have +// visibility into real speaker calls for the next implementation pass. +// +// Reference: deborahgu/soundcork main.py:805 takes the same shape — +// returns the SiriusXM service descriptor from the BMX services array +// (hardcoded index 2). We select by id.name instead of array index. +package handlers + +import ( + "log" + "net/http" +) + +// HandleSiriusXMLiveAdapter returns the SIRIUSXM_EVEREST service descriptor +// from bmx_services.json for the bare live-adapter base URL. +func (s *Server) HandleSiriusXMLiveAdapter(w http.ResponseWriter, r *http.Request) { + log.Printf("[BMX SiriusXM] %s %s ua=%q auth=%q query=%q", + r.Method, r.URL.Path, r.UserAgent(), + r.Header.Get("Authorization"), r.URL.RawQuery) + + svc, err := extractBMXService(bmxServicesJSON, "SIRIUSXM_EVEREST") + if err != nil { + log.Printf("[BMX SiriusXM] failed to extract service descriptor: %v", err) + http.Error(w, "service descriptor unavailable", http.StatusInternalServerError) + + return + } + + body := s.applyBMXTemplate(string(svc)) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(body)) +} + +// HandleSiriusXMLiveAdapterSubpath logs and 404s any unimplemented sub-path +// under the SiriusXM live-adapter. Visibility for the next implementation +// pass — the _links in the descriptor publish /availability, /token, +// /navigate, /logout; playback URLs come dynamically from navigate. +func (s *Server) HandleSiriusXMLiveAdapterSubpath(w http.ResponseWriter, r *http.Request) { + log.Printf("[BMX SiriusXM] UNIMPLEMENTED %s %s ua=%q auth=%q query=%q", + r.Method, r.URL.Path, r.UserAgent(), + r.Header.Get("Authorization"), r.URL.RawQuery) + + http.Error(w, "not implemented", http.StatusNotFound) +}