mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 00:26:29 +00:00
feat(cli): play a URL via UPnP/AVTransport, no app-key or DNS (refs #517)
Adds a third way to push a clip to a speaker, surfaced by @dagrider in #517: POST SetAVTransportURI + Play to the speaker's UPnP MediaRenderer control endpoint (port 8091). Unlike /speaker play_info it needs no app_key and no DNS interception, so it works on a plain LAN; the trade-off is it switches the speaker to the UPNP source and replaces the current playback (no duck-and-resume). - pkg/client: SetAVTransportURI, AVTransportPlay, PlayURLViaUPnP (+ the :8091 control-URL derivation and SOAP plumbing), with tests. - cmd/soundtouch-cli: `speaker url-upnp --url <url>`. - docs: document the UPnP/AVTransport option under POST /speaker. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
906c53e5b6
commit
a54204c1d0
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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://<speaker-ip>:8091/AVTransport/Control
|
||||
Content-Type: text/xml; charset="utf-8"
|
||||
SOAPAction: "urn:schemas-upnp-org:service:AVTransport:1#SetAVTransportURI"
|
||||
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
|
||||
<s:Body>
|
||||
<u:SetAVTransportURI xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
|
||||
<InstanceID>0</InstanceID>
|
||||
<CurrentURI>http://<host>/clip.mp3</CurrentURI>
|
||||
<CurrentURIMetaData></CurrentURIMetaData>
|
||||
</u:SetAVTransportURI>
|
||||
</s:Body>
|
||||
</s:Envelope>
|
||||
```
|
||||
|
||||
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-ip> speaker url-upnp --url http://<host>/clip.mp3
|
||||
```
|
||||
|
||||
(Thanks to @dagrider in #517 for surfacing this approach.)
|
||||
|
||||
### GET /playNotification ✅ **Implemented**
|
||||
Plays a notification beep sound (ST-10 Series only).
|
||||
|
||||
|
||||
@@ -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 = `<?xml version="1.0" encoding="utf-8"?>` +
|
||||
`<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"` +
|
||||
` s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">` +
|
||||
`<s:Body>%s</s:Body></s:Envelope>`
|
||||
|
||||
// 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 := `<u:SetAVTransportURI xmlns:u="` + avTransportServiceType + `">` +
|
||||
`<InstanceID>0</InstanceID>` +
|
||||
`<CurrentURI>` + escapeXMLText(mediaURL) + `</CurrentURI>` +
|
||||
`<CurrentURIMetaData></CurrentURIMetaData>` +
|
||||
`</u:SetAVTransportURI>`
|
||||
|
||||
return c.soapAVTransport("SetAVTransportURI", body)
|
||||
}
|
||||
|
||||
// AVTransportPlay starts playback (UPnP Play action, Speed 1).
|
||||
func (c *Client) AVTransportPlay() error {
|
||||
body := `<u:Play xmlns:u="` + avTransportServiceType + `">` +
|
||||
`<InstanceID>0</InstanceID><Speed>1</Speed>` +
|
||||
`</u:Play>`
|
||||
|
||||
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://<host>:<UPnPPort>/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 <CurrentURI>).
|
||||
func escapeXMLText(s string) string {
|
||||
var b bytes.Buffer
|
||||
|
||||
_ = xml.EscapeText(&b, []byte(s))
|
||||
|
||||
return b.String()
|
||||
}
|
||||
@@ -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 <CurrentURI> (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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user