feat: add source removal — health check, API endpoint, and CLI commands

Health check (checks_stale_internet_radio.go): detects stub INTERNET_RADIO
sources (empty credentials) left on devices initialised before the stub was
removed from the default source list. Quick-fix removes by ID; skips any
INTERNET_RADIO source that has real credentials.

Datastore: DeleteSourceByID and DeleteSourceByType (uniqueness-guarded).

API: DELETE /setup/sources/{account}/{device}/{sourceID}

CLI — two new commands:
  soundtouch-cli cloud source remove --service-url ... --account ... --device ... [--id 10002 | --type INTERNET_RADIO]
    Talks to AfterTouch (service side). --type resolves to canonical ID
    locally; fails for unknown types.
  soundtouch-cli source notify-updated --host <speaker-ip>
    Talks to the speaker directly. Fetches device ID from /info, then
    POSTs sourcesUpdated to :8090/notification so the speaker re-fetches
    its source list immediately.

CloudCommonFlags (--service-url / AFTERTOUCH_URL) mirrors CommonFlags
(--host) for AfterTouch-facing command groups.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-05-23 22:45:15 +02:00
co-authored by Claude Sonnet 4.6
parent c305d22de0
commit 9172072601
10 changed files with 407 additions and 0 deletions
+127
View File
@@ -0,0 +1,127 @@
package main
import (
"fmt"
"io"
"net/http"
"strings"
"github.com/urfave/cli/v2"
)
// cloudCommand assembles the `soundtouch-cli cloud …` command group.
// All subcommands talk to the AfterTouch service (not the speaker directly)
// and require --service-url.
func cloudCommand() *cli.Command {
return &cli.Command{
Name: "cloud",
Usage: "Manage AfterTouch service data (sources, accounts, devices)",
Subcommands: []*cli.Command{
cloudSourceCmd(),
},
}
}
func cloudSourceCmd() *cli.Command {
return &cli.Command{
Name: "source",
Usage: "Manage sources stored in AfterTouch",
Subcommands: []*cli.Command{
cloudSourceRemoveCmd(),
},
}
}
func cloudSourceRemoveCmd() *cli.Command {
return &cli.Command{
Name: "remove",
Usage: "Remove a source from AfterTouch's datastore for a specific device",
Flags: append(CloudCommonFlags,
&cli.StringFlag{
Name: "account",
Aliases: []string{"a"},
Usage: "Account ID",
Required: true,
},
&cli.StringFlag{
Name: "device",
Aliases: []string{"d"},
Usage: "Device ID",
Required: true,
},
&cli.StringFlag{
Name: "id",
Usage: "Source ID to remove (e.g. 10002)",
},
&cli.StringFlag{
Name: "type",
Aliases: []string{"t"},
Usage: "Source type to remove (e.g. INTERNET_RADIO). Resolved to a canonical ID; fails if multiple sources share the type.",
},
),
Action: cloudSourceRemove,
}
}
// canonicalSourceID maps well-known SourceKeyType values to their canonical IDs.
// Used to resolve --type to an ID without requiring a round-trip GET.
var canonicalSourceID = map[string]string{
"AUX": "10001",
"INTERNET_RADIO": "10002",
"LOCAL_INTERNET_RADIO": "10003",
"TUNEIN": "10004",
"RADIO_BROWSER": "10005",
}
func cloudSourceRemove(c *cli.Context) error {
serviceURL := strings.TrimRight(c.String("service-url"), "/")
account := c.String("account")
device := c.String("device")
sourceID := c.String("id")
sourceType := strings.ToUpper(c.String("type"))
if sourceID == "" && sourceType == "" {
return fmt.Errorf("one of --id or --type is required")
}
if sourceID != "" && sourceType != "" {
return fmt.Errorf("only one of --id or --type may be given")
}
if sourceType != "" {
id, ok := canonicalSourceID[sourceType]
if !ok {
return fmt.Errorf("unknown source type %q; use --id for non-canonical sources", sourceType)
}
sourceID = id
}
url := fmt.Sprintf("%s/setup/sources/%s/%s/%s", serviceURL, account, device, sourceID)
req, err := http.NewRequest(http.MethodDelete, url, nil)
if err != nil {
return fmt.Errorf("build request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNoContent {
PrintSuccess(fmt.Sprintf("Removed source %s from device %s (account %s)", sourceID, device, account))
if sourceType != "" {
fmt.Printf(" Type: %s\n", sourceType)
}
return nil
}
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10))
return fmt.Errorf("service returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
+50
View File
@@ -3,6 +3,8 @@ package main
import (
"encoding/base64"
"fmt"
"io"
"net/http"
"net/url"
"strings"
@@ -685,3 +687,51 @@ func boolToStatus(b bool) string {
return "❌ No"
}
// notifySourcesUpdated POSTs a sourcesUpdated notification directly to the
// speaker's :8090/notification endpoint. The speaker re-fetches its source
// list from AfterTouch immediately. Requires network access to the speaker.
func notifySourcesUpdated(c *cli.Context) error {
if err := RequireHost(c); err != nil {
return err
}
clientConfig := GetClientConfig(c)
client, err := CreateSoundTouchClient(clientConfig)
if err != nil {
return err
}
info, err := client.GetDeviceInfo()
if err != nil {
return fmt.Errorf("failed to get device info from %s: %w", clientConfig.Host, err)
}
body := fmt.Sprintf(`<updates deviceID="%s"><sourcesUpdated/></updates>`, info.DeviceID)
notifyURL := fmt.Sprintf("http://%s:8090/notification", clientConfig.Host)
req, err := http.NewRequest(http.MethodPost, notifyURL, strings.NewReader(body))
if err != nil {
return fmt.Errorf("build request: %w", err)
}
req.Header.Set("Content-Type", "application/xml")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("post to speaker: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= 300 {
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10))
return fmt.Errorf("speaker returned %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
}
PrintSuccess(fmt.Sprintf("Sent sourcesUpdated to %s (%s)", info.DeviceID, clientConfig.Host))
return nil
}
+10
View File
@@ -19,6 +19,16 @@ import (
"github.com/urfave/cli/v2"
)
// CloudCommonFlags defines flags for commands that talk to the AfterTouch service.
var CloudCommonFlags = []cli.Flag{
&cli.StringFlag{
Name: "service-url",
Usage: "AfterTouch service URL",
Value: "http://aftertouch.local:8000",
EnvVars: []string{"AFTERTOUCH_URL"},
},
}
// CommonFlags defines flags that are shared across multiple commands
var CommonFlags = []cli.Flag{
&cli.StringFlag{
+10
View File
@@ -1146,6 +1146,12 @@ func main() {
Action: introspectAllServices,
Before: RequireHost,
},
{
Name: "notify-updated",
Usage: "Tell the speaker to re-fetch its source list from AfterTouch",
Action: notifySourcesUpdated,
Before: RequireHost,
},
},
},
// Bass commands
@@ -2238,6 +2244,10 @@ func main() {
// Defined in cmd_setup.go to keep the top-level command list readable.
app.Commands = append(app.Commands, setupCommand())
// AfterTouch service management (sources, accounts, devices).
// Defined in cmd_cloud.go.
app.Commands = append(app.Commands, cloudCommand())
// Sort commands alphabetically (including subcommands and flags recursively)
sortCommands(app.Commands)
+1
View File
@@ -1244,6 +1244,7 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
r.Get("/dns-discoveries", server.HandleGetDNSDiscoveries)
r.Get("/dns-discoveries/download", server.HandleDownloadDNSDiscoveries)
r.Delete("/dns-discoveries", server.HandleClearDNSDiscoveries)
r.Delete("/sources/{account}/{device}/{sourceID}", server.HandleDeleteSource)
r.Get("/devices/{deviceId}/events", server.HandleGetDeviceEvents)
r.Get("/device-summary/{deviceId}", server.HandleDeviceSummary)
+1
View File
@@ -9,6 +9,7 @@ DELETE /setup/devices/{deviceId} handlers.(
DELETE /setup/dns-discoveries handlers.(*Server).HandleClearDNSDiscoveries-fm
DELETE /setup/interactions/sessions handlers.(*Server).HandleCleanupSessions-fm
DELETE /setup/interactions/sessions/{session} handlers.(*Server).HandleDeleteSession-fm
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/{groupId} handlers.(*Server).HandleMargeDeleteGroup-fm
+59
View File
@@ -2032,6 +2032,65 @@ func (ds *DataStore) SaveConfiguredSources(account, device string, sources []mod
return ds.atomicWriteFile(path, append(header, data...))
}
// DeleteSourceByID removes the source with the given ID from the device's
// Sources.xml. It is a no-op if the source is not present.
func (ds *DataStore) DeleteSourceByID(account, device, sourceID string) error {
sources, err := ds.GetConfiguredSources(account, device)
if err != nil {
return err
}
filtered := make([]models.ConfiguredSource, 0, len(sources))
for i := range sources {
if sources[i].ID != sourceID {
filtered = append(filtered, sources[i])
}
}
if len(filtered) == len(sources) {
return nil
}
return ds.SaveConfiguredSources(account, device, filtered)
}
// DeleteSourceByType removes the source with the given SourceKeyType from
// the device's Sources.xml. Returns an error if more than one source matches
// (to prevent accidental bulk deletion). No-op if no match is found.
func (ds *DataStore) DeleteSourceByType(account, device, sourceKeyType string) error {
sources, err := ds.GetConfiguredSources(account, device)
if err != nil {
return err
}
var matches int
for i := range sources {
if sources[i].SourceKeyType == sourceKeyType {
matches++
}
}
if matches > 1 {
return fmt.Errorf("found %d sources with type %q; use an ID-based deletion for precision", matches, sourceKeyType)
}
if matches == 0 {
return nil
}
filtered := make([]models.ConfiguredSource, 0, len(sources))
for i := range sources {
if sources[i].SourceKeyType != sourceKeyType {
filtered = append(filtered, sources[i])
}
}
return ds.SaveConfiguredSources(account, device, filtered)
}
// updateDeviceMappings creates bidirectional mappings for device resolution
func (ds *DataStore) updateDeviceMappings(info models.ServiceDeviceInfo) {
ds.idMutex.Lock()
+22
View File
@@ -1348,3 +1348,25 @@ func (s *Server) HandleDownloadSession(w http.ResponseWriter, r *http.Request) {
return
}
}
// HandleDeleteSource removes a source from a device's Sources.xml.
// DELETE /setup/sources/{account}/{device}/{sourceID}
func (s *Server) HandleDeleteSource(w http.ResponseWriter, r *http.Request) {
account := chi.URLParam(r, "account")
device := chi.URLParam(r, "device")
sourceID := chi.URLParam(r, "sourceID")
if account == "" || device == "" || sourceID == "" {
http.Error(w, "account, device, and sourceID are required", http.StatusBadRequest)
return
}
if err := s.ds.DeleteSourceByID(account, device, sourceID); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
+1
View File
@@ -133,6 +133,7 @@ func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, red
health.RegisterPresetsCountCheck(s.healthRegistry, ds)
health.RegisterPresetsConsistencyCheck(s.healthRegistry, ds)
health.RegisterRefreshSourcesCheck(s.healthRegistry, ds)
health.RegisterStaleInternetRadioCheck(s.healthRegistry, ds)
health.RegisterDefaultAccountNonBoseDevicesCheck(s.healthRegistry, ds)
health.RegisterOAuthTargetReachableCheck(
s.healthRegistry,
@@ -0,0 +1,126 @@
package health
import (
"fmt"
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
// CheckIDStaleInternetRadio is the registry id of the stale INTERNET_RADIO
// stub source check.
const CheckIDStaleInternetRadio = "stale_internet_radio"
// FixIDRemoveInternetRadio is the registry id of the quick-fix that removes
// the stub INTERNET_RADIO source.
const FixIDRemoveInternetRadio = "remove_internet_radio"
// RegisterStaleInternetRadioCheck registers a check that detects the legacy
// INTERNET_RADIO stub source (empty credentials) left over from devices
// initialised before AfterTouch removed it from the default source list.
func RegisterStaleInternetRadioCheck(r *Registry, ds *datastore.DataStore) {
r.Register(Check{
ID: CheckIDStaleInternetRadio,
Title: "Stale INTERNET_RADIO stub source",
Run: func() []Finding {
return runStaleInternetRadioCheck(ds)
},
})
r.RegisterFix(CheckIDStaleInternetRadio, FixIDRemoveInternetRadio, func(target Target) (string, error) {
return fixRemoveInternetRadio(ds, target)
})
}
func runStaleInternetRadioCheck(ds *datastore.DataStore) []Finding {
if ds == nil {
return nil
}
devices, err := ds.ListAllDevices()
if err != nil {
return []Finding{{
Severity: SeverityError,
Message: "Could not enumerate devices: " + err.Error(),
}}
}
var findings []Finding
for i := range devices {
dev := &devices[i]
if dev.AccountID == "" || dev.DeviceID == "" {
continue
}
sources, err := ds.GetConfiguredSources(dev.AccountID, dev.DeviceID)
if err != nil {
continue
}
for j := range sources {
s := &sources[j]
if s.SourceKeyType != constants.ProviderInternetRadio {
continue
}
if s.Secret != "" || s.Credential.Value != "" {
// Real user-configured INTERNET_RADIO source — don't touch it.
continue
}
findings = append(findings, Finding{
Severity: SeverityInfo,
Target: Target{Account: dev.AccountID, Device: dev.DeviceID},
Message: fmt.Sprintf(
"Device %s has a stub %s source (ID %s) with no credentials.",
displayName(dev.Name, dev.DeviceID),
constants.ProviderInternetRadio,
s.ID,
),
Details: "AfterTouch no longer adds INTERNET_RADIO to new devices. " +
"This entry is a leftover from an earlier version and can be safely removed. " +
"The speaker will re-sync its source list on the next reconnect.",
QuickFixes: []QuickFix{{
ID: FixIDRemoveInternetRadio,
Label: fmt.Sprintf("Remove %s source (ID %s)", constants.ProviderInternetRadio, s.ID),
}},
})
}
}
return findings
}
func fixRemoveInternetRadio(ds *datastore.DataStore, target Target) (string, error) {
if target.Device == "" {
return "", fmt.Errorf("device is required")
}
sources, err := ds.GetConfiguredSources(target.Account, target.Device)
if err != nil {
return "", fmt.Errorf("could not read sources: %w", err)
}
irID := ""
for i := range sources {
if sources[i].SourceKeyType == constants.ProviderInternetRadio &&
sources[i].Secret == "" && sources[i].Credential.Value == "" {
irID = sources[i].ID
break
}
}
if irID == "" {
return "No stub INTERNET_RADIO source found — nothing to remove.", nil
}
if err := ds.DeleteSourceByID(target.Account, target.Device, irID); err != nil {
return "", fmt.Errorf("delete source: %w", err)
}
return fmt.Sprintf("Removed stub %s source (ID %s) from device %s. The speaker will receive the updated source list on its next reconnect.", constants.ProviderInternetRadio, irID, target.Device), nil
}