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 <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 ca7f8d5453
commit 147a1a8490
6 changed files with 317 additions and 11 deletions
+34 -4
View File
@@ -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 <server-url>/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 <server-url>/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,
+6
View File
@@ -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.
+51
View File
@@ -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
+97
View File
@@ -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()
+47 -5
View File
@@ -264,11 +264,38 @@
</div>
<div style="margin-bottom: 20px">
<strong>Spotify Integration:</strong>
<div
id="spotify-config-status"
style="margin-top: 5px; font-size: 0.9em"
>
Checking configuration...
<div style="margin-top: 8px; margin-left: 20px">
<div style="margin-bottom: 8px">
<label for="spotify-client-id" style="display: inline-block; width: 130px">Client ID:</label>
<input type="text" id="spotify-client-id" placeholder="32-char hex string" style="width: 280px"/>
</div>
<div style="margin-bottom: 8px">
<label for="spotify-client-secret" style="display: inline-block; width: 130px">Client Secret:</label>
<input type="password" id="spotify-client-secret" placeholder="(leave blank to keep existing)" style="width: 280px"/>
</div>
<div style="margin-bottom: 8px">
<label for="spotify-redirect-uri" style="display: inline-block; width: 130px">Redirect URI:</label>
<input type="text" id="spotify-redirect-uri" placeholder="<your-service-url>/mgmt/spotify/callback" style="width: 280px"/>
</div>
<div id="spotify-config-status" style="margin-top: 5px; font-size: 0.9em">Checking configuration...</div>
</div>
</div>
<div style="margin-bottom: 20px">
<strong>Amazon Music Integration:</strong>
<div style="margin-top: 8px; margin-left: 20px">
<div style="margin-bottom: 8px">
<label for="amazon-client-id" style="display: inline-block; width: 130px">Client ID:</label>
<input type="text" id="amazon-client-id" placeholder="amzn1.application-oa2-client..." style="width: 280px"/>
</div>
<div style="margin-bottom: 8px">
<label for="amazon-client-secret" style="display: inline-block; width: 130px">Client Secret:</label>
<input type="password" id="amazon-client-secret" placeholder="(leave blank to keep existing)" style="width: 280px"/>
</div>
<div style="margin-bottom: 8px">
<label for="amazon-redirect-uri" style="display: inline-block; width: 130px">Redirect URI:</label>
<input type="text" id="amazon-redirect-uri" placeholder="<your-service-url>/mgmt/amazon/callback" style="width: 280px"/>
</div>
<div id="amazon-config-status" style="margin-top: 5px; font-size: 0.9em">Checking configuration...</div>
</div>
</div>
<div style="margin-bottom: 20px">
@@ -1485,6 +1512,21 @@
<div id="spotify-reg-status" style="margin-top: 10px; font-size: 0.9em;"></div>
</div>
<div id="amazon-registration-container" class="summary-box" style="margin-top: 20px;">
<h3>Amazon Music Integration</h3>
<p style="font-size: 0.9em; color: #555;">
Register a new Amazon Music source for this local account. This mimics the official SoundTouch app flow:
</p>
<ol style="font-size: 0.85em; color: #555; margin-bottom: 15px;">
<li>Exchange OAuth code for an Amazon LWA token.</li>
<li>Register the source in the local Marge cloud profile.</li>
</ol>
<button id="connect-amazon-account-btn" onclick="connectAmazonToAccount()" style="background: #ff9900; color: white; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer;">
Connect Amazon Music to this Account
</button>
<div id="amazon-reg-status" style="margin-top: 10px; font-size: 0.9em;"></div>
</div>
<div id="account-devices-container">
<h3>Connected Devices</h3>
<div id="account-devices-list">Select an account to view devices.</div>
+82 -2
View File
@@ -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 = '<span style="color: green;">✅ Configured</span> (Client ID present)';
spotifyStatus.innerHTML = '<span style="color: green;">✅ Active</span>';
} else if (settings.spotify_client_id) {
spotifyStatus.innerHTML = '<span style="color: orange;">⚠ Credentials saved — restart or re-save to activate</span>';
} else {
spotifyStatus.innerHTML = '<span style="color: #666;">❌ Not Configured</span><br>' + '<span style="font-size: 0.85em; color: #888;">To enable Spotify, provide <code>SPOTIFY_CLIENT_ID</code> and <code>SPOTIFY_CLIENT_SECRET</code> to the server.</span>';
spotifyStatus.innerHTML = '<span style="color: #666;">❌ Not configured</span>';
}
}
// 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 = '<span style="color: green;">✅ Active</span>';
} else if (settings.amazon_client_id) {
amazonStatus.innerHTML = '<span style="color: orange;">⚠ Credentials saved — restart or re-save to activate</span>';
} else {
amazonStatus.innerHTML = '<span style="color: #666;">❌ Not configured</span>';
}
}
@@ -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. <br/>If it didn't open, <a href="${redirectUrl}" target="_blank">click here to authorize</a>.`;
}
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 = `<span style="color:red">Error: ${error.message}</span>`;
console.error("Amazon link failed", error);
}
}
async function fetchInteractionStats() {
console.log("Fetching interaction stats...");
try {