mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 16:46:17 +00:00
🔥 NEW ENDPOINTS IMPLEMENTED: 📊 /introspect endpoint: - Get detailed music service state and capabilities data - Support for SPOTIFY, PANDORA, TUNEIN, AMAZON, DEEZER services - Service state tracking (Active, Inactive, InactiveUnselected) - Playback capabilities (skip, seek, resume, data collection) - Authentication token status and user account information - Subscription type and content history metadata 📚 /recents endpoint: - Retrieve recently played content history - Support for all music sources (Spotify, Local, TuneIn, Pandora, etc.) - Rich filtering by source type and content type - Content classification (tracks, stations, playlists, albums) - Presetable item identification and artwork metadata - Timestamp tracking with UTC time support ⚡ CLIENT API: - client.Introspect(source, sourceAccount) method - client.IntrospectSpotify(sourceAccount) convenience method - client.GetRecents() method with comprehensive filtering - Complete error handling and validation - Rich helper methods for content analysis 🖥️ CLI COMMANDS: - soundtouch-cli source introspect --source <SERVICE> - soundtouch-cli source introspect-spotify - soundtouch-cli source introspect-all (bulk introspect) - soundtouch-cli recents list [--detailed] [--limit N] - soundtouch-cli recents filter --source <SRC> --type <TYPE> - soundtouch-cli recents latest (most recent item) - soundtouch-cli recents stats (detailed analytics) 📦 MODELS & FEATURES: - IntrospectRequest/Response with service-specific handling - RecentsResponse with RecentsResponseItem for individual items - Rich filtering: GetSpotifyItems(), GetTracks(), GetPresetableItems() - Content type detection: IsTrack(), IsStation(), IsPlaylist() - Source classification: IsStreamingContent(), IsLocalContent() - Full XML marshalling/unmarshalling with proper attribute handling 🧪 COMPREHENSIVE TESTING: - Unit tests for models with XML parsing validation - Integration tests for real device communication - CLI command tests with mock server responses - Error condition testing and edge case handling - Performance tests and timeout validation 📖 DOCUMENTATION & EXAMPLES: - Updated API endpoints overview marking endpoints as implemented - Comprehensive CLI reference with usage examples - Removed endpoints from unimplemented list - Updated wiki implementation plan status - Complete example applications with README guides - Real-world usage patterns and best practices ✨ KEY FEATURES: - Service health monitoring and diagnostics - Recently played content discovery and analysis - Preset candidate identification - Content statistics and usage analytics - Time-based filtering and relative timestamps - Rich emoji-based CLI output formatting - Cross-service compatibility and error handling This implements two critical missing endpoints from the SoundTouch API, providing essential functionality for music service management and recently played content analysis with full programmatic and CLI access.
340 lines
9.8 KiB
Go
340 lines
9.8 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gesellix/bose-soundtouch/pkg/models"
|
|
"github.com/urfave/cli/v2"
|
|
)
|
|
|
|
// introspectService handles getting introspect data for a specific service
|
|
func introspectService(c *cli.Context) error {
|
|
clientConfig := GetClientConfig(c)
|
|
|
|
client, err := CreateSoundTouchClient(clientConfig)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
source := strings.ToUpper(c.String("source"))
|
|
sourceAccount := c.String("account")
|
|
|
|
// Check service availability first
|
|
checker := NewServiceAvailabilityChecker(client)
|
|
if !checker.CheckSourceAvailable(source, fmt.Sprintf("get introspect data for %s", strings.ToLower(source))) {
|
|
PrintWarning(fmt.Sprintf("Service %s may not be available, but continuing with introspect request...", source))
|
|
}
|
|
|
|
PrintDeviceHeader(fmt.Sprintf("Getting introspect data for %s", source), clientConfig.Host, clientConfig.Port)
|
|
|
|
if sourceAccount != "" {
|
|
fmt.Printf("Source Account: %s\n", sourceAccount)
|
|
}
|
|
fmt.Println()
|
|
|
|
response, err := client.Introspect(source, sourceAccount)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get introspect data: %w", err)
|
|
}
|
|
|
|
// Print basic information
|
|
fmt.Printf("=== %s Service Introspect Data ===\n", source)
|
|
printIntrospectBasicInfo(response)
|
|
|
|
// Print service state
|
|
fmt.Printf("\n=== Service State ===\n")
|
|
printIntrospectServiceState(response)
|
|
|
|
// Print capabilities
|
|
fmt.Printf("\n=== Service Capabilities ===\n")
|
|
printIntrospectCapabilities(response)
|
|
|
|
// Print history information
|
|
if response.GetMaxHistorySize() > 0 {
|
|
fmt.Printf("\n=== Content History ===\n")
|
|
printIntrospectHistory(response)
|
|
}
|
|
|
|
// Print technical details
|
|
if response.TokenLastChangedTimeSeconds > 0 || response.PlayStatusState != "" {
|
|
fmt.Printf("\n=== Technical Details ===\n")
|
|
printIntrospectTechnicalDetails(response)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// introspectSpotify handles getting Spotify introspect data using convenience method
|
|
func introspectSpotify(c *cli.Context) error {
|
|
clientConfig := GetClientConfig(c)
|
|
|
|
client, err := CreateSoundTouchClient(clientConfig)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
sourceAccount := c.String("account")
|
|
|
|
// Check Spotify availability
|
|
checker := NewServiceAvailabilityChecker(client)
|
|
if !checker.ValidateSpotifyAvailable("get Spotify introspect data") {
|
|
PrintWarning("Spotify may not be available, but continuing with introspect request...")
|
|
}
|
|
|
|
PrintDeviceHeader("Getting Spotify introspect data", clientConfig.Host, clientConfig.Port)
|
|
|
|
if sourceAccount != "" {
|
|
fmt.Printf("Spotify Account: %s\n", sourceAccount)
|
|
}
|
|
fmt.Println()
|
|
|
|
response, err := client.IntrospectSpotify(sourceAccount)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get Spotify introspect data: %w", err)
|
|
}
|
|
|
|
// Print Spotify-specific information
|
|
fmt.Printf("=== Spotify Service Introspect Data ===\n")
|
|
printIntrospectBasicInfo(response)
|
|
|
|
// Print service state with Spotify context
|
|
fmt.Printf("\n=== Spotify Service State ===\n")
|
|
printIntrospectServiceState(response)
|
|
|
|
// Print Spotify capabilities
|
|
fmt.Printf("\n=== Spotify Service Capabilities ===\n")
|
|
printIntrospectCapabilities(response)
|
|
|
|
// Show Spotify-specific recommendations
|
|
if response.IsInactive() {
|
|
fmt.Printf("\n💡 Spotify Setup Recommendations:\n")
|
|
if !response.HasUser() {
|
|
fmt.Printf(" • Sign in to your Spotify account on the device\n")
|
|
}
|
|
fmt.Printf(" • Use 'soundtouch-cli source select --source SPOTIFY' to activate Spotify\n")
|
|
fmt.Printf(" • Ensure you have Spotify Premium for full functionality\n")
|
|
}
|
|
|
|
// Print history information
|
|
if response.GetMaxHistorySize() > 0 {
|
|
fmt.Printf("\n=== Spotify Content History ===\n")
|
|
printIntrospectHistory(response)
|
|
}
|
|
|
|
// Print technical details
|
|
if response.TokenLastChangedTimeSeconds > 0 || response.PlayStatusState != "" {
|
|
fmt.Printf("\n=== Technical Details ===\n")
|
|
printIntrospectTechnicalDetails(response)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// introspectAllServices handles getting introspect data for all available services
|
|
func introspectAllServices(c *cli.Context) error {
|
|
clientConfig := GetClientConfig(c)
|
|
|
|
client, err := CreateSoundTouchClient(clientConfig)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
PrintDeviceHeader("Getting introspect data for all services", clientConfig.Host, clientConfig.Port)
|
|
|
|
// Get service availability to know which services to check
|
|
serviceAvailability, err := client.GetServiceAvailability()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get service availability: %w", err)
|
|
}
|
|
|
|
// Services to introspect (only streaming services that support introspect)
|
|
servicesToCheck := []string{"SPOTIFY", "PANDORA", "TUNEIN", "AMAZON", "DEEZER"}
|
|
|
|
successCount := 0
|
|
failCount := 0
|
|
|
|
for i, source := range servicesToCheck {
|
|
if i > 0 {
|
|
fmt.Println("\n" + strings.Repeat("─", 50))
|
|
}
|
|
|
|
// Check if service is available
|
|
serviceType := sourceToServiceType(source)
|
|
if serviceType != "" && !serviceAvailability.IsServiceAvailable(serviceType) {
|
|
fmt.Printf("\n❌ %s: Service not available on this device\n", source)
|
|
continue
|
|
}
|
|
|
|
fmt.Printf("\n🔍 Getting introspect data for %s...\n", source)
|
|
|
|
response, err := client.Introspect(source, "")
|
|
if err != nil {
|
|
fmt.Printf("❌ %s: Failed to get introspect data - %v\n", source, err)
|
|
failCount++
|
|
continue
|
|
}
|
|
|
|
fmt.Printf("✅ %s: Successfully retrieved introspect data\n", source)
|
|
printIntrospectSummary(source, response)
|
|
successCount++
|
|
}
|
|
|
|
// Print summary
|
|
fmt.Print("\n" + strings.Repeat("═", 50) + "\n")
|
|
fmt.Printf("📊 Introspect Summary:\n")
|
|
fmt.Printf(" ✅ Successful: %d services\n", successCount)
|
|
fmt.Printf(" ❌ Failed: %d services\n", failCount)
|
|
fmt.Printf(" 📡 Total checked: %d services\n", len(servicesToCheck))
|
|
|
|
if successCount > 0 {
|
|
PrintSuccess(fmt.Sprintf("Successfully retrieved introspect data for %d services", successCount))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// printIntrospectBasicInfo prints basic introspect information
|
|
func printIntrospectBasicInfo(response *models.IntrospectResponse) {
|
|
fmt.Printf("State: %s\n", response.State)
|
|
|
|
if response.HasUser() {
|
|
fmt.Printf("User: %s\n", response.User)
|
|
}
|
|
|
|
fmt.Printf("Currently Playing: %s\n", formatBooleanStatus(response.IsPlaying))
|
|
|
|
if response.HasCurrentContent() {
|
|
fmt.Printf("Current Content: %s\n", response.CurrentURI)
|
|
}
|
|
|
|
fmt.Printf("Shuffle Mode: %s\n", response.ShuffleMode)
|
|
|
|
if response.HasSubscription() {
|
|
fmt.Printf("Subscription Type: %s\n", response.SubscriptionType)
|
|
}
|
|
}
|
|
|
|
// printIntrospectServiceState prints service state information
|
|
func printIntrospectServiceState(response *models.IntrospectResponse) {
|
|
if response.IsActive() {
|
|
fmt.Printf("✅ Service is ACTIVE\n")
|
|
} else if response.IsInactive() {
|
|
fmt.Printf("❌ Service is INACTIVE")
|
|
if response.GetState() == models.IntrospectStateInactiveUnselected {
|
|
fmt.Printf(" (Never been used)")
|
|
}
|
|
fmt.Println()
|
|
}
|
|
|
|
// Additional state information
|
|
if response.IsPlaying {
|
|
fmt.Printf("🎵 Currently playing content\n")
|
|
} else {
|
|
fmt.Printf("⏸️ Not currently playing\n")
|
|
}
|
|
|
|
if response.IsShuffleEnabled() {
|
|
fmt.Printf("🔀 Shuffle mode is ON\n")
|
|
} else {
|
|
fmt.Printf("➡️ Shuffle mode is OFF\n")
|
|
}
|
|
}
|
|
|
|
// printIntrospectCapabilities prints service capabilities
|
|
func printIntrospectCapabilities(response *models.IntrospectResponse) {
|
|
capabilities := []struct {
|
|
supported bool
|
|
feature string
|
|
icon string
|
|
}{
|
|
{response.SupportsSkipPrevious(), "Skip Previous", "⏮️"},
|
|
{response.SupportsSeek(), "Seek within tracks", "🎯"},
|
|
{response.SupportsResume(), "Resume playback", "▶️"},
|
|
}
|
|
|
|
for _, cap := range capabilities {
|
|
status := "❌"
|
|
if cap.supported {
|
|
status = "✅"
|
|
}
|
|
fmt.Printf("%s %s %s\n", status, cap.icon, cap.feature)
|
|
}
|
|
|
|
// Data collection status
|
|
if response.CollectsData() {
|
|
fmt.Printf("📊 Data collection: ENABLED\n")
|
|
} else {
|
|
fmt.Printf("🚫 Data collection: DISABLED\n")
|
|
}
|
|
}
|
|
|
|
// printIntrospectHistory prints content history information
|
|
func printIntrospectHistory(response *models.IntrospectResponse) {
|
|
fmt.Printf("Max History Size: %d items\n", response.GetMaxHistorySize())
|
|
}
|
|
|
|
// printIntrospectTechnicalDetails prints technical details
|
|
func printIntrospectTechnicalDetails(response *models.IntrospectResponse) {
|
|
if response.TokenLastChangedTimeSeconds > 0 {
|
|
// Convert timestamp to readable format
|
|
tokenTime := time.Unix(response.TokenLastChangedTimeSeconds, 0)
|
|
fmt.Printf("Token Last Changed: %s\n", tokenTime.Format("2006-01-02 15:04:05 MST"))
|
|
fmt.Printf("Token Timestamp: %d seconds since Unix epoch\n", response.TokenLastChangedTimeSeconds)
|
|
|
|
if response.TokenLastChangedTimeMicroseconds > 0 {
|
|
fmt.Printf("Token Microseconds: %d\n", response.TokenLastChangedTimeMicroseconds)
|
|
}
|
|
}
|
|
|
|
if response.PlayStatusState != "" {
|
|
fmt.Printf("Play Status State: %s\n", response.PlayStatusState)
|
|
}
|
|
|
|
fmt.Printf("Received Playback Request: %s\n", formatBooleanStatus(response.ReceivedPlaybackRequest))
|
|
}
|
|
|
|
// printIntrospectSummary prints a brief summary for the "all" command
|
|
func printIntrospectSummary(source string, response *models.IntrospectResponse) {
|
|
fmt.Printf(" State: %s", response.State)
|
|
if response.HasUser() {
|
|
fmt.Printf(" (User: %s)", response.User)
|
|
}
|
|
fmt.Println()
|
|
|
|
fmt.Printf(" Playing: %s", formatBooleanStatus(response.IsPlaying))
|
|
if response.HasCurrentContent() {
|
|
fmt.Printf(" | Content: %.50s", response.CurrentURI)
|
|
if len(response.CurrentURI) > 50 {
|
|
fmt.Printf("...")
|
|
}
|
|
}
|
|
fmt.Println()
|
|
|
|
var capabilities []string
|
|
if response.SupportsSkipPrevious() {
|
|
capabilities = append(capabilities, "Skip")
|
|
}
|
|
if response.SupportsSeek() {
|
|
capabilities = append(capabilities, "Seek")
|
|
}
|
|
if response.SupportsResume() {
|
|
capabilities = append(capabilities, "Resume")
|
|
}
|
|
|
|
if len(capabilities) > 0 {
|
|
fmt.Printf(" Capabilities: %s\n", strings.Join(capabilities, ", "))
|
|
} else {
|
|
fmt.Printf(" Capabilities: None\n")
|
|
}
|
|
}
|
|
|
|
// formatBooleanStatus formats boolean values for display
|
|
func formatBooleanStatus(value bool) string {
|
|
if value {
|
|
return "✅ Yes"
|
|
}
|
|
return "❌ No"
|
|
}
|