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 ? + `
| 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}
+
+ `).join("");
+ })() : "None"}
+
|