From fc3e6ed795ad8f4055d4d31f51adf82172b4ba80 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Sun, 14 Jun 2026 18:50:38 +0200 Subject: [PATCH] fix(marge): preserve STORED_MUSIC account in recents (fix replay INVALID_SOURCE) Replaying a STORED_MUSIC media-server item from Recents failed with INVALID_SOURCE: the served recent's had an empty , so the speaker fell back to the provider id ("7") as the account and could not resolve which media server to use. Root cause: a media server's account ("/0") is persisted in SourceKey.Account, but Username is NOT persisted (SaveConfiguredSources writes sourceKey.account, not username). prepareRecentItemParitySource and formatRecentResponse emitted straight from the now-empty Username field. The /full path (mapToFullResponseSource) already falls back to SourceKeyAccount; the recents builders did not. Fix: add recentSourceUsername(src) that falls back to SourceKeyAccount when Username is empty (TuneIn / Internet Radio / Local Internet Radio keep an empty username for parity), used by both recent builders. Regression test drives the captured Bose_Lisa flow (sourceid-only recent POST) and asserts the served is the real UDN, never empty or the bare provider id. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/service/marge/marge.go | 25 +++++- .../marge/recent_storedmusic_account_test.go | 89 +++++++++++++++++++ 2 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 pkg/service/marge/recent_storedmusic_account_test.go diff --git a/pkg/service/marge/marge.go b/pkg/service/marge/marge.go index acb5378..58355d0 100644 --- a/pkg/service/marge/marge.go +++ b/pkg/service/marge/marge.go @@ -225,6 +225,27 @@ func (p presetParityXML) MarshalXML(e *xml.Encoder, start xml.StartElement) erro return e.EncodeElement(Alias(p), start) } +// recentSourceUsername returns the account/username to emit in a recent's +// block. The account (e.g. STORED_MUSIC's "/0") is persisted in +// SourceKey.Account, not Username, which does not round-trip through the +// datastore — so fall back to SourceKeyAccount. Without this, STORED_MUSIC +// media-server recents get an empty , the speaker falls back to the +// provider id (e.g. "7") as the account, and replay fails with INVALID_SOURCE. +// Mirrors the fallback in mapToFullResponseSource; TuneIn / Internet Radio / +// Local Internet Radio intentionally keep an empty username (parity). +func recentSourceUsername(src *models.ConfiguredSource) string { + if src.Username != "" { + return src.Username + } + + switch src.SourceKeyType { + case constants.ProviderTunein, constants.ProviderInternetRadio, constants.ProviderLocalInternetRadio: + return "" + } + + return src.SourceKeyAccount +} + func prepareRecentItemParitySource(src *models.ConfiguredSource) *models.RecentItemParitySource { sxml := &models.RecentItemParitySource{ ID: src.ID, @@ -234,7 +255,7 @@ func prepareRecentItemParitySource(src *models.ConfiguredSource) *models.RecentI Name: src.DisplayName, SourceProviderID: src.SourceProviderID, SourceName: src.SourceName, - Username: src.Username, + Username: recentSourceUsername(src), Credential: &models.RecentItemParityCredential{ Type: src.Credential.Type, Value: src.Credential.Value, @@ -2118,7 +2139,7 @@ func formatRecentResponse(recentObj *models.ServiceRecent, matchingSrc *models.C Name: matchingSrc.DisplayName, SourceProviderID: matchingSrc.SourceProviderID, SourceName: matchingSrc.SourceName, - Username: matchingSrc.Username, + Username: recentSourceUsername(matchingSrc), } if res.Source.Name == "TuneIn" || res.Source.Name == "LOCAL_INTERNET_RADIO" { diff --git a/pkg/service/marge/recent_storedmusic_account_test.go b/pkg/service/marge/recent_storedmusic_account_test.go new file mode 100644 index 0000000..18a694d --- /dev/null +++ b/pkg/service/marge/recent_storedmusic_account_test.go @@ -0,0 +1,89 @@ +package marge + +import ( + "fmt" + "os" + "strconv" + "strings" + "testing" + + "github.com/gesellix/bose-soundtouch/pkg/service/constants" + "github.com/gesellix/bose-soundtouch/pkg/service/datastore" +) + +// TestRecent_StoredMusicKeepsAccount is a regression test for the recents-replay +// bug: a STORED_MUSIC media server's account ("/0") is persisted in +// SourceKey.Account, not Username (which does not round-trip). The recent +// builders emitted Username verbatim, producing an empty ; +// the speaker then fell back to the provider id (e.g. "7") as the account and +// replaying the recent failed with INVALID_SOURCE. +// +// The speaker registers a recent by sourceid only (no account in the POST), so +// the served recent's account must come from the matched source. This asserts +// the served carries the real UDN, and never the bare provider id. +func TestRecent_StoredMusicKeepsAccount(t *testing.T) { + tmp, err := os.MkdirTemp("", "recent-storedmusic-*") + if err != nil { + t.Fatalf("temp dir: %v", err) + } + + defer func() { _ = os.RemoveAll(tmp) }() + + ds := datastore.NewDataStore(tmp) + account := "6919733" + device := "A81B6A536A98" + + if mkErr := os.MkdirAll(ds.AccountDeviceDir(account, device), 0o755); mkErr != nil { + t.Fatalf("mkdir: %v", mkErr) + } + + const udn = "4d696e69-444c-164e-9d41-72ecda78e4c1/0" + + sm := strconv.Itoa(constants.StoredMusicProviderID) + + srcID, err := AddSource(ds, account, udn, sm, "", "", "AfterTouch Test Library") + if err != nil { + t.Fatalf("add source: %v", err) + } + + // The speaker POSTs a recent referencing the source by id only (matches the + // captured Bose_Lisa payload: no account/username). + recXML := fmt.Sprintf(``+ + `2026-06-14T16:35:30+00:00`+ + `%s`+ + `02 - The Raven`+ + `1$4$1 TRACK`+ + ``, srcID) + + postResp, err := AddRecent(ds, account, device, []byte(recXML)) + if err != nil { + t.Fatalf("add recent: %v", err) + } + + // 1. The POST response (formatRecentResponse) must carry the real account. + assertAccount(t, "AddRecent response", string(postResp), udn) + + // 2. The served recents list (RecentsToXML) must carry it too. + served, err := RecentsToXML(ds, account, device) + if err != nil { + t.Fatalf("RecentsToXML: %v", err) + } + + assertAccount(t, "RecentsToXML", string(served), udn) +} + +func assertAccount(t *testing.T, what, xml, udn string) { + t.Helper() + + if !strings.Contains(xml, ""+udn+"") { + t.Errorf("%s: expected %s; got:\n%s", what, udn, xml) + } + + if strings.Contains(xml, "") || strings.Contains(xml, "") { + t.Errorf("%s: STORED_MUSIC source has an empty (account lost):\n%s", what, xml) + } + + if strings.Contains(xml, "7") { + t.Errorf("%s: STORED_MUSIC username is the bare provider id, not the account:\n%s", what, xml) + } +}