Files
Tobias GesellchenandClaude Sonnet 4.6 9172072601 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>
2026-05-23 22:45:15 +02:00

738 lines
20 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"encoding/base64"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/urfave/cli/v2"
)
func printSource(source models.SourceItem) {
fmt.Printf(" • %s", source.GetDisplayName())
if source.SourceAccount != "" && source.SourceAccount != source.Source {
fmt.Printf(" (%s)", source.SourceAccount)
}
var attributes []string
if source.IsLocalSource() {
attributes = append(attributes, "Local")
attributes = append(attributes, "Available")
}
if len(attributes) > 0 {
fmt.Printf(" [%s]", strings.Join(attributes, ", "))
}
fmt.Println()
}
// listSources handles listing available audio sources
func listSources(c *cli.Context) error {
clientConfig := GetClientConfig(c)
client, err := CreateSoundTouchClient(clientConfig)
if err != nil {
return err
}
PrintDeviceHeader("Getting available sources", clientConfig.Host, clientConfig.Port)
sources, err := client.GetSources()
if err != nil {
return fmt.Errorf("failed to get sources: %w", err)
}
fmt.Printf("Available Audio Sources:\n")
fmt.Printf(" Device ID: %s\n", sources.DeviceID)
// Show ready sources first
availableSources := sources.GetAvailableSources()
if len(availableSources) > 0 {
fmt.Printf(" Ready Sources:\n")
for _, source := range availableSources {
printSource(source)
}
}
// Show all configured sources
fmt.Printf(" All Sources:\n")
for _, source := range sources.SourceItem {
status := "Available"
if !source.IsLocalSource() {
status = "Remote"
}
fmt.Printf(" • %s (%s)\n", source.GetDisplayName(), status)
if source.SourceAccount != "" && source.SourceAccount != source.Source {
fmt.Printf(" Account: %s\n", source.SourceAccount)
}
}
// Show streaming sources
streamingSources := sources.GetStreamingSources()
if len(streamingSources) > 0 {
fmt.Printf(" Streaming Services:\n")
for _, source := range streamingSources {
fmt.Printf(" • %s", source.GetDisplayName())
if source.SourceAccount != "" {
fmt.Printf(" (%s)", source.SourceAccount)
}
fmt.Println()
}
}
// Show service availability summary
fmt.Println()
checker := NewServiceAvailabilityChecker(client)
checker.PrintServiceAvailabilitySummary()
return nil
}
// selectSource handles selecting an audio source
func selectSource(c *cli.Context) error {
clientConfig := GetClientConfig(c)
client, err := CreateSoundTouchClient(clientConfig)
if err != nil {
return err
}
sourceName := strings.ToUpper(c.String("source"))
sourceAccount := c.String("account")
// Check service availability
checker := NewServiceAvailabilityChecker(client)
actionDescription := fmt.Sprintf("select %s source", strings.ToLower(sourceName))
if !checker.CheckSourceAvailable(sourceName, actionDescription) {
return fmt.Errorf("source '%s' is not available", sourceName)
}
PrintDeviceHeader(fmt.Sprintf("Selecting source '%s'", sourceName), clientConfig.Host, clientConfig.Port)
err = client.SelectSource(sourceName, sourceAccount)
if err != nil {
return fmt.Errorf("failed to select source: %w", err)
}
if sourceAccount != "" {
PrintSuccess(fmt.Sprintf("Source '%s' with account '%s' selected", sourceName, sourceAccount))
} else {
PrintSuccess(fmt.Sprintf("Source '%s' selected", sourceName))
}
return nil
}
// selectSpotify handles selecting Spotify source
func selectSpotify(c *cli.Context) error {
clientConfig := GetClientConfig(c)
client, err := CreateSoundTouchClient(clientConfig)
if err != nil {
return err
}
// Check Spotify availability
checker := NewServiceAvailabilityChecker(client)
if !checker.ValidateSpotifyAvailable("select Spotify source") {
return fmt.Errorf("spotify is not available on this device")
}
PrintDeviceHeader("Selecting Spotify source", clientConfig.Host, clientConfig.Port)
err = client.SelectSpotify("")
if err != nil {
return fmt.Errorf("failed to select Spotify: %w", err)
}
PrintSuccess("Spotify source selected")
return nil
}
// selectBluetooth handles selecting Bluetooth source
func selectBluetooth(c *cli.Context) error {
clientConfig := GetClientConfig(c)
client, err := CreateSoundTouchClient(clientConfig)
if err != nil {
return err
}
// Check Bluetooth availability
checker := NewServiceAvailabilityChecker(client)
if !checker.ValidateBluetoothAvailable("select Bluetooth source") {
return fmt.Errorf("bluetooth is not available on this device")
}
PrintDeviceHeader("Selecting Bluetooth source", clientConfig.Host, clientConfig.Port)
err = client.SelectBluetooth()
if err != nil {
return fmt.Errorf("failed to select Bluetooth: %w", err)
}
PrintSuccess("Bluetooth source selected")
return nil
}
// selectAux handles selecting AUX input source
func selectAux(c *cli.Context) error {
clientConfig := GetClientConfig(c)
client, err := CreateSoundTouchClient(clientConfig)
if err != nil {
return err
}
PrintDeviceHeader("Selecting AUX input source", clientConfig.Host, clientConfig.Port)
err = client.SelectAux()
if err != nil {
return fmt.Errorf("failed to select AUX: %w", err)
}
PrintSuccess("AUX input source selected")
return nil
}
// selectLocalInternetRadio handles selecting LOCAL_INTERNET_RADIO source
func selectLocalInternetRadio(c *cli.Context) error {
clientConfig := GetClientConfig(c)
client, err := CreateSoundTouchClient(clientConfig)
if err != nil {
return err
}
location := c.String("location")
if location == "" {
return fmt.Errorf("location is required (use --location)")
}
sourceAccount := c.String("account")
itemName := c.String("name")
containerArt := c.String("artwork")
// Check LOCAL_INTERNET_RADIO availability
checker := NewServiceAvailabilityChecker(client)
if !checker.CheckSourceAvailable("LOCAL_INTERNET_RADIO", "select internet radio") {
return fmt.Errorf("LOCAL_INTERNET_RADIO is not available")
}
PrintDeviceHeader("Selecting internet radio stream", clientConfig.Host, clientConfig.Port)
if itemName != "" {
fmt.Printf(" Station: %s\n", itemName)
}
fmt.Printf(" Location: %s\n", location)
err = client.SelectLocalInternetRadio(location, sourceAccount, itemName, containerArt)
if err != nil {
return fmt.Errorf("failed to select internet radio: %w", err)
}
PrintSuccess("Internet radio stream selected")
return nil
}
// selectCustomRadio handles selecting custom radio stream via soundtouch-service
func selectCustomRadio(c *cli.Context) error {
clientConfig := GetClientConfig(c)
client, err := CreateSoundTouchClient(clientConfig)
if err != nil {
return err
}
streamURL := c.String("url")
itemName := c.String("name")
containerArt := c.String("artwork")
serviceURL := c.String("service-url")
encodedURL := base64.URLEncoding.EncodeToString([]byte(streamURL))
location := fmt.Sprintf("%s/custom/v1/playback/%s", serviceURL, encodedURL)
params := url.Values{}
if itemName != "" {
params.Add("name", itemName)
}
if containerArt != "" {
params.Add("imageUrl", containerArt)
}
if len(params) > 0 {
location += "?" + params.Encode()
}
// Check LOCAL_INTERNET_RADIO availability
checker := NewServiceAvailabilityChecker(client)
if !checker.CheckSourceAvailable("LOCAL_INTERNET_RADIO", "select custom radio") {
return fmt.Errorf("LOCAL_INTERNET_RADIO is not available")
}
PrintDeviceHeader("Selecting custom radio stream", clientConfig.Host, clientConfig.Port)
if itemName != "" {
fmt.Printf(" Station: %s\n", itemName)
}
fmt.Printf(" URL: %s\n", streamURL)
fmt.Printf(" Proxy: %s\n", location)
err = client.SelectLocalInternetRadio(location, "", itemName, containerArt)
if err != nil {
return fmt.Errorf("failed to select custom radio: %w", err)
}
PrintSuccess("Custom radio stream selected")
return nil
}
// selectLocalMusic handles selecting LOCAL_MUSIC source
func selectLocalMusic(c *cli.Context) error {
clientConfig := GetClientConfig(c)
client, err := CreateSoundTouchClient(clientConfig)
if err != nil {
return err
}
location := c.String("location")
if location == "" {
return fmt.Errorf("location is required (use --location)")
}
sourceAccount := c.String("account")
if sourceAccount == "" {
return fmt.Errorf("account is required for LOCAL_MUSIC (use --account)")
}
itemName := c.String("name")
containerArt := c.String("artwork")
// Check LOCAL_MUSIC availability
checker := NewServiceAvailabilityChecker(client)
if !checker.CheckSourceAvailable("LOCAL_MUSIC", "select local music") {
return fmt.Errorf("LOCAL_MUSIC is not available")
}
PrintDeviceHeader("Selecting local music content", clientConfig.Host, clientConfig.Port)
if itemName != "" {
fmt.Printf(" Content: %s\n", itemName)
}
fmt.Printf(" Location: %s\n", location)
fmt.Printf(" Account: %s\n", sourceAccount)
err = client.SelectLocalMusic(location, sourceAccount, itemName, containerArt)
if err != nil {
return fmt.Errorf("failed to select local music: %w", err)
}
PrintSuccess("Local music content selected")
return nil
}
// selectStoredMusic handles selecting STORED_MUSIC source
func selectStoredMusic(c *cli.Context) error {
clientConfig := GetClientConfig(c)
client, err := CreateSoundTouchClient(clientConfig)
if err != nil {
return err
}
location := c.String("location")
if location == "" {
return fmt.Errorf("location is required (use --location)")
}
sourceAccount := c.String("account")
if sourceAccount == "" {
return fmt.Errorf("account is required for STORED_MUSIC (use --account)")
}
itemName := c.String("name")
containerArt := c.String("artwork")
// Check STORED_MUSIC availability
checker := NewServiceAvailabilityChecker(client)
if !checker.CheckSourceAvailable("STORED_MUSIC", "select stored music") {
return fmt.Errorf("STORED_MUSIC is not available")
}
PrintDeviceHeader("Selecting stored music content", clientConfig.Host, clientConfig.Port)
if itemName != "" {
fmt.Printf(" Content: %s\n", itemName)
}
fmt.Printf(" Location: %s\n", location)
fmt.Printf(" Account: %s\n", sourceAccount)
err = client.SelectStoredMusic(location, sourceAccount, itemName, containerArt)
if err != nil {
return fmt.Errorf("failed to select stored music: %w", err)
}
PrintSuccess("Stored music content selected")
return nil
}
// selectContent handles selecting content using a ContentItem directly
func selectContent(c *cli.Context) error {
clientConfig := GetClientConfig(c)
client, err := CreateSoundTouchClient(clientConfig)
if err != nil {
return err
}
// Required parameters
source := strings.ToUpper(c.String("source"))
if source == "" {
return fmt.Errorf("source is required (use --source)")
}
location := c.String("location")
if location == "" {
return fmt.Errorf("location is required (use --location)")
}
// Optional parameters
sourceAccount := c.String("account")
itemName := c.String("name")
containerArt := c.String("artwork")
itemType := c.String("type")
isPresetable := c.Bool("presetable")
// Create ContentItem
contentItem := &models.ContentItem{
Source: source,
Type: itemType,
Location: location,
SourceAccount: sourceAccount,
IsPresetable: isPresetable,
ItemName: itemName,
ContainerArt: containerArt,
}
// Set default type if not specified
if itemType == "" {
switch source {
case "SPOTIFY":
contentItem.Type = "uri"
case "TUNEIN", "LOCAL_INTERNET_RADIO":
contentItem.Type = "stationurl"
case "LOCAL_MUSIC":
contentItem.Type = "album" // default, could be track, artist, etc.
}
}
// Set default item name if not specified
if itemName == "" {
contentItem.ItemName = source
}
PrintDeviceHeader("Selecting content", clientConfig.Host, clientConfig.Port)
fmt.Printf(" Source: %s\n", source)
fmt.Printf(" Location: %s\n", location)
if sourceAccount != "" {
fmt.Printf(" Account: %s\n", sourceAccount)
}
if itemName != "" {
fmt.Printf(" Name: %s\n", itemName)
}
if itemType != "" {
fmt.Printf(" Type: %s\n", itemType)
}
err = client.SelectContentItem(contentItem)
if err != nil {
return fmt.Errorf("failed to select content: %w", err)
}
PrintSuccess("Content selected")
return nil
}
// getServiceAvailability handles displaying service availability information
func getServiceAvailability(c *cli.Context) error {
clientConfig := GetClientConfig(c)
client, err := CreateSoundTouchClient(clientConfig)
if err != nil {
return err
}
PrintDeviceHeader("Getting service availability", clientConfig.Host, clientConfig.Port)
serviceAvailability, err := client.GetServiceAvailability()
if err != nil {
return fmt.Errorf("failed to get service availability: %w", err)
}
fmt.Printf("Service Availability Report:\n")
fmt.Printf(" Total Services: %d\n", serviceAvailability.GetServiceCount())
fmt.Printf(" Available Services: %d\n", serviceAvailability.GetAvailableServiceCount())
fmt.Printf(" Unavailable Services: %d\n", serviceAvailability.GetUnavailableServiceCount())
// Show available services
fmt.Printf("\n✅ Available Services:\n")
availableServices := serviceAvailability.GetAvailableServices()
if len(availableServices) == 0 {
fmt.Printf(" None\n")
} else {
for _, service := range availableServices {
fmt.Printf(" • %s\n", formatServiceTypeForDisplay(models.ServiceType(service.Type)))
}
}
// Show unavailable services with reasons
fmt.Printf("\n❌ Unavailable Services:\n")
unavailableServices := serviceAvailability.GetUnavailableServices()
if len(unavailableServices) == 0 {
fmt.Printf(" None\n")
} else {
for _, service := range unavailableServices {
reason := ""
if service.Reason != "" {
reason = fmt.Sprintf(" (%s)", service.Reason)
}
fmt.Printf(" • %s%s\n", formatServiceTypeForDisplay(models.ServiceType(service.Type)), reason)
}
}
// Show service categories
fmt.Printf("\n🎵 Streaming Services:\n")
streamingServices := serviceAvailability.GetStreamingServices()
availableCount := 0
for _, service := range streamingServices {
status := "❌"
if service.IsAvailable {
status = "✅"
availableCount++
}
fmt.Printf(" %s %s\n", status, formatServiceTypeForDisplay(models.ServiceType(service.Type)))
}
fmt.Printf(" Summary: %d/%d streaming services available\n", availableCount, len(streamingServices))
fmt.Printf("\n🔗 Local Input Services:\n")
localServices := serviceAvailability.GetLocalServices()
localAvailableCount := 0
for _, service := range localServices {
status := "❌"
if service.IsAvailable {
status = "✅"
localAvailableCount++
}
fmt.Printf(" %s %s\n", status, formatServiceTypeForDisplay(models.ServiceType(service.Type)))
}
fmt.Printf(" Summary: %d/%d local services available\n", localAvailableCount, len(localServices))
return nil
}
// compareSourcesAndAvailability compares configured sources with service availability
func compareSourcesAndAvailability(c *cli.Context) error {
clientConfig := GetClientConfig(c)
client, err := CreateSoundTouchClient(clientConfig)
if err != nil {
return err
}
PrintDeviceHeader("Comparing sources and service availability", clientConfig.Host, clientConfig.Port)
// Get both sources and service availability
sources, err := client.GetSources()
if err != nil {
return fmt.Errorf("failed to get sources: %w", err)
}
serviceAvailability, err := client.GetServiceAvailability()
if err != nil {
return fmt.Errorf("failed to get service availability: %w", err)
}
fmt.Printf("Source vs Availability Comparison:\n\n")
performSourceComparisons(sources, serviceAvailability)
printSourceSummary(sources, serviceAvailability)
return nil
}
// performSourceComparisons compares configured sources with availability
func performSourceComparisons(sources *models.Sources, serviceAvailability *models.ServiceAvailability) {
// Check key services
comparisons := []struct {
name string
configuredCheck func() bool
availableCheck func() bool
getConfiguredSources func() []models.SourceItem
}{
{
"Spotify",
sources.HasSpotify,
serviceAvailability.HasSpotify,
sources.GetSpotifySources,
},
{
"Bluetooth",
sources.HasBluetooth,
serviceAvailability.HasBluetooth,
func() []models.SourceItem { return sources.GetSourcesByType("BLUETOOTH") },
},
}
for _, comp := range comparisons {
compareServiceStatus(comp.name, comp.configuredCheck(), comp.availableCheck(), serviceAvailability)
}
}
// compareServiceStatus compares a single service's configuration vs availability
func compareServiceStatus(serviceName string, configured, available bool, serviceAvailability *models.ServiceAvailability) {
fmt.Printf("🔍 %s:\n", serviceName)
fmt.Printf(" Configured: %s\n", boolToStatus(configured))
fmt.Printf(" Available: %s\n", boolToStatus(available))
switch {
case available && !configured:
fmt.Printf(" 💡 %s is available but not configured - consider setting it up\n", serviceName)
case configured && !available:
fmt.Printf(" ⚠️ %s is configured but not available - check device status\n", serviceName)
printServiceUnavailableReason(serviceName, serviceAvailability)
case configured && available:
fmt.Printf(" ✅ %s is properly configured and available\n", serviceName)
default:
fmt.Printf(" %s is neither configured nor available\n", serviceName)
}
fmt.Println()
}
// printServiceUnavailableReason prints the reason why a service is unavailable
func printServiceUnavailableReason(serviceName string, serviceAvailability *models.ServiceAvailability) {
var service *models.Service
switch serviceName {
case "Spotify":
service = serviceAvailability.GetServiceByType(models.ServiceTypeSpotify)
case "Bluetooth":
service = serviceAvailability.GetServiceByType(models.ServiceTypeBluetooth)
}
if service != nil && service.Reason != "" {
fmt.Printf(" 📝 Reason: %s\n", service.Reason)
}
}
// printSourceSummary prints a summary of sources and services
func printSourceSummary(sources *models.Sources, serviceAvailability *models.ServiceAvailability) {
// Summary
fmt.Printf("📊 Summary:\n")
fmt.Printf(" Total configured sources: %d\n", sources.GetSourceCount())
fmt.Printf(" Ready configured sources: %d\n", sources.GetReadySourceCount())
fmt.Printf(" Total available services: %d\n", serviceAvailability.GetAvailableServiceCount())
fmt.Printf(" Total possible services: %d\n", serviceAvailability.GetServiceCount())
}
// boolToStatus converts boolean to user-friendly status
func boolToStatus(b bool) string {
if b {
return "✅ Yes"
}
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
}