mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 08:36:13 +00:00
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:
co-authored by
Claude Sonnet 4.6
parent
c305d22de0
commit
9172072601
@@ -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)))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user