Improve parity with upstream (#155)

See https://github.com/gesellix/Bose-SoundTouch/issues/135
This commit is contained in:
Tobias Gesellchen
2026-04-07 14:44:05 +02:00
committed by GitHub
parent 276d01fe42
commit 68f8efce4e
15 changed files with 918 additions and 234 deletions
+65 -51
View File
@@ -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"`
+142 -24
View File
@@ -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>`) {
+57 -40
View File
@@ -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)
+8 -9
View File
@@ -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>`
+448
View File
@@ -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)
}
}
+51
View File
@@ -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
View File
@@ -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(&copySource)
matchedSource = &copySource
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(&copySource)
matchedSource = &copySource
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(&copySource)
matchedSource = &copySource
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(&copySource)
matchedSource = &copySource
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(&copySource)
matchedSource = &copySource
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(&copySource)
matchedSource = &copySource
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)
+12 -11
View File
@@ -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))
}
}
+4 -17
View File
@@ -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)
}
}
+3 -26
View File
@@ -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)
}
+3 -3
View File
@@ -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),
})
}