mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-19 00:56:16 +00:00
Improve parity with upstream (#155)
See https://github.com/gesellix/Bose-SoundTouch/issues/135
This commit is contained in:
+65
-51
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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, `<credential type="token_version_3">dummy-token-spotify</credential>`) {
|
||||
t.Errorf("Spotify source missing <credential> 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, `<sourceKey type="SPOTIFY" account="test-user" />`) &&
|
||||
!strings.Contains(xmlContent, `<sourceKey type="SPOTIFY" account="test-user"></sourceKey>`) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -108,9 +108,9 @@ func TestHandleMgmtAccountDetails_Recents(t *testing.T) {
|
||||
presetsXML := `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<presets>
|
||||
<preset id="1" createdOn="1690000001">
|
||||
<ContentItem source="SPOTIFY" type="tracklisturl" sourceAccount="test-user">
|
||||
<contentItem source="SPOTIFY" type="tracklisturl" sourceAccount="test-user">
|
||||
<itemName>test-playlist</itemName>
|
||||
</ContentItem>
|
||||
</contentItem>
|
||||
</preset>
|
||||
</presets>`
|
||||
err = os.WriteFile(deviceDir+"/Presets.xml", []byte(presetsXML), 0644)
|
||||
|
||||
@@ -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) {
|
||||
"<source id=\"10004\" type=\"Audio\"",
|
||||
"<source id=\"10003\" type=\"Audio\"",
|
||||
"<source id=\"10002\" type=\"Audio\"",
|
||||
"<source id=\"10001\" type=\"Audio\" displayName=\"AUX IN\" secretType=\"token\">",
|
||||
"displayName=\"\"", // for the other sources
|
||||
"<source id=\"10001\" type=\"Audio\"",
|
||||
}
|
||||
|
||||
for _, snippet := range expectedSnippets {
|
||||
@@ -525,9 +524,9 @@ func TestMargeAccountSourcesNoDevices(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
// Verify that no sources have empty display names
|
||||
if strings.Count(bodyStr, "displayName=\"\"") != 0 {
|
||||
t.Errorf("Expected no sources with empty displayName, got %d: %s", strings.Count(bodyStr, "displayName=\"\""), bodyStr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -571,10 +570,10 @@ func TestMargePresets(t *testing.T) {
|
||||
if err := os.WriteFile(filepath.Join(deviceDir, "Presets.xml"), []byte(`
|
||||
<presets>
|
||||
<preset id="1">
|
||||
<ContentItem source="TUNEIN" type="station" location="/station/s123" sourceAccount="" isPresetable="true">
|
||||
<contentItem source="TUNEIN" type="station" location="/station/s123" sourceAccount="" isPresetable="true">
|
||||
<itemName>Test Station</itemName>
|
||||
<containerArt>http://example.com/art.jpg</containerArt>
|
||||
</ContentItem>
|
||||
</contentItem>
|
||||
</preset>
|
||||
</presets>
|
||||
`), 0644); err != nil {
|
||||
|
||||
@@ -30,10 +30,10 @@ func TestMargeParityRegressions(t *testing.T) {
|
||||
// One with "Other" and one with a specific name.
|
||||
sourcesXML := `
|
||||
<sources>
|
||||
<source id="14774275" displayName="Other" secret="" secretType="Audio">
|
||||
<source id="14774275" displayName="Other" secret="">
|
||||
<sourceKey type="TUNEIN" account=""/>
|
||||
</source>
|
||||
<source id="SPOT1" displayName="My Spotify" secret="token123" secretType="Audio">
|
||||
<source id="SPOT1" displayName="My Spotify" secret="token123">
|
||||
<sourceKey type="SPOTIFY" account="user123"/>
|
||||
</source>
|
||||
</sources>`
|
||||
|
||||
@@ -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 := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<sources>
|
||||
<source id="1001" type="Audio" secret="legacy-token" secretType="token">
|
||||
<credential type="token">new-token</credential>
|
||||
<sourceKey type="SPOTIFY" account="user1" />
|
||||
</source>
|
||||
<source id="1002" type="Audio" secret="only-legacy" secretType="token">
|
||||
<sourceKey type="TUNEIN" account="user2" />
|
||||
</source>
|
||||
<source id="1003" type="Audio">
|
||||
<credential type="token_version_3">only-new</credential>
|
||||
<sourceKey type="SPOTIFY" account="user3" />
|
||||
</source>
|
||||
</sources>`
|
||||
_ = 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, `<source id="1001" type="Audio">`) {
|
||||
t.Errorf("Missing source 1001 in XML")
|
||||
}
|
||||
// Spotify with 'token' is upgraded to 'token_version_3' in mapToFullResponseSource
|
||||
if !strings.Contains(xmlStr, `<credential type="token_version_3">new-token</credential>`) {
|
||||
t.Errorf("Source 1001: missing expected credential. XML: %s", xmlStr)
|
||||
}
|
||||
|
||||
// Check 1002: should have only-legacy
|
||||
if !strings.Contains(xmlStr, `<source id="1002" type="Audio">`) {
|
||||
t.Errorf("Missing source 1002 in XML")
|
||||
}
|
||||
if !strings.Contains(xmlStr, `<credential type="token">only-legacy</credential>`) {
|
||||
t.Errorf("Source 1002: missing expected credential. XML: %s", xmlStr)
|
||||
}
|
||||
|
||||
// Check 1003: should have only-new
|
||||
if !strings.Contains(xmlStr, `<source id="1003" type="Audio">`) {
|
||||
t.Errorf("Missing source 1003 in XML")
|
||||
}
|
||||
if !strings.Contains(xmlStr, `<credential type="token_version_3">only-new</credential>`) {
|
||||
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 := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<sources>
|
||||
<source id="9330201" type="Audio">
|
||||
<credential type="token"></credential>
|
||||
<sourceKey type="Audio" account=""></sourceKey>
|
||||
</source>
|
||||
<source id="14774275" secret="token-value" secretType="token" type="Audio">
|
||||
<credential type="token">token-value</credential>
|
||||
<sourceKey type="Audio" account=""></sourceKey>
|
||||
</source>
|
||||
</sources>`
|
||||
_ = os.WriteFile(filepath.Join(deviceDir, "Sources.xml"), []byte(sourcesXML), 0644)
|
||||
|
||||
// 2. Setup Recents.xml
|
||||
recentsXML := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<recents>
|
||||
<recent id="2270445222">
|
||||
<contentItem source="Audio" type="" location="/v1/playback/episodes/t104218136" sourceAccount="" isPresetable="">
|
||||
<itemName>Atemlos durch die Charts</itemName>
|
||||
</contentItem>
|
||||
<createdOn>2019-07-29T15:29:59.000+00:00</createdOn>
|
||||
<updatedOn>2019-07-29T15:29:59.000+00:00</updatedOn>
|
||||
<lastplayedat>2019-07-29T11:29:54.000+00:00</lastplayedat>
|
||||
<sourceid>14774275</sourceid>
|
||||
<username>Atemlos durch die Charts</username>
|
||||
</recent>
|
||||
</recents>`
|
||||
_ = os.WriteFile(filepath.Join(deviceDir, "Recents.xml"), []byte(recentsXML), 0644)
|
||||
|
||||
// Setup DeviceInfo.xml so CreateAccountDevice works
|
||||
deviceInfoXML := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<info deviceID="DEV123">
|
||||
<name>Test Device</name>
|
||||
<type>SoundTouch 10</type>
|
||||
</info>`
|
||||
_ = 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, `<sourceid>14774275</sourceid>`) {
|
||||
t.Errorf("/recents response should have sourceid 14774275. XML: %s", recentsStr)
|
||||
}
|
||||
if !strings.Contains(recentsStr, `<credential type="token">token-value</credential>`) {
|
||||
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, `<recent id="2270445222">`) {
|
||||
t.Errorf("/full response missing recent item. XML: %s", fullStr)
|
||||
}
|
||||
if !strings.Contains(fullStr, `<sourceid>14774275</sourceid>`) {
|
||||
t.Errorf("/full response should have sourceid 14774275. XML: %s", fullStr)
|
||||
}
|
||||
if !strings.Contains(fullStr, `<credential type="token">token-value</credential>`) {
|
||||
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, `<credential type="token_version_3">secret-val</credential>`) {
|
||||
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 := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<info deviceID="ABC">
|
||||
<name>Test Device</name>
|
||||
<type>SoundTouch 10</type>
|
||||
<components>
|
||||
<component componentCategory="SCM">
|
||||
<softwareVersion>1.2.3</softwareVersion>
|
||||
<serialNumber>ABC123</serialNumber>
|
||||
</component>
|
||||
</components>
|
||||
</info>`
|
||||
_ = 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 := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<recents>
|
||||
<recent id="1" deviceID="ABC" utcTime="123456789">
|
||||
<contentItem source="SPOTIFY" type="track" location="spotify:track:123" sourceAccount="user-recent" isPresetable="true" itemName="Recent Track" />
|
||||
</recent>
|
||||
</recents>`
|
||||
_ = 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, `<recent id="1">`) {
|
||||
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 <recent> element's nested <source> has the <credential>.
|
||||
// Simple way to check: is there at least TWO occurrences of the credential?
|
||||
// One in <sources><source> and one in <recents><recent><source>.
|
||||
count := strings.Count(xmlStr, `<credential type="token_version_3">recent-token</credential>`)
|
||||
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 := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<info deviceID="ABC">
|
||||
<name>Test Device</name>
|
||||
<components><component componentCategory="SCM"><serialNumber>ABC123</serialNumber></component></components>
|
||||
</info>`
|
||||
_ = 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 := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<recents>
|
||||
<recent id="1" deviceID="ABC" utcTime="123456789">
|
||||
<contentItem source="SPOTIFY" type="track" location="spotify:track:123" sourceAccount="user-2" isPresetable="true" itemName="User 2 Track" />
|
||||
</recent>
|
||||
</recents>`
|
||||
_ = 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, `<recent id="1">`) {
|
||||
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, `<credential type="token_version_3">token-2</credential>`) {
|
||||
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 := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<info deviceID="ABC">
|
||||
<name>Test Device</name>
|
||||
<type>SoundTouch 10</type>
|
||||
<components><component componentCategory="SCM"><serialNumber>ABC123</serialNumber></component></components>
|
||||
</info>`
|
||||
_ = os.WriteFile(filepath.Join(deviceDir, "DeviceInfo.xml"), []byte(deviceInfoXML), 0644)
|
||||
|
||||
// 1. Setup Sources.xml
|
||||
sourcesXML := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<sources>
|
||||
<source id="100" type="TUNEIN">
|
||||
<credential type="token"></credential>
|
||||
<sourceKey type="TUNEIN" account=""></sourceKey>
|
||||
</source>
|
||||
</sources>`
|
||||
_ = os.WriteFile(filepath.Join(deviceDir, "Sources.xml"), []byte(sourcesXML), 0644)
|
||||
|
||||
// 2. Setup Presets.xml and Recents.xml with contentItem elements
|
||||
presetsXML := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<presets>
|
||||
<preset id="1" createdOn="123456789" updatedOn="123456789">
|
||||
<contentItem source="TUNEIN" type="stationurl" location="/v1/playback/stations/s166521" sourceAccount="" isPresetable="true">
|
||||
<itemName>Station Name</itemName>
|
||||
</contentItem>
|
||||
</preset>
|
||||
</presets>`
|
||||
_ = os.WriteFile(filepath.Join(deviceDir, "Presets.xml"), []byte(presetsXML), 0644)
|
||||
|
||||
recentsXML := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<recents>
|
||||
<recent id="1" deviceID="ABC" utcTime="123456789">
|
||||
<contentItem source="TUNEIN" type="stationurl" location="/v1/playback/stations/s166521" sourceAccount="" isPresetable="true">
|
||||
<itemName>Station Name</itemName>
|
||||
</contentItem>
|
||||
</recent>
|
||||
</recents>`
|
||||
_ = 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, `<contentItemType>stationurl</contentItemType>`) {
|
||||
t.Errorf("Missing expected <contentItemType>stationurl</contentItemType> in XML. XML: %s", xmlStr)
|
||||
}
|
||||
|
||||
// It should appear twice: once in preset, once in recent
|
||||
count := strings.Count(xmlStr, `<contentItemType>stationurl</contentItemType>`)
|
||||
if count != 2 {
|
||||
t.Errorf("Expected <contentItemType>stationurl</contentItemType> to appear twice, got %d. XML: %s", count, xmlStr)
|
||||
}
|
||||
|
||||
// Verify itemName is present
|
||||
if !strings.Contains(xmlStr, `<name>Station Name</name>`) {
|
||||
t.Errorf("Missing expected <name>Station Name</name> in XML. XML: %s", xmlStr)
|
||||
}
|
||||
|
||||
// Verify location is present
|
||||
if !strings.Contains(xmlStr, `<location>/v1/playback/stations/s166521</location>`) {
|
||||
t.Errorf("Missing expected <location>/v1/playback/stations/s166521</location> in XML. XML: %s", xmlStr)
|
||||
}
|
||||
}
|
||||
@@ -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, "<lastplayedat>"+expectedLastPlayedAt+"</lastplayedat>") {
|
||||
t.Errorf("XML missing expected lastplayedat tag: %s", xmlStr)
|
||||
}
|
||||
}
|
||||
+105
-45
@@ -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 <presets>
|
||||
px := PresetsXML{
|
||||
Presets: []models.ServicePreset{presetObj},
|
||||
Presets: []presetParityXML{*mapPresetToParityXML(presetObj, sources)},
|
||||
}
|
||||
|
||||
data, err := xml.Marshal(px)
|
||||
|
||||
@@ -244,7 +244,7 @@ func TestAccountFullToXML_Structure(t *testing.T) {
|
||||
}
|
||||
|
||||
// Global Sources
|
||||
if !strings.Contains(xmlStr, `<source id="10863533" type="Audio" displayName="test-user" secretType="token_version_3">`) {
|
||||
if !strings.Contains(xmlStr, `<source id="10863533" type="Audio" displayName="test-user">`) {
|
||||
t.Errorf("Expected source tag with displayName attribute, got %s", xmlStr)
|
||||
}
|
||||
if !strings.Contains(xmlStr, `<name>test-user</name>`) {
|
||||
@@ -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 := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<presets>
|
||||
<preset id="1" createdOn="1719128436" updatedOn="1728740382">
|
||||
<ContentItem source="SPOTIFY" type="tracklisturl" location="/playback/container/c3BvdGlmeTpwbGF5bGlzdDo1Mm5QaVJrbWVmSkZPeHh1M1ZTd1hh" itemName="test-playlist" isPresetable="true" contentItemType="tracklisturl">
|
||||
<contentItem source="SPOTIFY" type="tracklisturl" location="/playback/container/c3BvdGlmeTpwbGF5bGlzdDo1Mm5QaVJrbWVmSkZPeHh1M1ZTd1hh" itemName="test-playlist" isPresetable="true" contentItemType="tracklisturl">
|
||||
<containerArt>https://i.scdn.co/image/art</containerArt>
|
||||
</ContentItem>
|
||||
</contentItem>
|
||||
</preset>
|
||||
</presets>`
|
||||
_ = 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(`<?xml version="1.0" encoding="UTF-8"?><info deviceID="001122334455"><name></name></info>`), 0644)
|
||||
fullXML2, _ := AccountFullToXML(ds, account)
|
||||
if !strings.Contains(string(fullXML2), `<name/>`) && !strings.Contains(string(fullXML2), `<name></name>`) && !strings.Contains(string(fullXML2), `<name>SoundTouch`) && !strings.Contains(string(fullXML2), `<name>PANDORA`) {
|
||||
if !strings.Contains(string(fullXML2), `<name/>`) && !strings.Contains(string(fullXML2), `<name></name>`) && !strings.Contains(string(fullXML2), `<name>SoundTouch`) && !strings.Contains(string(fullXML2), `<name>PANDORA`) && !strings.Contains(string(fullXML2), `<name>001122334455</name>`) {
|
||||
t.Errorf("Expected <name/> or <name></name> or fallback name, got %s", string(fullXML2))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user