mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 00:26:29 +00:00
Add /streaming/account/{account}/presets/all (#149)
This commit is contained in:
@@ -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 \
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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, "<presets>") {
|
||||
t.Error("Response body missing <presets>")
|
||||
}
|
||||
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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
+124
-46
@@ -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<presets/>"), 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, "", " ")
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
%}
|
||||
Reference in New Issue
Block a user