diff --git a/cmd/soundtouch-cli/cmd_account.go b/cmd/soundtouch-cli/cmd_account.go index 3c9df33..cb0ef5b 100644 --- a/cmd/soundtouch-cli/cmd_account.go +++ b/cmd/soundtouch-cli/cmd_account.go @@ -318,7 +318,7 @@ func removePandoraAccount(c *cli.Context) error { func addStoredMusicAccount(c *cli.Context) error { clientConfig := GetClientConfig(c) - client, err := CreateSoundTouchClient(clientConfig) + stClient, err := CreateSoundTouchClient(clientConfig) if err != nil { return err } @@ -340,13 +340,26 @@ func addStoredMusicAccount(c *cli.Context) error { fmt.Printf(" Display Name: %s\n", displayName) fmt.Printf(" Type: UPnP/DLNA Media Server\n") - err = client.AddStoredMusicAccount(user, displayName) + err = stClient.AddStoredMusicAccount(user, displayName) if err != nil { return fmt.Errorf("failed to add network music library: %w", err) } PrintSuccess("Network music library added successfully") + // Send a sourcesUpdated nudge so the speaker re-fetches its account list and + // registers the new source without requiring a power-cycle. This is + // best-effort: a failure here does not abort the command. + if info, infoErr := stClient.GetDeviceInfo(); infoErr == nil && info != nil && info.DeviceID != "" { + if nudgeErr := stClient.NotifySourcesUpdated(info.DeviceID); nudgeErr == nil { + fmt.Println(" Sent a sources refresh to the speaker (no reboot needed).") + } else { + fmt.Println(" Warning: could not send sources refresh; you may need to power-cycle the speaker for the new source to register.") + } + } else { + fmt.Println(" Warning: could not retrieve device ID; you may need to power-cycle the speaker for the new source to register.") + } + // Show next steps fmt.Printf("\n💡 Next Steps:\n") fmt.Printf(" • Check available sources: soundtouch-cli --host %s source list\n", clientConfig.Host) diff --git a/pkg/service/soundtouchweb/handler_library.go b/pkg/service/soundtouchweb/handler_library.go index ee56e5e..7339ac9 100644 --- a/pkg/service/soundtouchweb/handler_library.go +++ b/pkg/service/soundtouchweb/handler_library.go @@ -143,6 +143,12 @@ func (app *WebApp) HandleDeviceLibraryServers(w http.ResponseWriter, r *http.Req // contain {udn, name}. The account sent to the speaker is "/0" as // required by the STORED_MUSIC protocol. Error code 1024 from the speaker // means the account is already registered and is treated as success. +// +// After a successful registration the handler fires a best-effort +// sourcesUpdated notification so the speaker re-fetches its account list and +// registers the new source without requiring a power-cycle. The notification +// outcome is reflected in the response field "refreshed" but never fails the +// request. func (app *WebApp) HandleAddLibraryServer(w http.ResponseWriter, r *http.Request) { deviceID := chi.URLParam(r, "id") @@ -183,11 +189,33 @@ func (app *WebApp) HandleAddLibraryServer(w http.ResponseWriter, r *http.Request } } + // Resolve the Bose device ID for the sourcesUpdated nudge. Prefer the + // cached DeviceInfo (no extra round-trip); fall back to a live /info + // fetch only if the cached value is absent or empty. + boseDeviceID := "" + if device.DeviceInfo != nil && device.DeviceInfo.DeviceID != "" { + boseDeviceID = device.DeviceInfo.DeviceID + } else { + if info, infoErr := device.Client.GetDeviceInfo(); infoErr == nil && info != nil { + boseDeviceID = info.DeviceID + } + } + + // Send the sourcesUpdated nudge best-effort: the registration already + // succeeded, so an error here must never fail the request. + refreshed := false + + if boseDeviceID != "" { + if nudgeErr := device.Client.NotifySourcesUpdated(boseDeviceID); nudgeErr == nil { + refreshed = true + } + } + w.Header().Set("Content-Type", "application/json") if encErr := json.NewEncoder(w).Encode(webtypes.APIResponse{ Success: true, - Data: map[string]string{"account": account}, + Data: map[string]interface{}{"account": account, "refreshed": refreshed}, }); encErr != nil { http.Error(w, "Failed to encode response", http.StatusInternalServerError) } diff --git a/pkg/service/soundtouchweb/handler_library_test.go b/pkg/service/soundtouchweb/handler_library_test.go index eb2cbe8..17d1f4b 100644 --- a/pkg/service/soundtouchweb/handler_library_test.go +++ b/pkg/service/soundtouchweb/handler_library_test.go @@ -73,12 +73,14 @@ func setupSpeakerMock(t *testing.T, responseMap map[string]string) (*httptest.Se } // newLibraryTestApp builds a WebApp with a single device whose Client points -// at the given speaker URL. The device is registered under "lib-device". +// at the given speaker URL. The device is registered under "lib-device" with +// a non-empty DeviceID so HandleAddLibraryServer can resolve the Bose ID from +// the cached DeviceInfo without a /info fallback. func newLibraryTestApp(speakerURL string) *WebApp { app := NewWebApp() c := client.NewClient(&client.Config{Host: speakerURL}) - info := &models.DeviceInfo{Name: "Library Test Speaker"} + info := &models.DeviceInfo{Name: "Library Test Speaker", DeviceID: "AABBCCDDEEFF"} conn := webtypes.NewDeviceConnection(c, info) conn.SetStatus(&webtypes.DeviceStatus{IsConnected: true, LastActivity: time.Now()}) app.AddDevice("lib-device", conn) @@ -511,6 +513,8 @@ func TestNormalizeUDN(t *testing.T) { // TestHandleAddLibraryServer_AccountFormat verifies that the speaker receives // a setMusicServiceAccount call with the account set to "/0", i.e. // any "uuid:" prefix is stripped before the "/0" suffix is appended. +// It also asserts that a POST /notification (sourcesUpdated nudge) is sent +// after a successful registration and that the response carries refreshed=true. func TestHandleAddLibraryServer_AccountFormat(t *testing.T) { tests := []struct { name string @@ -532,8 +536,10 @@ func TestHandleAddLibraryServer_AccountFormat(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // The client parses the response XML and checks for the success sentinel. + // Also handle /notification so NotifySourcesUpdated succeeds. speaker, captured := setupSpeakerMock(t, map[string]string{ "/setMusicServiceAccount": `/setMusicServiceAccount`, + "/notification": `/notification`, }) defer speaker.Close() @@ -572,7 +578,17 @@ func TestHandleAddLibraryServer_AccountFormat(t *testing.T) { t.Errorf("setMusicServiceAccount XML should contain %q, got:\n%s", tt.wantAccount, setXML) } - // The response account field must also be the bare form. + // A sourcesUpdated nudge must have been POSTed to /notification. + notifXML := captured["/notification"] + if notifXML == "" { + t.Fatal("speaker /notification was never called (sourcesUpdated nudge missing)") + } + + if !strings.Contains(notifXML, "sourcesUpdated") { + t.Errorf("/notification body should contain 'sourcesUpdated', got:\n%s", notifXML) + } + + // The response must carry the account and refreshed=true. data, ok := resp.Data.(map[string]interface{}) if !ok { t.Fatalf("resp.Data is not a map: %T", resp.Data) @@ -581,10 +597,71 @@ func TestHandleAddLibraryServer_AccountFormat(t *testing.T) { if got, _ := data["account"].(string); got != tt.wantAccount { t.Errorf("response account = %q, want %q", got, tt.wantAccount) } + + if refreshed, _ := data["refreshed"].(bool); !refreshed { + t.Errorf("response refreshed should be true, got %v", data["refreshed"]) + } }) } } +// TestHandleAddLibraryServer_NudgeSentAfterAlreadyRegistered verifies that +// the sourcesUpdated nudge is also fired for the 1024 (already-registered) +// idempotent path, since the source still needs to be re-registered on the +// speaker. +func TestHandleAddLibraryServer_NudgeSentAfterAlreadyRegistered(t *testing.T) { + alreadyRegistered := ` + 1024: Account already exists + ` + + var notifCalled int + + speaker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/setMusicServiceAccount": + w.WriteHeader(http.StatusBadRequest) + w.Header().Set("Content-Type", "application/xml") + _, _ = w.Write([]byte(alreadyRegistered)) + case "/notification": + notifCalled++ + w.Header().Set("Content-Type", "application/xml") + _, _ = w.Write([]byte(`/notification`)) + default: + w.WriteHeader(http.StatusOK) + } + })) + defer speaker.Close() + + app := newLibraryTestApp(speaker.URL) + + body := strings.NewReader(`{"udn":"uuid:nas-udn","name":"My NAS"}`) + req := httptest.NewRequest("POST", + "/api/control/devices/lib-device/library/servers", + body) + req.Header.Set("Content-Type", "application/json") + req = withChiParams(req, map[string]string{"id": "lib-device"}) + w := httptest.NewRecorder() + + app.HandleAddLibraryServer(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp webtypes.APIResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + + if !resp.Success { + t.Errorf("expected success=true when error contains 1024, got error=%s", resp.Error) + } + + if notifCalled == 0 { + t.Error("expected /notification to be called for already-registered path, but it was not") + } +} + // TestHandleAddLibraryServer_MissingUDN checks that omitting udn returns 400. func TestHandleAddLibraryServer_MissingUDN(t *testing.T) { speaker, _ := setupSpeakerMock(t, nil)