feat(spotify): improve Spotify registration flow and speaker notification

- Implement full SoundTouch app flow for Spotify registration in the Web UI.
- Update `/mgmt/spotify/init` to pass `accountID` via OAuth `state`.
- Add "Connect Spotify" button to Local Account tab in Web UI with polling.
- Implement legacy and Marge-sync fallbacks for speaker notifications (Error 1029).
- Add support for parsing multi-error XML responses (`<errors>`) from speakers.
- Add `NotifySourcesUpdated` to client for triggering manual source synchronization.
- Improve test coverage for error parsing and Spotify initialization handlers.

Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
Tobias Gesellchen
2026-04-06 22:39:30 +02:00
co-authored by Junie
parent 4de7911817
commit 276d01fe42
11 changed files with 402 additions and 52 deletions
+23 -20
View File
@@ -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
<OAuthCredentials source="SPOTIFY" displayName="[DISPLAY_NAME]">
<user>[SPOTIFY_USER_ID]</user>
@@ -138,23 +137,27 @@ The app notifies the physical SoundTouch speaker about the new source. This is u
</OAuthCredentials>
```
**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
<msg>
<header deviceID="[DEVICE_UID]" url="setMusicServiceOAuthAccount" method="POST">
<request requestID="1">
<info type="new" />
<sourceItem source="SPOTIFY" />
</request>
</header>
<body>
<OAuthCredentials source="SPOTIFY" displayName="[NAME]">
<user>[USER]</user>
<code>[TOKEN]</code>
<version>token_version_3</version>
</OAuthCredentials>
</body>
</msg>
<updates deviceID="[DEVICE_UID]">
<sourcesUpdated></sourcesUpdated>
</updates>
```
#### Legacy Flow (Fall-back)
For older firmware that doesn't use Marge for Spotify:
- **Endpoint**: `http://[DEVICE_IP]:8090/setMusicServiceAccount`
- **Method**: `POST`
- **Payload**:
```xml
<credentials source="SPOTIFY" displayName="Spotify Premium">
<user>[USER]</user>
<pass>[TOKEN]</pass>
</credentials>
```
---
+49
View File
@@ -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 {
+135
View File
@@ -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 := `<?xml version="1.0" encoding="UTF-8" ?>
<errors deviceID="08DF1F0BA325">
<error value="1029" name="UNKNOWN_ACTION_ERROR" severity="Unknown">This version of SCM does not support spotify create account functionality.</error>
</errors>`
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 := `<?xml version="1.0" encoding="UTF-8" ?>
<errors deviceID="08DF1F0BA325">
<error value="1029" name="UNKNOWN_ACTION_ERROR" severity="Unknown">This version of SCM does not support spotify create account functionality.</error>
</errors>`
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 := `<?xml version="1.0" encoding="UTF-8"?><error code="404">Not Found</error>`
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)
}
}
+7 -1
View File
@@ -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"
}
+39
View File
@@ -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"`
+56 -7
View File
@@ -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(`<html><body><h1>Spotify Connected</h1><p>You can close this window.</p></body></html>`))
@@ -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)
}
+23 -22
View File
@@ -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) {
+15
View File
@@ -1463,6 +1463,21 @@
<div id="account-metadata">Loading...</div>
</div>
<div id="spotify-registration-container" class="summary-box" style="margin-top: 20px;">
<h3>Spotify Integration</h3>
<p style="font-size: 0.9em; color: #555;">
Register a new Spotify source for this local account. This mimics the official SoundTouch app flow:
</p>
<ol style="font-size: 0.85em; color: #555; margin-bottom: 15px;">
<li>Exchange OAuth code for a Bose-mediated token.</li>
<li>Register the source in the local Marge cloud profile.</li>
</ol>
<button id="connect-spotify-account-btn" onclick="connectSpotifyToAccount()" style="background: #1db954; color: white; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer;">
Connect Spotify to this Account
</button>
<div id="spotify-reg-status" style="margin-top: 10px; font-size: 0.9em;"></div>
</div>
<div id="account-devices-container">
<h3>Connected Devices</h3>
<div id="account-devices-list">Select an account to view devices.</div>
+46
View File
@@ -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. <br/>If it didn't open, <a href="${redirectUrl}" target="_blank">click here to authorize</a>.`;
}
// 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 = `<span style="color:red">Error: ${error.message}</span>`;
console.error("Spotify link failed", error);
}
}
async function fetchInteractionStats() {
console.log("Fetching interaction stats...");
try {
+4 -1
View File
@@ -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()
}
+5 -1
View File
@@ -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)
}