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 <source> had an empty <username>, 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 ("<UDN>/0") is persisted in
SourceKey.Account, but Username is NOT persisted (SaveConfiguredSources writes
sourceKey.account, not username). prepareRecentItemParitySource and
formatRecentResponse emitted <username> 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 <source> builders. Regression test
drives the captured Bose_Lisa flow (sourceid-only recent POST) and asserts the
served <source><username> is the real UDN, never empty or the bare provider id.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-06-14 19:01:07 +02:00
co-authored by Claude Opus 4.8
parent d862666fb7
commit fc3e6ed795
2 changed files with 112 additions and 2 deletions
+23 -2
View File
@@ -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
// <source> block. The account (e.g. STORED_MUSIC's "<UDN>/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 <username>, 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" {
@@ -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 ("<UDN>/0") is persisted in
// SourceKey.Account, not Username (which does not round-trip). The recent
// <source> builders emitted Username verbatim, producing an empty <username>;
// 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 <source> 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 <source> account/username).
recXML := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8" ?><recent>`+
`<lastplayedat>2026-06-14T16:35:30+00:00</lastplayedat>`+
`<sourceid>%s</sourceid>`+
`<name>02 - The Raven</name>`+
`<location>1$4$1 TRACK</location>`+
`<contentItemType></contentItemType></recent>`, 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, "<username>"+udn+"</username>") {
t.Errorf("%s: expected <username>%s</username>; got:\n%s", what, udn, xml)
}
if strings.Contains(xml, "<username></username>") || strings.Contains(xml, "<username/>") {
t.Errorf("%s: STORED_MUSIC source has an empty <username> (account lost):\n%s", what, xml)
}
if strings.Contains(xml, "<username>7</username>") {
t.Errorf("%s: STORED_MUSIC username is the bare provider id, not the account:\n%s", what, xml)
}
}