Add /streaming/account/{account}/presets/all (#149)

This commit is contained in:
Tobias Gesellchen
2026-04-05 23:10:53 +02:00
committed by GitHub
parent d22dc99c9e
commit de96b1f119
8 changed files with 265 additions and 46 deletions
+1
View File
@@ -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 \
+2
View File
@@ -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) {
+2
View File
@@ -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
+15
View File
@@ -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 {
+2
View File
@@ -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
View File
@@ -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");
});
%}