diff --git a/pkg/models/models.go b/pkg/models/models.go
index a6f47c8..df1c91a 100644
--- a/pkg/models/models.go
+++ b/pkg/models/models.go
@@ -142,34 +142,35 @@ type ServiceContentItem struct {
ContentItemType string `json:"content_item_type,omitempty" xml:"contentItemType,omitempty"`
Location string `json:"location,omitempty" xml:"location,attr,omitempty"`
SourceAccount string `json:"source_account,omitempty" xml:"sourceAccount,attr,omitempty"`
- SourceID string `json:"source_id,omitempty" xml:"sourceid,omitempty"`
+ SourceID string `json:"source_id,omitempty" xml:"sourceid"`
IsPresetable string `json:"is_presetable,omitempty" xml:"isPresetable,attr,omitempty"`
+ Username string `json:"username,omitempty" xml:"username,omitempty"`
+ ContainerArt string `json:"container_art,omitempty" xml:"containerArt,omitempty"`
}
// ServicePreset represents a user-defined preset for quick access to media content.
type ServicePreset struct {
ServiceContentItem
- ID string `json:"id,omitempty" xml:"id,attr,omitempty"`
- ContainerArt string `json:"container_art" xml:"containerArt"`
- CreatedOn string `json:"created_on" xml:"createdOn"`
- UpdatedOn string `json:"updated_on" xml:"updatedOn"`
- ButtonNumber string `json:"button_number,omitempty" xml:"buttonNumber,attr,omitempty"`
- Username string `json:"-" xml:"username,omitempty"`
- SourceConfig *ConfiguredSource `json:"-" xml:"source,omitempty"`
+ ID string `json:"id,omitempty" xml:"id,attr,omitempty"`
+ ContainerArt string `json:"container_art" xml:"containerArt"`
+ CreatedOn string `json:"created_on" xml:"createdOn"`
+ UpdatedOn string `json:"updated_on" xml:"updatedOn"`
+ ButtonNumber string `json:"button_number,omitempty" xml:"buttonNumber,attr,omitempty"`
+ Username string `json:"-" xml:"username,omitempty"`
}
// MarshalXML implements the xml.Marshaler interface for ServicePreset to match upstream parity.
func (p ServicePreset) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
type Alias struct {
- ButtonNumber string `xml:"buttonNumber,attr,omitempty"`
- ContainerArt string `xml:"containerArt"`
- ContentItemType string `xml:"contentItemType"`
- CreatedOn string `xml:"createdOn"`
- Location string `xml:"location"`
- Name string `xml:"name"`
- Source *ConfiguredSource `xml:"source,omitempty"`
- UpdatedOn string `xml:"updatedOn"`
- Username string `xml:"username"`
+ ButtonNumber string `xml:"buttonNumber,attr,omitempty"`
+ ContainerArt string `xml:"containerArt"`
+ ContentItemType string `xml:"contentItemType"`
+ CreatedOn string `xml:"createdOn"`
+ Location string `xml:"location"`
+ Name string `xml:"name"`
+ SourceID string `xml:"sourceid,omitempty"`
+ UpdatedOn string `xml:"updatedOn"`
+ Username string `xml:"username"`
}
createdOn := p.CreatedOn
@@ -193,7 +194,7 @@ func (p ServicePreset) MarshalXML(e *xml.Encoder, start xml.StartElement) error
CreatedOn: createdOn,
Location: p.Location,
Name: p.Name,
- Source: p.SourceConfig,
+ SourceID: p.SourceID,
UpdatedOn: updatedOn,
Username: p.Username,
}
@@ -213,13 +214,11 @@ func (p ServicePreset) MarshalXML(e *xml.Encoder, start xml.StartElement) error
type ServiceRecent struct {
XMLName xml.Name `json:"-" xml:"recent"`
ServiceContentItem
- DeviceID string `json:"device_id" xml:"deviceID,attr,omitempty"`
- UtcTime string `json:"utc_time" xml:"utcTime,attr,omitempty"`
- CreatedOn string `json:"created_on,omitempty" xml:"createdOn,omitempty"`
- UpdatedOn string `json:"updated_on,omitempty" xml:"updatedOn,omitempty"`
- ContainerArt string `json:"container_art,omitempty" xml:"containerArt,omitempty"`
- SourceConfig *ConfiguredSource `json:"-" xml:"source,omitempty"`
- LastPlayedAt string `json:"last_played_at,omitempty" xml:"lastplayedat,omitempty"`
+ DeviceID string `json:"device_id" xml:"deviceID,attr,omitempty"`
+ UtcTime string `json:"utc_time" xml:"utcTime,attr,omitempty"`
+ CreatedOn string `json:"created_on,omitempty" xml:"createdOn,omitempty"`
+ UpdatedOn string `json:"updated_on,omitempty" xml:"updatedOn,omitempty"`
+ LastPlayedAt string `json:"last_played_at,omitempty" xml:"lastplayedat,omitempty"`
}
// RecentItemParity represents recently played media content for web API responses (flat format).
@@ -282,13 +281,17 @@ func (r *ServiceRecent) UnmarshalXML(d *xml.Decoder, start xml.StartElement) err
ContentItem *NestedContentItem `xml:"contentItem,omitempty"`
// Flat format might use these tags
FlatLocation string `xml:"location"`
- FlatContentItemType string `xml:"contentItemType"`
- FlatName string `xml:"name"`
- FlatSourceID string `xml:"sourceid"`
- FlatSource string `xml:"source_key"`
FlatTypeTag string `xml:"type"`
+ AttrType string `xml:"type,attr"`
FlatSourceAccount string `xml:"sourceAccount"`
FlatIsPresetable string `xml:"isPresetable"`
+ FlatContentItemType string `xml:"contentItemType"`
+ AttrContentItemType string `xml:"contentItemType,attr"`
+ FlatName string `xml:"name"`
+ FlatSourceID string `xml:"sourceid"`
+ FlatSourceIDAttr string `xml:"sourceid,attr"`
+ FlatSource string `xml:"source_key"`
+ AttrSource string `xml:"source,attr"`
}
var a Alias
@@ -303,7 +306,6 @@ func (r *ServiceRecent) UnmarshalXML(d *xml.Decoder, start xml.StartElement) err
r.CreatedOn = a.CreatedOn
r.UpdatedOn = a.UpdatedOn
r.ContainerArt = a.ContainerArt
- r.SourceConfig = a.SourceConfig
r.LastPlayedAt = a.LastPlayedAt
r.SourceID = a.FlatSourceID
@@ -311,6 +313,7 @@ func (r *ServiceRecent) UnmarshalXML(d *xml.Decoder, start xml.StartElement) err
if a.ContentItem != nil {
r.Source = a.ContentItem.Source
r.Type = a.ContentItem.Type
+ r.ContentItemType = a.ContentItem.Type // Set ContentItemType from nested type
r.Location = a.ContentItem.Location
r.SourceAccount = a.ContentItem.SourceAccount
r.IsPresetable = a.ContentItem.IsPresetable
@@ -325,26 +328,40 @@ func (r *ServiceRecent) UnmarshalXML(d *xml.Decoder, start xml.StartElement) err
r.Location = a.FlatLocation
}
- if a.FlatContentItemType != "" {
+ switch {
+ case a.FlatContentItemType != "":
r.ContentItemType = a.FlatContentItemType
+ case a.FlatTypeTag != "":
+ r.ContentItemType = a.FlatTypeTag
+ case a.AttrType != "":
+ r.ContentItemType = a.AttrType
+ }
+
+ switch {
+ case a.FlatTypeTag != "":
+ r.Type = a.FlatTypeTag
+ case a.AttrType != "":
+ r.Type = a.AttrType
+ }
+
+ switch {
+ case a.FlatSourceID != "":
+ r.SourceID = a.FlatSourceID
+ case a.FlatSourceIDAttr != "":
+ r.SourceID = a.FlatSourceIDAttr
+ }
+
+ switch {
+ case a.FlatSource != "":
+ r.Source = a.FlatSource
+ case a.AttrSource != "":
+ r.Source = a.AttrSource
}
if a.FlatName != "" {
r.Name = a.FlatName
}
- if a.FlatSourceID != "" {
- r.SourceID = a.FlatSourceID
- }
-
- if a.FlatSource != "" {
- r.Source = a.FlatSource
- }
-
- if a.FlatTypeTag != "" {
- r.Type = a.FlatTypeTag
- }
-
if a.FlatSourceAccount != "" {
r.SourceAccount = a.FlatSourceAccount
}
@@ -380,7 +397,6 @@ func (r ServiceRecent) MarshalXML(e *xml.Encoder, start xml.StartElement) error
LastPlayedAt string `xml:"lastplayedat"`
SourceID string `xml:"sourceid"`
Username string `xml:"username"`
- SourceConfig *ConfiguredSource `xml:"source,omitempty"`
}
a := Alias{
@@ -392,7 +408,6 @@ func (r ServiceRecent) MarshalXML(e *xml.Encoder, start xml.StartElement) error
LastPlayedAt: r.LastPlayedAt,
SourceID: r.SourceID,
Username: r.Name, // Using Name as Username for parity
- SourceConfig: r.SourceConfig,
ContentItem: &NestedContentItem{
Source: r.Source,
Type: r.Type,
@@ -414,8 +429,8 @@ type ConfiguredSource struct {
XMLName xml.Name `json:"-" xml:"source"`
DisplayName string `json:"display_name" xml:"displayName,attr,omitempty"`
ID string `json:"id" xml:"id,attr,omitempty"`
- Secret string `json:"secret" xml:"-"`
- SecretType string `json:"secret_type" xml:"-"`
+ Secret string `json:"secret" xml:"secret,attr,omitempty"`
+ SecretType string `json:"secret_type" xml:"secretType,attr,omitempty"`
Credential struct {
Type string `xml:"type,attr"`
Value string `xml:",chardata"`
@@ -559,7 +574,7 @@ type ServiceComponent struct {
Category string `json:"category,omitempty" xml:"category,attr,omitempty"`
SoftwareVersion string `json:"firmware_version" xml:"firmware-version"`
SerialNumber string `json:"serial_number" xml:"serialnumber"`
- Label string `json:"label,omitempty" xml:"componentlabel,omitempty"`
+ Label string `json:"label,omitempty" xml:"componentlabel"`
}
// ServiceAccountInfo represents account-level metadata.
@@ -695,7 +710,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" xml:"displayName,attr"`
+ DisplayName string `json:"display_name" xml:"displayName,attr,omitempty"`
CreatedOn string `json:"created_on" xml:"createdOn"`
Credential struct {
Type string `json:"type" xml:"type,attr"`
@@ -709,7 +724,6 @@ type FullResponseSource struct {
Username string `json:"username" xml:"username"`
Account string `json:"account,omitempty" xml:"account,attr,omitempty"`
SourceLabel string `json:"source_label" xml:"-"`
- SecretType string `json:"secret_type,omitempty" xml:"secretType,attr,omitempty"`
}
// FullResponsePreset represents a preset specifically for the /full response.
@@ -780,7 +794,7 @@ type AccountDevice struct {
DeviceID string `json:"device_id" xml:"deviceid,attr"`
AttachedProduct *AttachedProduct `json:"attached_product" xml:"attachedProduct"`
CreatedOn string `json:"created_on" xml:"createdOn"`
- FirmwareVersion string `json:"firmware_version" xml:"firmwareVersion,omitempty"`
+ FirmwareVersion string `json:"firmware_version" xml:"firmwareVersion"`
IPAddress string `json:"ip_address" xml:"ipaddress"`
Name string `json:"name" xml:"name"`
Presets []FullResponsePreset `json:"presets" xml:"presets>preset,omitempty"`
diff --git a/pkg/service/datastore/datastore.go b/pkg/service/datastore/datastore.go
index 4915625..cb38eba 100644
--- a/pkg/service/datastore/datastore.go
+++ b/pkg/service/datastore/datastore.go
@@ -546,8 +546,8 @@ func (ds *DataStore) GetPresets(account, device string) ([]models.ServicePreset,
IsPresetable string `xml:"isPresetable,attr"`
ItemName string `xml:"itemName"`
ContainerArt string `xml:"containerArt"`
- } `xml:"ContentItem"`
- Source *models.ConfiguredSource `xml:"source"`
+ } `xml:"contentItem"`
+ SourceID string `xml:"sourceid"`
} `xml:"preset"`
}
@@ -560,24 +560,22 @@ func (ds *DataStore) GetPresets(account, device string) ([]models.ServicePreset,
for i := range presetsWrap.Presets {
p := &presetsWrap.Presets[i]
- cit := p.ContentItem.Type
-
presets = append(presets, models.ServicePreset{
ServiceContentItem: models.ServiceContentItem{
Name: p.ContentItem.ItemName,
Source: p.ContentItem.Source,
Type: p.ContentItem.Type,
+ ContentItemType: p.ContentItem.Type,
Location: p.ContentItem.Location,
SourceAccount: p.ContentItem.SourceAccount,
IsPresetable: p.ContentItem.IsPresetable,
- ContentItemType: cit,
+ SourceID: p.SourceID,
},
ID: p.ID,
ButtonNumber: p.ID,
ContainerArt: p.ContentItem.ContainerArt,
CreatedOn: p.CreatedOn,
UpdatedOn: p.UpdatedOn,
- SourceConfig: p.Source,
})
}
@@ -606,8 +604,8 @@ func (ds *DataStore) SavePresets(account, device string, presets []models.Servic
IsPresetable string `xml:"isPresetable,attr"`
ItemName string `xml:"itemName"`
ContainerArt string `xml:"containerArt"`
- } `xml:"ContentItem"`
- Source *models.ConfiguredSource `xml:"source,omitempty"`
+ } `xml:"contentItem"`
+ SourceID string `xml:"sourceid,omitempty"`
}
type PresetsXML struct {
@@ -636,7 +634,7 @@ func (ds *DataStore) SavePresets(account, device string, presets []models.Servic
pxml.ContentItem.IsPresetable = "true"
pxml.ContentItem.ItemName = p.Name
pxml.ContentItem.ContainerArt = p.ContainerArt
- pxml.Source = p.SourceConfig
+ pxml.SourceID = p.SourceID
px.Presets = append(px.Presets, pxml)
}
@@ -670,37 +668,82 @@ func (ds *DataStore) GetRecents(account, device string) ([]models.ServiceRecent,
data, err := os.ReadFile(path)
if err != nil {
+ if os.IsNotExist(err) {
+ return []models.ServiceRecent{}, nil
+ }
+
return nil, err
}
- type RecentsXML struct {
- XMLName xml.Name `xml:"recents"`
- Recents []models.ServiceRecent `xml:"recent"`
+ type RecentXML struct {
+ DeviceID string `xml:"deviceID,attr,omitempty"`
+ UtcTime string `xml:"utcTime,attr,omitempty"`
+ ID string `xml:"id,attr"`
+ ContentItem struct {
+ Source string `xml:"source,attr"`
+ Type string `xml:"type,attr"`
+ Location string `xml:"location,attr"`
+ SourceAccount string `xml:"sourceAccount,attr"`
+ IsPresetable string `xml:"isPresetable,attr"`
+ ItemName string `xml:"itemName"`
+ ContainerArt string `xml:"containerArt,omitempty"`
+ } `xml:"contentItem"`
+ CreatedOn string `xml:"createdOn,omitempty"`
+ UpdatedOn string `xml:"updatedOn,omitempty"`
+ LastPlayedAt string `xml:"lastplayedat,omitempty"`
+ SourceID string `xml:"sourceid,omitempty"`
+ Username string `xml:"username,omitempty"`
}
- var recentsWrap RecentsXML
+ type RecentsXML struct {
+ XMLName xml.Name `xml:"recents"`
+ Recents []RecentXML `xml:"recent"`
+ }
- if err := xml.Unmarshal(data, &recentsWrap); err != nil {
+ var wrap RecentsXML
+ if err := xml.Unmarshal(data, &wrap); err != nil {
return nil, fmt.Errorf("malformed recents XML at %s: %w", path, err)
}
- recents := recentsWrap.Recents
+ recents := make([]models.ServiceRecent, 0, len(wrap.Recents))
maxID := 0
- for i := range recents {
- r := &recents[i]
+ for i := range wrap.Recents {
+ rx := &wrap.Recents[i]
+ r := models.ServiceRecent{
+ DeviceID: rx.DeviceID,
+ UtcTime: rx.UtcTime,
+ ServiceContentItem: models.ServiceContentItem{
+ ID: rx.ID,
+ Name: rx.ContentItem.ItemName,
+ Source: rx.ContentItem.Source,
+ Type: rx.ContentItem.Type,
+ Location: rx.ContentItem.Location,
+ SourceAccount: rx.ContentItem.SourceAccount,
+ IsPresetable: rx.ContentItem.IsPresetable,
+ ContainerArt: rx.ContentItem.ContainerArt,
+ },
+ CreatedOn: rx.CreatedOn,
+ UpdatedOn: rx.UpdatedOn,
+ LastPlayedAt: rx.LastPlayedAt,
+ }
+ r.SourceID = rx.SourceID
if id, err := strconv.Atoi(r.ID); err == nil {
if id > maxID {
maxID = id
}
}
+
+ recents = append(recents, r)
}
for i := range recents {
r := &recents[i]
if r.ContentItemType == "" {
- r.ContentItemType = r.Type
+ if r.Type != "" {
+ r.ContentItemType = r.Type
+ }
}
if _, err := strconv.Atoi(recents[i].ID); err != nil || recents[i].ID == "" {
@@ -724,13 +767,62 @@ func (ds *DataStore) SaveRecents(account, device string, recents []models.Servic
path := filepath.Join(dir, constants.RecentsFile)
+ type RecentXML struct {
+ DeviceID string `xml:"deviceID,attr,omitempty"`
+ UtcTime string `xml:"utcTime,attr,omitempty"`
+ ID string `xml:"id,attr"`
+ ContentItem struct {
+ Source string `xml:"source,attr"`
+ Type string `xml:"type,attr"`
+ Location string `xml:"location,attr"`
+ SourceAccount string `xml:"sourceAccount,attr"`
+ IsPresetable string `xml:"isPresetable,attr"`
+ ItemName string `xml:"itemName"`
+ ContainerArt string `xml:"containerArt,omitempty"`
+ } `xml:"contentItem"`
+ CreatedOn string `xml:"createdOn,omitempty"`
+ UpdatedOn string `xml:"updatedOn,omitempty"`
+ LastPlayedAt string `xml:"lastplayedat,omitempty"`
+ SourceID string `xml:"sourceid,omitempty"`
+ Username string `xml:"username,omitempty"`
+ }
+
type RecentsXML struct {
- XMLName xml.Name `xml:"recents"`
- Recents []models.ServiceRecent `xml:"recent"`
+ XMLName xml.Name `xml:"recents"`
+ Recents []RecentXML `xml:"recent"`
}
wrap := RecentsXML{
- Recents: recents,
+ Recents: make([]RecentXML, 0, len(recents)),
+ }
+
+ for i := range recents {
+ r := &recents[i]
+ rx := RecentXML{
+ DeviceID: r.DeviceID,
+ UtcTime: r.UtcTime,
+ ID: r.ID,
+ CreatedOn: r.CreatedOn,
+ UpdatedOn: r.UpdatedOn,
+ LastPlayedAt: r.LastPlayedAt,
+ SourceID: r.SourceID,
+ Username: r.Username,
+ }
+ rx.ContentItem.Source = r.Source
+ rx.ContentItem.Type = r.Type
+ rx.ContentItem.Location = r.Location
+ rx.ContentItem.SourceAccount = r.SourceAccount
+
+ rx.ContentItem.IsPresetable = r.IsPresetable
+ if rx.ContentItem.IsPresetable == "" {
+ rx.ContentItem.IsPresetable = "true"
+ }
+
+ rx.ContentItem.ItemName = r.Name
+ rx.ContentItem.ContainerArt = r.ContainerArt
+ rx.SourceID = r.SourceID
+
+ wrap.Recents = append(wrap.Recents, rx)
}
data, err := xml.MarshalIndent(wrap, "", " ")
@@ -1009,7 +1101,11 @@ func (ds *DataStore) GetConfiguredSources(account, device string) ([]models.Conf
CreatedOn string `xml:"createdOn,attr,omitempty"`
UpdatedOn string `xml:"updatedOn,attr,omitempty"`
SourceProviderID string `xml:"sourceproviderid,attr,omitempty"`
- SourceKey struct {
+ Credential struct {
+ Type string `xml:"type,attr"`
+ Value string `xml:",chardata"`
+ } `xml:"credential,omitempty"`
+ SourceKey struct {
Type string `xml:"type,attr"`
Account string `xml:"account,attr"`
} `xml:"sourceKey"`
@@ -1039,7 +1135,16 @@ func (ds *DataStore) GetConfiguredSources(account, device string) ([]models.Conf
s.SourceKey.Type = ps.SourceKey.Type
s.SourceKey.Account = ps.SourceKey.Account
- // Ensure Secret/SecretType values are prioritized from legacy fields
+ // Prioritize Credential element if present, otherwise use secret/secretType attributes
+ if ps.Credential.Value != "" {
+ s.Secret = ps.Credential.Value
+ s.SecretType = ps.Credential.Type
+ } else {
+ s.Secret = ps.Secret
+ s.SecretType = ps.SecretType
+ }
+
+ // Ensure Secret/SecretType values are prioritized from legacy fields if still missing
if s.Secret == "" && s.Credential.Value != "" {
s.Secret = s.Credential.Value
}
@@ -1089,7 +1194,11 @@ func (ds *DataStore) SaveConfiguredSources(account, device string, sources []mod
CreatedOn string `xml:"createdOn,attr,omitempty"`
UpdatedOn string `xml:"updatedOn,attr,omitempty"`
SourceProviderID string `xml:"sourceproviderid,attr,omitempty"`
- SourceKey struct {
+ Credential struct {
+ Type string `xml:"type,attr"`
+ Value string `xml:",chardata"`
+ } `xml:"credential,omitempty"`
+ SourceKey struct {
Type string `xml:"type,attr"`
Account string `xml:"account,attr"`
} `xml:"sourceKey"`
@@ -1119,6 +1228,15 @@ func (ds *DataStore) SaveConfiguredSources(account, device string, sources []mod
SourceProviderID: s.SourceProviderID,
}
+ // Save to Credential element as well for parity with official Bose format
+ if s.Secret != "" {
+ persistSources[i].Credential.Value = s.Secret
+ persistSources[i].Credential.Type = s.SecretType
+ } else if s.Credential.Value != "" {
+ persistSources[i].Credential.Value = s.Credential.Value
+ persistSources[i].Credential.Type = s.Credential.Type
+ }
+
if persistSources[i].Secret == "" && s.Credential.Value != "" {
persistSources[i].Secret = s.Credential.Value
}
diff --git a/pkg/service/datastore/sources_regression_test.go b/pkg/service/datastore/sources_regression_test.go
index 66e95c8..f451d45 100644
--- a/pkg/service/datastore/sources_regression_test.go
+++ b/pkg/service/datastore/sources_regression_test.go
@@ -29,7 +29,6 @@ func TestSaveSources_Format(t *testing.T) {
}{Type: "AUX", Account: "AUX"},
},
{
- SecretType: "token",
SourceKey: struct {
Type string `xml:"type,attr"`
Account string `xml:"account,attr"`
@@ -68,12 +67,17 @@ func TestSaveSources_Format(t *testing.T) {
t.Errorf("First sourceKey incorrect. Got: %s", xmlContent)
}
+ // Check for credential element (new format)
+ if !strings.Contains(xmlContent, `dummy-token-spotify`) {
+ t.Errorf("Spotify source missing element. Got: %s", xmlContent)
+ }
+
// Check for third source (Spotify)
if !strings.Contains(xmlContent, `displayName="user@example.com"`) {
t.Errorf("Spotify source missing displayName. Got: %s", xmlContent)
}
- if !strings.Contains(xmlContent, `secretType="token_version_3"`) {
- t.Errorf("Spotify source missing secretType. Got: %s", xmlContent)
+ if !strings.Contains(xmlContent, `secret="dummy-token-spotify" secretType="token_version_3">`) {
+ t.Errorf("Spotify source missing secret. Got: %s", xmlContent)
}
if !strings.Contains(xmlContent, ``) &&
!strings.Contains(xmlContent, ``) {
diff --git a/pkg/service/handlers/handlers_account_mgmt.go b/pkg/service/handlers/handlers_account_mgmt.go
index 44897f3..cff8884 100644
--- a/pkg/service/handlers/handlers_account_mgmt.go
+++ b/pkg/service/handlers/handlers_account_mgmt.go
@@ -4,7 +4,9 @@ import (
"encoding/json"
"log"
"net/http"
+ "strconv"
"strings"
+ "time"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
@@ -310,7 +312,12 @@ func mapToFullResponsePreset(p *models.ServicePreset, configuredSources []models
Location: p.Location,
Name: p.Name,
UpdatedOn: p.UpdatedOn,
+ Username: p.Username,
}
+ if fp.Username == "" {
+ fp.Username = p.Name
+ }
+
if fp.Name == "" {
fp.Name = p.Name
}
@@ -319,31 +326,27 @@ func mapToFullResponsePreset(p *models.ServicePreset, configuredSources []models
fp.CreatedOn = p.CreatedOn
}
- if p.SourceConfig != nil {
- fp.Source = mapToFullResponseSource(p.SourceConfig)
- } else {
- // Attempt to find matching source in configuredSources
- found := false
+ // Attempt to find matching source in configuredSources
+ found := false
- for k := range configuredSources {
- src := &configuredSources[k]
- if src.SourceKey.Type == p.Source && (src.SourceKey.Account == p.SourceAccount || p.SourceAccount == "") {
- fp.Source = mapToFullResponseSource(src)
- found = true
+ for k := range configuredSources {
+ src := &configuredSources[k]
+ if src.SourceKey.Type == p.Source && (src.SourceKey.Account == p.SourceAccount || p.SourceAccount == "") {
+ fp.Source = mapToFullResponseSource(src)
+ found = true
- break
- }
+ break
}
+ }
- if !found && p.Source != "" {
- // Create a dummy source for UI purposes if not found in configured sources
- dummy := &models.ConfiguredSource{
- Type: p.Source,
- }
- dummy.SourceKey.Type = p.Source
- dummy.SourceKey.Account = p.SourceAccount
- fp.Source = mapToFullResponseSource(dummy)
+ if !found && p.Source != "" {
+ // Create a dummy source for UI purposes if not found in configured sources
+ dummy := &models.ConfiguredSource{
+ Type: p.Source,
}
+ dummy.SourceKey.Type = p.Source
+ dummy.SourceKey.Account = p.SourceAccount
+ fp.Source = mapToFullResponseSource(dummy)
}
return fp
@@ -359,6 +362,16 @@ func mapToFullResponseRecent(r *models.ServiceRecent, configuredSources []models
Name: r.Name,
SourceID: r.SourceID,
UpdatedOn: r.UpdatedOn,
+ Username: r.Username,
+ }
+ if fr.LastPlayedAt == "" && r.UtcTime != "" {
+ if ut, err := strconv.ParseInt(r.UtcTime, 10, 64); err == nil {
+ fr.LastPlayedAt = time.Unix(ut, 0).UTC().Format("2006-01-02T15:04:05.000+00:00")
+ }
+ }
+
+ if fr.Username == "" {
+ fr.Username = r.Name
}
if fr.Name == "" {
@@ -371,30 +384,34 @@ func mapToFullResponseRecent(r *models.ServiceRecent, configuredSources []models
fr.CreatedOn = r.UtcTime
}
- if r.SourceConfig != nil {
- fr.Source = mapToFullResponseSource(r.SourceConfig)
- } else {
- // Attempt to find matching source in configuredSources
- found := false
+ // Attempt to find matching source in configuredSources
+ found := false
- for k := range configuredSources {
- src := &configuredSources[k]
- if src.SourceKey.Type == r.Source && (src.SourceKey.Account == r.SourceAccount || r.SourceAccount == "") {
- fr.Source = mapToFullResponseSource(src)
- found = true
-
- break
+ for k := range configuredSources {
+ src := &configuredSources[k]
+ if src.SourceKey.Type == r.Source && (src.SourceKey.Account == r.SourceAccount || r.SourceAccount == "") {
+ fr.Source = mapToFullResponseSource(src)
+ if fr.SourceID == "" {
+ fr.SourceID = fr.Source.ID
}
+
+ found = true
+
+ break
}
+ }
- if !found && r.Source != "" {
- // Create a dummy source for UI purposes if not found in configured sources
- dummy := &models.ConfiguredSource{
- Type: r.Source,
- }
- dummy.SourceKey.Type = r.Source
- dummy.SourceKey.Account = r.SourceAccount
- fr.Source = mapToFullResponseSource(dummy)
+ if !found && r.Source != "" {
+ // Create a dummy source for UI purposes if not found in configured sources
+ dummy := &models.ConfiguredSource{
+ Type: r.Source,
+ }
+ dummy.SourceKey.Type = r.Source
+ dummy.SourceKey.Account = r.SourceAccount
+
+ fr.Source = mapToFullResponseSource(dummy)
+ if fr.SourceID == "" {
+ fr.SourceID = fr.Source.ID
}
}
diff --git a/pkg/service/handlers/handlers_account_mgmt_test.go b/pkg/service/handlers/handlers_account_mgmt_test.go
index 54966ca..049f181 100644
--- a/pkg/service/handlers/handlers_account_mgmt_test.go
+++ b/pkg/service/handlers/handlers_account_mgmt_test.go
@@ -108,9 +108,9 @@ func TestHandleMgmtAccountDetails_Recents(t *testing.T) {
presetsXML := `
-
+
test-playlist
-
+
`
err = os.WriteFile(deviceDir+"/Presets.xml", []byte(presetsXML), 0644)
diff --git a/pkg/service/handlers/handlers_marge_test.go b/pkg/service/handlers/handlers_marge_test.go
index 7423d9c..f0f296e 100644
--- a/pkg/service/handlers/handlers_marge_test.go
+++ b/pkg/service/handlers/handlers_marge_test.go
@@ -54,8 +54,8 @@ func TestMargeCreateAccount(t *testing.T) {
t.Fatalf("Failed to unmarshal response: %v", err)
}
- if resp.AccountStatus != "ACTIVE" {
- t.Errorf("Expected AccountStatus ACTIVE, got %v", resp.AccountStatus)
+ if resp.AccountStatus != "OK" {
+ t.Errorf("Expected AccountStatus OK, got %v", resp.AccountStatus)
}
if resp.PreferredLanguage != "de" {
t.Errorf("Expected PreferredLanguage de, got %v", resp.PreferredLanguage)
@@ -515,8 +515,7 @@ func TestMargeAccountSourcesNoDevices(t *testing.T) {
"",
- "displayName=\"\"", // for the other sources
+ "
-
+
Test Station
http://example.com/art.jpg
-
+
`), 0644); err != nil {
diff --git a/pkg/service/handlers/parity_regression_test.go b/pkg/service/handlers/parity_regression_test.go
index 66ca4e3..a95c4fd 100644
--- a/pkg/service/handlers/parity_regression_test.go
+++ b/pkg/service/handlers/parity_regression_test.go
@@ -30,10 +30,10 @@ func TestMargeParityRegressions(t *testing.T) {
// One with "Other" and one with a specific name.
sourcesXML := `
-
+
-
+
`
diff --git a/pkg/service/marge/credential_parity_test.go b/pkg/service/marge/credential_parity_test.go
new file mode 100644
index 0000000..53ccb97
--- /dev/null
+++ b/pkg/service/marge/credential_parity_test.go
@@ -0,0 +1,448 @@
+package marge
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/gesellix/bose-soundtouch/pkg/models"
+ "github.com/gesellix/bose-soundtouch/pkg/service/datastore"
+)
+
+func TestCredentialParity_LegacyAndNewFormat(t *testing.T) {
+ tempDir, err := os.MkdirTemp("", "credential-parity-test-*")
+ if err != nil {
+ t.Fatalf("Failed to create temp dir: %v", err)
+ }
+ defer func() { _ = os.RemoveAll(tempDir) }()
+
+ ds := datastore.NewDataStore(tempDir)
+ account := "12345"
+ device := "DEV123"
+ deviceDir := ds.AccountDeviceDir(account, device)
+ _ = os.MkdirAll(deviceDir, 0755)
+
+ // 1. Setup Sources.xml with BOTH legacy attribute and new element
+ // This simulates what the datastore now produces.
+ sourcesXML := `
+
+
+ new-token
+
+
+
+
+
+
+ only-new
+
+
+`
+ _ = os.WriteFile(filepath.Join(deviceDir, "Sources.xml"), []byte(sourcesXML), 0644)
+
+ // 2. Verify GetConfiguredSources prioritizes new element
+ sources, err := ds.GetConfiguredSources(account, device)
+ if err != nil {
+ t.Fatalf("GetConfiguredSources failed: %v", err)
+ }
+
+ if len(sources) != 3 {
+ t.Fatalf("Expected 3 sources, got %d", len(sources))
+ }
+
+ // Source 1001: should have "new-token"
+ if sources[0].Secret != "new-token" {
+ t.Errorf("Source 1001: expected secret 'new-token', got '%s'", sources[0].Secret)
+ }
+
+ // Source 1002: should have "only-legacy"
+ if sources[1].Secret != "only-legacy" {
+ t.Errorf("Source 1002: expected secret 'only-legacy', got '%s'", sources[1].Secret)
+ }
+
+ // Source 1003: should have "only-new" and "token_version_3"
+ if sources[2].Secret != "only-new" {
+ t.Errorf("Source 1003: expected secret 'only-new', got '%s'", sources[2].Secret)
+ }
+ if sources[2].SecretType != "token_version_3" {
+ t.Errorf("Source 1003: expected secretType 'token_version_3', got '%s'", sources[2].SecretType)
+ }
+
+ // 3. Verify AccountFullToXML (API response) contains correct credential elements
+ fullXML, err := AccountFullToXML(ds, account)
+ if err != nil {
+ t.Fatalf("AccountFullToXML failed: %v", err)
+ }
+
+ xmlStr := string(fullXML)
+
+ // Check 1001: should have new-token (Spotify with 'token' upgraded to 'token_version_3' in mapping)
+ if !strings.Contains(xmlStr, ``) {
+ t.Errorf("Missing source 1001 in XML")
+ }
+ // Spotify with 'token' is upgraded to 'token_version_3' in mapToFullResponseSource
+ if !strings.Contains(xmlStr, `new-token`) {
+ t.Errorf("Source 1001: missing expected credential. XML: %s", xmlStr)
+ }
+
+ // Check 1002: should have only-legacy
+ if !strings.Contains(xmlStr, ``) {
+ t.Errorf("Missing source 1002 in XML")
+ }
+ if !strings.Contains(xmlStr, `only-legacy`) {
+ t.Errorf("Source 1002: missing expected credential. XML: %s", xmlStr)
+ }
+
+ // Check 1003: should have only-new
+ if !strings.Contains(xmlStr, ``) {
+ t.Errorf("Missing source 1003 in XML")
+ }
+ if !strings.Contains(xmlStr, `only-new`) {
+ t.Errorf("Source 1003: missing expected credential. XML: %s", xmlStr)
+ }
+}
+
+func TestAccountFullToXML_RecentsCredentialConsistency(t *testing.T) {
+ tempDir, err := os.MkdirTemp("", "recents-consistency-test-*")
+ if err != nil {
+ t.Fatalf("Failed to create temp dir: %v", err)
+ }
+ defer func() { _ = os.RemoveAll(tempDir) }()
+
+ ds := datastore.NewDataStore(tempDir)
+ account := "12345"
+ device := "DEV123"
+ deviceDir := ds.AccountDeviceDir(account, device)
+ _ = os.MkdirAll(deviceDir, 0755)
+
+ // 1. Setup Sources.xml
+ // 9330201 comes first and matches type "Audio" but has NO token.
+ // 14774275 comes later and matches the sourceid exactly and HAS token.
+ sourcesXML := `
+
+
+
+
+
+
+ token-value
+
+
+`
+ _ = os.WriteFile(filepath.Join(deviceDir, "Sources.xml"), []byte(sourcesXML), 0644)
+
+ // 2. Setup Recents.xml
+ recentsXML := `
+
+
+
+ Atemlos durch die Charts
+
+ 2019-07-29T15:29:59.000+00:00
+ 2019-07-29T15:29:59.000+00:00
+ 2019-07-29T11:29:54.000+00:00
+ 14774275
+ Atemlos durch die Charts
+
+`
+ _ = os.WriteFile(filepath.Join(deviceDir, "Recents.xml"), []byte(recentsXML), 0644)
+
+ // Setup DeviceInfo.xml so CreateAccountDevice works
+ deviceInfoXML := `
+
+ Test Device
+ SoundTouch 10
+`
+ _ = os.WriteFile(filepath.Join(deviceDir, "DeviceInfo.xml"), []byte(deviceInfoXML), 0644)
+
+ // 3. Verify RecentsToXML (used by /recents)
+ recentsBytes, err := RecentsToXML(ds, account, device)
+ if err != nil {
+ t.Fatalf("RecentsToXML failed: %v", err)
+ }
+ recentsStr := string(recentsBytes)
+ // t.Logf("Recents XML: %s", recentsStr)
+ if !strings.Contains(recentsStr, `14774275`) {
+ t.Errorf("/recents response should have sourceid 14774275. XML: %s", recentsStr)
+ }
+ if !strings.Contains(recentsStr, `token-value`) {
+ t.Errorf("/recents response missing credential. XML: %s", recentsStr)
+ }
+
+ // 4. Verify AccountFullToXML (used by /full)
+ fullBytes, err := AccountFullToXML(ds, account)
+ if err != nil {
+ t.Fatalf("AccountFullToXML failed: %v", err)
+ }
+ fullStr := string(fullBytes)
+ // t.Logf("Full XML: %s", fullStr)
+ // In AccountFullToXML, recents are grouped under devices
+ if !strings.Contains(fullStr, ``) {
+ t.Errorf("/full response missing recent item. XML: %s", fullStr)
+ }
+ if !strings.Contains(fullStr, `14774275`) {
+ t.Errorf("/full response should have sourceid 14774275. XML: %s", fullStr)
+ }
+ if !strings.Contains(fullStr, `token-value`) {
+ t.Errorf("/full response missing credential. XML: %s", fullStr)
+ }
+}
+
+func TestAccountSourcesToXML_CredentialParity(t *testing.T) {
+ tempDir, err := os.MkdirTemp("", "sources-parity-test-*")
+ if err != nil {
+ t.Fatalf("Failed to create temp dir: %v", err)
+ }
+ defer func() { _ = os.RemoveAll(tempDir) }()
+
+ ds := datastore.NewDataStore(tempDir)
+ account := "123"
+ device := "ABC"
+
+ src := models.ConfiguredSource{
+ ID: "2001",
+ Secret: "secret-val",
+ SecretType: "token_version_3",
+ }
+ src.SourceKey.Type = "SPOTIFY"
+ src.SourceKey.Account = "user1"
+
+ _ = ds.SaveConfiguredSources(account, device, []models.ConfiguredSource{src})
+
+ xmlData, err := AccountSourcesToXML(ds, account)
+ if err != nil {
+ t.Fatalf("AccountSourcesToXML failed: %v", err)
+ }
+
+ xmlStr := string(xmlData)
+ if !strings.Contains(xmlStr, `secret-val`) {
+ t.Errorf("AccountSourcesToXML missing expected credential element. Got: %s", xmlStr)
+ }
+}
+
+func TestAccountFullToXML_RecentsCredentialParity(t *testing.T) {
+ tempDir, err := os.MkdirTemp("", "recents-parity-test-*")
+ if err != nil {
+ t.Fatalf("Failed to create temp dir: %v", err)
+ }
+ defer func() { _ = os.RemoveAll(tempDir) }()
+
+ ds := datastore.NewDataStore(tempDir)
+ account := "123"
+ device := "ABC"
+ deviceDir := ds.AccountDeviceDir(account, device)
+ _ = os.MkdirAll(deviceDir, 0755)
+
+ // 0. Setup DeviceInfo.xml (required for CreateAccountDevice)
+ deviceInfoXML := `
+
+ Test Device
+ SoundTouch 10
+
+
+ 1.2.3
+ ABC123
+
+
+`
+ _ = os.WriteFile(filepath.Join(deviceDir, "DeviceInfo.xml"), []byte(deviceInfoXML), 0644)
+
+ // 1. Setup Sources.xml
+ src := models.ConfiguredSource{
+ ID: "3001",
+ Secret: "recent-token",
+ SecretType: "token_version_3",
+ }
+ src.SourceKey.Type = "SPOTIFY"
+ src.SourceKey.Account = "user-recent"
+ _ = ds.SaveConfiguredSources(account, device, []models.ConfiguredSource{src})
+
+ // 2. Setup Recents.xml
+ recentsXML := `
+
+
+
+
+`
+ _ = os.WriteFile(filepath.Join(deviceDir, "Recents.xml"), []byte(recentsXML), 0644)
+
+ // 3. Generate Account Full XML
+ fullXML, err := AccountFullToXML(ds, account)
+ if err != nil {
+ t.Fatalf("AccountFullToXML failed: %v", err)
+ }
+
+ xmlStr := string(fullXML)
+
+ // 4. Verify that the recent item has the credential populated via its source
+ // The recent item's source should be mapped from the configured source with ID 3001 or matching source/account.
+ if !strings.Contains(xmlStr, ``) {
+ t.Errorf("Missing recent 1 in XML. Got: %s", xmlStr)
+ }
+
+ // This is what is currently missing according to the issue.
+ // We need to check if the element's nested has the .
+ // Simple way to check: is there at least TWO occurrences of the credential?
+ // One in and one in .
+ count := strings.Count(xmlStr, `recent-token`)
+ if count < 2 {
+ t.Errorf("Recent item likely missing expected credential element. Count: %d, XML: %s", count, xmlStr)
+ }
+}
+
+func TestAccountFullToXML_RecentsSourceAccountMatching(t *testing.T) {
+ tempDir, err := os.MkdirTemp("", "recents-matching-test-*")
+ if err != nil {
+ t.Fatalf("Failed to create temp dir: %v", err)
+ }
+ defer func() { _ = os.RemoveAll(tempDir) }()
+
+ ds := datastore.NewDataStore(tempDir)
+ account := "123"
+ device := "ABC"
+ deviceDir := ds.AccountDeviceDir(account, device)
+ _ = os.MkdirAll(deviceDir, 0755)
+
+ // 0. Setup DeviceInfo.xml
+ deviceInfoXML := `
+
+ Test Device
+ ABC123
+`
+ _ = os.WriteFile(filepath.Join(deviceDir, "DeviceInfo.xml"), []byte(deviceInfoXML), 0644)
+
+ // 1. Setup TWO Spotify sources with different accounts
+ src1 := models.ConfiguredSource{
+ ID: "101",
+ Secret: "token-1",
+ SecretType: "token_version_3",
+ }
+ src1.SourceKey.Type = "SPOTIFY"
+ src1.SourceKey.Account = "user-1"
+
+ src2 := models.ConfiguredSource{
+ ID: "202",
+ Secret: "token-2",
+ SecretType: "token_version_3",
+ }
+ src2.SourceKey.Type = "SPOTIFY"
+ src2.SourceKey.Account = "user-2"
+
+ _ = ds.SaveConfiguredSources(account, device, []models.ConfiguredSource{src1, src2})
+
+ // 2. Setup Recents.xml with a Spotify recent for user-2
+ recentsXML := `
+
+
+
+
+`
+ _ = os.WriteFile(filepath.Join(deviceDir, "Recents.xml"), []byte(recentsXML), 0644)
+
+ // 3. Generate Account Full XML
+ fullXML, err := AccountFullToXML(ds, account)
+ if err != nil {
+ t.Fatalf("AccountFullToXML failed: %v", err)
+ }
+
+ xmlStr := string(fullXML)
+
+ // 4. Verify that the recent item matches source 202 (user-2) and HAS token-2
+ if !strings.Contains(xmlStr, ``) {
+ t.Fatalf("Missing recent 1")
+ }
+
+ // It should have token-2. If it picked src1 by mistake, it would have token-1.
+ if !strings.Contains(xmlStr, `token-2`) {
+ t.Errorf("Recent item missing expected credential element (token-2). XML: %s", xmlStr)
+ }
+
+ // Total count: token-1 (once in sources), token-2 (once in sources, once in recents)
+ if strings.Count(xmlStr, `token-2`) < 2 {
+ t.Errorf("token-2 should appear twice (source list and recent). XML: %s", xmlStr)
+ }
+}
+
+func TestAccountFullToXML_ContentItemTypeParity(t *testing.T) {
+ tempDir, err := os.MkdirTemp("", "content-item-type-parity-test-*")
+ if err != nil {
+ t.Fatalf("Failed to create temp dir: %v", err)
+ }
+ defer func() { _ = os.RemoveAll(tempDir) }()
+
+ ds := datastore.NewDataStore(tempDir)
+ account := "123"
+ device := "ABC"
+ deviceDir := ds.AccountDeviceDir(account, device)
+ _ = os.MkdirAll(deviceDir, 0755)
+
+ // 0. Setup DeviceInfo.xml
+ deviceInfoXML := `
+
+ Test Device
+ SoundTouch 10
+ ABC123
+`
+ _ = os.WriteFile(filepath.Join(deviceDir, "DeviceInfo.xml"), []byte(deviceInfoXML), 0644)
+
+ // 1. Setup Sources.xml
+ sourcesXML := `
+
+
+
+
+
+`
+ _ = os.WriteFile(filepath.Join(deviceDir, "Sources.xml"), []byte(sourcesXML), 0644)
+
+ // 2. Setup Presets.xml and Recents.xml with contentItem elements
+ presetsXML := `
+
+
+
+ Station Name
+
+
+`
+ _ = os.WriteFile(filepath.Join(deviceDir, "Presets.xml"), []byte(presetsXML), 0644)
+
+ recentsXML := `
+
+
+
+ Station Name
+
+
+`
+ _ = os.WriteFile(filepath.Join(deviceDir, "Recents.xml"), []byte(recentsXML), 0644)
+
+ // 3. Generate Account Full XML
+ fullXML, err := AccountFullToXML(ds, account)
+ if err != nil {
+ t.Fatalf("AccountFullToXML failed: %v", err)
+ }
+
+ xmlStr := string(fullXML)
+
+ // 4. Verify that contentItemType is present and matches the contentItem's type
+ if !strings.Contains(xmlStr, `stationurl`) {
+ t.Errorf("Missing expected stationurl in XML. XML: %s", xmlStr)
+ }
+
+ // It should appear twice: once in preset, once in recent
+ count := strings.Count(xmlStr, `stationurl`)
+ if count != 2 {
+ t.Errorf("Expected stationurl to appear twice, got %d. XML: %s", count, xmlStr)
+ }
+
+ // Verify itemName is present
+ if !strings.Contains(xmlStr, `Station Name`) {
+ t.Errorf("Missing expected Station Name in XML. XML: %s", xmlStr)
+ }
+
+ // Verify location is present
+ if !strings.Contains(xmlStr, `/v1/playback/stations/s166521`) {
+ t.Errorf("Missing expected /v1/playback/stations/s166521 in XML. XML: %s", xmlStr)
+ }
+}
diff --git a/pkg/service/marge/lastplayedat_test.go b/pkg/service/marge/lastplayedat_test.go
new file mode 100644
index 0000000..cebe762
--- /dev/null
+++ b/pkg/service/marge/lastplayedat_test.go
@@ -0,0 +1,51 @@
+package marge
+
+import (
+ "encoding/xml"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/gesellix/bose-soundtouch/pkg/models"
+)
+
+func TestLastPlayedAtParity(t *testing.T) {
+ now := time.Now().Unix()
+ utcTimeStr := strconv.FormatInt(now, 10)
+ expectedLastPlayedAt := time.Unix(now, 0).UTC().Format("2006-01-02T15:04:05.000+00:00")
+
+ recents := []models.ServiceRecent{
+ {
+ ServiceContentItem: models.ServiceContentItem{
+ ID: "1",
+ Name: "Recent 1",
+ },
+ UtcTime: utcTimeStr,
+ LastPlayedAt: "", // Empty in datastore
+ },
+ }
+
+ sources := []models.ConfiguredSource{}
+
+ fullRecents := mapRecentsToFullResponse(recents, sources)
+
+ if len(fullRecents) != 1 {
+ t.Fatalf("Expected 1 recent, got %d", len(fullRecents))
+ }
+
+ if fullRecents[0].LastPlayedAt != expectedLastPlayedAt {
+ t.Errorf("Expected LastPlayedAt %s, got %s", expectedLastPlayedAt, fullRecents[0].LastPlayedAt)
+ }
+
+ // Verify XML marshaling
+ data, err := xml.Marshal(fullRecents[0])
+ if err != nil {
+ t.Fatalf("Failed to marshal: %v", err)
+ }
+
+ xmlStr := string(data)
+ if !strings.Contains(xmlStr, ""+expectedLastPlayedAt+"") {
+ t.Errorf("XML missing expected lastplayedat tag: %s", xmlStr)
+ }
+}
diff --git a/pkg/service/marge/marge.go b/pkg/service/marge/marge.go
index ae6a47e..a0954a1 100644
--- a/pkg/service/marge/marge.go
+++ b/pkg/service/marge/marge.go
@@ -159,8 +159,8 @@ func syncLegacySourceKey(s *models.ConfiguredSource) {
// PresetsXML is the XML wrapper for a list of presets.
type PresetsXML struct {
- XMLName xml.Name `xml:"presets"`
- Presets []models.ServicePreset `xml:"preset"`
+ XMLName xml.Name `xml:"presets"`
+ Presets []presetParityXML `xml:"preset"`
}
type presetParityXML struct {
@@ -171,7 +171,6 @@ type presetParityXML struct {
Location string `xml:"location"`
Name string `xml:"name"`
Source *models.ConfiguredSource `xml:"source,omitempty"`
- SourceID string `xml:"sourceid,omitempty"`
UpdatedOn string `xml:"updatedOn"`
Username string `xml:"username"`
}
@@ -274,11 +273,6 @@ func mapPresetToParityXML(p models.ServicePreset, sources []models.ConfiguredSou
username = p.Name
}
- sourceID := p.SourceID
- if sourceID == "" && matchedSource != nil {
- sourceID = matchedSource.ID
- }
-
return &presetParityXML{
ButtonNumber: p.ButtonNumber,
ContainerArt: p.ContainerArt,
@@ -287,7 +281,6 @@ func mapPresetToParityXML(p models.ServicePreset, sources []models.ConfiguredSou
Location: p.Location,
Name: p.Name,
Source: matchedSource,
- SourceID: sourceID,
UpdatedOn: p.UpdatedOn,
Username: username,
}
@@ -402,11 +395,20 @@ func PresetsToXML(ds *datastore.DataStore, account, deviceID string) ([]byte, er
}
func findMatchingSourceForPreset(sources []models.ConfiguredSource, p models.ServicePreset) *models.ConfiguredSource {
+ // First try exact ID match
+ if p.SourceID != "" {
+ for j := range sources {
+ if sources[j].ID == p.SourceID {
+ return &sources[j]
+ }
+ }
+ }
+
+ // Then try type and account match
for j := range sources {
s := &sources[j]
- if (p.SourceID != "" && s.ID == p.SourceID) ||
- (s.SourceKey.Type == p.Source && s.SourceKey.Account == p.SourceAccount) ||
- (s.SourceKeyType == p.Source && s.SourceKeyAccount == p.SourceAccount) {
+ if (s.SourceKey.Type == p.Source && (p.SourceAccount == "" || s.SourceKey.Account == p.SourceAccount)) ||
+ (s.SourceKeyType == p.Source && (p.SourceAccount == "" || s.SourceKeyAccount == p.SourceAccount)) {
return s
}
}
@@ -438,25 +440,28 @@ func RecentsToXML(ds *datastore.DataStore, account, deviceID string) ([]byte, er
for i := range recents {
r := &recents[i]
- if r.SourceConfig == nil && r.SourceID != "" {
- r.SourceConfig = findMatchingSource(sources, r.SourceID)
+
+ var matchingSrc *models.ConfiguredSource
+
+ if r.SourceID != "" {
+ matchingSrc = findMatchingSource(sources, r.SourceID)
}
- if r.SourceConfig != nil {
- PrepareConfiguredSource(r.SourceConfig)
+ if matchingSrc != nil {
+ PrepareConfiguredSource(matchingSrc)
} else if r.Source != "" {
// Try to find by Source and SourceAccount if SourceID didn't match
for j := range sources {
if sources[j].SourceKeyType == r.Source && sources[j].SourceKeyAccount == r.SourceAccount {
- r.SourceConfig = &sources[j]
- PrepareConfiguredSource(r.SourceConfig)
+ matchingSrc = &sources[j]
+ PrepareConfiguredSource(matchingSrc)
break
}
}
}
- rxml.Recents[i] = recentToXML(r)
+ rxml.Recents[i] = recentToXML(r, matchingSrc)
}
data, err := xml.MarshalIndent(rxml, "", " ")
@@ -495,7 +500,7 @@ type contentItem struct {
ContainerArt string `xml:"containerArt,omitempty"`
}
-func recentToXML(r *models.ServiceRecent) recent {
+func recentToXML(r *models.ServiceRecent, matchingSrc *models.ConfiguredSource) recent {
utcTime := int64(0)
if r.UtcTime != "" {
@@ -539,8 +544,8 @@ func recentToXML(r *models.ServiceRecent) recent {
},
}
- if r.SourceConfig != nil {
- res.Source = prepareRecentItemParitySource(r.SourceConfig)
+ if matchingSrc != nil {
+ res.Source = prepareRecentItemParitySource(matchingSrc)
}
return res
@@ -647,6 +652,7 @@ func CreateAccountDevice(ds *datastore.DataStore, account, deviceID string) (mod
Category: comp.Category,
SoftwareVersion: comp.SoftwareVersion,
SerialNumber: comp.SerialNumber,
+ Label: comp.Label,
})
}
}
@@ -703,7 +709,9 @@ func mapToFullResponseCredential(s models.ConfiguredSource, fullSource *models.F
if s.Credential.Value != "" {
fullSource.Credential.Value = s.Credential.Value
fullSource.Credential.Type = s.Credential.Type
- } else if s.Secret != "" {
+ }
+
+ if fullSource.Credential.Value == "" && s.Secret != "" {
fullSource.Credential.Value = s.Secret
fullSource.Credential.Type = s.SecretType
}
@@ -751,7 +759,6 @@ func mapToFullResponseSource(s models.ConfiguredSource) models.FullResponseSourc
SourceSettings: "",
UpdatedOn: s.UpdatedOn,
Username: s.Username,
- SecretType: s.SecretType,
}
mapToFullResponseCredential(s, &fullSource)
@@ -760,7 +767,7 @@ func mapToFullResponseSource(s models.ConfiguredSource) models.FullResponseSourc
fullSource.SourceName = ""
}
- if fullSource.Username == "" {
+ if fullSource.Username == "" && s.SourceKeyType != "TUNEIN" && s.SourceKeyType != "INTERNET_RADIO" && s.SourceKeyType != "LOCAL_INTERNET_RADIO" {
fullSource.Username = s.SourceKeyAccount
}
@@ -782,16 +789,30 @@ func mapPresetsToFullResponse(presets []models.ServicePreset, sources []models.C
}
var matchedSource *models.ConfiguredSource
+ // 1. Try exact ID match first
+ if p.SourceID != "" {
+ for j := range sources {
+ if sources[j].ID == p.SourceID {
+ copySource := sources[j]
+ PrepareConfiguredSource(©Source)
+ matchedSource = ©Source
- for j := range sources {
- s := sources[j]
- if s.ID == p.SourceID || s.SourceKeyType == p.Source {
- // Use a new variable to avoid pointer-to-iterator-variable bug
- copySource := s
- PrepareConfiguredSource(©Source)
- matchedSource = ©Source
+ break
+ }
+ }
+ }
- break
+ // 2. Fallback to type/account match if ID didn't match or was empty
+ if matchedSource == nil {
+ for j := range sources {
+ s := sources[j]
+ if s.SourceKeyType == p.Source && (p.SourceAccount == "" || s.SourceKeyAccount == p.SourceAccount) {
+ copySource := s
+ PrepareConfiguredSource(©Source)
+ matchedSource = ©Source
+
+ break
+ }
}
}
@@ -805,6 +826,14 @@ func mapPresetsToFullResponse(presets []models.ServicePreset, sources []models.C
UpdatedOn: p.UpdatedOn,
Username: p.Username,
}
+ if fullPreset.Username == "" {
+ fullPreset.Username = p.Name
+ }
+
+ if fullPreset.ContentItemType == "" && p.Type != "" {
+ fullPreset.ContentItemType = p.Type
+ }
+
if matchedSource != nil {
fullPreset.Source = mapToFullResponseSource(*matchedSource)
}
@@ -829,16 +858,30 @@ func mapRecentsToFullResponse(recents []models.ServiceRecent, sources []models.C
}
var matchedSource *models.ConfiguredSource
+ // 1. Try exact ID match first
+ if r.SourceID != "" {
+ for j := range sources {
+ if sources[j].ID == r.SourceID {
+ copySource := sources[j]
+ PrepareConfiguredSource(©Source)
+ matchedSource = ©Source
- for j := range sources {
- s := sources[j]
- if s.ID == r.SourceID || s.SourceKeyType == r.Source {
- // Use a new variable to avoid pointer-to-iterator-variable bug
- copySource := s
- PrepareConfiguredSource(©Source)
- matchedSource = ©Source
+ break
+ }
+ }
+ }
- break
+ // 2. Fallback to type/account match if ID didn't match or was empty
+ if matchedSource == nil {
+ for j := range sources {
+ s := sources[j]
+ if s.SourceKeyType == r.Source && (r.SourceAccount == "" || s.SourceKeyAccount == r.SourceAccount) {
+ copySource := s
+ PrepareConfiguredSource(©Source)
+ matchedSource = ©Source
+
+ break
+ }
}
}
@@ -851,10 +894,27 @@ func mapRecentsToFullResponse(recents []models.ServiceRecent, sources []models.C
Name: r.Name,
SourceID: r.SourceID,
UpdatedOn: r.UpdatedOn,
- Username: r.Name,
+ Username: r.Username,
}
+ if fullRecent.LastPlayedAt == "" && r.UtcTime != "" {
+ if ut, err := strconv.ParseInt(r.UtcTime, 10, 64); err == nil {
+ fullRecent.LastPlayedAt = time.Unix(ut, 0).UTC().Format("2006-01-02T15:04:05.000+00:00")
+ }
+ }
+
+ if fullRecent.Username == "" {
+ fullRecent.Username = r.Name
+ }
+
+ if fullRecent.ContentItemType == "" && r.Type != "" {
+ fullRecent.ContentItemType = r.Type
+ }
+
if matchedSource != nil {
fullRecent.Source = mapToFullResponseSource(*matchedSource)
+ if fullRecent.SourceID == "" {
+ fullRecent.SourceID = fullRecent.Source.ID
+ }
}
fullRecents = append(fullRecents, fullRecent)
@@ -1041,7 +1101,7 @@ func AccountFullToXML(ds *datastore.DataStore, account string) ([]byte, error) {
resp := models.AccountFullResponse{
ID: account,
- AccountStatus: "ACTIVE",
+ AccountStatus: "OK",
Mode: "global",
PreferredLanguage: "en",
}
@@ -1189,12 +1249,12 @@ func UpdatePreset(ds *datastore.DataStore, account, device string, presetNumber
SourceName: newPresetElem.Name,
},
})
- presetObj.SourceConfig = matchingSrc
+ presetObj.SourceID = matchingSrc.ID
presetObj.Username = newPresetElem.Name
// Parity: return the preset wrapped in
px := PresetsXML{
- Presets: []models.ServicePreset{presetObj},
+ Presets: []presetParityXML{*mapPresetToParityXML(presetObj, sources)},
}
data, err := xml.Marshal(px)
diff --git a/pkg/service/marge/marge_test.go b/pkg/service/marge/marge_test.go
index 9852896..bde0d27 100644
--- a/pkg/service/marge/marge_test.go
+++ b/pkg/service/marge/marge_test.go
@@ -244,7 +244,7 @@ func TestAccountFullToXML_Structure(t *testing.T) {
}
// Global Sources
- if !strings.Contains(xmlStr, ``) {
+ if !strings.Contains(xmlStr, ``) {
t.Errorf("Expected source tag with displayName attribute, got %s", xmlStr)
}
if !strings.Contains(xmlStr, `test-user`) {
@@ -428,13 +428,14 @@ func TestPresetsToXML_SourceIncluded(t *testing.T) {
presets := []models.ServicePreset{
{
ServiceContentItem: models.ServiceContentItem{
- ID: "1",
- Name: "Test Preset",
- SourceID: "100001",
- Source: "SPOTIFY",
- SourceAccount: "testuser",
- Type: "tracklisturl",
- Location: "/test",
+ ID: "1",
+ Name: "Test Preset",
+ SourceID: "100001",
+ Source: "SPOTIFY",
+ SourceAccount: "testuser",
+ Type: "tracklisturl",
+ ContentItemType: "tracklisturl",
+ Location: "/test",
},
ID: "1",
},
@@ -746,9 +747,9 @@ func TestAccountFullToXML_WithBackupStructure(t *testing.T) {
presetsXML := `
-
+
https://i.scdn.co/image/art
-
+
`
_ = os.WriteFile(filepath.Join(presetsDir, "Presets.xml"), []byte(presetsXML), 0644)
@@ -766,7 +767,7 @@ func TestAccountFullToXML_WithBackupStructure(t *testing.T) {
// 3. Test with empty name
_ = os.WriteFile(filepath.Join(deviceDir, "DeviceInfo.xml"), []byte(``), 0644)
fullXML2, _ := AccountFullToXML(ds, account)
- if !strings.Contains(string(fullXML2), ``) && !strings.Contains(string(fullXML2), ``) && !strings.Contains(string(fullXML2), `SoundTouch`) && !strings.Contains(string(fullXML2), `PANDORA`) {
+ if !strings.Contains(string(fullXML2), ``) && !strings.Contains(string(fullXML2), ``) && !strings.Contains(string(fullXML2), `SoundTouch`) && !strings.Contains(string(fullXML2), `PANDORA`) && !strings.Contains(string(fullXML2), `001122334455`) {
t.Errorf("Expected or or fallback name, got %s", string(fullXML2))
}
}
diff --git a/pkg/service/marge/repro_test.go b/pkg/service/marge/repro_test.go
index 055df63..a5acbb4 100644
--- a/pkg/service/marge/repro_test.go
+++ b/pkg/service/marge/repro_test.go
@@ -415,15 +415,8 @@ func TestSyncSourcesAttributes(t *testing.T) {
}
lp := presets[0]
- if lp.SourceConfig == nil {
- t.Fatal("SourceConfig is nil for synced preset")
- }
-
- if lp.SourceConfig.ID != "10863533" {
- t.Errorf("Synced preset source ID mismatch: expected 10863533, got '%s'", lp.SourceConfig.ID)
- }
- if lp.SourceConfig.Type != "Audio" {
- t.Errorf("Synced preset source Type mismatch: expected Audio, got '%s'", lp.SourceConfig.Type)
+ if lp.SourceID != "10863533" {
+ t.Errorf("Synced preset source ID mismatch: expected 10863533, got '%s'", lp.SourceID)
}
recents, err := ds.GetRecents("1234567", "08DF1F0BA325")
@@ -434,14 +427,8 @@ func TestSyncSourcesAttributes(t *testing.T) {
t.Fatal("No recents found in datastore after sync")
}
lr := recents[0]
- if lr.SourceConfig == nil {
- t.Fatal("SourceConfig is nil for synced recent")
- }
- if lr.SourceConfig.ID != "10863533" {
- t.Errorf("Synced recent source ID mismatch: expected 10863533, got '%s'", lr.SourceConfig.ID)
- }
- if lr.SourceConfig.Type != "Audio" {
- t.Errorf("Synced recent source Type mismatch: expected Audio, got '%s'", lr.SourceConfig.Type)
+ if lr.SourceID != "10863533" {
+ t.Errorf("Synced recent source ID mismatch: expected 10863533, got '%s'", lr.SourceID)
}
}
diff --git a/pkg/service/marge/sync.go b/pkg/service/marge/sync.go
index 21f8d45..1b2fe2c 100644
--- a/pkg/service/marge/sync.go
+++ b/pkg/service/marge/sync.go
@@ -178,31 +178,20 @@ func syncPresets(ds *datastore.DataStore, accountID, deviceID string, presetsSou
p := &presetsSource[i]
preset := models.ServicePreset{
ServiceContentItem: models.ServiceContentItem{
+ ID: p.ButtonNumber,
ContentItemType: p.ContentItemType,
Location: p.Location,
Name: p.Name,
Source: p.Source.Type,
SourceID: p.Source.ID,
SourceAccount: p.Source.Username,
+ Type: p.ContentItemType,
},
ButtonNumber: p.ButtonNumber,
ID: p.ButtonNumber,
CreatedOn: p.CreatedOn,
UpdatedOn: p.UpdatedOn,
ContainerArt: p.ContainerArt,
- SourceConfig: &models.ConfiguredSource{
- ID: p.Source.ID,
- Type: p.Source.Type,
- CreatedOn: p.Source.CreatedOn,
- UpdatedOn: p.Source.UpdatedOn,
- SourceName: p.Source.SourceName,
- DisplayName: p.Source.Name,
- Name: p.Source.Name,
- SourceProviderID: p.Source.SourceProviderID,
- Secret: p.Source.Credential.Value,
- SecretType: p.Source.Credential.Type,
- Username: p.Source.Username,
- },
}
presets = append(presets, preset)
}
@@ -226,23 +215,11 @@ func syncRecents(ds *datastore.DataStore, accountID, deviceID string, recentsSou
Source: r.Source.Type,
SourceID: r.Source.ID,
SourceAccount: r.Source.Username,
+ Type: r.ContentItemType,
},
CreatedOn: r.CreatedOn,
UpdatedOn: r.UpdatedOn,
LastPlayedAt: r.LastPlayedAt,
- SourceConfig: &models.ConfiguredSource{
- ID: r.Source.ID,
- Type: r.Source.Type,
- CreatedOn: r.Source.CreatedOn,
- UpdatedOn: r.Source.UpdatedOn,
- SourceName: r.Source.SourceName,
- DisplayName: r.Source.Name,
- Name: r.Source.Name,
- SourceProviderID: r.Source.SourceProviderID,
- Secret: r.Source.Credential.Value,
- SecretType: r.Source.Credential.Type,
- Username: r.Source.Username,
- },
}
recents = append(recents, recent)
}
diff --git a/pkg/service/setup/setup.go b/pkg/service/setup/setup.go
index 2745e51..d4fd4b5 100644
--- a/pkg/service/setup/setup.go
+++ b/pkg/service/setup/setup.go
@@ -2212,10 +2212,10 @@ func (m *Manager) syncRecents(deviceIP, accountID, deviceID string) {
SourceAccount: r.ContentItem.SourceAccount,
SourceID: "", // RecentsResponseItem doesn't have SourceID usually
IsPresetable: strconv.FormatBool(r.ContentItem.IsPresetable),
+ ContainerArt: r.ContentItem.ContainerArt,
},
- DeviceID: r.DeviceID,
- UtcTime: strconv.FormatInt(r.UTCTime, 10),
- ContainerArt: r.ContentItem.ContainerArt,
+ DeviceID: r.DeviceID,
+ UtcTime: strconv.FormatInt(r.UTCTime, 10),
})
}
diff --git a/tests/integration/http-client/spotify_full_flow.http b/tests/integration/http-client/spotify_full_flow.http
index d723947..17ba65b 100644
--- a/tests/integration/http-client/spotify_full_flow.http
+++ b/tests/integration/http-client/spotify_full_flow.http
@@ -77,7 +77,15 @@ GET {{host}}/accounts/{{accountId}}/sources
client.assert(id !== undefined && id !== "", "Spotify source ID missing");
client.global.set("spotify_source_id", id);
- const secretType = spotifySource.getAttribute("secretType");
+ function getCredentialType(source) {
+ const credentials = source.getElementsByTagName("credential");
+ if (credentials && credentials.length > 0) {
+ return credentials[0].getAttribute("type");
+ }
+ return null;
+ }
+
+ const secretType = getCredentialType(spotifySource);
client.assert(secretType === "token_version_3", "Wrong secret type in Marge: " + secretType);
});
%}