mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-19 00:56:16 +00:00
fix(marge): syncPresets/syncRecents persist speaker-perspective Source
marge.syncPresets / syncRecents were writing the upstream cloud's <source type="Audio"> attribute into ServicePreset.Source / ServiceRecent.Source on disk. That's a protocol-level classification, not the symbolic name the speaker itself uses (TUNEIN, INTERNET_RADIO, …). The on-disk shape ended up disagreeing with what the speaker writes via its own /presets endpoint, which IS the source of truth — and the disagreement surfaced as cross-side mismatches in the new consistency check (one user saw 30+ recent_mismatch findings, all "speaker source=X vs service source=Audio"). Project the upstream FullResponseSource back to the speaker's perspective at persist time via SourceProviderID lookup against StaticProviders (the inverse of canonicalProviderIDByID). Falls back to the upstream Type for unknown providerids so non-canonical sources stay no-worse-than-before. The consistency-check workaround in loadServiceView (which resolves Source via SourceID lookup on read) stays in place to cover legacy on-disk data written by the previous behaviour — that data only gets cleaned up when the operator re-runs setup.syncPresets from the speaker directly. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
ce2935a4bd
commit
9fafe9f960
@@ -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 <source> block when a preset references a source we no
|
||||
|
||||
@@ -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 <source type="Audio"> 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,
|
||||
|
||||
@@ -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 <type>Audio</type> + a numeric
|
||||
// <sourceproviderid>) 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user