mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-19 00:56:16 +00:00
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>
123 lines
3.2 KiB
Go
123 lines
3.2 KiB
Go
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)
|
|
}
|
|
}
|