feat: implement /introspect and /recents endpoints with full CLI support

🔥 NEW ENDPOINTS IMPLEMENTED:

📊 /introspect endpoint:
- Get detailed music service state and capabilities data
- Support for SPOTIFY, PANDORA, TUNEIN, AMAZON, DEEZER services
- Service state tracking (Active, Inactive, InactiveUnselected)
- Playback capabilities (skip, seek, resume, data collection)
- Authentication token status and user account information
- Subscription type and content history metadata

📚 /recents endpoint:
- Retrieve recently played content history
- Support for all music sources (Spotify, Local, TuneIn, Pandora, etc.)
- Rich filtering by source type and content type
- Content classification (tracks, stations, playlists, albums)
- Presetable item identification and artwork metadata
- Timestamp tracking with UTC time support

 CLIENT API:
- client.Introspect(source, sourceAccount) method
- client.IntrospectSpotify(sourceAccount) convenience method
- client.GetRecents() method with comprehensive filtering
- Complete error handling and validation
- Rich helper methods for content analysis

🖥️ CLI COMMANDS:
- soundtouch-cli source introspect --source <SERVICE>
- soundtouch-cli source introspect-spotify
- soundtouch-cli source introspect-all (bulk introspect)
- soundtouch-cli recents list [--detailed] [--limit N]
- soundtouch-cli recents filter --source <SRC> --type <TYPE>
- soundtouch-cli recents latest (most recent item)
- soundtouch-cli recents stats (detailed analytics)

📦 MODELS & FEATURES:
- IntrospectRequest/Response with service-specific handling
- RecentsResponse with RecentsResponseItem for individual items
- Rich filtering: GetSpotifyItems(), GetTracks(), GetPresetableItems()
- Content type detection: IsTrack(), IsStation(), IsPlaylist()
- Source classification: IsStreamingContent(), IsLocalContent()
- Full XML marshalling/unmarshalling with proper attribute handling

🧪 COMPREHENSIVE TESTING:
- Unit tests for models with XML parsing validation
- Integration tests for real device communication
- CLI command tests with mock server responses
- Error condition testing and edge case handling
- Performance tests and timeout validation

📖 DOCUMENTATION & EXAMPLES:
- Updated API endpoints overview marking endpoints as implemented
- Comprehensive CLI reference with usage examples
- Removed endpoints from unimplemented list
- Updated wiki implementation plan status
- Complete example applications with README guides
- Real-world usage patterns and best practices

 KEY FEATURES:
- Service health monitoring and diagnostics
- Recently played content discovery and analysis
- Preset candidate identification
- Content statistics and usage analytics
- Time-based filtering and relative timestamps
- Rich emoji-based CLI output formatting
- Cross-service compatibility and error handling

This implements two critical missing endpoints from the SoundTouch API,
providing essential functionality for music service management and
recently played content analysis with full programmatic and CLI access.
This commit is contained in:
Tobias Gesellchen
2026-02-02 16:26:40 +01:00
parent 1ec3c6950c
commit 7ec4ee67af
23 changed files with 6410 additions and 35 deletions
+35
View File
@@ -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)
+235
View File
@@ -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")
}
}
+361
View File
@@ -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: `<?xml version="1.0" encoding="UTF-8" ?>
<spotifyAccountIntrospectResponse state="InactiveUnselected" user="SpotifyConnectUserName" isPlaying="false" tokenLastChangedTimeSeconds="1702566495" tokenLastChangedTimeMicroseconds="427884" shuffleMode="OFF" playStatusState="2" currentUri="" receivedPlaybackRequest="false" subscriptionType="">
<cachedPlaybackRequest />
<nowPlaying skipPreviousSupported="false" seekSupported="false" resumeSupported="true" collectData="true" />
<contentItemHistory maxSize="10" />
</spotifyAccountIntrospectResponse>`,
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: `<?xml version="1.0" encoding="UTF-8" ?>
<pandoraAccountIntrospectResponse state="Active" user="pandora_user" isPlaying="true" shuffleMode="ON" currentUri="pandora://track/123" subscriptionType="Premium">
<nowPlaying skipPreviousSupported="true" seekSupported="false" resumeSupported="true" collectData="false" />
<contentItemHistory maxSize="20" />
</pandoraAccountIntrospectResponse>`,
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)
}
})
}
}
+325
View File
@@ -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)
}
+396
View File
@@ -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: `<?xml version="1.0" encoding="UTF-8" ?>
<recents>
<recent deviceID="1004567890AA" utcTime="1701202831">
<contentItem source="STORED_MUSIC" location="6_a2874b5d_4f83d999" sourceAccount="d09708a1-5953-44bc-a413-123456789012/0" isPresetable="true">
<itemName>MercyMe, It's Christmas!</itemName>
</contentItem>
</recent>
<recent deviceID="1004567890AA" utcTime="1700232917" id="2487503626">
<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</itemName>
</contentItem>
</recent>
</recents>`,
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: `<?xml version="1.0" encoding="UTF-8" ?>
<recents>
</recents>`,
wantResponse: &models.RecentsResponse{
Items: []models.RecentsResponseItem{},
},
},
{
name: "spotify recents with artwork",
statusCode: http.StatusOK,
responseXML: `<?xml version="1.0" encoding="UTF-8" ?>
<recents>
<recent deviceID="1004567890AA" utcTime="1701300000" id="spotify123">
<contentItem source="SPOTIFY" type="track" location="spotify:track:4iV5W9uYEdYUVa79Axb7Rh" sourceAccount="spotify_user" isPresetable="true">
<itemName>Shape of You - Ed Sheeran</itemName>
<containerArt>https://i.scdn.co/image/ab67616d0000b273ba5db46f4b838ef6027e6f96</containerArt>
</contentItem>
</recent>
<recent deviceID="1004567890AA" utcTime="1701250000" id="spotify124">
<contentItem source="SPOTIFY" type="playlist" location="spotify:playlist:37i9dQZF1DXcBWIGoYBM5M" sourceAccount="spotify_user" isPresetable="true">
<itemName>Today's Top Hits</itemName>
<containerArt>https://i.scdn.co/image/ab67706f00000002ca5a7517156021292e5663a6</containerArt>
</contentItem>
</recent>
</recents>`,
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: `<?xml version="1.0" encoding="UTF-8" ?>
<recents>
<recent deviceID="1004567890AA" utcTime="1701400000">
<contentItem source="TUNEIN" type="stationurl" location="tunein:station:s24939" sourceAccount="tunein" isPresetable="true">
<itemName>BBC Radio 1</itemName>
</contentItem>
</recent>
</recents>`,
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: `<invalid>xml</malformed>`,
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 := `<recents>
<recent deviceID="1004567890AA" utcTime="1701300000" id="1">
<contentItem source="SPOTIFY" type="track" location="spotify:track:123" isPresetable="true">
<itemName>Spotify Track</itemName>
</contentItem>
</recent>
<recent deviceID="1004567890AA" utcTime="1701200000" id="2">
<contentItem source="LOCAL_MUSIC" type="track" location="/music/local.mp3" isPresetable="false">
<itemName>Local Track</itemName>
</contentItem>
</recent>
<recent deviceID="1004567890AA" utcTime="1701100000" id="3">
<contentItem source="TUNEIN" type="stationurl" location="tunein:station:123" isPresetable="true">
<itemName>Radio Station</itemName>
</contentItem>
</recent>
<recent deviceID="1004567890AA" utcTime="1701000000" id="4">
<contentItem source="PANDORA" type="track" location="pandora:track:456" isPresetable="true">
<itemName>Pandora Track</itemName>
</contentItem>
</recent>
</recents>`
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")
}
}
})
}
}
+251
View File
@@ -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
}
+485
View File
@@ -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: `<introspect source="SPOTIFY" sourceAccount="SpotifyConnectUserName"></introspect>`,
},
{
name: "without source account",
request: &IntrospectRequest{
Source: "BLUETOOTH",
},
expected: `<introspect source="BLUETOOTH"></introspect>`,
},
}
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: `<spotifyAccountIntrospectResponse state="InactiveUnselected" user="SpotifyConnectUserName" isPlaying="false" tokenLastChangedTimeSeconds="1702566495" tokenLastChangedTimeMicroseconds="427884" shuffleMode="OFF" playStatusState="2" currentUri="" receivedPlaybackRequest="false" subscriptionType="">
<cachedPlaybackRequest />
<nowPlaying skipPreviousSupported="false" seekSupported="false" resumeSupported="true" collectData="true" />
<contentItemHistory maxSize="10" />
</spotifyAccountIntrospectResponse>`,
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: `<pandoraAccountIntrospectResponse state="Active" user="pandora_user" isPlaying="true" shuffleMode="ON" currentUri="pandora://track/123" subscriptionType="Premium">
<nowPlaying skipPreviousSupported="true" seekSupported="false" resumeSupported="true" collectData="false" />
<contentItemHistory maxSize="20" />
</pandoraAccountIntrospectResponse>`,
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: `<serviceIntrospectResponse state="Inactive">
</serviceIntrospectResponse>`,
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 := `<spotifyAccountIntrospectResponse state="InactiveUnselected" user="SpotifyConnectUserName" isPlaying="false" tokenLastChangedTimeSeconds="1702566495" tokenLastChangedTimeMicroseconds="427884" shuffleMode="OFF" playStatusState="2" currentUri="" receivedPlaybackRequest="false" subscriptionType="">
<cachedPlaybackRequest />
<nowPlaying skipPreviousSupported="false" seekSupported="false" resumeSupported="true" collectData="true" />
<contentItemHistory maxSize="10" />
</spotifyAccountIntrospectResponse>`
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")
}
}
+240
View File
@@ -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
}
+580
View File
@@ -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: `<recents>
<recent deviceID="1004567890AA" utcTime="1701202831">
<contentItem source="STORED_MUSIC" location="6_a2874b5d_4f83d999" sourceAccount="d09708a1-5953-44bc-a413-123456789012/0" isPresetable="true">
<itemName>MercyMe, It's Christmas!</itemName>
</contentItem>
</recent>
<recent deviceID="1004567890AA" utcTime="1700232917" id="2487503626">
<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</itemName>
</contentItem>
</recent>
</recents>`,
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: `<recents>
<recent deviceID="1004567890AA" utcTime="1701300000" id="spotify123">
<contentItem source="SPOTIFY" type="track" location="spotify:track:4iV5W9uYEdYUVa79Axb7Rh" sourceAccount="spotify_user" isPresetable="true">
<itemName>Shape of You - Ed Sheeran</itemName>
<containerArt>https://i.scdn.co/image/ab67616d0000b273ba5db46f4b838ef6027e6f96</containerArt>
</contentItem>
</recent>
</recents>`,
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: `<recents>
</recents>`,
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()))
}
}