diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go index 1d822ae..ed4a67e 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -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) }) diff --git a/pkg/models/models.go b/pkg/models/models.go index 3e3a2f5..3f9e017 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -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:"-"` } diff --git a/pkg/service/constants/constants.go b/pkg/service/constants/constants.go index ab405cf..75bf7e8 100644 --- a/pkg/service/constants/constants.go +++ b/pkg/service/constants/constants.go @@ -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", diff --git a/pkg/service/datastore/datastore.go b/pkg/service/datastore/datastore.go index 7ca259a..18cec93 100644 --- a/pkg/service/datastore/datastore.go +++ b/pkg/service/datastore/datastore.go @@ -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) diff --git a/pkg/service/datastore/datastore_test.go b/pkg/service/datastore/datastore_test.go index b6c3a91..af34cfd 100644 --- a/pkg/service/datastore/datastore_test.go +++ b/pkg/service/datastore/datastore_test.go @@ -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) { diff --git a/pkg/service/handlers/handlers_account_mgmt.go b/pkg/service/handlers/handlers_account_mgmt.go index f99b944..11bb9f6 100644 --- a/pkg/service/handlers/handlers_account_mgmt.go +++ b/pkg/service/handlers/handlers_account_mgmt.go @@ -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"` diff --git a/pkg/service/handlers/handlers_account_mgmt_test.go b/pkg/service/handlers/handlers_account_mgmt_test.go index 13e090a..54966ca 100644 --- a/pkg/service/handlers/handlers_account_mgmt_test.go +++ b/pkg/service/handlers/handlers_account_mgmt_test.go @@ -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) diff --git a/pkg/service/handlers/web/js/script.js b/pkg/service/handlers/web/js/script.js index 6a9f890..3176221 100644 --- a/pkg/service/handlers/web/js/script.js +++ b/pkg/service/handlers/web/js/script.js @@ -506,13 +506,149 @@ async function fetchAccountDetails(accountId) { // Render Metadata if (metadataEl) { + const warningNotice = data.account.is_placeholder ? + `
+ Notice: Account data (account.json) was not found in the expected location for this account ID. +
` : ""; + metadataEl.innerHTML = ` + ${warningNotice} - - + +
Account ID:${data.account.account_id}
Language:${data.account.preferred_language || "Not set"}
Provider Settings:${data.account.provider_settings ? "Configured" : "None"}
Language: + + +
Provider Settings: + ${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]) => ` +
+ ${pName} +
    + ${settings.map(s => { + if ((s.provider_name === "SPOTIFY" || s.provider_id === "15") && s.key_name === "STREAMING_QUALITY") { + return ` +
  • + Music Streaming Quality: + + +
  • + `; + } + return `
  • ${s.key_name}: ${s.value}
  • `; + }).join("")} +
+
+ `).join(""); + })() : "None"} +
`; + + 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 diff --git a/pkg/service/marge/marge.go b/pkg/service/marge/marge.go index 4a888ed..1abc49e 100644 --- a/pkg/service/marge/marge.go +++ b/pkg/service/marge/marge.go @@ -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 {