Add /streaming/account/{account}/sources (#147)

This commit is contained in:
Tobias Gesellchen
2026-04-05 01:09:02 +02:00
committed by GitHub
parent 379ac758f6
commit bd0e3d64a3
10 changed files with 335 additions and 76 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_sources.http \
/workdir/get_full_account.http \
/workdir/get_group.http \
/workdir/unregister_device.http \
+1
View File
@@ -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) {
+1
View File
@@ -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
View File
@@ -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"`
+35 -14
View File
@@ -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",
},
}
+38 -12
View File
@@ -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)
+128 -18
View File
@@ -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>`
+1
View File
@@ -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
View File
@@ -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]);
});
}
});
%}