diff --git a/pkg/service/marge/marge.go b/pkg/service/marge/marge.go index ddfa76f..8cab3e9 100644 --- a/pkg/service/marge/marge.go +++ b/pkg/service/marge/marge.go @@ -793,6 +793,26 @@ func canonicalProviderIDByID(id string) string { return "" } +// canonicalSourceKeyTypeByProviderID is the inverse of the canonical +// sourceproviderid mapping used in /full responses (25 = TuneIn, 11 = +// LocalInternetRadio, …). Used by syncPresets/syncRecents to project the +// upstream cloud /full perspective ("Audio" + providerid=25) back onto +// the speaker's perspective ("TUNEIN") at persist time, so the on-disk +// ServicePreset/ServiceRecent.Source matches what the speaker itself +// would write via setup.syncPresets (which is the source of truth). +// +// Returns "" for unknown / non-canonical provider IDs; the caller is +// expected to fall back to whatever upstream gave us. +func canonicalSourceKeyTypeByProviderID(providerID string) string { + for i := range constants.StaticProviders { + if strconv.Itoa(constants.StaticProviders[i].ID) == providerID { + return constants.StaticProviders[i].Name + } + } + + return "" +} + // canonicalDefaultsByType returns the canonical (built-in) source ID and // SourceProviderID for a well-known provider key type. Used to synthesise a // minimum-viable block when a preset references a source we no diff --git a/pkg/service/marge/sync.go b/pkg/service/marge/sync.go index 77e676c..c9c007b 100644 --- a/pkg/service/marge/sync.go +++ b/pkg/service/marge/sync.go @@ -185,7 +185,7 @@ func syncPresets(ds *datastore.DataStore, accountID, deviceID string, presetsSou ContentItemType: p.ContentItemType, Location: p.Location, Name: p.Name, - Source: p.Source.Type, + Source: sourceKeyTypeFromFullSource(p.Source), SourceID: p.Source.ID, SourceAccount: p.Source.Username, Type: p.ContentItemType, @@ -215,7 +215,7 @@ func syncRecents(ds *datastore.DataStore, accountID, deviceID string, recentsSou ContentItemType: r.ContentItemType, Location: r.Location, Name: r.Name, - Source: r.Source.Type, + Source: sourceKeyTypeFromFullSource(r.Source), SourceID: r.Source.ID, SourceAccount: r.Source.Username, Type: r.ContentItemType, @@ -232,6 +232,23 @@ func syncRecents(ds *datastore.DataStore, accountID, deviceID string, recentsSou } } +// sourceKeyTypeFromFullSource derives the speaker-perspective SourceKeyType +// ("TUNEIN", "INTERNET_RADIO", …) from an upstream FullResponseSource. +// The upstream attribute is a protocol-level +// classification, not the symbolic name the speaker stores locally — +// persisting "Audio" into ServicePreset.Source means the on-disk shape +// doesn't match what the speaker would write via its own /presets +// (which is the source of truth). Falls back to whatever upstream gave +// us when the providerid isn't one of the canonical built-ins; that +// keeps the legacy/poisoned-data path no-worse-than-before. +func sourceKeyTypeFromFullSource(s models.FullResponseSource) string { + if t := canonicalSourceKeyTypeByProviderID(s.SourceProviderID); t != "" { + return t + } + + return s.Type +} + func mapFullSourceToConfiguredSource(s models.FullResponseSource) models.ConfiguredSource { dsrc := models.ConfiguredSource{ ID: s.ID, diff --git a/pkg/service/marge/sync_sourcekeytype_test.go b/pkg/service/marge/sync_sourcekeytype_test.go new file mode 100644 index 0000000..93cf4c7 --- /dev/null +++ b/pkg/service/marge/sync_sourcekeytype_test.go @@ -0,0 +1,66 @@ +package marge + +import ( + "testing" + + "github.com/gesellix/bose-soundtouch/pkg/models" +) + +// TestSourceKeyTypeFromFullSource pins the projection of an upstream +// FullResponseSource (which carries Audio + a numeric +// ) back to the speaker-perspective SourceKeyType +// ("TUNEIN", "INTERNET_RADIO", …). syncPresets/syncRecents persist the +// projected value so the on-disk ServicePreset.Source matches what the +// speaker would write via its own /presets endpoint — speaker is the +// source of truth. +func TestSourceKeyTypeFromFullSource(t *testing.T) { + cases := []struct { + name string + in models.FullResponseSource + want string + }{ + { + name: "tunein providerid 25 -> TUNEIN", + in: models.FullResponseSource{Type: "Audio", SourceProviderID: "25"}, + want: "TUNEIN", + }, + { + name: "internet_radio providerid 2 -> INTERNET_RADIO", + in: models.FullResponseSource{Type: "Audio", SourceProviderID: "2"}, + want: "INTERNET_RADIO", + }, + { + name: "local_internet_radio providerid 11 -> LOCAL_INTERNET_RADIO", + in: models.FullResponseSource{Type: "Audio", SourceProviderID: "11"}, + want: "LOCAL_INTERNET_RADIO", + }, + { + name: "spotify providerid 15 -> SPOTIFY", + in: models.FullResponseSource{Type: "Audio", SourceProviderID: "15"}, + want: "SPOTIFY", + }, + { + name: "radio_browser providerid 39 -> RADIO_BROWSER", + in: models.FullResponseSource{Type: "Audio", SourceProviderID: "39"}, + want: "RADIO_BROWSER", + }, + { + name: "unknown providerid falls back to upstream Type", + in: models.FullResponseSource{Type: "Audio", SourceProviderID: "99999"}, + want: "Audio", + }, + { + name: "empty providerid falls back to upstream Type", + in: models.FullResponseSource{Type: "Audio", SourceProviderID: ""}, + want: "Audio", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := sourceKeyTypeFromFullSource(tc.in); got != tc.want { + t.Errorf("got %q, want %q", got, tc.want) + } + }) + } +}