diff --git a/docs/reference/spotify-account-addition.md b/docs/reference/spotify-account-addition.md index fcfdb8d..7392f37 100644 --- a/docs/reference/spotify-account-addition.md +++ b/docs/reference/spotify-account-addition.md @@ -121,15 +121,14 @@ curl -X POST "https://streaming.bose.com/streaming/account/[ACCOUNT_ID]/source" --- -## 3. Local Device Notification (LISA API) +### Local Device Sync (LISA API) The app notifies the physical SoundTouch speaker about the new source. This is usually done via the device's management API on port 8090. -### Request Details +#### Modern Flow (OAuth) - **Endpoint**: `http://[DEVICE_IP]:8090/setMusicServiceOAuthAccount` - **Method**: `POST` - -### Payload (XML) +- **Payload**: ```xml [SPOTIFY_USER_ID] @@ -138,23 +137,27 @@ The app notifies the physical SoundTouch speaker about the new source. This is u ``` -**Note**: In some cases, the app sends a wrapped message format if communicating over WebSockets: +#### Marge-Sync Notification (Fall-back) +If the speaker returns `1029 UNKNOWN_ACTION_ERROR`, it signifies the LISA API version is too old for the OAuth flow. Stockholm-based firmware often expects the account to be registered in Marge first, followed by a notification to sync. +- **Endpoint**: `http://[DEVICE_IP]:8090/notification` +- **Method**: `POST` +- **Payload**: ```xml - -
- - - - -
- - - [USER] - [TOKEN] - token_version_3 - - -
+ + + +``` + +#### Legacy Flow (Fall-back) +For older firmware that doesn't use Marge for Spotify: +- **Endpoint**: `http://[DEVICE_IP]:8090/setMusicServiceAccount` +- **Method**: `POST` +- **Payload**: +```xml + + [USER] + [TOKEN] + ``` --- diff --git a/pkg/client/client.go b/pkg/client/client.go index 1fcccb0..8010c1c 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -1157,6 +1157,19 @@ func (c *Client) post(endpoint string, payload interface{}) error { if resp.StatusCode != http.StatusOK { responseBody, _ := io.ReadAll(resp.Body) + + // Try to parse as ErrorsResponse (speaker error format) + var errs models.ErrorsResponse + if xmlErr := xml.Unmarshal(responseBody, &errs); xmlErr == nil && len(errs.Errors) > 0 { + return &errs + } + + // Try to parse as APIError (standard format) + var apiError models.APIError + if xmlErr := xml.Unmarshal(responseBody, &apiError); xmlErr == nil && apiError.Message != "" { + return &apiError + } + return fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(responseBody)) } @@ -1201,6 +1214,19 @@ func (c *Client) postWithResponse(endpoint string, payload, result interface{}) if resp.StatusCode != http.StatusOK { responseBody, _ := io.ReadAll(resp.Body) + + // Try to parse as ErrorsResponse (speaker error format) + var errs models.ErrorsResponse + if xmlErr := xml.Unmarshal(responseBody, &errs); xmlErr == nil && len(errs.Errors) > 0 { + return &errs + } + + // Try to parse as APIError (standard format) + var apiError models.APIError + if xmlErr := xml.Unmarshal(responseBody, &apiError); xmlErr == nil && apiError.Message != "" { + return &apiError + } + return fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(responseBody)) } @@ -1213,6 +1239,11 @@ func (c *Client) postWithResponse(endpoint string, payload, result interface{}) // Parse the actual response first if err := xml.Unmarshal(responseBody, result); err != nil { // Check if it might be an API error response instead + var errs models.ErrorsResponse + if xmlErr := xml.Unmarshal(responseBody, &errs); xmlErr == nil && len(errs.Errors) > 0 { + return &errs + } + var apiError models.APIError if xmlErr := xml.Unmarshal(responseBody, &apiError); xmlErr == nil && apiError.Message != "" { return &apiError @@ -1953,6 +1984,24 @@ func (c *Client) SetMusicServiceOAuthAccount(credentials *models.OAuthCredential return nil } +// NotifySourcesUpdated notifies the device that sources have been updated in Marge +func (c *Client) NotifySourcesUpdated(deviceID string) error { + notification := models.NewSourcesUpdatedNotification(deviceID) + + var response models.MusicServiceAccountResponse + + err := c.postWithResponse("/notification", notification, &response) + if err != nil { + return fmt.Errorf("failed to send sources updated notification: %w", err) + } + + if response.Status != "/notification" { + return fmt.Errorf("sources updated notification 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/errors_test.go b/pkg/client/errors_test.go new file mode 100644 index 0000000..529271e --- /dev/null +++ b/pkg/client/errors_test.go @@ -0,0 +1,135 @@ +package client + +import ( + "encoding/xml" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gesellix/bose-soundtouch/pkg/models" +) + +func TestClient_Post_ErrorsResponse(t *testing.T) { + // Mock speaker error response + errorXML := ` + + This version of SCM does not support spotify create account functionality. +` + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/xml") + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(errorXML)) + })) + defer server.Close() + + c := createTestClient(server.URL) + + // Test post method + err := c.post("/test", nil) + if err == nil { + t.Fatal("expected error, got nil") + } + + errs := &models.ErrorsResponse{} + ok := errors.As(err, &errs) + if !ok { + t.Fatalf("expected models.ErrorsResponse, got %T: %v", err, err) + } + + if errs.DeviceID != "08DF1F0BA325" { + t.Errorf("expected DeviceID 08DF1F0BA325, got %s", errs.DeviceID) + } + + if len(errs.Errors) != 1 { + t.Fatalf("expected 1 error, got %d", len(errs.Errors)) + } + + if errs.Errors[0].Value != 1029 { + t.Errorf("expected error value 1029, got %d", errs.Errors[0].Value) + } + + if errs.Errors[0].Name != "UNKNOWN_ACTION_ERROR" { + t.Errorf("expected error name UNKNOWN_ACTION_ERROR, got %s", errs.Errors[0].Name) + } + + expectedMsg := "This version of SCM does not support spotify create account functionality." + if errs.Errors[0].Message != expectedMsg { + t.Errorf("expected message '%s', got '%s'", expectedMsg, errs.Errors[0].Message) + } + + if err.Error() != expectedMsg { + t.Errorf("expected Error() to return '%s', got '%s'", expectedMsg, err.Error()) + } +} + +func TestClient_PostWithResponse_ErrorsResponse(t *testing.T) { + // Mock speaker error response + errorXML := ` + + This version of SCM does not support spotify create account functionality. +` + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/xml") + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(errorXML)) + })) + defer server.Close() + + c := createTestClient(server.URL) + + // Test postWithResponse method + var result struct { + XMLName xml.Name `xml:"status"` + Data string `xml:",chardata"` + } + err := c.postWithResponse("/test", nil, &result) + if err == nil { + t.Fatal("expected error, got nil") + } + + errs := &models.ErrorsResponse{} + ok := errors.As(err, &errs) + if !ok { + t.Fatalf("expected models.ErrorsResponse, got %T: %v", err, err) + } + + if errs.Errors[0].Value != 1029 { + t.Errorf("expected error value 1029, got %d", errs.Errors[0].Value) + } +} + +func TestClient_Post_StandardAPIError(t *testing.T) { + // Mock standard API error response + errorXML := `Not Found` + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/xml") + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(errorXML)) + })) + defer server.Close() + + c := createTestClient(server.URL) + + err := c.post("/test", nil) + if err == nil { + t.Fatal("expected error, got nil") + } + + apiErr := &models.APIError{} + ok := errors.As(err, &apiErr) + if !ok { + t.Fatalf("expected models.APIError, got %T: %v", err, err) + } + + if apiErr.Code != 404 { + t.Errorf("expected code 404, got %d", apiErr.Code) + } + + if apiErr.Message != "Not Found" { + t.Errorf("expected message 'Not Found', got '%s'", apiErr.Message) + } +} diff --git a/pkg/models/account.go b/pkg/models/account.go index 08c0f76..3df73c7 100644 --- a/pkg/models/account.go +++ b/pkg/models/account.go @@ -141,7 +141,13 @@ type MusicServiceAccountResponse struct { Status string `xml:",chardata"` } +// SourcesUpdatedResponse represents the response from /notification +type SourcesUpdatedResponse struct { + XMLName xml.Name `xml:"status"` + Status string `xml:",chardata"` +} + // IsSuccess returns true if the account operation was successful func (resp *MusicServiceAccountResponse) IsSuccess() bool { - return resp.Status == "/setMusicServiceAccount" || resp.Status == "/removeMusicServiceAccount" + return resp.Status == "/setMusicServiceAccount" || resp.Status == "/removeMusicServiceAccount" || resp.Status == "/notification" } diff --git a/pkg/models/device.go b/pkg/models/device.go index c2ce4e4..206f465 100644 --- a/pkg/models/device.go +++ b/pkg/models/device.go @@ -36,6 +36,22 @@ type NetworkInfo struct { IPAddress string `xml:"ipAddress"` } +// SourcesUpdatedNotification represents the notification XML sent to the device +type SourcesUpdatedNotification struct { + XMLName xml.Name `xml:"updates"` + DeviceID string `xml:"deviceID,attr"` + Sources struct { + XMLName xml.Name `xml:"sourcesUpdated"` + } `xml:"sourcesUpdated"` +} + +// NewSourcesUpdatedNotification creates a new sources updated notification +func NewSourcesUpdatedNotification(deviceID string) *SourcesUpdatedNotification { + return &SourcesUpdatedNotification{ + DeviceID: deviceID, + } +} + // XMLResponse is a generic wrapper for API responses type XMLResponse struct { XMLName xml.Name @@ -53,6 +69,29 @@ func (e *APIError) Error() string { return e.Message } +// ErrorsResponse represents a multi-error response from the API (common in some firmware versions) +type ErrorsResponse struct { + XMLName xml.Name `xml:"errors"` + DeviceID string `xml:"deviceID,attr"` + Errors []DeviceError `xml:"error"` +} + +// Error implements the error interface for ErrorsResponse +func (e *ErrorsResponse) Error() string { + if len(e.Errors) > 0 { + return e.Errors[0].Message + } + + return "unknown API error" +} + +// DeviceError represents a single error in an ErrorsResponse +type DeviceError struct { + Value int `xml:"value,attr"` + Name string `xml:"name,attr"` + Message string `xml:",chardata"` +} + // DiscoveredDevice represents a device found through network discovery type DiscoveredDevice struct { Name string `json:"name"` diff --git a/pkg/service/handlers/handlers_mgmt.go b/pkg/service/handlers/handlers_mgmt.go index 912243a..bdc3eb7 100644 --- a/pkg/service/handlers/handlers_mgmt.go +++ b/pkg/service/handlers/handlers_mgmt.go @@ -2,6 +2,7 @@ package handlers import ( "encoding/json" + "errors" "fmt" "io" "log" @@ -97,7 +98,7 @@ func (s *Server) HandleMgmtDeviceEvents(w http.ResponseWriter, r *http.Request) } // HandleMgmtSpotifyInit starts the Spotify OAuth flow by returning an authorization URL. -func (s *Server) HandleMgmtSpotifyInit(w http.ResponseWriter, _ *http.Request) { +func (s *Server) HandleMgmtSpotifyInit(w http.ResponseWriter, r *http.Request) { s.mu.RLock() svc := s.spotifyService s.mu.RUnlock() @@ -107,7 +108,8 @@ func (s *Server) HandleMgmtSpotifyInit(w http.ResponseWriter, _ *http.Request) { return } - redirectURL := svc.BuildAuthorizeURL() + state := r.URL.Query().Get("account") + redirectURL := svc.BuildAuthorizeURL(state) w.Header().Set("Content-Type", "application/json") enc := json.NewEncoder(w) @@ -163,7 +165,12 @@ func (s *Server) HandleMgmtSpotifyCallback(w http.ResponseWriter, r *http.Reques } // Register account in Marge and notify speakers - s.bridgeSpotifyToMarge(r.URL.Query().Get("account")) + accountID := r.URL.Query().Get("account") + if accountID == "" { + accountID = r.URL.Query().Get("state") + } + + s.bridgeSpotifyToMarge(accountID) w.Header().Set("Content-Type", "text/html") _, _ = w.Write([]byte(`

Spotify Connected

You can close this window.

`)) @@ -196,7 +203,12 @@ func (s *Server) HandleMgmtSpotifyConfirm(w http.ResponseWriter, r *http.Request } // Register account in Marge and notify speakers - s.bridgeSpotifyToMarge(r.URL.Query().Get("account")) + accountID := r.URL.Query().Get("account") + if accountID == "" { + accountID = r.URL.Query().Get("state") + } + + s.bridgeSpotifyToMarge(accountID) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) @@ -247,8 +259,6 @@ func (s *Server) bridgeSpotifyToMarge(accountID string) { continue } - creds := models.NewSpotifyOAuthCredentials(acc.UserID, credential, acc.DisplayName) - for i := range allDevices { dev := &allDevices[i] if dev.AccountID != accountID && accountID != "default" { @@ -263,8 +273,47 @@ func (s *Server) bridgeSpotifyToMarge(accountID string) { log.Printf("[Spotify Bridge] Notifying speaker %s (%s) about new Spotify account", d.Name, d.IPAddress) c := client.NewClientFromHost(d.IPAddress) + creds := models.NewSpotifyOAuthCredentials(acc.UserID, credential, acc.DisplayName) + if err := c.SetMusicServiceOAuthAccount(creds); err != nil { - log.Printf("[Spotify Bridge] Failed to notify speaker %s: %v", d.Name, err) + log.Printf("[Spotify Bridge] Failed to notify speaker %s via OAuth: %v", d.Name, err) + + // Fallback if OAuth is not supported (Error 1029) + errs := &models.ErrorsResponse{} + if errors.As(err, &errs) { + isUnsupported := false + + for _, e := range errs.Errors { + if e.Value == 1029 { + isUnsupported = true + break + } + } + + if isUnsupported { + log.Printf("[Spotify Bridge] Speaker %s doesn't support OAuth, falling back to Marge sync notification", d.Name) + + // Some speakers (especially Stockholm-based) don't support /setMusicServiceOAuthAccount + // via LISA but will pick up the new source from Marge if notified. + if err := c.NotifySourcesUpdated(d.DeviceID); err != nil { + log.Printf("[Spotify Bridge] Sync notification failed for speaker %s: %v", d.Name, err) + + // Final fallback to legacy account creation + log.Printf("[Spotify Bridge] Falling back to legacy account creation for speaker %s", d.Name) + + legacyCreds := models.NewSpotifyCredentials(acc.UserID, credential) + if err := c.SetMusicServiceAccount(legacyCreds); err != nil { + log.Printf("[Spotify Bridge] Legacy fallback failed for speaker %s: %v", d.Name, err) + } else { + log.Printf("[Spotify Bridge] Legacy fallback successful for speaker %s", d.Name) + } + } else { + log.Printf("[Spotify Bridge] Sync notification successful for speaker %s", d.Name) + } + + return + } + } } else { log.Printf("[Spotify Bridge] Successfully notified speaker %s", d.Name) } diff --git a/pkg/service/handlers/handlers_mgmt_test.go b/pkg/service/handlers/handlers_mgmt_test.go index 1b824f1..5158beb 100644 --- a/pkg/service/handlers/handlers_mgmt_test.go +++ b/pkg/service/handlers/handlers_mgmt_test.go @@ -14,34 +14,35 @@ import ( func TestHandleMgmtSpotifyInit(t *testing.T) { s := NewServer(nil, nil, "http://localhost", false, false, false) - // No spotify service configured - req := httptest.NewRequest("POST", "/mgmt/spotify/init", nil) - w := httptest.NewRecorder() - s.HandleMgmtSpotifyInit(w, req) - if w.Code != http.StatusServiceUnavailable { - t.Errorf("expected 503, got %d", w.Code) - } + t.Run("POST - No spotify service configured", func(t *testing.T) { + req := httptest.NewRequest("POST", "/mgmt/spotify/init", nil) + w := httptest.NewRecorder() + s.HandleMgmtSpotifyInit(w, req) + if w.Code != http.StatusServiceUnavailable { + t.Errorf("expected 503, got %d", w.Code) + } + }) // With spotify service svc := spotify.NewSpotifyService("cid", "secret", "http://localhost/cb", t.TempDir()) s.SetSpotifyService(svc) - w = httptest.NewRecorder() - s.HandleMgmtSpotifyInit(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected 200, got %d", w.Code) - } - - var resp map[string]string - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatal(err) - } - - if !strings.Contains(resp["redirectUrl"], "client_id=cid") { - t.Errorf("expected redirectUrl to contain client_id=cid, got %s", resp["redirectUrl"]) - } + t.Run("POST - Success", func(t *testing.T) { + req := httptest.NewRequest("POST", "/mgmt/spotify/init", nil) + w := httptest.NewRecorder() + s.HandleMgmtSpotifyInit(w, req) + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } + var resp map[string]string + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatal(err) + } + if !strings.Contains(resp["redirectUrl"], "client_id=cid") { + t.Errorf("expected redirectUrl to contain client_id=cid, got %s", resp["redirectUrl"]) + } + }) } func TestHandleMgmtSpotifyAccounts(t *testing.T) { diff --git a/pkg/service/handlers/web/index.html b/pkg/service/handlers/web/index.html index cbaf711..06c070d 100644 --- a/pkg/service/handlers/web/index.html +++ b/pkg/service/handlers/web/index.html @@ -1463,6 +1463,21 @@
Loading...
+
+

Spotify Integration

+

+ Register a new Spotify source for this local account. This mimics the official SoundTouch app flow: +

+
    +
  1. Exchange OAuth code for a Bose-mediated token.
  2. +
  3. Register the source in the local Marge cloud profile.
  4. +
+ +
+
+

Connected Devices

Select an account to view devices.
diff --git a/pkg/service/handlers/web/js/script.js b/pkg/service/handlers/web/js/script.js index 3176221..a06f56f 100644 --- a/pkg/service/handlers/web/js/script.js +++ b/pkg/service/handlers/web/js/script.js @@ -492,7 +492,9 @@ async function fetchAccountDetails(accountId) { if (!accountId) return; const metadataEl = document.getElementById("account-metadata"); const devicesEl = document.getElementById("account-devices-list"); + const regStatus = document.getElementById("spotify-reg-status"); + if (regStatus) regStatus.innerText = ""; if (metadataEl) metadataEl.innerHTML = "Loading..."; if (devicesEl) devicesEl.innerHTML = "Loading devices..."; @@ -761,6 +763,50 @@ async function fetchAccountDetails(accountId) { } } +async function connectSpotifyToAccount() { + const selector = document.getElementById("account-selector"); + const accountId = selector ? selector.value : "default"; + const statusEl = document.getElementById("spotify-reg-status"); + + if (statusEl) statusEl.innerHTML = "Initializing Spotify authorization..."; + + try { + const response = await fetch(`/mgmt/spotify/init?account=${encodeURIComponent(accountId)}`, { + method: "POST" + }); + if (!response.ok) { + const err = await response.text(); + throw new Error(err || response.statusText); + } + + const data = await response.json(); + const redirectUrl = data.redirectUrl; + + if (statusEl) { + statusEl.innerHTML = `Spotify authorization window opened.
If it didn't open, click here to authorize.`; + } + + // Open Spotify auth in a new window + window.open(redirectUrl, "SpotifyAuth", "width=600,height=800"); + + // Simple poll to see when we might be done (refresh every 5s for 5 mins) + let pollCount = 0; + const interval = setInterval(async () => { + pollCount++; + if (pollCount > 60) { + clearInterval(interval); + return; + } + // Refresh account details to see if source appeared + await fetchAccountDetails(accountId); + }, 5000); + + } catch (error) { + if (statusEl) statusEl.innerHTML = `Error: ${error.message}`; + console.error("Spotify link failed", error); + } +} + async function fetchInteractionStats() { console.log("Fetching interaction stats..."); try { diff --git a/pkg/service/spotify/service.go b/pkg/service/spotify/service.go index d33809c..5309de0 100644 --- a/pkg/service/spotify/service.go +++ b/pkg/service/spotify/service.go @@ -86,13 +86,16 @@ func (s *Service) SetEndpoints(tokenURL, apiBase string) { } // BuildAuthorizeURL constructs the Spotify OAuth authorization URL. -func (s *Service) BuildAuthorizeURL() string { +func (s *Service) BuildAuthorizeURL(state string) string { params := url.Values{ "client_id": {s.clientID}, "response_type": {"code"}, "redirect_uri": {s.redirectURI}, "scope": {SpotifyScopes}, } + if state != "" { + params.Set("state", state) + } return SpotifyAuthorizeURL + "?" + params.Encode() } diff --git a/pkg/service/spotify/service_test.go b/pkg/service/spotify/service_test.go index 77592b8..f48d0ef 100644 --- a/pkg/service/spotify/service_test.go +++ b/pkg/service/spotify/service_test.go @@ -14,7 +14,8 @@ import ( func TestBuildAuthorizeURL(t *testing.T) { svc := NewSpotifyService("test-client-id", "test-secret", "http://localhost/callback", t.TempDir()) - url := svc.BuildAuthorizeURL() + state := "test-state" + url := svc.BuildAuthorizeURL(state) if !strings.Contains(url, "client_id=test-client-id") { t.Errorf("URL should contain client_id, got: %s", url) @@ -28,6 +29,9 @@ func TestBuildAuthorizeURL(t *testing.T) { if !strings.Contains(url, "response_type=code") { t.Errorf("URL should contain response_type=code, got: %s", url) } + if !strings.Contains(url, "state=test-state") { + t.Errorf("URL should contain state=test-state, got: %s", url) + } if !strings.HasPrefix(url, SpotifyAuthorizeURL) { t.Errorf("URL should start with %s, got: %s", SpotifyAuthorizeURL, url) }