From 147a1a849050ebfce73895c577dfe9d8b5c1fe45 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Wed, 29 Apr 2026 13:04:05 +0200 Subject: [PATCH] feat: add Spotify and Amazon credential fields to Settings UI - Add SpotifyClientID/Secret/RedirectURI and AmazonClientID/Secret/RedirectURI fields to datastore.Settings for persistent storage - Server: add amazonClientID/Secret/RedirectURI fields, SetAmazonConfig, GetSpotifyConfig/GetAmazonConfig, ReinitSpotifyService/ReinitAmazonService, and applyMusicServiceCredentials (called under lock from HandleUpdateSettings) - GET /setup/settings: expose credential fields; mask secrets as "***" when set - POST /setup/settings: apply credential updates and reinitialize services live - applyPersistedSettings: fill in music credentials from settings.json when not set via CLI/env (CLI takes precedence) - Settings tab: replace read-only Spotify status with editable Client ID / Secret / Redirect URI inputs for both Spotify and Amazon; save via existing Save button - script.js: populate and collect the six new fields in fetchSettings/updateSettings Co-Authored-By: Claude Sonnet 4.6 --- cmd/soundtouch-service/main.go | 38 ++++++++-- pkg/service/datastore/datastore.go | 6 ++ pkg/service/handlers/handlers_setup.go | 51 ++++++++++++++ pkg/service/handlers/server.go | 97 ++++++++++++++++++++++++++ pkg/service/handlers/web/index.html | 52 ++++++++++++-- pkg/service/handlers/web/js/script.js | 84 +++++++++++++++++++++- 6 files changed, 317 insertions(+), 11 deletions(-) diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go index fae292f..9609058 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -275,8 +275,7 @@ func main() { }, &cli.StringFlag{ Name: "spotify-redirect-uri", - Usage: "Spotify OAuth redirect URI", - Value: "ueberboese-login://spotify", + Usage: "Spotify OAuth redirect URI (defaults to /mgmt/spotify/callback)", EnvVars: []string{"SPOTIFY_REDIRECT_URI"}, }, &cli.StringFlag{ @@ -301,8 +300,7 @@ func main() { }, &cli.StringFlag{ Name: "amazon-redirect-uri", - Usage: "Amazon LWA OAuth redirect URI", - Value: "ueberboese-login://amazon", + Usage: "Amazon LWA OAuth redirect URI (defaults to /mgmt/amazon/callback)", EnvVars: []string{"AMAZON_REDIRECT_URI"}, }, &cli.StringFlag{ @@ -402,6 +400,7 @@ func main() { server.SetMirrorSettings(persisted.MirrorEnabled, persisted.MirrorEndpoints, persisted.SkipMirrorEndpoints, persisted.PreferredSource) server.SetInternalPaths(persisted.InternalPaths) server.SetSpotifyConfig(config.spotifyClientID, config.spotifyClientSecret, config.spotifyRedirectURI) + server.SetAmazonConfig(config.amazonClientID, config.amazonClientSecret, config.amazonRedirectURI) server.SetMgmtConfig(config.mgmtUsername, config.mgmtPassword) initMusicServices(config, server) @@ -738,9 +737,40 @@ func applyPersistedSettings(ds *datastore.DataStore, config *serviceConfig) data config.preferredSource = persisted.PreferredSource config.internalPaths = persisted.InternalPaths + // CLI/env args take precedence; only apply persisted credentials when not set via CLI. + applyPersistedMusicServiceCredentials(config, persisted) + return persisted } +// applyPersistedMusicServiceCredentials fills in music service credentials from persisted +// settings when they have not been supplied via CLI flags or environment variables. +func applyPersistedMusicServiceCredentials(config *serviceConfig, persisted datastore.Settings) { + if config.spotifyClientID == "" { + config.spotifyClientID = persisted.SpotifyClientID + } + + if config.spotifyClientSecret == "" { + config.spotifyClientSecret = persisted.SpotifyClientSecret + } + + if config.spotifyRedirectURI == "" { + config.spotifyRedirectURI = persisted.SpotifyRedirectURI + } + + if config.amazonClientID == "" { + config.amazonClientID = persisted.AmazonClientID + } + + if config.amazonClientSecret == "" { + config.amazonClientSecret = persisted.AmazonClientSecret + } + + if config.amazonRedirectURI == "" { + config.amazonRedirectURI = persisted.AmazonRedirectURI + } +} + func createDefaultSettings(ds *datastore.DataStore, config serviceConfig) datastore.Settings { settings := datastore.Settings{ ServerURL: config.serverURL, diff --git a/pkg/service/datastore/datastore.go b/pkg/service/datastore/datastore.go index 9ee4032..0d4f29b 100644 --- a/pkg/service/datastore/datastore.go +++ b/pkg/service/datastore/datastore.go @@ -1718,6 +1718,12 @@ type Settings struct { PreferredSource string `json:"preferred_source,omitempty"` InternalPaths []string `json:"internal_paths,omitempty"` Shortcuts map[string]int `json:"shortcuts,omitempty"` + SpotifyClientID string `json:"spotify_client_id,omitempty"` + SpotifyClientSecret string `json:"spotify_client_secret,omitempty"` + SpotifyRedirectURI string `json:"spotify_redirect_uri,omitempty"` + AmazonClientID string `json:"amazon_client_id,omitempty"` + AmazonClientSecret string `json:"amazon_client_secret,omitempty"` + AmazonRedirectURI string `json:"amazon_redirect_uri,omitempty"` } // GetSettings retrieves the global service settings. diff --git a/pkg/service/handlers/handlers_setup.go b/pkg/service/handlers/handlers_setup.go index 7bf6b9f..365fa51 100644 --- a/pkg/service/handlers/handlers_setup.go +++ b/pkg/service/handlers/handlers_setup.go @@ -161,10 +161,26 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) { redact, logBody, record := s.proxyRedact, s.proxyLogBody, s.recordEnabled shortcuts := s.shortcuts spotifyConfigured := s.spotifyService != nil + spotifyClientID := s.spotifyClientID + spotifyClientSecret := s.spotifyClientSecret + spotifyRedirectURI := s.spotifyRedirectURI + amazonConfigured := s.amazonService != nil + amazonClientID := s.amazonClientID + amazonClientSecret := s.amazonClientSecret + amazonRedirectURI := s.amazonRedirectURI s.mu.RUnlock() dnsRunning, actualBind := s.GetDNSRunning() + // Mask secrets: return "***" if set so the UI can show "configured" without exposing the value. + if spotifyClientSecret != "" { + spotifyClientSecret = "***" + } + + if amazonClientSecret != "" { + amazonClientSecret = "***" + } + if err := json.NewEncoder(w).Encode(map[string]interface{}{ "server_url": serverURL, "https_server_url": httpsServerURL, @@ -185,6 +201,13 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) { "record_interactions": record, "shortcuts": shortcuts, "spotify_configured": spotifyConfigured, + "spotify_client_id": spotifyClientID, + "spotify_client_secret": spotifyClientSecret, + "spotify_redirect_uri": spotifyRedirectURI, + "amazon_configured": amazonConfigured, + "amazon_client_id": amazonClientID, + "amazon_client_secret": amazonClientSecret, + "amazon_redirect_uri": amazonRedirectURI, }); err != nil { http.Error(w, "Failed to encode response", http.StatusInternalServerError) return @@ -206,6 +229,12 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) { PreferredSource string `json:"preferred_source"` InternalPaths []string `json:"internal_paths"` Shortcuts map[string]int `json:"shortcuts"` + SpotifyClientID string `json:"spotify_client_id"` + SpotifyClientSecret string `json:"spotify_client_secret"` + SpotifyRedirectURI string `json:"spotify_redirect_uri"` + AmazonClientID string `json:"amazon_client_id"` + AmazonClientSecret string `json:"amazon_client_secret"` + AmazonRedirectURI string `json:"amazon_redirect_uri"` } if err := json.NewDecoder(r.Body).Decode(&settings); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) @@ -263,6 +292,12 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) { s.sm.ServerURL = settings.ServerURL } + // Update music service credentials (empty or "***" means "unchanged"). + s.applyMusicServiceCredentials( + settings.SpotifyClientID, settings.SpotifyClientSecret, settings.SpotifyRedirectURI, + settings.AmazonClientID, settings.AmazonClientSecret, settings.AmazonRedirectURI, + ) + // Persist to datastore // Access fields directly since we already hold the lock currentRedact := s.proxyRedact @@ -288,16 +323,32 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) { PreferredSource: s.preferredSource, InternalPaths: s.internalPaths, Shortcuts: s.shortcuts, + SpotifyClientID: s.spotifyClientID, + SpotifyClientSecret: s.spotifyClientSecret, + SpotifyRedirectURI: s.spotifyRedirectURI, + AmazonClientID: s.amazonClientID, + AmazonClientSecret: s.amazonClientSecret, + AmazonRedirectURI: s.amazonRedirectURI, }) dnsEnabled := s.dnsEnabled dnsUpstreamStr := strings.Join(s.dnsUpstream, ",") dnsBindAddr := s.dnsBindAddr + reinitSpotify := s.spotifyClientID != "" + reinitAmazon := s.amazonClientID != "" s.mu.Unlock() s.SetDNSSettings(dnsEnabled, dnsUpstreamStr, dnsBindAddr) + if reinitSpotify { + s.ReinitSpotifyService() + } + + if reinitAmazon { + s.ReinitAmazonService() + } + if err != nil { http.Error(w, "Failed to save settings: "+err.Error(), http.StatusInternalServerError) return diff --git a/pkg/service/handlers/server.go b/pkg/service/handlers/server.go index ebbd11d..e475ec1 100644 --- a/pkg/service/handlers/server.go +++ b/pkg/service/handlers/server.go @@ -57,6 +57,9 @@ type Server struct { spotifyClientSecret string spotifyRedirectURI string spotifyService *spotify.Service + amazonClientID string + amazonClientSecret string + amazonRedirectURI string amazonService *amazon.Service } @@ -313,6 +316,100 @@ func (s *Server) SetSpotifyConfig(clientID, clientSecret, redirectURI string) { s.spotifyRedirectURI = redirectURI } +// SetAmazonConfig sets the Amazon LWA OAuth configuration. +func (s *Server) SetAmazonConfig(clientID, clientSecret, redirectURI string) { + s.mu.Lock() + defer s.mu.Unlock() + + s.amazonClientID = clientID + s.amazonClientSecret = clientSecret + s.amazonRedirectURI = redirectURI +} + +// GetSpotifyConfig returns the current Spotify OAuth configuration. +func (s *Server) GetSpotifyConfig() (clientID, clientSecret, redirectURI string) { + s.mu.RLock() + defer s.mu.RUnlock() + + return s.spotifyClientID, s.spotifyClientSecret, s.spotifyRedirectURI +} + +// GetAmazonConfig returns the current Amazon LWA OAuth configuration. +func (s *Server) GetAmazonConfig() (clientID, clientSecret, redirectURI string) { + s.mu.RLock() + defer s.mu.RUnlock() + + return s.amazonClientID, s.amazonClientSecret, s.amazonRedirectURI +} + +// applyMusicServiceCredentials updates music service credential fields on the server. +// Must be called with s.mu held. Empty string or "***" (the masked GET value) means "unchanged". +func (s *Server) applyMusicServiceCredentials(spotifyID, spotifySecret, spotifyURI, amazonID, amazonSecret, amazonURI string) { + if spotifyID != "" { + s.spotifyClientID = spotifyID + } + + if spotifySecret != "" && spotifySecret != "***" { + s.spotifyClientSecret = spotifySecret + } + + if spotifyURI != "" { + s.spotifyRedirectURI = spotifyURI + } + + if amazonID != "" { + s.amazonClientID = amazonID + } + + if amazonSecret != "" && amazonSecret != "***" { + s.amazonClientSecret = amazonSecret + } + + if amazonURI != "" { + s.amazonRedirectURI = amazonURI + } +} + +// ReinitSpotifyService creates a new Spotify service from current config and replaces the running one. +func (s *Server) ReinitSpotifyService() { + clientID, clientSecret, redirectURI := s.GetSpotifyConfig() + if clientID == "" { + return + } + + if redirectURI == "" { + redirectURI = s.serverURL + "/mgmt/spotify/callback" + } + + svc := spotify.NewSpotifyService(clientID, clientSecret, redirectURI, s.ds.DataDir) + if err := svc.Load(); err != nil { + log.Printf("[Spotify] Failed to load accounts during reinit: %v", err) + } + + s.SetSpotifyService(svc) + log.Printf("[Spotify] Service reinitialized") +} + +// ReinitAmazonService creates a new Amazon service from current config and replaces the running one. +func (s *Server) ReinitAmazonService() { + clientID, clientSecret, redirectURI := s.GetAmazonConfig() + if clientID == "" { + return + } + + if redirectURI == "" { + redirectURI = s.serverURL + "/mgmt/amazon/callback" + } + + svc := amazon.NewAmazonService(clientID, clientSecret, redirectURI, s.ds.DataDir) + if err := svc.Load(); err != nil { + log.Printf("[Amazon] Failed to load accounts during reinit: %v", err) + } + + s.SetAmazonService(svc) + log.Printf("[Amazon] Service reinitialized") +} + // SetMgmtConfig sets the management API authentication credentials. func (s *Server) SetMgmtConfig(username, password string) { s.mu.Lock() diff --git a/pkg/service/handlers/web/index.html b/pkg/service/handlers/web/index.html index 4493b28..f108e08 100644 --- a/pkg/service/handlers/web/index.html +++ b/pkg/service/handlers/web/index.html @@ -264,11 +264,38 @@
Spotify Integration: -
- Checking configuration... +
+
+ + +
+
+ + +
+
+ + +
+
Checking configuration...
+
+
+
+ Amazon Music Integration: +
+
+ + +
+
+ + +
+
+ + +
+
Checking configuration...
@@ -1485,6 +1512,21 @@
+
+

Amazon Music Integration

+

+ Register a new Amazon Music source for this local account. This mimics the official SoundTouch app flow: +

+
    +
  1. Exchange OAuth code for an Amazon LWA token.
  2. +
  3. Register the source in the local Marge cloud profile.
  4. +
+ +
+
+

Connected Devices

Select an account to view devices.
diff --git a/pkg/service/handlers/web/js/script.js b/pkg/service/handlers/web/js/script.js index 5d76128..53a26cd 100644 --- a/pkg/service/handlers/web/js/script.js +++ b/pkg/service/handlers/web/js/script.js @@ -165,12 +165,43 @@ async function fetchSettings() { document.getElementById("internal-paths").value = settings.internal_paths.join("\n"); } + // Spotify credential fields + if (settings.spotify_client_id !== undefined) { + document.getElementById("spotify-client-id").value = settings.spotify_client_id || ""; + } + // Secret is masked to "***" by the backend when set; leave the password input blank + // so the placeholder "(leave blank to keep existing)" is shown. + document.getElementById("spotify-client-secret").value = ""; + if (settings.spotify_redirect_uri !== undefined) { + document.getElementById("spotify-redirect-uri").value = settings.spotify_redirect_uri || ""; + } const spotifyStatus = document.getElementById("spotify-config-status"); if (spotifyStatus) { if (settings.spotify_configured) { - spotifyStatus.innerHTML = '✅ Configured (Client ID present)'; + spotifyStatus.innerHTML = '✅ Active'; + } else if (settings.spotify_client_id) { + spotifyStatus.innerHTML = '⚠ Credentials saved — restart or re-save to activate'; } else { - spotifyStatus.innerHTML = '❌ Not Configured
' + 'To enable Spotify, provide SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET to the server.'; + spotifyStatus.innerHTML = '❌ Not configured'; + } + } + + // Amazon credential fields + if (settings.amazon_client_id !== undefined) { + document.getElementById("amazon-client-id").value = settings.amazon_client_id || ""; + } + document.getElementById("amazon-client-secret").value = ""; + if (settings.amazon_redirect_uri !== undefined) { + document.getElementById("amazon-redirect-uri").value = settings.amazon_redirect_uri || ""; + } + const amazonStatus = document.getElementById("amazon-config-status"); + if (amazonStatus) { + if (settings.amazon_configured) { + amazonStatus.innerHTML = '✅ Active'; + } else if (settings.amazon_client_id) { + amazonStatus.innerHTML = '⚠ Credentials saved — restart or re-save to activate'; + } else { + amazonStatus.innerHTML = '❌ Not configured'; } } @@ -233,6 +264,12 @@ async function updateSettings() { .value.split("\n") .map((s) => s.trim()) .filter((s) => s !== ""), + spotify_client_id: document.getElementById("spotify-client-id").value, + spotify_client_secret: document.getElementById("spotify-client-secret").value, + spotify_redirect_uri: document.getElementById("spotify-redirect-uri").value, + amazon_client_id: document.getElementById("amazon-client-id").value, + amazon_client_secret: document.getElementById("amazon-client-secret").value, + amazon_redirect_uri: document.getElementById("amazon-redirect-uri").value, }; const status = document.getElementById("settings-status"); status.innerText = "Saving..."; @@ -502,8 +539,10 @@ async function fetchAccountDetails(accountId) { const metadataEl = document.getElementById("account-metadata"); const devicesEl = document.getElementById("account-devices-list"); const regStatus = document.getElementById("spotify-reg-status"); + const amazonRegStatus = document.getElementById("amazon-reg-status"); if (regStatus) regStatus.innerText = ""; + if (amazonRegStatus) amazonRegStatus.innerText = ""; if (metadataEl) metadataEl.innerHTML = "Loading..."; if (devicesEl) devicesEl.innerHTML = "Loading devices..."; @@ -819,6 +858,47 @@ async function connectSpotifyToAccount() { } } +async function connectAmazonToAccount() { + const selector = document.getElementById("account-selector"); + const accountId = selector ? selector.value : "default"; + const statusEl = document.getElementById("amazon-reg-status"); + + if (statusEl) statusEl.innerHTML = "Initializing Amazon Music authorization..."; + + try { + const response = await fetch(`/mgmt/amazon/init?account=${encodeURIComponent(accountId)}`, { + method: "POST" + }); + if (!response.ok) { + const err = await response.text(); + throw new Error(err || response.statusText); + } + + const data = await response.json(); + const redirectUrl = data.redirectUrl; + + if (statusEl) { + statusEl.innerHTML = `Amazon Music authorization window opened.
If it didn't open, click here to authorize.`; + } + + window.open(redirectUrl, "AmazonAuth", "width=600,height=800"); + + let pollCount = 0; + const interval = setInterval(async () => { + pollCount++; + if (pollCount > 60) { + clearInterval(interval); + return; + } + await fetchAccountDetails(accountId); + }, 5000); + + } catch (error) { + if (statusEl) statusEl.innerHTML = `Error: ${error.message}`; + console.error("Amazon link failed", error); + } +} + async function fetchInteractionStats() { console.log("Fetching interaction stats..."); try {