mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-24 14:47:23 +00:00
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:
co-authored by
Junie
parent
4de7911817
commit
276d01fe42
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user