mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-15 23:26:13 +00:00
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>
738 lines
20 KiB
Go
738 lines
20 KiB
Go
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
|
||
}
|