mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-15 15:16:13 +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
@@ -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>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user