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