fix(tts): play via LOCAL_INTERNET_RADIO; accept app_key at /v1/auth

Root cause of the failed TTS playback: the /speaker notification path
makes the speaker validate the app_key via GET /v1/auth against the
service, which returned 404 -> the speaker reports an invalid app key
(HandleInvalidAppKeyCb) and refuses to play. Our /media/tts hosting was
fine all along (confirmed by a direct GET returning the mp3).

Two fixes:

- TTS speak now plays the synthesized clip as a LOCAL_INTERNET_RADIO
  ContentItem via the /custom/v1/playback proxy (the same mechanism the
  "ding" health check uses), which needs no app_key. New
  buildCustomPlaybackURL helper + tts.Service.BaseURL().
- Add GET /v1/auth -> 200 so the /speaker notification path also works
  (we're the cloud replacement; a 404 there is read as "invalid app
  key"). Includes a TEMPORARY full-request debug dump on /v1/auth to
  learn how the speaker presents the app_key; to be removed later.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-05-31 22:35:31 +02:00
co-authored by Claude Opus 4.8
parent 169c1c5b9f
commit 56c4ae4e2d
4 changed files with 54 additions and 20 deletions
+5
View File
@@ -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) {
+1
View File
@@ -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
+44 -20
View File
@@ -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) {
+4
View File
@@ -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 }