Add .../api_versions.xml and .../musicprovider/{providerID}/is_eligible (#150)

This commit is contained in:
Tobias Gesellchen
2026-04-05 23:25:30 +02:00
committed by GitHub
parent de96b1f119
commit 382567d67d
12 changed files with 250 additions and 2 deletions
+2
View File
@@ -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 \
+8
View File
@@ -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) {
+2
View File
@@ -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
+22
View File
@@ -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"`
}
+5
View File
@@ -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 {
+32
View File
@@ -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)
@@ -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 := `<?xml version = "1.0" encoding = "utf-8"?><account><accountId>12345</accountId></account>`
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 := `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><eligibility><isEligible>false</isEligible></eligibility>`
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), "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<marge ") {
t.Errorf("Response body has incorrect header or root element: %s", body)
}
if !strings.Contains(string(body), `<api type="streaming">`) {
t.Error("Response body missing streaming API")
}
if !strings.Contains(string(body), `<api type="customer">`) {
t.Error("Response body missing customer API")
}
if !strings.Contains(string(body), `<api type="support">`) {
t.Error("Response body missing support API")
}
})
}
+2
View File
@@ -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) {
+32
View File
@@ -572,6 +572,38 @@ func SoftwareUpdateToXML() string {
</software_update>`
}
// 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)
@@ -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 <marge> 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 <api> 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");
});
%}
@@ -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"
}
}
@@ -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
<account><accountId>{{accountId}}</accountId></account>
> {%
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 <eligibility> not found");
var isEligibleElement = eligibility.getElementsByTagName("isEligible")[0];
client.assert(isEligibleElement !== null, "Element <isEligible> not found");
var isEligible = isEligibleElement.textContent;
client.assert(isEligible === "false", "Expected isEligible to be 'false' but received '" + isEligible + "'");
});
%}