diff --git a/cmd/soundtouch-cli/cmd_speaker.go b/cmd/soundtouch-cli/cmd_speaker.go index 2f2879c..33d1ac1 100644 --- a/cmd/soundtouch-cli/cmd_speaker.go +++ b/cmd/soundtouch-cli/cmd_speaker.go @@ -136,6 +136,40 @@ func playURL(c *cli.Context) error { return nil } +// playURLUPnP plays audio from a URL via the speaker's UPnP AVTransport service. +// Unlike `speaker url` (the /speaker play_info path), it needs no app-key and no +// DNS interception, so it works on a plain LAN. It switches the speaker to the +// UPNP source and replaces the current playback (no duck-and-resume), and the +// speaker itself must be able to reach the URL. +func playURLUPnP(c *cli.Context) error { + clientConfig := GetClientConfig(c) + urlStr := c.String("url") + + if urlStr == "" { + PrintError("URL is required") + return fmt.Errorf("URL cannot be empty") + } + + PrintDeviceHeader(fmt.Sprintf("Playing URL via UPnP: %s", urlStr), clientConfig.Host, clientConfig.Port) + + client, err := CreateSoundTouchClient(clientConfig) + if err != nil { + PrintError(fmt.Sprintf("Failed to create client: %v", err)) + return err + } + + if err := client.PlayURLViaUPnP(urlStr); err != nil { + PrintError(fmt.Sprintf("Failed to play URL via UPnP: %v", err)) + return err + } + + fmt.Printf("✅ URL playback started via UPnP\n") + fmt.Printf(" URL: %s\n", urlStr) + fmt.Printf(" Note: replaces the current source (UPNP); no app-key or DNS needed\n") + + return nil +} + // playNotification plays a notification sound or a local file on the speaker func playNotification(c *cli.Context) error { clientConfig := GetClientConfig(c) diff --git a/cmd/soundtouch-cli/main.go b/cmd/soundtouch-cli/main.go index 2f4aa3c..c77bb9f 100644 --- a/cmd/soundtouch-cli/main.go +++ b/cmd/soundtouch-cli/main.go @@ -1926,6 +1926,20 @@ func main() { }, }, }, + { + Name: "url-upnp", + Usage: "Play a URL via UPnP/AVTransport (no app-key, no DNS; replaces current source)", + Action: playURLUPnP, + Before: RequireHost, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "url", + Aliases: []string{"u"}, + Usage: "URL of the audio content to play (must be reachable by the speaker)", + Required: true, + }, + }, + }, { Name: "notify", Usage: "Play a notification sound or local file", diff --git a/docs/content/docs/reference/API-ENDPOINTS.md b/docs/content/docs/reference/API-ENDPOINTS.md index 2def64e..d14a46b 100644 --- a/docs/content/docs/reference/API-ENDPOINTS.md +++ b/docs/content/docs/reference/API-ENDPOINTS.md @@ -386,6 +386,47 @@ Plays TTS messages or URL content for notifications (ST-10 Series only). - Custom metadata for NowPlaying display - Pauses current content, plays notification, then resumes +#### Alternative: play a URL via UPnP / AVTransport (no app key, no DNS) + +If the `play_info` DNS requirement above is a problem (for example a home +automation hub that just wants to push a TTS or notification clip), the speaker's +UPnP `AVTransport` service can play a URL directly with no app key and no DNS +interception. POST a SOAP `SetAVTransportURI` to the MediaRenderer control +endpoint on port **8091** (not 8090), then `Play`: + +``` +POST http://:8091/AVTransport/Control +Content-Type: text/xml; charset="utf-8" +SOAPAction: "urn:schemas-upnp-org:service:AVTransport:1#SetAVTransportURI" + + + + + + 0 + http://<host>/clip.mp3 + + + + +``` + +The URL must be plain **`http://`**: the speaker's AVTransport rejects `https://` +("URI must start with http://, qplay:// or Stored Music XML") and then reports a +misleading `402 "No URI supplied"`. For an `https`-only source, host the clip +over HTTP or use a method that proxies it (the service TTS / `LOCAL_INTERNET_RADIO` +path). + +Trade-offs versus `play_info`: this switches the speaker to the `UPNP` source and +**replaces** the current playback (it does not duck and resume), and the speaker +itself must be able to reach the URL. The CLI wraps both steps: + +```bash +soundtouch-cli --host speaker url-upnp --url http:///clip.mp3 +``` + +(Thanks to @dagrider in #517 for surfacing this approach.) + ### GET /playNotification ✅ **Implemented** Plays a notification beep sound (ST-10 Series only). diff --git a/pkg/client/avtransport.go b/pkg/client/avtransport.go new file mode 100644 index 0000000..28006d9 --- /dev/null +++ b/pkg/client/avtransport.go @@ -0,0 +1,152 @@ +package client + +import ( + "bytes" + "encoding/xml" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/gesellix/bose-soundtouch/pkg/speaker" +) + +// avTransportControlPath is the UPnP AVTransport control endpoint on the +// speaker's MediaRenderer (served on speaker.UPnPPort, not HTTPPort). +const avTransportControlPath = "/AVTransport/Control" + +// avTransportServiceType is the UPnP service type used in the SOAPAction header +// and the action element namespace. +const avTransportServiceType = "urn:schemas-upnp-org:service:AVTransport:1" + +// soapEnvelope wraps a SOAP action body in the standard envelope. +const soapEnvelope = `` + + `` + + `%s` + +// PlayURLViaUPnP plays an audio URL on the speaker through its UPnP AVTransport +// service: SetAVTransportURI followed by Play. +// +// Unlike the /speaker play_info path (PlayURL/PlayCustom), this needs no app_key +// and no DNS interception, so it works on a plain LAN. The trade-offs: it +// switches the speaker to the UPNP source and replaces the current playback +// (it does not duck and resume like a notification), and the speaker itself must +// be able to reach mediaURL. The speaker auto-plays on SetAVTransportURI on +// current firmware; the explicit Play afterwards makes it robust regardless of +// the speaker's prior transport state. +func (c *Client) PlayURLViaUPnP(mediaURL string) error { + mediaURL = strings.TrimSpace(mediaURL) + if mediaURL == "" { + return fmt.Errorf("media URL cannot be empty") + } + + // The speaker's AVTransport rejects https:// outright ("URI must start with + // http://, qplay:// or Stored Music XML") and then reports a misleading + // "No URI supplied" 402. Fail fast with an actionable message instead. + if strings.HasPrefix(strings.ToLower(mediaURL), "https://") { + return fmt.Errorf("the speaker's UPnP AVTransport only accepts plain http:// URLs, not https:// — host the clip over HTTP, or use a method that proxies it (e.g. the service TTS/radio path): %s", mediaURL) + } + + if err := c.SetAVTransportURI(mediaURL); err != nil { + return err + } + + return c.AVTransportPlay() +} + +// SetAVTransportURI points the speaker's AVTransport at mediaURL (UPnP +// SetAVTransportURI action). Metadata is sent empty, which the speaker accepts. +// Note the speaker only accepts http:// (and qplay:// / Stored Music) URIs, not +// https://; PlayURLViaUPnP guards against that. +func (c *Client) SetAVTransportURI(mediaURL string) error { + body := `` + + `0` + + `` + escapeXMLText(mediaURL) + `` + + `` + + `` + + return c.soapAVTransport("SetAVTransportURI", body) +} + +// AVTransportPlay starts playback (UPnP Play action, Speed 1). +func (c *Client) AVTransportPlay() error { + body := `` + + `01` + + `` + + return c.soapAVTransport("Play", body) +} + +// soapAVTransport POSTs a SOAP action to the speaker's AVTransport control URL. +func (c *Client) soapAVTransport(action, innerBody string) error { + controlURL, err := c.avTransportControlURL() + if err != nil { + return err + } + + payload := fmt.Sprintf(soapEnvelope, innerBody) + + req, err := http.NewRequest(http.MethodPost, controlURL, strings.NewReader(payload)) + if err != nil { + return fmt.Errorf("create %s request: %w", action, err) + } + + req.Header.Set("Content-Type", `text/xml; charset="utf-8"`) + req.Header.Set("User-Agent", c.userAgent) + // UPnP control points send a SOAPAction header. Write it through the map + // directly to preserve the exact casing the UPnP convention uses (Set would + // canonicalise it to "Soapaction"), mirroring how this codebase preserves + // the speaker-facing ETag header casing. + req.Header["SOAPAction"] = []string{`"` + avTransportServiceType + "#" + action + `"`} + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("execute %s: %w", action, err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<12)) + return fmt.Errorf("UPnP %s failed with status %d: %s", action, resp.StatusCode, strings.TrimSpace(string(b))) + } + + return nil +} + +// avTransportControlURL derives the UPnP AVTransport control URL +// (http://:/AVTransport/Control) from the client's base URL, +// which targets the :8090 local API. UPnP control lives on a different port. +func (c *Client) avTransportControlURL() (string, error) { + if c.avTransportURLOverride != "" { + return c.avTransportURLOverride, nil + } + + u, err := url.Parse(c.baseURL) + if err != nil { + return "", fmt.Errorf("parse base URL %q: %w", c.baseURL, err) + } + + host := u.Hostname() + if host == "" { + return "", fmt.Errorf("no host in base URL %q", c.baseURL) + } + + hostPort := net.JoinHostPort(host, strconv.Itoa(speaker.UPnPPort)) + + return "http://" + hostPort + avTransportControlPath, nil +} + +// escapeXMLText XML-escapes a string for safe inclusion as element character +// data (e.g. the media URL inside ). +func escapeXMLText(s string) string { + var b bytes.Buffer + + _ = xml.EscapeText(&b, []byte(s)) + + return b.String() +} diff --git a/pkg/client/avtransport_test.go b/pkg/client/avtransport_test.go new file mode 100644 index 0000000..ffce3d9 --- /dev/null +++ b/pkg/client/avtransport_test.go @@ -0,0 +1,122 @@ +package client + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestAVTransportControlURL(t *testing.T) { + tests := []struct { + name string + host string + want string + }{ + {name: "host only", host: "192.0.2.10", want: "http://192.0.2.10:8091/AVTransport/Control"}, + {name: "host with api port", host: "http://192.0.2.10:8090", want: "http://192.0.2.10:8091/AVTransport/Control"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := NewClientFromHost(tt.host) + + got, err := c.avTransportControlURL() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if got != tt.want { + t.Errorf("control URL = %q, want %q", got, tt.want) + } + }) + } +} + +func TestPlayURLViaUPnPRejectsHTTPS(t *testing.T) { + called := false + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + called = true + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + c := NewClientFromHost("192.0.2.10") + c.avTransportURLOverride = server.URL + + // The speaker rejects https:// URIs, so we should fail fast without even + // contacting it, with a message that names the constraint. + err := c.PlayURLViaUPnP("https://example.com/clip.mp3") + if err == nil { + t.Fatal("expected an error for an https:// URL") + } + + if !strings.Contains(err.Error(), "http://") { + t.Errorf("error should explain the http:// requirement, got: %v", err) + } + + if called { + t.Error("no SOAP request should be sent for an https:// URL") + } +} + +func TestPlayURLViaUPnP(t *testing.T) { + type capture struct { + path string + soapAction string + contentType string + body string + } + + var calls []capture + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + calls = append(calls, capture{ + path: r.URL.Path, + soapAction: r.Header.Get("SOAPAction"), + contentType: r.Header.Get("Content-Type"), + body: string(b), + }) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + c := NewClientFromHost("192.0.2.10") + c.avTransportURLOverride = server.URL // route SOAP at the test server + + mediaURL := "http://192.0.2.99/tts/hello.mp3?a=1&b=2" + if err := c.PlayURLViaUPnP(mediaURL); err != nil { + t.Fatalf("PlayURLViaUPnP: %v", err) + } + + // Two SOAP actions in order: SetAVTransportURI then Play. + if len(calls) != 2 { + t.Fatalf("expected 2 SOAP calls, got %d", len(calls)) + } + + set, play := calls[0], calls[1] + + if !strings.Contains(set.soapAction, "AVTransport:1#SetAVTransportURI") { + t.Errorf("first SOAPAction = %q, want SetAVTransportURI", set.soapAction) + } + + if !strings.Contains(play.soapAction, "AVTransport:1#Play") { + t.Errorf("second SOAPAction = %q, want Play", play.soapAction) + } + + if !strings.HasPrefix(set.contentType, "text/xml") { + t.Errorf("Content-Type = %q, want text/xml", set.contentType) + } + + // The media URL must be XML-escaped inside (the & becomes &). + if !strings.Contains(set.body, "http://192.0.2.99/tts/hello.mp3?a=1&b=2") { + t.Errorf("SetAVTransportURI body missing escaped media URL, got: %s", set.body) + } + + if strings.Contains(set.body, "a=1&b=2") { + t.Errorf("media URL was not XML-escaped in the body: %s", set.body) + } +} diff --git a/pkg/client/client.go b/pkg/client/client.go index 584485a..ae13adb 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -162,6 +162,11 @@ type Client struct { httpClient *http.Client timeout time.Duration userAgent string + + // avTransportURLOverride, when set, replaces the UPnP AVTransport control + // URL that is otherwise derived from baseURL (host + speaker.UPnPPort). Used + // only by tests to point the SOAP requests at an httptest server. + avTransportURLOverride string } // Config holds configuration for the SoundTouch client diff --git a/pkg/speaker/speaker.go b/pkg/speaker/speaker.go index 5fbfd56..d8d7514 100644 --- a/pkg/speaker/speaker.go +++ b/pkg/speaker/speaker.go @@ -13,6 +13,11 @@ package speaker // API on (e.g. /info, /presets, /group). const HTTPPort = 8090 +// UPnPPort is the port the SoundTouch device exposes its UPnP/DLNA +// MediaRenderer on, including the AVTransport control endpoint +// (/AVTransport/Control). Distinct from HTTPPort. +const UPnPPort = 8091 + // Well-known HTTP paths the SoundTouch device serves on HTTPPort. const ( DeviceInfoPath = "/info"