mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-24 14:47:23 +00:00
Add .../api_versions.xml and .../musicprovider/{providerID}/is_eligible (#150)
This commit is contained in:
@@ -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 \
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 + "'");
|
||||
});
|
||||
%}
|
||||
Reference in New Issue
Block a user