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:
Tobias Gesellchen
2026-05-20 22:41:32 +02:00
co-authored by Claude Sonnet 4.6
parent ce2935a4bd
commit 9fafe9f960
3 changed files with 105 additions and 2 deletions
+20
View File
@@ -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
+19 -2
View File
@@ -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)
}
})
}
}