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 + "'");
+ });
+%}