From abe907938231c44d3398002cac73cf89501119cd Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Sat, 23 May 2026 14:48:21 +0200 Subject: [PATCH] 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 --- pkg/service/soundtouchweb/handler.go | 18 +++-- pkg/service/soundtouchweb/handler_test.go | 82 +++++++++++++++++++++++ 2 files changed, 93 insertions(+), 7 deletions(-) diff --git a/pkg/service/soundtouchweb/handler.go b/pkg/service/soundtouchweb/handler.go index 31e4994..265af40 100644 --- a/pkg/service/soundtouchweb/handler.go +++ b/pkg/service/soundtouchweb/handler.go @@ -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) diff --git a/pkg/service/soundtouchweb/handler_test.go b/pkg/service/soundtouchweb/handler_test.go index 141eb8c..b131b82 100644 --- a/pkg/service/soundtouchweb/handler_test.go +++ b/pkg/service/soundtouchweb/handler_test.go @@ -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) + } + }) + } +}