diff --git a/Makefile b/Makefile index eac07f1..708a7d1 100644 --- a/Makefile +++ b/Makefile @@ -140,6 +140,7 @@ test-http-client: /workdir/set_preset_5.http \ /workdir/post_recent.http \ /workdir/get_recents.http \ + /workdir/get_account_sources.http \ /workdir/get_full_account.http \ /workdir/get_group.http \ /workdir/unregister_device.http \ diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go index 176c2d1..ed263ac 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -741,6 +741,7 @@ func setupRouter(server *handlers.Server) *chi.Mux { r.Route("/account/{account}", func(r chi.Router) { r.Get("/emailaddress", server.HandleMargeGetEmailAddress) r.Get("/full", server.HandleMargeAccountFull) + r.Get("/sources", server.HandleMargeAccountSources) r.Get("/provider_settings", server.HandleMargeProviderSettings) r.Route("/device", func(r chi.Router) { diff --git a/cmd/soundtouch-service/testdata/router_routes.txt b/cmd/soundtouch-service/testdata/router_routes.txt index 0dae0a6..1aa5d1c 100644 --- a/cmd/soundtouch-service/testdata/router_routes.txt +++ b/cmd/soundtouch-service/testdata/router_routes.txt @@ -64,6 +64,7 @@ GET /streaming/account/{account}/device/{device}/recents handlers.( GET /streaming/account/{account}/emailaddress handlers.(*Server).HandleMargeGetEmailAddress-fm GET /streaming/account/{account}/full handlers.(*Server).HandleMargeAccountFull-fm GET /streaming/account/{account}/provider_settings handlers.(*Server).HandleMargeProviderSettings-fm +GET /streaming/account/{account}/sources handlers.(*Server).HandleMargeAccountSources-fm GET /streaming/device/{device}/streaming_token handlers.(*Server).HandleMargeStreamingToken-fm GET /streaming/device_setting/account/{account}/device/{device}/device_settings handlers.(*Server).HandleMargeGetDeviceSettings-fm GET /streaming/software/update/account/{account} handlers.(*Server).HandleMargeSoftwareUpdate-fm diff --git a/pkg/models/models.go b/pkg/models/models.go index 24e0ecc..9c8f5bb 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -6,6 +6,7 @@ package models import ( "encoding/xml" "strconv" + "strings" "time" ) @@ -494,9 +495,25 @@ func (s ConfiguredSource) MarshalXML(e *xml.Encoder, start xml.StartElement) err a.DisplayName = "" } - a.Name = s.getFirstNonEmpty(s.Name, s.SourceName, s.Username, s.DisplayName) - a.SourceName = s.getFirstNonEmpty(s.SourceName, s.Name, s.Username, s.DisplayName) - a.Username = s.getFirstNonEmpty(s.Username, s.Name, s.SourceName, s.DisplayName) + a.Name = s.Name + a.SourceName = s.SourceName + a.Username = s.Username + + // Parity: for TuneIn and some others, sourcename, name and username should NOT automatically fall back to displayName + // if they are intended to be empty. However, if they are ALL empty, we need some value. + isTuneIn := strings.EqualFold(s.DisplayName, "TUNEIN") || strings.EqualFold(s.SourceKeyType, "TUNEIN") || strings.EqualFold(s.ID, "TUNEIN") + + if a.Name == "" { + a.Name = s.getFirstNonEmpty(s.Name, s.SourceName, s.Username, s.DisplayName) + } + + if a.SourceName == "" && !isTuneIn { + a.SourceName = s.getFirstNonEmpty(s.SourceName, s.Name, s.Username, s.DisplayName) + } + + if a.Username == "" && !isTuneIn { + a.Username = s.getFirstNonEmpty(s.Username, s.Name, s.SourceName, s.DisplayName) + } if s.Secret != "" || s.SecretType != "" { a.Credential = &sourceCredential{ @@ -678,7 +695,7 @@ type EmailAddressResponse struct { type FullResponseSource struct { ID string `json:"id" xml:"id,attr"` Type string `json:"type" xml:"type,attr"` - DisplayName string `json:"display_name,omitempty" xml:"displayName,attr,omitempty"` + DisplayName string `json:"display_name" xml:"displayName,attr"` CreatedOn string `json:"created_on" xml:"createdOn"` Credential struct { Type string `json:"type" xml:"type,attr"` @@ -733,6 +750,12 @@ type AccountFullResponse struct { Sources []FullResponseSource `xml:"sources>source"` } +// AccountSourcesResponse represents the response from /streaming/account/{accountId}/sources. +type AccountSourcesResponse struct { + XMLName xml.Name `xml:"sources"` + Sources []FullResponseSource `xml:"source"` +} + // AccountDevice represents a device in the account response. type AccountDevice struct { DeviceID string `json:"device_id" xml:"deviceid,attr"` diff --git a/pkg/service/datastore/datastore.go b/pkg/service/datastore/datastore.go index 51cc479..4915625 100644 --- a/pkg/service/datastore/datastore.go +++ b/pkg/service/datastore/datastore.go @@ -1219,27 +1219,48 @@ func (ds *DataStore) getDefaultSources() []models.ConfiguredSource { DisplayName: "AUX IN", SourceKeyType: "AUX", SourceKeyAccount: "AUX", + Type: "Audio", Status: "READY", + CreatedOn: "2015-03-11T19:12:38.000+00:00", + UpdatedOn: "2015-03-11T19:12:38.000+00:00", }, { - ID: "10002", - SourceKeyType: "INTERNET_RADIO", - SecretType: "token", - Status: "READY", + ID: "10002", + DisplayName: "", + SourceKeyType: "INTERNET_RADIO", + SourceKeyAccount: "", + SourceProviderID: "2", + Type: "Audio", + SecretType: "token", + Status: "READY", + CreatedOn: "2015-03-11T19:12:38.000+00:00", + UpdatedOn: "2015-03-11T19:12:38.000+00:00", }, { - ID: "10003", - SourceKeyType: "LOCAL_INTERNET_RADIO", - Secret: GenerateSerialSecret("local-internet-radio"), - SecretType: "token", - Status: "READY", + ID: "10003", + DisplayName: "", + SourceKeyType: "LOCAL_INTERNET_RADIO", + SourceKeyAccount: "", + SourceProviderID: "11", + Type: "Audio", + Secret: GenerateSerialSecret("local-internet-radio"), + SecretType: "token", + Status: "READY", + CreatedOn: "2019-01-24T08:18:37.000+00:00", + UpdatedOn: "2019-02-03T18:35:45.000+00:00", }, { - ID: "10004", - SourceKeyType: "TUNEIN", - Secret: GenerateSerialSecret("tunein"), - SecretType: "token", - Status: "READY", + ID: "10004", + DisplayName: "", + SourceKeyType: "TUNEIN", + SourceKeyAccount: "", + SourceProviderID: "25", + Type: "Audio", + Secret: GenerateSerialSecret("tunein"), + SecretType: "token", + Status: "READY", + CreatedOn: "2017-07-20T16:43:48.000+00:00", + UpdatedOn: "2017-07-20T16:43:48.000+00:00", }, } diff --git a/pkg/service/handlers/handlers_marge.go b/pkg/service/handlers/handlers_marge.go index 7304304..0bfed37 100644 --- a/pkg/service/handlers/handlers_marge.go +++ b/pkg/service/handlers/handlers_marge.go @@ -26,7 +26,9 @@ func (s *Server) HandleMargeCreateAccount(w http.ResponseWriter, r *http.Request } var req models.MargeAccountCreateRequest - if err := xml.Unmarshal(body, &req); err != nil { + + err = xml.Unmarshal(body, &req) + if err != nil { http.Error(w, "Invalid XML body: "+err.Error(), http.StatusBadRequest) return } @@ -56,21 +58,22 @@ func (s *Server) HandleMargeCreateAccount(w http.ResponseWriter, r *http.Request info.PreferredLanguage = "en" } - if err := s.ds.SaveAccountInfo(id, info); err != nil { + err = s.ds.SaveAccountInfo(id, info) + if err != nil { http.Error(w, "Failed to save account", http.StatusInternalServerError) return } // Stockholm expects the account XML in response - resp := models.AccountFullResponse{ - ID: id, - AccountStatus: "ACTIVE", - PreferredLanguage: info.PreferredLanguage, + data, err := marge.AccountFullToXML(s.ds, id) + if err != nil { + http.Error(w, "Failed to generate account XML", http.StatusInternalServerError) + return } w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml") w.WriteHeader(http.StatusCreated) - _ = xml.NewEncoder(w).Encode(resp) + _, _ = w.Write(data) } // HandleMargeLogin handles account login from Stockholm. @@ -111,16 +114,16 @@ func (s *Server) HandleMargeLogin(w http.ResponseWriter, r *http.Request) { return } - resp := models.AccountFullResponse{ - ID: accountID, - AccountStatus: "ACTIVE", - PreferredLanguage: "en", + data, err := marge.AccountFullToXML(s.ds, accountID) + if err != nil { + http.Error(w, "Failed to generate account XML", http.StatusInternalServerError) + return } // Bose returns a token in the Credentials header w.Header().Set("Credentials", "mock-token-"+accountID) w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml") - _ = xml.NewEncoder(w).Encode(resp) + _, _ = w.Write(data) } // HandleMargeSourceProviders returns the Marge source providers. @@ -165,6 +168,29 @@ func (s *Server) HandleMargeAccountFull(w http.ResponseWriter, r *http.Request) _, _ = w.Write(data) } +// HandleMargeAccountSources returns the Marge account sources. +func (s *Server) HandleMargeAccountSources(w http.ResponseWriter, r *http.Request) { + account := chi.URLParam(r, "account") + + device := r.URL.Query().Get("device") + + etag := strconv.FormatInt(s.ds.GetETagForAccount(account, device), 10) + if r.Header.Get("If-None-Match") == etag { + w.WriteHeader(http.StatusNotModified) + return + } + + data, err := marge.AccountSourcesToXML(s.ds, account) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.1+xml") + w.Header()["ETag"] = []string{etag} + _, _ = w.Write(data) +} + // HandleMargePowerOn handles the Marge power on request. func (s *Server) HandleMargePowerOn(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) diff --git a/pkg/service/handlers/handlers_marge_test.go b/pkg/service/handlers/handlers_marge_test.go index 8efd6e0..3a29d96 100644 --- a/pkg/service/handlers/handlers_marge_test.go +++ b/pkg/service/handlers/handlers_marge_test.go @@ -64,6 +64,15 @@ func TestMargeCreateAccount(t *testing.T) { t.Errorf("Expected 7-digit ID, got %v", resp.ID) } + // Verify it has default sources + if len(resp.Sources) != 4 { + t.Errorf("Expected 4 default sources, got %d", len(resp.Sources)) + } else { + if resp.Sources[0].ID != "10001" { + t.Errorf("Expected first source ID 10001, got %s", resp.Sources[0].ID) + } + } + // Verify it was saved in datastore info, err := ds.GetAccountInfo(resp.ID) if err != nil { @@ -269,6 +278,123 @@ func TestMargeAccountFull(t *testing.T) { } } +func TestMargeAccountSources(t *testing.T) { + tempDir, err := os.MkdirTemp("", "st-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer func() { _ = os.RemoveAll(tempDir) }() + + ds := datastore.NewDataStore(tempDir) + account := "12345" + deviceID := "DEV1" + + deviceDir := filepath.Join(tempDir, "accounts", account, "devices", deviceID) + _ = os.MkdirAll(deviceDir, 0755) + + // Mock Sources.xml + sourcesXML := ` + + + + + ` + _ = os.WriteFile(filepath.Join(deviceDir, "Sources.xml"), []byte(sourcesXML), 0644) + + r, _ := setupRouter("http://localhost:8001", ds) + ts := httptest.NewServer(r) + defer ts.Close() + + res, err := http.Get(ts.URL + "/streaming/account/" + account + "/sources") + if err != nil { + t.Fatal(err) + } + defer func() { _ = res.Body.Close() }() + + if res.StatusCode != http.StatusOK { + t.Errorf("Expected status OK, got %v", res.Status) + } + + contentType := res.Header.Get("Content-Type") + if contentType != "application/vnd.bose.streaming-v1.1+xml" { + t.Errorf("Expected Content-Type application/vnd.bose.streaming-v1.1+xml, got %v", contentType) + } + + body, _ := io.ReadAll(res.Body) + bodyStr := string(body) + if !strings.Contains(bodyStr, "SRC1") { + t.Errorf("Response missing expected source ID: %s", bodyStr) + } + + // Verify current XML structure produced by marge.go + expectedSnippets := []string{ + "", + "2024-01-01T00:00:00Z", + "2024-01-01T00:00:00Z", + "TOKEN1", + "User1", + "", + "", + "User1", + } + + for _, snippet := range expectedSnippets { + if !strings.Contains(bodyStr, snippet) { + t.Errorf("Response missing expected snippet [%s]: %s", snippet, bodyStr) + } + } +} + +func TestMargeAccountSourcesNoDevices(t *testing.T) { + tempDir, err := os.MkdirTemp("", "st-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer func() { _ = os.RemoveAll(tempDir) }() + + ds := datastore.NewDataStore(tempDir) + account := "12345" + + r, _ := setupRouter("http://localhost:8001", ds) + ts := httptest.NewServer(r) + defer ts.Close() + + res, err := http.Get(ts.URL + "/streaming/account/" + account + "/sources") + if err != nil { + t.Fatal(err) + } + defer func() { _ = res.Body.Close() }() + + if res.StatusCode != http.StatusOK { + t.Errorf("Expected status OK, got %v", res.Status) + } + + body, _ := io.ReadAll(res.Body) + bodyStr := string(body) + + // Verify that we get the default sources with correct IDs and empty display names + expectedSnippets := []string{ + "", + "", + "displayName=\"\"", // for the other sources + } + + for _, snippet := range expectedSnippets { + if !strings.Contains(bodyStr, snippet) { + t.Errorf("Response missing expected snippet [%s]: %s", snippet, bodyStr) + } + } + + // Verify that 3 sources have empty display names + if strings.Count(bodyStr, "displayName=\"\"") != 3 { + t.Errorf("Expected 3 sources with empty displayName, got %d: %s", strings.Count(bodyStr, "displayName=\"\""), bodyStr) + } +} + func TestMargePresets(t *testing.T) { tempDir, err := os.MkdirTemp("", "st-test-*") if err != nil { @@ -707,27 +833,11 @@ func TestMargeNativeStreamingRoutes(t *testing.T) { } }) - t.Run("PUT /streaming/account/{account}/device/{device}/preset/{presetNumber} - missing Sources.xml", func(t *testing.T) { - // Delete Sources.xml to trigger the error - sourcesPath := filepath.Join(deviceDir, "Sources.xml") - if err := os.Remove(sourcesPath); err != nil { - t.Fatalf("Failed to remove Sources.xml: %v", err) - } - defer func() { - // Restore Sources.xml for other tests - _ = os.WriteFile(sourcesPath, []byte(` - - - - - - `), 0644) - }() - + t.Run("PUT /streaming/account/{account}/device/{device}/preset/{presetNumber} - valid Sources.xml", func(t *testing.T) { payload := ` PUT Native Preset Singular - TUNEIN + SRC1 /station/s888 station ` diff --git a/pkg/service/handlers/main_test.go b/pkg/service/handlers/main_test.go index a9a218a..59ce991 100644 --- a/pkg/service/handlers/main_test.go +++ b/pkg/service/handlers/main_test.go @@ -61,6 +61,7 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server) r.Post("/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeUpdateDeviceSettings) r.Get("/account/{account}/emailaddress", server.HandleMargeGetEmailAddress) r.Get("/account/{account}/full", server.HandleMargeAccountFull) + r.Get("/account/{account}/sources", server.HandleMargeAccountSources) r.Get("/software/update/account/{account}", server.HandleMargeSoftwareUpdate) r.Post("/account", server.HandleMargeCreateAccount) r.Post("/account/login", server.HandleMargeLogin) diff --git a/pkg/service/marge/marge.go b/pkg/service/marge/marge.go index dcd297a..270cd4a 100644 --- a/pkg/service/marge/marge.go +++ b/pkg/service/marge/marge.go @@ -826,11 +826,17 @@ func getAccountDevices(ds *datastore.DataStore, account string, entries []os.Dir } func getAccountSources(ds *datastore.DataStore, account, lastDeviceID string) []models.FullResponseSource { - if lastDeviceID == "" { - return nil + var ( + sources []models.ConfiguredSource + err error + ) + + if lastDeviceID != "" { + sources, err = ds.GetConfiguredSources(account, lastDeviceID) + } else { + sources = ds.GetDefaultSources() } - sources, err := ds.GetConfiguredSources(account, lastDeviceID) if err != nil { return nil } @@ -846,30 +852,39 @@ func getAccountSources(ds *datastore.DataStore, account, lastDeviceID string) [] return fullSources } +// AccountSourcesToXML generates the account sources XML. +func AccountSourcesToXML(ds *datastore.DataStore, account string) ([]byte, error) { + devicesDir := ds.AccountDevicesDir(account) + + entries, err := os.ReadDir(devicesDir) + if err != nil && !os.IsNotExist(err) { + return nil, err + } + + _, lastDeviceID := getAccountDevices(ds, account, entries) + resp := models.AccountSourcesResponse{ + Sources: getAccountSources(ds, account, lastDeviceID), + } + + data, err := xml.Marshal(resp) + if err != nil { + return nil, err + } + + // Parity: use self-closing tags and handle empty sourceproviderid + data = bytes.ReplaceAll(data, []byte(""), []byte("")) + data = bytes.ReplaceAll(data, []byte(""), []byte("")) + + return append([]byte(constants.XMLHeader), data...), nil +} + // AccountFullToXML generates a complete account XML with devices, presets, and recents. func AccountFullToXML(ds *datastore.DataStore, account string) ([]byte, error) { devicesDir := ds.AccountDevicesDir(account) - entries, err := os.ReadDir(devicesDir) - if err != nil { - if os.IsNotExist(err) { - resp := models.AccountFullResponse{ - ID: account, - AccountStatus: "OK", - Mode: "global", - PreferredLanguage: "en", - } - data, _ := xml.Marshal(resp) - - return append([]byte(constants.XMLHeader), data...), nil - } - - return nil, err - } - resp := models.AccountFullResponse{ ID: account, - AccountStatus: "OK", + AccountStatus: "ACTIVE", Mode: "global", PreferredLanguage: "en", } @@ -877,6 +892,11 @@ func AccountFullToXML(ds *datastore.DataStore, account string) ([]byte, error) { fillDefaultProviderSettings(account, &resp) fillAccountInfo(ds, account, &resp) + entries, err := os.ReadDir(devicesDir) + if err != nil && !os.IsNotExist(err) { + return nil, err + } + devices, lastDeviceID := getAccountDevices(ds, account, entries) resp.Devices = devices resp.Sources = getAccountSources(ds, account, lastDeviceID) @@ -991,6 +1011,22 @@ func UpdatePreset(ds *datastore.DataStore, account, device string, presetNumber // Return XML for the single preset PrepareConfiguredSource(matchingSrc) + syncMatchingSource(matchingSrc, recentInput{ + Source: struct { + ID string `xml:"id,attr"` + Type string `xml:"type,attr"` + SourceName string `xml:"sourcename"` + SourceProviderID string `xml:"sourceproviderid"` + CreatedOn string `xml:"createdOn"` + UpdatedOn string `xml:"updatedOn"` + Credential struct { + Type string `xml:"type,attr"` + Value string `xml:",chardata"` + } `xml:"credential"` + }{ + SourceName: newPresetElem.Name, + }, + }) presetObj.SourceConfig = matchingSrc presetObj.Username = newPresetElem.Name @@ -1064,10 +1100,9 @@ func syncMatchingSource(matchingSrc *models.ConfiguredSource, input recentInput) matchingSrc.ID = input.SourceID } - // Ensure DisplayName and SourceName are consistent if matchingSrc.SourceName == "" && matchingSrc.DisplayName != "" { // Parity: for some services like TuneIn, sourcename should be empty - if matchingSrc.DisplayName != "TuneIn" && matchingSrc.DisplayName != "Other" { + if !strings.EqualFold(matchingSrc.DisplayName, "TUNEIN") && matchingSrc.DisplayName != "Other" { matchingSrc.SourceName = matchingSrc.DisplayName } } @@ -1076,12 +1111,11 @@ func syncMatchingSource(matchingSrc *models.ConfiguredSource, input recentInput) matchingSrc.DisplayName = matchingSrc.SourceName } - if matchingSrc.SourceName == "" && matchingSrc.DisplayName != "" { - matchingSrc.SourceName = matchingSrc.DisplayName - } - if matchingSrc.Username == "" && matchingSrc.DisplayName != "" { - matchingSrc.Username = matchingSrc.DisplayName + // Parity: for some services like TuneIn, username should be empty + if !strings.EqualFold(matchingSrc.DisplayName, "TUNEIN") && matchingSrc.DisplayName != "Other" { + matchingSrc.Username = matchingSrc.DisplayName + } } } diff --git a/tests/integration/http-client/get_account_sources.http b/tests/integration/http-client/get_account_sources.http new file mode 100644 index 0000000..eea6b5c --- /dev/null +++ b/tests/integration/http-client/get_account_sources.http @@ -0,0 +1,41 @@ +### GET /streaming/account/{accountId}/sources +GET {{host}}/streaming/account/3230304/sources +Accept: application/vnd.bose.streaming-v1.1+xml +Authorization: Bearer dummy-token + +> {% + client.test("Response is 200 OK", function() { + client.assert(response.status === 200, "Response status is not 200"); + }); + + client.test("Content-Type is correct", function() { + client.assert(response.contentType.mimeType === "application/vnd.bose.streaming-v1.1+xml", "Wrong content type"); + }); + + client.test("Response is XML and contains sources", function() { + const doc = response.body; + const sources = doc.documentElement; + client.assert(sources.nodeName === "sources", "Root element is not 'sources'"); + + const sourceList = sources.getElementsByTagName("source"); + client.assert(sourceList.length === 4, "Expected 4 source elements, found " + sourceList.length); + + const expectedIds = ["10001", "10002", "10003", "10004"]; + for (let i = 0; i < sourceList.length; i++) { + const source = sourceList.item(i); + client.assert(source.getAttribute("id") === expectedIds[i], "Wrong source ID at index " + i); + client.assert(source.getAttribute("type") === "Audio", "Wrong source type at index " + i); + + const credentials = source.getElementsByTagName("credential"); + client.assert(credentials.length > 0, "Missing credential for source " + expectedIds[i]); + const credential = credentials.item(0); + client.assert(credential.getAttribute("type") === "token", "Wrong credential type for source " + expectedIds[i]); + + const expectedChildren = ["createdOn", "updatedOn", "name", "sourceproviderid", "sourcename", "sourceSettings", "username"]; + expectedChildren.forEach(childName => { + const children = source.getElementsByTagName(childName); + client.assert(children.length > 0, "Missing " + childName + " for source " + expectedIds[i]); + }); + } + }); +%}