mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 08:36:13 +00:00
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:
co-authored by
Claude Sonnet 4.6
parent
f1c2b7a53f
commit
cb037df831
+105
-25
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"}`))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user