diff --git a/Makefile b/Makefile index 85440a4..1ca6272 100644 --- a/Makefile +++ b/Makefile @@ -143,6 +143,8 @@ test-http-client: /workdir/get_account_presets.http \ /workdir/get_account_devices.http \ /workdir/get_account_sources.http \ + /workdir/get_api_versions.http \ + /workdir/post_musicprovider_is_eligible.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 d24b3d2..7752a51 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -786,6 +786,14 @@ func setupRouter(server *handlers.Server) *chi.Mux { r.Post("/usage", server.HandleUsageStats) r.Post("/error", server.HandleErrorStats) }) + + r.Route("/music", func(r chi.Router) { + r.Route("/musicprovider/{providerID}", func(r chi.Router) { + r.Post("/is_eligible", server.HandleMusicProviderIsEligible) + }) + }) + + r.Get("/resources/api_versions.xml", server.HandleMargeAPIVersions) }) r.Route("/accounts", func(r chi.Router) { diff --git a/cmd/soundtouch-service/testdata/router_routes.txt b/cmd/soundtouch-service/testdata/router_routes.txt index a72c8d4..9396f8b 100644 --- a/cmd/soundtouch-service/testdata/router_routes.txt +++ b/cmd/soundtouch-service/testdata/router_routes.txt @@ -72,6 +72,7 @@ GET /streaming/account/{account}/provider_settings handlers.( 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/resources/api_versions.xml handlers.(*Server).HandleMargeAPIVersions-fm GET /streaming/software/update/account/{account} handlers.(*Server).HandleMargeSoftwareUpdate-fm GET /streaming/sourceproviders handlers.(*Server).HandleMargeSourceProviders-fm GET /updates/soundtouch handlers.(*Server).HandleMargeSoftwareUpdate-fm @@ -121,6 +122,7 @@ POST /streaming/account/{account}/device/{device}/presets/{presetNumber} han POST /streaming/account/{account}/device/{device}/recent handlers.(*Server).HandleMargeAddRecent-fm POST /streaming/account/{account}/source handlers.(*Server).HandleMargeAddSource-fm POST /streaming/device_setting/account/{account}/device/{device}/device_settings handlers.(*Server).HandleMargeUpdateDeviceSettings-fm +POST /streaming/music/musicprovider/{providerID}/is_eligible handlers.(*Server).HandleMusicProviderIsEligible-fm POST /streaming/stats/error handlers.(*Server).HandleErrorStats-fm POST /streaming/stats/usage handlers.(*Server).HandleUsageStats-fm POST /streaming/support/customersupport handlers.(*Server).HandleMargeCustomerSupport-fm diff --git a/pkg/models/models.go b/pkg/models/models.go index 903bd44..7cab1b8 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -837,3 +837,25 @@ type MargeAddSourceResponse struct { CreatedOn string `xml:"createdOn"` UpdatedOn string `xml:"updatedOn"` } + +// EligibilityResponse represents the XML response for music provider eligibility. +type EligibilityResponse struct { + XMLName xml.Name `xml:"eligibility"` + IsEligible bool `xml:"isEligible"` +} + +// MargeAPIVersionsResponse represents the XML response for Marge API versions. +type MargeAPIVersionsResponse struct { + XMLName xml.Name `xml:"marge"` + Version string `xml:"version,attr"` + Project string `xml:"project,attr"` + Apis []MargeAPI `xml:"apis>api"` + Dependencies string `xml:"dependencies"` +} + +// MargeAPI represents a single API entry in MargeAPIVersionsResponse. +type MargeAPI struct { + Type string `xml:"type,attr"` + XML string `xml:"xml"` + JSON string `xml:"json"` +} diff --git a/pkg/service/constants/constants.go b/pkg/service/constants/constants.go index 75bf7e8..3d64c63 100644 --- a/pkg/service/constants/constants.go +++ b/pkg/service/constants/constants.go @@ -55,6 +55,11 @@ var StaticProviders = []SourceProvider{ {ID: 39, Name: "RADIO_BROWSER", Label: "Radio Browser", CreatedOn: "2026-03-14T22:47:00.000+00:00", UpdatedOn: "2026-03-14T22:47:00.000+00:00"}, } +const ( + // QPlayProviderID is the provider identifier for QPlay. + QPlayProviderID = 26 +) + // GetSourceLabel returns a user-friendly label for a source type. func GetSourceLabel(sourceType string) string { for _, provider := range StaticProviders { diff --git a/pkg/service/handlers/handlers_marge.go b/pkg/service/handlers/handlers_marge.go index 27e4b79..a563af2 100644 --- a/pkg/service/handlers/handlers_marge.go +++ b/pkg/service/handlers/handlers_marge.go @@ -693,6 +693,38 @@ func (s *Server) HandleMargeDeviceGroupMember(w http.ResponseWriter, r *http.Req http.NotFound(w, r) } +// HandleMusicProviderIsEligible returns the music provider eligibility. +func (s *Server) HandleMusicProviderIsEligible(w http.ResponseWriter, _ *http.Request) { + // For now, we return false as seen in the interaction sample. + resp := models.EligibilityResponse{ + IsEligible: false, + } + + data, err := xml.Marshal(resp) + 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([]byte(constants.XMLHeader)) + _, _ = w.Write(data) +} + +// HandleMargeAPIVersions returns the XML response for Marge API versions. +func (s *Server) HandleMargeAPIVersions(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/xml") + + output, err := marge.APIVersionsToXML() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + _, _ = w.Write(output) +} + // HandleMargeCustomerSupport handles Marge customer support uploads. func (s *Server) HandleMargeCustomerSupport(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 f52f328..adf346e 100644 --- a/pkg/service/handlers/handlers_marge_test.go +++ b/pkg/service/handlers/handlers_marge_test.go @@ -1500,4 +1500,61 @@ func TestMargeAdvancedFeatures(t *testing.T) { t.Errorf("Expected name 'My top tracks playlist', got '%s'", recents[0].Name) } }) + + t.Run("MusicProviderIsEligible", func(t *testing.T) { + path := "/marge/streaming/music/musicprovider/26/is_eligible" + payload := `12345` + + res, err := http.Post(ts.URL+path, "application/vnd.bose.streaming-v1.1+xml", strings.NewReader(payload)) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + t.Errorf("Expected status OK, got %v", res.Status) + } + + if ct := res.Header.Get("Content-Type"); ct != "application/vnd.bose.streaming-v1.1+xml" { + t.Errorf("Expected Content-Type application/vnd.bose.streaming-v1.1+xml, got %v", ct) + } + + body, _ := io.ReadAll(res.Body) + expected := `false` + if string(body) != expected { + t.Errorf("Expected body %s, got %s", expected, string(body)) + } + }) + + t.Run("APIVersions", func(t *testing.T) { + path := "/marge/streaming/resources/api_versions.xml" + + res, err := http.Get(ts.URL + path) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + t.Errorf("Expected status OK, got %v", res.Status) + } + + if ct := res.Header.Get("Content-Type"); ct != "text/xml" { + t.Errorf("Expected Content-Type text/xml, got %v", ct) + } + + body, _ := io.ReadAll(res.Body) + if !strings.HasPrefix(string(body), "\n`) { + t.Error("Response body missing streaming API") + } + if !strings.Contains(string(body), ``) { + t.Error("Response body missing customer API") + } + if !strings.Contains(string(body), ``) { + t.Error("Response body missing support API") + } + }) } diff --git a/pkg/service/handlers/main_test.go b/pkg/service/handlers/main_test.go index d467a67..0740d8e 100644 --- a/pkg/service/handlers/main_test.go +++ b/pkg/service/handlers/main_test.go @@ -68,6 +68,8 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server) r.Get("/software/update/account/{account}", server.HandleMargeSoftwareUpdate) r.Post("/account", server.HandleMargeCreateAccount) r.Post("/account/login", server.HandleMargeLogin) + r.Post("/music/musicprovider/{providerID}/is_eligible", server.HandleMusicProviderIsEligible) + r.Get("/resources/api_versions.xml", server.HandleMargeAPIVersions) } accountsRoutes := func(r chi.Router) { diff --git a/pkg/service/marge/marge.go b/pkg/service/marge/marge.go index 092626d..1c034b6 100644 --- a/pkg/service/marge/marge.go +++ b/pkg/service/marge/marge.go @@ -572,6 +572,38 @@ func SoftwareUpdateToXML() string { ` } +// APIVersionsToXML returns the XML response for Marge API versions. +func APIVersionsToXML() ([]byte, error) { + resp := models.MargeAPIVersionsResponse{ + Version: "221", + Project: "origin/master", + Apis: []models.MargeAPI{ + { + Type: "streaming", + XML: "application/vnd.bose.streaming-v1.0+xml", + JSON: "application/vnd.bose.streaming-v1.0+json", + }, + { + Type: "customer", + XML: "application/vnd.bose.customer-v1.0+xml", + JSON: "application/vnd.bose.customer-v1.0+json", + }, + { + Type: "support", + XML: "application/vnd.bose.support-v1.0+xml", + JSON: "application/vnd.bose.support-v1.0+json", + }, + }, + } + + output, err := xml.MarshalIndent(resp, "", " ") + if err != nil { + return nil, err + } + + return append([]byte(constants.XMLHeader+"\n"), output...), nil +} + // CreateAccountDevice creates an AccountDevice model for the given account and device. func CreateAccountDevice(ds *datastore.DataStore, account, deviceID string) (models.AccountDevice, error) { info, err := ds.GetDeviceInfo(account, deviceID) diff --git a/tests/integration/http-client/get_api_versions.http b/tests/integration/http-client/get_api_versions.http new file mode 100644 index 0000000..7d74a86 --- /dev/null +++ b/tests/integration/http-client/get_api_versions.http @@ -0,0 +1,53 @@ +### GET /streaming/resources/api_versions.xml +GET {{host}}/streaming/resources/api_versions.xml + +> {% + client.test("Request executed successfully", function() { + client.assert(response.status === 200, "Response status is not 200"); + }); + + client.test("Response content type is text/xml", function() { + var type = response.contentType.mimeType; + client.assert(type === "text/xml", "Expected 'text/xml' but received '" + type + "'"); + }); + + client.test("Response body has correct structure", function() { + var doc = response.body; + client.assert(doc !== undefined, "Response body is undefined"); + + var marge = doc.getElementsByTagName("marge")[0]; + client.assert(marge !== null, "Root element not found"); + + var version = marge.getAttribute("version"); + client.assert(version === "221", "Expected version '221' but received '" + version + "'"); + + var project = marge.getAttribute("project"); + client.assert(project === "origin/master", "Expected project 'origin/master' but received '" + project + "'"); + + var apiList = doc.getElementsByTagName("api"); + client.assert(apiList.length === 3, "Expected 3 elements but found " + apiList.length); + + // Helper to find API by type attribute + function findApiByType(list, type) { + for (var i = 0; i < list.length; i++) { + if (list[i].getAttribute("type") === type) { + return list[i]; + } + } + return null; + } + + var streamingApi = findApiByType(apiList, "streaming"); + client.assert(streamingApi !== null, "API type 'streaming' not found"); + client.assert(streamingApi.getElementsByTagName("xml")[0].textContent === "application/vnd.bose.streaming-v1.0+xml", "Incorrect XML for streaming API"); + client.assert(streamingApi.getElementsByTagName("json")[0].textContent === "application/vnd.bose.streaming-v1.0+json", "Incorrect JSON for streaming API"); + + var customerApi = findApiByType(apiList, "customer"); + client.assert(customerApi !== null, "API type 'customer' not found"); + client.assert(customerApi.getElementsByTagName("xml")[0].textContent === "application/vnd.bose.customer-v1.0+xml", "Incorrect XML for customer API"); + + var supportApi = findApiByType(apiList, "support"); + client.assert(supportApi !== null, "API type 'support' not found"); + client.assert(supportApi.getElementsByTagName("xml")[0].textContent === "application/vnd.bose.support-v1.0+xml", "Incorrect XML for support API"); + }); +%} diff --git a/tests/integration/http-client/http-client.env.json b/tests/integration/http-client/http-client.env.json index 37edc6c..e8122e0 100644 --- a/tests/integration/http-client/http-client.env.json +++ b/tests/integration/http-client/http-client.env.json @@ -16,7 +16,8 @@ "spotifyToken": "example-spotify-token", "spotifyRefreshToken": "example-spotify-refresh-token", "spotifyDisplayName": "For Lovers, Not Killers", - "bmxApiKey": "bmx-api-key" + "bmxApiKey": "bmx-api-key", + "qplayProviderID": "26" }, "ci": { "host": "http://soundtouch-service:8000", @@ -35,6 +36,7 @@ "spotifyToken": "example-spotify-token", "spotifyRefreshToken": "example-spotify-refresh-token", "spotifyDisplayName": "For Lovers, Not Killers", - "bmxApiKey": "bmx-api-key" + "bmxApiKey": "bmx-api-key", + "qplayProviderID": "26" } } diff --git a/tests/integration/http-client/post_musicprovider_is_eligible.http b/tests/integration/http-client/post_musicprovider_is_eligible.http new file mode 100644 index 0000000..21e1ab0 --- /dev/null +++ b/tests/integration/http-client/post_musicprovider_is_eligible.http @@ -0,0 +1,31 @@ +### POST /streaming/music/musicprovider/{{qplayProviderID}}/is_eligible +POST {{host}}/streaming/music/musicprovider/{{qplayProviderID}}/is_eligible +Accept: application/vnd.bose.streaming-v1.1+xml +Content-Type: application/vnd.bose.streaming-v1.1+xml + +{{accountId}} + +> {% + client.test("Request executed successfully", function() { + client.assert(response.status === 200, "Response status is not 200"); + }); + + client.test("Response content type is correct", function() { + var type = response.contentType.mimeType; + client.assert(type === "application/vnd.bose.streaming-v1.1+xml", "Expected 'application/vnd.bose.streaming-v1.1+xml' but received '" + type + "'"); + }); + + client.test("Response body has correct structure", function() { + var doc = response.body; + client.assert(doc !== undefined, "Response body is undefined"); + + var eligibility = doc.getElementsByTagName("eligibility")[0]; + client.assert(eligibility !== null, "Root element not found"); + + var isEligibleElement = eligibility.getElementsByTagName("isEligible")[0]; + client.assert(isEligibleElement !== null, "Element not found"); + + var isEligible = isEligibleElement.textContent; + client.assert(isEligible === "false", "Expected isEligible to be 'false' but received '" + isEligible + "'"); + }); +%}