diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go index d657b88..c5f2f52 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -1283,6 +1283,11 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) * r.Get("/blacklist/{deviceId}", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusMethodNotAllowed) }) + // app_key validation for the /speaker notification endpoint. Real Bose + // validated the app_key against its cloud; as the cloud replacement we + // accept it (200). A 404 here makes the speaker report "invalid app key" + // (HandleInvalidAppKeyCb) and refuse TTS/URL notifications. + r.Get("/auth", server.HandleSpeakerAuth) }) r.Route("/mgmt", func(r chi.Router) { diff --git a/cmd/soundtouch-service/testdata/router_routes.txt b/cmd/soundtouch-service/testdata/router_routes.txt index 413d75b..ff7f536 100644 --- a/cmd/soundtouch-service/testdata/router_routes.txt +++ b/cmd/soundtouch-service/testdata/router_routes.txt @@ -100,6 +100,7 @@ GET /streaming/resources/api_versions.xml handlers.( GET /streaming/software/update/account/{account} handlers.(*Server).HandleMargeSoftwareUpdate-fm GET /streaming/sourceproviders handlers.(*Server).HandleMargeSourceProviders-fm GET /updates/soundtouch handlers.(*Server).HandleMargeSoftwareUpdate-fm +GET /v1/auth handlers.(*Server).HandleSpeakerAuth-fm GET /v1/blacklist/{deviceId} setupRouter GET /web/* setupRouter.(*Server).HandleWeb HEAD /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm diff --git a/pkg/service/handlers/handlers_tts.go b/pkg/service/handlers/handlers_tts.go index 5eca64d..eff385c 100644 --- a/pkg/service/handlers/handlers_tts.go +++ b/pkg/service/handlers/handlers_tts.go @@ -1,9 +1,13 @@ package handlers import ( + "encoding/base64" "encoding/json" "fmt" + "log" "net/http" + "net/http/httputil" + "net/url" "strconv" "strings" @@ -12,7 +16,7 @@ import ( "github.com/go-chi/chi/v5" ) -// ttsSpeakRequest is the JSON body for POST /mgmt/tts/speak. Either DeviceID +// ttsSpeakRequest is the JSON body for POST /setup/tts/speak. Either DeviceID // (resolved to an IP via the datastore) or Host (an explicit IP/hostname) must // be set. The remaining fields fall back to the service defaults when empty. type ttsSpeakRequest struct { @@ -25,8 +29,11 @@ type ttsSpeakRequest struct { Volume *int `json:"volume,omitempty"` } -// HandleTTSSpeak synthesizes the requested text (or builds a direct URL), -// then tells the target speaker to play it via the /speaker endpoint. +// HandleTTSSpeak synthesizes the requested text, then tells the target speaker +// to play it as a LOCAL_INTERNET_RADIO ContentItem via the /custom/v1/playback +// proxy. This is the same path the "ding" health check uses and, unlike the +// /speaker notification endpoint, needs no Bose app_key (which speakers +// validate against the now-dead Bose cloud). func (s *Server) HandleTTSSpeak(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") @@ -64,34 +71,51 @@ func (s *Server) HandleTTSSpeak(w http.ResponseWriter, r *http.Request) { return } - volume := svc.DefaultVolume() - if req.Volume != nil { - volume = *req.Volume - } + location := buildCustomPlaybackURL(svc.BaseURL(), playURL, "AfterTouch TTS: "+req.Text) c := client.NewClientFromHost(host) - - var playErr error - if volume > 0 { - playErr = c.PlayURL(playURL, svc.AppKey(), "AfterTouch TTS", req.Text, "", volume) - } else { - playErr = c.PlayURL(playURL, svc.AppKey(), "AfterTouch TTS", req.Text, "") - } - - if playErr != nil { - http.Error(w, fmt.Sprintf(`{"error":%q}`, "play: "+playErr.Error()), http.StatusBadGateway) + if err := c.SelectLocalInternetRadio(location, "", "AfterTouch TTS", ""); err != nil { + http.Error(w, fmt.Sprintf(`{"error":%q}`, "play: "+err.Error()), http.StatusBadGateway) return } if err := json.NewEncoder(w).Encode(map[string]interface{}{ - "status": "ok", - "host": host, - "url": playURL, + "status": "ok", + "host": host, + "url": playURL, + "location": location, }); err != nil { http.Error(w, "failed to encode response", http.StatusInternalServerError) } } +// buildCustomPlaybackURL wraps a target audio URL in the AfterTouch +// /custom/v1/playback proxy so the speaker plays it via LOCAL_INTERNET_RADIO +// (same mechanism as the "ding"). base is this service's public URL. +func buildCustomPlaybackURL(base, audioURL, name string) string { + base = strings.TrimRight(base, "/") + encoded := base64.URLEncoding.EncodeToString([]byte(audioURL)) + + return base + "/custom/v1/playback/" + encoded + "?name=" + url.QueryEscape(name) +} + +// HandleSpeakerAuth accepts the app_key the speaker presents when validating a +// /speaker notification. Real Bose validated against its cloud; as the cloud +// replacement we always accept (200) so the speaker doesn't report an invalid +// app key and refuse the notification. +func (s *Server) HandleSpeakerAuth(w http.ResponseWriter, r *http.Request) { + // TEMP DEBUG: dump the full request so we can see how the speaker presents + // the app_key (query param / header / body) and what a valid response might + // need to look like. Remove once the /speaker auth contract is understood. + if dump, err := httputil.DumpRequest(r, true); err == nil { + log.Printf("[TTS][/v1/auth DEBUG] %s", dump) + } else { + log.Printf("[TTS][/v1/auth DEBUG] dump failed: %v; method=%s url=%s headers=%v", err, r.Method, r.URL.String(), r.Header) + } + + w.WriteHeader(http.StatusOK) +} + // resolveTTSHost returns the speaker IP/hostname to target. An explicit Host // wins; otherwise DeviceID is looked up in the datastore. func (s *Server) resolveTTSHost(req ttsSpeakRequest) (string, error) { diff --git a/pkg/service/tts/service.go b/pkg/service/tts/service.go index acd8751..f2d42b7 100644 --- a/pkg/service/tts/service.go +++ b/pkg/service/tts/service.go @@ -49,6 +49,10 @@ func NewService(provider Provider, cfg Config) *Service { // ProviderName returns the active provider's identifier. func (s *Service) ProviderName() string { return s.provider.Name() } +// BaseURL returns the configured public service URL (used to build the +// /custom/v1/playback LOCAL_INTERNET_RADIO proxy URL for playback). +func (s *Service) BaseURL() string { return s.cfg.BaseURL } + // AppKey returns the configured Bose /speaker app_key. func (s *Service) AppKey() string { return s.cfg.AppKey }