feat(player,cli): nudge sources refresh after adding a media server

Adding a DLNA media server (setMusicServiceAccount) can leave the new
STORED_MUSIC source not fully registered on the speaker, so playing a track
fails with INVALID_SOURCE until a power-cycle. AfterTouch's health
diagnostic already recommends the no-reboot fix: a sourcesUpdated
notification makes the speaker re-fetch its account /full and re-register
its source list.

Fire that nudge automatically right after a successful registration, in
both the player (HandleAddLibraryServer) and the CLI (account add-nas), via
the existing client.NotifySourcesUpdated. It is best-effort: registration
already succeeded, so a failed nudge never fails the request (the handler
returns {account, refreshed}, the CLI prints a warning that a power-cycle
may still be needed). The handler resolves the Bose device ID from the
cached DeviceConnection.DeviceInfo, falling back to GetDeviceInfo.

Note: per the diagnostic, a power-cycle is still occasionally required, so
the nudge is an improvement, not a guarantee.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-06-11 20:56:04 +02:00
co-authored by Claude Opus 4.8
parent f010e96699
commit 836e985c58
3 changed files with 124 additions and 6 deletions
+29 -1
View File
@@ -143,6 +143,12 @@ func (app *WebApp) HandleDeviceLibraryServers(w http.ResponseWriter, r *http.Req
// contain {udn, name}. The account sent to the speaker is "<udn>/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)
}
@@ -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 "<bare-uuid>/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": `<status>/setMusicServiceAccount</status>`,
"/notification": `<status>/notification</status>`,
})
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 := `<errors deviceID="AABBCCDDEEFF">
<error value="1024">1024: Account already exists</error>
</errors>`
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(`<status>/notification</status>`))
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)