mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 08:36:13 +00:00
Add /streaming/account/{account}/sources (#147)
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_sources.http \
|
||||
/workdir/get_full_account.http \
|
||||
/workdir/get_group.http \
|
||||
/workdir/unregister_device.http \
|
||||
|
||||
@@ -741,6 +741,7 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
r.Route("/account/{account}", func(r chi.Router) {
|
||||
r.Get("/emailaddress", server.HandleMargeGetEmailAddress)
|
||||
r.Get("/full", server.HandleMargeAccountFull)
|
||||
r.Get("/sources", server.HandleMargeAccountSources)
|
||||
r.Get("/provider_settings", server.HandleMargeProviderSettings)
|
||||
|
||||
r.Route("/device", func(r chi.Router) {
|
||||
|
||||
@@ -64,6 +64,7 @@ GET /streaming/account/{account}/device/{device}/recents handlers.(
|
||||
GET /streaming/account/{account}/emailaddress handlers.(*Server).HandleMargeGetEmailAddress-fm
|
||||
GET /streaming/account/{account}/full handlers.(*Server).HandleMargeAccountFull-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
|
||||
GET /streaming/device_setting/account/{account}/device/{device}/device_settings handlers.(*Server).HandleMargeGetDeviceSettings-fm
|
||||
GET /streaming/software/update/account/{account} handlers.(*Server).HandleMargeSoftwareUpdate-fm
|
||||
|
||||
+27
-4
@@ -6,6 +6,7 @@ package models
|
||||
import (
|
||||
"encoding/xml"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -494,9 +495,25 @@ func (s ConfiguredSource) MarshalXML(e *xml.Encoder, start xml.StartElement) err
|
||||
a.DisplayName = ""
|
||||
}
|
||||
|
||||
a.Name = s.getFirstNonEmpty(s.Name, s.SourceName, s.Username, s.DisplayName)
|
||||
a.SourceName = s.getFirstNonEmpty(s.SourceName, s.Name, s.Username, s.DisplayName)
|
||||
a.Username = s.getFirstNonEmpty(s.Username, s.Name, s.SourceName, s.DisplayName)
|
||||
a.Name = s.Name
|
||||
a.SourceName = s.SourceName
|
||||
a.Username = s.Username
|
||||
|
||||
// Parity: for TuneIn and some others, sourcename, name and username should NOT automatically fall back to displayName
|
||||
// if they are intended to be empty. However, if they are ALL empty, we need some value.
|
||||
isTuneIn := strings.EqualFold(s.DisplayName, "TUNEIN") || strings.EqualFold(s.SourceKeyType, "TUNEIN") || strings.EqualFold(s.ID, "TUNEIN")
|
||||
|
||||
if a.Name == "" {
|
||||
a.Name = s.getFirstNonEmpty(s.Name, s.SourceName, s.Username, s.DisplayName)
|
||||
}
|
||||
|
||||
if a.SourceName == "" && !isTuneIn {
|
||||
a.SourceName = s.getFirstNonEmpty(s.SourceName, s.Name, s.Username, s.DisplayName)
|
||||
}
|
||||
|
||||
if a.Username == "" && !isTuneIn {
|
||||
a.Username = s.getFirstNonEmpty(s.Username, s.Name, s.SourceName, s.DisplayName)
|
||||
}
|
||||
|
||||
if s.Secret != "" || s.SecretType != "" {
|
||||
a.Credential = &sourceCredential{
|
||||
@@ -678,7 +695,7 @@ type EmailAddressResponse struct {
|
||||
type FullResponseSource struct {
|
||||
ID string `json:"id" xml:"id,attr"`
|
||||
Type string `json:"type" xml:"type,attr"`
|
||||
DisplayName string `json:"display_name,omitempty" xml:"displayName,attr,omitempty"`
|
||||
DisplayName string `json:"display_name" xml:"displayName,attr"`
|
||||
CreatedOn string `json:"created_on" xml:"createdOn"`
|
||||
Credential struct {
|
||||
Type string `json:"type" xml:"type,attr"`
|
||||
@@ -733,6 +750,12 @@ type AccountFullResponse struct {
|
||||
Sources []FullResponseSource `xml:"sources>source"`
|
||||
}
|
||||
|
||||
// AccountSourcesResponse represents the response from /streaming/account/{accountId}/sources.
|
||||
type AccountSourcesResponse struct {
|
||||
XMLName xml.Name `xml:"sources"`
|
||||
Sources []FullResponseSource `xml:"source"`
|
||||
}
|
||||
|
||||
// AccountDevice represents a device in the account response.
|
||||
type AccountDevice struct {
|
||||
DeviceID string `json:"device_id" xml:"deviceid,attr"`
|
||||
|
||||
@@ -1219,27 +1219,48 @@ func (ds *DataStore) getDefaultSources() []models.ConfiguredSource {
|
||||
DisplayName: "AUX IN",
|
||||
SourceKeyType: "AUX",
|
||||
SourceKeyAccount: "AUX",
|
||||
Type: "Audio",
|
||||
Status: "READY",
|
||||
CreatedOn: "2015-03-11T19:12:38.000+00:00",
|
||||
UpdatedOn: "2015-03-11T19:12:38.000+00:00",
|
||||
},
|
||||
{
|
||||
ID: "10002",
|
||||
SourceKeyType: "INTERNET_RADIO",
|
||||
SecretType: "token",
|
||||
Status: "READY",
|
||||
ID: "10002",
|
||||
DisplayName: "",
|
||||
SourceKeyType: "INTERNET_RADIO",
|
||||
SourceKeyAccount: "",
|
||||
SourceProviderID: "2",
|
||||
Type: "Audio",
|
||||
SecretType: "token",
|
||||
Status: "READY",
|
||||
CreatedOn: "2015-03-11T19:12:38.000+00:00",
|
||||
UpdatedOn: "2015-03-11T19:12:38.000+00:00",
|
||||
},
|
||||
{
|
||||
ID: "10003",
|
||||
SourceKeyType: "LOCAL_INTERNET_RADIO",
|
||||
Secret: GenerateSerialSecret("local-internet-radio"),
|
||||
SecretType: "token",
|
||||
Status: "READY",
|
||||
ID: "10003",
|
||||
DisplayName: "",
|
||||
SourceKeyType: "LOCAL_INTERNET_RADIO",
|
||||
SourceKeyAccount: "",
|
||||
SourceProviderID: "11",
|
||||
Type: "Audio",
|
||||
Secret: GenerateSerialSecret("local-internet-radio"),
|
||||
SecretType: "token",
|
||||
Status: "READY",
|
||||
CreatedOn: "2019-01-24T08:18:37.000+00:00",
|
||||
UpdatedOn: "2019-02-03T18:35:45.000+00:00",
|
||||
},
|
||||
{
|
||||
ID: "10004",
|
||||
SourceKeyType: "TUNEIN",
|
||||
Secret: GenerateSerialSecret("tunein"),
|
||||
SecretType: "token",
|
||||
Status: "READY",
|
||||
ID: "10004",
|
||||
DisplayName: "",
|
||||
SourceKeyType: "TUNEIN",
|
||||
SourceKeyAccount: "",
|
||||
SourceProviderID: "25",
|
||||
Type: "Audio",
|
||||
Secret: GenerateSerialSecret("tunein"),
|
||||
SecretType: "token",
|
||||
Status: "READY",
|
||||
CreatedOn: "2017-07-20T16:43:48.000+00:00",
|
||||
UpdatedOn: "2017-07-20T16:43:48.000+00:00",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,9 @@ func (s *Server) HandleMargeCreateAccount(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
|
||||
var req models.MargeAccountCreateRequest
|
||||
if err := xml.Unmarshal(body, &req); err != nil {
|
||||
|
||||
err = xml.Unmarshal(body, &req)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid XML body: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
@@ -56,21 +58,22 @@ func (s *Server) HandleMargeCreateAccount(w http.ResponseWriter, r *http.Request
|
||||
info.PreferredLanguage = "en"
|
||||
}
|
||||
|
||||
if err := s.ds.SaveAccountInfo(id, info); err != nil {
|
||||
err = s.ds.SaveAccountInfo(id, info)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to save account", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Stockholm expects the account XML in response
|
||||
resp := models.AccountFullResponse{
|
||||
ID: id,
|
||||
AccountStatus: "ACTIVE",
|
||||
PreferredLanguage: info.PreferredLanguage,
|
||||
data, err := marge.AccountFullToXML(s.ds, id)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to generate account XML", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = xml.NewEncoder(w).Encode(resp)
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
// HandleMargeLogin handles account login from Stockholm.
|
||||
@@ -111,16 +114,16 @@ func (s *Server) HandleMargeLogin(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
resp := models.AccountFullResponse{
|
||||
ID: accountID,
|
||||
AccountStatus: "ACTIVE",
|
||||
PreferredLanguage: "en",
|
||||
data, err := marge.AccountFullToXML(s.ds, accountID)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to generate account XML", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Bose returns a token in the Credentials header
|
||||
w.Header().Set("Credentials", "mock-token-"+accountID)
|
||||
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
|
||||
_ = xml.NewEncoder(w).Encode(resp)
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
// HandleMargeSourceProviders returns the Marge source providers.
|
||||
@@ -165,6 +168,29 @@ func (s *Server) HandleMargeAccountFull(w http.ResponseWriter, r *http.Request)
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
// HandleMargeAccountSources returns the Marge account sources.
|
||||
func (s *Server) HandleMargeAccountSources(w http.ResponseWriter, r *http.Request) {
|
||||
account := chi.URLParam(r, "account")
|
||||
|
||||
device := r.URL.Query().Get("device")
|
||||
|
||||
etag := strconv.FormatInt(s.ds.GetETagForAccount(account, device), 10)
|
||||
if r.Header.Get("If-None-Match") == etag {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := marge.AccountSourcesToXML(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.Header()["ETag"] = []string{etag}
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
// HandleMargePowerOn handles the Marge power on request.
|
||||
func (s *Server) HandleMargePowerOn(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
|
||||
@@ -64,6 +64,15 @@ func TestMargeCreateAccount(t *testing.T) {
|
||||
t.Errorf("Expected 7-digit ID, got %v", resp.ID)
|
||||
}
|
||||
|
||||
// Verify it has default sources
|
||||
if len(resp.Sources) != 4 {
|
||||
t.Errorf("Expected 4 default sources, got %d", len(resp.Sources))
|
||||
} else {
|
||||
if resp.Sources[0].ID != "10001" {
|
||||
t.Errorf("Expected first source ID 10001, got %s", resp.Sources[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify it was saved in datastore
|
||||
info, err := ds.GetAccountInfo(resp.ID)
|
||||
if err != nil {
|
||||
@@ -269,6 +278,123 @@ func TestMargeAccountFull(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMargeAccountSources(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"
|
||||
deviceID := "DEV1"
|
||||
|
||||
deviceDir := filepath.Join(tempDir, "accounts", account, "devices", deviceID)
|
||||
_ = os.MkdirAll(deviceDir, 0755)
|
||||
|
||||
// Mock Sources.xml
|
||||
sourcesXML := `
|
||||
<sources>
|
||||
<source id="SRC1" type="Audio" createdOn="2024-01-01T00:00:00Z" updatedOn="2024-01-01T00:00:00Z" displayName="Source1" secret="TOKEN1" secretType="token" sourceProviderId="2" sourceName="SourceName1">
|
||||
<sourceKey type="NOT_TUNEIN" account="User1"/>
|
||||
</source>
|
||||
</sources>`
|
||||
_ = os.WriteFile(filepath.Join(deviceDir, "Sources.xml"), []byte(sourcesXML), 0644)
|
||||
|
||||
r, _ := setupRouter("http://localhost:8001", ds)
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
res, err := http.Get(ts.URL + "/streaming/account/" + account + "/sources")
|
||||
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, "SRC1") {
|
||||
t.Errorf("Response missing expected source ID: %s", bodyStr)
|
||||
}
|
||||
|
||||
// Verify current XML structure produced by marge.go
|
||||
expectedSnippets := []string{
|
||||
"<sources>",
|
||||
"<source id=\"SRC1\" type=\"Audio\"",
|
||||
"<createdOn>2024-01-01T00:00:00Z</createdOn>",
|
||||
"<updatedOn>2024-01-01T00:00:00Z</updatedOn>",
|
||||
"<credential type=\"token\">TOKEN1</credential>",
|
||||
"<name>User1</name>",
|
||||
"<sourcename></sourcename>",
|
||||
"<sourceSettings/>",
|
||||
"<username>User1</username>",
|
||||
}
|
||||
|
||||
for _, snippet := range expectedSnippets {
|
||||
if !strings.Contains(bodyStr, snippet) {
|
||||
t.Errorf("Response missing expected snippet [%s]: %s", snippet, bodyStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMargeAccountSourcesNoDevices(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"
|
||||
|
||||
r, _ := setupRouter("http://localhost:8001", ds)
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
res, err := http.Get(ts.URL + "/streaming/account/" + account + "/sources")
|
||||
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)
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
bodyStr := string(body)
|
||||
|
||||
// Verify that we get the default sources with correct IDs and empty display names
|
||||
expectedSnippets := []string{
|
||||
"<sources>",
|
||||
"<source id=\"10004\" type=\"Audio\"",
|
||||
"<source id=\"10003\" type=\"Audio\"",
|
||||
"<source id=\"10002\" type=\"Audio\"",
|
||||
"<source id=\"10001\" type=\"Audio\" displayName=\"AUX IN\">",
|
||||
"displayName=\"\"", // for the other sources
|
||||
}
|
||||
|
||||
for _, snippet := range expectedSnippets {
|
||||
if !strings.Contains(bodyStr, snippet) {
|
||||
t.Errorf("Response missing expected snippet [%s]: %s", snippet, bodyStr)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify that 3 sources have empty display names
|
||||
if strings.Count(bodyStr, "displayName=\"\"") != 3 {
|
||||
t.Errorf("Expected 3 sources with empty displayName, got %d: %s", strings.Count(bodyStr, "displayName=\"\""), bodyStr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMargePresets(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "st-test-*")
|
||||
if err != nil {
|
||||
@@ -707,27 +833,11 @@ func TestMargeNativeStreamingRoutes(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("PUT /streaming/account/{account}/device/{device}/preset/{presetNumber} - missing Sources.xml", func(t *testing.T) {
|
||||
// Delete Sources.xml to trigger the error
|
||||
sourcesPath := filepath.Join(deviceDir, "Sources.xml")
|
||||
if err := os.Remove(sourcesPath); err != nil {
|
||||
t.Fatalf("Failed to remove Sources.xml: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
// Restore Sources.xml for other tests
|
||||
_ = os.WriteFile(sourcesPath, []byte(`
|
||||
<sources>
|
||||
<source id="SRC1" displayName="TUNEIN" secret="" secretType="Audio">
|
||||
<sourceKey type="TUNEIN" account=""/>
|
||||
</source>
|
||||
</sources>
|
||||
`), 0644)
|
||||
}()
|
||||
|
||||
t.Run("PUT /streaming/account/{account}/device/{device}/preset/{presetNumber} - valid Sources.xml", func(t *testing.T) {
|
||||
payload := `
|
||||
<preset>
|
||||
<name>PUT Native Preset Singular</name>
|
||||
<sourceid>TUNEIN</sourceid>
|
||||
<sourceid>SRC1</sourceid>
|
||||
<location>/station/s888</location>
|
||||
<contentItemType>station</contentItemType>
|
||||
</preset>`
|
||||
|
||||
@@ -61,6 +61,7 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server)
|
||||
r.Post("/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeUpdateDeviceSettings)
|
||||
r.Get("/account/{account}/emailaddress", server.HandleMargeGetEmailAddress)
|
||||
r.Get("/account/{account}/full", server.HandleMargeAccountFull)
|
||||
r.Get("/account/{account}/sources", server.HandleMargeAccountSources)
|
||||
r.Get("/software/update/account/{account}", server.HandleMargeSoftwareUpdate)
|
||||
r.Post("/account", server.HandleMargeCreateAccount)
|
||||
r.Post("/account/login", server.HandleMargeLogin)
|
||||
|
||||
+62
-28
@@ -826,11 +826,17 @@ func getAccountDevices(ds *datastore.DataStore, account string, entries []os.Dir
|
||||
}
|
||||
|
||||
func getAccountSources(ds *datastore.DataStore, account, lastDeviceID string) []models.FullResponseSource {
|
||||
if lastDeviceID == "" {
|
||||
return nil
|
||||
var (
|
||||
sources []models.ConfiguredSource
|
||||
err error
|
||||
)
|
||||
|
||||
if lastDeviceID != "" {
|
||||
sources, err = ds.GetConfiguredSources(account, lastDeviceID)
|
||||
} else {
|
||||
sources = ds.GetDefaultSources()
|
||||
}
|
||||
|
||||
sources, err := ds.GetConfiguredSources(account, lastDeviceID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
@@ -846,30 +852,39 @@ func getAccountSources(ds *datastore.DataStore, account, lastDeviceID string) []
|
||||
return fullSources
|
||||
}
|
||||
|
||||
// AccountSourcesToXML generates the account sources XML.
|
||||
func AccountSourcesToXML(ds *datastore.DataStore, account string) ([]byte, error) {
|
||||
devicesDir := ds.AccountDevicesDir(account)
|
||||
|
||||
entries, err := os.ReadDir(devicesDir)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, lastDeviceID := getAccountDevices(ds, account, entries)
|
||||
resp := models.AccountSourcesResponse{
|
||||
Sources: getAccountSources(ds, account, lastDeviceID),
|
||||
}
|
||||
|
||||
data, err := xml.Marshal(resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parity: use self-closing tags and handle empty sourceproviderid
|
||||
data = bytes.ReplaceAll(data, []byte("<sourceSettings></sourceSettings>"), []byte("<sourceSettings/>"))
|
||||
data = bytes.ReplaceAll(data, []byte("<sourceproviderid></sourceproviderid>"), []byte(""))
|
||||
|
||||
return append([]byte(constants.XMLHeader), data...), nil
|
||||
}
|
||||
|
||||
// AccountFullToXML generates a complete account XML with devices, presets, and recents.
|
||||
func AccountFullToXML(ds *datastore.DataStore, account string) ([]byte, error) {
|
||||
devicesDir := ds.AccountDevicesDir(account)
|
||||
|
||||
entries, err := os.ReadDir(devicesDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
resp := models.AccountFullResponse{
|
||||
ID: account,
|
||||
AccountStatus: "OK",
|
||||
Mode: "global",
|
||||
PreferredLanguage: "en",
|
||||
}
|
||||
data, _ := xml.Marshal(resp)
|
||||
|
||||
return append([]byte(constants.XMLHeader), data...), nil
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp := models.AccountFullResponse{
|
||||
ID: account,
|
||||
AccountStatus: "OK",
|
||||
AccountStatus: "ACTIVE",
|
||||
Mode: "global",
|
||||
PreferredLanguage: "en",
|
||||
}
|
||||
@@ -877,6 +892,11 @@ func AccountFullToXML(ds *datastore.DataStore, account string) ([]byte, error) {
|
||||
fillDefaultProviderSettings(account, &resp)
|
||||
fillAccountInfo(ds, account, &resp)
|
||||
|
||||
entries, err := os.ReadDir(devicesDir)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
devices, lastDeviceID := getAccountDevices(ds, account, entries)
|
||||
resp.Devices = devices
|
||||
resp.Sources = getAccountSources(ds, account, lastDeviceID)
|
||||
@@ -991,6 +1011,22 @@ func UpdatePreset(ds *datastore.DataStore, account, device string, presetNumber
|
||||
|
||||
// Return XML for the single preset
|
||||
PrepareConfiguredSource(matchingSrc)
|
||||
syncMatchingSource(matchingSrc, recentInput{
|
||||
Source: struct {
|
||||
ID string `xml:"id,attr"`
|
||||
Type string `xml:"type,attr"`
|
||||
SourceName string `xml:"sourcename"`
|
||||
SourceProviderID string `xml:"sourceproviderid"`
|
||||
CreatedOn string `xml:"createdOn"`
|
||||
UpdatedOn string `xml:"updatedOn"`
|
||||
Credential struct {
|
||||
Type string `xml:"type,attr"`
|
||||
Value string `xml:",chardata"`
|
||||
} `xml:"credential"`
|
||||
}{
|
||||
SourceName: newPresetElem.Name,
|
||||
},
|
||||
})
|
||||
presetObj.SourceConfig = matchingSrc
|
||||
presetObj.Username = newPresetElem.Name
|
||||
|
||||
@@ -1064,10 +1100,9 @@ func syncMatchingSource(matchingSrc *models.ConfiguredSource, input recentInput)
|
||||
matchingSrc.ID = input.SourceID
|
||||
}
|
||||
|
||||
// Ensure DisplayName and SourceName are consistent
|
||||
if matchingSrc.SourceName == "" && matchingSrc.DisplayName != "" {
|
||||
// Parity: for some services like TuneIn, sourcename should be empty
|
||||
if matchingSrc.DisplayName != "TuneIn" && matchingSrc.DisplayName != "Other" {
|
||||
if !strings.EqualFold(matchingSrc.DisplayName, "TUNEIN") && matchingSrc.DisplayName != "Other" {
|
||||
matchingSrc.SourceName = matchingSrc.DisplayName
|
||||
}
|
||||
}
|
||||
@@ -1076,12 +1111,11 @@ func syncMatchingSource(matchingSrc *models.ConfiguredSource, input recentInput)
|
||||
matchingSrc.DisplayName = matchingSrc.SourceName
|
||||
}
|
||||
|
||||
if matchingSrc.SourceName == "" && matchingSrc.DisplayName != "" {
|
||||
matchingSrc.SourceName = matchingSrc.DisplayName
|
||||
}
|
||||
|
||||
if matchingSrc.Username == "" && matchingSrc.DisplayName != "" {
|
||||
matchingSrc.Username = matchingSrc.DisplayName
|
||||
// Parity: for some services like TuneIn, username should be empty
|
||||
if !strings.EqualFold(matchingSrc.DisplayName, "TUNEIN") && matchingSrc.DisplayName != "Other" {
|
||||
matchingSrc.Username = matchingSrc.DisplayName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
### GET /streaming/account/{accountId}/sources
|
||||
GET {{host}}/streaming/account/3230304/sources
|
||||
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 sources", function() {
|
||||
const doc = response.body;
|
||||
const sources = doc.documentElement;
|
||||
client.assert(sources.nodeName === "sources", "Root element is not 'sources'");
|
||||
|
||||
const sourceList = sources.getElementsByTagName("source");
|
||||
client.assert(sourceList.length === 4, "Expected 4 source elements, found " + sourceList.length);
|
||||
|
||||
const expectedIds = ["10001", "10002", "10003", "10004"];
|
||||
for (let i = 0; i < sourceList.length; i++) {
|
||||
const source = sourceList.item(i);
|
||||
client.assert(source.getAttribute("id") === expectedIds[i], "Wrong source ID at index " + i);
|
||||
client.assert(source.getAttribute("type") === "Audio", "Wrong source type at index " + i);
|
||||
|
||||
const credentials = source.getElementsByTagName("credential");
|
||||
client.assert(credentials.length > 0, "Missing credential for source " + expectedIds[i]);
|
||||
const credential = credentials.item(0);
|
||||
client.assert(credential.getAttribute("type") === "token", "Wrong credential type for source " + expectedIds[i]);
|
||||
|
||||
const expectedChildren = ["createdOn", "updatedOn", "name", "sourceproviderid", "sourcename", "sourceSettings", "username"];
|
||||
expectedChildren.forEach(childName => {
|
||||
const children = source.getElementsByTagName(childName);
|
||||
client.assert(children.length > 0, "Missing " + childName + " for source " + expectedIds[i]);
|
||||
});
|
||||
}
|
||||
});
|
||||
%}
|
||||
Reference in New Issue
Block a user