diff --git a/Makefile b/Makefile index 2dc9b18..85440a4 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_presets.http \ /workdir/get_account_devices.http \ /workdir/get_account_sources.http \ /workdir/get_full_account.http \ diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go index 0b6e972..d24b3d2 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -743,6 +743,8 @@ func setupRouter(server *handlers.Server) *chi.Mux { r.Get("/full", server.HandleMargeAccountFull) r.Get("/sources", server.HandleMargeAccountSources) r.Get("/devices", server.HandleMargeAccountDevices) + r.Get("/presets", server.HandleMargeAccountPresets) + r.Get("/presets/all", server.HandleMargeAccountPresets) 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 346d26b..a72c8d4 100644 --- a/cmd/soundtouch-service/testdata/router_routes.txt +++ b/cmd/soundtouch-service/testdata/router_routes.txt @@ -66,6 +66,8 @@ GET /streaming/account/{account}/device/{device}/recents handlers.( GET /streaming/account/{account}/devices handlers.(*Server).HandleMargeAccountDevices-fm GET /streaming/account/{account}/emailaddress handlers.(*Server).HandleMargeGetEmailAddress-fm GET /streaming/account/{account}/full handlers.(*Server).HandleMargeAccountFull-fm +GET /streaming/account/{account}/presets handlers.(*Server).HandleMargeAccountPresets-fm +GET /streaming/account/{account}/presets/all handlers.(*Server).HandleMargeAccountPresets-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 diff --git a/pkg/service/handlers/handlers_marge.go b/pkg/service/handlers/handlers_marge.go index 5603bcd..27e4b79 100644 --- a/pkg/service/handlers/handlers_marge.go +++ b/pkg/service/handlers/handlers_marge.go @@ -398,6 +398,21 @@ func (s *Server) HandleMargeSoftwareUpdate(w http.ResponseWriter, r *http.Reques } } +// HandleMargeAccountPresets handles the GET /streaming/account/{account}/presets/all request. +func (s *Server) HandleMargeAccountPresets(w http.ResponseWriter, r *http.Request) { + account := chi.URLParam(r, "account") + + data, err := marge.AccountPresetsToXML(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.WriteHeader(http.StatusOK) + _, _ = w.Write(data) +} + // HandleMargePresets returns the Marge presets for a device. func (s *Server) HandleMargePresets(w http.ResponseWriter, r *http.Request) { account := chi.URLParam(r, "account") diff --git a/pkg/service/handlers/handlers_marge_test.go b/pkg/service/handlers/handlers_marge_test.go index 7f7ba6f..f52f328 100644 --- a/pkg/service/handlers/handlers_marge_test.go +++ b/pkg/service/handlers/handlers_marge_test.go @@ -346,6 +346,78 @@ func TestMargeAccountSources(t *testing.T) { } } +func TestMargeAccountPresets(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" + device1 := "DEVICE1" + device2 := "DEVICE2" + + // Setup presets for device1 + presets1 := []models.ServicePreset{ + { + ID: "1", + ServiceContentItem: models.ServiceContentItem{ + Name: "Station 1", + }, + }, + } + if err := ds.SavePresets(account, device1, presets1); err != nil { + t.Fatal(err) + } + + // Setup presets for device2 + presets2 := []models.ServicePreset{ + { + ID: "2", + ServiceContentItem: models.ServiceContentItem{ + Name: "Station 2", + }, + }, + } + if err := ds.SavePresets(account, device2, presets2); err != nil { + t.Fatal(err) + } + + r, _ := setupRouter("http://localhost:8001", ds) + ts := httptest.NewServer(r) + defer ts.Close() + + // Test /streaming/account/{account}/presets/all + res, err := http.Get(ts.URL + "/streaming/account/" + account + "/presets/all") + 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, "") { + t.Error("Response body missing ") + } + if !strings.Contains(bodyStr, "buttonNumber=\"1\"") { + t.Error("Response body missing preset 1") + } + if !strings.Contains(bodyStr, "buttonNumber=\"2\"") { + t.Error("Response body missing preset 2") + } +} + func TestMargeAccountDevices(t *testing.T) { tempDir, err := os.MkdirTemp("", "st-test-*") if err != nil { diff --git a/pkg/service/handlers/main_test.go b/pkg/service/handlers/main_test.go index 75a0046..d467a67 100644 --- a/pkg/service/handlers/main_test.go +++ b/pkg/service/handlers/main_test.go @@ -63,6 +63,8 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server) r.Get("/account/{account}/full", server.HandleMargeAccountFull) r.Get("/account/{account}/sources", server.HandleMargeAccountSources) r.Get("/account/{account}/devices", server.HandleMargeAccountDevices) + r.Get("/account/{account}/presets", server.HandleMargeAccountPresets) + r.Get("/account/{account}/presets/all", server.HandleMargeAccountPresets) 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 c042005..092626d 100644 --- a/pkg/service/marge/marge.go +++ b/pkg/service/marge/marge.go @@ -8,6 +8,7 @@ import ( "fmt" "log" "os" + "sort" "strconv" "strings" "time" @@ -244,6 +245,127 @@ func prepareRecentItemParitySource(src *models.ConfiguredSource) *models.RecentI return sxml } +func mapPresetToParityXML(p models.ServicePreset, sources []models.ConfiguredSource) *presetParityXML { + // Find and prepare source + matchedSource := findMatchingSourceForPreset(sources, p) + if matchedSource != nil { + PrepareConfiguredSource(matchedSource) + } + + if p.ContentItemType == "" && p.Name == "" && p.Location == "" && (matchedSource == nil || matchedSource.ID == "") { + return nil + } + + p.ButtonNumber = p.ID + if p.CreatedOn == "" { + p.CreatedOn = constants.DateStr + } else if t, e := strconv.ParseInt(p.CreatedOn, 10, 64); e == nil { + p.CreatedOn = time.Unix(t, 0).UTC().Format("2006-01-02T15:04:05.000+00:00") + } + + if p.UpdatedOn == "" { + p.UpdatedOn = constants.DateStr + } else if t, e := strconv.ParseInt(p.UpdatedOn, 10, 64); e == nil { + p.UpdatedOn = time.Unix(t, 0).UTC().Format("2006-01-02T15:04:05.000+00:00") + } + + username := p.Username + if username == "" { + username = p.Name + } + + sourceID := p.SourceID + if sourceID == "" && matchedSource != nil { + sourceID = matchedSource.ID + } + + return &presetParityXML{ + ButtonNumber: p.ButtonNumber, + ContainerArt: p.ContainerArt, + ContentItemType: p.ContentItemType, + CreatedOn: p.CreatedOn, + Location: p.Location, + Name: p.Name, + Source: matchedSource, + SourceID: sourceID, + UpdatedOn: p.UpdatedOn, + Username: username, + } +} + +// AccountPresetsToXML aggregates presets from all account devices and converts to XML format. +func AccountPresetsToXML(ds *datastore.DataStore, account string) ([]byte, error) { + accountDir := ds.AccountDevicesDir(account) + + entries, err := os.ReadDir(accountDir) + if err != nil { + if os.IsNotExist(err) { + return []byte(constants.XMLHeader + "\n"), nil + } + + return nil, err + } + + type presetsParityWrapper struct { + XMLName xml.Name `xml:"presets"` + Presets []presetParityXML `xml:"preset"` + } + + pxml := presetsParityWrapper{ + Presets: make([]presetParityXML, 0), + } + + seenPresets := make(map[string]bool) + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + deviceID := entry.Name() + + presets, gerr := ds.GetPresets(account, deviceID) + if gerr != nil { + continue + } + + sources, serr := ds.GetConfiguredSources(account, deviceID) + if serr != nil { + continue + } + + for i := range presets { + p := presets[i] + + // Deduplicate by ID (which is the buttonNumber in our store) + if seenPresets[p.ID] { + continue + } + + if pXML := mapPresetToParityXML(p, sources); pXML != nil { + seenPresets[p.ID] = true + + pxml.Presets = append(pxml.Presets, *pXML) + } + } + } + + // Sort by buttonNumber (optional but nice) + sort.Slice(pxml.Presets, func(i, j int) bool { + ni, _ := strconv.Atoi(pxml.Presets[i].ButtonNumber) + nj, _ := strconv.Atoi(pxml.Presets[j].ButtonNumber) + + return ni < nj + }) + + data, err := xml.MarshalIndent(pxml, "", " ") + if err != nil { + return nil, err + } + + return append([]byte(constants.XMLHeader+"\n"), data...), nil +} + // PresetsToXML converts account presets to XML format for Marge responses. func PresetsToXML(ds *datastore.DataStore, account, deviceID string) ([]byte, error) { presets, err := ds.GetPresets(account, deviceID) @@ -266,53 +388,9 @@ func PresetsToXML(ds *datastore.DataStore, account, deviceID string) ([]byte, er } for i := range presets { - p := presets[i] - - // Find and prepare source - matchedSource := findMatchingSourceForPreset(sources, p) - if matchedSource != nil { - PrepareConfiguredSource(matchedSource) + if pXML := mapPresetToParityXML(presets[i], sources); pXML != nil { + pxml.Presets = append(pxml.Presets, *pXML) } - - if p.ContentItemType == "" && p.Name == "" && p.Location == "" && (matchedSource == nil || matchedSource.ID == "") { - continue - } - - p.ButtonNumber = p.ID - if p.CreatedOn == "" { - p.CreatedOn = constants.DateStr - } else if t, e := strconv.ParseInt(p.CreatedOn, 10, 64); e == nil { - p.CreatedOn = time.Unix(t, 0).UTC().Format("2006-01-02T15:04:05.000+00:00") - } - - if p.UpdatedOn == "" { - p.UpdatedOn = constants.DateStr - } else if t, e := strconv.ParseInt(p.UpdatedOn, 10, 64); e == nil { - p.UpdatedOn = time.Unix(t, 0).UTC().Format("2006-01-02T15:04:05.000+00:00") - } - - username := p.Username - if username == "" { - username = p.Name - } - - sourceID := p.SourceID - if sourceID == "" && matchedSource != nil { - sourceID = matchedSource.ID - } - - pxml.Presets = append(pxml.Presets, presetParityXML{ - ButtonNumber: p.ButtonNumber, - ContainerArt: p.ContainerArt, - ContentItemType: p.ContentItemType, - CreatedOn: p.CreatedOn, - Location: p.Location, - Name: p.Name, - Source: matchedSource, - SourceID: sourceID, - UpdatedOn: p.UpdatedOn, - Username: username, - }) } data, err := xml.MarshalIndent(pxml, "", " ") diff --git a/tests/integration/http-client/get_account_presets.http b/tests/integration/http-client/get_account_presets.http new file mode 100644 index 0000000..4e622c4 --- /dev/null +++ b/tests/integration/http-client/get_account_presets.http @@ -0,0 +1,47 @@ +### GET /streaming/account/{{accountId}}/presets/all +GET {{host}}/streaming/account/{{accountId}}/presets/all +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 presets", function() { + const doc = response.body; + const root = doc.documentElement; + client.assert(root.nodeName === "presets", "Root element is not 'presets'"); + + const presetList = doc.getElementsByTagName("preset"); + // Based on Makefile flow: set_preset_6, delete_preset_6, set_preset_5. + // There should be at exactly one preset (number 6) by the time this is run. + client.assert(presetList.length >= 1, "Expected at least 1 preset elements, found " + presetList.length); + + // Map presets by buttonNumber for easier validation + const presetsMap = {}; + for (let i = 0; i < presetList.length; i++) { + const p = presetList.item(i); + const bn = p.getAttribute("buttonNumber"); + presetsMap[bn] = p; + } + + client.assert(presetsMap["5"] !== undefined, "Missing preset with buttonNumber=\"5\""); + + // Validate structure of a preset (using #5 as example) + const preset5 = presetsMap["5"]; + client.assert(preset5.getElementsByTagName("name").length > 0, "Missing 'name' in preset 5"); + client.assert(preset5.getElementsByTagName("location").length > 0, "Missing 'location' in preset 5"); + client.assert(preset5.getElementsByTagName("contentItemType").length > 0, "Missing 'contentItemType' in preset 5"); + + const source = preset5.getElementsByTagName("source")[0]; + client.assert(source !== null, "Missing 'source' in preset 5"); + client.assert(source.getAttribute("id") !== null, "Missing 'id' attribute in source of preset 5"); + client.assert(source.getAttribute("type") !== null, "Missing 'type' attribute in source of preset 5"); + client.assert(source.getElementsByTagName("sourceproviderid").length > 0, "Missing 'sourceproviderid' in source of preset 5"); + }); +%}