mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 08:36:13 +00:00
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:
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user