diff --git a/cmd/soundtouch-cli/cmd_introspect.go b/cmd/soundtouch-cli/cmd_introspect.go new file mode 100644 index 0000000..ee289d0 --- /dev/null +++ b/cmd/soundtouch-cli/cmd_introspect.go @@ -0,0 +1,339 @@ +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" +} diff --git a/cmd/soundtouch-cli/cmd_introspect_test.go b/cmd/soundtouch-cli/cmd_introspect_test.go new file mode 100644 index 0000000..6c489cb --- /dev/null +++ b/cmd/soundtouch-cli/cmd_introspect_test.go @@ -0,0 +1,525 @@ +package main + +import ( + "bytes" + "os" + "testing" + + "github.com/gesellix/bose-soundtouch/pkg/models" + "github.com/urfave/cli/v2" +) + +func TestIntrospectCommands(t *testing.T) { + // Test data - would be used in full integration tests + _ = &models.IntrospectResponse{ + State: "Active", + User: "test_user", + IsPlaying: true, + ShuffleMode: "ON", + CurrentURI: "spotify://track/123", + SubscriptionType: "Premium", + TokenLastChangedTimeSeconds: 1702566495, + PlayStatusState: "2", + ReceivedPlaybackRequest: false, + NowPlaying: &models.IntrospectNowPlaying{ + SkipPreviousSupported: true, + SeekSupported: true, + ResumeSupported: true, + CollectData: false, + }, + ContentItemHistory: &models.ContentItemHistory{ + MaxSize: 15, + }, + } + + tests := []struct { + name string + args []string + expectedOutput []string + expectError bool + }{ + { + name: "introspect service with source flag", + args: []string{"soundtouch-cli", "--host", "192.168.1.100", "source", "introspect", "--source", "SPOTIFY"}, + expectedOutput: []string{ + "Getting introspect data for SPOTIFY", + "=== SPOTIFY Service Introspect Data ===", + "State: Active", + "User: test_user", + "Currently Playing: āœ… Yes", + "Current Content: spotify://track/123", + "Shuffle Mode: ON", + "Subscription Type: Premium", + "=== Service State ===", + "āœ… Service is ACTIVE", + "šŸŽµ Currently playing content", + "šŸ”€ Shuffle mode is ON", + "=== Service Capabilities ===", + "āœ… ā®ļø Skip Previous", + "āœ… šŸŽÆ Seek within tracks", + "āœ… ā–¶ļø Resume playback", + "🚫 Data collection: DISABLED", + "=== Spotify Content History ===", + "Max History Size: 15 items", + "=== Technical Details ===", + "Token Last Changed:", + "Token Timestamp: 1702566495", + "Play Status State: 2", + "Received Playback Request: āŒ No", + }, + }, + { + name: "introspect spotify convenience command", + args: []string{"soundtouch-cli", "--host", "192.168.1.100", "source", "introspect-spotify"}, + expectedOutput: []string{ + "Getting Spotify introspect data", + "=== Spotify Service Introspect Data ===", + "State: Active", + "User: test_user", + "=== Spotify Service State ===", + "āœ… Service is ACTIVE", + "=== Spotify Service Capabilities ===", + }, + }, + { + name: "introspect with account parameter", + args: []string{"soundtouch-cli", "--host", "192.168.1.100", "source", "introspect", "--source", "SPOTIFY", "--account", "my_spotify_account"}, + expectedOutput: []string{ + "Getting introspect data for SPOTIFY", + "Source Account: my_spotify_account", + }, + }, + { + name: "introspect missing source flag", + args: []string{"soundtouch-cli", "--host", "192.168.1.100", "source", "introspect"}, + expectError: true, + }, + { + name: "introspect missing host", + args: []string{"soundtouch-cli", "source", "introspect", "--source", "SPOTIFY"}, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Skip actual execution for now - these would need mock HTTP servers + // This test structure shows how the CLI commands would be tested + t.Skip("Integration test - requires mock HTTP server setup") + + // Example of how you would set up the test: + // app := createTestApp() + // + // var buf bytes.Buffer + // app.Writer = &buf + // app.ErrWriter = &buf + // + // err := app.Run(tt.args) + // + // if tt.expectError { + // if err == nil { + // t.Error("expected error, got nil") + // } + // return + // } + // + // if err != nil { + // t.Fatalf("unexpected error: %v", err) + // } + // + // output := buf.String() + // for _, expected := range tt.expectedOutput { + // if !strings.Contains(output, expected) { + // t.Errorf("expected output to contain %q, got:\n%s", expected, output) + // } + // } + }) + } +} + +func TestPrintIntrospectBasicInfo(t *testing.T) { + tests := []struct { + name string + response *models.IntrospectResponse + expected []string + }{ + { + name: "active spotify response", + response: &models.IntrospectResponse{ + State: "Active", + User: "test_user", + IsPlaying: true, + ShuffleMode: "ON", + CurrentURI: "spotify://track/123", + SubscriptionType: "Premium", + }, + expected: []string{ + "State: Active", + "User: test_user", + "Currently Playing: āœ… Yes", + "Current Content: spotify://track/123", + "Shuffle Mode: ON", + "Subscription Type: Premium", + }, + }, + { + name: "inactive response", + response: &models.IntrospectResponse{ + State: "InactiveUnselected", + User: "", + IsPlaying: false, + ShuffleMode: "OFF", + CurrentURI: "", + }, + expected: []string{ + "State: InactiveUnselected", + "Currently Playing: āŒ No", + "Shuffle Mode: OFF", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Capture stdout + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + // Call the function + printIntrospectBasicInfo(tt.response) + + // Restore stdout and read output + w.Close() + os.Stdout = oldStdout + + var buf bytes.Buffer + _, err := buf.ReadFrom(r) + if err != nil { + t.Fatalf("failed to read output: %v", err) + } + + output := buf.String() + + // Check expected strings are present + for _, expected := range tt.expected { + if !containsSubstring(output, expected) { + t.Errorf("expected output to contain %q, got:\n%s", expected, output) + } + } + + // Check unwanted strings are not present + if tt.response.User == "" && containsSubstring(output, "User:") { + t.Error("expected no user information when user is empty") + } + if tt.response.CurrentURI == "" && containsSubstring(output, "Current Content:") { + t.Error("expected no current content when URI is empty") + } + if tt.response.SubscriptionType == "" && containsSubstring(output, "Subscription Type:") { + t.Error("expected no subscription information when type is empty") + } + }) + } +} + +func TestPrintIntrospectServiceState(t *testing.T) { + tests := []struct { + name string + response *models.IntrospectResponse + expected []string + }{ + { + name: "active playing with shuffle", + response: &models.IntrospectResponse{ + State: "Active", + IsPlaying: true, + ShuffleMode: "ON", + }, + expected: []string{ + "āœ… Service is ACTIVE", + "šŸŽµ Currently playing content", + "šŸ”€ Shuffle mode is ON", + }, + }, + { + name: "inactive unselected", + response: &models.IntrospectResponse{ + State: "InactiveUnselected", + IsPlaying: false, + ShuffleMode: "OFF", + }, + expected: []string{ + "āŒ Service is INACTIVE (Never been used)", + "āøļø Not currently playing", + "āž”ļø Shuffle mode is OFF", + }, + }, + { + name: "inactive but configured", + response: &models.IntrospectResponse{ + State: "Inactive", + IsPlaying: false, + ShuffleMode: "OFF", + }, + expected: []string{ + "āŒ Service is INACTIVE", + "āøļø Not currently playing", + "āž”ļø Shuffle mode is OFF", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Capture stdout + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + // Call the function + printIntrospectServiceState(tt.response) + + // Restore stdout and read output + w.Close() + os.Stdout = oldStdout + + var buf bytes.Buffer + _, err := buf.ReadFrom(r) + if err != nil { + t.Fatalf("failed to read output: %v", err) + } + + output := buf.String() + + // Check expected strings are present + for _, expected := range tt.expected { + if !containsSubstring(output, expected) { + t.Errorf("expected output to contain %q, got:\n%s", expected, output) + } + } + }) + } +} + +func TestPrintIntrospectCapabilities(t *testing.T) { + tests := []struct { + name string + response *models.IntrospectResponse + expected []string + }{ + { + name: "full capabilities enabled", + response: &models.IntrospectResponse{ + NowPlaying: &models.IntrospectNowPlaying{ + SkipPreviousSupported: true, + SeekSupported: true, + ResumeSupported: true, + CollectData: true, + }, + }, + expected: []string{ + "āœ… ā®ļø Skip Previous", + "āœ… šŸŽÆ Seek within tracks", + "āœ… ā–¶ļø Resume playback", + "šŸ“Š Data collection: ENABLED", + }, + }, + { + name: "limited capabilities", + response: &models.IntrospectResponse{ + NowPlaying: &models.IntrospectNowPlaying{ + SkipPreviousSupported: false, + SeekSupported: false, + ResumeSupported: true, + CollectData: false, + }, + }, + expected: []string{ + "āŒ ā®ļø Skip Previous", + "āŒ šŸŽÆ Seek within tracks", + "āœ… ā–¶ļø Resume playback", + "🚫 Data collection: DISABLED", + }, + }, + { + name: "no capabilities info", + response: &models.IntrospectResponse{ + NowPlaying: nil, + }, + expected: []string{ + "āŒ ā®ļø Skip Previous", + "āŒ šŸŽÆ Seek within tracks", + "āŒ ā–¶ļø Resume playback", + "🚫 Data collection: DISABLED", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Capture stdout + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + // Call the function + printIntrospectCapabilities(tt.response) + + // Restore stdout and read output + w.Close() + os.Stdout = oldStdout + + var buf bytes.Buffer + _, err := buf.ReadFrom(r) + if err != nil { + t.Fatalf("failed to read output: %v", err) + } + + output := buf.String() + + // Check expected strings are present + for _, expected := range tt.expected { + if !containsSubstring(output, expected) { + t.Errorf("expected output to contain %q, got:\n%s", expected, output) + } + } + }) + } +} + +func TestPrintIntrospectSummary(t *testing.T) { + tests := []struct { + name string + source string + response *models.IntrospectResponse + expected []string + }{ + { + name: "full spotify summary", + source: "SPOTIFY", + response: &models.IntrospectResponse{ + State: "Active", + User: "spotify_user", + IsPlaying: true, + CurrentURI: "spotify://track/very_long_track_uri_that_should_be_truncated_because_its_too_long_for_display", + NowPlaying: &models.IntrospectNowPlaying{ + SkipPreviousSupported: true, + SeekSupported: true, + ResumeSupported: true, + }, + }, + expected: []string{ + "State: Active (User: spotify_user)", + "Playing: āœ… Yes | Content: spotify://track/very_long_track_uri_that_should_be...", + "Capabilities: Skip, Seek, Resume", + }, + }, + { + name: "minimal summary", + source: "PANDORA", + response: &models.IntrospectResponse{ + State: "Inactive", + IsPlaying: false, + }, + expected: []string{ + "State: Inactive", + "Playing: āŒ No", + "Capabilities: None", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Capture stdout + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + // Call the function + printIntrospectSummary(tt.source, tt.response) + + // Restore stdout and read output + w.Close() + os.Stdout = oldStdout + + var buf bytes.Buffer + _, err := buf.ReadFrom(r) + if err != nil { + t.Fatalf("failed to read output: %v", err) + } + + output := buf.String() + + // Check expected strings are present + for _, expected := range tt.expected { + if !containsSubstring(output, expected) { + t.Errorf("expected output to contain %q, got:\n%s", expected, output) + } + } + }) + } +} + +func TestFormatBooleanStatus(t *testing.T) { + tests := []struct { + name string + value bool + expected string + }{ + { + name: "true value", + value: true, + expected: "āœ… Yes", + }, + { + name: "false value", + value: false, + expected: "āŒ No", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := formatBooleanStatus(tt.value) + if result != tt.expected { + t.Errorf("expected %q, got %q", tt.expected, result) + } + }) + } +} + +// Helper function to check if output contains a substring +func containsSubstring(output, substring string) bool { + return bytes.Contains([]byte(output), []byte(substring)) +} + +// createTestApp creates a test CLI application for integration testing +func createTestApp() *cli.App { + // This would create a minimal CLI app for testing + // In a real implementation, you'd want to create a version of the main app + // but with mock HTTP clients instead of real ones + app := &cli.App{ + Name: "test-soundtouch-cli", + Commands: []*cli.Command{ + { + Name: "source", + Subcommands: []*cli.Command{ + { + Name: "introspect", + Action: introspectService, + }, + { + Name: "introspect-spotify", + Action: introspectSpotify, + }, + { + Name: "introspect-all", + Action: introspectAllServices, + }, + }, + }, + }, + } + return app +} diff --git a/cmd/soundtouch-cli/cmd_recents.go b/cmd/soundtouch-cli/cmd_recents.go new file mode 100644 index 0000000..b7e6497 --- /dev/null +++ b/cmd/soundtouch-cli/cmd_recents.go @@ -0,0 +1,469 @@ +package main + +import ( + "fmt" + "strings" + "time" + + "github.com/gesellix/bose-soundtouch/pkg/models" + "github.com/urfave/cli/v2" +) + +// getRecents handles getting recently played content +func getRecents(c *cli.Context) error { + clientConfig := GetClientConfig(c) + + client, err := CreateSoundTouchClient(clientConfig) + if err != nil { + return err + } + + PrintDeviceHeader("Getting recently played content", clientConfig.Host, clientConfig.Port) + + response, err := client.GetRecents() + if err != nil { + return fmt.Errorf("failed to get recent items: %w", err) + } + + if response.IsEmpty() { + fmt.Printf("šŸ“­ No recent items found\n") + fmt.Printf("šŸ’” Play some content to populate the recent items list\n") + return nil + } + + // Display summary + fmt.Printf("šŸ“Š Recent Items Summary:\n") + fmt.Printf(" Total Items: %d\n", response.GetItemCount()) + + // Show source breakdown + sources := map[string]int{ + "Spotify": len(response.GetSpotifyItems()), + "Local Music": len(response.GetLocalMusicItems()), + "Stored Music": len(response.GetStoredMusicItems()), + "TuneIn": len(response.GetTuneInItems()), + "Pandora": len(response.GetPandoraItems()), + } + + fmt.Printf(" By Source:\n") + for source, count := range sources { + if count > 0 { + fmt.Printf(" • %s: %d items\n", source, count) + } + } + + // Show type breakdown + tracks := len(response.GetTracks()) + stations := len(response.GetStations()) + playlists := len(response.GetPlaylistsAndAlbums()) + presetable := len(response.GetPresetableItems()) + + fmt.Printf(" By Type:\n") + if tracks > 0 { + fmt.Printf(" • šŸŽµ Tracks: %d\n", tracks) + } + if stations > 0 { + fmt.Printf(" • šŸ“» Stations: %d\n", stations) + } + if playlists > 0 { + fmt.Printf(" • šŸ“‹ Playlists/Albums: %d\n", playlists) + } + if presetable > 0 { + fmt.Printf(" • ⭐ Presetable: %d\n", presetable) + } + + fmt.Printf("\n=== Recent Items ===\n") + + // Display items with details + maxItems := c.Int("limit") + if maxItems <= 0 || maxItems > len(response.Items) { + maxItems = len(response.Items) + } + + for i, item := range response.Items[:maxItems] { + printRecentItem(i+1, &item, c.Bool("detailed")) + } + + if len(response.Items) > maxItems { + fmt.Printf("\n... and %d more items (use --limit to show more)\n", len(response.Items)-maxItems) + } + + return nil +} + +// getRecentsFiltered handles getting filtered recent content +func getRecentsFiltered(c *cli.Context) error { + clientConfig := GetClientConfig(c) + + client, err := CreateSoundTouchClient(clientConfig) + if err != nil { + return err + } + + source := strings.ToUpper(c.String("source")) + contentType := strings.ToLower(c.String("type")) + + filterDesc := "" + if source != "" && contentType != "" { + filterDesc = fmt.Sprintf(" (filtered by source: %s, type: %s)", source, contentType) + } else if source != "" { + filterDesc = fmt.Sprintf(" (filtered by source: %s)", source) + } else if contentType != "" { + filterDesc = fmt.Sprintf(" (filtered by type: %s)", contentType) + } + + PrintDeviceHeader("Getting filtered recent content"+filterDesc, clientConfig.Host, clientConfig.Port) + + response, err := client.GetRecents() + if err != nil { + return fmt.Errorf("failed to get recent items: %w", err) + } + + if response.IsEmpty() { + fmt.Printf("šŸ“­ No recent items found\n") + return nil + } + + // Apply filters + var filteredItems []models.RecentsResponseItem + + if source != "" { + filteredItems = response.GetItemsBySource(source) + } else { + filteredItems = response.Items + } + + // Apply type filter + if contentType != "" { + var typeFiltered []models.RecentsResponseItem + for _, item := range filteredItems { + switch contentType { + case "track", "tracks": + if item.IsTrack() { + typeFiltered = append(typeFiltered, item) + } + case "station", "stations": + if item.IsStation() { + typeFiltered = append(typeFiltered, item) + } + case "playlist", "playlists": + if item.IsPlaylist() { + typeFiltered = append(typeFiltered, item) + } + case "album", "albums": + if item.IsAlbum() { + typeFiltered = append(typeFiltered, item) + } + case "container", "containers": + if item.IsContainer() { + typeFiltered = append(typeFiltered, item) + } + case "presetable": + if item.IsPresetable() { + typeFiltered = append(typeFiltered, item) + } + } + } + filteredItems = typeFiltered + } + + if len(filteredItems) == 0 { + fmt.Printf("šŸ“­ No items match the specified filters\n") + fmt.Printf("šŸ’” Try different filter criteria or check available content\n") + return nil + } + + fmt.Printf("šŸ“Š Filtered Results: %d items\n\n", len(filteredItems)) + + // Display filtered items + maxItems := c.Int("limit") + if maxItems <= 0 || maxItems > len(filteredItems) { + maxItems = len(filteredItems) + } + + for i, item := range filteredItems[:maxItems] { + printRecentItem(i+1, &item, c.Bool("detailed")) + } + + if len(filteredItems) > maxItems { + fmt.Printf("\n... and %d more items (use --limit to show more)\n", len(filteredItems)-maxItems) + } + + return nil +} + +// getRecentsMostRecent shows only the most recent item +func getRecentsMostRecent(c *cli.Context) error { + clientConfig := GetClientConfig(c) + + client, err := CreateSoundTouchClient(clientConfig) + if err != nil { + return err + } + + PrintDeviceHeader("Getting most recent item", clientConfig.Host, clientConfig.Port) + + response, err := client.GetRecents() + if err != nil { + return fmt.Errorf("failed to get recent items: %w", err) + } + + mostRecent := response.GetMostRecent() + if mostRecent == nil { + fmt.Printf("šŸ“­ No recent items found\n") + return nil + } + + fmt.Printf("šŸ•’ Most Recent Item:\n\n") + printRecentItem(1, mostRecent, true) + + return nil +} + +// printRecentItem prints details about a recent item +func printRecentItem(index int, item *models.RecentsResponseItem, detailed bool) { + // Basic information + displayName := item.GetDisplayName() + source := item.GetSource() + contentType := item.GetContentType() + + // Format source display + sourceDisplay := formatSourceForDisplay(source) + + // Content type icon + typeIcon := getContentTypeIcon(item) + + fmt.Printf("%d. %s %s\n", index, typeIcon, displayName) + fmt.Printf(" Source: %s", sourceDisplay) + + if contentType != "" { + fmt.Printf(" | Type: %s", strings.Title(contentType)) + } + fmt.Printf("\n") + + // Time information + if item.GetUTCTime() > 0 { + playTime := time.Unix(item.GetUTCTime(), 0) + fmt.Printf(" Played: %s\n", playTime.Format("2006-01-02 15:04:05")) + } + + // Additional details if requested + if detailed { + if item.HasID() { + fmt.Printf(" ID: %s\n", item.GetID()) + } + + if item.IsPresetable() { + fmt.Printf(" ⭐ Can be saved as preset\n") + } + + if item.HasArtwork() { + fmt.Printf(" šŸŽØ Has artwork: %s\n", truncateString(item.GetArtwork(), 50)) + } + + location := item.GetLocation() + if location != "" { + fmt.Printf(" šŸ“ Location: %s\n", truncateString(location, 50)) + } + + sourceAccount := item.GetSourceAccount() + if sourceAccount != "" && sourceAccount != source { + fmt.Printf(" šŸ‘¤ Account: %s\n", truncateString(sourceAccount, 30)) + } + + // Content classification + var classifications []string + if item.IsStreamingContent() { + classifications = append(classifications, "Streaming") + } + if item.IsLocalContent() { + classifications = append(classifications, "Local") + } + if len(classifications) > 0 { + fmt.Printf(" šŸ·ļø Classification: %s\n", strings.Join(classifications, ", ")) + } + } + + fmt.Println() +} + +// getContentTypeIcon returns an emoji icon for the content type +func getContentTypeIcon(item *models.RecentsResponseItem) string { + if item.IsTrack() { + return "šŸŽµ" + } else if item.IsStation() { + return "šŸ“»" + } else if item.IsPlaylist() { + return "šŸ“‹" + } else if item.IsAlbum() { + return "šŸ’æ" + } else if item.IsContainer() { + return "šŸ“" + } + return "šŸŽ¼" +} + +// formatSourceForDisplay formats source names for user-friendly display +func formatSourceForDisplay(source string) string { + switch source { + case "SPOTIFY": + return "Spotify" + case "LOCAL_MUSIC": + return "Local Music" + case "STORED_MUSIC": + return "Stored Music" + case "TUNEIN": + return "TuneIn Radio" + case "PANDORA": + return "Pandora" + case "AMAZON": + return "Amazon Music" + case "DEEZER": + return "Deezer" + case "IHEART": + return "iHeartRadio" + case "BLUETOOTH": + return "Bluetooth" + case "AUX": + return "AUX Input" + case "AIRPLAY": + return "AirPlay" + default: + return source + } +} + +// truncateString truncates a string to the specified length with ellipsis +func truncateString(s string, maxLength int) string { + if len(s) <= maxLength { + return s + } + if maxLength <= 3 { + return "..." + } + return s[:maxLength-3] + "..." +} + +// recentsStats shows statistics about recent items +func recentsStats(c *cli.Context) error { + clientConfig := GetClientConfig(c) + + client, err := CreateSoundTouchClient(clientConfig) + if err != nil { + return err + } + + PrintDeviceHeader("Getting recent items statistics", clientConfig.Host, clientConfig.Port) + + response, err := client.GetRecents() + if err != nil { + return fmt.Errorf("failed to get recent items: %w", err) + } + + if response.IsEmpty() { + fmt.Printf("šŸ“Š Statistics: No recent items found\n") + return nil + } + + fmt.Printf("šŸ“Š Recent Items Statistics\n\n") + + // Basic stats + fmt.Printf("Overall Statistics:\n") + fmt.Printf(" Total Items: %d\n", response.GetItemCount()) + + if !response.IsEmpty() { + mostRecent := response.GetMostRecent() + if mostRecent != nil { + lastPlayTime := time.Unix(mostRecent.GetUTCTime(), 0) + fmt.Printf(" Last Played: %s\n", lastPlayTime.Format("2006-01-02 15:04:05")) + } + } + + // Source breakdown + fmt.Printf("\nBy Source:\n") + sourceStats := map[string]int{ + "Spotify": len(response.GetSpotifyItems()), + "Pandora": len(response.GetPandoraItems()), + "TuneIn": len(response.GetTuneInItems()), + "Local Music": len(response.GetLocalMusicItems()), + "Stored Music": len(response.GetStoredMusicItems()), + } + + // Add other sources if they exist + otherSources := make(map[string]int) + for _, item := range response.Items { + source := item.GetSource() + found := false + for knownSource := range sourceStats { + if strings.Contains(strings.ToLower(knownSource), strings.ToLower(source)) || + strings.Contains(strings.ToLower(source), strings.ToLower(knownSource)) { + found = true + break + } + } + if !found && source != "" { + otherSources[formatSourceForDisplay(source)]++ + } + } + + // Merge other sources + for source, count := range otherSources { + sourceStats[source] = count + } + + for source, count := range sourceStats { + if count > 0 { + percentage := float64(count) / float64(response.GetItemCount()) * 100 + fmt.Printf(" %-15s %3d items (%5.1f%%)\n", source+":", count, percentage) + } + } + + // Content type breakdown + fmt.Printf("\nBy Content Type:\n") + tracks := len(response.GetTracks()) + stations := len(response.GetStations()) + playlists := len(response.GetPlaylistsAndAlbums()) + + if tracks > 0 { + percentage := float64(tracks) / float64(response.GetItemCount()) * 100 + fmt.Printf(" %-15s %3d items (%5.1f%%)\n", "Tracks:", tracks, percentage) + } + if stations > 0 { + percentage := float64(stations) / float64(response.GetItemCount()) * 100 + fmt.Printf(" %-15s %3d items (%5.1f%%)\n", "Stations:", stations, percentage) + } + if playlists > 0 { + percentage := float64(playlists) / float64(response.GetItemCount()) * 100 + fmt.Printf(" %-15s %3d items (%5.1f%%)\n", "Playlists/Albums:", playlists, percentage) + } + + // Special categories + presetable := len(response.GetPresetableItems()) + if presetable > 0 { + fmt.Printf("\nSpecial Categories:\n") + percentage := float64(presetable) / float64(response.GetItemCount()) * 100 + fmt.Printf(" %-15s %3d items (%5.1f%%)\n", "Presetable:", presetable, percentage) + } + + // Content source analysis + streamingCount := 0 + localCount := 0 + for _, item := range response.Items { + if item.IsStreamingContent() { + streamingCount++ + } else if item.IsLocalContent() { + localCount++ + } + } + + fmt.Printf("\nSource Analysis:\n") + if streamingCount > 0 { + percentage := float64(streamingCount) / float64(response.GetItemCount()) * 100 + fmt.Printf(" %-15s %3d items (%5.1f%%)\n", "Streaming:", streamingCount, percentage) + } + if localCount > 0 { + percentage := float64(localCount) / float64(response.GetItemCount()) * 100 + fmt.Printf(" %-15s %3d items (%5.1f%%)\n", "Local:", localCount, percentage) + } + + return nil +} diff --git a/cmd/soundtouch-cli/cmd_recents_test.go b/cmd/soundtouch-cli/cmd_recents_test.go new file mode 100644 index 0000000..6362eea --- /dev/null +++ b/cmd/soundtouch-cli/cmd_recents_test.go @@ -0,0 +1,436 @@ +package main + +import ( + "bytes" + "os" + "testing" + + "github.com/gesellix/bose-soundtouch/pkg/models" +) + +func TestRecentsCommands(t *testing.T) { + // Test data - would be used in full integration tests + _ = &models.RecentsResponse{ + Items: []models.RecentsResponseItem{ + { + DeviceID: "device1", + UTCTime: 1701200000, + ID: "1", + ContentItem: &models.ContentItem{ + Source: "SPOTIFY", + Type: "track", + ItemName: "Test Song", + IsPresetable: true, + }, + }, + { + DeviceID: "device1", + UTCTime: 1701100000, + ID: "2", + ContentItem: &models.ContentItem{ + Source: "LOCAL_MUSIC", + Type: "track", + ItemName: "Local Song", + }, + }, + }, + } + + tests := []struct { + name string + args []string + expectedOutput []string + expectError bool + }{ + { + name: "recents list command", + args: []string{"soundtouch-cli", "--host", "192.168.1.100", "recents", "list"}, + expectedOutput: []string{ + "Getting recently played content", + "Recent Items Summary:", + "Recent Items", + }, + }, + { + name: "recents filter by source", + args: []string{"soundtouch-cli", "--host", "192.168.1.100", "recents", "filter", "--source", "SPOTIFY"}, + expectedOutput: []string{ + "Getting filtered recent content", + "filtered by source: SPOTIFY", + }, + }, + { + name: "recents latest command", + args: []string{"soundtouch-cli", "--host", "192.168.1.100", "recents", "latest"}, + expectedOutput: []string{ + "Getting most recent item", + "Most Recent Item:", + }, + }, + { + name: "recents stats command", + args: []string{"soundtouch-cli", "--host", "192.168.1.100", "recents", "stats"}, + expectedOutput: []string{ + "Getting recent items statistics", + "Recent Items Statistics", + }, + }, + { + name: "recents missing host", + args: []string{"soundtouch-cli", "recents", "list"}, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Skip actual execution for now - these would need mock HTTP servers + // This test structure shows how the CLI commands would be tested + t.Skip("Integration test - requires mock HTTP server setup") + + // Example of how you would set up the test: + // app := createTestApp() + // + // var buf bytes.Buffer + // app.Writer = &buf + // app.ErrWriter = &buf + // + // err := app.Run(tt.args) + // + // if tt.expectError { + // if err == nil { + // t.Error("expected error, got nil") + // } + // return + // } + // + // if err != nil { + // t.Fatalf("unexpected error: %v", err) + // } + // + // output := buf.String() + // for _, expected := range tt.expectedOutput { + // if !strings.Contains(output, expected) { + // t.Errorf("expected output to contain %q, got:\n%s", expected, output) + // } + // } + }) + } +} + +func TestPrintRecentItem(t *testing.T) { + tests := []struct { + name string + item *models.RecentsResponseItem + detailed bool + expected []string + }{ + { + name: "basic track item", + item: &models.RecentsResponseItem{ + DeviceID: "device1", + UTCTime: 1701200000, + ContentItem: &models.ContentItem{ + Source: "SPOTIFY", + Type: "track", + ItemName: "Test Song", + }, + }, + detailed: false, + expected: []string{ + "šŸŽµ Test Song", + "Source: Spotify", + "Type: Track", + }, + }, + { + name: "detailed station item", + item: &models.RecentsResponseItem{ + DeviceID: "device1", + UTCTime: 1701200000, + ID: "station123", + ContentItem: &models.ContentItem{ + Source: "TUNEIN", + Type: "stationurl", + ItemName: "Rock FM", + Location: "tunein:station:s12345", + SourceAccount: "tunein_account", + IsPresetable: true, + }, + }, + detailed: true, + expected: []string{ + "šŸ“» Rock FM", + "Source: TuneIn Radio", + "ID: station123", + "Can be saved as preset", + "Location: tunein:station:s12345", + "Classification: Streaming", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Capture stdout + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + // Call the function + printRecentItem(1, tt.item, tt.detailed) + + // Restore stdout and read output + w.Close() + os.Stdout = oldStdout + + var buf bytes.Buffer + _, err := buf.ReadFrom(r) + if err != nil { + t.Fatalf("failed to read output: %v", err) + } + + output := buf.String() + + // Check expected strings are present + for _, expected := range tt.expected { + if !bytes.Contains(buf.Bytes(), []byte(expected)) { + t.Errorf("expected output to contain %q, got:\n%s", expected, output) + } + } + }) + } +} + +func TestGetContentTypeIcon(t *testing.T) { + tests := []struct { + name string + item *models.RecentsResponseItem + expected string + }{ + { + name: "track item", + item: &models.RecentsResponseItem{ + ContentItem: &models.ContentItem{Type: "track"}, + }, + expected: "šŸŽµ", + }, + { + name: "station item", + item: &models.RecentsResponseItem{ + ContentItem: &models.ContentItem{Type: "stationurl"}, + }, + expected: "šŸ“»", + }, + { + name: "playlist item", + item: &models.RecentsResponseItem{ + ContentItem: &models.ContentItem{Type: "playlist"}, + }, + expected: "šŸ“‹", + }, + { + name: "album item", + item: &models.RecentsResponseItem{ + ContentItem: &models.ContentItem{Type: "album"}, + }, + expected: "šŸ’æ", + }, + { + name: "container item", + item: &models.RecentsResponseItem{ + ContentItem: &models.ContentItem{Type: "container"}, + }, + expected: "šŸ“", + }, + { + name: "unknown type", + item: &models.RecentsResponseItem{ + ContentItem: &models.ContentItem{Type: "unknown"}, + }, + expected: "šŸŽ¼", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := getContentTypeIcon(tt.item) + if result != tt.expected { + t.Errorf("expected %q, got %q", tt.expected, result) + } + }) + } +} + +func TestFormatSourceForDisplay(t *testing.T) { + tests := []struct { + name string + source string + expected string + }{ + {"Spotify", "SPOTIFY", "Spotify"}, + {"Local Music", "LOCAL_MUSIC", "Local Music"}, + {"Stored Music", "STORED_MUSIC", "Stored Music"}, + {"TuneIn", "TUNEIN", "TuneIn Radio"}, + {"Pandora", "PANDORA", "Pandora"}, + {"Amazon", "AMAZON", "Amazon Music"}, + {"Deezer", "DEEZER", "Deezer"}, + {"iHeart", "IHEART", "iHeartRadio"}, + {"Bluetooth", "BLUETOOTH", "Bluetooth"}, + {"AUX", "AUX", "AUX Input"}, + {"AirPlay", "AIRPLAY", "AirPlay"}, + {"Unknown", "UNKNOWN_SOURCE", "UNKNOWN_SOURCE"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := formatSourceForDisplay(tt.source) + if result != tt.expected { + t.Errorf("expected %q, got %q", tt.expected, result) + } + }) + } +} + +func TestTruncateString(t *testing.T) { + tests := []struct { + name string + input string + maxLength int + expected string + }{ + { + name: "short string", + input: "hello", + maxLength: 10, + expected: "hello", + }, + { + name: "exact length", + input: "hello", + maxLength: 5, + expected: "hello", + }, + { + name: "long string", + input: "this is a very long string that needs truncation", + maxLength: 20, + expected: "this is a very lo...", + }, + { + name: "very short max length", + input: "hello world", + maxLength: 3, + expected: "...", + }, + { + name: "zero length", + input: "hello", + maxLength: 0, + expected: "...", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := truncateString(tt.input, tt.maxLength) + if result != tt.expected { + t.Errorf("expected %q, got %q", tt.expected, result) + } + }) + } +} + +// Test helper functions that would be used in full integration tests +func createTestRecentsResponse() *models.RecentsResponse { + return &models.RecentsResponse{ + Items: []models.RecentsResponseItem{ + { + DeviceID: "1004567890AA", + UTCTime: 1701300000, + ID: "spotify1", + ContentItem: &models.ContentItem{ + Source: "SPOTIFY", + Type: "track", + Location: "spotify:track:4iV5W9uYEdYUVa79Axb7Rh", + SourceAccount: "spotify_user", + IsPresetable: true, + ItemName: "Shape of You - Ed Sheeran", + ContainerArt: "https://i.scdn.co/image/ab67616d0000b273ba5db46f4b838ef6027e6f96", + }, + }, + { + DeviceID: "1004567890AA", + UTCTime: 1701200000, + ID: "local1", + ContentItem: &models.ContentItem{ + Source: "LOCAL_MUSIC", + Type: "track", + Location: "/music/local_song.mp3", + IsPresetable: false, + ItemName: "Local Song - Local Artist", + }, + }, + { + DeviceID: "1004567890AA", + UTCTime: 1701100000, + ID: "tunein1", + ContentItem: &models.ContentItem{ + Source: "TUNEIN", + Type: "stationurl", + Location: "tunein:station:s24939", + SourceAccount: "tunein", + IsPresetable: true, + ItemName: "BBC Radio 1", + }, + }, + }, + } +} + +func TestCreateTestRecentsResponse(t *testing.T) { + response := createTestRecentsResponse() + + if response == nil { + t.Fatal("expected response, got nil") + } + + if response.GetItemCount() != 3 { + t.Errorf("expected 3 items, got %d", response.GetItemCount()) + } + + if response.IsEmpty() { + t.Error("expected response not to be empty") + } + + // Test filtering + spotifyItems := response.GetSpotifyItems() + if len(spotifyItems) != 1 { + t.Errorf("expected 1 Spotify item, got %d", len(spotifyItems)) + } + + localItems := response.GetLocalMusicItems() + if len(localItems) != 1 { + t.Errorf("expected 1 local music item, got %d", len(localItems)) + } + + tuneInItems := response.GetTuneInItems() + if len(tuneInItems) != 1 { + t.Errorf("expected 1 TuneIn item, got %d", len(tuneInItems)) + } + + tracks := response.GetTracks() + if len(tracks) != 2 { + t.Errorf("expected 2 tracks, got %d", len(tracks)) + } + + stations := response.GetStations() + if len(stations) != 1 { + t.Errorf("expected 1 station, got %d", len(stations)) + } + + presetableItems := response.GetPresetableItems() + if len(presetableItems) != 2 { + t.Errorf("expected 2 presetable items, got %d", len(presetableItems)) + } +} diff --git a/cmd/soundtouch-cli/main.go b/cmd/soundtouch-cli/main.go index 4f5f521..0ce1d65 100644 --- a/cmd/soundtouch-cli/main.go +++ b/cmd/soundtouch-cli/main.go @@ -209,6 +209,72 @@ func main() { Action: getPresets, Before: RequireHost, }, + // Recent content commands + { + Name: "recents", + Aliases: []string{"recent"}, + Usage: "Recently played content commands", + Subcommands: []*cli.Command{ + { + Name: "list", + Usage: "List recently played content", + Action: getRecents, + Flags: []cli.Flag{ + &cli.IntFlag{ + Name: "limit", + Usage: "Maximum number of items to display (0 for all)", + Value: 10, + }, + &cli.BoolFlag{ + Name: "detailed", + Aliases: []string{"d"}, + Usage: "Show detailed information for each item", + }, + }, + Before: RequireHost, + }, + { + Name: "filter", + Usage: "List recently played content with filters", + Action: getRecentsFiltered, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "source", + Aliases: []string{"s"}, + Usage: "Filter by source (SPOTIFY, LOCAL_MUSIC, TUNEIN, etc.)", + }, + &cli.StringFlag{ + Name: "type", + Aliases: []string{"t"}, + Usage: "Filter by content type (track, station, playlist, album, presetable)", + }, + &cli.IntFlag{ + Name: "limit", + Usage: "Maximum number of items to display (0 for all)", + Value: 10, + }, + &cli.BoolFlag{ + Name: "detailed", + Aliases: []string{"d"}, + Usage: "Show detailed information for each item", + }, + }, + Before: RequireHost, + }, + { + Name: "latest", + Usage: "Show only the most recent item", + Action: getRecentsMostRecent, + Before: RequireHost, + }, + { + Name: "stats", + Usage: "Show statistics about recent content", + Action: recentsStats, + Before: RequireHost, + }, + }, + }, // Playback commands { Name: "play", @@ -842,6 +908,44 @@ func main() { Action: compareSourcesAndAvailability, Before: RequireHost, }, + { + Name: "introspect", + Usage: "Get introspect data for a music service", + Action: introspectService, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "source", + Aliases: []string{"s"}, + Usage: "Music service source (SPOTIFY, PANDORA, TUNEIN, etc.)", + Required: true, + }, + &cli.StringFlag{ + Name: "account", + Aliases: []string{"a"}, + Usage: "Source account name (optional)", + }, + }, + Before: RequireHost, + }, + { + Name: "introspect-spotify", + Usage: "Get Spotify introspect data (convenience command)", + Action: introspectSpotify, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "account", + Aliases: []string{"a"}, + Usage: "Spotify account name (optional)", + }, + }, + Before: RequireHost, + }, + { + Name: "introspect-all", + Usage: "Get introspect data for all available services", + Action: introspectAllServices, + Before: RequireHost, + }, }, }, // Bass commands diff --git a/docs/API-Endpoints-Overview.md b/docs/API-Endpoints-Overview.md index f86f3f0..6d06ba1 100644 --- a/docs/API-Endpoints-Overview.md +++ b/docs/API-Endpoints-Overview.md @@ -686,7 +686,7 @@ Retrieves all supported endpoints for the specific device with comprehensive fea - `/setMusicServiceOAuthAccount` - OAuth account setup - `/removeMusicServiceAccount` - Remove music service account - `/serviceAvailability` āœ… **Implemented** - Check service availability -- `/introspect` - Get introspect data for specific sources +- `/introspect` āœ… **Implemented** - Get introspect data for specific sources **Station Management (Radio/Streaming):** - `/searchStation` - Search for stations (tested with Pandora) diff --git a/docs/CLI-REFERENCE.md b/docs/CLI-REFERENCE.md index 4caddbc..e3eec9b 100644 --- a/docs/CLI-REFERENCE.md +++ b/docs/CLI-REFERENCE.md @@ -174,6 +174,77 @@ soundtouch-cli --host 192.168.1.10 play now soundtouch-cli --host 192.168.1.10 play now --verbose ``` +### Recent Content + +Recently played content management. + +#### `recents ` + +Recently played content commands. + +```bash +# List recently played items +soundtouch-cli --host recents list [--limit ] [--detailed] + +# Filter recent items by source or type +soundtouch-cli --host recents filter --source [--type ] [--limit ] + +# Show only the most recent item +soundtouch-cli --host recents latest + +# Show statistics about recent content +soundtouch-cli --host recents stats +``` + +**Basic Usage Examples:** +```bash +# List last 10 recent items (default) +soundtouch-cli --host 192.168.1.10 recents list + +# Show all recent items with detailed information +soundtouch-cli --host 192.168.1.10 recents list --limit 0 --detailed + +# Show only the most recent item +soundtouch-cli --host 192.168.1.10 recents latest +``` + +**Filtering Examples:** +```bash +# Show only Spotify items +soundtouch-cli --host 192.168.1.10 recents filter --source SPOTIFY + +# Show only tracks (no stations or playlists) +soundtouch-cli --host 192.168.1.10 recents filter --type track + +# Show only presetable items +soundtouch-cli --host 192.168.1.10 recents filter --type presetable + +# Show last 5 local music items +soundtouch-cli --host 192.168.1.10 recents filter --source LOCAL_MUSIC --limit 5 +``` + +**Available Sources:** +- `SPOTIFY` - Spotify streaming +- `LOCAL_MUSIC` - Local music files +- `STORED_MUSIC` - Stored music library +- `TUNEIN` - TuneIn radio stations +- `PANDORA` - Pandora music +- `AMAZON` - Amazon Music +- `DEEZER` - Deezer streaming + +**Available Types:** +- `track` - Individual songs +- `station` - Radio stations +- `playlist` - Music playlists +- `album` - Music albums +- `presetable` - Items that can be saved as presets + +**Statistics Example:** +```bash +# Get detailed statistics about recent content +soundtouch-cli --host 192.168.1.10 recents stats +``` + #### `presets` (Legacy) Get configured presets (legacy command for backward compatibility). @@ -346,6 +417,75 @@ soundtouch-cli --host 192.168.1.10 source select --source SPOTIFY --account user # Select Bluetooth soundtouch-cli --host 192.168.1.10 source bluetooth + +# Get introspect data for Spotify +soundtouch-cli --host 192.168.1.10 source introspect --source SPOTIFY + +# Get introspect data with account +soundtouch-cli --host 192.168.1.10 source introspect --source SPOTIFY --account user@spotify.com + +# Spotify introspect (convenience command) +soundtouch-cli --host 192.168.1.10 source introspect-spotify + +# Get introspect data for all available services +soundtouch-cli --host 192.168.1.10 source introspect-all + +# Check service availability +soundtouch-cli --host 192.168.1.10 source availability + +# Compare sources and availability +soundtouch-cli --host 192.168.1.10 source compare +``` + +#### Service Introspection + +Get detailed information about music service states, user accounts, capabilities, and authentication status. + +**Introspect Commands:** + +```bash +# Get introspect data for specific service +soundtouch-cli --host source introspect --source [--account ] + +# Spotify introspect (convenience) +soundtouch-cli --host source introspect-spotify [--account ] + +# Get introspect data for all services +soundtouch-cli --host source introspect-all +``` + +**Supported Services for Introspect:** +- `SPOTIFY` - Spotify streaming service +- `PANDORA` - Pandora music service +- `TUNEIN` - TuneIn radio service +- `AMAZON` - Amazon Music service +- `DEEZER` - Deezer streaming service + +**Introspect Information Includes:** +- Service state (Active, Inactive, InactiveUnselected) +- User account information +- Current playback status and content URI +- Service capabilities (skip, seek, resume support) +- Authentication token status +- Subscription type and content history limits +- Shuffle mode and data collection settings + +**Examples:** +```bash +# Get Spotify service status +soundtouch-cli --host 192.168.1.10 source introspect --source SPOTIFY + +# Get Spotify status with specific account +soundtouch-cli --host 192.168.1.10 source introspect --source SPOTIFY --account my_spotify_user + +# Use Spotify convenience command +soundtouch-cli --host 192.168.1.10 source introspect-spotify + +# Get status for all available streaming services +soundtouch-cli --host 192.168.1.10 source introspect-all + +# Check which services are available before introspecting +soundtouch-cli --host 192.168.1.10 source availability ``` ### Bass Control diff --git a/docs/UNIMPLEMENTED-ENDPOINTS.md b/docs/UNIMPLEMENTED-ENDPOINTS.md index 4095d89..9be4c1b 100644 --- a/docs/UNIMPLEMENTED-ENDPOINTS.md +++ b/docs/UNIMPLEMENTED-ENDPOINTS.md @@ -267,24 +267,7 @@ Rates currently playing media (Pandora only). ### System Information -#### GET /recents šŸ”„ **CRITICAL** -Returns recently played media content. -**Response Example:** -```xml - - - - MercyMe, It's Christmas! - - - - - Baby It's Cold Outside - ANNE MURRAY - - - -``` #### GET /listMediaServers šŸ”„ **CRITICAL** Returns detected UPnP/DLNA media servers. @@ -323,22 +306,7 @@ Returns source service availability status. ``` -#### POST /introspect šŸ”„ **CRITICAL** -Retrieves introspect data for specified music service. -**Request Example:** -```xml - -``` - -**Response Example:** -```xml - - - - - -``` ### Power Management diff --git a/docs/WIKI-IMPLEMENTATION-PLAN.md b/docs/WIKI-IMPLEMENTATION-PLAN.md index b0603dd..df9c9e1 100644 --- a/docs/WIKI-IMPLEMENTATION-PLAN.md +++ b/docs/WIKI-IMPLEMENTATION-PLAN.md @@ -108,8 +108,8 @@ Essential for browsing music libraries and searching content. // pkg/api/content.go (new file) func (c *Client) Navigate(source, sourceAccount string, options NavigateOptions) (*NavigateResponse, error) func (c *Client) Search(source, sourceAccount, searchTerm string, options SearchOptions) (*SearchResponse, error) -func (c *Client) GetRecents() (*RecentsResponse, error) -func (c *Client) Introspect(source, sourceAccount string) (*IntrospectResponse, error) +func (c *Client) GetRecents() (*RecentsResponse, error) // āœ… IMPLEMENTED +func (c *Client) Introspect(source, sourceAccount string) (*IntrospectResponse, error) // āœ… IMPLEMENTED ``` #### Data Structures: diff --git a/examples/introspect/README.md b/examples/introspect/README.md new file mode 100644 index 0000000..9c59346 --- /dev/null +++ b/examples/introspect/README.md @@ -0,0 +1,187 @@ +# Introspect Endpoint Example + +This example demonstrates how to use the `/introspect` endpoint to get detailed information about music service states and capabilities on your SoundTouch device. + +## What is the Introspect Endpoint? + +The introspect endpoint provides detailed information about music services (like Spotify, Pandora, TuneIn) including: + +- **Service State**: Active, Inactive, or InactiveUnselected +- **User Information**: Associated account names +- **Playback Status**: Currently playing content and URIs +- **Service Capabilities**: Skip, seek, resume support +- **Token Information**: Authentication token status +- **Content History**: History size limits +- **Subscription Details**: Premium/free account status + +## Usage + +```bash +# Basic usage - check Spotify status +go run main.go -host 192.168.1.100 + +# Check specific service with account +go run main.go -host 192.168.1.100 -source SPOTIFY -account "your_spotify_username" + +# Check Pandora service +go run main.go -host 192.168.1.100 -source PANDORA + +# Check TuneIn radio +go run main.go -host 192.168.1.100 -source TUNEIN + +# Custom timeout +go run main.go -host 192.168.1.100 -timeout 5s +``` + +## Command Line Options + +- `-host` - **Required**: SoundTouch device IP address +- `-source` - Music service to introspect (default: `SPOTIFY`) + - Supported: `SPOTIFY`, `PANDORA`, `TUNEIN`, `AMAZON`, `DEEZER`, etc. +- `-account` - Source account name (optional) +- `-timeout` - Request timeout (default: `10s`) + +## Example Output + +``` +Getting introspect data for SPOTIFY + +=== SPOTIFY Service Introspect Data === +State: InactiveUnselected +User: SpotifyConnectUserName +Currently Playing: false +Current Content: +Shuffle Mode: OFF +Subscription Type: + +=== Service State === +āŒ Service is INACTIVE + +=== Service Capabilities === +āŒ Skip Previous not supported +āŒ Seek not supported +āœ… Resume supported +āœ… Data collection enabled + +=== Content History === +Max History Size: 10 items + +=== Technical Details === +Token Last Changed: 1702566495 seconds +Token Microseconds: 427884 +Play Status State: 2 +Received Playback Request: false + +=== Service Availability Check === +āœ… Spotify is available on this device + +Done! +``` + +## Understanding the Output + +### Service States +- **Active**: Service is currently selected and active +- **Inactive**: Service is available but not currently active +- **InactiveUnselected**: Service is available but never been used + +### Capabilities +- **Skip Previous**: Can skip to previous track +- **Seek**: Can seek within tracks (scrub timeline) +- **Resume**: Can resume paused playback +- **Data Collection**: Service collects usage analytics + +### Technical Fields +- **Token Last Changed**: Unix timestamp of last authentication +- **Play Status State**: Internal playback state code +- **Current URI**: Unique identifier for currently playing content + +## Common Use Cases + +### 1. Check if Spotify is Logged In +```go +response, err := client.Introspect("SPOTIFY", "") +if err != nil { + log.Fatal(err) +} + +if response.HasUser() && response.IsActive() { + fmt.Println("Spotify is logged in and active") +} else { + fmt.Println("Spotify needs authentication or activation") +} +``` + +### 2. Verify Service Capabilities Before Playback Control +```go +response, err := client.IntrospectSpotify("") +if err != nil { + log.Fatal(err) +} + +if response.SupportsSeek() { + // Safe to use seek controls + fmt.Println("Seek controls available") +} + +if response.SupportsSkipPrevious() { + // Safe to use previous track + fmt.Println("Previous track control available") +} +``` + +### 3. Monitor Service Health +```go +response, err := client.Introspect("PANDORA", "my_pandora_user") +if err != nil { + log.Fatal(err) +} + +if !response.IsActive() { + fmt.Println("Pandora service needs activation") +} + +if response.HasSubscription() { + fmt.Printf("Premium account: %s\n", response.SubscriptionType) +} +``` + +## Related API Methods + +- `client.GetServiceAvailability()` - Check which services are available +- `client.SelectSource(source, account)` - Activate a music service +- `client.GetNowPlaying()` - Get current playback information + +## Error Handling + +The introspect endpoint may fail if: +- Service is not supported on the device +- Invalid source name provided +- Network connectivity issues +- Device is in standby mode + +Always check for errors and handle gracefully: + +```go +response, err := client.Introspect("SPOTIFY", "") +if err != nil { + if strings.Contains(err.Error(), "failed to get introspect data") { + fmt.Println("Service may not be configured or available") + return + } + log.Fatal(err) +} +``` + +## Integration with Other Examples + +This introspect data is useful before: +- [Preset Management](../preset-management/) - Verify service state before storing presets +- [Source Selection](../source-selection/) - Check capabilities before switching sources +- [Zone Management](../zone-management/) - Ensure all devices support the service + +## API Documentation + +For complete API documentation, see: +- [API Reference](../../docs/API-Endpoints-Overview.md) +- [Service Management Guide](../../docs/SERVICE-MANAGEMENT.md) \ No newline at end of file diff --git a/examples/introspect/cli-demo.md b/examples/introspect/cli-demo.md new file mode 100644 index 0000000..ade2ec7 --- /dev/null +++ b/examples/introspect/cli-demo.md @@ -0,0 +1,393 @@ +# Introspect CLI Commands Demo + +This document demonstrates the usage and output of the new introspect CLI commands added to the soundtouch-cli tool. + +## Available Commands + +The introspect functionality is available through three commands in the `source` command group: + +1. `source introspect` - Get introspect data for any supported service +2. `source introspect-spotify` - Convenience command specifically for Spotify +3. `source introspect-all` - Get introspect data for all available services + +## Command Examples and Expected Output + +### 1. Basic Spotify Introspect + +```bash +$ soundtouch-cli --host 192.168.1.100 source introspect --source SPOTIFY +``` + +**Expected Output:** +``` +ā Žā •ā „ā ā ™ā ¤ā žā •ā „ā ‰ā “ SoundTouch CLI v1.0.0 +šŸ”— Connecting to SoundTouch device at 192.168.1.100:8090 + +Getting introspect data for SPOTIFY + +=== SPOTIFY Service Introspect Data === +State: InactiveUnselected +User: SpotifyConnectUserName +Currently Playing: āŒ No +Current Content: +Shuffle Mode: OFF +Subscription Type: + +=== Service State === +āŒ Service is INACTIVE (Never been used) +āøļø Not currently playing +āž”ļø Shuffle mode is OFF + +=== Service Capabilities === +āŒ ā®ļø Skip Previous +āŒ šŸŽÆ Seek within tracks +āœ… ā–¶ļø Resume playback +āœ… šŸ“Š Data collection: ENABLED + +=== Spotify Content History === +Max History Size: 10 items + +=== Technical Details === +Token Last Changed: 2023-12-14 10:48:15 MST +Token Timestamp: 1702566495 seconds since Unix epoch +Token Microseconds: 427884 +Play Status State: 2 +Received Playback Request: āŒ No +``` + +### 2. Spotify Introspect with Account + +```bash +$ soundtouch-cli --host 192.168.1.100 source introspect --source SPOTIFY --account my_spotify_user +``` + +**Expected Output:** +``` +ā Žā •ā „ā ā ™ā ¤ā žā •ā „ā ‰ā “ SoundTouch CLI v1.0.0 +šŸ”— Connecting to SoundTouch device at 192.168.1.100:8090 + +Getting introspect data for SPOTIFY +Source Account: my_spotify_user + +=== SPOTIFY Service Introspect Data === +State: Active +User: my_spotify_user +Currently Playing: āœ… Yes +Current Content: spotify://track/4iV5W9uYEdYUVa79Axb7Rh +Shuffle Mode: ON +Subscription Type: Premium + +=== Service State === +āœ… Service is ACTIVE +šŸŽµ Currently playing content +šŸ”€ Shuffle mode is ON + +=== Service Capabilities === +āœ… ā®ļø Skip Previous +āœ… šŸŽÆ Seek within tracks +āœ… ā–¶ļø Resume playback +🚫 Data collection: DISABLED + +=== Spotify Content History === +Max History Size: 15 items + +=== Technical Details === +Token Last Changed: 2023-12-14 15:30:22 MST +Token Timestamp: 1702583422 seconds since Unix epoch +Token Microseconds: 123456 +Play Status State: 1 +Received Playback Request: āœ… Yes +``` + +### 3. Spotify Convenience Command + +```bash +$ soundtouch-cli --host 192.168.1.100 source introspect-spotify +``` + +**Expected Output:** +``` +ā Žā •ā „ā ā ™ā ¤ā žā •ā „ā ‰ā “ SoundTouch CLI v1.0.0 +šŸ”— Connecting to SoundTouch device at 192.168.1.100:8090 + +Getting Spotify introspect data + +=== Spotify Service Introspect Data === +State: Active +User: premium_user +Currently Playing: āœ… Yes +Current Content: spotify://playlist/37i9dQZF1DXcBWIGoYBM5M +Shuffle Mode: ON +Subscription Type: Premium + +=== Spotify Service State === +āœ… Service is ACTIVE +šŸŽµ Currently playing content +šŸ”€ Shuffle mode is ON + +=== Spotify Service Capabilities === +āœ… ā®ļø Skip Previous +āœ… šŸŽÆ Seek within tracks +āœ… ā–¶ļø Resume playback +🚫 Data collection: DISABLED + +šŸ’” Spotify Setup Recommendations: + (None - service is properly configured and active) + +=== Spotify Content History === +Max History Size: 20 items + +=== Technical Details === +Token Last Changed: 2023-12-14 16:45:10 MST +Token Timestamp: 1702587910 seconds since Unix epoch +Token Microseconds: 789012 +Play Status State: 1 +Received Playback Request: āœ… Yes +``` + +### 4. Inactive Service Example + +```bash +$ soundtouch-cli --host 192.168.1.100 source introspect-spotify +``` + +**Expected Output (when Spotify is not set up):** +``` +ā Žā •ā „ā ā ™ā ¤ā žā •ā „ā ‰ā “ SoundTouch CLI v1.0.0 +šŸ”— Connecting to SoundTouch device at 192.168.1.100:8090 + +Getting Spotify introspect data + +=== Spotify Service Introspect Data === +State: InactiveUnselected +User: +Currently Playing: āŒ No +Current Content: +Shuffle Mode: OFF +Subscription Type: + +=== Spotify Service State === +āŒ Service is INACTIVE (Never been used) +āøļø Not currently playing +āž”ļø Shuffle mode is OFF + +=== Spotify Service Capabilities === +āŒ ā®ļø Skip Previous +āŒ šŸŽÆ Seek within tracks +āœ… ā–¶ļø Resume playback +āœ… šŸ“Š Data collection: ENABLED + +šŸ’” Spotify Setup Recommendations: + • Sign in to your Spotify account on the device + • Use 'soundtouch-cli source select --source SPOTIFY' to activate Spotify + • Ensure you have Spotify Premium for full functionality +``` + +### 5. All Services Introspect + +```bash +$ soundtouch-cli --host 192.168.1.100 source introspect-all +``` + +**Expected Output:** +``` +ā Žā •ā „ā ā ™ā ¤ā žā •ā „ā ‰ā “ SoundTouch CLI v1.0.0 +šŸ”— Connecting to SoundTouch device at 192.168.1.100:8090 + +Getting introspect data for all services + +šŸ” Getting introspect data for SPOTIFY... +āœ… SPOTIFY: Successfully retrieved introspect data + State: Active (User: spotify_user) + Playing: āœ… Yes | Content: spotify://track/4iV5W9uYEdYUVa79Axb7Rh + Capabilities: Skip, Seek, Resume + +────────────────────────────────────────────────── +šŸ” Getting introspect data for PANDORA... +āŒ PANDORA: Service not available on this device + +────────────────────────────────────────────────── +šŸ” Getting introspect data for TUNEIN... +āœ… TUNEIN: Successfully retrieved introspect data + State: Inactive + Playing: āŒ No + Capabilities: Resume + +────────────────────────────────────────────────── +šŸ” Getting introspect data for AMAZON... +āŒ AMAZON: Failed to get introspect data - service not configured + +────────────────────────────────────────────────── +šŸ” Getting introspect data for DEEZER... +āŒ DEEZER: Service not available on this device + +══════════════════════════════════════════════════ +šŸ“Š Introspect Summary: + āœ… Successful: 2 services + āŒ Failed: 3 services + šŸ“” Total checked: 5 services + +āœ… Successfully retrieved introspect data for 2 services +``` + +### 6. Error Handling Examples + +#### Missing Source Parameter +```bash +$ soundtouch-cli --host 192.168.1.100 source introspect +``` + +**Output:** +``` +NAME: + soundtouch-cli source introspect - Get introspect data for a music service + +USAGE: + soundtouch-cli source introspect [command options] + +OPTIONS: + --account value, -a value Source account name (optional) + --source value, -s value Music service source (SPOTIFY, PANDORA, TUNEIN, etc.) + --help, -h show help + +Required flag "source" not set +``` + +#### Missing Host Parameter +```bash +$ soundtouch-cli source introspect --source SPOTIFY +``` + +**Output:** +``` +host is required. Use --host flag or set SOUNDTOUCH_HOST environment variable +``` + +#### Invalid Service +```bash +$ soundtouch-cli --host 192.168.1.100 source introspect --source INVALID_SERVICE +``` + +**Expected Output:** +``` +ā Žā •ā „ā ā ™ā ¤ā žā •ā „ā ‰ā “ SoundTouch CLI v1.0.0 +šŸ”— Connecting to SoundTouch device at 192.168.1.100:8090 + +āš ļø Service INVALID_SERVICE may not be available, but continuing with introspect request... + +Getting introspect data for INVALID_SERVICE + +āŒ Error: failed to get introspect data: HTTP 404: endpoint not found or service not supported +``` + +## Integration with Other Commands + +The introspect commands work well with other CLI commands: + +### 1. Check Availability First +```bash +# Check what services are available +$ soundtouch-cli --host 192.168.1.100 source availability + +# Then introspect specific services +$ soundtouch-cli --host 192.168.1.100 source introspect --source SPOTIFY +``` + +### 2. Activate Service After Introspect +```bash +# Check service status +$ soundtouch-cli --host 192.168.1.100 source introspect-spotify + +# If inactive, activate it +$ soundtouch-cli --host 192.168.1.100 source select --source SPOTIFY +``` + +### 3. Compare Sources and Introspect Data +```bash +# Compare configured sources vs available services +$ soundtouch-cli --host 192.168.1.100 source compare + +# Get detailed introspect data for specific services +$ soundtouch-cli --host 192.168.1.100 source introspect-all +``` + +## Environment Variables + +The introspect commands respect the same environment variables as other CLI commands: + +- `SOUNDTOUCH_HOST` - Default device IP address +- `SOUNDTOUCH_SKIP_AVAILABILITY_CHECK` - Skip service availability validation +- `SOUNDTOUCH_TIMEOUT` - Request timeout duration + +**Example:** +```bash +export SOUNDTOUCH_HOST=192.168.1.100 +soundtouch-cli source introspect-spotify +``` + +## Use Cases + +### 1. Service Setup Verification +Check if streaming services are properly configured and authenticated: +```bash +soundtouch-cli --host $DEVICE source introspect-spotify +soundtouch-cli --host $DEVICE source introspect --source PANDORA +``` + +### 2. Troubleshooting Playback Issues +Understand why certain playback controls aren't working: +```bash +# Check if seek is supported +soundtouch-cli --host $DEVICE source introspect --source SPOTIFY | grep -i seek + +# Check current playback state +soundtouch-cli --host $DEVICE source introspect-spotify | grep -i playing +``` + +### 3. Service Health Monitoring +Monitor the health and status of streaming services: +```bash +# Quick health check for all services +soundtouch-cli --host $DEVICE source introspect-all + +# Detailed status for critical service +soundtouch-cli --host $DEVICE source introspect-spotify +``` + +### 4. Account Management +Verify which accounts are associated with services: +```bash +# Check current Spotify account +soundtouch-cli --host $DEVICE source introspect-spotify | grep -i user + +# Check with specific account parameter +soundtouch-cli --host $DEVICE source introspect --source SPOTIFY --account specific_user +``` + +## Tips + +1. **Use with grep**: Pipe output to `grep` to filter specific information: + ```bash + soundtouch-cli --host $DEVICE source introspect-spotify | grep -E "(State|User|Playing)" + ``` + +2. **JSON output**: While not currently implemented, future versions may support JSON output for scripting: + ```bash + # Future feature + soundtouch-cli --host $DEVICE source introspect-spotify --format json + ``` + +3. **Batch operations**: Use shell scripting to check multiple devices: + ```bash + for device in 192.168.1.100 192.168.1.101; do + echo "=== Device $device ===" + soundtouch-cli --host $device source introspect-spotify + done + ``` + +4. **Environment setup**: Set up your environment for easier usage: + ```bash + export SOUNDTOUCH_HOST=192.168.1.100 + alias st='soundtouch-cli' + st source introspect-spotify + ``` diff --git a/examples/introspect/main.go b/examples/introspect/main.go new file mode 100644 index 0000000..c1156e9 --- /dev/null +++ b/examples/introspect/main.go @@ -0,0 +1,148 @@ +package main + +import ( + "flag" + "fmt" + "log" + "time" + + "github.com/gesellix/bose-soundtouch/pkg/client" +) + +func main() { + var ( + host = flag.String("host", "", "SoundTouch device IP address") + source = flag.String("source", "SPOTIFY", "Music service source (SPOTIFY, PANDORA, TUNEIN)") + sourceAccount = flag.String("account", "", "Source account name (optional)") + timeout = flag.Duration("timeout", 10*time.Second, "Request timeout") + ) + flag.Parse() + + if *host == "" { + log.Fatal("Please provide a SoundTouch device IP address with -host flag") + } + + // Create client + config := &client.Config{ + Host: *host, + Port: 8090, + Timeout: *timeout, + } + soundTouchClient := client.NewClient(config) + + fmt.Printf("Getting introspect data for %s", *source) + if *sourceAccount != "" { + fmt.Printf(" (account: %s)", *sourceAccount) + } + fmt.Println() + + // Get introspect data + response, err := soundTouchClient.Introspect(*source, *sourceAccount) + if err != nil { + log.Fatalf("Failed to get introspect data: %v", err) + } + + // Display basic information + fmt.Printf("\n=== %s Service Introspect Data ===\n", *source) + fmt.Printf("State: %s\n", response.State) + + if response.HasUser() { + fmt.Printf("User: %s\n", response.User) + } + + fmt.Printf("Currently Playing: %t\n", 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) + } + + // Display service state + fmt.Printf("\n=== Service State ===\n") + if response.IsActive() { + fmt.Println("āœ… Service is ACTIVE") + } else if response.IsInactive() { + fmt.Println("āŒ Service is INACTIVE") + } + + // Display capabilities + fmt.Printf("\n=== Service Capabilities ===\n") + if response.SupportsSkipPrevious() { + fmt.Println("āœ… Skip Previous supported") + } else { + fmt.Println("āŒ Skip Previous not supported") + } + + if response.SupportsSeek() { + fmt.Println("āœ… Seek supported") + } else { + fmt.Println("āŒ Seek not supported") + } + + if response.SupportsResume() { + fmt.Println("āœ… Resume supported") + } else { + fmt.Println("āŒ Resume not supported") + } + + if response.CollectsData() { + fmt.Println("šŸ“Š Data collection enabled") + } else { + fmt.Println("🚫 Data collection disabled") + } + + // Display history information + historySize := response.GetMaxHistorySize() + if historySize > 0 { + fmt.Printf("\n=== Content History ===\n") + fmt.Printf("Max History Size: %d items\n", historySize) + } + + // Display technical details + if response.TokenLastChangedTimeSeconds > 0 { + fmt.Printf("\n=== Technical Details ===\n") + fmt.Printf("Token Last Changed: %d seconds\n", response.TokenLastChangedTimeSeconds) + if response.TokenLastChangedTimeMicroseconds > 0 { + fmt.Printf("Token Microseconds: %d\n", response.TokenLastChangedTimeMicroseconds) + } + fmt.Printf("Play Status State: %s\n", response.PlayStatusState) + fmt.Printf("Received Playback Request: %t\n", response.ReceivedPlaybackRequest) + } + + // Show service availability for comparison + fmt.Printf("\n=== Service Availability Check ===\n") + availability, err := soundTouchClient.GetServiceAvailability() + if err != nil { + fmt.Printf("Could not check service availability: %v\n", err) + } else { + switch *source { + case "SPOTIFY": + if availability.HasSpotify() { + fmt.Println("āœ… Spotify is available on this device") + } else { + fmt.Println("āŒ Spotify is not available on this device") + } + case "PANDORA": + if availability.HasPandora() { + fmt.Println("āœ… Pandora is available on this device") + } else { + fmt.Println("āŒ Pandora is not available on this device") + } + case "TUNEIN": + if availability.HasTuneIn() { + fmt.Println("āœ… TuneIn is available on this device") + } else { + fmt.Println("āŒ TuneIn is not available on this device") + } + default: + fmt.Printf("Service availability check not implemented for %s\n", *source) + } + } + + fmt.Println("\nDone!") +} diff --git a/examples/recents/README.md b/examples/recents/README.md new file mode 100644 index 0000000..6a4455b --- /dev/null +++ b/examples/recents/README.md @@ -0,0 +1,279 @@ +# Recents Endpoint Example + +This example demonstrates how to use the `/recents` endpoint to retrieve and analyze recently played content from your SoundTouch device. + +## What is the Recents Endpoint? + +The recents endpoint provides access to the device's recently played content history, including: + +- **Recently played tracks** from various music services +- **Radio stations** that were recently listened to +- **Playlists and albums** that were recently accessed +- **Local music** files that were recently played +- **Metadata** including play timestamps, content types, and source information +- **Filtering capabilities** by source type and content type + +## Usage + +```bash +# Basic usage - show last 10 items +go run main.go -host 192.168.1.100 + +# Show detailed information for all items +go run main.go -host 192.168.1.100 -detailed -limit 0 + +# Filter by source (show only Spotify items) +go run main.go -host 192.168.1.100 -source SPOTIFY + +# Filter by content type (show only tracks) +go run main.go -host 192.168.1.100 -type track + +# Show statistics only +go run main.go -host 192.168.1.100 -stats + +# Combined filters with custom limit +go run main.go -host 192.168.1.100 -source LOCAL_MUSIC -type track -limit 5 -detailed +``` + +## Command Line Options + +- `-host` - **Required**: SoundTouch device IP address +- `-detailed` - Show detailed information for each item (default: false) +- `-limit` - Maximum number of items to display, 0 for all (default: 10) +- `-source` - Filter by source (SPOTIFY, LOCAL_MUSIC, TUNEIN, etc.) +- `-type` - Filter by content type (track, station, playlist, album, presetable) +- `-stats` - Show statistics only (default: false) +- `-timeout` - Request timeout duration (default: 10s) + +## Example Output + +### Basic Listing +``` +Getting recent items from 192.168.1.100 + +šŸ“Š Recent Items Summary: + Showing: 5 items (of 15 total) + By Source: Spotify: 3, Local: 1, TuneIn: 1 + +=== Recent Items === +1. šŸŽµ Shape of You - Ed Sheeran + Source: Spotify | Type: Track + Played: 2023-12-14 15:30:22 (2 hours ago) + +2. šŸ“» BBC Radio 1 + Source: TuneIn Radio | Type: Stationurl + Played: 2023-12-14 13:15:45 (4 hours ago) + +3. šŸŽµ Local Song.mp3 + Source: Local Music | Type: Track + Played: 2023-12-14 10:45:12 (7 hours ago) + +šŸ’” Showing 3 of 15 total items + Use -limit 0 to show all items +``` + +### Detailed Information +``` +1. šŸŽµ Shape of You - Ed Sheeran + Source: Spotify | Type: Track + Played: 2023-12-14 15:30:22 (2 hours ago) + ID: spotify123 + ⭐ Can be saved as preset + šŸŽØ Has artwork + šŸ“ Location: spotify:track:4iV5W9uYEdYUVa79Axb7Rh + šŸ‘¤ Account: spotify_user + šŸ·ļø Type: Streaming +``` + +### Statistics View +``` +šŸ“Š Recent Items Statistics + +Overall Statistics: + Total Items: 25 + Last Played: 2023-12-14 15:30:22 + +šŸ“ By Source: + Spotify 15 items ( 60.0%) + Local Music 6 items ( 24.0%) + TuneIn 3 items ( 12.0%) + Pandora 1 items ( 4.0%) + +šŸŽ¼ By Content Type: + Tracks 20 items ( 80.0%) + Stations 4 items ( 16.0%) + Playlists/Albums 1 items ( 4.0%) + +⭐ Special Categories: + Presetable 18 items ( 72.0%) + +šŸ“” Source Analysis: + Streaming 19 items ( 76.0%) + Local 6 items ( 24.0%) + +šŸ• Time Analysis: + Today 12 items + Yesterday 8 items + This Week 3 items + Older 2 items +``` + +## Supported Sources + +- **SPOTIFY** - Spotify streaming service +- **LOCAL_MUSIC** - Local music files +- **STORED_MUSIC** - Stored music library +- **TUNEIN** - TuneIn radio stations +- **PANDORA** - Pandora music service +- **AMAZON** - Amazon Music +- **DEEZER** - Deezer streaming +- **IHEART** - iHeartRadio +- **BLUETOOTH** - Bluetooth input +- **AUX** - AUX input +- **AIRPLAY** - AirPlay + +## Content Types + +- **track** - Individual songs/tracks +- **station** - Radio stations +- **playlist** - Music playlists +- **album** - Music albums +- **container** - Folders/collections +- **presetable** - Items that can be saved as presets + +## Use Cases + +### 1. Recently Played Music Discovery +```bash +# Find recently played Spotify tracks +go run main.go -host 192.168.1.100 -source SPOTIFY -type track -detailed +``` + +### 2. Radio Station History +```bash +# See what radio stations were recently played +go run main.go -host 192.168.1.100 -type station -detailed +``` + +### 3. Content Analytics +```bash +# Get detailed listening statistics +go run main.go -host 192.168.1.100 -stats +``` + +### 4. Preset Candidates +```bash +# Find content that can be saved as presets +go run main.go -host 192.168.1.100 -type presetable -limit 6 +``` + +### 5. Local vs Streaming Analysis +```bash +# Compare local vs streaming content usage +go run main.go -host 192.168.1.100 -stats +``` + +## API Integration + +The example demonstrates several key API patterns: + +### Basic Retrieval +```go +response, err := client.GetRecents() +if err != nil { + log.Fatal(err) +} + +if response.IsEmpty() { + fmt.Println("No recent items found") + return +} +``` + +### Filtering by Source +```go +spotifyItems := response.GetSpotifyItems() +localItems := response.GetLocalMusicItems() +tuneInItems := response.GetTuneInItems() +``` + +### Filtering by Type +```go +tracks := response.GetTracks() +stations := response.GetStations() +presetableItems := response.GetPresetableItems() +``` + +### Item Analysis +```go +for _, item := range response.Items { + if item.IsSpotifyContent() { + fmt.Printf("Spotify track: %s\n", item.GetDisplayName()) + } + + if item.IsPresetable() { + fmt.Printf("Can be saved as preset: %s\n", item.GetDisplayName()) + } + + if item.HasArtwork() { + fmt.Printf("Artwork URL: %s\n", item.GetArtwork()) + } +} +``` + +## Error Handling + +The example includes comprehensive error handling: + +```bash +# Test with invalid host +go run main.go -host 192.168.255.255 +# Output: Failed to get recent items: connection timeout + +# Test with unknown source +go run main.go -host 192.168.1.100 -source UNKNOWN +# Output: šŸ“­ No items found for source: UNKNOWN +# šŸ’” Available sources: SPOTIFY, LOCAL_MUSIC, TUNEIN + +# Test with unknown type +go run main.go -host 192.168.1.100 -type unknown +# Output: āŒ Unknown type filter: unknown +# šŸ’” Available types: track, station, playlist, album, presetable +``` + +## Performance Considerations + +- The recents endpoint typically returns up to 20-50 items depending on device configuration +- Response times are usually under 500ms for typical recent lists +- Use filtering to reduce processing time for large recent lists +- Consider caching results if calling frequently in applications + +## Integration with Other Examples + +This recents data is useful for: +- [Preset Management](../preset-management/) - Finding presetable content to save +- [Source Selection](../source-selection/) - Understanding usage patterns +- [Navigation](../navigation/) - Quickly accessing recently played content + +## Related CLI Commands + +```bash +# List recent items using CLI +soundtouch-cli --host 192.168.1.100 recents list + +# Filter recent items by source +soundtouch-cli --host 192.168.1.100 recents filter --source SPOTIFY + +# Get recent items statistics +soundtouch-cli --host 192.168.1.100 recents stats + +# Show most recent item only +soundtouch-cli --host 192.168.1.100 recents latest +``` + +## API Documentation + +For complete API documentation, see: +- [API Reference](../../docs/API-Endpoints-Overview.md) +- [CLI Reference](../../docs/CLI-REFERENCE.md) +- [Recents Models](../../pkg/models/recents.go) \ No newline at end of file diff --git a/examples/recents/main.go b/examples/recents/main.go new file mode 100644 index 0000000..dd9000a --- /dev/null +++ b/examples/recents/main.go @@ -0,0 +1,479 @@ +package main + +import ( + "flag" + "fmt" + "log" + "sort" + "strings" + "time" + + "github.com/gesellix/bose-soundtouch/pkg/client" + "github.com/gesellix/bose-soundtouch/pkg/models" +) + +func main() { + var ( + host = flag.String("host", "", "SoundTouch device IP address") + timeout = flag.Duration("timeout", 10*time.Second, "Request timeout") + detailed = flag.Bool("detailed", false, "Show detailed information for each item") + limit = flag.Int("limit", 10, "Maximum number of items to display (0 for all)") + source = flag.String("source", "", "Filter by source (SPOTIFY, LOCAL_MUSIC, etc.)") + itemType = flag.String("type", "", "Filter by type (track, station, playlist, presetable)") + stats = flag.Bool("stats", false, "Show statistics only") + ) + flag.Parse() + + if *host == "" { + log.Fatal("Please provide a SoundTouch device IP address with -host flag") + } + + // Create client + config := &client.Config{ + Host: *host, + Port: 8090, + Timeout: *timeout, + } + soundTouchClient := client.NewClient(config) + + fmt.Printf("Getting recent items from %s\n", *host) + + // Get recent items + response, err := soundTouchClient.GetRecents() + if err != nil { + log.Fatalf("Failed to get recent items: %v", err) + } + + if response.IsEmpty() { + fmt.Println("\nšŸ“­ No recent items found") + fmt.Println("šŸ’” Play some content to populate the recent items list") + return + } + + // Show statistics if requested + if *stats { + showStatistics(response) + return + } + + // Apply filters + items := response.Items + + if *source != "" { + items = response.GetItemsBySource(strings.ToUpper(*source)) + if len(items) == 0 { + fmt.Printf("šŸ“­ No items found for source: %s\n", *source) + fmt.Println("šŸ’” Available sources:", getAvailableSources(response)) + return + } + } + + // Apply type filter + if *itemType != "" { + var filteredItems []models.RecentsResponseItem + switch strings.ToLower(*itemType) { + case "track", "tracks": + for _, item := range items { + if item.IsTrack() { + filteredItems = append(filteredItems, item) + } + } + case "station", "stations": + for _, item := range items { + if item.IsStation() { + filteredItems = append(filteredItems, item) + } + } + case "playlist", "playlists": + for _, item := range items { + if item.IsPlaylist() { + filteredItems = append(filteredItems, item) + } + } + case "album", "albums": + for _, item := range items { + if item.IsAlbum() { + filteredItems = append(filteredItems, item) + } + } + case "presetable": + for _, item := range items { + if item.IsPresetable() { + filteredItems = append(filteredItems, item) + } + } + default: + fmt.Printf("āŒ Unknown type filter: %s\n", *itemType) + fmt.Println("šŸ’” Available types: track, station, playlist, album, presetable") + return + } + + items = filteredItems + + if len(items) == 0 { + fmt.Printf("šŸ“­ No items found for type: %s\n", *itemType) + return + } + } + + // Apply limit + if *limit > 0 && *limit < len(items) { + items = items[:*limit] + } + + // Display results + displayResults(response, items, *detailed, *source, *itemType) +} + +func showStatistics(response *models.RecentsResponse) { + fmt.Printf("\nšŸ“Š Recent Items Statistics\n\n") + + // Basic stats + fmt.Printf("Overall Statistics:\n") + fmt.Printf(" Total Items: %d\n", response.GetItemCount()) + + if !response.IsEmpty() { + mostRecent := response.GetMostRecent() + if mostRecent != nil { + lastPlayTime := time.Unix(mostRecent.GetUTCTime(), 0) + fmt.Printf(" Last Played: %s\n", lastPlayTime.Format("2006-01-02 15:04:05")) + } + } + + // Source breakdown + fmt.Printf("\nšŸ“ By Source:\n") + sourceStats := map[string]int{ + "Spotify": len(response.GetSpotifyItems()), + "Pandora": len(response.GetPandoraItems()), + "TuneIn": len(response.GetTuneInItems()), + "Local Music": len(response.GetLocalMusicItems()), + "Stored Music": len(response.GetStoredMusicItems()), + } + + // Sort sources by count + type sourceCount struct { + name string + count int + } + var sources []sourceCount + for name, count := range sourceStats { + if count > 0 { + sources = append(sources, sourceCount{name, count}) + } + } + sort.Slice(sources, func(i, j int) bool { + return sources[i].count > sources[j].count + }) + + for _, sc := range sources { + percentage := float64(sc.count) / float64(response.GetItemCount()) * 100 + fmt.Printf(" %-15s %3d items (%5.1f%%)\n", sc.name+":", sc.count, percentage) + } + + // Content type breakdown + fmt.Printf("\nšŸŽ¼ By Content Type:\n") + tracks := len(response.GetTracks()) + stations := len(response.GetStations()) + playlists := len(response.GetPlaylistsAndAlbums()) + + typeStats := []sourceCount{ + {"Tracks", tracks}, + {"Stations", stations}, + {"Playlists/Albums", playlists}, + } + + for _, ts := range typeStats { + if ts.count > 0 { + percentage := float64(ts.count) / float64(response.GetItemCount()) * 100 + fmt.Printf(" %-15s %3d items (%5.1f%%)\n", ts.name+":", ts.count, percentage) + } + } + + // Special categories + presetable := len(response.GetPresetableItems()) + if presetable > 0 { + fmt.Printf("\n⭐ Special Categories:\n") + percentage := float64(presetable) / float64(response.GetItemCount()) * 100 + fmt.Printf(" %-15s %3d items (%5.1f%%)\n", "Presetable:", presetable, percentage) + } + + // Content source analysis + streamingCount := 0 + localCount := 0 + for _, item := range response.Items { + if item.IsStreamingContent() { + streamingCount++ + } else if item.IsLocalContent() { + localCount++ + } + } + + if streamingCount > 0 || localCount > 0 { + fmt.Printf("\nšŸ“” Source Analysis:\n") + if streamingCount > 0 { + percentage := float64(streamingCount) / float64(response.GetItemCount()) * 100 + fmt.Printf(" %-15s %3d items (%5.1f%%)\n", "Streaming:", streamingCount, percentage) + } + if localCount > 0 { + percentage := float64(localCount) / float64(response.GetItemCount()) * 100 + fmt.Printf(" %-15s %3d items (%5.1f%%)\n", "Local:", localCount, percentage) + } + } + + // Time analysis - show when items were played + fmt.Printf("\nšŸ• Time Analysis:\n") + now := time.Now() + today := 0 + yesterday := 0 + thisWeek := 0 + older := 0 + + for _, item := range response.Items { + if item.GetUTCTime() > 0 { + playTime := time.Unix(item.GetUTCTime(), 0) + diff := now.Sub(playTime) + + if diff < 24*time.Hour { + today++ + } else if diff < 48*time.Hour { + yesterday++ + } else if diff < 7*24*time.Hour { + thisWeek++ + } else { + older++ + } + } + } + + if today > 0 { + fmt.Printf(" %-15s %3d items\n", "Today:", today) + } + if yesterday > 0 { + fmt.Printf(" %-15s %3d items\n", "Yesterday:", yesterday) + } + if thisWeek > 0 { + fmt.Printf(" %-15s %3d items\n", "This Week:", thisWeek) + } + if older > 0 { + fmt.Printf(" %-15s %3d items\n", "Older:", older) + } +} + +func displayResults(response *models.RecentsResponse, items []models.RecentsResponseItem, detailed bool, sourceFilter, typeFilter string) { + // Build filter description + var filters []string + if sourceFilter != "" { + filters = append(filters, fmt.Sprintf("source: %s", sourceFilter)) + } + if typeFilter != "" { + filters = append(filters, fmt.Sprintf("type: %s", typeFilter)) + } + + filterDesc := "" + if len(filters) > 0 { + filterDesc = fmt.Sprintf(" (filtered by %s)", strings.Join(filters, ", ")) + } + + // Display header + fmt.Printf("\nšŸ“Š Recent Items Summary%s:\n", filterDesc) + fmt.Printf(" Showing: %d items", len(items)) + if len(items) < response.GetItemCount() { + fmt.Printf(" (of %d total)", response.GetItemCount()) + } + fmt.Println() + + if len(filters) == 0 { + // Show source breakdown for unfiltered results + sources := []string{} + sourceCounts := map[string]int{ + "Spotify": len(response.GetSpotifyItems()), + "Local": len(response.GetLocalMusicItems()) + len(response.GetStoredMusicItems()), + "TuneIn": len(response.GetTuneInItems()), + "Pandora": len(response.GetPandoraItems()), + } + + for source, count := range sourceCounts { + if count > 0 { + sources = append(sources, fmt.Sprintf("%s: %d", source, count)) + } + } + + if len(sources) > 0 { + fmt.Printf(" By Source: %s\n", strings.Join(sources, ", ")) + } + } + + fmt.Printf("\n=== Recent Items ===\n") + + // Display items + for i, item := range items { + displayItem(i+1, &item, detailed) + } + + if len(items) < response.GetItemCount() { + fmt.Printf("\nšŸ’” Showing %d of %d total items\n", len(items), response.GetItemCount()) + fmt.Printf(" Use -limit 0 to show all items\n") + } +} + +func displayItem(index int, item *models.RecentsResponseItem, detailed bool) { + // Basic information + displayName := item.GetDisplayName() + source := formatSource(item.GetSource()) + contentType := item.GetContentType() + + // Content type icon + icon := getIcon(item) + + fmt.Printf("%d. %s %s\n", index, icon, displayName) + fmt.Printf(" Source: %s", source) + + if contentType != "" { + fmt.Printf(" | Type: %s", strings.Title(contentType)) + } + fmt.Println() + + // Time information + if item.GetUTCTime() > 0 { + playTime := time.Unix(item.GetUTCTime(), 0) + timeAgo := time.Since(playTime) + fmt.Printf(" Played: %s", playTime.Format("2006-01-02 15:04:05")) + fmt.Printf(" (%s ago)\n", formatDuration(timeAgo)) + } + + // Additional details if requested + if detailed { + if item.HasID() { + fmt.Printf(" ID: %s\n", item.GetID()) + } + + if item.IsPresetable() { + fmt.Printf(" ⭐ Can be saved as preset\n") + } + + if item.HasArtwork() { + fmt.Printf(" šŸŽØ Has artwork\n") + } + + location := item.GetLocation() + if location != "" { + fmt.Printf(" šŸ“ Location: %s\n", truncateString(location, 60)) + } + + sourceAccount := item.GetSourceAccount() + if sourceAccount != "" && sourceAccount != item.GetSource() { + fmt.Printf(" šŸ‘¤ Account: %s\n", truncateString(sourceAccount, 40)) + } + + // Content classification + var classifications []string + if item.IsStreamingContent() { + classifications = append(classifications, "Streaming") + } + if item.IsLocalContent() { + classifications = append(classifications, "Local") + } + if len(classifications) > 0 { + fmt.Printf(" šŸ·ļø Type: %s\n", strings.Join(classifications, ", ")) + } + } + + fmt.Println() +} + +func getIcon(item *models.RecentsResponseItem) string { + if item.IsTrack() { + return "šŸŽµ" + } else if item.IsStation() { + return "šŸ“»" + } else if item.IsPlaylist() { + return "šŸ“‹" + } else if item.IsAlbum() { + return "šŸ’æ" + } else if item.IsContainer() { + return "šŸ“" + } + return "šŸŽ¼" +} + +func formatSource(source string) string { + switch source { + case "SPOTIFY": + return "Spotify" + case "LOCAL_MUSIC": + return "Local Music" + case "STORED_MUSIC": + return "Stored Music" + case "TUNEIN": + return "TuneIn Radio" + case "PANDORA": + return "Pandora" + case "AMAZON": + return "Amazon Music" + case "DEEZER": + return "Deezer" + case "IHEART": + return "iHeartRadio" + case "BLUETOOTH": + return "Bluetooth" + case "AUX": + return "AUX Input" + case "AIRPLAY": + return "AirPlay" + default: + return source + } +} + +func formatDuration(d time.Duration) string { + if d < time.Minute { + return "just now" + } else if d < time.Hour { + minutes := int(d.Minutes()) + return fmt.Sprintf("%d minute%s", minutes, pluralize(minutes)) + } else if d < 24*time.Hour { + hours := int(d.Hours()) + return fmt.Sprintf("%d hour%s", hours, pluralize(hours)) + } else { + days := int(d.Hours() / 24) + return fmt.Sprintf("%d day%s", days, pluralize(days)) + } +} + +func pluralize(count int) string { + if count == 1 { + return "" + } + return "s" +} + +func truncateString(s string, maxLength int) string { + if len(s) <= maxLength { + return s + } + if maxLength <= 3 { + return "..." + } + return s[:maxLength-3] + "..." +} + +func getAvailableSources(response *models.RecentsResponse) string { + sourceMap := make(map[string]bool) + for _, item := range response.Items { + if source := item.GetSource(); source != "" { + sourceMap[source] = true + } + } + + var sources []string + for source := range sourceMap { + sources = append(sources, source) + } + sort.Strings(sources) + + if len(sources) == 0 { + return "none" + } + + return strings.Join(sources, ", ") +} diff --git a/pkg/client/client.go b/pkg/client/client.go index c6d047d..13afbbc 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -1681,6 +1681,41 @@ func (c *Client) PlayNotificationBeep() error { return c.get("/playNotification", &status) } +// Introspect retrieves introspect data for a specified music service +func (c *Client) Introspect(source, sourceAccount string) (*models.IntrospectResponse, error) { + if source == "" { + return nil, fmt.Errorf("source cannot be empty") + } + + request := models.NewIntrospectRequest(source, sourceAccount) + + var response models.IntrospectResponse + + err := c.postWithResponse("/introspect", request, &response) + if err != nil { + return nil, fmt.Errorf("failed to get introspect data for %s: %w", source, err) + } + + return &response, nil +} + +// IntrospectSpotify is a convenience method to get introspect data for Spotify +func (c *Client) IntrospectSpotify(sourceAccount string) (*models.IntrospectResponse, error) { + return c.Introspect("SPOTIFY", sourceAccount) +} + +// GetRecents retrieves recently played content from the device +func (c *Client) GetRecents() (*models.RecentsResponse, error) { + var response models.RecentsResponse + + err := c.get("/recents", &response) + if err != nil { + return nil, fmt.Errorf("failed to get recent items: %w", err) + } + + return &response, nil +} + // postPlayInfo sends a PlayInfo request to the /speaker endpoint func (c *Client) postPlayInfo(playInfo *models.PlayInfo) error { return c.post("/speaker", playInfo) diff --git a/pkg/client/introspect_integration_test.go b/pkg/client/introspect_integration_test.go new file mode 100644 index 0000000..5fa7d85 --- /dev/null +++ b/pkg/client/introspect_integration_test.go @@ -0,0 +1,235 @@ +package client + +import ( + "os" + "testing" + "time" +) + +func TestClient_Introspect_Integration(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + host := os.Getenv("SOUNDTOUCH_HOST") + if host == "" { + t.Skip("SOUNDTOUCH_HOST not set, skipping integration test") + } + + config := &Config{ + Host: host, + Timeout: 10 * time.Second, + } + client := NewClient(config) + + // Test getting Spotify introspect data + t.Run("spotify introspect", func(t *testing.T) { + // First check if Spotify is available + serviceAvailability, err := client.GetServiceAvailability() + if err != nil { + t.Fatalf("failed to get service availability: %v", err) + } + + if !serviceAvailability.HasSpotify() { + t.Skip("Spotify not available on this device") + } + + // Test introspect with empty source account (should still work) + response, err := client.Introspect("SPOTIFY", "") + if err != nil { + t.Fatalf("failed to get Spotify introspect data: %v", err) + } + + if response == nil { + t.Fatal("expected response, got nil") + } + + t.Logf("Spotify introspect state: %s", response.State) + t.Logf("Spotify user: %s", response.User) + t.Logf("Spotify is playing: %t", response.IsPlaying) + t.Logf("Spotify shuffle mode: %s", response.ShuffleMode) + t.Logf("Spotify current URI: %s", response.CurrentURI) + t.Logf("Spotify subscription type: %s", response.SubscriptionType) + + // Test state methods + if response.IsActive() { + t.Log("Spotify service is active") + } else if response.IsInactive() { + t.Log("Spotify service is inactive") + } + + // Test capabilities + if response.SupportsSkipPrevious() { + t.Log("Spotify supports skip previous") + } + if response.SupportsSeek() { + t.Log("Spotify supports seek") + } + if response.SupportsResume() { + t.Log("Spotify supports resume") + } + + // Test history + historySize := response.GetMaxHistorySize() + if historySize > 0 { + t.Logf("Spotify content history max size: %d", historySize) + } + }) + + // Test the convenience method + t.Run("spotify introspect convenience method", func(t *testing.T) { + // First check if Spotify is available + serviceAvailability, err := client.GetServiceAvailability() + if err != nil { + t.Fatalf("failed to get service availability: %v", err) + } + + if !serviceAvailability.HasSpotify() { + t.Skip("Spotify not available on this device") + } + + response, err := client.IntrospectSpotify("") + if err != nil { + t.Fatalf("failed to get Spotify introspect data using convenience method: %v", err) + } + + if response == nil { + t.Fatal("expected response from convenience method, got nil") + } + + t.Logf("Convenience method - Spotify state: %s", response.State) + }) + + // Test introspect with other services if available + t.Run("other services introspect", func(t *testing.T) { + serviceAvailability, err := client.GetServiceAvailability() + if err != nil { + t.Fatalf("failed to get service availability: %v", err) + } + + // Test Pandora if available + if serviceAvailability.HasPandora() { + t.Log("Testing Pandora introspect...") + response, err := client.Introspect("PANDORA", "") + if err != nil { + t.Logf("Pandora introspect failed (expected for some configurations): %v", err) + } else { + t.Logf("Pandora introspect state: %s", response.State) + } + } + + // Test TuneIn if available + if serviceAvailability.HasTuneIn() { + t.Log("Testing TuneIn introspect...") + response, err := client.Introspect("TUNEIN", "") + if err != nil { + t.Logf("TuneIn introspect failed (expected for some configurations): %v", err) + } else { + t.Logf("TuneIn introspect state: %s", response.State) + } + } + }) +} + +func TestClient_Introspect_ErrorCases_Integration(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + host := os.Getenv("SOUNDTOUCH_HOST") + if host == "" { + t.Skip("SOUNDTOUCH_HOST not set, skipping integration test") + } + + config := &Config{ + Host: host, + Timeout: 5 * time.Second, + } + client := NewClient(config) + + // Test with invalid source + t.Run("invalid source", func(t *testing.T) { + response, err := client.Introspect("INVALID_SOURCE", "") + if err == nil { + t.Error("expected error for invalid source, got nil") + } + if response != nil { + t.Error("expected nil response for invalid source, got non-nil") + } + t.Logf("Expected error for invalid source: %v", err) + }) + + // Test with empty source + t.Run("empty source", func(t *testing.T) { + response, err := client.Introspect("", "") + if err == nil { + t.Error("expected error for empty source, got nil") + } + if response != nil { + t.Error("expected nil response for empty source, got non-nil") + } + }) +} + +// ExampleClient_Introspect demonstrates how to use the Introspect method +func ExampleClient_Introspect() { + config := &Config{ + Host: "192.168.1.100", + Port: 8090, + } + client := NewClient(config) + + // Get introspect data for Spotify + response, err := client.Introspect("SPOTIFY", "") + if err != nil { + panic(err) + } + + // Check service state + if response.IsActive() { + println("Spotify service is active") + if response.IsPlaying { + println("Currently playing:", response.CurrentURI) + } + } else { + println("Spotify service is inactive") + } + + // Check capabilities + if response.SupportsSeek() { + println("Seek is supported") + } + if response.SupportsSkipPrevious() { + println("Skip previous is supported") + } +} + +// ExampleClient_IntrospectSpotify demonstrates the Spotify convenience method +func ExampleClient_IntrospectSpotify() { + config := &Config{ + Host: "192.168.1.100", + Port: 8090, + } + client := NewClient(config) + + // Get Spotify introspect data using convenience method + response, err := client.IntrospectSpotify("") + if err != nil { + panic(err) + } + + // Display user and subscription info + if response.HasUser() { + println("Spotify user:", response.User) + } + if response.HasSubscription() { + println("Subscription type:", response.SubscriptionType) + } + + // Check shuffle state + if response.IsShuffleEnabled() { + println("Shuffle is enabled") + } else { + println("Shuffle is disabled") + } +} diff --git a/pkg/client/introspect_test.go b/pkg/client/introspect_test.go new file mode 100644 index 0000000..0d28a60 --- /dev/null +++ b/pkg/client/introspect_test.go @@ -0,0 +1,361 @@ +package client + +import ( + "encoding/xml" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gesellix/bose-soundtouch/pkg/models" +) + +func TestClient_Introspect(t *testing.T) { + tests := []struct { + name string + source string + sourceAccount string + responseXML string + expectedError string + wantResponse *models.IntrospectResponse + }{ + { + name: "successful spotify introspect", + source: "SPOTIFY", + sourceAccount: "SpotifyConnectUserName", + responseXML: ` + + + + +`, + wantResponse: &models.IntrospectResponse{ + State: "InactiveUnselected", + User: "SpotifyConnectUserName", + IsPlaying: false, + TokenLastChangedTimeSeconds: 1702566495, + TokenLastChangedTimeMicroseconds: 427884, + ShuffleMode: "OFF", + PlayStatusState: "2", + CurrentURI: "", + ReceivedPlaybackRequest: false, + SubscriptionType: "", + CachedPlaybackRequest: &models.CachedPlaybackRequest{}, + NowPlaying: &models.IntrospectNowPlaying{ + SkipPreviousSupported: false, + SeekSupported: false, + ResumeSupported: true, + CollectData: true, + }, + ContentItemHistory: &models.ContentItemHistory{ + MaxSize: 10, + }, + }, + }, + { + name: "successful pandora introspect", + source: "PANDORA", + sourceAccount: "pandora_user", + responseXML: ` + + + +`, + wantResponse: &models.IntrospectResponse{ + State: "Active", + User: "pandora_user", + IsPlaying: true, + ShuffleMode: "ON", + CurrentURI: "pandora://track/123", + SubscriptionType: "Premium", + NowPlaying: &models.IntrospectNowPlaying{ + SkipPreviousSupported: true, + SeekSupported: false, + ResumeSupported: true, + CollectData: false, + }, + ContentItemHistory: &models.ContentItemHistory{ + MaxSize: 20, + }, + }, + }, + { + name: "empty source error", + source: "", + sourceAccount: "test_user", + expectedError: "source cannot be empty", + }, + { + name: "http error", + source: "SPOTIFY", + sourceAccount: "test_user", + responseXML: "", + expectedError: "failed to get introspect data for SPOTIFY:", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify request method and path + if r.Method != "POST" { + t.Errorf("expected POST request, got %s", r.Method) + } + if r.URL.Path != "/introspect" { + t.Errorf("expected /introspect path, got %s", r.URL.Path) + } + + // Verify request body + var requestBody models.IntrospectRequest + if err := xml.NewDecoder(r.Body).Decode(&requestBody); err != nil { + t.Errorf("failed to decode request body: %v", err) + } + + if requestBody.Source != tt.source { + t.Errorf("expected source %s, got %s", tt.source, requestBody.Source) + } + if requestBody.SourceAccount != tt.sourceAccount { + t.Errorf("expected sourceAccount %s, got %s", tt.sourceAccount, requestBody.SourceAccount) + } + + if tt.responseXML == "" { + // Simulate server error + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/xml") + w.WriteHeader(http.StatusOK) + w.Write([]byte(tt.responseXML)) + })) + defer server.Close() + + config := &Config{ + Host: server.URL[7:], // Remove "http://" prefix + Port: 80, + } + client := NewClient(config) + // Override the base URL to use test server + client.baseURL = server.URL + + response, err := client.Introspect(tt.source, tt.sourceAccount) + + if tt.expectedError != "" { + if err == nil { + t.Errorf("expected error containing %q, got nil", tt.expectedError) + return + } + if !containsString(err.Error(), tt.expectedError) { + t.Errorf("expected error containing %q, got %q", tt.expectedError, err.Error()) + } + return + } + + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + if response == nil { + t.Error("expected response, got nil") + return + } + + // Verify response fields + if response.State != tt.wantResponse.State { + t.Errorf("expected state %s, got %s", tt.wantResponse.State, response.State) + } + if response.User != tt.wantResponse.User { + t.Errorf("expected user %s, got %s", tt.wantResponse.User, response.User) + } + if response.IsPlaying != tt.wantResponse.IsPlaying { + t.Errorf("expected isPlaying %t, got %t", tt.wantResponse.IsPlaying, response.IsPlaying) + } + if response.ShuffleMode != tt.wantResponse.ShuffleMode { + t.Errorf("expected shuffleMode %s, got %s", tt.wantResponse.ShuffleMode, response.ShuffleMode) + } + if response.CurrentURI != tt.wantResponse.CurrentURI { + t.Errorf("expected currentUri %s, got %s", tt.wantResponse.CurrentURI, response.CurrentURI) + } + if response.SubscriptionType != tt.wantResponse.SubscriptionType { + t.Errorf("expected subscriptionType %s, got %s", tt.wantResponse.SubscriptionType, response.SubscriptionType) + } + + // Verify nested structures + if tt.wantResponse.NowPlaying != nil { + if response.NowPlaying == nil { + t.Error("expected nowPlaying, got nil") + } else { + if response.NowPlaying.SkipPreviousSupported != tt.wantResponse.NowPlaying.SkipPreviousSupported { + t.Errorf("expected skipPreviousSupported %t, got %t", + tt.wantResponse.NowPlaying.SkipPreviousSupported, + response.NowPlaying.SkipPreviousSupported) + } + if response.NowPlaying.SeekSupported != tt.wantResponse.NowPlaying.SeekSupported { + t.Errorf("expected seekSupported %t, got %t", + tt.wantResponse.NowPlaying.SeekSupported, + response.NowPlaying.SeekSupported) + } + if response.NowPlaying.ResumeSupported != tt.wantResponse.NowPlaying.ResumeSupported { + t.Errorf("expected resumeSupported %t, got %t", + tt.wantResponse.NowPlaying.ResumeSupported, + response.NowPlaying.ResumeSupported) + } + if response.NowPlaying.CollectData != tt.wantResponse.NowPlaying.CollectData { + t.Errorf("expected collectData %t, got %t", + tt.wantResponse.NowPlaying.CollectData, + response.NowPlaying.CollectData) + } + } + } + + if tt.wantResponse.ContentItemHistory != nil { + if response.ContentItemHistory == nil { + t.Error("expected contentItemHistory, got nil") + } else { + if response.ContentItemHistory.MaxSize != tt.wantResponse.ContentItemHistory.MaxSize { + t.Errorf("expected maxSize %d, got %d", + tt.wantResponse.ContentItemHistory.MaxSize, + response.ContentItemHistory.MaxSize) + } + } + } + }) + } +} + +func TestIntrospectResponse_Methods(t *testing.T) { + response := &models.IntrospectResponse{ + State: "Active", + User: "test_user", + IsPlaying: true, + ShuffleMode: "ON", + CurrentURI: "spotify://track/123", + SubscriptionType: "Premium", + NowPlaying: &models.IntrospectNowPlaying{ + SkipPreviousSupported: true, + SeekSupported: true, + ResumeSupported: true, + CollectData: false, + }, + ContentItemHistory: &models.ContentItemHistory{ + MaxSize: 15, + }, + } + + // Test state methods + if !response.IsActive() { + t.Error("expected IsActive() to return true") + } + if response.IsInactive() { + t.Error("expected IsInactive() to return false") + } + + // Test user methods + if !response.HasUser() { + t.Error("expected HasUser() to return true") + } + + // Test shuffle methods + if !response.IsShuffleEnabled() { + t.Error("expected IsShuffleEnabled() to return true") + } + + // Test content methods + if !response.HasCurrentContent() { + t.Error("expected HasCurrentContent() to return true") + } + + // Test capability methods + if !response.SupportsSkipPrevious() { + t.Error("expected SupportsSkipPrevious() to return true") + } + if !response.SupportsSeek() { + t.Error("expected SupportsSeek() to return true") + } + if !response.SupportsResume() { + t.Error("expected SupportsResume() to return true") + } + if response.CollectsData() { + t.Error("expected CollectsData() to return false") + } + + // Test history methods + if response.GetMaxHistorySize() != 15 { + t.Errorf("expected GetMaxHistorySize() to return 15, got %d", response.GetMaxHistorySize()) + } + + // Test subscription methods + if !response.HasSubscription() { + t.Error("expected HasSubscription() to return true") + } +} + +func TestIntrospectResponse_InactiveState(t *testing.T) { + response := &models.IntrospectResponse{ + State: "InactiveUnselected", + User: "", + IsPlaying: false, + ShuffleMode: "OFF", + CurrentURI: "", + SubscriptionType: "", + } + + // Test inactive state + if response.IsActive() { + t.Error("expected IsActive() to return false") + } + if !response.IsInactive() { + t.Error("expected IsInactive() to return true") + } + + // Test empty values + if response.HasUser() { + t.Error("expected HasUser() to return false") + } + if response.IsShuffleEnabled() { + t.Error("expected IsShuffleEnabled() to return false") + } + if response.HasCurrentContent() { + t.Error("expected HasCurrentContent() to return false") + } + if response.HasSubscription() { + t.Error("expected HasSubscription() to return false") + } +} + +func TestNewIntrospectRequest(t *testing.T) { + tests := []struct { + name string + source string + sourceAccount string + }{ + { + name: "with source account", + source: "SPOTIFY", + sourceAccount: "test_user", + }, + { + name: "without source account", + source: "BLUETOOTH", + sourceAccount: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + request := models.NewIntrospectRequest(tt.source, tt.sourceAccount) + + if request == nil { + t.Error("expected request, got nil") + return + } + + if request.Source != tt.source { + t.Errorf("expected source %s, got %s", tt.source, request.Source) + } + if request.SourceAccount != tt.sourceAccount { + t.Errorf("expected sourceAccount %s, got %s", tt.sourceAccount, request.SourceAccount) + } + }) + } +} diff --git a/pkg/client/recents_integration_test.go b/pkg/client/recents_integration_test.go new file mode 100644 index 0000000..8b87047 --- /dev/null +++ b/pkg/client/recents_integration_test.go @@ -0,0 +1,325 @@ +package client + +import ( + "os" + "testing" + "time" +) + +func TestClient_GetRecents_Integration(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + host := os.Getenv("SOUNDTOUCH_HOST") + if host == "" { + t.Skip("SOUNDTOUCH_HOST not set, skipping integration test") + } + + config := &Config{ + Host: host, + Timeout: 10 * time.Second, + } + client := NewClient(config) + + t.Run("get recents", func(t *testing.T) { + response, err := client.GetRecents() + if err != nil { + t.Fatalf("failed to get recents: %v", err) + } + + if response == nil { + t.Fatal("expected response, got nil") + } + + t.Logf("Recent items count: %d", response.GetItemCount()) + + if response.IsEmpty() { + t.Log("No recent items found - this is normal if device hasn't played anything recently") + return + } + + // Test basic functionality + t.Logf("Recent items found: %d", response.GetItemCount()) + + // Get most recent item + mostRecent := response.GetMostRecent() + if mostRecent != nil { + t.Logf("Most recent item: %s (Source: %s, Time: %d)", + mostRecent.GetDisplayName(), + mostRecent.GetSource(), + mostRecent.GetUTCTime()) + + if mostRecent.HasArtwork() { + t.Logf(" Has artwork: %s", mostRecent.GetArtwork()) + } + + if mostRecent.IsPresetable() { + t.Log(" Can be saved as preset") + } + + // Test content type detection + if mostRecent.IsTrack() { + t.Log(" Content type: Track") + } else if mostRecent.IsStation() { + t.Log(" Content type: Radio Station") + } else if mostRecent.IsPlaylist() { + t.Log(" Content type: Playlist") + } else if mostRecent.IsAlbum() { + t.Log(" Content type: Album") + } else if mostRecent.IsContainer() { + t.Log(" Content type: Container") + } + + // Test source type detection + if mostRecent.IsSpotifyContent() { + t.Log(" Source type: Spotify") + } else if mostRecent.IsLocalContent() { + t.Log(" Source type: Local") + } else if mostRecent.IsStreamingContent() { + t.Log(" Source type: Streaming service") + } + } + + // Test filtering methods + spotifyItems := response.GetSpotifyItems() + if len(spotifyItems) > 0 { + t.Logf("Spotify items: %d", len(spotifyItems)) + for i, item := range spotifyItems { + if i < 3 { // Show first 3 + t.Logf(" - %s", item.GetDisplayName()) + } + } + } + + localItems := response.GetLocalMusicItems() + if len(localItems) > 0 { + t.Logf("Local music items: %d", len(localItems)) + } + + storedItems := response.GetStoredMusicItems() + if len(storedItems) > 0 { + t.Logf("Stored music items: %d", len(storedItems)) + } + + tuneInItems := response.GetTuneInItems() + if len(tuneInItems) > 0 { + t.Logf("TuneIn items: %d", len(tuneInItems)) + } + + pandoraItems := response.GetPandoraItems() + if len(pandoraItems) > 0 { + t.Logf("Pandora items: %d", len(pandoraItems)) + } + + // Test content type filters + tracks := response.GetTracks() + if len(tracks) > 0 { + t.Logf("Track items: %d", len(tracks)) + } + + stations := response.GetStations() + if len(stations) > 0 { + t.Logf("Station items: %d", len(stations)) + } + + playlistsAndAlbums := response.GetPlaylistsAndAlbums() + if len(playlistsAndAlbums) > 0 { + t.Logf("Playlist/Album items: %d", len(playlistsAndAlbums)) + } + + presetableItems := response.GetPresetableItems() + if len(presetableItems) > 0 { + t.Logf("Presetable items: %d", len(presetableItems)) + } + + // Show all items with details + t.Log("\nAll recent items:") + for i, item := range response.Items { + if i >= 10 { // Limit to first 10 items to avoid spam + t.Logf(" ... and %d more items", len(response.Items)-i) + break + } + + displayName := item.GetDisplayName() + source := item.GetSource() + contentType := item.GetContentType() + utcTime := item.GetUTCTime() + + timeStr := "" + if utcTime > 0 { + playTime := time.Unix(utcTime, 0) + timeStr = playTime.Format("2006-01-02 15:04:05") + } + + t.Logf(" %d. %s (%s/%s) - %s", i+1, displayName, source, contentType, timeStr) + + if item.HasID() { + t.Logf(" ID: %s", item.GetID()) + } + } + }) +} + +func TestClient_GetRecents_Performance(t *testing.T) { + if testing.Short() { + t.Skip("skipping performance test") + } + + host := os.Getenv("SOUNDTOUCH_HOST") + if host == "" { + t.Skip("SOUNDTOUCH_HOST not set, skipping integration test") + } + + config := &Config{ + Host: host, + Timeout: 5 * time.Second, + } + client := NewClient(config) + + // Measure response time + start := time.Now() + response, err := client.GetRecents() + duration := time.Since(start) + + if err != nil { + t.Fatalf("failed to get recents: %v", err) + } + + t.Logf("GetRecents() took %v", duration) + + if duration > 2*time.Second { + t.Logf("Warning: GetRecents() took longer than expected: %v", duration) + } + + if response != nil { + t.Logf("Retrieved %d recent items", response.GetItemCount()) + } +} + +func TestClient_GetRecents_ErrorConditions(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + // Test with invalid host + t.Run("invalid host", func(t *testing.T) { + config := &Config{ + Host: "192.168.255.255", // Non-existent IP + Timeout: 2 * time.Second, // Short timeout + } + client := NewClient(config) + + response, err := client.GetRecents() + if err == nil { + t.Error("expected error for invalid host, got nil") + } + if response != nil { + t.Error("expected nil response for invalid host, got non-nil") + } + t.Logf("Expected error for invalid host: %v", err) + }) + + // Test with very short timeout + t.Run("timeout", func(t *testing.T) { + host := os.Getenv("SOUNDTOUCH_HOST") + if host == "" { + t.Skip("SOUNDTOUCH_HOST not set") + } + + config := &Config{ + Host: host, + Timeout: 1 * time.Nanosecond, // Impossibly short timeout + } + client := NewClient(config) + + response, err := client.GetRecents() + if err == nil { + t.Log("Warning: expected timeout error, but request succeeded") + } + if response != nil && err != nil { + t.Error("got both response and error") + } + t.Logf("Timeout test result - error: %v, response nil: %t", err, response == nil) + }) +} + +// ExampleClient_GetRecents demonstrates how to use the GetRecents method +func ExampleClient_GetRecents() { + config := &Config{ + Host: "192.168.1.100", + Port: 8090, + } + client := NewClient(config) + + // Get recent items + response, err := client.GetRecents() + if err != nil { + panic(err) + } + + if response.IsEmpty() { + println("No recent items found") + return + } + + // Show most recent item + mostRecent := response.GetMostRecent() + if mostRecent != nil { + println("Most recent:", mostRecent.GetDisplayName()) + println("Source:", mostRecent.GetSource()) + + if mostRecent.IsPresetable() { + println("Can be saved as preset") + } + } + + // Show Spotify items + spotifyItems := response.GetSpotifyItems() + if len(spotifyItems) > 0 { + println("Recent Spotify tracks:") + for _, item := range spotifyItems { + println("-", item.GetDisplayName()) + } + } + + // Show only tracks (no stations or playlists) + tracks := response.GetTracks() + println("Total tracks in recent items:", len(tracks)) +} + +// ExampleRecentsResponse_filtering demonstrates filtering recent items +func ExampleRecentsResponse_filtering() { + config := &Config{ + Host: "192.168.1.100", + Port: 8090, + } + client := NewClient(config) + + response, err := client.GetRecents() + if err != nil { + panic(err) + } + + // Filter by source + println("Spotify items:", len(response.GetSpotifyItems())) + println("Local music items:", len(response.GetLocalMusicItems())) + println("TuneIn items:", len(response.GetTuneInItems())) + + // Filter by type + println("Tracks:", len(response.GetTracks())) + println("Stations:", len(response.GetStations())) + println("Playlists/Albums:", len(response.GetPlaylistsAndAlbums())) + + // Filter by capability + println("Presetable items:", len(response.GetPresetableItems())) + + // Get items from streaming services only + streamingItems := 0 + for _, item := range response.Items { + if item.IsStreamingContent() { + streamingItems++ + } + } + println("Streaming service items:", streamingItems) +} diff --git a/pkg/client/recents_test.go b/pkg/client/recents_test.go new file mode 100644 index 0000000..86d6d01 --- /dev/null +++ b/pkg/client/recents_test.go @@ -0,0 +1,396 @@ +package client + +import ( + "encoding/xml" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gesellix/bose-soundtouch/pkg/models" +) + +func TestClient_GetRecents(t *testing.T) { + tests := []struct { + name string + responseXML string + statusCode int + expectedError string + wantResponse *models.RecentsResponse + }{ + { + name: "successful recents response", + statusCode: http.StatusOK, + responseXML: ` + + + + MercyMe, It's Christmas! + + + + + Baby It's Cold Outside - ANNE MURRAY + + +`, + wantResponse: &models.RecentsResponse{ + Items: []models.RecentsResponseItem{ + { + DeviceID: "1004567890AA", + UTCTime: 1701202831, + ContentItem: &models.ContentItem{ + Source: "STORED_MUSIC", + Location: "6_a2874b5d_4f83d999", + SourceAccount: "d09708a1-5953-44bc-a413-123456789012/0", + IsPresetable: true, + ItemName: "MercyMe, It's Christmas!", + }, + }, + { + DeviceID: "1004567890AA", + UTCTime: 1700232917, + ID: "2487503626", + ContentItem: &models.ContentItem{ + Source: "LOCAL_MUSIC", + Type: "track", + Location: "track:2590", + SourceAccount: "3f205110-4a57-4e91-810a-123456789012", + IsPresetable: true, + ItemName: "Baby It's Cold Outside - ANNE MURRAY", + }, + }, + }, + }, + }, + { + name: "empty recents response", + statusCode: http.StatusOK, + responseXML: ` + +`, + wantResponse: &models.RecentsResponse{ + Items: []models.RecentsResponseItem{}, + }, + }, + { + name: "spotify recents with artwork", + statusCode: http.StatusOK, + responseXML: ` + + + + Shape of You - Ed Sheeran + https://i.scdn.co/image/ab67616d0000b273ba5db46f4b838ef6027e6f96 + + + + + Today's Top Hits + https://i.scdn.co/image/ab67706f00000002ca5a7517156021292e5663a6 + + +`, + wantResponse: &models.RecentsResponse{ + Items: []models.RecentsResponseItem{ + { + DeviceID: "1004567890AA", + UTCTime: 1701300000, + ID: "spotify123", + ContentItem: &models.ContentItem{ + Source: "SPOTIFY", + Type: "track", + Location: "spotify:track:4iV5W9uYEdYUVa79Axb7Rh", + SourceAccount: "spotify_user", + IsPresetable: true, + ItemName: "Shape of You - Ed Sheeran", + ContainerArt: "https://i.scdn.co/image/ab67616d0000b273ba5db46f4b838ef6027e6f96", + }, + }, + { + DeviceID: "1004567890AA", + UTCTime: 1701250000, + ID: "spotify124", + ContentItem: &models.ContentItem{ + Source: "SPOTIFY", + Type: "playlist", + Location: "spotify:playlist:37i9dQZF1DXcBWIGoYBM5M", + SourceAccount: "spotify_user", + IsPresetable: true, + ItemName: "Today's Top Hits", + ContainerArt: "https://i.scdn.co/image/ab67706f00000002ca5a7517156021292e5663a6", + }, + }, + }, + }, + }, + { + name: "tunein radio station", + statusCode: http.StatusOK, + responseXML: ` + + + + BBC Radio 1 + + +`, + wantResponse: &models.RecentsResponse{ + Items: []models.RecentsResponseItem{ + { + DeviceID: "1004567890AA", + UTCTime: 1701400000, + ContentItem: &models.ContentItem{ + Source: "TUNEIN", + Type: "stationurl", + Location: "tunein:station:s24939", + SourceAccount: "tunein", + IsPresetable: true, + ItemName: "BBC Radio 1", + }, + }, + }, + }, + }, + { + name: "http error", + statusCode: http.StatusInternalServerError, + responseXML: "", + expectedError: "failed to get recent items:", + }, + { + name: "malformed xml", + statusCode: http.StatusOK, + responseXML: `xml`, + expectedError: "failed to get recent items:", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify request method and path + if r.Method != "GET" { + t.Errorf("expected GET request, got %s", r.Method) + } + if r.URL.Path != "/recents" { + t.Errorf("expected /recents path, got %s", r.URL.Path) + } + + if tt.statusCode != http.StatusOK { + w.WriteHeader(tt.statusCode) + return + } + + w.Header().Set("Content-Type", "application/xml") + w.WriteHeader(http.StatusOK) + w.Write([]byte(tt.responseXML)) + })) + defer server.Close() + + config := &Config{ + Host: server.URL[7:], // Remove "http://" prefix + Port: 80, + } + client := NewClient(config) + // Override the base URL to use test server + client.baseURL = server.URL + + response, err := client.GetRecents() + + if tt.expectedError != "" { + if err == nil { + t.Errorf("expected error containing %q, got nil", tt.expectedError) + return + } + if !containsString(err.Error(), tt.expectedError) { + t.Errorf("expected error containing %q, got %q", tt.expectedError, err.Error()) + } + return + } + + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + if response == nil { + t.Error("expected response, got nil") + return + } + + // Verify response structure + if len(response.Items) != len(tt.wantResponse.Items) { + t.Errorf("expected %d items, got %d", len(tt.wantResponse.Items), len(response.Items)) + } + + // Verify each item + for i, expectedItem := range tt.wantResponse.Items { + if i >= len(response.Items) { + break + } + + actualItem := response.Items[i] + + if actualItem.DeviceID != expectedItem.DeviceID { + t.Errorf("item %d: expected deviceID %s, got %s", i, expectedItem.DeviceID, actualItem.DeviceID) + } + if actualItem.UTCTime != expectedItem.UTCTime { + t.Errorf("item %d: expected utcTime %d, got %d", i, expectedItem.UTCTime, actualItem.UTCTime) + } + if actualItem.ID != expectedItem.ID { + t.Errorf("item %d: expected id %s, got %s", i, expectedItem.ID, actualItem.ID) + } + + // Verify ContentItem + if expectedItem.ContentItem != nil { + if actualItem.ContentItem == nil { + t.Errorf("item %d: expected contentItem, got nil", i) + continue + } + + if actualItem.ContentItem.Source != expectedItem.ContentItem.Source { + t.Errorf("item %d: expected source %s, got %s", i, expectedItem.ContentItem.Source, actualItem.ContentItem.Source) + } + if actualItem.ContentItem.Type != expectedItem.ContentItem.Type { + t.Errorf("item %d: expected type %s, got %s", i, expectedItem.ContentItem.Type, actualItem.ContentItem.Type) + } + if actualItem.ContentItem.Location != expectedItem.ContentItem.Location { + t.Errorf("item %d: expected location %s, got %s", i, expectedItem.ContentItem.Location, actualItem.ContentItem.Location) + } + if actualItem.ContentItem.ItemName != expectedItem.ContentItem.ItemName { + t.Errorf("item %d: expected itemName %s, got %s", i, expectedItem.ContentItem.ItemName, actualItem.ContentItem.ItemName) + } + if actualItem.ContentItem.IsPresetable != expectedItem.ContentItem.IsPresetable { + t.Errorf("item %d: expected isPresetable %t, got %t", i, expectedItem.ContentItem.IsPresetable, actualItem.ContentItem.IsPresetable) + } + if actualItem.ContentItem.ContainerArt != expectedItem.ContentItem.ContainerArt { + t.Errorf("item %d: expected containerArt %s, got %s", i, expectedItem.ContentItem.ContainerArt, actualItem.ContentItem.ContainerArt) + } + } else if actualItem.ContentItem != nil { + t.Errorf("item %d: expected nil contentItem, got non-nil", i) + } + } + }) + } +} + +func TestRecentsResponse_MethodsIntegration(t *testing.T) { + // Test the response methods with a realistic response + xmlData := ` + + + Spotify Track + + + + + Local Track + + + + + Radio Station + + + + + Pandora Track + + +` + + var response models.RecentsResponse + err := xml.Unmarshal([]byte(xmlData), &response) + if err != nil { + t.Fatalf("failed to unmarshal test data: %v", err) + } + + // Test various filtering methods + tests := []struct { + name string + method func() interface{} + expected interface{} + }{ + {"GetItemCount", func() interface{} { return response.GetItemCount() }, 4}, + {"IsEmpty", func() interface{} { return response.IsEmpty() }, false}, + {"GetSpotifyItems count", func() interface{} { return len(response.GetSpotifyItems()) }, 1}, + {"GetLocalMusicItems count", func() interface{} { return len(response.GetLocalMusicItems()) }, 1}, + {"GetTuneInItems count", func() interface{} { return len(response.GetTuneInItems()) }, 1}, + {"GetPandoraItems count", func() interface{} { return len(response.GetPandoraItems()) }, 1}, + {"GetTracks count", func() interface{} { return len(response.GetTracks()) }, 3}, + {"GetStations count", func() interface{} { return len(response.GetStations()) }, 1}, + {"GetPresetableItems count", func() interface{} { return len(response.GetPresetableItems()) }, 3}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.method() + if result != tt.expected { + t.Errorf("expected %v, got %v", tt.expected, result) + } + }) + } + + // Test most recent item + mostRecent := response.GetMostRecent() + if mostRecent == nil { + t.Error("expected most recent item, got nil") + } else { + if mostRecent.GetDisplayName() != "Spotify Track" { + t.Errorf("expected most recent to be 'Spotify Track', got %s", mostRecent.GetDisplayName()) + } + if mostRecent.GetUTCTime() != 1701300000 { + t.Errorf("expected most recent UTC time 1701300000, got %d", mostRecent.GetUTCTime()) + } + } + + // Test individual item methods + for i, item := range response.Items { + t.Run(t.Name()+"/item_"+item.GetID(), func(t *testing.T) { + if !item.HasContent() { + t.Error("expected item to have content") + } + if item.GetDisplayName() == "" { + t.Error("expected item to have display name") + } + if item.GetSource() == "" { + t.Error("expected item to have source") + } + if item.GetUTCTime() == 0 { + t.Error("expected item to have UTC time") + } + + // Test specific item properties + switch i { + case 0: // Spotify track + if !item.IsSpotifyContent() { + t.Error("expected first item to be Spotify content") + } + if !item.IsTrack() { + t.Error("expected first item to be a track") + } + if !item.IsStreamingContent() { + t.Error("expected first item to be streaming content") + } + case 1: // Local music + if !item.IsLocalContent() { + t.Error("expected second item to be local content") + } + if item.IsStreamingContent() { + t.Error("expected second item to not be streaming content") + } + case 2: // TuneIn station + if !item.IsStation() { + t.Error("expected third item to be a station") + } + if item.IsTrack() { + t.Error("expected third item to not be a track") + } + case 3: // Pandora track + if !item.IsStreamingContent() { + t.Error("expected fourth item to be streaming content") + } + } + }) + } +} diff --git a/pkg/models/introspect.go b/pkg/models/introspect.go new file mode 100644 index 0000000..b0289dd --- /dev/null +++ b/pkg/models/introspect.go @@ -0,0 +1,251 @@ +package models + +import "encoding/xml" + +// IntrospectRequest represents a request to get introspect data for a music service +type IntrospectRequest struct { + XMLName xml.Name `xml:"introspect"` + Source string `xml:"source,attr"` + SourceAccount string `xml:"sourceAccount,attr,omitempty"` +} + +// IntrospectResponse represents a generic introspect response +// The actual XML name will vary based on the source (e.g., spotifyAccountIntrospectResponse) +type IntrospectResponse struct { + XMLName xml.Name `xml:""` + State string `xml:"state,attr,omitempty"` + User string `xml:"user,attr,omitempty"` + IsPlaying bool `xml:"isPlaying,attr,omitempty"` + TokenLastChangedTimeSeconds int64 `xml:"tokenLastChangedTimeSeconds,attr,omitempty"` + TokenLastChangedTimeMicroseconds int64 `xml:"tokenLastChangedTimeMicroseconds,attr,omitempty"` + ShuffleMode string `xml:"shuffleMode,attr,omitempty"` + PlayStatusState string `xml:"playStatusState,attr,omitempty"` + CurrentURI string `xml:"currentUri,attr,omitempty"` + ReceivedPlaybackRequest bool `xml:"receivedPlaybackRequest,attr,omitempty"` + SubscriptionType string `xml:"subscriptionType,attr,omitempty"` + CachedPlaybackRequest *CachedPlaybackRequest `xml:"cachedPlaybackRequest,omitempty"` + NowPlaying *IntrospectNowPlaying `xml:"nowPlaying,omitempty"` + ContentItemHistory *ContentItemHistory `xml:"contentItemHistory,omitempty"` +} + +// SpotifyIntrospectResponse represents a Spotify-specific introspect response +type SpotifyIntrospectResponse struct { + XMLName xml.Name `xml:"spotifyAccountIntrospectResponse"` + State string `xml:"state,attr"` + User string `xml:"user,attr"` + IsPlaying bool `xml:"isPlaying,attr"` + TokenLastChangedTimeSeconds int64 `xml:"tokenLastChangedTimeSeconds,attr"` + TokenLastChangedTimeMicroseconds int64 `xml:"tokenLastChangedTimeMicroseconds,attr"` + ShuffleMode string `xml:"shuffleMode,attr"` + PlayStatusState string `xml:"playStatusState,attr"` + CurrentURI string `xml:"currentUri,attr"` + ReceivedPlaybackRequest bool `xml:"receivedPlaybackRequest,attr"` + SubscriptionType string `xml:"subscriptionType,attr"` + CachedPlaybackRequest *CachedPlaybackRequest `xml:"cachedPlaybackRequest"` + NowPlaying *IntrospectNowPlaying `xml:"nowPlaying"` + ContentItemHistory *ContentItemHistory `xml:"contentItemHistory"` +} + +// CachedPlaybackRequest represents cached playback request information +type CachedPlaybackRequest struct { + XMLName xml.Name `xml:"cachedPlaybackRequest"` + // Add fields as discovered from actual responses +} + +// IntrospectNowPlaying represents now playing information in introspect response +type IntrospectNowPlaying struct { + XMLName xml.Name `xml:"nowPlaying"` + SkipPreviousSupported bool `xml:"skipPreviousSupported,attr"` + SeekSupported bool `xml:"seekSupported,attr"` + ResumeSupported bool `xml:"resumeSupported,attr"` + CollectData bool `xml:"collectData,attr"` +} + +// ContentItemHistory represents the content item history +type ContentItemHistory struct { + XMLName xml.Name `xml:"contentItemHistory"` + MaxSize int `xml:"maxSize,attr"` + // Add items as discovered from actual responses +} + +// IntrospectState represents possible introspect states +type IntrospectState string + +const ( + // IntrospectStateInactiveUnselected indicates the service is inactive and unselected + IntrospectStateInactiveUnselected IntrospectState = "InactiveUnselected" + // IntrospectStateActive indicates the service is active + IntrospectStateActive IntrospectState = "Active" + // IntrospectStateInactive indicates the service is inactive + IntrospectStateInactive IntrospectState = "Inactive" +) + +// ShuffleMode represents possible shuffle modes +type ShuffleMode string + +const ( + // ShuffleModeOff indicates shuffle is disabled + ShuffleModeOff ShuffleMode = "OFF" + // ShuffleModeOn indicates shuffle is enabled + ShuffleModeOn ShuffleMode = "ON" +) + +// NewIntrospectRequest creates a new introspect request +func NewIntrospectRequest(source, sourceAccount string) *IntrospectRequest { + return &IntrospectRequest{ + Source: source, + SourceAccount: sourceAccount, + } +} + +// GetState returns the introspect state as a typed value +func (ir *IntrospectResponse) GetState() IntrospectState { + return IntrospectState(ir.State) +} + +// GetShuffleMode returns the shuffle mode as a typed value +func (ir *IntrospectResponse) GetShuffleMode() ShuffleMode { + return ShuffleMode(ir.ShuffleMode) +} + +// IsActive returns true if the service is in an active state +func (ir *IntrospectResponse) IsActive() bool { + return ir.GetState() == IntrospectStateActive +} + +// IsInactive returns true if the service is in an inactive state +func (ir *IntrospectResponse) IsInactive() bool { + state := ir.GetState() + return state == IntrospectStateInactive || state == IntrospectStateInactiveUnselected +} + +// HasUser returns true if a user is associated with the service +func (ir *IntrospectResponse) HasUser() bool { + return ir.User != "" +} + +// IsShuffleEnabled returns true if shuffle mode is enabled +func (ir *IntrospectResponse) IsShuffleEnabled() bool { + return ir.GetShuffleMode() == ShuffleModeOn +} + +// HasCurrentContent returns true if there is current content playing +func (ir *IntrospectResponse) HasCurrentContent() bool { + return ir.CurrentURI != "" +} + +// SupportsSkipPrevious returns true if the service supports skipping to previous track +func (ir *IntrospectResponse) SupportsSkipPrevious() bool { + return ir.NowPlaying != nil && ir.NowPlaying.SkipPreviousSupported +} + +// SupportsSeek returns true if the service supports seeking within tracks +func (ir *IntrospectResponse) SupportsSeek() bool { + return ir.NowPlaying != nil && ir.NowPlaying.SeekSupported +} + +// SupportsResume returns true if the service supports resuming playback +func (ir *IntrospectResponse) SupportsResume() bool { + return ir.NowPlaying != nil && ir.NowPlaying.ResumeSupported +} + +// CollectsData returns true if the service collects usage data +func (ir *IntrospectResponse) CollectsData() bool { + return ir.NowPlaying != nil && ir.NowPlaying.CollectData +} + +// GetMaxHistorySize returns the maximum size of the content item history +func (ir *IntrospectResponse) GetMaxHistorySize() int { + if ir.ContentItemHistory != nil { + return ir.ContentItemHistory.MaxSize + } + return 0 +} + +// HasSubscription returns true if the user has a subscription +func (ir *IntrospectResponse) HasSubscription() bool { + return ir.SubscriptionType != "" +} + +// GetTokenAge returns the age of the token in seconds since last change +func (ir *IntrospectResponse) GetTokenAge() int64 { + // This would need current time to calculate actual age + // For now, just return the timestamp + return ir.TokenLastChangedTimeSeconds +} + +// Spotify-specific methods for SpotifyIntrospectResponse + +// GetState returns the introspect state as a typed value +func (sir *SpotifyIntrospectResponse) GetState() IntrospectState { + return IntrospectState(sir.State) +} + +// GetShuffleMode returns the shuffle mode as a typed value +func (sir *SpotifyIntrospectResponse) GetShuffleMode() ShuffleMode { + return ShuffleMode(sir.ShuffleMode) +} + +// IsActive returns true if the service is in an active state +func (sir *SpotifyIntrospectResponse) IsActive() bool { + return sir.GetState() == IntrospectStateActive +} + +// IsInactive returns true if the service is in an inactive state +func (sir *SpotifyIntrospectResponse) IsInactive() bool { + state := sir.GetState() + return state == IntrospectStateInactive || state == IntrospectStateInactiveUnselected +} + +// HasUser returns true if a user is associated with the service +func (sir *SpotifyIntrospectResponse) HasUser() bool { + return sir.User != "" +} + +// IsShuffleEnabled returns true if shuffle mode is enabled +func (sir *SpotifyIntrospectResponse) IsShuffleEnabled() bool { + return sir.GetShuffleMode() == ShuffleModeOn +} + +// HasCurrentContent returns true if there is current content playing +func (sir *SpotifyIntrospectResponse) HasCurrentContent() bool { + return sir.CurrentURI != "" +} + +// SupportsSkipPrevious returns true if the service supports skipping to previous track +func (sir *SpotifyIntrospectResponse) SupportsSkipPrevious() bool { + return sir.NowPlaying != nil && sir.NowPlaying.SkipPreviousSupported +} + +// SupportsSeek returns true if the service supports seeking within tracks +func (sir *SpotifyIntrospectResponse) SupportsSeek() bool { + return sir.NowPlaying != nil && sir.NowPlaying.SeekSupported +} + +// SupportsResume returns true if the service supports resuming playback +func (sir *SpotifyIntrospectResponse) SupportsResume() bool { + return sir.NowPlaying != nil && sir.NowPlaying.ResumeSupported +} + +// CollectsData returns true if the service collects usage data +func (sir *SpotifyIntrospectResponse) CollectsData() bool { + return sir.NowPlaying != nil && sir.NowPlaying.CollectData +} + +// GetMaxHistorySize returns the maximum size of the content item history +func (sir *SpotifyIntrospectResponse) GetMaxHistorySize() int { + if sir.ContentItemHistory != nil { + return sir.ContentItemHistory.MaxSize + } + return 0 +} + +// HasSubscription returns true if the user has a subscription +func (sir *SpotifyIntrospectResponse) HasSubscription() bool { + return sir.SubscriptionType != "" +} + +// GetTokenAge returns the age of the token in seconds since last change +func (sir *SpotifyIntrospectResponse) GetTokenAge() int64 { + return sir.TokenLastChangedTimeSeconds +} diff --git a/pkg/models/introspect_test.go b/pkg/models/introspect_test.go new file mode 100644 index 0000000..5d97010 --- /dev/null +++ b/pkg/models/introspect_test.go @@ -0,0 +1,485 @@ +package models + +import ( + "encoding/xml" + "testing" +) + +func TestIntrospectRequest_Marshal(t *testing.T) { + tests := []struct { + name string + request *IntrospectRequest + expected string + }{ + { + name: "with source account", + request: &IntrospectRequest{ + Source: "SPOTIFY", + SourceAccount: "SpotifyConnectUserName", + }, + expected: ``, + }, + { + name: "without source account", + request: &IntrospectRequest{ + Source: "BLUETOOTH", + }, + expected: ``, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data, err := xml.Marshal(tt.request) + if err != nil { + t.Fatalf("failed to marshal request: %v", err) + } + + if string(data) != tt.expected { + t.Errorf("expected %s, got %s", tt.expected, string(data)) + } + }) + } +} + +func TestIntrospectResponse_Unmarshal(t *testing.T) { + tests := []struct { + name string + xmlData string + expected *IntrospectResponse + expectError bool + }{ + { + name: "spotify introspect response", + xmlData: ` + + + +`, + expected: &IntrospectResponse{ + State: "InactiveUnselected", + User: "SpotifyConnectUserName", + IsPlaying: false, + TokenLastChangedTimeSeconds: 1702566495, + TokenLastChangedTimeMicroseconds: 427884, + ShuffleMode: "OFF", + PlayStatusState: "2", + CurrentURI: "", + ReceivedPlaybackRequest: false, + SubscriptionType: "", + CachedPlaybackRequest: &CachedPlaybackRequest{}, + NowPlaying: &IntrospectNowPlaying{ + SkipPreviousSupported: false, + SeekSupported: false, + ResumeSupported: true, + CollectData: true, + }, + ContentItemHistory: &ContentItemHistory{ + MaxSize: 10, + }, + }, + }, + { + name: "pandora introspect response", + xmlData: ` + + +`, + expected: &IntrospectResponse{ + State: "Active", + User: "pandora_user", + IsPlaying: true, + ShuffleMode: "ON", + CurrentURI: "pandora://track/123", + SubscriptionType: "Premium", + NowPlaying: &IntrospectNowPlaying{ + SkipPreviousSupported: true, + SeekSupported: false, + ResumeSupported: true, + CollectData: false, + }, + ContentItemHistory: &ContentItemHistory{ + MaxSize: 20, + }, + }, + }, + { + name: "minimal response", + xmlData: ` +`, + expected: &IntrospectResponse{ + State: "Inactive", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var response IntrospectResponse + err := xml.Unmarshal([]byte(tt.xmlData), &response) + + if tt.expectError { + if err == nil { + t.Error("expected error, got nil") + } + return + } + + if err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + // Compare basic fields + if response.State != tt.expected.State { + t.Errorf("expected state %s, got %s", tt.expected.State, response.State) + } + if response.User != tt.expected.User { + t.Errorf("expected user %s, got %s", tt.expected.User, response.User) + } + if response.IsPlaying != tt.expected.IsPlaying { + t.Errorf("expected isPlaying %t, got %t", tt.expected.IsPlaying, response.IsPlaying) + } + if response.TokenLastChangedTimeSeconds != tt.expected.TokenLastChangedTimeSeconds { + t.Errorf("expected tokenLastChangedTimeSeconds %d, got %d", + tt.expected.TokenLastChangedTimeSeconds, response.TokenLastChangedTimeSeconds) + } + if response.TokenLastChangedTimeMicroseconds != tt.expected.TokenLastChangedTimeMicroseconds { + t.Errorf("expected tokenLastChangedTimeMicroseconds %d, got %d", + tt.expected.TokenLastChangedTimeMicroseconds, response.TokenLastChangedTimeMicroseconds) + } + if response.ShuffleMode != tt.expected.ShuffleMode { + t.Errorf("expected shuffleMode %s, got %s", tt.expected.ShuffleMode, response.ShuffleMode) + } + if response.PlayStatusState != tt.expected.PlayStatusState { + t.Errorf("expected playStatusState %s, got %s", tt.expected.PlayStatusState, response.PlayStatusState) + } + if response.CurrentURI != tt.expected.CurrentURI { + t.Errorf("expected currentUri %s, got %s", tt.expected.CurrentURI, response.CurrentURI) + } + if response.ReceivedPlaybackRequest != tt.expected.ReceivedPlaybackRequest { + t.Errorf("expected receivedPlaybackRequest %t, got %t", + tt.expected.ReceivedPlaybackRequest, response.ReceivedPlaybackRequest) + } + if response.SubscriptionType != tt.expected.SubscriptionType { + t.Errorf("expected subscriptionType %s, got %s", tt.expected.SubscriptionType, response.SubscriptionType) + } + + // Compare nested structures + if tt.expected.CachedPlaybackRequest != nil { + if response.CachedPlaybackRequest == nil { + t.Error("expected cachedPlaybackRequest, got nil") + } + } else if response.CachedPlaybackRequest != nil { + t.Error("expected cachedPlaybackRequest to be nil, got non-nil") + } + + if tt.expected.NowPlaying != nil { + if response.NowPlaying == nil { + t.Error("expected nowPlaying, got nil") + } else { + if response.NowPlaying.SkipPreviousSupported != tt.expected.NowPlaying.SkipPreviousSupported { + t.Errorf("expected skipPreviousSupported %t, got %t", + tt.expected.NowPlaying.SkipPreviousSupported, + response.NowPlaying.SkipPreviousSupported) + } + if response.NowPlaying.SeekSupported != tt.expected.NowPlaying.SeekSupported { + t.Errorf("expected seekSupported %t, got %t", + tt.expected.NowPlaying.SeekSupported, + response.NowPlaying.SeekSupported) + } + if response.NowPlaying.ResumeSupported != tt.expected.NowPlaying.ResumeSupported { + t.Errorf("expected resumeSupported %t, got %t", + tt.expected.NowPlaying.ResumeSupported, + response.NowPlaying.ResumeSupported) + } + if response.NowPlaying.CollectData != tt.expected.NowPlaying.CollectData { + t.Errorf("expected collectData %t, got %t", + tt.expected.NowPlaying.CollectData, + response.NowPlaying.CollectData) + } + } + } else if response.NowPlaying != nil { + t.Error("expected nowPlaying to be nil, got non-nil") + } + + if tt.expected.ContentItemHistory != nil { + if response.ContentItemHistory == nil { + t.Error("expected contentItemHistory, got nil") + } else { + if response.ContentItemHistory.MaxSize != tt.expected.ContentItemHistory.MaxSize { + t.Errorf("expected maxSize %d, got %d", + tt.expected.ContentItemHistory.MaxSize, + response.ContentItemHistory.MaxSize) + } + } + } else if response.ContentItemHistory != nil { + t.Error("expected contentItemHistory to be nil, got non-nil") + } + }) + } +} + +func TestSpotifyIntrospectResponse_Unmarshal(t *testing.T) { + xmlData := ` + + + +` + + var response SpotifyIntrospectResponse + err := xml.Unmarshal([]byte(xmlData), &response) + if err != nil { + t.Fatalf("failed to unmarshal spotify response: %v", err) + } + + if response.State != "InactiveUnselected" { + t.Errorf("expected state InactiveUnselected, got %s", response.State) + } + if response.User != "SpotifyConnectUserName" { + t.Errorf("expected user SpotifyConnectUserName, got %s", response.User) + } + if response.IsPlaying != false { + t.Errorf("expected isPlaying false, got %t", response.IsPlaying) + } + if response.TokenLastChangedTimeSeconds != 1702566495 { + t.Errorf("expected tokenLastChangedTimeSeconds 1702566495, got %d", response.TokenLastChangedTimeSeconds) + } + if response.ShuffleMode != "OFF" { + t.Errorf("expected shuffleMode OFF, got %s", response.ShuffleMode) + } +} + +func TestIntrospectState_Constants(t *testing.T) { + tests := []struct { + name string + state IntrospectState + expected string + }{ + {"InactiveUnselected", IntrospectStateInactiveUnselected, "InactiveUnselected"}, + {"Active", IntrospectStateActive, "Active"}, + {"Inactive", IntrospectStateInactive, "Inactive"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if string(tt.state) != tt.expected { + t.Errorf("expected %s, got %s", tt.expected, string(tt.state)) + } + }) + } +} + +func TestShuffleMode_Constants(t *testing.T) { + tests := []struct { + name string + mode ShuffleMode + expected string + }{ + {"Off", ShuffleModeOff, "OFF"}, + {"On", ShuffleModeOn, "ON"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if string(tt.mode) != tt.expected { + t.Errorf("expected %s, got %s", tt.expected, string(tt.mode)) + } + }) + } +} + +func TestNewIntrospectRequest(t *testing.T) { + tests := []struct { + name string + source string + sourceAccount string + }{ + { + name: "with source account", + source: "SPOTIFY", + sourceAccount: "test_user", + }, + { + name: "without source account", + source: "BLUETOOTH", + sourceAccount: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + request := NewIntrospectRequest(tt.source, tt.sourceAccount) + + if request == nil { + t.Error("expected request, got nil") + return + } + + if request.Source != tt.source { + t.Errorf("expected source %s, got %s", tt.source, request.Source) + } + if request.SourceAccount != tt.sourceAccount { + t.Errorf("expected sourceAccount %s, got %s", tt.sourceAccount, request.SourceAccount) + } + }) + } +} + +func TestIntrospectResponse_Methods(t *testing.T) { + tests := []struct { + name string + response *IntrospectResponse + testFunc func(t *testing.T, r *IntrospectResponse) + }{ + { + name: "active spotify response", + response: &IntrospectResponse{ + State: "Active", + User: "test_user", + IsPlaying: true, + ShuffleMode: "ON", + CurrentURI: "spotify://track/123", + SubscriptionType: "Premium", + NowPlaying: &IntrospectNowPlaying{ + SkipPreviousSupported: true, + SeekSupported: true, + ResumeSupported: true, + CollectData: false, + }, + ContentItemHistory: &ContentItemHistory{ + MaxSize: 15, + }, + }, + testFunc: func(t *testing.T, r *IntrospectResponse) { + if !r.IsActive() { + t.Error("expected IsActive() to return true") + } + if r.IsInactive() { + t.Error("expected IsInactive() to return false") + } + if !r.HasUser() { + t.Error("expected HasUser() to return true") + } + if !r.IsShuffleEnabled() { + t.Error("expected IsShuffleEnabled() to return true") + } + if !r.HasCurrentContent() { + t.Error("expected HasCurrentContent() to return true") + } + if !r.SupportsSkipPrevious() { + t.Error("expected SupportsSkipPrevious() to return true") + } + if !r.SupportsSeek() { + t.Error("expected SupportsSeek() to return true") + } + if !r.SupportsResume() { + t.Error("expected SupportsResume() to return true") + } + if r.CollectsData() { + t.Error("expected CollectsData() to return false") + } + if r.GetMaxHistorySize() != 15 { + t.Errorf("expected GetMaxHistorySize() to return 15, got %d", r.GetMaxHistorySize()) + } + if !r.HasSubscription() { + t.Error("expected HasSubscription() to return true") + } + }, + }, + { + name: "inactive response", + response: &IntrospectResponse{ + State: "InactiveUnselected", + User: "", + IsPlaying: false, + ShuffleMode: "OFF", + CurrentURI: "", + SubscriptionType: "", + }, + testFunc: func(t *testing.T, r *IntrospectResponse) { + if r.IsActive() { + t.Error("expected IsActive() to return false") + } + if !r.IsInactive() { + t.Error("expected IsInactive() to return true") + } + if r.HasUser() { + t.Error("expected HasUser() to return false") + } + if r.IsShuffleEnabled() { + t.Error("expected IsShuffleEnabled() to return false") + } + if r.HasCurrentContent() { + t.Error("expected HasCurrentContent() to return false") + } + if r.HasSubscription() { + t.Error("expected HasSubscription() to return false") + } + if r.GetMaxHistorySize() != 0 { + t.Errorf("expected GetMaxHistorySize() to return 0, got %d", r.GetMaxHistorySize()) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.testFunc(t, tt.response) + }) + } +} + +func TestSpotifyIntrospectResponse_Methods(t *testing.T) { + response := &SpotifyIntrospectResponse{ + State: "Active", + User: "test_user", + IsPlaying: true, + ShuffleMode: "ON", + CurrentURI: "spotify://track/123", + SubscriptionType: "Premium", + NowPlaying: &IntrospectNowPlaying{ + SkipPreviousSupported: true, + SeekSupported: true, + ResumeSupported: true, + CollectData: false, + }, + ContentItemHistory: &ContentItemHistory{ + MaxSize: 15, + }, + } + + // Test that Spotify-specific response has same methods as generic response + if !response.IsActive() { + t.Error("expected IsActive() to return true") + } + if response.IsInactive() { + t.Error("expected IsInactive() to return false") + } + if !response.HasUser() { + t.Error("expected HasUser() to return true") + } + if !response.IsShuffleEnabled() { + t.Error("expected IsShuffleEnabled() to return true") + } + if !response.HasCurrentContent() { + t.Error("expected HasCurrentContent() to return true") + } + if !response.SupportsSkipPrevious() { + t.Error("expected SupportsSkipPrevious() to return true") + } + if !response.SupportsSeek() { + t.Error("expected SupportsSeek() to return true") + } + if !response.SupportsResume() { + t.Error("expected SupportsResume() to return true") + } + if response.CollectsData() { + t.Error("expected CollectsData() to return false") + } + if response.GetMaxHistorySize() != 15 { + t.Errorf("expected GetMaxHistorySize() to return 15, got %d", response.GetMaxHistorySize()) + } + if !response.HasSubscription() { + t.Error("expected HasSubscription() to return true") + } +} diff --git a/pkg/models/recents.go b/pkg/models/recents.go new file mode 100644 index 0000000..023c772 --- /dev/null +++ b/pkg/models/recents.go @@ -0,0 +1,240 @@ +package models + +import "encoding/xml" + +// RecentsResponse represents the response from the /recents endpoint +type RecentsResponse struct { + XMLName xml.Name `xml:"recents"` + Items []RecentsResponseItem `xml:"recent"` +} + +// RecentsResponseItem represents a recently played item from the /recents API endpoint +type RecentsResponseItem struct { + XMLName xml.Name `xml:"recent"` + DeviceID string `xml:"deviceID,attr"` + UTCTime int64 `xml:"utcTime,attr"` + ID string `xml:"id,attr,omitempty"` + ContentItem *ContentItem `xml:"contentItem"` +} + +// GetItemCount returns the number of recent items +func (r *RecentsResponse) GetItemCount() int { + return len(r.Items) +} + +// IsEmpty returns true if there are no recent items +func (r *RecentsResponse) IsEmpty() bool { + return len(r.Items) == 0 +} + +// GetMostRecent returns the most recently played item (first in the list) +func (r *RecentsResponse) GetMostRecent() *RecentsResponseItem { + if len(r.Items) == 0 { + return nil + } + return &r.Items[0] +} + +// GetItemsBySource returns recent items filtered by source type +func (r *RecentsResponse) GetItemsBySource(source string) []RecentsResponseItem { + var filtered []RecentsResponseItem + for _, item := range r.Items { + if item.ContentItem != nil && item.ContentItem.Source == source { + filtered = append(filtered, item) + } + } + return filtered +} + +// GetSpotifyItems returns only Spotify recent items +func (r *RecentsResponse) GetSpotifyItems() []RecentsResponseItem { + return r.GetItemsBySource("SPOTIFY") +} + +// GetLocalMusicItems returns only local music recent items +func (r *RecentsResponse) GetLocalMusicItems() []RecentsResponseItem { + return r.GetItemsBySource("LOCAL_MUSIC") +} + +// GetStoredMusicItems returns only stored music recent items +func (r *RecentsResponse) GetStoredMusicItems() []RecentsResponseItem { + return r.GetItemsBySource("STORED_MUSIC") +} + +// GetTuneInItems returns only TuneIn radio recent items +func (r *RecentsResponse) GetTuneInItems() []RecentsResponseItem { + return r.GetItemsBySource("TUNEIN") +} + +// GetPandoraItems returns only Pandora recent items +func (r *RecentsResponse) GetPandoraItems() []RecentsResponseItem { + return r.GetItemsBySource("PANDORA") +} + +// GetPresetableItems returns recent items that can be saved as presets +func (r *RecentsResponse) GetPresetableItems() []RecentsResponseItem { + var presetable []RecentsResponseItem + for _, item := range r.Items { + if item.ContentItem != nil && item.ContentItem.IsPresetable { + presetable = append(presetable, item) + } + } + return presetable +} + +// GetItemsByType returns recent items filtered by content type +func (r *RecentsResponse) GetItemsByType(contentType string) []RecentsResponseItem { + var filtered []RecentsResponseItem + for _, item := range r.Items { + if item.ContentItem != nil && item.ContentItem.Type == contentType { + filtered = append(filtered, item) + } + } + return filtered +} + +// GetTracks returns only track-type recent items +func (r *RecentsResponse) GetTracks() []RecentsResponseItem { + return r.GetItemsByType("track") +} + +// GetStations returns only station-type recent items +func (r *RecentsResponse) GetStations() []RecentsResponseItem { + return r.GetItemsByType("stationurl") +} + +// GetPlaylistsAndAlbums returns playlist and album-type recent items +func (r *RecentsResponse) GetPlaylistsAndAlbums() []RecentsResponseItem { + var items []RecentsResponseItem + for _, item := range r.Items { + if item.ContentItem != nil { + contentType := item.ContentItem.Type + if contentType == "playlist" || contentType == "album" || contentType == "container" { + items = append(items, item) + } + } + } + return items +} + +// HasContent returns true if the recent item has content information +func (ri *RecentsResponseItem) HasContent() bool { + return ri.ContentItem != nil +} + +// GetDisplayName returns the display name for the recent item +func (ri *RecentsResponseItem) GetDisplayName() string { + if ri.ContentItem != nil && ri.ContentItem.ItemName != "" { + return ri.ContentItem.ItemName + } + return "Unknown Item" +} + +// GetSource returns the content source +func (ri *RecentsResponseItem) GetSource() string { + if ri.ContentItem != nil { + return ri.ContentItem.Source + } + return "" +} + +// GetSourceAccount returns the source account +func (ri *RecentsResponseItem) GetSourceAccount() string { + if ri.ContentItem != nil { + return ri.ContentItem.SourceAccount + } + return "" +} + +// GetLocation returns the content location +func (ri *RecentsResponseItem) GetLocation() string { + if ri.ContentItem != nil { + return ri.ContentItem.Location + } + return "" +} + +// GetContentType returns the content type +func (ri *RecentsResponseItem) GetContentType() string { + if ri.ContentItem != nil { + return ri.ContentItem.Type + } + return "" +} + +// IsPresetable returns true if the item can be saved as a preset +func (ri *RecentsResponseItem) IsPresetable() bool { + return ri.ContentItem != nil && ri.ContentItem.IsPresetable +} + +// IsTrack returns true if the recent item is a track +func (ri *RecentsResponseItem) IsTrack() bool { + return ri.GetContentType() == "track" +} + +// IsStation returns true if the recent item is a radio station +func (ri *RecentsResponseItem) IsStation() bool { + return ri.GetContentType() == "stationurl" +} + +// IsPlaylist returns true if the recent item is a playlist +func (ri *RecentsResponseItem) IsPlaylist() bool { + return ri.GetContentType() == "playlist" +} + +// IsAlbum returns true if the recent item is an album +func (ri *RecentsResponseItem) IsAlbum() bool { + return ri.GetContentType() == "album" +} + +// IsContainer returns true if the recent item is a container (folder/collection) +func (ri *RecentsResponseItem) IsContainer() bool { + contentType := ri.GetContentType() + return contentType == "container" || contentType == "dir" +} + +// IsSpotifyContent returns true if the recent item is from Spotify +func (ri *RecentsResponseItem) IsSpotifyContent() bool { + return ri.GetSource() == "SPOTIFY" +} + +// IsLocalContent returns true if the recent item is from local sources +func (ri *RecentsResponseItem) IsLocalContent() bool { + source := ri.GetSource() + return source == "LOCAL_MUSIC" || source == "STORED_MUSIC" +} + +// IsStreamingContent returns true if the recent item is from streaming services +func (ri *RecentsResponseItem) IsStreamingContent() bool { + source := ri.GetSource() + return source == "SPOTIFY" || source == "PANDORA" || source == "TUNEIN" || + source == "AMAZON" || source == "DEEZER" || source == "IHEART" +} + +// GetArtwork returns the artwork URL if available +func (ri *RecentsResponseItem) GetArtwork() string { + if ri.ContentItem != nil { + return ri.ContentItem.ContainerArt + } + return "" +} + +// HasArtwork returns true if artwork is available +func (ri *RecentsResponseItem) HasArtwork() bool { + return ri.GetArtwork() != "" +} + +// GetUTCTime returns the UTC timestamp when the item was played +func (ri *RecentsResponseItem) GetUTCTime() int64 { + return ri.UTCTime +} + +// HasID returns true if the recent item has an ID +func (ri *RecentsResponseItem) HasID() bool { + return ri.ID != "" +} + +// GetID returns the recent item ID +func (ri *RecentsResponseItem) GetID() string { + return ri.ID +} diff --git a/pkg/models/recents_test.go b/pkg/models/recents_test.go new file mode 100644 index 0000000..13f1adf --- /dev/null +++ b/pkg/models/recents_test.go @@ -0,0 +1,580 @@ +package models + +import ( + "encoding/xml" + "testing" +) + +func TestRecentsResponse_Unmarshal(t *testing.T) { + tests := []struct { + name string + xmlData string + expected *RecentsResponse + expectError bool + }{ + { + name: "complete recents response", + xmlData: ` + + + MercyMe, It's Christmas! + + + + + Baby It's Cold Outside - ANNE MURRAY + + +`, + expected: &RecentsResponse{ + Items: []RecentsResponseItem{ + { + DeviceID: "1004567890AA", + UTCTime: 1701202831, + ContentItem: &ContentItem{ + Source: "STORED_MUSIC", + Location: "6_a2874b5d_4f83d999", + SourceAccount: "d09708a1-5953-44bc-a413-123456789012/0", + IsPresetable: true, + ItemName: "MercyMe, It's Christmas!", + }, + }, + { + DeviceID: "1004567890AA", + UTCTime: 1700232917, + ID: "2487503626", + ContentItem: &ContentItem{ + Source: "LOCAL_MUSIC", + Type: "track", + Location: "track:2590", + SourceAccount: "3f205110-4a57-4e91-810a-123456789012", + IsPresetable: true, + ItemName: "Baby It's Cold Outside - ANNE MURRAY", + }, + }, + }, + }, + }, + { + name: "spotify recent item", + xmlData: ` + + + Shape of You - Ed Sheeran + https://i.scdn.co/image/ab67616d0000b273ba5db46f4b838ef6027e6f96 + + +`, + expected: &RecentsResponse{ + Items: []RecentsResponseItem{ + { + DeviceID: "1004567890AA", + UTCTime: 1701300000, + ID: "spotify123", + ContentItem: &ContentItem{ + Source: "SPOTIFY", + Type: "track", + Location: "spotify:track:4iV5W9uYEdYUVa79Axb7Rh", + SourceAccount: "spotify_user", + IsPresetable: true, + ItemName: "Shape of You - Ed Sheeran", + ContainerArt: "https://i.scdn.co/image/ab67616d0000b273ba5db46f4b838ef6027e6f96", + }, + }, + }, + }, + }, + { + name: "empty recents", + xmlData: ` +`, + expected: &RecentsResponse{ + Items: []RecentsResponseItem{}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var response RecentsResponse + err := xml.Unmarshal([]byte(tt.xmlData), &response) + + if tt.expectError { + if err == nil { + t.Error("expected error, got nil") + } + return + } + + if err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + // Compare basic structure + if len(response.Items) != len(tt.expected.Items) { + t.Errorf("expected %d items, got %d", len(tt.expected.Items), len(response.Items)) + } + + // Compare each item + for i, expectedItem := range tt.expected.Items { + if i >= len(response.Items) { + break + } + + actualItem := response.Items[i] + + if actualItem.DeviceID != expectedItem.DeviceID { + t.Errorf("item %d: expected deviceID %s, got %s", i, expectedItem.DeviceID, actualItem.DeviceID) + } + if actualItem.UTCTime != expectedItem.UTCTime { + t.Errorf("item %d: expected utcTime %d, got %d", i, expectedItem.UTCTime, actualItem.UTCTime) + } + if actualItem.ID != expectedItem.ID { + t.Errorf("item %d: expected id %s, got %s", i, expectedItem.ID, actualItem.ID) + } + + // Compare ContentItem + if expectedItem.ContentItem != nil { + if actualItem.ContentItem == nil { + t.Errorf("item %d: expected contentItem, got nil", i) + continue + } + + if actualItem.ContentItem.Source != expectedItem.ContentItem.Source { + t.Errorf("item %d: expected source %s, got %s", i, expectedItem.ContentItem.Source, actualItem.ContentItem.Source) + } + if actualItem.ContentItem.Type != expectedItem.ContentItem.Type { + t.Errorf("item %d: expected type %s, got %s", i, expectedItem.ContentItem.Type, actualItem.ContentItem.Type) + } + if actualItem.ContentItem.Location != expectedItem.ContentItem.Location { + t.Errorf("item %d: expected location %s, got %s", i, expectedItem.ContentItem.Location, actualItem.ContentItem.Location) + } + if actualItem.ContentItem.ItemName != expectedItem.ContentItem.ItemName { + t.Errorf("item %d: expected itemName %s, got %s", i, expectedItem.ContentItem.ItemName, actualItem.ContentItem.ItemName) + } + if actualItem.ContentItem.IsPresetable != expectedItem.ContentItem.IsPresetable { + t.Errorf("item %d: expected isPresetable %t, got %t", i, expectedItem.ContentItem.IsPresetable, actualItem.ContentItem.IsPresetable) + } + } else if actualItem.ContentItem != nil { + t.Errorf("item %d: expected nil contentItem, got non-nil", i) + } + } + }) + } +} + +func TestRecentsResponse_Methods(t *testing.T) { + response := &RecentsResponse{ + Items: []RecentsResponseItem{ + { + DeviceID: "device1", + UTCTime: 1701200000, + ContentItem: &ContentItem{ + Source: "SPOTIFY", + Type: "track", + ItemName: "Song 1", + IsPresetable: true, + }, + }, + { + DeviceID: "device1", + UTCTime: 1701100000, + ContentItem: &ContentItem{ + Source: "LOCAL_MUSIC", + Type: "track", + ItemName: "Song 2", + IsPresetable: false, + }, + }, + { + DeviceID: "device1", + UTCTime: 1701000000, + ContentItem: &ContentItem{ + Source: "TUNEIN", + Type: "stationurl", + ItemName: "Radio Station", + IsPresetable: true, + }, + }, + }, + } + + // Test GetItemCount + if response.GetItemCount() != 3 { + t.Errorf("expected item count 3, got %d", response.GetItemCount()) + } + + // Test IsEmpty + if response.IsEmpty() { + t.Error("expected IsEmpty() to return false") + } + + // Test GetMostRecent + mostRecent := response.GetMostRecent() + if mostRecent == nil { + t.Error("expected most recent item, got nil") + } else if mostRecent.UTCTime != 1701200000 { + t.Errorf("expected most recent UTCTime 1701200000, got %d", mostRecent.UTCTime) + } + + // Test GetItemsBySource + spotifyItems := response.GetItemsBySource("SPOTIFY") + if len(spotifyItems) != 1 { + t.Errorf("expected 1 Spotify item, got %d", len(spotifyItems)) + } + + localItems := response.GetItemsBySource("LOCAL_MUSIC") + if len(localItems) != 1 { + t.Errorf("expected 1 LOCAL_MUSIC item, got %d", len(localItems)) + } + + // Test GetSpotifyItems + spotifyItems2 := response.GetSpotifyItems() + if len(spotifyItems2) != 1 { + t.Errorf("expected 1 Spotify item, got %d", len(spotifyItems2)) + } + + // Test GetPresetableItems + presetableItems := response.GetPresetableItems() + if len(presetableItems) != 2 { + t.Errorf("expected 2 presetable items, got %d", len(presetableItems)) + } + + // Test GetTracks + tracks := response.GetTracks() + if len(tracks) != 2 { + t.Errorf("expected 2 track items, got %d", len(tracks)) + } + + // Test GetStations + stations := response.GetStations() + if len(stations) != 1 { + t.Errorf("expected 1 station item, got %d", len(stations)) + } +} + +func TestRecentsResponse_EmptyResponse(t *testing.T) { + response := &RecentsResponse{ + Items: []RecentsResponseItem{}, + } + + // Test empty response methods + if response.GetItemCount() != 0 { + t.Errorf("expected item count 0, got %d", response.GetItemCount()) + } + + if !response.IsEmpty() { + t.Error("expected IsEmpty() to return true") + } + + if response.GetMostRecent() != nil { + t.Error("expected GetMostRecent() to return nil") + } + + if len(response.GetSpotifyItems()) != 0 { + t.Errorf("expected 0 Spotify items, got %d", len(response.GetSpotifyItems())) + } +} + +func TestRecentItem_Methods(t *testing.T) { + tests := []struct { + name string + item RecentsResponseItem + test func(t *testing.T, item *RecentsResponseItem) + }{ + { + name: "spotify track item", + item: RecentsResponseItem{ + DeviceID: "device1", + UTCTime: 1701200000, + ID: "spotify123", + ContentItem: &ContentItem{ + Source: "SPOTIFY", + Type: "track", + Location: "spotify:track:123", + SourceAccount: "user@spotify.com", + IsPresetable: true, + ItemName: "Test Song", + ContainerArt: "https://example.com/art.jpg", + }, + }, + test: func(t *testing.T, item *RecentsResponseItem) { + if !item.HasContent() { + t.Error("expected HasContent() to return true") + } + if item.GetDisplayName() != "Test Song" { + t.Errorf("expected display name 'Test Song', got %s", item.GetDisplayName()) + } + if item.GetSource() != "SPOTIFY" { + t.Errorf("expected source 'SPOTIFY', got %s", item.GetSource()) + } + if !item.IsTrack() { + t.Error("expected IsTrack() to return true") + } + if !item.IsSpotifyContent() { + t.Error("expected IsSpotifyContent() to return true") + } + if !item.IsStreamingContent() { + t.Error("expected IsStreamingContent() to return true") + } + if item.IsLocalContent() { + t.Error("expected IsLocalContent() to return false") + } + if !item.IsPresetable() { + t.Error("expected IsPresetable() to return true") + } + if !item.HasArtwork() { + t.Error("expected HasArtwork() to return true") + } + if item.GetArtwork() != "https://example.com/art.jpg" { + t.Errorf("expected artwork URL, got %s", item.GetArtwork()) + } + if item.GetUTCTime() != 1701200000 { + t.Errorf("expected UTC time 1701200000, got %d", item.GetUTCTime()) + } + if !item.HasID() { + t.Error("expected HasID() to return true") + } + if item.GetID() != "spotify123" { + t.Errorf("expected ID 'spotify123', got %s", item.GetID()) + } + }, + }, + { + name: "local music item", + item: RecentsResponseItem{ + DeviceID: "device1", + UTCTime: 1701100000, + ContentItem: &ContentItem{ + Source: "LOCAL_MUSIC", + Type: "track", + Location: "/music/song.mp3", + IsPresetable: false, + ItemName: "Local Song", + }, + }, + test: func(t *testing.T, item *RecentsResponseItem) { + if !item.IsLocalContent() { + t.Error("expected IsLocalContent() to return true") + } + if item.IsStreamingContent() { + t.Error("expected IsStreamingContent() to return false") + } + if item.IsSpotifyContent() { + t.Error("expected IsSpotifyContent() to return false") + } + if item.HasArtwork() { + t.Error("expected HasArtwork() to return false") + } + if item.GetArtwork() != "" { + t.Errorf("expected empty artwork, got %s", item.GetArtwork()) + } + }, + }, + { + name: "radio station item", + item: RecentsResponseItem{ + DeviceID: "device1", + UTCTime: 1701000000, + ContentItem: &ContentItem{ + Source: "TUNEIN", + Type: "stationurl", + Location: "tunein:station:123", + IsPresetable: true, + ItemName: "Rock FM", + }, + }, + test: func(t *testing.T, item *RecentsResponseItem) { + if !item.IsStation() { + t.Error("expected IsStation() to return true") + } + if item.IsTrack() { + t.Error("expected IsTrack() to return false") + } + if !item.IsStreamingContent() { + t.Error("expected IsStreamingContent() to return true") + } + }, + }, + { + name: "empty content item", + item: RecentsResponseItem{ + DeviceID: "device1", + UTCTime: 1701000000, + }, + test: func(t *testing.T, item *RecentsResponseItem) { + if item.HasContent() { + t.Error("expected HasContent() to return false") + } + if item.GetDisplayName() != "Unknown Item" { + t.Errorf("expected display name 'Unknown Item', got %s", item.GetDisplayName()) + } + if item.GetSource() != "" { + t.Errorf("expected empty source, got %s", item.GetSource()) + } + if item.IsTrack() { + t.Error("expected IsTrack() to return false") + } + if item.IsPresetable() { + t.Error("expected IsPresetable() to return false") + } + if item.HasID() { + t.Error("expected HasID() to return false") + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.test(t, &tt.item) + }) + } +} + +func TestRecentItem_ContentTypes(t *testing.T) { + tests := []struct { + name string + contentType string + expected map[string]bool + }{ + { + name: "track type", + contentType: "track", + expected: map[string]bool{ + "IsTrack": true, + "IsStation": false, + "IsPlaylist": false, + "IsAlbum": false, + "IsContainer": false, + }, + }, + { + name: "station type", + contentType: "stationurl", + expected: map[string]bool{ + "IsTrack": false, + "IsStation": true, + "IsPlaylist": false, + "IsAlbum": false, + "IsContainer": false, + }, + }, + { + name: "playlist type", + contentType: "playlist", + expected: map[string]bool{ + "IsTrack": false, + "IsStation": false, + "IsPlaylist": true, + "IsAlbum": false, + "IsContainer": false, + }, + }, + { + name: "album type", + contentType: "album", + expected: map[string]bool{ + "IsTrack": false, + "IsStation": false, + "IsPlaylist": false, + "IsAlbum": true, + "IsContainer": false, + }, + }, + { + name: "container type", + contentType: "container", + expected: map[string]bool{ + "IsTrack": false, + "IsStation": false, + "IsPlaylist": false, + "IsAlbum": false, + "IsContainer": true, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + item := RecentsResponseItem{ + ContentItem: &ContentItem{ + Type: tt.contentType, + }, + } + + results := map[string]bool{ + "IsTrack": item.IsTrack(), + "IsStation": item.IsStation(), + "IsPlaylist": item.IsPlaylist(), + "IsAlbum": item.IsAlbum(), + "IsContainer": item.IsContainer(), + } + + for method, expected := range tt.expected { + if results[method] != expected { + t.Errorf("expected %s() to return %t, got %t", method, expected, results[method]) + } + } + }) + } +} + +func TestRecentsResponse_FilterMethods(t *testing.T) { + response := &RecentsResponse{ + Items: []RecentsResponseItem{ + { + ContentItem: &ContentItem{Source: "SPOTIFY", Type: "track"}, + }, + { + ContentItem: &ContentItem{Source: "PANDORA", Type: "track"}, + }, + { + ContentItem: &ContentItem{Source: "LOCAL_MUSIC", Type: "track"}, + }, + { + ContentItem: &ContentItem{Source: "STORED_MUSIC", Type: "track"}, + }, + { + ContentItem: &ContentItem{Source: "TUNEIN", Type: "stationurl"}, + }, + { + ContentItem: &ContentItem{Source: "SPOTIFY", Type: "playlist"}, + }, + }, + } + + // Test individual service filters + if len(response.GetSpotifyItems()) != 2 { + t.Errorf("expected 2 Spotify items, got %d", len(response.GetSpotifyItems())) + } + + if len(response.GetPandoraItems()) != 1 { + t.Errorf("expected 1 Pandora item, got %d", len(response.GetPandoraItems())) + } + + if len(response.GetLocalMusicItems()) != 1 { + t.Errorf("expected 1 LOCAL_MUSIC item, got %d", len(response.GetLocalMusicItems())) + } + + if len(response.GetStoredMusicItems()) != 1 { + t.Errorf("expected 1 STORED_MUSIC item, got %d", len(response.GetStoredMusicItems())) + } + + if len(response.GetTuneInItems()) != 1 { + t.Errorf("expected 1 TuneIn item, got %d", len(response.GetTuneInItems())) + } + + // Test type filters + if len(response.GetTracks()) != 4 { + t.Errorf("expected 4 track items, got %d", len(response.GetTracks())) + } + + if len(response.GetStations()) != 1 { + t.Errorf("expected 1 station item, got %d", len(response.GetStations())) + } + + if len(response.GetPlaylistsAndAlbums()) != 1 { + t.Errorf("expected 1 playlist/album item, got %d", len(response.GetPlaylistsAndAlbums())) + } +}