fix(web): strip placeholder SourceAccount before replaying recents

Speakers echo back the source name as SourceAccount when no real
credential is set (e.g. SourceAccount="TUNEIN" for a TUNEIN source).
HandleDevicePlay was forwarding this verbatim, causing the speaker to
try authenticating with the source name as a TuneIn account and
returning INVALID_SOURCE.

Clear SourceAccount when it equals Source; preserve it when it differs
(real credentials such as Spotify or STORED_MUSIC UUIDs).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-05-23 15:03:04 +02:00
co-authored by Claude Sonnet 4.6
parent 11a6515f4d
commit abe9079382
2 changed files with 93 additions and 7 deletions
+11 -7
View File
@@ -1031,13 +1031,12 @@ func (app *WebApp) HandleDevicePlay(w http.ResponseWriter, r *http.Request) {
}
contentItem := &models.ContentItem{
Source: req.Source,
Type: req.Type,
Location: req.Location,
SourceAccount: req.SourceAccount,
ItemName: req.ItemName,
ContainerArt: req.ContainerArt,
IsPresetable: req.IsPresetable,
Source: req.Source,
Type: req.Type,
Location: req.Location,
ItemName: req.ItemName,
ContainerArt: req.ContainerArt,
IsPresetable: req.IsPresetable,
}
if err := device.Client.SelectContentItem(contentItem); err != nil {
@@ -1124,6 +1123,11 @@ func (app *WebApp) HandlePlayRadioBrowser(w http.ResponseWriter, r *http.Request
ItemName: req.Name,
IsPresetable: true,
}
// Only pass SourceAccount when it's a real credential, not the placeholder
// value that speakers echo back (source name == source account, e.g. "TUNEIN").
if req.SourceAccount != "" && req.SourceAccount != req.Source {
contentItem.SourceAccount = req.SourceAccount
}
if err := device.Client.SelectContentItem(contentItem); err != nil {
app.sendError(w, err.Error(), http.StatusInternalServerError)
+82
View File
@@ -4,6 +4,7 @@ package soundtouchweb
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
@@ -622,3 +623,84 @@ func BenchmarkSendError(b *testing.B) {
app.sendError(w, "Test error", http.StatusBadRequest)
}
}
// TestHandleDevicePlay_SourceAccountFiltering verifies that a SourceAccount
// equal to Source (the placeholder speakers echo back, e.g. "TUNEIN") is
// stripped before the ContentItem XML is sent to the speaker, while a real
// credential (SourceAccount != Source) is preserved.
func TestHandleDevicePlay_SourceAccountFiltering(t *testing.T) {
tests := []struct {
name string
source string
sourceAccount string
wantSourceAccount string // empty means the XML attr must be absent
}{
{
name: "placeholder echoed back — stripped",
source: "TUNEIN",
sourceAccount: "TUNEIN",
wantSourceAccount: "",
},
{
name: "real credential — preserved",
source: "TUNEIN",
sourceAccount: "real-account-id",
wantSourceAccount: "real-account-id",
},
{
name: "empty account — stays empty",
source: "TUNEIN",
sourceAccount: "",
wantSourceAccount: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var capturedBody string
// Fake speaker that captures the /select POST body.
speaker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/select" {
b, _ := io.ReadAll(r.Body)
capturedBody = string(b)
}
w.WriteHeader(http.StatusOK)
}))
defer speaker.Close()
speakerClient := client.NewClient(&client.Config{Host: speaker.URL})
app := NewWebApp()
deviceInfo := &models.DeviceInfo{Name: "Test Speaker"}
conn := webtypes.NewDeviceConnection(speakerClient, deviceInfo)
conn.SetStatus(&webtypes.DeviceStatus{IsConnected: true, LastActivity: time.Now()})
app.AddDevice("play-device", conn)
body := strings.NewReader(`{
"source":"` + tt.source + `",
"type":"stationurl",
"location":"/v1/playback/station/s6634",
"sourceAccount":"` + tt.sourceAccount + `",
"itemName":"Venice Classic Radio"
}`)
req := httptest.NewRequest("POST", "/api/device-play/play-device", body)
req.Header.Set("Content-Type", "application/json")
req = withChiParams(req, map[string]string{"id": "play-device"})
w := httptest.NewRecorder()
app.HandleDevicePlay(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
// SourceAccount XML attribute is always emitted (no omitempty on the struct
// tag), so check its value rather than its presence/absence.
want := `sourceAccount="` + tt.wantSourceAccount + `"`
if !strings.Contains(capturedBody, want) {
t.Errorf("XML should contain %q, got: %s", want, capturedBody)
}
})
}
}