fix(datastore): stop INTERNET_RADIO from being re-added on service restart

initializeDefaultSources() called GetDefaultSources(), which includes
the legacy INTERNET_RADIO stub (ID 10002). On every service start it
would re-add that entry to any device whose Sources.xml had it removed
— including devices where the stale_internet_radio health-check quick
fix was applied — silently undoing the clean-up.

getAccountSources() in marge.go had the same issue: it passed the full
default list into the /full cloud response, causing a phantom
"sources_xml_diff" Info finding after a clean-up.

Fix: export the existing private getInitialSources() as
GetInitialSources() (excludes INTERNET_RADIO) and use it in both call
sites instead of GetDefaultSources().

Existing devices that still have INTERNET_RADIO in their Sources.xml
are unaffected: the merge loop only appends entries that are missing,
so a present entry is preserved (the token is refreshed as before).

Update unit and integration test expectations accordingly: the no-device
fallback now returns 3 cloud sources (LOCAL_INTERNET_RADIO, TUNEIN,
RADIO_BROWSER) instead of 4 (dropping INTERNET_RADIO / ID 10002).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-05-26 22:51:35 +02:00
co-authored by Claude Sonnet 4.6
parent 6c4c420b29
commit aafc5ba3f9
6 changed files with 37 additions and 16 deletions
+1 -1
View File
@@ -77,7 +77,7 @@ func initializeDefaultSources(ds *datastore.DataStore) {
// claimed tracks which stored sources have already been matched by a default,
// so two defaults with the same SourceKeyType but different SourceProviderIDs
// (e.g. INTERNET_RADIO/2 and INTERNET_RADIO/39) are treated as distinct entries.
defaults := ds.GetDefaultSources()
defaults := ds.GetInitialSources()
modified := false
claimed := make(map[int]bool)
+9
View File
@@ -2173,6 +2173,15 @@ func (ds *DataStore) GetDefaultSources() []models.ConfiguredSource {
return ds.getDefaultSources()
}
// GetInitialSources returns the default sources for a brand-new device:
// the full default list minus the legacy INTERNET_RADIO stub.
// Use this (not GetDefaultSources) whenever deciding which sources to add
// to a device — re-adding INTERNET_RADIO to existing devices would silently
// undo the stale_internet_radio health-check quick-fix.
func (ds *DataStore) GetInitialSources() []models.ConfiguredSource {
return ds.getInitialSources()
}
// CanonicalSourceByID returns the canonical default ConfiguredSource for one
// of the well-known built-in source IDs (10001..10005). The returned source
// has its SourceKey.Type / SourceKey.Account mirrored from
+17 -11
View File
@@ -64,16 +64,17 @@ func TestMargeCreateAccount(t *testing.T) {
t.Errorf("Expected 7-digit ID, got %v", resp.ID)
}
// Verify default sources. AUX (id=10001, sourceproviderid=9) is
// intentionally excluded from cloud responses — real Bose never
// emitted AUX in /full; the speaker enumerates AUX from its own
// hardware via isLocal=true in :8090/sources. See
// Verify default sources. AUX (id=10001) is intentionally excluded from
// cloud responses — real Bose never emitted AUX in /full; the speaker
// enumerates AUX from its own hardware via isLocal=true in :8090/sources.
// INTERNET_RADIO (id=10002) is also excluded — it is a legacy stub that
// AfterTouch no longer adds to new devices. See GetInitialSources() and
// pkg/service/marge/marge.go getAccountSources.
if len(resp.Sources) != 4 {
t.Errorf("Expected 4 cloud default sources (AUX excluded), got %d", len(resp.Sources))
if len(resp.Sources) != 3 {
t.Errorf("Expected 3 cloud default sources (AUX and INTERNET_RADIO excluded), got %d", len(resp.Sources))
} else {
if resp.Sources[0].ID != "10002" {
t.Errorf("Expected first cloud source ID 10002 (INTERNET_RADIO), got %s", resp.Sources[0].ID)
if resp.Sources[0].ID != "10003" {
t.Errorf("Expected first cloud source ID 10003 (LOCAL_INTERNET_RADIO), got %s", resp.Sources[0].ID)
}
}
@@ -641,13 +642,14 @@ func TestMargeAccountSourcesNoDevices(t *testing.T) {
// Verify that we get the default cloud sources with correct IDs. AUX
// (id=10001) is intentionally excluded — real Bose never emitted AUX
// in cloud responses; the speaker enumerates AUX from its own
// hardware (isLocal=true on :8090/sources). See
// pkg/service/marge/marge.go getAccountSources.
// hardware (isLocal=true on :8090/sources). INTERNET_RADIO (id=10002)
// is also intentionally excluded — it is a legacy stub that AfterTouch
// no longer adds to new or no-device accounts. See GetInitialSources()
// and pkg/service/marge/marge.go getAccountSources.
expectedSnippets := []string{
"<sources>",
"<source id=\"10004\" type=\"Audio\"",
"<source id=\"10003\" type=\"Audio\"",
"<source id=\"10002\" type=\"Audio\"",
}
for _, snippet := range expectedSnippets {
@@ -660,6 +662,10 @@ func TestMargeAccountSourcesNoDevices(t *testing.T) {
t.Errorf("Response must not include AUX (id=10001); body:\n%s", bodyStr)
}
if strings.Contains(bodyStr, "<source id=\"10002\"") {
t.Errorf("Response must not include INTERNET_RADIO (id=10002) for accounts with no device; body:\n%s", 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)
+2 -2
View File
@@ -1219,10 +1219,10 @@ func getAccountSources(ds *datastore.DataStore, account, lastDeviceID string) []
if lastDeviceID != "" {
sources, err = ds.GetConfiguredSources(account, lastDeviceID)
if err == nil {
sources = mergeDefaultSources(sources, ds.GetDefaultSources())
sources = mergeDefaultSources(sources, ds.GetInitialSources())
}
} else {
sources = ds.GetDefaultSources()
sources = ds.GetInitialSources()
}
if err != nil {
@@ -25,7 +25,10 @@ Authorization: Bearer dummy-token
// the speaker enumerates AUX from its own hardware via isLocal=true
// in :8090/sources. See pkg/service/marge/marge.go getAccountSources
// and commit 2b40481 (#195/#269).
const expectedIds = ["10002", "10003", "10004"];
// INTERNET_RADIO (id=10002) is also excluded — it is a legacy stub
// that AfterTouch no longer adds to new or existing devices. See
// GetInitialSources() in pkg/service/datastore/datastore.go.
const expectedIds = ["10003", "10004", "10005"];
for (let i = 0; i < sourceList.length; i++) {
const source = sourceList.item(i);
const sourceId = source.getAttribute("id");
@@ -52,7 +52,10 @@ Authorization: Bearer {{token}}
// AUX from its own hardware via isLocal=true in :8090/sources. See
// pkg/service/marge/marge.go getAccountSources for the reasoning and
// commit 2b40481 for the fix that closed #195/#269.
client.assert(sourceCount === 5, "Expected 5 cloud sources (INTERNET_RADIO, LOCAL_INTERNET_RADIO, TUNEIN, RADIO_BROWSER, Spotify; AUX is hardware-local) but got " + sourceCount);
// INTERNET_RADIO (id=10002) is also excluded — it is a legacy stub
// that AfterTouch no longer adds to new or existing devices. See
// GetInitialSources() in pkg/service/datastore/datastore.go.
client.assert(sourceCount === 4, "Expected 4 cloud sources (LOCAL_INTERNET_RADIO, TUNEIN, RADIO_BROWSER, Spotify; AUX and INTERNET_RADIO are excluded) but got " + sourceCount);
// Explicit negative assertion: AUX must not appear in /full.
var sourceList = sources.getElementsByTagName("source");