Files
Tobias GesellchenandClaude Sonnet 4.6 a5f5bdb916 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>
2026-05-27 21:28:20 +02:00

384 lines
10 KiB
Go

package main
import (
"fmt"
"net"
"sync"
"github.com/gesellix/bose-soundtouch/pkg/client"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/speaker"
"github.com/urfave/cli/v2"
)
// getGroupStatus retrieves and prints the device's current stereo-pair state.
func getGroupStatus(c *cli.Context) error {
clientConfig := GetClientConfig(c)
PrintDeviceHeader("Getting group information", clientConfig.Host, clientConfig.Port)
client, err := CreateSoundTouchClient(clientConfig)
if err != nil {
PrintError(fmt.Sprintf("Failed to create client: %v", err))
return err
}
group, err := client.GetGroup()
if err != nil {
PrintError(fmt.Sprintf("Failed to get group: %v", err))
return err
}
if group.IsEmpty() {
fmt.Println("Device is not in a stereo pair")
return nil
}
printGroup(group)
return nil
}
// createGroup forms a stereo pair by POSTing /addGroup to both speakers in
// parallel. LEFT is the master. Addressing each speaker directly (instead of
// only the master and letting it propagate via marge) sidesteps the
// inter-device round-trip that surfaced as client timeouts in #252.
func createGroup(c *cli.Context) error {
leftIP := c.String("left")
rightIP := c.String("right")
name := c.String("name")
if net.ParseIP(leftIP) == nil {
PrintError(fmt.Sprintf("Invalid left IP address: %s", leftIP))
return fmt.Errorf("invalid left IP: %s", leftIP)
}
if net.ParseIP(rightIP) == nil {
PrintError(fmt.Sprintf("Invalid right IP address: %s", rightIP))
return fmt.Errorf("invalid right IP: %s", rightIP)
}
PrintDeviceHeader(fmt.Sprintf("Creating stereo pair: LEFT=%s RIGHT=%s", leftIP, rightIP), leftIP, speaker.HTTPPort)
leftInfo, err := fetchDeviceInfo(c, leftIP)
if err != nil {
PrintError(fmt.Sprintf("Failed to read LEFT device info: %v", err))
return err
}
rightInfo, err := fetchDeviceInfo(c, rightIP)
if err != nil {
PrintError(fmt.Sprintf("Failed to read RIGHT device info: %v", err))
return err
}
if name == "" {
name = fmt.Sprintf("%s + %s", leftInfo.Name, rightInfo.Name)
}
req := &models.Group{
Name: name,
MasterDeviceID: leftInfo.DeviceID,
Roles: models.GroupRoles{
Roles: []models.GroupRole{
{DeviceID: leftInfo.DeviceID, Role: "LEFT", IPAddress: leftIP},
{DeviceID: rightInfo.DeviceID, Role: "RIGHT", IPAddress: rightIP},
},
},
// SenderIPAddress is intentionally omitted on the base request.
// propagateAddGroup adds it to the slave's copy only — see comment there.
}
leftClient, err := clientForHost(c, leftIP)
if err != nil {
PrintError(fmt.Sprintf("Failed to create client for LEFT: %v", err))
return err
}
rightClient, err := clientForHost(c, rightIP)
if err != nil {
PrintError(fmt.Sprintf("Failed to create client for RIGHT: %v", err))
return err
}
leftOut, rightOut := propagateAddGroup(leftClient, rightClient, leftIP, rightIP, req)
if leftOut.err != nil {
PrintError(fmt.Sprintf("LEFT (%s) /addGroup failed: %v", leftIP, leftOut.err))
}
if rightOut.err != nil {
PrintError(fmt.Sprintf("RIGHT (%s) /addGroup failed: %v", rightIP, rightOut.err))
}
if leftOut.err != nil || rightOut.err != nil {
if (leftOut.err == nil) != (rightOut.err == nil) {
succeeded := leftIP
if leftOut.err != nil {
succeeded = rightIP
}
PrintError(fmt.Sprintf("Partial group state on %s — clean up with `soundtouch-cli --host %s group remove`", succeeded, succeeded))
}
return fmt.Errorf("/addGroup propagation failed")
}
// The LEFT (master) response carries the assigned group ID; use it for display.
PrintSuccess(fmt.Sprintf("Stereo pair created (id=%s)", leftOut.group.ID))
printGroup(leftOut.group)
return nil
}
// addGroupOutcome is the per-speaker result of a parallel /addGroup call.
type addGroupOutcome struct {
host string
group *models.Group
err error
}
// propagateAddGroup POSTs /addGroup to both speakers concurrently and returns
// the (LEFT, RIGHT) outcomes. A non-GROUP_OK Status in the response is
// reported as an error so callers don't have to re-inspect the body.
//
// The two POSTs carry different payloads: the master (LEFT) receives the base
// request with no senderIPAddress so its state machine forms the group as the
// master, while the slave (RIGHT) receives a copy with senderIPAddress set to
// the master's IP so its state machine joins as the slave. Sending the same
// payload to both makes both speakers think they're the slave — they enter
// AddingSlave, wait for a master that never confirms, time out after 5 s, and
// revert (issue #252).
func propagateAddGroup(left, right *client.Client, leftIP, rightIP string, req *models.Group) (addGroupOutcome, addGroupOutcome) {
masterReq := *req
masterReq.SenderIPAddress = ""
slaveReq := *req
slaveReq.SenderIPAddress = leftIP
var (
wg sync.WaitGroup
leftOut, rightOut addGroupOutcome
)
wg.Add(2)
go func() {
defer wg.Done()
leftOut = postAddGroup(left, leftIP, &masterReq)
}()
go func() {
defer wg.Done()
rightOut = postAddGroup(right, rightIP, &slaveReq)
}()
wg.Wait()
return leftOut, rightOut
}
func postAddGroup(cli *client.Client, host string, req *models.Group) addGroupOutcome {
out := addGroupOutcome{host: host}
g, err := cli.AddGroup(req)
if err != nil {
out.err = err
return out
}
out.group = g
if g != nil && g.Status != "" && g.Status != "GROUP_OK" {
out.err = fmt.Errorf("device returned status %q (want GROUP_OK)", g.Status)
}
return out
}
// renameGroup updates the name of the existing stereo pair. The device
// requires the full structure on every update, so we fetch the current
// state first.
func renameGroup(c *cli.Context) error {
clientConfig := GetClientConfig(c)
newName := c.String("name")
if newName == "" {
PrintError("--name is required")
return fmt.Errorf("name is required")
}
PrintDeviceHeader(fmt.Sprintf("Renaming stereo pair to %q", newName), clientConfig.Host, clientConfig.Port)
stClient, err := CreateSoundTouchClient(clientConfig)
if err != nil {
PrintError(fmt.Sprintf("Failed to create client: %v", err))
return err
}
current, err := stClient.GetGroup()
if err != nil {
PrintError(fmt.Sprintf("Failed to read current group: %v", err))
return err
}
if current.IsEmpty() {
PrintError("Device is not in a stereo pair — nothing to rename")
return fmt.Errorf("no group configured")
}
// Status is read-only on the device side; don't echo it back.
current.Status = ""
current.Name = newName
result, err := stClient.UpdateGroup(current)
if err != nil {
PrintError(fmt.Sprintf("Failed to rename group: %v", err))
return err
}
PrintSuccess(fmt.Sprintf("Stereo pair renamed to %q", result.Name))
printGroup(result)
return nil
}
// 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)
stClient, err := CreateSoundTouchClient(clientConfig)
if err != nil {
PrintError(fmt.Sprintf("Failed to create client: %v", err))
return 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
}
// fetchDeviceInfo builds a one-off client for the given IP and reads /info.
// Reused for both halves of a `create` invocation so the caller doesn't have
// to babysit two host/port pairs.
func fetchDeviceInfo(c *cli.Context, host string) (*models.DeviceInfo, error) {
stClient, err := clientForHost(c, host)
if err != nil {
return nil, err
}
return stClient.GetDeviceInfo()
}
// clientForHost mirrors CreateSoundTouchClient but overrides the host so we
// can talk to a speaker other than the one named in --host.
func clientForHost(c *cli.Context, host string) (*client.Client, error) {
cfg, err := loadConfig(c.Duration("timeout"))
if err != nil {
return nil, fmt.Errorf("failed to load config: %w", err)
}
return client.NewClient(&client.Config{
Host: host,
Port: speaker.HTTPPort,
Timeout: cfg.HTTPTimeout,
UserAgent: cfg.UserAgent,
}), nil
}
func printGroup(g *models.Group) {
fmt.Println("Stereo Pair Configuration:")
fmt.Printf(" ID: %s\n", g.ID)
fmt.Printf(" Name: %s\n", g.Name)
fmt.Printf(" Master: %s\n", g.MasterDeviceID)
if g.Status != "" {
fmt.Printf(" Status: %s\n", g.Status)
}
for _, r := range g.Roles.Roles {
fmt.Printf(" %-5s %s", r.Role, r.DeviceID)
if r.IPAddress != "" {
fmt.Printf(" (IP: %s)", r.IPAddress)
}
fmt.Println()
}
}