From 80cfb03f6e09778cc3046b4031648f0c7f3ac77e Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Sun, 31 May 2026 21:27:54 +0200 Subject: [PATCH] feat(tts): default to /speaker playback; drop /v1/auth debug dump Confirmed working on a real speaker (Bose_Lisa/27.0.6): the speaker GETs /v1/auth at audionotification.api.bosecm.com (DNS-redirected to us) with the app_key in an "Apikeyheader" header, and an empty 200 is sufficient. - Make "speaker" the default playback method (ducks + resumes the current playback, supports volume) for the speak endpoint, the CLI --method flag, and the web UI button; "radio" remains opt-in. - Remove the temporary full-request debug dump from /v1/auth now that the contract is understood; document it in the handler comment instead. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/soundtouch-cli/cmd_tts.go | 4 +- .../docs/reference/SPEAKER-ENDPOINT.md | 5 +- pkg/service/handlers/handlers_tts.go | 58 ++++++++----------- 3 files changed, 30 insertions(+), 37 deletions(-) diff --git a/cmd/soundtouch-cli/cmd_tts.go b/cmd/soundtouch-cli/cmd_tts.go index 0b89c14..8b983de 100644 --- a/cmd/soundtouch-cli/cmd_tts.go +++ b/cmd/soundtouch-cli/cmd_tts.go @@ -54,8 +54,8 @@ func ttsCloudCmd() *cli.Command { }, &cli.StringFlag{ Name: "method", - Usage: "Playback method: 'radio' (LOCAL_INTERNET_RADIO, no app_key, replaces source) or 'speaker' (/speaker notification, ducks+resumes, supports volume)", - Value: "radio", + Usage: "Playback method: 'speaker' (/speaker notification, ducks+resumes, supports volume) or 'radio' (LOCAL_INTERNET_RADIO, no app_key, replaces source)", + Value: "speaker", }, ), Action: ttsCloud, diff --git a/docs/content/docs/reference/SPEAKER-ENDPOINT.md b/docs/content/docs/reference/SPEAKER-ENDPOINT.md index 0ff5c20..3a7d977 100644 --- a/docs/content/docs/reference/SPEAKER-ENDPOINT.md +++ b/docs/content/docs/reference/SPEAKER-ENDPOINT.md @@ -318,8 +318,9 @@ curl -X POST http://soundtouch.local:8000/setup/tts/speak \ ``` `deviceId` may be used instead of `host` (the service resolves it to an IP from its datastore). Optional fields: `language`, `voice`, `volume`, and `method` -(`radio`, the default LOCAL_INTERNET_RADIO path, or `speaker`, the /speaker -notification path that ducks and resumes playback). +(`speaker`, the default /speaker notification path that ducks and resumes +playback, or `radio`, the LOCAL_INTERNET_RADIO path that needs no app_key but +replaces the current source). CLI (`speaker tts-cloud` routes through the service for Cloud TTS, in contrast to `speaker tts` which sends a Google Translate URL straight to the speaker): diff --git a/pkg/service/handlers/handlers_tts.go b/pkg/service/handlers/handlers_tts.go index 3692985..8bf40ef 100644 --- a/pkg/service/handlers/handlers_tts.go +++ b/pkg/service/handlers/handlers_tts.go @@ -4,9 +4,7 @@ import ( "encoding/base64" "encoding/json" "fmt" - "log" "net/http" - "net/http/httputil" "net/url" "strconv" "strings" @@ -28,19 +26,19 @@ type ttsSpeakRequest struct { Format string `json:"format,omitempty"` Volume *int `json:"volume,omitempty"` // Method selects how the clip is played on the speaker: - // "radio" (default) — LOCAL_INTERNET_RADIO via /custom/v1/playback, + // "speaker" (default) — POST /speaker notification; ducks and resumes + // the current playback, supports volume. Requires + // the speaker to accept the app_key (validated via + // GET /v1/auth, which we answer 200). + // "radio" — LOCAL_INTERNET_RADIO via /custom/v1/playback, // no app_key; replaces the current source. - // "speaker" — POST /speaker notification; ducks and resumes - // the current playback, supports volume, but - // requires the speaker to accept the app_key - // (validated via GET /v1/auth, which we answer 200). Method string `json:"method,omitempty"` } // HandleTTSSpeak synthesizes the requested text and plays it on the target // speaker. Two playback methods (see ttsSpeakRequest.Method): the default -// LOCAL_INTERNET_RADIO path (like the "ding", no app_key) or the /speaker -// notification path (ducks/resumes and supports volume). +// /speaker notification path (ducks/resumes and supports volume) or the +// LOCAL_INTERNET_RADIO path (like the "ding", no app_key, replaces the source). func (s *Server) HandleTTSSpeak(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") @@ -84,7 +82,16 @@ func (s *Server) HandleTTSSpeak(w http.ResponseWriter, r *http.Request) { resp := map[string]interface{}{"status": "ok", "host": host, "url": playURL} switch method { - case "speaker": + case "radio": + location := buildCustomPlaybackURL(svc.BaseURL(), playURL, "AfterTouch TTS: "+req.Text) + if err := c.SelectLocalInternetRadio(location, "", "AfterTouch TTS", ""); err != nil { + http.Error(w, fmt.Sprintf(`{"error":%q}`, "play (radio): "+err.Error()), http.StatusBadGateway) + return + } + + resp["method"] = "radio" + resp["location"] = location + default: // "speaker" or unset — ducks and resumes the current playback appKey := svc.AppKey() if appKey == "" { // The speaker validates the app_key via GET /v1/auth, which we @@ -110,15 +117,6 @@ func (s *Server) HandleTTSSpeak(w http.ResponseWriter, r *http.Request) { } resp["method"] = "speaker" - default: // "radio" or unset - location := buildCustomPlaybackURL(svc.BaseURL(), playURL, "AfterTouch TTS: "+req.Text) - if err := c.SelectLocalInternetRadio(location, "", "AfterTouch TTS", ""); err != nil { - http.Error(w, fmt.Sprintf(`{"error":%q}`, "play (radio): "+err.Error()), http.StatusBadGateway) - return - } - - resp["method"] = "radio" - resp["location"] = location } if err := json.NewEncoder(w).Encode(resp); err != nil { @@ -136,20 +134,14 @@ func buildCustomPlaybackURL(base, audioURL, name string) string { 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) - } - +// HandleSpeakerAuth accepts the app_key a speaker presents when validating a +// /speaker notification. The speaker issues GET /v1/auth to the (now-dead) Bose +// host audionotification.api.bosecm.com — which AfterTouch's DNS interception +// points at us — with the key in an "Apikeyheader" header. An empty 200 is +// sufficient; real Bose validated against its cloud, but as the cloud +// replacement we always accept, so the speaker doesn't report an invalid app +// key (HandleInvalidAppKeyCb) and refuse the notification. +func (s *Server) HandleSpeakerAuth(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }