diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go index 2c1e983..fae292f 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -19,6 +19,7 @@ import ( "time" "github.com/gesellix/bose-soundtouch/pkg/discovery" + "github.com/gesellix/bose-soundtouch/pkg/service/amazon" "github.com/gesellix/bose-soundtouch/pkg/service/certmanager" "github.com/gesellix/bose-soundtouch/pkg/service/datastore" "github.com/gesellix/bose-soundtouch/pkg/service/handlers" @@ -119,6 +120,58 @@ func initializeDefaultSources(ds *datastore.DataStore) { } } +func initMusicServices(config serviceConfig, server *handlers.Server) { + if config.spotifyClientID != "" { + spotifyService := spotify.NewSpotifyService( + config.spotifyClientID, + config.spotifyClientSecret, + config.spotifyRedirectURI, + config.dataDir, + ) + if config.spotifyTokenURL != "" || config.spotifyAPIBase != "" { + spotifyService.SetEndpoints(config.spotifyTokenURL, config.spotifyAPIBase) + } + + if err := spotifyService.Load(); err != nil { + log.Printf("[Spotify] Failed to load accounts: %v", err) + } + + server.SetSpotifyService(spotifyService) + + clientIDPrefix := config.spotifyClientID + if len(clientIDPrefix) > 8 { + clientIDPrefix = clientIDPrefix[:8] + } + + log.Printf("Spotify service initialized (client ID: %s...)", clientIDPrefix) + } + + if config.amazonClientID != "" { + amazonService := amazon.NewAmazonService( + config.amazonClientID, + config.amazonClientSecret, + config.amazonRedirectURI, + config.dataDir, + ) + if config.amazonTokenURL != "" || config.amazonProfileURL != "" { + amazonService.SetEndpoints(config.amazonTokenURL, config.amazonProfileURL) + } + + if err := amazonService.Load(); err != nil { + log.Printf("[Amazon] Failed to load accounts: %v", err) + } + + server.SetAmazonService(amazonService) + + clientIDPrefix := config.amazonClientID + if len(clientIDPrefix) > 8 { + clientIDPrefix = clientIDPrefix[:8] + } + + log.Printf("Amazon Music service initialized (client ID: %s...)", clientIDPrefix) + } +} + func main() { updateBuildInfo() @@ -236,6 +289,32 @@ func main() { Usage: "Spotify API base URL (for testing)", EnvVars: []string{"SPOTIFY_API_BASE"}, }, + &cli.StringFlag{ + Name: "amazon-client-id", + Usage: "Amazon LWA OAuth client ID", + EnvVars: []string{"AMAZON_CLIENT_ID"}, + }, + &cli.StringFlag{ + Name: "amazon-client-secret", + Usage: "Amazon LWA OAuth client secret", + EnvVars: []string{"AMAZON_CLIENT_SECRET"}, + }, + &cli.StringFlag{ + Name: "amazon-redirect-uri", + Usage: "Amazon LWA OAuth redirect URI", + Value: "ueberboese-login://amazon", + EnvVars: []string{"AMAZON_REDIRECT_URI"}, + }, + &cli.StringFlag{ + Name: "amazon-token-url", + Usage: "Amazon LWA token URL (for testing)", + EnvVars: []string{"AMAZON_TOKEN_URL"}, + }, + &cli.StringFlag{ + Name: "amazon-profile-url", + Usage: "Amazon LWA profile URL (for testing)", + EnvVars: []string{"AMAZON_PROFILE_URL"}, + }, &cli.StringFlag{ Name: "mgmt-username", Usage: "Management API username for HTTP Basic Auth", @@ -325,30 +404,7 @@ func main() { server.SetSpotifyConfig(config.spotifyClientID, config.spotifyClientSecret, config.spotifyRedirectURI) server.SetMgmtConfig(config.mgmtUsername, config.mgmtPassword) - if config.spotifyClientID != "" { - spotifyService := spotify.NewSpotifyService( - config.spotifyClientID, - config.spotifyClientSecret, - config.spotifyRedirectURI, - config.dataDir, - ) - if config.spotifyTokenURL != "" || config.spotifyAPIBase != "" { - spotifyService.SetEndpoints(config.spotifyTokenURL, config.spotifyAPIBase) - } - - if err := spotifyService.Load(); err != nil { - log.Printf("[Spotify] Failed to load accounts: %v", err) - } - - server.SetSpotifyService(spotifyService) - - clientIDPrefix := config.spotifyClientID - if len(clientIDPrefix) > 8 { - clientIDPrefix = clientIDPrefix[:8] - } - - log.Printf("Spotify service initialized (client ID: %s...)", clientIDPrefix) - } + initMusicServices(config, server) // Load and set initial DNS discoveries dnsDiscoveries, err := ds.LoadDNSDiscoveries() @@ -472,6 +528,11 @@ type serviceConfig struct { spotifyRedirectURI string spotifyTokenURL string spotifyAPIBase string + amazonClientID string + amazonClientSecret string + amazonRedirectURI string + amazonTokenURL string + amazonProfileURL string mgmtUsername string mgmtPassword string migrationEnabled bool @@ -538,6 +599,11 @@ func loadConfig(c *cli.Context) serviceConfig { spotifyRedirectURI := c.String("spotify-redirect-uri") spotifyTokenURL := c.String("spotify-token-url") spotifyAPIBase := c.String("spotify-api-base") + amazonClientID := c.String("amazon-client-id") + amazonClientSecret := c.String("amazon-client-secret") + amazonRedirectURI := c.String("amazon-redirect-uri") + amazonTokenURL := c.String("amazon-token-url") + amazonProfileURL := c.String("amazon-profile-url") mgmtUsername := c.String("mgmt-username") mgmtPassword := c.String("mgmt-password") mirrorEnabled := c.Bool("mirror-enabled") @@ -573,6 +639,11 @@ func loadConfig(c *cli.Context) serviceConfig { spotifyRedirectURI: spotifyRedirectURI, spotifyTokenURL: spotifyTokenURL, spotifyAPIBase: spotifyAPIBase, + amazonClientID: amazonClientID, + amazonClientSecret: amazonClientSecret, + amazonRedirectURI: amazonRedirectURI, + amazonTokenURL: amazonTokenURL, + amazonProfileURL: amazonProfileURL, mgmtUsername: mgmtUsername, mgmtPassword: mgmtPassword, migrationEnabled: migrationEnabled, @@ -888,10 +959,11 @@ func setupRouter(server *handlers.Server) *chi.Mux { }) r.Route("/mgmt", func(r chi.Router) { - // Browser OAuth callback — no auth required (Spotify redirects the + // Browser OAuth callbacks — no auth required (provider redirects the // user's browser here directly). The authorization code is single-use, // short-lived, and useless without the client_secret. r.Get("/spotify/callback", server.HandleMgmtSpotifyCallback) + r.Get("/amazon/callback", server.HandleMgmtAmazonCallback) // All other management endpoints require Basic Auth. r.Group(func(r chi.Router) { @@ -914,6 +986,14 @@ func setupRouter(server *handlers.Server) *chi.Mux { r.Post("/prime", server.HandleMgmtPrimeDevice) }) + r.Route("/amazon", func(r chi.Router) { + r.Post("/init", server.HandleMgmtAmazonInit) + r.Post("/confirm", server.HandleMgmtAmazonConfirm) + r.Get("/accounts", server.HandleMgmtAmazonAccounts) + r.Get("/token", server.HandleMgmtAmazonToken) + r.Post("/prime", server.HandleMgmtPrimeDeviceAmazon) + }) + r.Get("/devices/{deviceId}/events", server.HandleMgmtDeviceEvents) }) }) diff --git a/cmd/soundtouch-service/testdata/router_routes.txt b/cmd/soundtouch-service/testdata/router_routes.txt index 170f07a..fe544c7 100644 --- a/cmd/soundtouch-service/testdata/router_routes.txt +++ b/cmd/soundtouch-service/testdata/router_routes.txt @@ -36,6 +36,9 @@ GET /media/* handlers.( GET /mgmt/accounts/ handlers.(*Server).HandleMgmtListAccounts-fm GET /mgmt/accounts/{accountId} handlers.(*Server).HandleMgmtAccountDetails-fm GET /mgmt/accounts/{accountId}/speakers handlers.(*Server).HandleMgmtListSpeakers-fm +GET /mgmt/amazon/accounts handlers.(*Server).HandleMgmtAmazonAccounts-fm +GET /mgmt/amazon/callback handlers.(*Server).HandleMgmtAmazonCallback-fm +GET /mgmt/amazon/token handlers.(*Server).HandleMgmtAmazonToken-fm GET /mgmt/devices/{deviceId}/events handlers.(*Server).HandleMgmtDeviceEvents-fm GET /mgmt/spotify/accounts handlers.(*Server).HandleMgmtSpotifyAccounts-fm GET /mgmt/spotify/callback handlers.(*Server).HandleMgmtSpotifyCallback-fm @@ -95,6 +98,9 @@ POST /customer/account/{account} handlers.( POST /customer/account/{account}/password handlers.(*Server).HandleMargeChangePassword-fm POST /mgmt/accounts/{accountId}/language handlers.(*Server).HandleMgmtUpdateAccountLanguage-fm POST /mgmt/accounts/{accountId}/provider-settings handlers.(*Server).HandleMgmtUpdateAccountProviderSetting-fm +POST /mgmt/amazon/confirm handlers.(*Server).HandleMgmtAmazonConfirm-fm +POST /mgmt/amazon/init handlers.(*Server).HandleMgmtAmazonInit-fm +POST /mgmt/amazon/prime handlers.(*Server).HandleMgmtPrimeDeviceAmazon-fm POST /mgmt/spotify/confirm handlers.(*Server).HandleMgmtSpotifyConfirm-fm POST /mgmt/spotify/entity handlers.(*Server).HandleMgmtSpotifyEntity-fm POST /mgmt/spotify/init handlers.(*Server).HandleMgmtSpotifyInit-fm diff --git a/pkg/models/account.go b/pkg/models/account.go index 3df73c7..fbf7332 100644 --- a/pkg/models/account.go +++ b/pkg/models/account.go @@ -135,6 +135,22 @@ func NewSpotifyOAuthCredentials(user, code, displayName string) *OAuthCredential } } +// NewAmazonOAuthCredentials creates OAuth credentials for Amazon Music (cs1 / "token"). +// code is the AmazonSecret JSON envelope stored as the credential in Sources.xml. +func NewAmazonOAuthCredentials(user, code, displayName string) *OAuthCredentials { + if displayName == "" { + displayName = user + } + + return &OAuthCredentials{ + Source: "AMAZON", + DisplayName: displayName, + User: user, + Code: code, + Version: "token", + } +} + // MusicServiceAccountResponse represents the response from account management operations type MusicServiceAccountResponse struct { XMLName xml.Name `xml:"status"` diff --git a/pkg/service/amazon/service.go b/pkg/service/amazon/service.go index 39bac8d..fa18fb6 100644 --- a/pkg/service/amazon/service.go +++ b/pkg/service/amazon/service.go @@ -39,6 +39,9 @@ type Account struct { RefreshToken string `json:"refresh_token"` ExpiresAt int64 `json:"expires_at"` BoseSecret string `json:"bose_secret,omitempty"` + // SiteID is written into the AmazonSecret credential envelope. Its origin is + // unconfirmed (may be a static Bose partner ID or a per-user Music API value). + SiteID string `json:"site_id,omitempty"` } // Service manages Amazon OAuth flow and token lifecycle. @@ -352,6 +355,20 @@ func (s *Service) GetAccountBySecret(secret string) (*Account, bool) { return nil, false } +// GetAllAccounts returns all accounts including tokens. Used internally by +// bridgeAmazonToMarge to build the AmazonSecret credential envelope. +func (s *Service) GetAllAccounts() []*Account { + s.mu.RLock() + defer s.mu.RUnlock() + + result := make([]*Account, 0, len(s.accounts)) + for _, a := range s.accounts { + result = append(result, a) + } + + return result +} + // GetAccountByRefreshToken retrieves an Amazon account by its current refresh token. // Used by the token handler because the speaker sends back the actual LWA refresh token // (extracted from the AmazonSecret JSON in Sources.xml), not a surrogate. diff --git a/pkg/service/handlers/handlers_mgmt.go b/pkg/service/handlers/handlers_mgmt.go index e0cfba5..2a9b282 100644 --- a/pkg/service/handlers/handlers_mgmt.go +++ b/pkg/service/handlers/handlers_mgmt.go @@ -442,3 +442,299 @@ func (s *Server) HandleMgmtPrimeDevice(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"status":"Priming triggered"}`)) } + +// HandleMgmtAmazonInit starts the Amazon OAuth flow by returning an authorization URL. +func (s *Server) HandleMgmtAmazonInit(w http.ResponseWriter, r *http.Request) { + s.mu.RLock() + svc := s.amazonService + s.mu.RUnlock() + + if svc == nil { + http.Error(w, `{"error":"amazon not configured"}`, http.StatusServiceUnavailable) + return + } + + state := r.URL.Query().Get("account") + redirectURL := svc.BuildAuthorizeURL(state) + + w.Header().Set("Content-Type", "application/json") + enc := json.NewEncoder(w) + enc.SetEscapeHTML(false) + + if err := enc.Encode(map[string]string{ + "redirectUrl": redirectURL, + }); err != nil { + log.Printf("[Mgmt] Failed to encode Amazon redirect URL: %v", err) + } +} + +// HandleMgmtAmazonCallback is the browser OAuth callback from Amazon LWA. +// Not protected by Basic Auth — Amazon redirects the user's browser here directly. +// Returns an HTML page the user can close. +func (s *Server) HandleMgmtAmazonCallback(w http.ResponseWriter, r *http.Request) { + s.mu.RLock() + svc := s.amazonService + s.mu.RUnlock() + + if svc == nil { + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write([]byte(`
Amazon Music integration not configured
`)) + + return + } + + if errMsg := r.URL.Query().Get("error"); errMsg != "" { + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`Error: ` + errMsg + `
`)) + + return + } + + code := r.URL.Query().Get("code") + if code == "" { + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`Token exchange failed
`)) + + return + } + + accountID := r.URL.Query().Get("account") + if accountID == "" { + accountID = r.URL.Query().Get("state") + } + + s.bridgeAmazonToMarge(accountID) + + w.Header().Set("Content-Type", "text/html") + _, _ = w.Write([]byte(`You can close this window.
`)) +} + +// HandleMgmtAmazonConfirm exchanges an authorization code for tokens. +// Used by the ueberboese mobile app after the deep link callback delivers the code. +// Protected by Basic Auth. +func (s *Server) HandleMgmtAmazonConfirm(w http.ResponseWriter, r *http.Request) { + s.mu.RLock() + svc := s.amazonService + s.mu.RUnlock() + + if svc == nil { + http.Error(w, `{"error":"amazon not configured"}`, http.StatusServiceUnavailable) + return + } + + code := r.URL.Query().Get("code") + if code == "" { + http.Error(w, `{"error":"missing code parameter"}`, http.StatusBadRequest) + return + } + + if err := svc.ExchangeCodeAndStore(code); err != nil { + log.Printf("[Mgmt] Amazon confirm failed: %v", err) + http.Error(w, `{"error":"token exchange failed"}`, http.StatusInternalServerError) + + return + } + + accountID := r.URL.Query().Get("account") + if accountID == "" { + accountID = r.URL.Query().Get("state") + } + + s.bridgeAmazonToMarge(accountID) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) +} + +func (s *Server) bridgeAmazonToMarge(accountID string) { + if accountID == "" { + accountID = "default" + } + + s.mu.RLock() + svc := s.amazonService + s.mu.RUnlock() + + if svc == nil { + return + } + + accounts := svc.GetAllAccounts() + if len(accounts) == 0 { + return + } + + for _, acc := range accounts { + log.Printf("[Amazon Bridge] Registering Amazon user %s in Marge for account %s", acc.UserID, accountID) + + // Build the AmazonSecret credential envelope expected by the speaker firmware. + credMap := map[string]interface{}{ + "AmazonSecret": map[string]string{ + "refresh_token": acc.RefreshToken, + "site_id": acc.SiteID, + }, + } + + credJSON, err := json.Marshal(credMap) + if err != nil { + log.Printf("[Amazon Bridge] Failed to marshal credential: %v", err) + continue + } + + _, err = marge.AddSource(s.ds, accountID, acc.UserID, strconv.Itoa(constants.AmazonProviderID), string(credJSON), constants.CredentialTypeToken, acc.DisplayName) + if err != nil { + log.Printf("[Amazon Bridge] Failed to register source in Marge: %v", err) + continue + } + + allDevices, err := s.ds.ListAllDevices() + if err != nil { + log.Printf("[Amazon Bridge] Failed to list devices: %v", err) + continue + } + + 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("[Amazon Bridge] Notifying speaker %s (%s) about new Amazon account", d.Name, d.IPAddress) + + c := client.NewClientFromHost(d.IPAddress) + creds := models.NewAmazonOAuthCredentials(acc.UserID, string(credJSON), acc.DisplayName) + + if err := c.SetMusicServiceOAuthAccount(creds); err != nil { + log.Printf("[Amazon Bridge] Failed to notify speaker %s via OAuth: %v", d.Name, err) + + 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("[Amazon Bridge] Speaker %s doesn't support OAuth, falling back to Marge sync notification", d.Name) + + if err := c.NotifySourcesUpdated(d.DeviceID); err != nil { + log.Printf("[Amazon Bridge] Sync notification failed for speaker %s: %v", d.Name, err) + + log.Printf("[Amazon Bridge] Falling back to legacy account creation for speaker %s", d.Name) + + legacyCreds := models.NewAmazonMusicCredentials(acc.UserID, string(credJSON)) + if err := c.SetMusicServiceAccount(legacyCreds); err != nil { + log.Printf("[Amazon Bridge] Legacy fallback failed for speaker %s: %v", d.Name, err) + } else { + log.Printf("[Amazon Bridge] Legacy fallback successful for speaker %s", d.Name) + } + } else { + log.Printf("[Amazon Bridge] Sync notification successful for speaker %s", d.Name) + } + + return + } + } + } else { + log.Printf("[Amazon Bridge] Successfully notified speaker %s", d.Name) + } + }(*dev) + } + } +} + +// HandleMgmtAmazonAccounts returns linked Amazon accounts (tokens stripped). +func (s *Server) HandleMgmtAmazonAccounts(w http.ResponseWriter, _ *http.Request) { + s.mu.RLock() + svc := s.amazonService + s.mu.RUnlock() + + if svc == nil { + http.Error(w, `{"error":"amazon not configured"}`, http.StatusServiceUnavailable) + return + } + + accounts := svc.GetAccounts() + + w.Header().Set("Content-Type", "application/json") + + if err := json.NewEncoder(w).Encode(map[string]interface{}{ + "accounts": accounts, + }); err != nil { + log.Printf("[Mgmt] Failed to encode Amazon accounts: %v", err) + } +} + +// HandleMgmtAmazonToken returns a fresh Amazon access token for the linked account. +func (s *Server) HandleMgmtAmazonToken(w http.ResponseWriter, _ *http.Request) { + s.mu.RLock() + svc := s.amazonService + s.mu.RUnlock() + + if svc == nil { + http.Error(w, `{"error":"amazon not configured"}`, http.StatusServiceUnavailable) + return + } + + accessToken, username, err := svc.GetFreshToken() + if err != nil { + log.Printf("[Mgmt] Amazon token error: %v", err) + http.Error(w, `{"error":"no token available"}`, http.StatusInternalServerError) + + return + } + + w.Header().Set("Content-Type", "application/json") + + if err := json.NewEncoder(w).Encode(map[string]string{ + "access_token": accessToken, + "username": username, + }); err != nil { + log.Printf("[Mgmt] Failed to encode Amazon token: %v", err) + } +} + +// HandleMgmtPrimeDeviceAmazon triggers Amazon Music priming for a specific device. +func (s *Server) HandleMgmtPrimeDeviceAmazon(w http.ResponseWriter, r *http.Request) { + deviceID := r.URL.Query().Get("deviceId") + + if deviceID == "" { + http.Error(w, `{"error":"missing deviceId"}`, http.StatusBadRequest) + return + } + + deviceIP, err := s.resolveDeviceIDToIP(deviceID) + if err != nil { + log.Printf("[Mgmt] Amazon prime failed: %v", err) + http.Error(w, fmt.Sprintf(`{"error":"%v"}`, err), http.StatusNotFound) + + return + } + + go s.PrimeDeviceWithAmazon(deviceIP) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"status":"Priming triggered"}`)) +} diff --git a/pkg/service/handlers/handlers_oauth_test.go b/pkg/service/handlers/handlers_oauth_test.go index 938cdb7..3fca4ea 100644 --- a/pkg/service/handlers/handlers_oauth_test.go +++ b/pkg/service/handlers/handlers_oauth_test.go @@ -145,16 +145,19 @@ func TestHandleBoseAmazonToken_LocalResponse_ByRefreshToken(t *testing.T) { amazonDir := filepath.Join(tmpDir, "amazon") _ = os.MkdirAll(amazonDir, 0755) - account := map[string]interface{}{ - "amzn1.account.USER1": map[string]interface{}{ - "user_id": "amzn1.account.USER1", - "display_name": "Amazon User", - "access_token": "Atza|old-access-token", - "refresh_token": "Atzr|stored-refresh-token", - "expires_at": time.Now().Add(-1 * time.Hour).Unix(), // expired + accounts := map[string]amazon.Account{ + "amzn1.account.USER1": { + UserID: "amzn1.account.USER1", + DisplayName: "Amazon User", + AccessToken: "Atza|old-access-token", + RefreshToken: "Atzr|stored-refresh-token", + ExpiresAt: time.Now().Add(-1 * time.Hour).Unix(), }, } - data, _ := json.Marshal(account) + data, err := json.Marshal(accounts) + if err != nil { + t.Fatal(err) + } _ = os.WriteFile(filepath.Join(amazonDir, "accounts.json"), data, 0644) as := amazon.NewAmazonService("client-id", "client-secret", "ueberboese-login://amazon", tmpDir) @@ -203,16 +206,19 @@ func TestHandleBoseAmazonToken_LocalResponse_DefaultAccount(t *testing.T) { amazonDir := filepath.Join(tmpDir, "amazon") _ = os.MkdirAll(amazonDir, 0755) - account := map[string]interface{}{ - "amzn1.account.USER1": map[string]interface{}{ - "user_id": "amzn1.account.USER1", - "display_name": "Amazon User", - "access_token": "Atza|valid-access-token", - "refresh_token": "Atzr|valid-refresh-token", - "expires_at": time.Now().Add(1 * time.Hour).Unix(), + accounts := map[string]amazon.Account{ + "amzn1.account.USER1": { + UserID: "amzn1.account.USER1", + DisplayName: "Amazon User", + AccessToken: "Atza|valid-access-token", + RefreshToken: "Atzr|valid-refresh-token", + ExpiresAt: time.Now().Add(1 * time.Hour).Unix(), }, } - data, _ := json.Marshal(account) + data, err := json.Marshal(accounts) + if err != nil { + t.Fatal(err) + } _ = os.WriteFile(filepath.Join(amazonDir, "accounts.json"), data, 0644) as := amazon.NewAmazonService("client-id", "client-secret", "ueberboese-login://amazon", tmpDir) diff --git a/pkg/service/handlers/server.go b/pkg/service/handlers/server.go index 48b9d2a..ebbd11d 100644 --- a/pkg/service/handlers/server.go +++ b/pkg/service/handlers/server.go @@ -492,6 +492,47 @@ func (s *Server) pushSpotifyTokenToDevice(deviceIP, username, accessToken string return spotify.PushSpotifyCredentials(zcURL, username, accessToken) } +// PrimeDeviceWithAmazon triggers an Amazon Music priming of the speaker if an Amazon account is linked. +func (s *Server) PrimeDeviceWithAmazon(deviceIP string) { + s.mu.RLock() + svc := s.amazonService + s.mu.RUnlock() + + if svc == nil { + return + } + + accounts := svc.GetAccounts() + if len(accounts) == 0 { + return + } + + accessToken, username, err := svc.GetFreshToken() + if err != nil { + log.Printf("[Amazon Watchdog] Failed to get fresh token for %s: %v", deviceIP, err) + return + } + + log.Printf("[Amazon Watchdog] Proactively priming %s with Amazon user %s", deviceIP, username) + + if err := s.pushAmazonTokenToDevice(deviceIP, username, accessToken); err != nil { + log.Printf("[Amazon Watchdog] Failed to prime %s: %v", deviceIP, err) + } else { + log.Printf("[Amazon Watchdog] Successfully primed %s", deviceIP) + } +} + +func (s *Server) pushAmazonTokenToDevice(deviceIP, username, accessToken string) error { + var zcURL string + if _, _, err := net.SplitHostPort(deviceIP); err == nil { + zcURL = fmt.Sprintf("http://%s/zc", deviceIP) + } else { + zcURL = fmt.Sprintf("http://%s:8200/zc", deviceIP) + } + + return amazon.PushAmazonCredentials(zcURL, username, accessToken) +} + func (s *Server) handleDiscoveredDevice(d models.DiscoveredDevice) { log.Printf("Discovered Bose device: %s at %s (Serial: %s)", d.Name, d.Host, d.SerialNo)