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
+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 {