mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 00:26:29 +00:00
Implement /name, /capabilities, and /presets informational endpoints
## New Endpoints ### GET /name ✅ - Simple device name retrieval with XML parsing - Helper methods for name validation and display - Real device name integration with anonymization ### GET /capabilities ✅ - Comprehensive device capabilities detection - Complex XML structure with nested network, DSP, and system configurations - Smart categorization: System Features, Audio Features, Network Features - Capability-specific helper methods (HasLRStereoCapability, HasDualModeNetwork, etc.) - Extended capabilities parsing with URLs and metadata ### GET /presets ✅ - Complete preset management with timestamps and metadata - Spotify playlist integration with anonymized account information - Smart filtering: by source, used/empty slots, most recent, oldest presets - Comprehensive analysis: preset summaries with source breakdowns - Time-based operations: creation/update timestamps with formatted display ## Device Introspection Features ### Capability Detection - System capabilities: Light Switch, Clock Display, BCO Reset, Power Saving - Audio capabilities: L/R Stereo support, DSP Mono/Stereo availability - Network capabilities: Dual Mode, WSAPI Proxy, Hosted WiFi Configuration - Extended capabilities: Custom endpoint discovery with URL mapping ### Preset Analysis - Usage pattern analysis (used vs empty slots) - Source distribution (Spotify, TuneIn, etc.) - Temporal analysis (most recent, oldest presets) - Content metadata extraction (artwork URLs, display names) ## Enhanced CLI Tool ### New Commands - Added -name command with simple device identification - Added -capabilities command with categorized feature display - Added -presets command with comprehensive preset analysis - Enhanced help system with all new command examples ### Rich Output Formatting - Capability categorization with bullet-point display - Preset timeline with creation/update timestamps - Smart metadata display (artwork, source accounts, content types) - Device-specific feature highlighting (different capabilities per device) ## Real Device Integration ### Multi-Device Testing - Device 192.168.178.28: SoundTouch 10 with Light Switch, Clock Display, Hosted WiFi - Device 192.168.178.35: SoundTouch 20 with L/R Stereo, Dual Mode networking - Verified capability differences between device models - Real preset data with anonymized Spotify account information ### Edge Case Handling - Non-responsive endpoints (/trackInfo timeout handling) - Empty preset configurations - Missing capability sections - Device-specific feature variations ## Quality & Testing ### Comprehensive Test Coverage - 15+ unit tests for XML models with real device response patterns - Client integration tests with mock HTTP servers - Edge case validation (empty names, missing capabilities, no presets) - Timestamp parsing and validation with Unix epoch conversion ### Production-Ready Features - Type-safe XML unmarshaling with custom validation - Robust error handling for network and parsing failures - Privacy protection with anonymized real device data - Documentation updates with real-world usage examples ## API Coverage Progress ✅ Complete Information Endpoints: - GET /info - Device information - GET /name - Device name - GET /capabilities - Device capabilities - GET /presets - Configured presets - GET /now_playing - Current playback status - GET /sources - Available audio sources 🔄 Next Phase - Control Endpoints: - POST /key - Media controls - GET/POST /volume - Volume management - WebSocket / - Real-time events Features: ✅ Comprehensive device introspection and capability detection ✅ Smart preset management with timeline analysis ✅ Multi-device support with hardware-specific feature detection ✅ Production-ready error handling and data validation ✅ Rich CLI interface with categorized output formatting ✅ Real device integration with privacy-protected test data
This commit is contained in:
@@ -7,12 +7,14 @@ A modern Go library and CLI tool for interacting with Bose SoundTouch devices vi
|
||||
### ✅ Implemented (Phase 1)
|
||||
- **HTTP Client with XML Support**: Complete client for SoundTouch Web API
|
||||
- **Device Information**: Get detailed device info via `/info` endpoint
|
||||
- **Device Name**: Get device name via `/name` endpoint
|
||||
- **Device Capabilities**: Get device capabilities via `/capabilities` endpoint
|
||||
- **Configured Presets**: Get preset configurations via `/presets` endpoint
|
||||
- **Now Playing Status**: Get current playback information via `/now_playing` endpoint
|
||||
- **Audio Sources**: Get available sources via `/sources` endpoint
|
||||
- **UPnP Discovery**: Automatic device discovery on local network
|
||||
- **Cross-Platform**: Works on Windows, macOS, Linux, and WASM
|
||||
- **CLI Tool**: Command-line interface for testing and basic operations
|
||||
- **Comprehensive Tests**: Unit and integration tests with real device responses
|
||||
- **Flexible Configuration**: Support for .env files and environment variables
|
||||
- **Hybrid Discovery**: Combines UPnP discovery with configured device lists
|
||||
|
||||
@@ -127,6 +129,59 @@ soundtouch-cli -host 192.168.1.100 -sources
|
||||
# Streaming Services: 3 ready
|
||||
```
|
||||
|
||||
#### Device Name
|
||||
```bash
|
||||
# Get device name
|
||||
soundtouch-cli -host 192.168.1.100 -name
|
||||
|
||||
# Example output:
|
||||
# Device Name: Sound Machinechen
|
||||
```
|
||||
|
||||
#### Device Capabilities
|
||||
```bash
|
||||
# Get device capabilities
|
||||
soundtouch-cli -host 192.168.1.100 -capabilities
|
||||
|
||||
# Example output:
|
||||
# Device Capabilities:
|
||||
# Device ID: A81B6A536A98
|
||||
#
|
||||
# System Features:
|
||||
# • Power Saving Disabled
|
||||
#
|
||||
# Audio Features:
|
||||
# • L/R Stereo
|
||||
#
|
||||
# Network Features:
|
||||
# • Dual Mode
|
||||
# • WSAPI Proxy
|
||||
#
|
||||
# Extended Capabilities:
|
||||
# • systemtimeout (/systemtimeout)
|
||||
# • rebroadcastlatencymode (/rebroadcastlatencymode)
|
||||
```
|
||||
|
||||
#### Configured Presets
|
||||
```bash
|
||||
# Get configured presets
|
||||
soundtouch-cli -host 192.168.1.100 -presets
|
||||
|
||||
# Example output:
|
||||
# Configured Presets:
|
||||
# Used Slots: 6/6
|
||||
# Spotify Presets: 6
|
||||
#
|
||||
# Preset 1: My Playlist
|
||||
# Source: SPOTIFY (user@example.com)
|
||||
# Type: tracklisturl
|
||||
# Created: 2024-06-23 09:40:36
|
||||
# Updated: 2024-10-12 15:39:42
|
||||
# Artwork: https://i.scdn.co/image/...
|
||||
#
|
||||
# Most Recent: Preset 4 (Movie Soundtrack)
|
||||
```
|
||||
|
||||
### Go Library Usage
|
||||
|
||||
```go
|
||||
@@ -191,6 +246,27 @@ func main() {
|
||||
if sources.HasSpotify() {
|
||||
fmt.Println("Spotify is available")
|
||||
}
|
||||
|
||||
// Get device name
|
||||
name, err := soundtouchClient.GetName()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Printf("Device: %s\n", name.GetName())
|
||||
|
||||
// Get device capabilities
|
||||
capabilities, err := soundtouchClient.GetCapabilities()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Printf("L/R Stereo Support: %v\n", capabilities.HasLRStereoCapability())
|
||||
|
||||
// Get presets
|
||||
presets, err := soundtouchClient.GetPresets()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Printf("Presets Used: %d/6\n", len(presets.GetUsedPresetSlots()))
|
||||
}
|
||||
```
|
||||
|
||||
@@ -261,6 +337,9 @@ make help
|
||||
The SoundTouch Web API uses HTTP with XML payloads. Key endpoints include:
|
||||
|
||||
- `GET /info` - Device information ✅ Implemented
|
||||
- `GET /name` - Device name ✅ Implemented
|
||||
- `GET /capabilities` - Device capabilities ✅ Implemented
|
||||
- `GET /presets` - Configured presets ✅ Implemented
|
||||
- `GET /now_playing` - Current playback status ✅ Implemented
|
||||
- `GET /sources` - Available audio sources ✅ Implemented
|
||||
- `POST /key` - Send key commands (play, pause, etc.)
|
||||
|
||||
+265
-10
@@ -16,15 +16,18 @@ import (
|
||||
|
||||
func main() {
|
||||
var (
|
||||
host = flag.String("host", "", "SoundTouch device host/IP address")
|
||||
port = flag.Int("port", 8090, "SoundTouch device port")
|
||||
timeout = flag.Duration("timeout", 10*time.Second, "Request timeout")
|
||||
discover = flag.Bool("discover", false, "Discover SoundTouch devices via UPnP")
|
||||
discoverAll = flag.Bool("discover-all", false, "Discover all SoundTouch devices and show info")
|
||||
info = flag.Bool("info", false, "Get device information")
|
||||
nowPlaying = flag.Bool("nowplaying", false, "Get current playback status")
|
||||
sources = flag.Bool("sources", false, "Get available audio sources")
|
||||
help = flag.Bool("help", false, "Show help")
|
||||
host = flag.String("host", "", "SoundTouch device host/IP address")
|
||||
port = flag.Int("port", 8090, "SoundTouch device port")
|
||||
timeout = flag.Duration("timeout", 10*time.Second, "Request timeout")
|
||||
discover = flag.Bool("discover", false, "Discover SoundTouch devices via UPnP")
|
||||
discoverAll = flag.Bool("discover-all", false, "Discover all SoundTouch devices and show info")
|
||||
info = flag.Bool("info", false, "Get device information")
|
||||
nowPlaying = flag.Bool("nowplaying", false, "Get current playback status")
|
||||
sources = flag.Bool("sources", false, "Get available audio sources")
|
||||
name = flag.Bool("name", false, "Get device name")
|
||||
capabilities = flag.Bool("capabilities", false, "Get device capabilities")
|
||||
presets = flag.Bool("presets", false, "Get configured presets")
|
||||
help = flag.Bool("help", false, "Show help")
|
||||
)
|
||||
|
||||
flag.Parse()
|
||||
@@ -35,7 +38,7 @@ func main() {
|
||||
}
|
||||
|
||||
// If no specific action is requested, show help
|
||||
if !*discover && !*discoverAll && !*info && !*nowPlaying && !*sources && *host == "" {
|
||||
if !*discover && !*discoverAll && !*info && !*nowPlaying && !*sources && !*name && !*capabilities && !*presets && *host == "" {
|
||||
printHelp()
|
||||
return
|
||||
}
|
||||
@@ -80,6 +83,39 @@ func main() {
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle name
|
||||
if *name {
|
||||
if *host == "" {
|
||||
log.Fatal("Host is required for name command. Use -host flag or -discover to find devices.")
|
||||
}
|
||||
if err := handleName(*host, *port, *timeout); err != nil {
|
||||
log.Fatalf("Failed to get device name: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle capabilities
|
||||
if *capabilities {
|
||||
if *host == "" {
|
||||
log.Fatal("Host is required for capabilities command. Use -host flag or -discover to find devices.")
|
||||
}
|
||||
if err := handleCapabilities(*host, *port, *timeout); err != nil {
|
||||
log.Fatalf("Failed to get device capabilities: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle presets
|
||||
if *presets {
|
||||
if *host == "" {
|
||||
log.Fatal("Host is required for presets command. Use -host flag or -discover to find devices.")
|
||||
}
|
||||
if err := handlePresets(*host, *port, *timeout); err != nil {
|
||||
log.Fatalf("Failed to get presets: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func printHelp() {
|
||||
@@ -97,6 +133,9 @@ func printHelp() {
|
||||
fmt.Println(" -info Get device information (requires -host)")
|
||||
fmt.Println(" -nowplaying Get current playback status (requires -host)")
|
||||
fmt.Println(" -sources Get available audio sources (requires -host)")
|
||||
fmt.Println(" -name Get device name (requires -host)")
|
||||
fmt.Println(" -capabilities Get device capabilities (requires -host)")
|
||||
fmt.Println(" -presets Get configured presets (requires -host)")
|
||||
fmt.Println(" -help Show this help message")
|
||||
fmt.Println()
|
||||
fmt.Println("Examples:")
|
||||
@@ -105,6 +144,9 @@ func printHelp() {
|
||||
fmt.Println(" soundtouch-cli -host 192.168.1.100 -info")
|
||||
fmt.Println(" soundtouch-cli -host 192.168.1.100 -nowplaying")
|
||||
fmt.Println(" soundtouch-cli -host 192.168.1.100 -sources")
|
||||
fmt.Println(" soundtouch-cli -host 192.168.1.100 -name")
|
||||
fmt.Println(" soundtouch-cli -host 192.168.1.100 -capabilities")
|
||||
fmt.Println(" soundtouch-cli -host 192.168.1.100 -presets")
|
||||
fmt.Println(" soundtouch-cli -host 192.168.1.100 -port 8090 -info")
|
||||
}
|
||||
|
||||
@@ -469,3 +511,216 @@ func handleSources(host string, port int, timeout time.Duration) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleName(host string, port int, timeout time.Duration) error {
|
||||
cfg, err := config.LoadFromEnv()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
|
||||
// Override config with command line arguments if provided
|
||||
if timeout > 0 {
|
||||
cfg.HTTPTimeout = timeout
|
||||
}
|
||||
|
||||
clientConfig := client.ClientConfig{
|
||||
Host: host,
|
||||
Port: port,
|
||||
Timeout: cfg.HTTPTimeout,
|
||||
UserAgent: cfg.UserAgent,
|
||||
}
|
||||
|
||||
soundtouchClient := client.NewClient(clientConfig)
|
||||
|
||||
fmt.Printf("Getting device name from %s:%d...\n", host, port)
|
||||
|
||||
// Get device name
|
||||
name, err := soundtouchClient.GetName()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get device name: %w", err)
|
||||
}
|
||||
|
||||
// Display name information
|
||||
fmt.Printf("Device Name: %s\n", name.GetName())
|
||||
|
||||
if name.IsEmpty() {
|
||||
fmt.Printf("Warning: Device name is empty\n")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleCapabilities(host string, port int, timeout time.Duration) error {
|
||||
cfg, err := config.LoadFromEnv()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
|
||||
// Override config with command line arguments if provided
|
||||
if timeout > 0 {
|
||||
cfg.HTTPTimeout = timeout
|
||||
}
|
||||
|
||||
clientConfig := client.ClientConfig{
|
||||
Host: host,
|
||||
Port: port,
|
||||
Timeout: cfg.HTTPTimeout,
|
||||
UserAgent: cfg.UserAgent,
|
||||
}
|
||||
|
||||
soundtouchClient := client.NewClient(clientConfig)
|
||||
|
||||
fmt.Printf("Getting device capabilities from %s:%d...\n", host, port)
|
||||
|
||||
// Get device capabilities
|
||||
capabilities, err := soundtouchClient.GetCapabilities()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get device capabilities: %w", err)
|
||||
}
|
||||
|
||||
// Display capabilities information
|
||||
fmt.Printf("Device Capabilities:\n")
|
||||
fmt.Printf(" Device ID: %s\n", capabilities.DeviceID)
|
||||
fmt.Println()
|
||||
|
||||
// System capabilities
|
||||
systemCaps := capabilities.GetSystemCapabilities()
|
||||
if len(systemCaps) > 0 {
|
||||
fmt.Printf("System Features:\n")
|
||||
for _, cap := range systemCaps {
|
||||
fmt.Printf(" • %s\n", cap)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// Audio capabilities
|
||||
audioCaps := capabilities.GetAudioCapabilities()
|
||||
if len(audioCaps) > 0 {
|
||||
fmt.Printf("Audio Features:\n")
|
||||
for _, cap := range audioCaps {
|
||||
fmt.Printf(" • %s\n", cap)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// Network capabilities
|
||||
networkCaps := capabilities.GetNetworkCapabilities()
|
||||
if len(networkCaps) > 0 {
|
||||
fmt.Printf("Network Features:\n")
|
||||
for _, cap := range networkCaps {
|
||||
fmt.Printf(" • %s\n", cap)
|
||||
}
|
||||
|
||||
// Show hosted wifi details if available
|
||||
if capabilities.HasHostedWifiConfig() {
|
||||
fmt.Printf(" Hosted WiFi Config:\n")
|
||||
fmt.Printf(" • Port: %s\n", capabilities.GetHostedWifiPort())
|
||||
fmt.Printf(" • Hosted by: %s\n", capabilities.GetHostedWifiHostedBy())
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// Extended capabilities
|
||||
capNames := capabilities.GetCapabilityNames()
|
||||
if len(capNames) > 0 {
|
||||
fmt.Printf("Extended Capabilities:\n")
|
||||
for _, capName := range capNames {
|
||||
cap := capabilities.GetCapabilityByName(capName)
|
||||
fmt.Printf(" • %s", capName)
|
||||
if cap.URL != "" {
|
||||
fmt.Printf(" (%s)", cap.URL)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func handlePresets(host string, port int, timeout time.Duration) error {
|
||||
cfg, err := config.LoadFromEnv()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
|
||||
// Override config with command line arguments if provided
|
||||
if timeout > 0 {
|
||||
cfg.HTTPTimeout = timeout
|
||||
}
|
||||
|
||||
clientConfig := client.ClientConfig{
|
||||
Host: host,
|
||||
Port: port,
|
||||
Timeout: cfg.HTTPTimeout,
|
||||
UserAgent: cfg.UserAgent,
|
||||
}
|
||||
|
||||
soundtouchClient := client.NewClient(clientConfig)
|
||||
|
||||
fmt.Printf("Getting configured presets from %s:%d...\n", host, port)
|
||||
|
||||
// Get presets
|
||||
presets, err := soundtouchClient.GetPresets()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get presets: %w", err)
|
||||
}
|
||||
|
||||
// Display presets information
|
||||
fmt.Printf("Configured Presets:\n")
|
||||
|
||||
if !presets.HasPresets() {
|
||||
fmt.Printf(" No presets configured\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
summary := presets.GetPresetsSummary()
|
||||
fmt.Printf(" Used Slots: %d/6\n", summary["used"])
|
||||
fmt.Printf(" Spotify Presets: %d\n", summary["spotify"])
|
||||
fmt.Println()
|
||||
|
||||
// Show each configured preset
|
||||
for _, preset := range presets.Preset {
|
||||
if preset.IsEmpty() {
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf("Preset %d: %s\n", preset.ID, preset.GetDisplayName())
|
||||
fmt.Printf(" Source: %s", preset.GetSource())
|
||||
if preset.GetSourceAccount() != "" {
|
||||
fmt.Printf(" (%s)", preset.GetSourceAccount())
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
if preset.GetContentType() != "" {
|
||||
fmt.Printf(" Type: %s\n", preset.GetContentType())
|
||||
}
|
||||
|
||||
if preset.HasTimestamps() {
|
||||
if !preset.GetCreatedTime().IsZero() {
|
||||
fmt.Printf(" Created: %s\n", preset.GetCreatedTime().Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
if !preset.GetUpdatedTime().IsZero() {
|
||||
fmt.Printf(" Updated: %s\n", preset.GetUpdatedTime().Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
}
|
||||
|
||||
if preset.GetArtworkURL() != "" {
|
||||
fmt.Printf(" Artwork: %s\n", preset.GetArtworkURL())
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// Show empty slots
|
||||
emptySlots := presets.GetEmptyPresetSlots()
|
||||
if len(emptySlots) > 0 {
|
||||
fmt.Printf("Available Slots: %v\n", emptySlots)
|
||||
}
|
||||
|
||||
// Show most recent preset
|
||||
if recent := presets.GetMostRecentPreset(); recent != nil {
|
||||
fmt.Printf("Most Recent: Preset %d (%s)\n", recent.ID, recent.GetDisplayName())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -78,6 +78,7 @@ When creating test data for API endpoints, prefer real device responses over hyp
|
||||
- **Usage**: Fetch real responses to create accurate test fixtures
|
||||
- **Privacy**: Anonymize any personal data (account names, personal playlists, etc.)
|
||||
- **Coverage**: Use multiple real devices to cover different response variations
|
||||
- **Non-responsive endpoints**: Some endpoints like `/trackInfo` may not respond or exist on all devices
|
||||
|
||||
## Additional Notes
|
||||
|
||||
|
||||
+10
-2
@@ -310,6 +310,9 @@ func (c Config) Validate() error
|
||||
- [x] DeviceInfo - Device information endpoint
|
||||
- [x] NowPlaying - Current playback status endpoint
|
||||
- [x] Sources - Available audio sources endpoint
|
||||
- [x] Name - Device name endpoint
|
||||
- [x] Capabilities - Device capabilities endpoint
|
||||
- [x] Presets - Configured presets endpoint
|
||||
- [x] Custom XML unmarshaling for enums
|
||||
- [x] Validation and defaults
|
||||
- [x] **CLI tool for testing** ✅ DONE
|
||||
@@ -318,6 +321,9 @@ func (c Config) Validate() error
|
||||
- [x] Device info retrieval
|
||||
- [x] Now playing status
|
||||
- [x] Audio sources listing
|
||||
- [x] Device name retrieval
|
||||
- [x] Device capabilities inspection
|
||||
- [x] Preset configuration listing
|
||||
- [x] **Unit tests with mocks** ✅ DONE
|
||||
- [x] HTTP client tests
|
||||
- [x] XML parsing tests
|
||||
@@ -659,11 +665,13 @@ docker-compose up # Mock devices + web app
|
||||
|
||||
### Phase 1-2 (Foundation)
|
||||
- ✅ Stable HTTP API connection to SoundTouch devices
|
||||
- ✅ XML model coverage for implemented APIs (DeviceInfo, NowPlaying, Sources)
|
||||
- ✅ XML model coverage for implemented APIs (DeviceInfo, NowPlaying, Sources, Name, Capabilities, Presets)
|
||||
- ✅ Automatic device discovery via UPnP
|
||||
- ✅ Functional CLI tool with discovery, info, now playing, and sources commands
|
||||
- ✅ Functional CLI tool with discovery, info, now playing, sources, name, capabilities, and presets commands
|
||||
- ✅ Now Playing endpoint with comprehensive status information
|
||||
- ✅ Sources endpoint with filtering and categorization features
|
||||
- ✅ Device identification endpoints (name, capabilities)
|
||||
- ✅ Preset management with comprehensive analysis and filtering
|
||||
|
||||
### Phase 3-4 (Real-time & Web)
|
||||
- ✅ WebSocket event streaming with reconnection
|
||||
|
||||
@@ -96,6 +96,36 @@ func (c *Client) GetSources() (*models.Sources, error) {
|
||||
return &sources, nil
|
||||
}
|
||||
|
||||
// GetName retrieves the device name from the /name endpoint
|
||||
func (c *Client) GetName() (*models.Name, error) {
|
||||
var name models.Name
|
||||
err := c.get("/name", &name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get device name: %w", err)
|
||||
}
|
||||
return &name, nil
|
||||
}
|
||||
|
||||
// GetCapabilities retrieves device capabilities from the /capabilities endpoint
|
||||
func (c *Client) GetCapabilities() (*models.Capabilities, error) {
|
||||
var capabilities models.Capabilities
|
||||
err := c.get("/capabilities", &capabilities)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get device capabilities: %w", err)
|
||||
}
|
||||
return &capabilities, nil
|
||||
}
|
||||
|
||||
// GetPresets retrieves configured presets from the /presets endpoint
|
||||
func (c *Client) GetPresets() (*models.Presets, error) {
|
||||
var presets models.Presets
|
||||
err := c.get("/presets", &presets)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get presets: %w", err)
|
||||
}
|
||||
return &presets, nil
|
||||
}
|
||||
|
||||
// Ping checks if the device is reachable by calling /info
|
||||
func (c *Client) Ping() error {
|
||||
_, err := c.GetDeviceInfo()
|
||||
|
||||
@@ -682,6 +682,366 @@ func TestClient_GetSources_InvalidXML(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_GetName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
responseFile string
|
||||
expectedError bool
|
||||
expectedName string
|
||||
}{
|
||||
{
|
||||
name: "valid device name",
|
||||
responseFile: "name_response.xml",
|
||||
expectedError: false,
|
||||
expectedName: "Sound Machinechen",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" {
|
||||
t.Errorf("Expected GET request, got %s", r.Method)
|
||||
}
|
||||
|
||||
if r.URL.Path != "/name" {
|
||||
t.Errorf("Expected path /name, got %s", r.URL.Path)
|
||||
}
|
||||
|
||||
// Check headers
|
||||
if userAgent := r.Header.Get("User-Agent"); userAgent == "" {
|
||||
t.Error("Expected User-Agent header to be set")
|
||||
}
|
||||
|
||||
if accept := r.Header.Get("Accept"); accept != "application/xml" {
|
||||
t.Errorf("Expected Accept header 'application/xml', got '%s'", accept)
|
||||
}
|
||||
|
||||
// Read test data
|
||||
data, err := os.ReadFile(filepath.Join("testdata", tt.responseFile))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read test data: %v", err)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(data)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Parse server URL to get host and port
|
||||
serverURL, err := url.Parse(server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse server URL: %v", err)
|
||||
}
|
||||
|
||||
host := serverURL.Hostname()
|
||||
port, _ := strconv.Atoi(serverURL.Port())
|
||||
|
||||
client := NewClient(ClientConfig{
|
||||
Host: host,
|
||||
Port: port,
|
||||
Timeout: 5 * time.Second,
|
||||
UserAgent: "test-client",
|
||||
})
|
||||
|
||||
name, err := client.GetName()
|
||||
|
||||
if tt.expectedError {
|
||||
if err == nil {
|
||||
t.Error("Expected error but got none")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if name == nil {
|
||||
t.Fatal("Expected Name response but got nil")
|
||||
}
|
||||
|
||||
if name.GetName() != tt.expectedName {
|
||||
t.Errorf("Expected name '%s', got '%s'", tt.expectedName, name.GetName())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_GetCapabilities(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
responseFile string
|
||||
expectedError bool
|
||||
expectedDevice string
|
||||
hasLRStereo bool
|
||||
hasDualMode bool
|
||||
hasWSAPIProxy bool
|
||||
}{
|
||||
{
|
||||
name: "valid capabilities",
|
||||
responseFile: "capabilities_response.xml",
|
||||
expectedError: false,
|
||||
expectedDevice: "A81B6A536A98",
|
||||
hasLRStereo: true,
|
||||
hasDualMode: true,
|
||||
hasWSAPIProxy: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" {
|
||||
t.Errorf("Expected GET request, got %s", r.Method)
|
||||
}
|
||||
|
||||
if r.URL.Path != "/capabilities" {
|
||||
t.Errorf("Expected path /capabilities, got %s", r.URL.Path)
|
||||
}
|
||||
|
||||
// Read test data
|
||||
data, err := os.ReadFile(filepath.Join("testdata", tt.responseFile))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read test data: %v", err)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(data)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Parse server URL to get host and port
|
||||
serverURL, err := url.Parse(server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse server URL: %v", err)
|
||||
}
|
||||
|
||||
host := serverURL.Hostname()
|
||||
port, _ := strconv.Atoi(serverURL.Port())
|
||||
|
||||
client := NewClient(ClientConfig{
|
||||
Host: host,
|
||||
Port: port,
|
||||
Timeout: 5 * time.Second,
|
||||
UserAgent: "test-client",
|
||||
})
|
||||
|
||||
capabilities, err := client.GetCapabilities()
|
||||
|
||||
if tt.expectedError {
|
||||
if err == nil {
|
||||
t.Error("Expected error but got none")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if capabilities == nil {
|
||||
t.Fatal("Expected Capabilities response but got nil")
|
||||
}
|
||||
|
||||
if capabilities.DeviceID != tt.expectedDevice {
|
||||
t.Errorf("Expected device ID '%s', got '%s'", tt.expectedDevice, capabilities.DeviceID)
|
||||
}
|
||||
|
||||
if capabilities.HasLRStereoCapability() != tt.hasLRStereo {
|
||||
t.Errorf("Expected HasLRStereoCapability() %v, got %v", tt.hasLRStereo, capabilities.HasLRStereoCapability())
|
||||
}
|
||||
|
||||
if capabilities.HasDualModeNetwork() != tt.hasDualMode {
|
||||
t.Errorf("Expected HasDualModeNetwork() %v, got %v", tt.hasDualMode, capabilities.HasDualModeNetwork())
|
||||
}
|
||||
|
||||
if capabilities.HasWSAPIProxy() != tt.hasWSAPIProxy {
|
||||
t.Errorf("Expected HasWSAPIProxy() %v, got %v", tt.hasWSAPIProxy, capabilities.HasWSAPIProxy())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_GetPresets(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
responseFile string
|
||||
expectedError bool
|
||||
expectedCount int
|
||||
expectedUsed int
|
||||
expectedSpotify int
|
||||
}{
|
||||
{
|
||||
name: "valid presets",
|
||||
responseFile: "presets_response.xml",
|
||||
expectedError: false,
|
||||
expectedCount: 6,
|
||||
expectedUsed: 6,
|
||||
expectedSpotify: 6,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" {
|
||||
t.Errorf("Expected GET request, got %s", r.Method)
|
||||
}
|
||||
|
||||
if r.URL.Path != "/presets" {
|
||||
t.Errorf("Expected path /presets, got %s", r.URL.Path)
|
||||
}
|
||||
|
||||
// Read test data
|
||||
data, err := os.ReadFile(filepath.Join("testdata", tt.responseFile))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read test data: %v", err)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(data)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Parse server URL to get host and port
|
||||
serverURL, err := url.Parse(server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse server URL: %v", err)
|
||||
}
|
||||
|
||||
host := serverURL.Hostname()
|
||||
port, _ := strconv.Atoi(serverURL.Port())
|
||||
|
||||
client := NewClient(ClientConfig{
|
||||
Host: host,
|
||||
Port: port,
|
||||
Timeout: 5 * time.Second,
|
||||
UserAgent: "test-client",
|
||||
})
|
||||
|
||||
presets, err := client.GetPresets()
|
||||
|
||||
if tt.expectedError {
|
||||
if err == nil {
|
||||
t.Error("Expected error but got none")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if presets == nil {
|
||||
t.Fatal("Expected Presets response but got nil")
|
||||
}
|
||||
|
||||
if presets.GetPresetCount() != tt.expectedCount {
|
||||
t.Errorf("Expected preset count %d, got %d", tt.expectedCount, presets.GetPresetCount())
|
||||
}
|
||||
|
||||
if len(presets.GetUsedPresetSlots()) != tt.expectedUsed {
|
||||
t.Errorf("Expected used count %d, got %d", tt.expectedUsed, len(presets.GetUsedPresetSlots()))
|
||||
}
|
||||
|
||||
if len(presets.GetSpotifyPresets()) != tt.expectedSpotify {
|
||||
t.Errorf("Expected Spotify count %d, got %d", tt.expectedSpotify, len(presets.GetSpotifyPresets()))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_GetName_ServerError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte("Internal Server Error"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
serverURL, _ := url.Parse(server.URL)
|
||||
host := serverURL.Hostname()
|
||||
port, _ := strconv.Atoi(serverURL.Port())
|
||||
|
||||
client := NewClient(ClientConfig{
|
||||
Host: host,
|
||||
Port: port,
|
||||
Timeout: 5 * time.Second,
|
||||
})
|
||||
|
||||
_, err := client.GetName()
|
||||
|
||||
if err == nil {
|
||||
t.Error("Expected error for server error response")
|
||||
}
|
||||
|
||||
expectedErrorMsg := "failed to get device name"
|
||||
if !strings.Contains(err.Error(), expectedErrorMsg) {
|
||||
t.Errorf("Expected error message to contain '%s', got '%s'", expectedErrorMsg, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_GetCapabilities_ServerError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte("Internal Server Error"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
serverURL, _ := url.Parse(server.URL)
|
||||
host := serverURL.Hostname()
|
||||
port, _ := strconv.Atoi(serverURL.Port())
|
||||
|
||||
client := NewClient(ClientConfig{
|
||||
Host: host,
|
||||
Port: port,
|
||||
Timeout: 5 * time.Second,
|
||||
})
|
||||
|
||||
_, err := client.GetCapabilities()
|
||||
|
||||
if err == nil {
|
||||
t.Error("Expected error for server error response")
|
||||
}
|
||||
|
||||
expectedErrorMsg := "failed to get device capabilities"
|
||||
if !strings.Contains(err.Error(), expectedErrorMsg) {
|
||||
t.Errorf("Expected error message to contain '%s', got '%s'", expectedErrorMsg, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_GetPresets_ServerError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte("Internal Server Error"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
serverURL, _ := url.Parse(server.URL)
|
||||
host := serverURL.Hostname()
|
||||
port, _ := strconv.Atoi(serverURL.Port())
|
||||
|
||||
client := NewClient(ClientConfig{
|
||||
Host: host,
|
||||
Port: port,
|
||||
Timeout: 5 * time.Second,
|
||||
})
|
||||
|
||||
_, err := client.GetPresets()
|
||||
|
||||
if err == nil {
|
||||
t.Error("Expected error for server error response")
|
||||
}
|
||||
|
||||
expectedErrorMsg := "failed to get presets"
|
||||
if !strings.Contains(err.Error(), expectedErrorMsg) {
|
||||
t.Errorf("Expected error message to contain '%s', got '%s'", expectedErrorMsg, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func loadTestData(t *testing.T, filename string) string {
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<capabilities deviceID="A81B6A536A98">
|
||||
<networkConfig>
|
||||
<dualMode>true</dualMode>
|
||||
<wsapiproxy>true</wsapiproxy>
|
||||
<allInterfacesSupported />
|
||||
<wlanInterfaces />
|
||||
<security />
|
||||
</networkConfig>
|
||||
<dspCapabilities>
|
||||
<dspMonoStereo available="false" />
|
||||
</dspCapabilities>
|
||||
<lightswitch>false</lightswitch>
|
||||
<clockDisplay>false</clockDisplay>
|
||||
<capability name="systemtimeout" url="/systemtimeout" info="" />
|
||||
<capability name="rebroadcastlatencymode" url="/rebroadcastlatencymode" info="" />
|
||||
<lrStereoCapable>true</lrStereoCapable>
|
||||
<bcoresetCapable>false</bcoresetCapable>
|
||||
<disablePowerSaving>true</disablePowerSaving>
|
||||
</capabilities>
|
||||
+1
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?><name>Sound Machinechen</name>
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<presets>
|
||||
<preset id="1" createdOn="1719128436" updatedOn="1728740382">
|
||||
<ContentItem source="SPOTIFY" type="tracklisturl" location="/playback/container/c3BvdGlmeTpwbGF5bGlzdDo1Mm5QaVJrbWVmSkZPeHh1M1ZTd1hh" sourceAccount="user@example.com" isPresetable="true">
|
||||
<itemName>My Playlist</itemName>
|
||||
<containerArt>https://i.scdn.co/image/ab67616d00001e025ff75c5d082fc50a3a74ad7b</containerArt>
|
||||
</ContentItem>
|
||||
</preset>
|
||||
<preset id="2" createdOn="1703353552" updatedOn="1743615710">
|
||||
<ContentItem source="SPOTIFY" type="tracklisturl" location="/playbook/container/c3BvdGlmeTpwbGF5bGlzdDoxV2dKT3EyWktYU1BTRGxDdWI1NERV" sourceAccount="user@example.com" isPresetable="true">
|
||||
<itemName>Chill Music Collection</itemName>
|
||||
<containerArt>https://i.scdn.co/image/ab67616d0000b273e07c8adc6fb49168dc8b7a2f</containerArt>
|
||||
</ContentItem>
|
||||
</preset>
|
||||
<preset id="3" createdOn="1585994240" updatedOn="1727112013">
|
||||
<ContentItem source="SPOTIFY" type="tracklisturl" location="/playbook/container/c3BvdGlmeTphbGJ1bTo3MnY3UTRtNkZ3bUlpWkV2QXZUY0hT" sourceAccount="user@example.com" isPresetable="true">
|
||||
<itemName>Kids Songs</itemName>
|
||||
<containerArt>https://i.scdn.co/image/ab67616d0000b273e32d3cf7356dacb162678d1f</containerArt>
|
||||
</ContentItem>
|
||||
</preset>
|
||||
<preset id="4" createdOn="1514712063" updatedOn="1745991460">
|
||||
<ContentItem source="SPOTIFY" type="tracklisturl" location="/playbook/container/c3BvdGlmeTphbGJ1bTo2QXI1SHhOV1h0dnJhcXM3Rkk3Yllx" sourceAccount="user@example.com" isPresetable="true">
|
||||
<itemName>Movie Soundtrack</itemName>
|
||||
<containerArt>https://i.scdn.co/image/ab67616d0000b273bc16d1eefe86b079c8805f8f</containerArt>
|
||||
</ContentItem>
|
||||
</preset>
|
||||
<preset id="5" createdOn="1509901730" updatedOn="1744629417">
|
||||
<ContentItem source="SPOTIFY" type="tracklisturl" location="/playbook/container/c3BvdGlmeTp0cmFjazo2M1RsOWsxc0g4dHpubjNicW9NdXlG" sourceAccount="user@example.com" isPresetable="true">
|
||||
<itemName>Pop Hits</itemName>
|
||||
<containerArt>https://i.scdn.co/image/ab67616d0000b2734214ddc9e33e76de6a8ee888</containerArt>
|
||||
</ContentItem>
|
||||
</preset>
|
||||
<preset id="6" createdOn="1585502139" updatedOn="1730021067">
|
||||
<ContentItem source="SPOTIFY" type="tracklisturl" location="/playbook/container/c3BvdGlmeTphbGJ1bTo3RjUwdWg3b0dpdG1BRVNjUktWNnBE" sourceAccount="user@example.com" isPresetable="true">
|
||||
<itemName>World Music</itemName>
|
||||
<containerArt>https://i.scdn.co/image/ab67616d0000b273f0e55478f4a15182405bcb47</containerArt>
|
||||
</ContentItem>
|
||||
</preset>
|
||||
</presets>
|
||||
@@ -0,0 +1,201 @@
|
||||
package models
|
||||
|
||||
import "encoding/xml"
|
||||
|
||||
// Capabilities represents the response from /capabilities endpoint
|
||||
type Capabilities struct {
|
||||
XMLName xml.Name `xml:"capabilities"`
|
||||
DeviceID string `xml:"deviceID,attr"`
|
||||
NetworkConfig *NetworkConfig `xml:"networkConfig,omitempty"`
|
||||
DSPConfig *DSPConfig `xml:"dspCapabilities,omitempty"`
|
||||
Lightswitch bool `xml:"lightswitch,omitempty"`
|
||||
ClockDisplay bool `xml:"clockDisplay,omitempty"`
|
||||
Capability []Capability `xml:"capability,omitempty"`
|
||||
LRStereo bool `xml:"lrStereoCapable,omitempty"`
|
||||
BCOReset bool `xml:"bcoresetCapable,omitempty"`
|
||||
PowerSaving bool `xml:"disablePowerSaving,omitempty"`
|
||||
}
|
||||
|
||||
// NetworkConfig represents network configuration capabilities
|
||||
type NetworkConfig struct {
|
||||
HostedWifiConfig *HostedWifiConfig `xml:"hostedWifiConfigWebPage,omitempty"`
|
||||
DualMode bool `xml:"dualMode,omitempty"`
|
||||
WSAPIProxy bool `xml:"wsapiproxy,omitempty"`
|
||||
AllInterfaceSupport *AllInterfaces `xml:"allInterfacesSupported,omitempty"`
|
||||
WLANInterfaces *WLANInterfaces `xml:"wlanInterfaces,omitempty"`
|
||||
Security *Security `xml:"security,omitempty"`
|
||||
}
|
||||
|
||||
// HostedWifiConfig represents hosted wifi configuration settings
|
||||
type HostedWifiConfig struct {
|
||||
HostedBy string `xml:"hostedBy,attr,omitempty"`
|
||||
Generation string `xml:"generation,attr,omitempty"`
|
||||
Port string `xml:"port,attr,omitempty"`
|
||||
Enabled bool `xml:",chardata"`
|
||||
}
|
||||
|
||||
// AllInterfaces represents all network interfaces support
|
||||
type AllInterfaces struct{}
|
||||
|
||||
// WLANInterfaces represents WLAN interfaces configuration
|
||||
type WLANInterfaces struct{}
|
||||
|
||||
// Security represents security configuration
|
||||
type Security struct{}
|
||||
|
||||
// DSPConfig represents DSP capabilities
|
||||
type DSPConfig struct {
|
||||
DSPMonoStereo *DSPMonoStereo `xml:"dspMonoStereo,omitempty"`
|
||||
}
|
||||
|
||||
// DSPMonoStereo represents mono/stereo DSP capability
|
||||
type DSPMonoStereo struct {
|
||||
Available bool `xml:"available,attr"`
|
||||
}
|
||||
|
||||
// Capability represents an individual device capability
|
||||
type Capability struct {
|
||||
Name string `xml:"name,attr"`
|
||||
URL string `xml:"url,attr"`
|
||||
Info string `xml:"info,attr"`
|
||||
}
|
||||
|
||||
// HasLightswitch returns true if the device has a light switch
|
||||
func (c *Capabilities) HasLightswitch() bool {
|
||||
return c.Lightswitch
|
||||
}
|
||||
|
||||
// HasClockDisplay returns true if the device has a clock display
|
||||
func (c *Capabilities) HasClockDisplay() bool {
|
||||
return c.ClockDisplay
|
||||
}
|
||||
|
||||
// HasLRStereoCapability returns true if the device supports left/right stereo
|
||||
func (c *Capabilities) HasLRStereoCapability() bool {
|
||||
return c.LRStereo
|
||||
}
|
||||
|
||||
// HasBCOResetCapability returns true if the device supports BCO reset
|
||||
func (c *Capabilities) HasBCOResetCapability() bool {
|
||||
return c.BCOReset
|
||||
}
|
||||
|
||||
// HasPowerSavingDisabled returns true if power saving is disabled
|
||||
func (c *Capabilities) HasPowerSavingDisabled() bool {
|
||||
return c.PowerSaving
|
||||
}
|
||||
|
||||
// HasDualModeNetwork returns true if the device supports dual mode networking
|
||||
func (c *Capabilities) HasDualModeNetwork() bool {
|
||||
return c.NetworkConfig != nil && c.NetworkConfig.DualMode
|
||||
}
|
||||
|
||||
// HasWSAPIProxy returns true if the device supports WSAPI proxy
|
||||
func (c *Capabilities) HasWSAPIProxy() bool {
|
||||
return c.NetworkConfig != nil && c.NetworkConfig.WSAPIProxy
|
||||
}
|
||||
|
||||
// HasHostedWifiConfig returns true if the device supports hosted wifi configuration
|
||||
func (c *Capabilities) HasHostedWifiConfig() bool {
|
||||
return c.NetworkConfig != nil &&
|
||||
c.NetworkConfig.HostedWifiConfig != nil &&
|
||||
c.NetworkConfig.HostedWifiConfig.Enabled
|
||||
}
|
||||
|
||||
// GetHostedWifiPort returns the hosted wifi configuration port
|
||||
func (c *Capabilities) GetHostedWifiPort() string {
|
||||
if c.HasHostedWifiConfig() {
|
||||
return c.NetworkConfig.HostedWifiConfig.Port
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetHostedWifiHostedBy returns who hosts the wifi configuration
|
||||
func (c *Capabilities) GetHostedWifiHostedBy() string {
|
||||
if c.HasHostedWifiConfig() {
|
||||
return c.NetworkConfig.HostedWifiConfig.HostedBy
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// HasDSPMonoStereo returns true if DSP mono/stereo is available
|
||||
func (c *Capabilities) HasDSPMonoStereo() bool {
|
||||
return c.DSPConfig != nil &&
|
||||
c.DSPConfig.DSPMonoStereo != nil &&
|
||||
c.DSPConfig.DSPMonoStereo.Available
|
||||
}
|
||||
|
||||
// GetCapabilityByName returns a capability by name
|
||||
func (c *Capabilities) GetCapabilityByName(name string) *Capability {
|
||||
for _, cap := range c.Capability {
|
||||
if cap.Name == name {
|
||||
return &cap
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasCapability returns true if the device has the specified capability
|
||||
func (c *Capabilities) HasCapability(name string) bool {
|
||||
return c.GetCapabilityByName(name) != nil
|
||||
}
|
||||
|
||||
// GetCapabilityNames returns a list of all capability names
|
||||
func (c *Capabilities) GetCapabilityNames() []string {
|
||||
names := make([]string, len(c.Capability))
|
||||
for i, cap := range c.Capability {
|
||||
names[i] = cap.Name
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// GetNetworkCapabilities returns a summary of network capabilities
|
||||
func (c *Capabilities) GetNetworkCapabilities() []string {
|
||||
var capabilities []string
|
||||
|
||||
if c.HasDualModeNetwork() {
|
||||
capabilities = append(capabilities, "Dual Mode")
|
||||
}
|
||||
if c.HasWSAPIProxy() {
|
||||
capabilities = append(capabilities, "WSAPI Proxy")
|
||||
}
|
||||
if c.HasHostedWifiConfig() {
|
||||
capabilities = append(capabilities, "Hosted WiFi Config")
|
||||
}
|
||||
|
||||
return capabilities
|
||||
}
|
||||
|
||||
// GetAudioCapabilities returns a summary of audio capabilities
|
||||
func (c *Capabilities) GetAudioCapabilities() []string {
|
||||
var capabilities []string
|
||||
|
||||
if c.HasLRStereoCapability() {
|
||||
capabilities = append(capabilities, "L/R Stereo")
|
||||
}
|
||||
if c.HasDSPMonoStereo() {
|
||||
capabilities = append(capabilities, "DSP Mono/Stereo")
|
||||
}
|
||||
|
||||
return capabilities
|
||||
}
|
||||
|
||||
// GetSystemCapabilities returns a summary of system capabilities
|
||||
func (c *Capabilities) GetSystemCapabilities() []string {
|
||||
var capabilities []string
|
||||
|
||||
if c.HasLightswitch() {
|
||||
capabilities = append(capabilities, "Light Switch")
|
||||
}
|
||||
if c.HasClockDisplay() {
|
||||
capabilities = append(capabilities, "Clock Display")
|
||||
}
|
||||
if c.HasBCOResetCapability() {
|
||||
capabilities = append(capabilities, "BCO Reset")
|
||||
}
|
||||
if c.HasPowerSavingDisabled() {
|
||||
capabilities = append(capabilities, "Power Saving Disabled")
|
||||
}
|
||||
|
||||
return capabilities
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestName_UnmarshalXML(t *testing.T) {
|
||||
xmlData := `<?xml version="1.0" encoding="UTF-8" ?><name>Sound Machinechen</name>`
|
||||
|
||||
var name Name
|
||||
err := xml.Unmarshal([]byte(xmlData), &name)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to unmarshal XML: %v", err)
|
||||
}
|
||||
|
||||
if name.Value != "Sound Machinechen" {
|
||||
t.Errorf("Expected name 'Sound Machinechen', got '%s'", name.Value)
|
||||
}
|
||||
|
||||
if name.GetName() != "Sound Machinechen" {
|
||||
t.Errorf("Expected GetName() 'Sound Machinechen', got '%s'", name.GetName())
|
||||
}
|
||||
|
||||
if name.String() != "Sound Machinechen" {
|
||||
t.Errorf("Expected String() 'Sound Machinechen', got '%s'", name.String())
|
||||
}
|
||||
|
||||
if name.IsEmpty() {
|
||||
t.Error("Expected IsEmpty() to return false for non-empty name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestName_EmptyName(t *testing.T) {
|
||||
xmlData := `<?xml version="1.0" encoding="UTF-8" ?><name></name>`
|
||||
|
||||
var name Name
|
||||
err := xml.Unmarshal([]byte(xmlData), &name)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to unmarshal XML: %v", err)
|
||||
}
|
||||
|
||||
if !name.IsEmpty() {
|
||||
t.Error("Expected IsEmpty() to return true for empty name")
|
||||
}
|
||||
|
||||
if name.GetName() != "" {
|
||||
t.Errorf("Expected GetName() to return empty string, got '%s'", name.GetName())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapabilities_UnmarshalXML(t *testing.T) {
|
||||
xmlData := `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<capabilities deviceID="A81B6A536A98">
|
||||
<networkConfig>
|
||||
<dualMode>true</dualMode>
|
||||
<wsapiproxy>true</wsapiproxy>
|
||||
<allInterfacesSupported />
|
||||
<wlanInterfaces />
|
||||
<security />
|
||||
</networkConfig>
|
||||
<dspCapabilities>
|
||||
<dspMonoStereo available="false" />
|
||||
</dspCapabilities>
|
||||
<lightswitch>false</lightswitch>
|
||||
<clockDisplay>false</clockDisplay>
|
||||
<capability name="systemtimeout" url="/systemtimeout" info="" />
|
||||
<capability name="rebroadcastlatencymode" url="/rebroadcastlatencymode" info="" />
|
||||
<lrStereoCapable>true</lrStereoCapable>
|
||||
<bcoresetCapable>false</bcoresetCapable>
|
||||
<disablePowerSaving>true</disablePowerSaving>
|
||||
</capabilities>`
|
||||
|
||||
var capabilities Capabilities
|
||||
err := xml.Unmarshal([]byte(xmlData), &capabilities)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to unmarshal XML: %v", err)
|
||||
}
|
||||
|
||||
// Test basic fields
|
||||
if capabilities.DeviceID != "A81B6A536A98" {
|
||||
t.Errorf("Expected DeviceID 'A81B6A536A98', got '%s'", capabilities.DeviceID)
|
||||
}
|
||||
|
||||
// Test boolean capabilities
|
||||
if capabilities.HasLightswitch() {
|
||||
t.Error("Expected HasLightswitch() to return false")
|
||||
}
|
||||
|
||||
if capabilities.HasClockDisplay() {
|
||||
t.Error("Expected HasClockDisplay() to return false")
|
||||
}
|
||||
|
||||
if !capabilities.HasLRStereoCapability() {
|
||||
t.Error("Expected HasLRStereoCapability() to return true")
|
||||
}
|
||||
|
||||
if capabilities.HasBCOResetCapability() {
|
||||
t.Error("Expected HasBCOResetCapability() to return false")
|
||||
}
|
||||
|
||||
if !capabilities.HasPowerSavingDisabled() {
|
||||
t.Error("Expected HasPowerSavingDisabled() to return true")
|
||||
}
|
||||
|
||||
// Test network capabilities
|
||||
if !capabilities.HasDualModeNetwork() {
|
||||
t.Error("Expected HasDualModeNetwork() to return true")
|
||||
}
|
||||
|
||||
if !capabilities.HasWSAPIProxy() {
|
||||
t.Error("Expected HasWSAPIProxy() to return true")
|
||||
}
|
||||
|
||||
// Test DSP capabilities
|
||||
if capabilities.HasDSPMonoStereo() {
|
||||
t.Error("Expected HasDSPMonoStereo() to return false")
|
||||
}
|
||||
|
||||
// Test capability by name
|
||||
if !capabilities.HasCapability("systemtimeout") {
|
||||
t.Error("Expected to have systemtimeout capability")
|
||||
}
|
||||
|
||||
if capabilities.HasCapability("nonexistent") {
|
||||
t.Error("Expected to not have nonexistent capability")
|
||||
}
|
||||
|
||||
// Test capability names
|
||||
capNames := capabilities.GetCapabilityNames()
|
||||
if len(capNames) != 2 {
|
||||
t.Errorf("Expected 2 capability names, got %d", len(capNames))
|
||||
}
|
||||
|
||||
// Test summaries
|
||||
networkCaps := capabilities.GetNetworkCapabilities()
|
||||
expectedNetworkCaps := []string{"Dual Mode", "WSAPI Proxy"}
|
||||
if len(networkCaps) != len(expectedNetworkCaps) {
|
||||
t.Errorf("Expected %d network capabilities, got %d", len(expectedNetworkCaps), len(networkCaps))
|
||||
}
|
||||
|
||||
audioCaps := capabilities.GetAudioCapabilities()
|
||||
expectedAudioCaps := []string{"L/R Stereo"}
|
||||
if len(audioCaps) != len(expectedAudioCaps) {
|
||||
t.Errorf("Expected %d audio capabilities, got %d", len(expectedAudioCaps), len(audioCaps))
|
||||
}
|
||||
|
||||
systemCaps := capabilities.GetSystemCapabilities()
|
||||
expectedSystemCaps := []string{"Power Saving Disabled"}
|
||||
if len(systemCaps) != len(expectedSystemCaps) {
|
||||
t.Errorf("Expected %d system capabilities, got %d", len(expectedSystemCaps), len(systemCaps))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapabilities_HostedWifiConfig(t *testing.T) {
|
||||
xmlData := `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<capabilities deviceID="1234567890AB">
|
||||
<networkConfig>
|
||||
<hostedWifiConfigWebPage hostedBy="BCO" generation="1" port="80">true</hostedWifiConfigWebPage>
|
||||
<wsapiproxy>false</wsapiproxy>
|
||||
<allInterfacesSupported />
|
||||
<wlanInterfaces />
|
||||
<security />
|
||||
</networkConfig>
|
||||
<lightswitch>true</lightswitch>
|
||||
<clockDisplay>true</clockDisplay>
|
||||
</capabilities>`
|
||||
|
||||
var capabilities Capabilities
|
||||
err := xml.Unmarshal([]byte(xmlData), &capabilities)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to unmarshal XML: %v", err)
|
||||
}
|
||||
|
||||
if !capabilities.HasHostedWifiConfig() {
|
||||
t.Error("Expected HasHostedWifiConfig() to return true")
|
||||
}
|
||||
|
||||
if capabilities.GetHostedWifiPort() != "80" {
|
||||
t.Errorf("Expected hosted wifi port '80', got '%s'", capabilities.GetHostedWifiPort())
|
||||
}
|
||||
|
||||
if capabilities.GetHostedWifiHostedBy() != "BCO" {
|
||||
t.Errorf("Expected hosted wifi hosted by 'BCO', got '%s'", capabilities.GetHostedWifiHostedBy())
|
||||
}
|
||||
|
||||
if !capabilities.HasLightswitch() {
|
||||
t.Error("Expected HasLightswitch() to return true")
|
||||
}
|
||||
|
||||
if !capabilities.HasClockDisplay() {
|
||||
t.Error("Expected HasClockDisplay() to return true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresets_UnmarshalXML(t *testing.T) {
|
||||
xmlData := `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<presets>
|
||||
<preset id="1" createdOn="1719128436" updatedOn="1728740382">
|
||||
<ContentItem source="SPOTIFY" type="tracklisturl" location="/playback/container/abc123" sourceAccount="user@example.com" isPresetable="true">
|
||||
<itemName>My Playlist</itemName>
|
||||
<containerArt>https://i.scdn.co/image/ab67616d00001e025ff75c5d082fc50a3a74ad7b</containerArt>
|
||||
</ContentItem>
|
||||
</preset>
|
||||
<preset id="2" createdOn="1703353552" updatedOn="1743615710">
|
||||
<ContentItem source="SPOTIFY" type="tracklisturl" location="/playback/container/def456" sourceAccount="user@example.com" isPresetable="true">
|
||||
<itemName>Chill Music Collection</itemName>
|
||||
<containerArt>https://i.scdn.co/image/ab67616d0000b273e07c8adc6fb49168dc8b7a2f</containerArt>
|
||||
</ContentItem>
|
||||
</preset>
|
||||
<preset id="3">
|
||||
<ContentItem source="TUNEIN" type="stationurl" location="http://stream.example.com" isPresetable="true">
|
||||
<itemName>Radio Station</itemName>
|
||||
</ContentItem>
|
||||
</preset>
|
||||
</presets>`
|
||||
|
||||
var presets Presets
|
||||
err := xml.Unmarshal([]byte(xmlData), &presets)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to unmarshal XML: %v", err)
|
||||
}
|
||||
|
||||
// Test basic structure
|
||||
if presets.GetPresetCount() != 3 {
|
||||
t.Errorf("Expected 3 presets, got %d", presets.GetPresetCount())
|
||||
}
|
||||
|
||||
// Test first preset
|
||||
preset1 := presets.GetPresetByID(1)
|
||||
if preset1 == nil {
|
||||
t.Fatal("Expected to find preset with ID 1")
|
||||
}
|
||||
|
||||
if preset1.GetDisplayName() != "My Playlist" {
|
||||
t.Errorf("Expected display name 'My Playlist', got '%s'", preset1.GetDisplayName())
|
||||
}
|
||||
|
||||
if !preset1.IsSpotifyPreset() {
|
||||
t.Error("Expected preset 1 to be a Spotify preset")
|
||||
}
|
||||
|
||||
if preset1.GetSource() != "SPOTIFY" {
|
||||
t.Errorf("Expected source 'SPOTIFY', got '%s'", preset1.GetSource())
|
||||
}
|
||||
|
||||
if preset1.GetSourceAccount() != "user@example.com" {
|
||||
t.Errorf("Expected source account 'user@example.com', got '%s'", preset1.GetSourceAccount())
|
||||
}
|
||||
|
||||
if !preset1.HasTimestamps() {
|
||||
t.Error("Expected preset 1 to have timestamps")
|
||||
}
|
||||
|
||||
// Test timestamps
|
||||
expectedCreated := time.Unix(1719128436, 0)
|
||||
if !preset1.GetCreatedTime().Equal(expectedCreated) {
|
||||
t.Errorf("Expected created time %v, got %v", expectedCreated, preset1.GetCreatedTime())
|
||||
}
|
||||
|
||||
expectedUpdated := time.Unix(1728740382, 0)
|
||||
if !preset1.GetUpdatedTime().Equal(expectedUpdated) {
|
||||
t.Errorf("Expected updated time %v, got %v", expectedUpdated, preset1.GetUpdatedTime())
|
||||
}
|
||||
|
||||
// Test third preset (TuneIn radio)
|
||||
preset3 := presets.GetPresetByID(3)
|
||||
if preset3 == nil {
|
||||
t.Fatal("Expected to find preset with ID 3")
|
||||
}
|
||||
|
||||
if preset3.IsSpotifyPreset() {
|
||||
t.Error("Expected preset 3 to not be a Spotify preset")
|
||||
}
|
||||
|
||||
if preset3.GetSource() != "TUNEIN" {
|
||||
t.Errorf("Expected source 'TUNEIN', got '%s'", preset3.GetSource())
|
||||
}
|
||||
|
||||
if preset3.HasTimestamps() {
|
||||
t.Error("Expected preset 3 to not have timestamps")
|
||||
}
|
||||
|
||||
// Test filtering methods
|
||||
spotifyPresets := presets.GetSpotifyPresets()
|
||||
if len(spotifyPresets) != 2 {
|
||||
t.Errorf("Expected 2 Spotify presets, got %d", len(spotifyPresets))
|
||||
}
|
||||
|
||||
tuneinPresets := presets.GetPresetsBySource("TUNEIN")
|
||||
if len(tuneinPresets) != 1 {
|
||||
t.Errorf("Expected 1 TuneIn preset, got %d", len(tuneinPresets))
|
||||
}
|
||||
|
||||
// Test empty slots
|
||||
emptySlots := presets.GetEmptyPresetSlots()
|
||||
expectedEmpty := []int{4, 5, 6}
|
||||
if len(emptySlots) != len(expectedEmpty) {
|
||||
t.Errorf("Expected %d empty slots, got %d", len(expectedEmpty), len(emptySlots))
|
||||
}
|
||||
|
||||
// Test used slots
|
||||
usedSlots := presets.GetUsedPresetSlots()
|
||||
expectedUsed := []int{1, 2, 3}
|
||||
if len(usedSlots) != len(expectedUsed) {
|
||||
t.Errorf("Expected %d used slots, got %d", len(expectedUsed), len(usedSlots))
|
||||
}
|
||||
|
||||
// Test summary
|
||||
summary := presets.GetPresetsSummary()
|
||||
if summary["total"] != 3 {
|
||||
t.Errorf("Expected total 3, got %d", summary["total"])
|
||||
}
|
||||
if summary["used"] != 3 {
|
||||
t.Errorf("Expected used 3, got %d", summary["used"])
|
||||
}
|
||||
if summary["spotify"] != 2 {
|
||||
t.Errorf("Expected spotify 2, got %d", summary["spotify"])
|
||||
}
|
||||
if summary["SPOTIFY"] != 2 {
|
||||
t.Errorf("Expected SPOTIFY 2, got %d", summary["SPOTIFY"])
|
||||
}
|
||||
if summary["TUNEIN"] != 1 {
|
||||
t.Errorf("Expected TUNEIN 1, got %d", summary["TUNEIN"])
|
||||
}
|
||||
|
||||
// Test most recent preset
|
||||
mostRecent := presets.GetMostRecentPreset()
|
||||
if mostRecent == nil {
|
||||
t.Fatal("Expected to find most recent preset")
|
||||
}
|
||||
if mostRecent.ID != 2 {
|
||||
t.Errorf("Expected most recent preset ID 2, got %d", mostRecent.ID)
|
||||
}
|
||||
|
||||
// Test oldest preset
|
||||
oldest := presets.GetOldestPreset()
|
||||
if oldest == nil {
|
||||
t.Fatal("Expected to find oldest preset")
|
||||
}
|
||||
if oldest.ID != 2 {
|
||||
t.Errorf("Expected oldest preset ID 2, got %d", oldest.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresets_EmptyPresets(t *testing.T) {
|
||||
xmlData := `<?xml version="1.0" encoding="UTF-8" ?><presets></presets>`
|
||||
|
||||
var presets Presets
|
||||
err := xml.Unmarshal([]byte(xmlData), &presets)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to unmarshal XML: %v", err)
|
||||
}
|
||||
|
||||
if presets.HasPresets() {
|
||||
t.Error("Expected HasPresets() to return false for empty presets")
|
||||
}
|
||||
|
||||
if presets.GetPresetCount() != 0 {
|
||||
t.Errorf("Expected preset count 0, got %d", presets.GetPresetCount())
|
||||
}
|
||||
|
||||
emptySlots := presets.GetEmptyPresetSlots()
|
||||
expectedSlots := []int{1, 2, 3, 4, 5, 6}
|
||||
if len(emptySlots) != len(expectedSlots) {
|
||||
t.Errorf("Expected %d empty slots, got %d", len(expectedSlots), len(emptySlots))
|
||||
}
|
||||
|
||||
if presets.GetMostRecentPreset() != nil {
|
||||
t.Error("Expected GetMostRecentPreset() to return nil for empty presets")
|
||||
}
|
||||
|
||||
if presets.GetOldestPreset() != nil {
|
||||
t.Error("Expected GetOldestPreset() to return nil for empty presets")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreset_EdgeCases(t *testing.T) {
|
||||
// Test preset without ContentItem
|
||||
emptyPreset := Preset{ID: 1}
|
||||
|
||||
if !emptyPreset.IsEmpty() {
|
||||
t.Error("Expected IsEmpty() to return true for preset without ContentItem")
|
||||
}
|
||||
|
||||
if emptyPreset.GetDisplayName() != "Preset 1" {
|
||||
t.Errorf("Expected display name 'Preset 1', got '%s'", emptyPreset.GetDisplayName())
|
||||
}
|
||||
|
||||
if emptyPreset.GetSource() != "" {
|
||||
t.Errorf("Expected empty source, got '%s'", emptyPreset.GetSource())
|
||||
}
|
||||
|
||||
if emptyPreset.GetArtworkURL() != "" {
|
||||
t.Errorf("Expected empty artwork URL, got '%s'", emptyPreset.GetArtworkURL())
|
||||
}
|
||||
|
||||
if emptyPreset.IsSpotifyPreset() {
|
||||
t.Error("Expected IsSpotifyPreset() to return false for empty preset")
|
||||
}
|
||||
|
||||
// Test preset with ContentItem but no artwork
|
||||
presetNoArt := Preset{
|
||||
ID: 2,
|
||||
ContentItem: &ContentItem{
|
||||
Source: "TUNEIN",
|
||||
ItemName: "Test Station",
|
||||
},
|
||||
}
|
||||
|
||||
if presetNoArt.GetArtworkURL() != "" {
|
||||
t.Errorf("Expected empty artwork URL, got '%s'", presetNoArt.GetArtworkURL())
|
||||
}
|
||||
|
||||
if presetNoArt.GetDisplayName() != "Test Station" {
|
||||
t.Errorf("Expected display name 'Test Station', got '%s'", presetNoArt.GetDisplayName())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package models
|
||||
|
||||
import "encoding/xml"
|
||||
|
||||
// Name represents the response from /name endpoint
|
||||
type Name struct {
|
||||
XMLName xml.Name `xml:"name"`
|
||||
Value string `xml:",chardata"`
|
||||
}
|
||||
|
||||
// GetName returns the device name
|
||||
func (n *Name) GetName() string {
|
||||
return n.Value
|
||||
}
|
||||
|
||||
// IsEmpty returns true if the name is empty
|
||||
func (n *Name) IsEmpty() bool {
|
||||
return n.Value == ""
|
||||
}
|
||||
|
||||
// String returns the device name as a string
|
||||
func (n *Name) String() string {
|
||||
return n.Value
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Presets represents the response from /presets endpoint
|
||||
type Presets struct {
|
||||
XMLName xml.Name `xml:"presets"`
|
||||
Preset []Preset `xml:"preset"`
|
||||
}
|
||||
|
||||
// Preset represents an individual preset
|
||||
type Preset struct {
|
||||
ID int `xml:"id,attr"`
|
||||
CreatedOn *int64 `xml:"createdOn,attr,omitempty"`
|
||||
UpdatedOn *int64 `xml:"updatedOn,attr,omitempty"`
|
||||
ContentItem *ContentItem `xml:"ContentItem,omitempty"`
|
||||
}
|
||||
|
||||
// GetCreatedTime returns the creation time as a time.Time
|
||||
func (p *Preset) GetCreatedTime() time.Time {
|
||||
if p.CreatedOn != nil {
|
||||
return time.Unix(*p.CreatedOn, 0)
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// GetUpdatedTime returns the last updated time as a time.Time
|
||||
func (p *Preset) GetUpdatedTime() time.Time {
|
||||
if p.UpdatedOn != nil {
|
||||
return time.Unix(*p.UpdatedOn, 0)
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// HasTimestamps returns true if the preset has creation/update timestamps
|
||||
func (p *Preset) HasTimestamps() bool {
|
||||
return p.CreatedOn != nil || p.UpdatedOn != nil
|
||||
}
|
||||
|
||||
// GetDisplayName returns the best available display name for the preset
|
||||
func (p *Preset) GetDisplayName() string {
|
||||
if p.ContentItem != nil && p.ContentItem.ItemName != "" {
|
||||
return p.ContentItem.ItemName
|
||||
}
|
||||
return "Preset " + strconv.Itoa(p.ID)
|
||||
}
|
||||
|
||||
// GetArtworkURL returns the artwork URL if available
|
||||
func (p *Preset) GetArtworkURL() string {
|
||||
if p.ContentItem != nil && p.ContentItem.ContainerArt != "" {
|
||||
return p.ContentItem.ContainerArt
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// IsSpotifyPreset returns true if this is a Spotify preset
|
||||
func (p *Preset) IsSpotifyPreset() bool {
|
||||
return p.ContentItem != nil && p.ContentItem.Source == "SPOTIFY"
|
||||
}
|
||||
|
||||
// IsEmpty returns true if the preset has no content
|
||||
func (p *Preset) IsEmpty() bool {
|
||||
return p.ContentItem == nil
|
||||
}
|
||||
|
||||
// GetSource returns the source of the preset content
|
||||
func (p *Preset) GetSource() string {
|
||||
if p.ContentItem != nil {
|
||||
return p.ContentItem.Source
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetSourceAccount returns the source account of the preset content
|
||||
func (p *Preset) GetSourceAccount() string {
|
||||
if p.ContentItem != nil {
|
||||
return p.ContentItem.SourceAccount
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetContentType returns the content type of the preset
|
||||
func (p *Preset) GetContentType() string {
|
||||
if p.ContentItem != nil {
|
||||
return p.ContentItem.Type
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetLocation returns the content location/URL
|
||||
func (p *Preset) GetLocation() string {
|
||||
if p.ContentItem != nil {
|
||||
return p.ContentItem.Location
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// IsPresetable returns true if the content can be saved as a preset
|
||||
func (p *Preset) IsPresetable() bool {
|
||||
return p.ContentItem != nil && p.ContentItem.IsPresetable
|
||||
}
|
||||
|
||||
// GetPresetCount returns the total number of presets
|
||||
func (ps *Presets) GetPresetCount() int {
|
||||
return len(ps.Preset)
|
||||
}
|
||||
|
||||
// GetPresetByID returns a preset by its ID
|
||||
func (ps *Presets) GetPresetByID(id int) *Preset {
|
||||
for _, preset := range ps.Preset {
|
||||
if preset.ID == id {
|
||||
return &preset
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSpotifyPresets returns all Spotify presets
|
||||
func (ps *Presets) GetSpotifyPresets() []Preset {
|
||||
var spotify []Preset
|
||||
for _, preset := range ps.Preset {
|
||||
if preset.IsSpotifyPreset() {
|
||||
spotify = append(spotify, preset)
|
||||
}
|
||||
}
|
||||
return spotify
|
||||
}
|
||||
|
||||
// GetPresetsBySource returns presets filtered by source
|
||||
func (ps *Presets) GetPresetsBySource(source string) []Preset {
|
||||
var filtered []Preset
|
||||
for _, preset := range ps.Preset {
|
||||
if preset.GetSource() == source {
|
||||
filtered = append(filtered, preset)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// GetEmptyPresetSlots returns preset IDs that are empty (1-6)
|
||||
func (ps *Presets) GetEmptyPresetSlots() []int {
|
||||
var empty []int
|
||||
used := make(map[int]bool)
|
||||
|
||||
// Mark used slots
|
||||
for _, preset := range ps.Preset {
|
||||
if !preset.IsEmpty() {
|
||||
used[preset.ID] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Find empty slots (1-6 are typical preset slots)
|
||||
for i := 1; i <= 6; i++ {
|
||||
if !used[i] {
|
||||
empty = append(empty, i)
|
||||
}
|
||||
}
|
||||
|
||||
return empty
|
||||
}
|
||||
|
||||
// HasPresets returns true if there are any presets configured
|
||||
func (ps *Presets) HasPresets() bool {
|
||||
for _, preset := range ps.Preset {
|
||||
if !preset.IsEmpty() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetUsedPresetSlots returns preset IDs that have content
|
||||
func (ps *Presets) GetUsedPresetSlots() []int {
|
||||
var used []int
|
||||
for _, preset := range ps.Preset {
|
||||
if !preset.IsEmpty() {
|
||||
used = append(used, preset.ID)
|
||||
}
|
||||
}
|
||||
return used
|
||||
}
|
||||
|
||||
// GetMostRecentPreset returns the most recently updated preset
|
||||
func (ps *Presets) GetMostRecentPreset() *Preset {
|
||||
var mostRecent *Preset
|
||||
var latestTime int64
|
||||
|
||||
for _, preset := range ps.Preset {
|
||||
if preset.UpdatedOn != nil && *preset.UpdatedOn > latestTime {
|
||||
latestTime = *preset.UpdatedOn
|
||||
mostRecent = &preset
|
||||
} else if preset.CreatedOn != nil && preset.UpdatedOn == nil && *preset.CreatedOn > latestTime {
|
||||
latestTime = *preset.CreatedOn
|
||||
mostRecent = &preset
|
||||
}
|
||||
}
|
||||
|
||||
return mostRecent
|
||||
}
|
||||
|
||||
// GetOldestPreset returns the oldest preset
|
||||
func (ps *Presets) GetOldestPreset() *Preset {
|
||||
var oldest *Preset
|
||||
var earliestTime int64 = 9223372036854775807 // max int64
|
||||
|
||||
for _, preset := range ps.Preset {
|
||||
if preset.CreatedOn != nil && *preset.CreatedOn < earliestTime {
|
||||
earliestTime = *preset.CreatedOn
|
||||
oldest = &preset
|
||||
}
|
||||
}
|
||||
|
||||
return oldest
|
||||
}
|
||||
|
||||
// GetPresetsSummary returns a summary of preset usage
|
||||
func (ps *Presets) GetPresetsSummary() map[string]int {
|
||||
summary := map[string]int{
|
||||
"total": ps.GetPresetCount(),
|
||||
"used": len(ps.GetUsedPresetSlots()),
|
||||
"empty": len(ps.GetEmptyPresetSlots()),
|
||||
"spotify": len(ps.GetSpotifyPresets()),
|
||||
}
|
||||
|
||||
// Count by source
|
||||
sources := make(map[string]int)
|
||||
for _, preset := range ps.Preset {
|
||||
if !preset.IsEmpty() {
|
||||
source := preset.GetSource()
|
||||
sources[source]++
|
||||
}
|
||||
}
|
||||
|
||||
// Add source counts to summary
|
||||
for source, count := range sources {
|
||||
summary[source] = count
|
||||
}
|
||||
|
||||
return summary
|
||||
}
|
||||
Reference in New Issue
Block a user