mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-19 09:06:14 +00:00
Implement the Spotify source bridge
This commit is contained in:
+66
-3
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user