Enhance account overview UI and make fields editable (#127)

- Added detailed provider settings display to account overview
- Made 'Language' field editable with auto-save functionality (currently
only `en` and `de` available without actual effect on any UI or speaker
config)
- Made 'SPOTIFY - STREAMING_QUALITY' editable with descriptive quality
options
- ⚠️ this currently only writes the account config, but does not update
the actual speaker setting
- Improved account data persistence and error handling
- Added tests for new management API endpoints and data store changes

---------

Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
Tobias Gesellchen
2026-03-22 11:42:58 +01:00
committed by GitHub
co-authored by Junie
parent 9f7cb81b45
commit 61b5c71097
9 changed files with 442 additions and 7 deletions
+2
View File
@@ -757,6 +757,8 @@ func setupRouter(server *handlers.Server) *chi.Mux {
r.Route("/accounts", func(r chi.Router) {
r.Get("/", server.HandleMgmtListAccounts)
r.Get("/{accountId}", server.HandleMgmtAccountDetails)
r.Post("/{accountId}/language", server.HandleMgmtUpdateAccountLanguage)
r.Post("/{accountId}/provider-settings", server.HandleMgmtUpdateAccountProviderSetting)
r.Get("/{accountId}/speakers", server.HandleMgmtListSpeakers)
})
+6 -4
View File
@@ -439,6 +439,7 @@ type ServiceAccountInfo struct {
AccountID string `json:"account_id"`
PreferredLanguage string `json:"preferred_language"`
ProviderSettings []ProviderSetting `json:"provider_settings"`
IsPlaceholder bool `json:"is_placeholder,omitempty"`
}
// CustomerSupportDevice represents device information for customer support purposes.
@@ -649,8 +650,9 @@ type AttachedProduct struct {
// ProviderSetting represents a single provider setting.
type ProviderSetting struct {
BoseID string `json:"bose_id" xml:"boseId"`
KeyName string `json:"key_name" xml:"keyName"`
Value string `json:"value" xml:"value"`
ProviderID string `json:"provider_id" xml:"providerId"`
BoseID string `json:"bose_id" xml:"boseId"`
KeyName string `json:"key_name" xml:"keyName"`
Value string `json:"value" xml:"value"`
ProviderID string `json:"provider_id" xml:"providerId"`
ProviderName string `json:"provider_name,omitempty" xml:"-"`
}
+18
View File
@@ -1,6 +1,8 @@
// Package constants defines file names, directories, and common values used by the service layer.
package constants
import "strconv"
// SourceProvider represents a media source provider configuration.
type SourceProvider struct {
ID int
@@ -97,6 +99,22 @@ func GetSourceLabel(sourceType string) string {
}
}
// GetProviderName returns the human-readable name for a provider ID.
func GetProviderName(providerID string) string {
id, err := strconv.Atoi(providerID)
if err != nil {
return providerID
}
for _, p := range StaticProviders {
if p.ID == id {
return p.Name
}
}
return providerID
}
// Providers lists known source provider identifiers used by Bose SoundTouch.
var Providers = []string{
"PANDORA",
+2 -1
View File
@@ -865,9 +865,10 @@ func (ds *DataStore) GetAccountInfo(accountID string) (*models.ServiceAccountInf
return &models.ServiceAccountInfo{AccountID: accountID}, nil
}
// Try account root (canonical location)
path := filepath.Join(ds.AccountDir(accountID), "account.json")
if !exists(path) {
return &models.ServiceAccountInfo{AccountID: accountID}, nil
return &models.ServiceAccountInfo{AccountID: accountID, IsPlaceholder: true}, nil
}
data, err := os.ReadFile(path)
+31
View File
@@ -92,6 +92,37 @@ func TestDataStore(t *testing.T) {
if ds.AccountDir(account) != expectedAccountDir {
t.Errorf("Expected account dir %s, got %s", expectedAccountDir, ds.AccountDir(account))
}
// Test GetAccountInfo with placeholder
accInfo, err := ds.GetAccountInfo("non-existent")
if err != nil {
t.Errorf("GetAccountInfo failed: %v", err)
}
if !accInfo.IsPlaceholder {
t.Errorf("Expected IsPlaceholder to be true for non-existent account")
}
// Test SaveAccountInfo and GetAccountInfo
accountID := "acc-123"
info2 := &models.ServiceAccountInfo{
AccountID: accountID,
PreferredLanguage: "en",
}
err = ds.SaveAccountInfo(accountID, info2)
if err != nil {
t.Errorf("SaveAccountInfo failed: %v", err)
}
loadedAccInfo, err := ds.GetAccountInfo(accountID)
if err != nil {
t.Errorf("GetAccountInfo failed: %v", err)
}
if loadedAccInfo.PreferredLanguage != "en" {
t.Errorf("Expected language en, got %s", loadedAccInfo.PreferredLanguage)
}
if loadedAccInfo.IsPlaceholder {
t.Errorf("Expected IsPlaceholder to be false for existing account")
}
}
func TestListAllDevices_Empty(t *testing.T) {
@@ -21,6 +21,14 @@ func (s *Server) HandleMgmtAccountDetails(w http.ResponseWriter, r *http.Request
accountInfo = &models.ServiceAccountInfo{AccountID: accountID}
}
// Enrich provider settings with names
for i := range accountInfo.ProviderSettings {
s := &accountInfo.ProviderSettings[i]
if s.ProviderName == "" {
s.ProviderName = constants.GetProviderName(s.ProviderID)
}
}
// 2. List all devices for this account
allDevices, err := s.ds.ListAllDevices()
if err != nil {
@@ -49,6 +57,103 @@ func (s *Server) HandleMgmtAccountDetails(w http.ResponseWriter, r *http.Request
}
}
// HandleMgmtUpdateAccountLanguage updates the preferred language for an account.
func (s *Server) HandleMgmtUpdateAccountLanguage(w http.ResponseWriter, r *http.Request) {
accountID := chi.URLParam(r, "accountId")
var req struct {
Language string `json:"language"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.Language != "en" && req.Language != "de" {
http.Error(w, "Language must be 'en' or 'de'", http.StatusBadRequest)
return
}
// 1. Load current account info
accountInfo, err := s.ds.GetAccountInfo(accountID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// 2. Update language
accountInfo.AccountID = accountID // Ensure ID is correct
accountInfo.PreferredLanguage = req.Language
accountInfo.IsPlaceholder = false
// 3. Save account info
if err := s.ds.SaveAccountInfo(accountID, accountInfo); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
// HandleMgmtUpdateAccountProviderSetting updates a specific provider setting for an account.
func (s *Server) HandleMgmtUpdateAccountProviderSetting(w http.ResponseWriter, r *http.Request) {
accountID := chi.URLParam(r, "accountId")
var req struct {
ProviderID string `json:"provider_id"`
Key string `json:"key"`
Value string `json:"value"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.ProviderID == "" || req.Key == "" {
http.Error(w, "provider_id and key are required", http.StatusBadRequest)
return
}
// 1. Load current account info
accountInfo, err := s.ds.GetAccountInfo(accountID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// 2. Update the specific setting
found := false
for i, setting := range accountInfo.ProviderSettings {
if setting.ProviderID == req.ProviderID && setting.KeyName == req.Key {
accountInfo.ProviderSettings[i].Value = req.Value
found = true
break
}
}
if !found {
// If not found, we can choose to add it or return error.
// For now, let's return an error as we expect to edit existing ones.
http.Error(w, "Provider setting not found", http.StatusNotFound)
return
}
accountInfo.AccountID = accountID // Ensure ID is correct
accountInfo.IsPlaceholder = false
// 3. Save account info
if err := s.ds.SaveAccountInfo(accountID, accountInfo); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
type deviceDetail struct {
models.AccountDevice
Presets []models.FullResponsePreset `json:"presets,omitempty"`
@@ -1,6 +1,7 @@
package handlers
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -147,6 +148,138 @@ func TestHandleMgmtAccountDetails_Recents(t *testing.T) {
}
}
func TestHandleMgmtUpdateAccountLanguage(t *testing.T) {
tempBaseDir := "mgmt_test_data_lang"
err := os.MkdirAll(tempBaseDir, 0755)
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempBaseDir)
ds := datastore.NewDataStore(tempBaseDir)
err = ds.Initialize()
if err != nil {
t.Fatal(err)
}
accountID := "1234567"
server := &Server{ds: ds}
r := chi.NewRouter()
r.Post("/mgmt/accounts/{accountId}/language", server.HandleMgmtUpdateAccountLanguage)
t.Run("Valid Language de", func(t *testing.T) {
body, _ := json.Marshal(map[string]string{"language": "de"})
req := httptest.NewRequest("POST", "/mgmt/accounts/1234567/language", bytes.NewBuffer(body))
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String())
}
accInfo, _ := ds.GetAccountInfo(accountID)
if accInfo.PreferredLanguage != "de" {
t.Errorf("Expected language 'de', got '%s'", accInfo.PreferredLanguage)
}
})
t.Run("Invalid Language fr", func(t *testing.T) {
body, _ := json.Marshal(map[string]string{"language": "fr"})
req := httptest.NewRequest("POST", "/mgmt/accounts/1234567/language", bytes.NewBuffer(body))
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected status 400, got %d", w.Code)
}
})
}
func TestHandleMgmtUpdateAccountProviderSetting(t *testing.T) {
tempBaseDir := "mgmt_test_data_provider"
err := os.MkdirAll(tempBaseDir, 0755)
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempBaseDir)
ds := datastore.NewDataStore(tempBaseDir)
err = ds.Initialize()
if err != nil {
t.Fatal(err)
}
accountID := "1234567"
server := &Server{ds: ds}
// Setup initial account info
initialInfo := &models.ServiceAccountInfo{
AccountID: accountID,
ProviderSettings: []models.ProviderSetting{
{
ProviderID: "15",
KeyName: "STREAMING_QUALITY",
Value: "2",
},
},
}
ds.SaveAccountInfo(accountID, initialInfo)
r := chi.NewRouter()
r.Post("/mgmt/accounts/{accountId}/provider-settings", server.HandleMgmtUpdateAccountProviderSetting)
t.Run("Valid Update", func(t *testing.T) {
payload := map[string]string{
"provider_id": "15",
"key": "STREAMING_QUALITY",
"value": "3",
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", "/mgmt/accounts/1234567/provider-settings", bytes.NewBuffer(body))
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String())
}
accInfo, _ := ds.GetAccountInfo(accountID)
found := false
for _, s := range accInfo.ProviderSettings {
if s.ProviderID == "15" && s.KeyName == "STREAMING_QUALITY" {
if s.Value != "3" {
t.Errorf("Expected value '3', got '%s'", s.Value)
}
found = true
}
}
if !found {
t.Error("Provider setting not found after update")
}
})
t.Run("Setting Not Found", func(t *testing.T) {
payload := map[string]string{
"provider_id": "99",
"key": "NON_EXISTENT",
"value": "val",
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", "/mgmt/accounts/1234567/provider-settings", bytes.NewBuffer(body))
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("Expected status 404, got %d", w.Code)
}
})
}
func TestHandleMgmtAccountDetails_Sources(t *testing.T) {
tempBaseDir := "sources_test_data"
err := os.MkdirAll(tempBaseDir, 0755)
+138 -2
View File
@@ -506,13 +506,149 @@ async function fetchAccountDetails(accountId) {
// Render Metadata
if (metadataEl) {
const warningNotice = data.account.is_placeholder ?
`<div style="background: #fff3cd; color: #856404; padding: 10px; border: 1px solid #ffeeba; border-radius: 4px; margin-bottom: 10px; font-size: 0.85em;">
<strong>Notice:</strong> Account data (account.json) was not found in the expected location for this account ID.
</div>` : "";
metadataEl.innerHTML = `
${warningNotice}
<table style="width: 100%; font-size: 0.9em;">
<tr><td style="padding: 4px"><strong>Account ID:</strong></td><td style="padding: 4px">${data.account.account_id}</td></tr>
<tr><td style="padding: 4px"><strong>Language:</strong></td><td style="padding: 4px">${data.account.preferred_language || "Not set"}</td></tr>
<tr><td style="padding: 4px"><strong>Provider Settings:</strong></td><td style="padding: 4px">${data.account.provider_settings ? "Configured" : "None"}</td></tr>
<tr><td style="padding: 4px"><strong>Language:</strong></td><td style="padding: 4px">
<select id="account-language-select" style="font-size: 0.9em; padding: 2px;">
<option value="en" ${data.account.preferred_language === "en" || !data.account.preferred_language ? "selected" : ""}>en</option>
<option value="de" ${data.account.preferred_language === "de" ? "selected" : ""}>de</option>
</select>
<span id="language-update-status" style="margin-left: 8px; font-size: 0.8em; display: none;">Saving...</span>
</td></tr>
<tr><td style="padding: 4px"><strong>Provider Settings:</strong></td><td style="padding: 4px">
${data.account.provider_settings && data.account.provider_settings.length > 0 ?
(() => {
const grouped = data.account.provider_settings.reduce((acc, s) => {
const pName = s.provider_name || s.provider_id;
if (!acc[pName]) acc[pName] = [];
acc[pName].push(s);
return acc;
}, {});
return Object.entries(grouped).map(([pName, settings]) => `
<div style="margin-bottom: 8px;">
<strong>${pName}</strong>
<ul style="margin: 2px 0 0 0; padding-left: 20px; list-style-type: disc;">
${settings.map(s => {
if ((s.provider_name === "SPOTIFY" || s.provider_id === "15") && s.key_name === "STREAMING_QUALITY") {
return `
<li style="margin-bottom: 4px;">
Music Streaming Quality:
<select class="provider-setting-select"
data-account-id="${data.account.account_id}"
data-provider-id="${s.provider_id}"
data-key="${s.key_name}"
style="font-size: 0.9em; padding: 2px; margin-left: 4px;">
<option value="1" ${s.value === "1" ? "selected" : ""}>Fastest Streaming - up to 128 kbit/s</option>
<option value="2" ${s.value === "2" ? "selected" : ""}>Balanced Quality and Speed - up to 192 kbit/s</option>
<option value="3" ${s.value === "3" ? "selected" : ""}>Best Quality - up to 320 kbit/s</option>
</select>
<span class="setting-update-status" style="margin-left: 8px; font-size: 0.8em; display: none;">Saving...</span>
</li>
`;
}
return `<li>${s.key_name}: ${s.value}</li>`;
}).join("")}
</ul>
</div>
`).join("");
})() : "None"}
</td></tr>
</table>
`;
const languageSelect = document.getElementById("account-language-select");
if (languageSelect) {
languageSelect.addEventListener("change", async (e) => {
const statusEl = document.getElementById("language-update-status");
const newLang = e.target.value;
if (statusEl) {
statusEl.innerText = "Saving...";
statusEl.style.display = "inline";
statusEl.style.color = "#666";
}
try {
const response = await fetch(`/mgmt/accounts/${data.account.account_id}/language`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ language: newLang }),
});
if (response.ok) {
if (statusEl) {
statusEl.innerText = "Saved!";
statusEl.style.color = "#28a745";
setTimeout(() => {
statusEl.style.display = "none";
}, 2000);
}
} else {
throw new Error(await response.text());
}
} catch (error) {
console.error("Failed to update language", error);
if (statusEl) {
statusEl.innerText = "Error!";
statusEl.style.color = "#dc3545";
}
}
});
}
const providerSettingSelects = document.querySelectorAll(".provider-setting-select");
providerSettingSelects.forEach(select => {
select.addEventListener("change", async (e) => {
const statusEl = e.target.parentElement.querySelector(".setting-update-status");
const accID = e.target.dataset.accountId;
const provID = e.target.dataset.providerId;
const key = e.target.dataset.key;
const newValue = e.target.value;
if (statusEl) {
statusEl.innerText = "Saving...";
statusEl.style.display = "inline";
statusEl.style.color = "#666";
}
try {
const response = await fetch(`/mgmt/accounts/${accID}/provider-settings`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
provider_id: provID,
key: key,
value: newValue
}),
});
if (response.ok) {
if (statusEl) {
statusEl.innerText = "Saved!";
statusEl.style.color = "#28a745";
setTimeout(() => {
statusEl.style.display = "none";
}, 2000);
}
} else {
throw new Error(await response.text());
}
} catch (error) {
console.error("Failed to update provider setting", error);
if (statusEl) {
statusEl.innerText = "Error!";
statusEl.style.color = "#dc3545";
}
}
});
});
}
// Render Devices
+7
View File
@@ -518,6 +518,13 @@ func AccountFullToXML(ds *datastore.DataStore, account string) ([]byte, error) {
}
}
for i := range resp.ProviderSettings {
ps := &resp.ProviderSettings[i]
if ps.ProviderName == "" {
ps.ProviderName = constants.GetProviderName(ps.ProviderID)
}
}
var lastDeviceID string
for _, entry := range entries {