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(`/setMusicServiceOAuthAccount`)) + } + })) + defer speakerTS.Close() + + // Register the speaker in the datastore so the bridge finds it + devInfo := &models.ServiceDeviceInfo{ + DeviceID: "DEV123", + AccountID: "acc123", + Name: "Test Speaker", + IPAddress: strings.TrimPrefix(speakerTS.URL, "http://"), + } + _ = ds.SaveDeviceInfo("acc123", "DEV123", devInfo) + + // Ensure the directory structure exists for marge.AddSource + _ = os.MkdirAll(ds.AccountDevicesDir("acc123"), 0755) + _ = os.MkdirAll(filepath.Join(ds.AccountDevicesDir("acc123"), "DEV123"), 0755) + + // Mock Spotify response + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/token": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "access_token": "access-123", + "refresh_token": "refresh-123", + "expires_in": 3600, + }) + case "/me": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "id": "spotify-user", + "display_name": "Spotify User", + "email": "user@example.com", + }) + } + })) + defer ts.Close() + + // Initialize Spotify service + ss := spotify.NewSpotifyService("client-id", "client-secret", "http://localhost/callback", tmpDir) + ss.SetEndpoints(ts.URL+"/token", ts.URL) + server.SetSpotifyService(ss) + + r := chi.NewRouter() + r.Get("/mgmt/spotify/callback", server.HandleMgmtSpotifyCallback) + + // Trigger the callback + req := httptest.NewRequest("GET", "/mgmt/spotify/callback?code=fake-code&account=acc123", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("Expected 200 OK, got %d: %s", w.Code, w.Body.String()) + } + + // 1. Verify Marge registration + // We need to check if the source was added to the datastore + foundInMarge := false + sources, err := ds.GetConfiguredSources("acc123", "DEV123") + if err == nil { + for _, src := range sources { + t.Logf(" Found source: %s (User: %s)", src.SourceKey.Type, src.Username) + if (src.Username == "spotify-user" || src.SourceKey.Account == "spotify-user") && + (src.SourceProviderID == "15" || src.SourceKey.Type == "SPOTIFY") { + foundInMarge = true + break + } + } + } + + if !foundInMarge { + // Log what we found to debug + allDevices, _ := ds.ListAllDevices() + t.Logf("Total devices in datastore: %d", len(allDevices)) + for _, d := range allDevices { + t.Logf("Device: %s (Account: %s)", d.DeviceID, d.AccountID) + s, _ := ds.GetConfiguredSources(d.AccountID, d.DeviceID) + t.Logf(" Sources: %d", len(s)) + } + t.Errorf("Spotify user not found in Marge configured sources") + } + + // 2. Verify Speaker notification (LISA API) + // Using time.Sleep for simplicity in this test + // Wait up to 1 second + deadline := time.Now().Add(1 * time.Second) + for time.Now().Before(deadline) && !speakerReceived { + time.Sleep(50 * time.Millisecond) + } + + if !speakerReceived { + t.Errorf("Speaker did not receive /setMusicServiceOAuthAccount notification") + } +} diff --git a/pkg/service/handlers/handlers_mgmt.go b/pkg/service/handlers/handlers_mgmt.go index d13aa10..d5ccac4 100644 --- a/pkg/service/handlers/handlers_mgmt.go +++ b/pkg/service/handlers/handlers_mgmt.go @@ -7,6 +7,9 @@ import ( "log" "net/http" + "github.com/gesellix/bose-soundtouch/pkg/client" + "github.com/gesellix/bose-soundtouch/pkg/models" + "github.com/gesellix/bose-soundtouch/pkg/service/marge" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" ) @@ -159,6 +162,9 @@ func (s *Server) HandleMgmtSpotifyCallback(w http.ResponseWriter, r *http.Reques return } + // Register account in Marge and notify speakers + s.bridgeSpotifyToMarge(r.URL.Query().Get("account")) + w.Header().Set("Content-Type", "text/html") _, _ = w.Write([]byte(`

Spotify Connected

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 }