mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 08:36:13 +00:00
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:
co-authored by
Claude Opus 4.8
parent
d862666fb7
commit
fc3e6ed795
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user