diff --git a/pkg/client/client.go b/pkg/client/client.go
index ce532ca..1fcccb0 100644
--- a/pkg/client/client.go
+++ b/pkg/client/client.go
@@ -146,7 +146,9 @@ import (
"encoding/xml"
"fmt"
"io"
+ "net"
"net/http"
+ "net/url"
"strings"
"time"
@@ -192,12 +194,51 @@ func NewClient(config *Config) *Client {
config.UserAgent = "Bose-SoundTouch-Go-Client/1.0"
}
- if config.Port == 0 {
- config.Port = 8090
+ host := config.Host
+ if !strings.Contains(host, "://") {
+ host = "http://" + host
+ }
+
+ u, err := url.Parse(host)
+ if err != nil {
+ // Fallback for invalid URLs
+ port := config.Port
+ if port == 0 {
+ port = 8090
+ }
+
+ return &Client{
+ baseURL: fmt.Sprintf("http://%s:%d", config.Host, port),
+ httpClient: &http.Client{
+ Timeout: config.Timeout,
+ },
+ timeout: config.Timeout,
+ userAgent: config.UserAgent,
+ }
+ }
+
+ // Use SplitHostPort to check for port in the host string
+ _, p, splitErr := net.SplitHostPort(u.Host)
+ if splitErr != nil {
+ // No port in the host string, use the one from config or default
+ port := config.Port
+ if port == 0 {
+ port = 8090
+ }
+
+ u.Host = net.JoinHostPort(u.Host, fmt.Sprintf("%d", port))
+ } else if p == "" {
+ // Empty port, use config or default
+ port := config.Port
+ if port == 0 {
+ port = 8090
+ }
+
+ u.Host = net.JoinHostPort(u.Hostname(), fmt.Sprintf("%d", port))
}
return &Client{
- baseURL: fmt.Sprintf("http://%s:%d", config.Host, config.Port),
+ baseURL: u.String(),
httpClient: &http.Client{
Timeout: config.Timeout,
},
@@ -1890,6 +1931,28 @@ func (c *Client) SetMusicServiceAccount(credentials *models.MusicServiceCredenti
return nil
}
+// SetMusicServiceOAuthAccount adds or updates a music service account using OAuth credentials
+func (c *Client) SetMusicServiceOAuthAccount(credentials *models.OAuthCredentials) error {
+ if credentials == nil {
+ return fmt.Errorf("credentials cannot be nil")
+ }
+
+ var response models.MusicServiceAccountResponse
+
+ // Note: Modern firmware uses /setMusicServiceOAuthAccount, but we reuse the success logic
+ err := c.postWithResponse("/setMusicServiceOAuthAccount", credentials, &response)
+ if err != nil {
+ return fmt.Errorf("failed to set music service OAuth account for %s: %w", credentials.Source, err)
+ }
+
+ // The speaker returns /setMusicServiceOAuthAccount on success
+ if response.Status != "/setMusicServiceOAuthAccount" {
+ return fmt.Errorf("music service OAuth account operation failed: unexpected response %s", response.Status)
+ }
+
+ return nil
+}
+
// RemoveMusicServiceAccount removes an existing music service account
func (c *Client) RemoveMusicServiceAccount(credentials *models.MusicServiceCredentials) error {
if credentials == nil {
diff --git a/pkg/client/client_test.go b/pkg/client/client_test.go
index 669add6..4d829d4 100644
--- a/pkg/client/client_test.go
+++ b/pkg/client/client_test.go
@@ -1059,12 +1059,7 @@ func loadTestData(t *testing.T, filename string) string {
}
func createTestClient(serverURL string) *Client {
- config := DefaultConfig()
- config.Host = "localhost" // Will be overridden by baseURL
- client := NewClient(config)
- client.baseURL = serverURL
-
- return client
+ return NewClientFromHost(serverURL)
}
func contains(s, substr string) bool {
diff --git a/pkg/models/account.go b/pkg/models/account.go
index 91103d8..08c0f76 100644
--- a/pkg/models/account.go
+++ b/pkg/models/account.go
@@ -110,6 +110,31 @@ func (cred *MusicServiceCredentials) GetDescription() string {
}
}
+// OAuthCredentials represents the credentials sent to /setMusicServiceOAuthAccount
+type OAuthCredentials struct {
+ XMLName xml.Name `xml:"OAuthCredentials"`
+ Source string `xml:"source,attr"`
+ DisplayName string `xml:"displayName,attr,omitempty"`
+ User string `xml:"user"`
+ Code string `xml:"code"`
+ Version string `xml:"version"`
+}
+
+// NewSpotifyOAuthCredentials creates OAuth credentials for Spotify
+func NewSpotifyOAuthCredentials(user, code, displayName string) *OAuthCredentials {
+ if displayName == "" {
+ displayName = user
+ }
+
+ return &OAuthCredentials{
+ Source: "SPOTIFY",
+ DisplayName: displayName,
+ User: user,
+ Code: code,
+ Version: "token_version_3",
+ }
+}
+
// MusicServiceAccountResponse represents the response from account management operations
type MusicServiceAccountResponse struct {
XMLName xml.Name `xml:"status"`
diff --git a/pkg/service/handlers/bridge_test.go b/pkg/service/handlers/bridge_test.go
new file mode 100644
index 0000000..e240895
--- /dev/null
+++ b/pkg/service/handlers/bridge_test.go
@@ -0,0 +1,125 @@
+package handlers
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/gesellix/bose-soundtouch/pkg/models"
+ "github.com/gesellix/bose-soundtouch/pkg/service/datastore"
+ "github.com/gesellix/bose-soundtouch/pkg/service/spotify"
+ "github.com/go-chi/chi/v5"
+)
+
+func TestSpotifyBridge(t *testing.T) {
+ tmpDir := t.TempDir()
+ ds := datastore.NewDataStore(tmpDir)
+ server := NewServer(ds, nil, "http://localhost", false, false, false)
+
+ // Mock Speaker (LISA API)
+ speakerReceived := false
+ speakerTS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == "/setMusicServiceOAuthAccount" {
+ speakerReceived = true
+ w.Header().Set("Content-Type", "application/xml")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`
You can close this window.
`)) } @@ -189,11 +195,77 @@ func (s *Server) HandleMgmtSpotifyConfirm(w http.ResponseWriter, r *http.Request return } + // Register account in Marge and notify speakers + s.bridgeSpotifyToMarge(r.URL.Query().Get("account")) + w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"ok":true}`)) } +func (s *Server) bridgeSpotifyToMarge(accountID string) { + if accountID == "" { + accountID = "default" + } + + s.mu.RLock() + svc := s.spotifyService + s.mu.RUnlock() + + if svc == nil { + return + } + + accounts := svc.GetAccounts() + if len(accounts) == 0 { + return + } + + // For now, we use the first account found or match by ID if possible. + // In this bridge, we'll ensure all linked Spotify accounts are registered in Marge. + for _, acc := range accounts { + log.Printf("[Spotify Bridge] Registering Spotify user %s in Marge for account %s", acc.UserID, accountID) + + // 1. Register in Marge (updates configuredsources.xml for all devices in the account) + _, err := marge.AddSource(s.ds, accountID, acc.UserID, "15", acc.AccessToken, "token_version_3", acc.DisplayName) + if err != nil { + log.Printf("[Spotify Bridge] Failed to register source in Marge: %v", err) + continue + } + + // 2. Notify discovered speakers via LISA API (/setMusicServiceOAuthAccount) + allDevices, err := s.ds.ListAllDevices() + if err != nil { + log.Printf("[Spotify Bridge] Failed to list devices: %v", err) + continue + } + + creds := models.NewSpotifyOAuthCredentials(acc.UserID, acc.AccessToken, acc.DisplayName) + + for i := range allDevices { + dev := &allDevices[i] + if dev.AccountID != accountID && accountID != "default" { + continue + } + + if dev.IPAddress == "" { + continue + } + + go func(d models.ServiceDeviceInfo) { + log.Printf("[Spotify Bridge] Notifying speaker %s (%s) about new Spotify account", d.Name, d.IPAddress) + + c := client.NewClientFromHost(d.IPAddress) + if err := c.SetMusicServiceOAuthAccount(creds); err != nil { + log.Printf("[Spotify Bridge] Failed to notify speaker %s: %v", d.Name, err) + } else { + log.Printf("[Spotify Bridge] Successfully notified speaker %s", d.Name) + } + }(*dev) + } + } +} + // HandleMgmtSpotifyAccounts returns linked Spotify accounts (tokens stripped). func (s *Server) HandleMgmtSpotifyAccounts(w http.ResponseWriter, _ *http.Request) { s.mu.RLock() diff --git a/pkg/service/marge/marge.go b/pkg/service/marge/marge.go index 1c034b6..eda6c4a 100644 --- a/pkg/service/marge/marge.go +++ b/pkg/service/marge/marge.go @@ -1727,6 +1727,26 @@ func AddSourceToAccount(ds *datastore.DataStore, account string, sourceXML []byt return nil, fmt.Errorf("failed to unmarshal source XML: %w", err) } + sourceID, err := AddSource(ds, account, input.Username, input.SourceProviderID, input.Credential.Value, input.Credential.Type, input.SourceName) + if err != nil { + return nil, err + } + + resp := models.MargeAddSourceResponse{ + SourceID: sourceID, + SourceProviderID: input.SourceProviderID, + CreatedOn: FormatTime(time.Now()), + UpdatedOn: FormatTime(time.Now()), + } + + res, _ := xml.Marshal(resp) + header := constants.XMLHeader + + return append([]byte(header), res...), nil +} + +// AddSource adds a new music source to the account and returns the generated source ID. +func AddSource(ds *datastore.DataStore, account, username, providerID, secret, secretType, sourceName string) (string, error) { now := time.Now() createdOn := FormatTime(now) sourceID := "SRC_" + strconv.FormatInt(now.Unix(), 10) @@ -1745,32 +1765,34 @@ func AddSourceToAccount(ds *datastore.DataStore, account string, sourceXML []byt newSrc := models.ConfiguredSource{ ID: sourceID, - SourceProviderID: input.SourceProviderID, - Username: input.Username, - Secret: input.Credential.Value, - SecretType: input.Credential.Type, - SourceName: input.SourceName, - Name: input.Username, + SourceProviderID: providerID, + Username: username, + Secret: secret, + SecretType: secretType, + SourceName: sourceName, + Name: username, CreatedOn: createdOn, UpdatedOn: createdOn, Status: "READY", } - newSrc.SourceKey.Account = input.Username - if input.SourceProviderID == "15" { + newSrc.SourceKey.Account = username + if providerID == "15" { newSrc.SourceKey.Type = "SPOTIFY" } else { - newSrc.SourceKey.Type = input.SourceProviderID + newSrc.SourceKey.Type = providerID } + log.Printf("[Marge] Adding source %s (%s) for device %s", newSrc.SourceKey.Type, username, devID) + PrepareConfiguredSource(&newSrc) // Update or append. If it's the same provider, we replace it. replaced := false for i := range sources { - if sources[i].SourceProviderID == input.SourceProviderID || - (input.SourceProviderID == "15" && sources[i].SourceKey.Type == "SPOTIFY") { + if sources[i].SourceProviderID == providerID || + (providerID == "15" && sources[i].SourceKey.Type == "SPOTIFY") { sources[i] = newSrc replaced = true @@ -1785,15 +1807,5 @@ func AddSourceToAccount(ds *datastore.DataStore, account string, sourceXML []byt _ = ds.SaveConfiguredSources(account, devID, sources) } - resp := models.MargeAddSourceResponse{ - SourceID: sourceID, - SourceProviderID: input.SourceProviderID, - CreatedOn: createdOn, - UpdatedOn: createdOn, - } - - res, _ := xml.Marshal(resp) - header := constants.XMLHeader - - return append([]byte(header), res...), nil + return sourceID, nil }