Implement the Spotify source bridge

This commit is contained in:
Tobias Gesellchen
2026-04-06 21:15:15 +02:00
parent 3b1c639892
commit fea6df32f3
6 changed files with 323 additions and 31 deletions
+66 -3
View File
@@ -146,7 +146,9 @@ import (
"encoding/xml"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"time"
@@ -192,12 +194,51 @@ func NewClient(config *Config) *Client {
config.UserAgent = "Bose-SoundTouch-Go-Client/1.0"
}
if config.Port == 0 {
config.Port = 8090
host := config.Host
if !strings.Contains(host, "://") {
host = "http://" + host
}
u, err := url.Parse(host)
if err != nil {
// Fallback for invalid URLs
port := config.Port
if port == 0 {
port = 8090
}
return &Client{
baseURL: fmt.Sprintf("http://%s:%d", config.Host, port),
httpClient: &http.Client{
Timeout: config.Timeout,
},
timeout: config.Timeout,
userAgent: config.UserAgent,
}
}
// Use SplitHostPort to check for port in the host string
_, p, splitErr := net.SplitHostPort(u.Host)
if splitErr != nil {
// No port in the host string, use the one from config or default
port := config.Port
if port == 0 {
port = 8090
}
u.Host = net.JoinHostPort(u.Host, fmt.Sprintf("%d", port))
} else if p == "" {
// Empty port, use config or default
port := config.Port
if port == 0 {
port = 8090
}
u.Host = net.JoinHostPort(u.Hostname(), fmt.Sprintf("%d", port))
}
return &Client{
baseURL: fmt.Sprintf("http://%s:%d", config.Host, config.Port),
baseURL: u.String(),
httpClient: &http.Client{
Timeout: config.Timeout,
},
@@ -1890,6 +1931,28 @@ func (c *Client) SetMusicServiceAccount(credentials *models.MusicServiceCredenti
return nil
}
// SetMusicServiceOAuthAccount adds or updates a music service account using OAuth credentials
func (c *Client) SetMusicServiceOAuthAccount(credentials *models.OAuthCredentials) error {
if credentials == nil {
return fmt.Errorf("credentials cannot be nil")
}
var response models.MusicServiceAccountResponse
// Note: Modern firmware uses /setMusicServiceOAuthAccount, but we reuse the success logic
err := c.postWithResponse("/setMusicServiceOAuthAccount", credentials, &response)
if err != nil {
return fmt.Errorf("failed to set music service OAuth account for %s: %w", credentials.Source, err)
}
// The speaker returns /setMusicServiceOAuthAccount on success
if response.Status != "/setMusicServiceOAuthAccount" {
return fmt.Errorf("music service OAuth account operation 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 {
+1 -6
View File
@@ -1059,12 +1059,7 @@ func loadTestData(t *testing.T, filename string) string {
}
func createTestClient(serverURL string) *Client {
config := DefaultConfig()
config.Host = "localhost" // Will be overridden by baseURL
client := NewClient(config)
client.baseURL = serverURL
return client
return NewClientFromHost(serverURL)
}
func contains(s, substr string) bool {
+25
View File
@@ -110,6 +110,31 @@ func (cred *MusicServiceCredentials) GetDescription() string {
}
}
// OAuthCredentials represents the credentials sent to /setMusicServiceOAuthAccount
type OAuthCredentials struct {
XMLName xml.Name `xml:"OAuthCredentials"`
Source string `xml:"source,attr"`
DisplayName string `xml:"displayName,attr,omitempty"`
User string `xml:"user"`
Code string `xml:"code"`
Version string `xml:"version"`
}
// NewSpotifyOAuthCredentials creates OAuth credentials for Spotify
func NewSpotifyOAuthCredentials(user, code, displayName string) *OAuthCredentials {
if displayName == "" {
displayName = user
}
return &OAuthCredentials{
Source: "SPOTIFY",
DisplayName: displayName,
User: user,
Code: code,
Version: "token_version_3",
}
}
// MusicServiceAccountResponse represents the response from account management operations
type MusicServiceAccountResponse struct {
XMLName xml.Name `xml:"status"`
+125
View File
@@ -0,0 +1,125 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/spotify"
"github.com/go-chi/chi/v5"
)
func TestSpotifyBridge(t *testing.T) {
tmpDir := t.TempDir()
ds := datastore.NewDataStore(tmpDir)
server := NewServer(ds, nil, "http://localhost", false, false, false)
// Mock Speaker (LISA API)
speakerReceived := false
speakerTS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/setMusicServiceOAuthAccount" {
speakerReceived = true
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="UTF-8" ?><status>/setMusicServiceOAuthAccount</status>`))
}
}))
defer speakerTS.Close()
// Register the speaker in the datastore so the bridge finds it
devInfo := &models.ServiceDeviceInfo{
DeviceID: "DEV123",
AccountID: "acc123",
Name: "Test Speaker",
IPAddress: strings.TrimPrefix(speakerTS.URL, "http://"),
}
_ = ds.SaveDeviceInfo("acc123", "DEV123", devInfo)
// Ensure the directory structure exists for marge.AddSource
_ = os.MkdirAll(ds.AccountDevicesDir("acc123"), 0755)
_ = os.MkdirAll(filepath.Join(ds.AccountDevicesDir("acc123"), "DEV123"), 0755)
// Mock Spotify response
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/token":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"access_token": "access-123",
"refresh_token": "refresh-123",
"expires_in": 3600,
})
case "/me":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"id": "spotify-user",
"display_name": "Spotify User",
"email": "user@example.com",
})
}
}))
defer ts.Close()
// Initialize Spotify service
ss := spotify.NewSpotifyService("client-id", "client-secret", "http://localhost/callback", tmpDir)
ss.SetEndpoints(ts.URL+"/token", ts.URL)
server.SetSpotifyService(ss)
r := chi.NewRouter()
r.Get("/mgmt/spotify/callback", server.HandleMgmtSpotifyCallback)
// Trigger the callback
req := httptest.NewRequest("GET", "/mgmt/spotify/callback?code=fake-code&account=acc123", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Expected 200 OK, got %d: %s", w.Code, w.Body.String())
}
// 1. Verify Marge registration
// We need to check if the source was added to the datastore
foundInMarge := false
sources, err := ds.GetConfiguredSources("acc123", "DEV123")
if err == nil {
for _, src := range sources {
t.Logf(" Found source: %s (User: %s)", src.SourceKey.Type, src.Username)
if (src.Username == "spotify-user" || src.SourceKey.Account == "spotify-user") &&
(src.SourceProviderID == "15" || src.SourceKey.Type == "SPOTIFY") {
foundInMarge = true
break
}
}
}
if !foundInMarge {
// Log what we found to debug
allDevices, _ := ds.ListAllDevices()
t.Logf("Total devices in datastore: %d", len(allDevices))
for _, d := range allDevices {
t.Logf("Device: %s (Account: %s)", d.DeviceID, d.AccountID)
s, _ := ds.GetConfiguredSources(d.AccountID, d.DeviceID)
t.Logf(" Sources: %d", len(s))
}
t.Errorf("Spotify user not found in Marge configured sources")
}
// 2. Verify Speaker notification (LISA API)
// Using time.Sleep for simplicity in this test
// Wait up to 1 second
deadline := time.Now().Add(1 * time.Second)
for time.Now().Before(deadline) && !speakerReceived {
time.Sleep(50 * time.Millisecond)
}
if !speakerReceived {
t.Errorf("Speaker did not receive /setMusicServiceOAuthAccount notification")
}
}
+72
View File
@@ -7,6 +7,9 @@ import (
"log"
"net/http"
"github.com/gesellix/bose-soundtouch/pkg/client"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/marge"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
@@ -159,6 +162,9 @@ func (s *Server) HandleMgmtSpotifyCallback(w http.ResponseWriter, r *http.Reques
return
}
// Register account in Marge and notify speakers
s.bridgeSpotifyToMarge(r.URL.Query().Get("account"))
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>`))
}
@@ -189,11 +195,77 @@ func (s *Server) HandleMgmtSpotifyConfirm(w http.ResponseWriter, r *http.Request
return
}
// Register account in Marge and notify speakers
s.bridgeSpotifyToMarge(r.URL.Query().Get("account"))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"ok":true}`))
}
func (s *Server) bridgeSpotifyToMarge(accountID string) {
if accountID == "" {
accountID = "default"
}
s.mu.RLock()
svc := s.spotifyService
s.mu.RUnlock()
if svc == nil {
return
}
accounts := svc.GetAccounts()
if len(accounts) == 0 {
return
}
// For now, we use the first account found or match by ID if possible.
// In this bridge, we'll ensure all linked Spotify accounts are registered in Marge.
for _, acc := range accounts {
log.Printf("[Spotify Bridge] Registering Spotify user %s in Marge for account %s", acc.UserID, accountID)
// 1. Register in Marge (updates configuredsources.xml for all devices in the account)
_, err := marge.AddSource(s.ds, accountID, acc.UserID, "15", acc.AccessToken, "token_version_3", acc.DisplayName)
if err != nil {
log.Printf("[Spotify Bridge] Failed to register source in Marge: %v", err)
continue
}
// 2. Notify discovered speakers via LISA API (/setMusicServiceOAuthAccount)
allDevices, err := s.ds.ListAllDevices()
if err != nil {
log.Printf("[Spotify Bridge] Failed to list devices: %v", err)
continue
}
creds := models.NewSpotifyOAuthCredentials(acc.UserID, acc.AccessToken, acc.DisplayName)
for i := range allDevices {
dev := &allDevices[i]
if dev.AccountID != accountID && accountID != "default" {
continue
}
if dev.IPAddress == "" {
continue
}
go func(d models.ServiceDeviceInfo) {
log.Printf("[Spotify Bridge] Notifying speaker %s (%s) about new Spotify account", d.Name, d.IPAddress)
c := client.NewClientFromHost(d.IPAddress)
if err := c.SetMusicServiceOAuthAccount(creds); err != nil {
log.Printf("[Spotify Bridge] Failed to notify speaker %s: %v", d.Name, err)
} else {
log.Printf("[Spotify Bridge] Successfully notified speaker %s", d.Name)
}
}(*dev)
}
}
}
// HandleMgmtSpotifyAccounts returns linked Spotify accounts (tokens stripped).
func (s *Server) HandleMgmtSpotifyAccounts(w http.ResponseWriter, _ *http.Request) {
s.mu.RLock()
+34 -22
View File
@@ -1727,6 +1727,26 @@ func AddSourceToAccount(ds *datastore.DataStore, account string, sourceXML []byt
return nil, fmt.Errorf("failed to unmarshal source XML: %w", err)
}
sourceID, err := AddSource(ds, account, input.Username, input.SourceProviderID, input.Credential.Value, input.Credential.Type, input.SourceName)
if err != nil {
return nil, err
}
resp := models.MargeAddSourceResponse{
SourceID: sourceID,
SourceProviderID: input.SourceProviderID,
CreatedOn: FormatTime(time.Now()),
UpdatedOn: FormatTime(time.Now()),
}
res, _ := xml.Marshal(resp)
header := constants.XMLHeader
return append([]byte(header), res...), nil
}
// AddSource adds a new music source to the account and returns the generated source ID.
func AddSource(ds *datastore.DataStore, account, username, providerID, secret, secretType, sourceName string) (string, error) {
now := time.Now()
createdOn := FormatTime(now)
sourceID := "SRC_" + strconv.FormatInt(now.Unix(), 10)
@@ -1745,32 +1765,34 @@ func AddSourceToAccount(ds *datastore.DataStore, account string, sourceXML []byt
newSrc := models.ConfiguredSource{
ID: sourceID,
SourceProviderID: input.SourceProviderID,
Username: input.Username,
Secret: input.Credential.Value,
SecretType: input.Credential.Type,
SourceName: input.SourceName,
Name: input.Username,
SourceProviderID: providerID,
Username: username,
Secret: secret,
SecretType: secretType,
SourceName: sourceName,
Name: username,
CreatedOn: createdOn,
UpdatedOn: createdOn,
Status: "READY",
}
newSrc.SourceKey.Account = input.Username
if input.SourceProviderID == "15" {
newSrc.SourceKey.Account = username
if providerID == "15" {
newSrc.SourceKey.Type = "SPOTIFY"
} else {
newSrc.SourceKey.Type = input.SourceProviderID
newSrc.SourceKey.Type = providerID
}
log.Printf("[Marge] Adding source %s (%s) for device %s", newSrc.SourceKey.Type, username, devID)
PrepareConfiguredSource(&newSrc)
// Update or append. If it's the same provider, we replace it.
replaced := false
for i := range sources {
if sources[i].SourceProviderID == input.SourceProviderID ||
(input.SourceProviderID == "15" && sources[i].SourceKey.Type == "SPOTIFY") {
if sources[i].SourceProviderID == providerID ||
(providerID == "15" && sources[i].SourceKey.Type == "SPOTIFY") {
sources[i] = newSrc
replaced = true
@@ -1785,15 +1807,5 @@ func AddSourceToAccount(ds *datastore.DataStore, account string, sourceXML []byt
_ = ds.SaveConfiguredSources(account, devID, sources)
}
resp := models.MargeAddSourceResponse{
SourceID: sourceID,
SourceProviderID: input.SourceProviderID,
CreatedOn: createdOn,
UpdatedOn: createdOn,
}
res, _ := xml.Marshal(resp)
header := constants.XMLHeader
return append([]byte(header), res...), nil
return sourceID, nil
}