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