feat: add Amazon Music management handlers and CLI wiring

- Add HandleMgmtAmazonInit/Callback/Confirm/Accounts/Token/PrimeDeviceAmazon
- Add bridgeAmazonToMarge (AmazonSecret JSON envelope, Marge registration, speaker notification with OAuth/sync/legacy fallbacks)
- Add PrimeDeviceWithAmazon and pushAmazonTokenToDevice to Server
- Wire --amazon-client-id/secret/redirect-uri/token-url/profile-url CLI flags
- Initialize Amazon service on startup alongside Spotify
- Register /mgmt/amazon/* routes (callback unauthenticated, rest Basic Auth)
- Update router_routes.txt snapshot with 6 new Amazon routes
- Fix errchkjson lint: use typed amazon.Account in test fixtures
- Fix gocyclo lint: extract initMusicServices helper from main action

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-04-29 20:30:06 +02:00
co-authored by Claude Sonnet 4.6
parent f1c2b7a53f
commit cb037df831
7 changed files with 503 additions and 41 deletions
+105 -25
View File
@@ -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)
})
})
+6
View File
@@ -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
+16
View File
@@ -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"`
+17
View File
@@ -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.
+296
View File
@@ -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(`<html><body><h1>Error</h1><p>Amazon Music integration not configured</p></body></html>`))
return
}
if errMsg := r.URL.Query().Get("error"); errMsg != "" {
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`<html><body><h1>Amazon Authorization Failed</h1><p>Error: ` + errMsg + `</p></body></html>`))
return
}
code := r.URL.Query().Get("code")
if code == "" {
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`<html><body><h1>Missing authorization code</h1></body></html>`))
return
}
if err := svc.ExchangeCodeAndStore(code); err != nil {
log.Printf("[Mgmt] Amazon callback failed: %v", err)
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`<html><body><h1>Error</h1><p>Token exchange failed</p></body></html>`))
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(`<html><body><h1>Amazon Music Connected</h1><p>You can close this window.</p></body></html>`))
}
// 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"}`))
}
+22 -16
View File
@@ -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)
+41
View File
@@ -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)