diff --git a/cmd/soundtouch-cli/cmd_cloud.go b/cmd/soundtouch-cli/cmd_cloud.go new file mode 100644 index 0000000..ed66fb4 --- /dev/null +++ b/cmd/soundtouch-cli/cmd_cloud.go @@ -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))) +} diff --git a/cmd/soundtouch-cli/cmd_source.go b/cmd/soundtouch-cli/cmd_source.go index ee5fbf2..b9e5e7f 100644 --- a/cmd/soundtouch-cli/cmd_source.go +++ b/cmd/soundtouch-cli/cmd_source.go @@ -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(``, 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 +} diff --git a/cmd/soundtouch-cli/common.go b/cmd/soundtouch-cli/common.go index cafe9ad..4df3146 100644 --- a/cmd/soundtouch-cli/common.go +++ b/cmd/soundtouch-cli/common.go @@ -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{ diff --git a/cmd/soundtouch-cli/main.go b/cmd/soundtouch-cli/main.go index 0c388ac..776ffd7 100644 --- a/cmd/soundtouch-cli/main.go +++ b/cmd/soundtouch-cli/main.go @@ -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) diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go index f143d9d..80a754e 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -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) diff --git a/cmd/soundtouch-service/testdata/router_routes.txt b/cmd/soundtouch-service/testdata/router_routes.txt index e420e65..ed33ac3 100644 --- a/cmd/soundtouch-service/testdata/router_routes.txt +++ b/cmd/soundtouch-service/testdata/router_routes.txt @@ -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 diff --git a/pkg/service/datastore/datastore.go b/pkg/service/datastore/datastore.go index 93c03ef..ba87ed5 100644 --- a/pkg/service/datastore/datastore.go +++ b/pkg/service/datastore/datastore.go @@ -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() diff --git a/pkg/service/handlers/handlers_setup.go b/pkg/service/handlers/handlers_setup.go index 2d02ae6..d50ec09 100644 --- a/pkg/service/handlers/handlers_setup.go +++ b/pkg/service/handlers/handlers_setup.go @@ -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) +} diff --git a/pkg/service/handlers/server.go b/pkg/service/handlers/server.go index fbf814f..fda3f3b 100644 --- a/pkg/service/handlers/server.go +++ b/pkg/service/handlers/server.go @@ -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, diff --git a/pkg/service/health/checks_stale_internet_radio.go b/pkg/service/health/checks_stale_internet_radio.go new file mode 100644 index 0000000..4eeddf0 --- /dev/null +++ b/pkg/service/health/checks_stale_internet_radio.go @@ -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 +}