feat: implement comprehensive /supportedURLs endpoint with feature mapping system

 New Features:
- Implement missing /supportedURLs endpoint with full XML parsing
- Add comprehensive endpoint-to-feature mapping system (15+ features, 9 categories)
- Create device capability analysis with personalized recommendations
- Add intelligent device classification (Premium, Standard, Basic, Essential, Limited)

🔧 CLI Enhancements:
- Add 'supported-urls' command with --features and --verbose flags
- Add 'analyze' command for comprehensive device capability analysis
- Add 'station list' command for saved station management
- Add 'source availability' and 'source compare' commands
- Enhanced service availability checking across all commands

📚 Models & API:
- New SupportedURLsResponse model with rich helper methods
- Enhanced ServiceAvailability model with validation utilities
- New EndpointFeature mapping system with CLI command references
- Feature completeness scoring and partial implementation detection

🧪 Testing:
- 35+ new test cases covering all functionality
- Comprehensive feature mapping validation tests
- Service availability integration tests with real device scenarios
- Mock server tests for error handling and edge cases

📖 Documentation:
- New FEATURE-MAPPING-GUIDE.md with comprehensive usage examples
- Updated API documentation with correct implementation status
- CLI command reference organized by feature category
- Device troubleshooting guide with capability checking

🎯 Key Capabilities:
- Device feature coverage scoring (0-100%)
- Essential vs optional feature classification
- Personalized CLI command recommendations
- Missing capability detection with usage impact analysis
- Smart device type classification based on supported endpoints

This resolves the documentation inconsistency where /supportedURLs was marked as
implemented but was actually missing from the client. The new implementation goes
far beyond basic endpoint listing to provide intelligent device capability analysis
and personalized usage recommendations.
This commit is contained in:
Tobias Gesellchen
2026-01-31 20:23:30 +01:00
parent 4ebc42f5d5
commit 83e289ab38
24 changed files with 4936 additions and 50 deletions
+403 -8
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"time"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/urfave/cli/v2"
)
@@ -207,11 +208,10 @@ func getPresets(c *cli.Context) error {
return nil
}
// selectPreset selects a preset by number (1-6)
func selectPreset(c *cli.Context) error {
presetNum := c.Int("preset")
// getSupportedURLs handles getting supported URLs/endpoints
func getSupportedURLs(c *cli.Context) error {
clientConfig := GetClientConfig(c)
PrintDeviceHeader(fmt.Sprintf("Selecting preset %d", presetNum), clientConfig.Host, clientConfig.Port)
PrintDeviceHeader("Getting supported URLs", clientConfig.Host, clientConfig.Port)
client, err := CreateSoundTouchClient(clientConfig)
if err != nil {
@@ -219,17 +219,412 @@ func selectPreset(c *cli.Context) error {
return err
}
err = client.SelectPreset(presetNum)
supportedURLs, err := client.GetSupportedURLs()
if err != nil {
PrintError(fmt.Sprintf("Failed to select preset: %v", err))
PrintError(fmt.Sprintf("Failed to get supported URLs: %v", err))
return err
}
PrintSuccess(fmt.Sprintf("Preset %d selected", presetNum))
printSupportedURLs(supportedURLs, c)
return nil
}
// printSupportedURLs formats and displays supported URLs information
func printSupportedURLs(supportedURLs *models.SupportedURLsResponse, c *cli.Context) {
verbose := c.Bool("verbose")
showFeatures := c.Bool("features")
fmt.Printf("Device Supported URLs:\n")
fmt.Printf(" Device ID: %s\n", supportedURLs.DeviceID)
fmt.Printf(" Total Endpoints: %d\n", supportedURLs.GetURLCount())
// Show feature completeness score
completeness, supported, total := supportedURLs.GetFeatureCompleteness()
fmt.Printf(" Feature Coverage: %d%% (%d/%d features)\n\n", completeness, supported, total)
if showFeatures || (!verbose && !showFeatures) {
// Show feature mapping (default view)
printFeatureMapping(supportedURLs, verbose)
}
if verbose {
fmt.Println()
printDetailedEndpoints(supportedURLs)
}
if !showFeatures && !verbose {
fmt.Printf("\n💡 Options:\n")
fmt.Printf(" --features Show detailed feature mapping and CLI commands\n")
fmt.Printf(" --verbose Show complete endpoint list\n")
}
}
// printFeatureMapping displays the feature-to-endpoint mapping
func printFeatureMapping(supportedURLs *models.SupportedURLsResponse, verbose bool) {
fmt.Printf("🎯 Device Feature Support:\n\n")
// Get features organized by category
featuresByCategory := supportedURLs.GetFeaturesByCategory()
printFeatureCategories(featuresByCategory, supportedURLs, verbose)
printMissingEssentialFeatures(supportedURLs)
printPartiallyImplementedFeatures(supportedURLs, verbose)
}
func printFeatureCategories(featuresByCategory map[string][]models.EndpointFeature, supportedURLs *models.SupportedURLsResponse, verbose bool) {
categoryInfo := map[string]string{
"Core": "⚡",
"Audio": "🔊",
"Playback": "▶️",
"Sources": "📱",
"Content": "📻",
"Presets": "⭐",
"Multiroom": "🏠",
"Network": "🌐",
"System": "⚙️",
}
categoryOrder := []string{"Core", "Audio", "Playback", "Sources", "Content", "Presets", "Multiroom", "Network", "System"}
for _, category := range categoryOrder {
features := featuresByCategory[category]
if len(features) == 0 {
continue
}
emoji := categoryInfo[category]
fmt.Printf("%s %s (%d features):\n", emoji, category, len(features))
for _, feature := range features {
printFeatureStatus(feature, supportedURLs, verbose)
}
fmt.Println()
}
}
func printFeatureStatus(feature models.EndpointFeature, supportedURLs *models.SupportedURLsResponse, verbose bool) {
supportedEndpoints := countSupportedEndpoints(feature, supportedURLs)
status := "✅"
if supportedEndpoints < len(feature.Endpoints) && len(feature.Endpoints) > 1 {
status = "⚠️" // Partial support
}
fmt.Printf(" %s %s", status, feature.Name)
if feature.Essential {
fmt.Printf(" ⭐")
}
fmt.Printf("\n")
if verbose {
printVerboseFeatureDetails(feature, supportedEndpoints)
}
}
func countSupportedEndpoints(feature models.EndpointFeature, supportedURLs *models.SupportedURLsResponse) int {
supportedEndpoints := 0
for _, endpoint := range feature.Endpoints {
if supportedURLs.HasURL(endpoint) {
supportedEndpoints++
}
}
return supportedEndpoints
}
func printVerboseFeatureDetails(feature models.EndpointFeature, supportedEndpoints int) {
fmt.Printf(" %s\n", feature.Description)
fmt.Printf(" CLI: %s\n", feature.CLICommand)
fmt.Printf(" Endpoints: %d/%d supported", supportedEndpoints, len(feature.Endpoints))
if supportedEndpoints < len(feature.Endpoints) {
fmt.Printf(" (partial)")
}
fmt.Printf("\n")
}
func printMissingEssentialFeatures(supportedURLs *models.SupportedURLsResponse) {
missingEssential := supportedURLs.GetMissingEssentialFeatures()
if len(missingEssential) > 0 {
fmt.Printf("⚠️ Missing Essential Features:\n")
for _, feature := range missingEssential {
fmt.Printf(" ❌ %s - %s\n", feature.Name, feature.Description)
}
fmt.Println()
}
}
func printPartiallyImplementedFeatures(supportedURLs *models.SupportedURLsResponse, verbose bool) {
partial := supportedURLs.GetPartiallyImplementedFeatures()
if len(partial) > 0 && verbose {
fmt.Printf("⚠️ Partially Supported Features:\n")
for _, feature := range partial {
fmt.Printf(" 🟡 %s\n", feature.Name)
for _, endpoint := range feature.Endpoints {
status := "❌"
if supportedURLs.HasURL(endpoint) {
status = "✅"
}
fmt.Printf(" %s %s\n", status, endpoint)
}
}
fmt.Println()
}
}
// printDetailedEndpoints shows the traditional endpoint listing
func printDetailedEndpoints(supportedURLs *models.SupportedURLsResponse) {
fmt.Printf("📋 Detailed Endpoint Analysis:\n\n")
// Show core functionality
coreURLs := supportedURLs.GetCoreURLs()
if len(coreURLs) > 0 {
fmt.Printf("⚡ Core Functionality (%d endpoints):\n", len(coreURLs))
for _, url := range coreURLs {
fmt.Printf(" • %s\n", url)
}
fmt.Println()
}
// Show streaming functionality
streamingURLs := supportedURLs.GetStreamingURLs()
if len(streamingURLs) > 0 {
fmt.Printf("📻 Streaming Services (%d endpoints):\n", len(streamingURLs))
for _, url := range streamingURLs {
fmt.Printf(" • %s\n", url)
}
fmt.Println()
}
// Show advanced audio functionality
advancedURLs := supportedURLs.GetAdvancedURLs()
if len(advancedURLs) > 0 {
fmt.Printf("🔧 Advanced Audio (%d endpoints):\n", len(advancedURLs))
for _, url := range advancedURLs {
fmt.Printf(" • %s\n", url)
}
fmt.Println()
}
// Show network functionality
networkURLs := supportedURLs.GetNetworkURLs()
if len(networkURLs) > 0 {
fmt.Printf("🌐 Network & Connectivity (%d endpoints):\n", len(networkURLs))
for _, url := range networkURLs {
fmt.Printf(" • %s\n", url)
}
fmt.Println()
}
// Show all supported URLs
fmt.Printf("📝 Complete Endpoint List:\n")
allURLs := supportedURLs.GetURLs()
for i, url := range allURLs {
fmt.Printf(" %3d. %s\n", i+1, url)
}
}
// getDeviceAnalysis handles comprehensive device capability analysis
func getDeviceAnalysis(c *cli.Context) error {
clientConfig := GetClientConfig(c)
PrintDeviceHeader("Analyzing device capabilities", clientConfig.Host, clientConfig.Port)
client, err := CreateSoundTouchClient(clientConfig)
if err != nil {
PrintError(fmt.Sprintf("Failed to create client: %v", err))
return err
}
supportedURLs, err := client.GetSupportedURLs()
if err != nil {
PrintError(fmt.Sprintf("Failed to get supported URLs: %v", err))
return err
}
printDeviceAnalysis(supportedURLs)
return nil
}
// printDeviceAnalysis provides comprehensive device capability analysis
func printDeviceAnalysis(supportedURLs *models.SupportedURLsResponse) {
fmt.Printf("🔍 Device Capability Analysis:\n")
fmt.Printf(" Device ID: %s\n", supportedURLs.DeviceID)
// Overall score
completeness, supported, total := supportedURLs.GetFeatureCompleteness()
fmt.Printf(" Feature Coverage: %d%% (%d/%d features)\n", completeness, supported, total)
// Device classification
classification := classifyDevice(supportedURLs)
fmt.Printf(" Device Type: %s\n\n", classification)
// Essential features check
missingEssential := supportedURLs.GetMissingEssentialFeatures()
if len(missingEssential) > 0 {
fmt.Printf("❌ Missing Essential Features:\n")
for _, feature := range missingEssential {
fmt.Printf(" • %s - %s\n", feature.Name, feature.Description)
fmt.Printf(" Impact: Device may not function properly without this\n")
}
fmt.Println()
} else {
fmt.Printf("✅ All essential features are supported\n\n")
}
// Show what works
supportedFeatures := supportedURLs.GetSupportedFeatures()
fmt.Printf("✅ Available Features (%d):\n", len(supportedFeatures))
categoryCount := make(map[string]int)
for _, feature := range supportedFeatures {
categoryCount[feature.Category]++
}
for category, count := range categoryCount {
emoji := getCategoryEmoji(category)
fmt.Printf(" %s %s: %d features\n", emoji, category, count)
}
fmt.Println()
// Show what's missing
unsupportedFeatures := supportedURLs.GetUnsupportedFeatures()
if len(unsupportedFeatures) > 0 {
fmt.Printf("❌ Unavailable Features (%d):\n", len(unsupportedFeatures))
for _, feature := range unsupportedFeatures {
fmt.Printf(" • %s - %s\n", feature.Name, feature.Description)
}
fmt.Println()
}
// Partial implementations
partial := supportedURLs.GetPartiallyImplementedFeatures()
if len(partial) > 0 {
fmt.Printf("⚠️ Partially Supported Features (%d):\n", len(partial))
for _, feature := range partial {
supportedCount := 0
for _, endpoint := range feature.Endpoints {
if supportedURLs.HasURL(endpoint) {
supportedCount++
}
}
fmt.Printf(" • %s (%d/%d endpoints)\n", feature.Name, supportedCount, len(feature.Endpoints))
}
fmt.Println()
}
// Recommendations
printRecommendations(supportedURLs)
// CLI usage suggestions
printCLIUsageSuggestions(supportedURLs)
}
// classifyDevice determines the device type based on supported features
func classifyDevice(supportedURLs *models.SupportedURLsResponse) string {
if supportedURLs.HasMultiroomSupport() && supportedURLs.HasAdvancedAudioSupport() {
return "Premium SoundTouch Speaker (Full Feature Set)"
}
if supportedURLs.HasMultiroomSupport() {
return "Standard SoundTouch Speaker (Multiroom Capable)"
}
if supportedURLs.HasStreamingSupport() && supportedURLs.HasPresetSupport() {
return "Basic SoundTouch Speaker"
}
if supportedURLs.HasCorePlaybackSupport() {
return "Essential SoundTouch Device"
}
return "Limited SoundTouch Device"
}
// printRecommendations provides usage recommendations based on device capabilities
func printRecommendations(supportedURLs *models.SupportedURLsResponse) {
fmt.Printf("💡 Recommendations:\n")
if supportedURLs.HasMultiroomSupport() {
fmt.Printf(" 🏠 This device supports multiroom - you can create speaker groups\n")
fmt.Printf(" Try: soundtouch-cli zone create --master <this-device> --members <other-devices>\n")
}
if supportedURLs.HasPresetSupport() {
fmt.Printf(" ⭐ Save your favorite content as presets for quick access\n")
fmt.Printf(" Try: soundtouch-cli preset store-current --slot 1\n")
}
if supportedURLs.HasStreamingSupport() {
fmt.Printf(" 📻 Browse and discover new content from streaming services\n")
fmt.Printf(" Try: soundtouch-cli browse tunein, station search-tunein --query jazz\n")
}
if supportedURLs.HasAdvancedAudioSupport() {
fmt.Printf(" 🔧 Fine-tune your audio with advanced controls\n")
fmt.Printf(" Try: soundtouch-cli audio dsp get, audio tone get\n")
}
if !supportedURLs.HasURL("/bassCapabilities") {
fmt.Printf(" ⚠️ Device may have limited bass control options\n")
}
if !supportedURLs.HasURL("/balance") {
fmt.Printf(" ⚠️ No balance control available on this device\n")
}
fmt.Println()
}
// printCLIUsageSuggestions shows common CLI commands for this device
func printCLIUsageSuggestions(supportedURLs *models.SupportedURLsResponse) {
fmt.Printf("🚀 Common Commands for This Device:\n")
// Always available
fmt.Printf(" • Get device info: soundtouch-cli info get\n")
fmt.Printf(" • Control volume: soundtouch-cli volume set --level 50\n")
if supportedURLs.HasURL("/nowPlaying") {
fmt.Printf(" • Check what's playing: soundtouch-cli play now\n")
}
if supportedURLs.HasURL("/sources") {
fmt.Printf(" • List audio sources: soundtouch-cli source list\n")
}
if supportedURLs.HasURL("/presets") {
fmt.Printf(" • Manage presets: soundtouch-cli preset list\n")
}
if supportedURLs.HasURL("/bass") {
fmt.Printf(" • Adjust bass: soundtouch-cli bass set --level 5\n")
}
if supportedURLs.HasURL("/setZone") {
fmt.Printf(" • Create speaker group: soundtouch-cli zone create\n")
}
if supportedURLs.HasURL("/search") {
fmt.Printf(" • Search content: soundtouch-cli station search-tunein --query \"classic rock\"\n")
}
fmt.Println()
}
// getCategoryEmoji returns emoji for feature categories
func getCategoryEmoji(category string) string {
emojis := map[string]string{
"Core": "⚡",
"Audio": "🔊",
"Playback": "▶️",
"Sources": "📱",
"Content": "📻",
"Presets": "⭐",
"Multiroom": "🏠",
"Network": "🌐",
"System": "⚙️",
}
if emoji, exists := emojis[category]; exists {
return emoji
}
return "📋"
}
// getTrackInfo gets the track information
func getTrackInfo(c *cli.Context) error {
clientConfig := GetClientConfig(c)
-1
View File
@@ -133,7 +133,6 @@ func TestShouldShowContentDetails(t *testing.T) {
})
}
}
func TestContentDetailsDisplayLogic(t *testing.T) {
// Test the specific conditions that determine when to show content details
tests := []struct {
+200
View File
@@ -89,6 +89,11 @@ func listSources(c *cli.Context) error {
}
}
// Show service availability summary
fmt.Println()
checker := NewServiceAvailabilityChecker(client)
checker.PrintServiceAvailabilitySummary()
return nil
}
@@ -104,6 +109,14 @@ func selectSource(c *cli.Context) error {
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)
@@ -129,6 +142,12 @@ func selectSpotify(c *cli.Context) error {
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("")
@@ -150,6 +169,12 @@ func selectBluetooth(c *cli.Context) error {
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()
@@ -182,3 +207,178 @@ func selectAux(c *cli.Context) error {
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")
// 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 {
configured := comp.configuredCheck()
available := comp.availableCheck()
fmt.Printf("🔍 %s:\n", comp.name)
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", comp.name)
case configured && !available:
fmt.Printf(" ⚠️ %s is configured but not available - check device status\n", comp.name)
// Show specific reason if available
switch comp.name {
case "Spotify":
service := serviceAvailability.GetServiceByType(models.ServiceTypeSpotify)
if service != nil && service.Reason != "" {
fmt.Printf(" 📝 Reason: %s\n", service.Reason)
}
case "Bluetooth":
service := serviceAvailability.GetServiceByType(models.ServiceTypeBluetooth)
if service != nil && service.Reason != "" {
fmt.Printf(" 📝 Reason: %s\n", service.Reason)
}
}
case configured && available:
fmt.Printf(" ✅ %s is properly configured and available\n", comp.name)
default:
fmt.Printf(" %s is neither configured nor available\n", comp.name)
}
fmt.Println()
}
// 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())
return nil
}
// boolToStatus converts boolean to user-friendly status
func boolToStatus(b bool) string {
if b {
return "✅ Yes"
}
return "❌ No"
}
+115 -27
View File
@@ -28,6 +28,13 @@ func searchStations(c *cli.Context) error {
return err
}
// Check service availability for the source
checker := NewServiceAvailabilityChecker(client)
actionDescription := fmt.Sprintf("search %s stations", source)
if !checker.CheckSourceAvailable(source, actionDescription) {
return fmt.Errorf("source '%s' is not available for station search", source)
}
response, err := client.SearchStation(source, sourceAccount, searchTerm)
if err != nil {
PrintError(fmt.Sprintf("Failed to search stations: %v", err))
@@ -56,6 +63,12 @@ func searchTuneIn(c *cli.Context) error {
return err
}
// Check TuneIn availability
checker := NewServiceAvailabilityChecker(client)
if !checker.ValidateTuneInAvailable("search TuneIn stations") {
return fmt.Errorf("TuneIn is not available on this device")
}
response, err := client.SearchTuneInStations(searchTerm)
if err != nil {
PrintError(fmt.Sprintf("Failed to search TuneIn: %v", err))
@@ -90,6 +103,12 @@ func searchPandora(c *cli.Context) error {
return err
}
// Check Pandora availability
checker := NewServiceAvailabilityChecker(client)
if !checker.ValidatePandoraAvailable("search Pandora stations") {
return fmt.Errorf("pandora is not available on this device")
}
response, err := client.SearchPandoraStations(sourceAccount, searchTerm)
if err != nil {
PrintError(fmt.Sprintf("Failed to search Pandora: %v", err))
@@ -124,6 +143,12 @@ func searchSpotify(c *cli.Context) error {
return err
}
// Check Spotify availability
checker := NewServiceAvailabilityChecker(client)
if !checker.ValidateSpotifyAvailable("search Spotify content") {
return fmt.Errorf("Spotify is not available on this device")
}
response, err := client.SearchSpotifyContent(sourceAccount, searchTerm)
if err != nil {
PrintError(fmt.Sprintf("Failed to search Spotify: %v", err))
@@ -165,6 +190,13 @@ func addStation(c *cli.Context) error {
return err
}
// Check service availability for the source
checker := NewServiceAvailabilityChecker(client)
actionDescription := fmt.Sprintf("add %s station", source)
if !checker.CheckSourceAvailable(source, actionDescription) {
return fmt.Errorf("source '%s' is not available for adding stations", source)
}
err = client.AddStation(source, sourceAccount, token, name)
if err != nil {
PrintError(fmt.Sprintf("Failed to add station: %v", err))
@@ -237,7 +269,8 @@ func printSearchResults(response *models.SearchStationResponse, searchTerm strin
if len(songs) > 0 {
fmt.Printf("\n 🎵 Songs (%d):\n", len(songs))
for i, song := range songs {
for i := range songs {
song := &songs[i]
fmt.Printf(" %d. %s\n", i+1, song.GetDisplayName())
if song.Artist != "" {
fmt.Printf(" Artist: %s\n", song.Artist)
@@ -255,7 +288,8 @@ func printSearchResults(response *models.SearchStationResponse, searchTerm strin
if len(artists) > 0 {
fmt.Printf(" 🎤 Artists (%d):\n", len(artists))
for i, artist := range artists {
for i := range artists {
artist := &artists[i]
fmt.Printf(" %d. %s\n", i+1, artist.GetDisplayName())
if artist.SourceAccount != "" {
fmt.Printf(" Account: %s\n", artist.SourceAccount)
@@ -267,7 +301,8 @@ func printSearchResults(response *models.SearchStationResponse, searchTerm strin
if len(stations) > 0 {
fmt.Printf(" 📻 Stations (%d):\n", len(stations))
for i, station := range stations {
for i := range stations {
station := &stations[i]
fmt.Printf(" %d. %s\n", i+1, station.GetDisplayName())
if station.SourceAccount != "" {
fmt.Printf(" Account: %s\n", station.SourceAccount)
@@ -294,43 +329,96 @@ func printSearchResults(response *models.SearchStationResponse, searchTerm strin
// hasAccountResults checks if any results have source accounts
func hasAccountResults(response *models.SearchStationResponse) bool {
allResults := response.GetAllResults()
for _, result := range allResults {
if result.SourceAccount != "" {
for i := range allResults {
if allResults[i].SourceAccount != "" {
return true
}
}
return false
}
// validateStationSource validates that the source is supported for station operations
func validateStationSource(source string) error {
if source == "" {
return fmt.Errorf("source is required")
// listStations handles listing saved stations
func listStations(c *cli.Context) error {
source := c.String("source")
sourceAccount := c.String("source-account")
clientConfig := GetClientConfig(c)
PrintDeviceHeader(fmt.Sprintf("Getting %s stations", source), clientConfig.Host, clientConfig.Port)
client, err := CreateSoundTouchClient(clientConfig)
if err != nil {
PrintError(fmt.Sprintf("Failed to create client: %v", err))
return err
}
validSources := []string{"TUNEIN", "PANDORA", "SPOTIFY"}
for _, validSource := range validSources {
if strings.EqualFold(source, validSource) {
return nil
// Check service availability for the source
checker := NewServiceAvailabilityChecker(client)
actionDescription := fmt.Sprintf("list %s stations", source)
if !checker.CheckSourceAvailable(source, actionDescription) {
return fmt.Errorf("source '%s' is not available for listing stations", source)
}
var response *models.NavigateResponse
switch strings.ToUpper(source) {
case "TUNEIN":
response, err = client.GetTuneInStations(sourceAccount)
case "PANDORA":
if sourceAccount == "" {
PrintError("Pandora source account is required")
return fmt.Errorf("source account required for Pandora")
}
response, err = client.GetPandoraStations(sourceAccount)
default:
return fmt.Errorf("listing stations is not supported for source: %s", source)
}
return fmt.Errorf("invalid source: %s (valid sources: %v)", source, validSources)
}
// formatStationToken formats a station token for display (truncate if too long)
func formatStationToken(token string) string {
if len(token) <= 50 {
return token
if err != nil {
PrintError(fmt.Sprintf("Failed to get stations: %v", err))
return err
}
return token[:47] + "..."
printStationList(response, source)
return nil
}
// extractStationInfo extracts key information from a search result for display
func extractStationInfo(result *models.SearchResult) (string, string, string) {
name := result.GetDisplayName()
token := result.Token
description := result.Description
// printStationList formats and displays saved station results
func printStationList(response *models.NavigateResponse, source string) {
fmt.Printf("Saved %s Stations:\n", source)
return name, token, description
if response.TotalItems == 0 {
fmt.Printf(" No stations found\n")
return
}
stations := response.GetStations()
fmt.Printf(" Total stations: %d\n", response.TotalItems)
fmt.Printf(" Showing: %d\n\n", len(stations))
for i, station := range stations {
fmt.Printf(" %d. %s\n", i+1, station.Name)
if station.ContentItem != nil {
if station.ContentItem.Location != "" {
fmt.Printf(" Location: %s\n", station.ContentItem.Location)
}
if station.ContentItem.SourceAccount != "" {
fmt.Printf(" Account: %s\n", station.ContentItem.SourceAccount)
}
if station.ContentItem.IsPresetable {
fmt.Printf(" Can be saved as preset: Yes\n")
}
}
if station.Type != "" {
fmt.Printf(" Type: %s\n", station.Type)
}
fmt.Println()
}
// Show usage hints
fmt.Printf("💡 Usage hints:\n")
fmt.Printf(" • To play a station: Use the location value with 'play content' command\n")
fmt.Printf(" • To save as preset: Use 'preset set' command with the location\n")
}
+55
View File
@@ -178,6 +178,32 @@ func main() {
Action: getCapabilities,
Before: RequireHost,
},
{
Name: "supported-urls",
Aliases: []string{"urls"},
Usage: "Get supported device endpoints",
Action: getSupportedURLs,
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "verbose",
Aliases: []string{"v"},
Usage: "Show complete endpoint list",
},
&cli.BoolFlag{
Name: "features",
Aliases: []string{"f"},
Usage: "Show detailed feature mapping and CLI commands",
},
},
Before: RequireHost,
},
{
Name: "analyze",
Aliases: []string{"analysis"},
Usage: "Analyze device capabilities and provide recommendations",
Action: getDeviceAnalysis,
Before: RequireHost,
},
{
Name: "presets",
Usage: "Get configured presets",
@@ -616,6 +642,23 @@ func main() {
},
Before: RequireHost,
},
{
Name: "list",
Usage: "List saved stations",
Action: listStations,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "source",
Usage: "Station source (TUNEIN, PANDORA)",
Required: true,
},
&cli.StringFlag{
Name: "source-account",
Usage: "Source account (required for Pandora)",
},
},
Before: RequireHost,
},
},
},
// Key commands
@@ -788,6 +831,18 @@ func main() {
Action: selectAux,
Before: RequireHost,
},
{
Name: "availability",
Usage: "Show service availability",
Action: getServiceAvailability,
Before: RequireHost,
},
{
Name: "compare",
Usage: "Compare sources and service availability",
Action: compareSourcesAndAvailability,
Before: RequireHost,
},
},
},
// Bass commands
+382
View File
@@ -0,0 +1,382 @@
package main
import (
"fmt"
"os"
"strings"
"github.com/gesellix/bose-soundtouch/pkg/client"
"github.com/gesellix/bose-soundtouch/pkg/models"
)
// ServiceAvailabilityChecker provides service availability validation for CLI commands
type ServiceAvailabilityChecker struct {
client *client.Client
serviceAvailability *models.ServiceAvailability
skipAvailabilityCheck bool
cached bool
}
// NewServiceAvailabilityChecker creates a new service availability checker
func NewServiceAvailabilityChecker(client *client.Client) *ServiceAvailabilityChecker {
skipCheck := os.Getenv("SOUNDTOUCH_SKIP_AVAILABILITY_CHECK") == "true" ||
os.Getenv("SOUNDTOUCH_SKIP_AVAILABILITY_CHECK") == "1"
return &ServiceAvailabilityChecker{
client: client,
skipAvailabilityCheck: skipCheck,
cached: false,
}
}
// loadServiceAvailability loads service availability data (cached after first call)
func (sac *ServiceAvailabilityChecker) loadServiceAvailability() {
if sac.cached {
return
}
if sac.skipAvailabilityCheck {
// Create a mock availability that allows everything
sac.serviceAvailability = &models.ServiceAvailability{}
sac.cached = true
return
}
serviceAvailability, err := sac.client.GetServiceAvailability()
if err != nil {
// If availability check fails, warn but don't fail the command
PrintWarning(fmt.Sprintf("Could not check service availability: %v", err))
if !sac.skipAvailabilityCheck {
PrintWarning("Command will proceed without availability validation")
PrintWarning("Set SOUNDTOUCH_SKIP_AVAILABILITY_CHECK=true to disable these checks")
}
// Create empty availability to prevent further errors
sac.serviceAvailability = &models.ServiceAvailability{}
sac.cached = true
return
}
sac.serviceAvailability = serviceAvailability
sac.cached = true
}
// CheckServiceAvailable validates if a service is available and provides user feedback
func (sac *ServiceAvailabilityChecker) CheckServiceAvailable(serviceType models.ServiceType, actionDescription string) bool {
if sac.skipAvailabilityCheck {
return true
}
sac.loadServiceAvailability()
// If we couldn't load availability data, allow the operation
if sac.serviceAvailability == nil || sac.serviceAvailability.Services == nil {
return true
}
if sac.serviceAvailability.IsServiceAvailable(serviceType) {
return true
}
// Service is not available - provide helpful feedback
serviceName := formatServiceTypeForDisplay(serviceType)
PrintError(fmt.Sprintf("Cannot %s: %s service is not available", actionDescription, serviceName))
// Get specific reason if available
service := sac.serviceAvailability.GetServiceByType(serviceType)
if service != nil && service.Reason != "" {
PrintError(fmt.Sprintf("Reason: %s", service.Reason))
}
// Provide troubleshooting hints
sac.provideTroubleshootingHints(serviceType)
// Suggest alternatives
sac.suggestAlternatives(serviceType, actionDescription)
PrintWarning("To bypass this check, set SOUNDTOUCH_SKIP_AVAILABILITY_CHECK=true")
return false
}
// CheckSourceAvailable validates if a source string corresponds to an available service
func (sac *ServiceAvailabilityChecker) CheckSourceAvailable(source, actionDescription string) bool {
if sac.skipAvailabilityCheck {
return true
}
serviceType := sourceToServiceType(source)
if serviceType == "" {
// Unknown source type, allow it (might be a valid source not in our list)
return true
}
return sac.CheckServiceAvailable(serviceType, actionDescription)
}
// ValidateSpotifyAvailable checks Spotify availability for Spotify-specific operations
func (sac *ServiceAvailabilityChecker) ValidateSpotifyAvailable(actionDescription string) bool {
return sac.CheckServiceAvailable(models.ServiceTypeSpotify, actionDescription)
}
// ValidateBluetoothAvailable checks Bluetooth availability for Bluetooth operations
func (sac *ServiceAvailabilityChecker) ValidateBluetoothAvailable(actionDescription string) bool {
return sac.CheckServiceAvailable(models.ServiceTypeBluetooth, actionDescription)
}
// ValidateTuneInAvailable checks TuneIn availability for radio operations
func (sac *ServiceAvailabilityChecker) ValidateTuneInAvailable(actionDescription string) bool {
return sac.CheckServiceAvailable(models.ServiceTypeTuneIn, actionDescription)
}
// ValidatePandoraAvailable checks Pandora availability for Pandora operations
func (sac *ServiceAvailabilityChecker) ValidatePandoraAvailable(actionDescription string) bool {
return sac.CheckServiceAvailable(models.ServiceTypePandora, actionDescription)
}
// GetAvailableStreamingServices returns a list of available streaming services for user feedback
func (sac *ServiceAvailabilityChecker) GetAvailableStreamingServices() []string {
if sac.skipAvailabilityCheck {
return []string{"All services (availability check disabled)"}
}
sac.loadServiceAvailability()
if sac.serviceAvailability == nil || sac.serviceAvailability.Services == nil {
return []string{"Unable to determine available services"}
}
streamingServices := sac.serviceAvailability.GetStreamingServices()
var available []string
for _, service := range streamingServices {
if service.IsAvailable {
available = append(available, formatServiceTypeForDisplay(models.ServiceType(service.Type)))
}
}
if len(available) == 0 {
return []string{"No streaming services currently available"}
}
return available
}
// GetAvailableLocalServices returns a list of available local input services
func (sac *ServiceAvailabilityChecker) GetAvailableLocalServices() []string {
if sac.skipAvailabilityCheck {
return []string{"All services (availability check disabled)"}
}
sac.loadServiceAvailability()
if sac.serviceAvailability == nil || sac.serviceAvailability.Services == nil {
return []string{"Unable to determine available services"}
}
localServices := sac.serviceAvailability.GetLocalServices()
var available []string
for _, service := range localServices {
if service.IsAvailable {
available = append(available, formatServiceTypeForDisplay(models.ServiceType(service.Type)))
}
}
if len(available) == 0 {
return []string{"No local input services currently available"}
}
return available
}
// provideTroubleshootingHints provides specific troubleshooting advice based on service type
func (sac *ServiceAvailabilityChecker) provideTroubleshootingHints(serviceType models.ServiceType) {
switch serviceType {
case models.ServiceTypeBluetooth:
PrintWarning("💡 Bluetooth troubleshooting:")
PrintWarning(" • Check if your device supports Bluetooth audio input")
PrintWarning(" • Ensure Bluetooth is enabled on the SoundTouch device")
PrintWarning(" • Try restarting the device")
case models.ServiceTypeSpotify:
PrintWarning("💡 Spotify troubleshooting:")
PrintWarning(" • Ensure you have a Spotify Premium account")
PrintWarning(" • Check if you're logged in to Spotify on the device")
PrintWarning(" • Verify your network connection")
case models.ServiceTypeAirPlay:
PrintWarning("💡 AirPlay troubleshooting:")
PrintWarning(" • Ensure your Apple device and SoundTouch are on the same network")
PrintWarning(" • Check that AirPlay is enabled in device settings")
PrintWarning(" • Verify network connectivity")
case models.ServiceTypeAlexa:
PrintWarning("💡 Alexa troubleshooting:")
PrintWarning(" • Check if Amazon Alexa is properly configured")
PrintWarning(" • Ensure the device is connected to your Amazon account")
PrintWarning(" • Verify internet connectivity")
case models.ServiceTypeTuneIn:
PrintWarning("💡 TuneIn troubleshooting:")
PrintWarning(" • Check internet connectivity")
PrintWarning(" • Verify the device can access external streaming services")
case models.ServiceTypePandora:
PrintWarning("💡 Pandora troubleshooting:")
PrintWarning(" • Ensure you have a valid Pandora account")
PrintWarning(" • Check if you're logged in to Pandora on the device")
PrintWarning(" • Verify internet connectivity")
}
}
// suggestAlternatives suggests alternative services when the requested one is unavailable
func (sac *ServiceAvailabilityChecker) suggestAlternatives(serviceType models.ServiceType, _ string) {
if sac.serviceAvailability == nil {
return
}
switch serviceType {
case models.ServiceTypeSpotify:
if sac.serviceAvailability.HasTuneIn() {
PrintWarning("💡 Alternative: TuneIn Radio is available for music streaming")
}
if sac.serviceAvailability.HasPandora() {
PrintWarning("💡 Alternative: Pandora is available for music streaming")
}
case models.ServiceTypeBluetooth:
if sac.serviceAvailability.HasAirPlay() {
PrintWarning("💡 Alternative: AirPlay is available for wireless audio")
}
if sac.serviceAvailability.HasLocalMusic() {
PrintWarning("💡 Alternative: Local Music Library is available")
}
case models.ServiceTypeTuneIn:
if sac.serviceAvailability.HasSpotify() {
PrintWarning("💡 Alternative: Spotify is available for music streaming")
}
if sac.serviceAvailability.HasPandora() {
PrintWarning("💡 Alternative: Pandora is available for music streaming")
}
}
// Show all available streaming services as suggestions
available := sac.GetAvailableStreamingServices()
if len(available) > 0 && available[0] != "No streaming services currently available" {
PrintWarning(fmt.Sprintf("💡 Available streaming services: %s", strings.Join(available, ", ")))
}
}
// sourceToServiceType maps source strings to service types
func sourceToServiceType(source string) models.ServiceType {
switch strings.ToUpper(source) {
case "SPOTIFY":
return models.ServiceTypeSpotify
case "BLUETOOTH":
return models.ServiceTypeBluetooth
case "AIRPLAY":
return models.ServiceTypeAirPlay
case "ALEXA":
return models.ServiceTypeAlexa
case "AMAZON":
return models.ServiceTypeAmazon
case "PANDORA":
return models.ServiceTypePandora
case "TUNEIN":
return models.ServiceTypeTuneIn
case "DEEZER":
return models.ServiceTypeDeezer
case "IHEART", "IHEARTRADIO":
return models.ServiceTypeIHeart
case "LOCAL_INTERNET_RADIO":
return models.ServiceTypeLocalInternetRadio
case "LOCAL_MUSIC":
return models.ServiceTypeLocalMusic
case "BMX":
return models.ServiceTypeBMX
case "NOTIFICATION":
return models.ServiceTypeNotification
default:
return ""
}
}
// formatServiceTypeForDisplay formats service types for user-friendly display
func formatServiceTypeForDisplay(serviceType models.ServiceType) string {
switch serviceType {
case models.ServiceTypeSpotify:
return "Spotify"
case models.ServiceTypeBluetooth:
return "Bluetooth"
case models.ServiceTypeAirPlay:
return "AirPlay"
case models.ServiceTypeAlexa:
return "Amazon Alexa"
case models.ServiceTypeAmazon:
return "Amazon Music"
case models.ServiceTypePandora:
return "Pandora"
case models.ServiceTypeTuneIn:
return "TuneIn Radio"
case models.ServiceTypeDeezer:
return "Deezer"
case models.ServiceTypeIHeart:
return "iHeartRadio"
case models.ServiceTypeLocalInternetRadio:
return "Internet Radio"
case models.ServiceTypeLocalMusic:
return "Local Music Library"
case models.ServiceTypeBMX:
return "BMX"
case models.ServiceTypeNotification:
return "Notifications"
default:
return string(serviceType)
}
}
// PrintServiceAvailabilitySummary prints a summary of available services
func (sac *ServiceAvailabilityChecker) PrintServiceAvailabilitySummary() {
if sac.skipAvailabilityCheck {
PrintWarning("Service availability checking is disabled")
return
}
sac.loadServiceAvailability()
if sac.serviceAvailability == nil || sac.serviceAvailability.Services == nil {
PrintWarning("Unable to determine service availability")
return
}
fmt.Printf("📊 Service Availability Summary:\n")
fmt.Printf(" Total services: %d\n", sac.serviceAvailability.GetServiceCount())
fmt.Printf(" Available: %d\n", sac.serviceAvailability.GetAvailableServiceCount())
fmt.Printf(" Unavailable: %d\n", sac.serviceAvailability.GetUnavailableServiceCount())
// Show quick status for popular services
fmt.Printf(" Popular services:\n")
popularChecks := []struct {
check func() bool
name string
}{
{sac.serviceAvailability.HasSpotify, "Spotify"},
{sac.serviceAvailability.HasBluetooth, "Bluetooth"},
{sac.serviceAvailability.HasAirPlay, "AirPlay"},
{sac.serviceAvailability.HasTuneIn, "TuneIn Radio"},
{sac.serviceAvailability.HasPandora, "Pandora"},
}
for _, check := range popularChecks {
status := "❌"
if check.check() {
status = "✅"
}
fmt.Printf(" %s %s\n", status, check.name)
}
fmt.Printf("💡 Use 'soundtouch-cli sources list' to see configured sources\n")
}