fix(group): propagate removeGroup to all members; handle DELETE /group/

Two bugs prevented clean stereo-pair teardown:

1. removeGroup (CLI) only contacted the --host speaker (master). The
   slave never received /removeGroup and stayed stuck in GroupSlave state
   indefinitely, blocking direct playback. Fix: fetch the current group
   first, then send /removeGroup to every member in parallel — mirrors
   the same symmetry as createGroup (issue #252).

2. Speakers send DELETE /streaming/account/{id}/group/ (trailing slash,
   no group ID) during teardown. Master and slave live in different
   accounts, so each deletes its own copy independently. AfterTouch had
   no route for this form → 405. Fix: add DeleteAllGroupsForAccount to
   the datastore (scans Group_*.xml, idempotent if none found) and wire
   DELETE /group and DELETE /group/ to a new HandleMargeDeleteAccountGroups
   handler in both routing blocks.

Confirmed: after the fix both DELETE calls return 200 and the slave
exits GroupSlave state cleanly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-05-27 21:28:20 +02:00
co-authored by Claude Sonnet 4.6
parent 04f7388051
commit a5f5bdb916
5 changed files with 136 additions and 4 deletions
+71 -3
View File
@@ -244,7 +244,10 @@ func renameGroup(c *cli.Context) error {
return nil
}
// removeGroup tears down the device's stereo pair.
// removeGroup tears down the device's stereo pair by sending /removeGroup to
// every member in parallel. Sending it only to the master (as the old code
// did) leaves the slave stuck in GroupSlave state indefinitely — mirrors the
// same symmetry as createGroup (see issue #252 comment there).
func removeGroup(c *cli.Context) error {
clientConfig := GetClientConfig(c)
PrintDeviceHeader("Removing stereo pair", clientConfig.Host, clientConfig.Port)
@@ -255,11 +258,76 @@ func removeGroup(c *cli.Context) error {
return err
}
if err := stClient.RemoveGroup(); err != nil {
PrintError(fmt.Sprintf("Failed to remove group: %v", err))
// Fetch current group to learn every member's IP before tearing down.
group, err := stClient.GetGroup()
if err != nil {
PrintError(fmt.Sprintf("Failed to read current group: %v", err))
return err
}
if group.IsEmpty() {
fmt.Println("Device is not in a stereo pair — nothing to remove")
return nil
}
// Collect the unique set of member IPs. The master is always reachable
// via clientConfig.Host; the roles carry all members including slaves.
type memberResult struct {
ip string
err error
}
members := make([]string, 0, len(group.Roles.Roles))
seen := map[string]bool{}
for _, role := range group.Roles.Roles {
if role.IPAddress != "" && !seen[role.IPAddress] {
seen[role.IPAddress] = true
members = append(members, role.IPAddress)
}
}
// Always include the addressed host even if the group response omitted IPs.
if !seen[clientConfig.Host] {
members = append(members, clientConfig.Host)
}
results := make([]memberResult, len(members))
var wg sync.WaitGroup
for i, ip := range members {
wg.Add(1)
go func(idx int, host string) {
defer wg.Done()
mc, mcErr := clientForHost(c, host)
if mcErr != nil {
results[idx] = memberResult{ip: host, err: mcErr}
return
}
results[idx] = memberResult{ip: host, err: mc.RemoveGroup()}
}(i, ip)
}
wg.Wait()
anyErr := false
for _, r := range results {
if r.err != nil {
PrintError(fmt.Sprintf("%s /removeGroup failed: %v", r.ip, r.err))
anyErr = true
}
}
if anyErr {
return fmt.Errorf("/removeGroup propagation failed")
}
PrintSuccess("Stereo pair removed")
return nil
+7
View File
@@ -1097,6 +1097,11 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
r.Post("/group/", server.HandleMargeAddGroup)
r.Post("/group/{groupId}", server.HandleMargeModifyGroup)
r.Delete("/group/{groupId}", server.HandleMargeDeleteGroup)
// Speakers send DELETE /group/ (no group ID, trailing slash) during
// stereo-pair teardown; master and slave use their own account IDs
// so each deletes its own copy.
r.Delete("/group", server.HandleMargeDeleteAccountGroups)
r.Delete("/group/", server.HandleMargeDeleteAccountGroups)
})
r.Get("/device/{device}/streaming_token", server.HandleMargeStreamingToken)
@@ -1144,6 +1149,8 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
r.Post("/group/", server.HandleMargeAddGroup)
r.Post("/group/{groupId}", server.HandleMargeModifyGroup)
r.Delete("/group/{groupId}", server.HandleMargeDeleteGroup)
r.Delete("/group", server.HandleMargeDeleteAccountGroups)
r.Delete("/group/", server.HandleMargeDeleteAccountGroups)
r.Get("/devices/{device}/presets", server.HandleMargePresets)
r.Get("/devices/{device}/recents", server.HandleMargeRecents)
+4
View File
@@ -1,6 +1,8 @@
CONNECT /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
CONNECT /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
DELETE /accounts/{account}/devices/{device} handlers.(*Server).HandleMargeRemoveDevice-fm
DELETE /accounts/{account}/group handlers.(*Server).HandleMargeDeleteAccountGroups-fm
DELETE /accounts/{account}/group/ handlers.(*Server).HandleMargeDeleteAccountGroups-fm
DELETE /accounts/{account}/group/{groupId} handlers.(*Server).HandleMargeDeleteGroup-fm
DELETE /bmx/tunein/v1/favorite/{stationID} handlers.(*Server).HandleTuneInDeleteFavorite-fm
DELETE /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
@@ -12,6 +14,8 @@ DELETE /setup/interactions/sessions/{session} handlers.(
DELETE /setup/sources/{account}/{device}/{sourceID} handlers.(*Server).HandleDeleteSource-fm
DELETE /streaming/account/{account}/device/{device} handlers.(*Server).HandleMargeRemoveDevice-fm
DELETE /streaming/account/{account}/device/{device}/preset/{presetNumber} handlers.(*Server).HandleMargeRemovePreset-fm
DELETE /streaming/account/{account}/group handlers.(*Server).HandleMargeDeleteAccountGroups-fm
DELETE /streaming/account/{account}/group/ handlers.(*Server).HandleMargeDeleteAccountGroups-fm
DELETE /streaming/account/{account}/group/{groupId} handlers.(*Server).HandleMargeDeleteGroup-fm
GET / handlers.(*Server).HandleRoot-fm
GET /accounts/{account}/devices handlers.(*Server).HandleMargeAccountDevices-fm