mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 08:36:13 +00:00
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:
co-authored by
Claude Opus 4.8
parent
169c1c5b9f
commit
56c4ae4e2d
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user