diff --git a/README.md b/README.md
index 6b43e99..a5a8eff 100644
--- a/README.md
+++ b/README.md
@@ -218,7 +218,9 @@ This library supports all Bose SoundTouch-compatible devices, including:
| Media Control | ✅ Complete | Play/pause/stop, track navigation |
| Volume & Audio | ✅ Complete | Volume, bass, balance control |
| Source Selection | ✅ Complete | Spotify, Bluetooth, AUX, etc. |
-| Preset Management | ✅ Complete | Read preset configurations |
+| Content Navigation | ✅ Complete | Browse music libraries, radio stations |
+| Station Management | ✅ Complete | Search, add, remove stations |
+| Preset Management | ✅ Complete | Store, select, remove presets |
| Real-time Events | ✅ Complete | WebSocket event streaming |
| Multiroom Zones | ✅ Complete | Zone creation and management |
| System Settings | ✅ Complete | Clock, display, network info |
@@ -232,6 +234,8 @@ This library supports all Bose SoundTouch-compatible devices, including:
- 📚 [API Reference](docs/API-Endpoints-Overview.md) - Complete endpoint documentation
- 🔧 [CLI Reference](docs/CLI-REFERENCE.md) - Command-line tool guide
- 🎯 [Getting Started](docs/GETTING-STARTED.md) - Detailed setup and usage
+- 🧭 [Navigation Guide](docs/NAVIGATION-GUIDE.md) - Content browsing and station management
+- 📋 [Navigation API Reference](docs/API-NAVIGATION-REFERENCE.md) - Navigation API documentation
- ⚙️ [Advanced Features](docs/SYSTEM-ENDPOINTS.md) - Advanced functionality
- 🏠 [Multiroom Setup](docs/zone-management.md) - Zone configuration guide
- ⚡ [WebSocket Events](docs/websocket-events.md) - Real-time event handling
diff --git a/docs/API-NAVIGATION-REFERENCE.md b/docs/API-NAVIGATION-REFERENCE.md
new file mode 100644
index 0000000..85270e3
--- /dev/null
+++ b/docs/API-NAVIGATION-REFERENCE.md
@@ -0,0 +1,809 @@
+# Navigation API Reference
+
+## Overview
+
+This document provides a complete API reference for the Bose SoundTouch navigation and station management functionality. For usage examples and workflows, see [NAVIGATION-GUIDE.md](NAVIGATION-GUIDE.md).
+
+## Table of Contents
+
+- [Client Methods](#client-methods)
+- [Models](#models)
+- [HTTP Endpoints](#http-endpoints)
+- [XML Schemas](#xml-schemas)
+- [Error Codes](#error-codes)
+
+## Client Methods
+
+### Navigation Methods
+
+#### `Navigate(source, sourceAccount string, startItem, numItems int) (*models.NavigateResponse, error)`
+
+Browse content within a source.
+
+**Parameters:**
+- `source` (string, required): Content source identifier
+ - Valid values: `"TUNEIN"`, `"PANDORA"`, `"SPOTIFY"`, `"STORED_MUSIC"`, `"BLUETOOTH"`, `"AUX"`
+- `sourceAccount` (string, optional): Account identifier for authenticated sources
+- `startItem` (int, required): Starting position (1-based index)
+- `numItems` (int, required): Number of items to retrieve
+
+**Returns:**
+- `*models.NavigateResponse`: Navigation results with items and metadata
+- `error`: Error if request fails
+
+**Example:**
+```go
+response, err := client.Navigate("TUNEIN", "", 1, 25)
+```
+
+**Validation:**
+- `source` cannot be empty
+- `startItem` must be >= 1
+- `numItems` must be >= 1
+
+---
+
+#### `NavigateWithMenu(source, sourceAccount, menu, sort string, startItem, numItems int) (*models.NavigateResponse, error)`
+
+Browse content with specific menu and sorting options (primarily for Pandora).
+
+**Parameters:**
+- `source` (string, required): Content source identifier
+- `sourceAccount` (string, optional): Account identifier
+- `menu` (string, optional): Menu context (e.g., `"radioStations"`)
+- `sort` (string, optional): Sort order (e.g., `"dateCreated"`)
+- `startItem` (int, required): Starting position (1-based)
+- `numItems` (int, required): Number of items to retrieve
+
+**Returns:**
+- `*models.NavigateResponse`: Navigation results
+- `error`: Error if request fails
+
+**Example:**
+```go
+response, err := client.NavigateWithMenu("PANDORA", "user123", "radioStations", "dateCreated", 1, 100)
+```
+
+---
+
+#### `NavigateContainer(source, sourceAccount string, startItem, numItems int, containerItem *models.ContentItem) (*models.NavigateResponse, error)`
+
+Browse into a specific container/directory.
+
+**Parameters:**
+- `source` (string, required): Content source identifier
+- `sourceAccount` (string, optional): Account identifier
+- `startItem` (int, required): Starting position (1-based)
+- `numItems` (int, required): Number of items to retrieve
+- `containerItem` (*models.ContentItem, required): Container to browse into
+
+**Returns:**
+- `*models.NavigateResponse`: Container contents
+- `error`: Error if request fails
+
+**Example:**
+```go
+response, err := client.NavigateContainer("STORED_MUSIC", "device/0", 1, 100, albumContentItem)
+```
+
+**Validation:**
+- `containerItem` cannot be nil
+- Container must have valid `Location` field
+
+---
+
+### Convenience Navigation Methods
+
+#### `GetTuneInStations(sourceAccount string) (*models.NavigateResponse, error)`
+
+Browse TuneIn radio stations.
+
+**Parameters:**
+- `sourceAccount` (string, optional): TuneIn account (usually empty)
+
+**Returns:**
+- `*models.NavigateResponse`: TuneIn stations and content
+
+**Example:**
+```go
+stations, err := client.GetTuneInStations("")
+```
+
+---
+
+#### `GetPandoraStations(sourceAccount string) (*models.NavigateResponse, error)`
+
+Browse Pandora radio stations with proper sorting.
+
+**Parameters:**
+- `sourceAccount` (string, required): Pandora user account identifier
+
+**Returns:**
+- `*models.NavigateResponse`: Pandora stations sorted by creation date
+
+**Example:**
+```go
+stations, err := client.GetPandoraStations("user123")
+```
+
+**Validation:**
+- `sourceAccount` cannot be empty
+
+---
+
+#### `GetStoredMusicLibrary(sourceAccount string) (*models.NavigateResponse, error)`
+
+Browse stored/local music library.
+
+**Parameters:**
+- `sourceAccount` (string, required): Device account identifier (format: `deviceID/index`)
+
+**Returns:**
+- `*models.NavigateResponse`: Music library root contents
+
+**Example:**
+```go
+library, err := client.GetStoredMusicLibrary("A81B6A536A98/0")
+```
+
+**Validation:**
+- `sourceAccount` cannot be empty
+
+---
+
+### Search Methods
+
+#### `SearchStation(source, sourceAccount, searchTerm string) (*models.SearchStationResponse, error)`
+
+Search for stations and content within a music service.
+
+**Parameters:**
+- `source` (string, required): Service to search
+- `sourceAccount` (string, optional): Account identifier
+- `searchTerm` (string, required): Search query
+
+**Returns:**
+- `*models.SearchStationResponse`: Search results categorized by type
+- `error`: Error if request fails
+
+**Example:**
+```go
+results, err := client.SearchStation("PANDORA", "user123", "jazz")
+```
+
+**Validation:**
+- `source` cannot be empty
+- `searchTerm` cannot be empty
+
+---
+
+#### `SearchTuneInStations(searchTerm string) (*models.SearchStationResponse, error)`
+
+Search TuneIn radio stations.
+
+**Parameters:**
+- `searchTerm` (string, required): Search query
+
+**Returns:**
+- `*models.SearchStationResponse`: TuneIn search results
+
+**Example:**
+```go
+results, err := client.SearchTuneInStations("classical music")
+```
+
+---
+
+#### `SearchPandoraStations(sourceAccount, searchTerm string) (*models.SearchStationResponse, error)`
+
+Search Pandora for artists and stations.
+
+**Parameters:**
+- `sourceAccount` (string, required): Pandora account identifier
+- `searchTerm` (string, required): Artist or genre to search for
+
+**Returns:**
+- `*models.SearchStationResponse`: Pandora search results with songs, artists, stations
+
+**Example:**
+```go
+results, err := client.SearchPandoraStations("user123", "Taylor Swift")
+```
+
+**Validation:**
+- `sourceAccount` cannot be empty
+
+---
+
+#### `SearchSpotifyContent(sourceAccount, searchTerm string) (*models.SearchStationResponse, error)`
+
+Search Spotify for tracks, albums, and playlists.
+
+**Parameters:**
+- `sourceAccount` (string, required): Spotify account identifier
+- `searchTerm` (string, required): Content to search for
+
+**Returns:**
+- `*models.SearchStationResponse`: Spotify search results
+
+**Example:**
+```go
+results, err := client.SearchSpotifyContent("user@example.com", "Queen")
+```
+
+**Validation:**
+- `sourceAccount` cannot be empty
+
+---
+
+### Station Management Methods
+
+#### `AddStation(source, sourceAccount, token, name string) error`
+
+Add a station to music service collection and immediately start playing it.
+
+**Parameters:**
+- `source` (string, required): Music service identifier
+- `sourceAccount` (string, optional): Account identifier
+- `token` (string, required): Station token from search results
+- `name` (string, required): Display name for the station
+
+**Returns:**
+- `error`: Error if operation fails
+
+**Example:**
+```go
+err := client.AddStation("PANDORA", "user123", "R4328162", "Classic Rock Radio")
+```
+
+**Behavior:**
+- Station is immediately selected and starts playing
+- Station is added to user's collection permanently
+- Generates `presetsUpdated` WebSocket event if station is stored as preset
+
+**Validation:**
+- `source` cannot be empty
+- `token` cannot be empty
+- `name` cannot be empty
+
+---
+
+#### `RemoveStation(contentItem *models.ContentItem) error`
+
+Remove a station from music service collection.
+
+**Parameters:**
+- `contentItem` (*models.ContentItem, required): Station content item with source and location
+
+**Returns:**
+- `error`: Error if operation fails
+
+**Example:**
+```go
+err := client.RemoveStation(stationContentItem)
+```
+
+**Behavior:**
+- Station is removed from user's collection
+- If station is currently playing, playback stops
+- Generates `nowPlayingUpdated` WebSocket event if playing station was removed
+
+**Validation:**
+- `contentItem` cannot be nil
+- `contentItem.Source` cannot be empty
+- `contentItem.Location` cannot be empty
+
+---
+
+## Models
+
+### NavigateRequest
+
+Request structure for `/navigate` endpoint.
+
+```go
+type NavigateRequest struct {
+ Source string `xml:"source,attr"`
+ SourceAccount string `xml:"sourceAccount,attr,omitempty"`
+ Menu string `xml:"menu,attr,omitempty"`
+ Sort string `xml:"sort,attr,omitempty"`
+ StartItem int `xml:"startItem"`
+ NumItems int `xml:"numItems"`
+ Item *NavigateItem `xml:"item,omitempty"`
+}
+```
+
+**Constructors:**
+- `NewNavigateRequest(source, sourceAccount string, startItem, numItems int)`
+- `NewNavigateRequestWithMenu(source, sourceAccount, menu, sort string, startItem, numItems int)`
+- `NewNavigateRequestWithItem(source, sourceAccount string, startItem, numItems int, item *ContentItem)`
+
+---
+
+### NavigateResponse
+
+Response structure from navigation operations.
+
+```go
+type NavigateResponse struct {
+ Source string `xml:"source,attr"`
+ SourceAccount string `xml:"sourceAccount,attr,omitempty"`
+ TotalItems int `xml:"totalItems"`
+ Items []NavigateItem `xml:"items>item"`
+}
+```
+
+**Helper Methods:**
+- `GetPlayableItems() []NavigateItem` - Filter items with `Playable="1"`
+- `GetDirectories() []NavigateItem` - Filter directory items (`type="dir"`)
+- `GetTracks() []NavigateItem` - Filter track items (`type="track"`)
+- `GetStations() []NavigateItem` - Filter station items (`type="stationurl"`)
+- `IsEmpty() bool` - Check if response has no items
+
+---
+
+### NavigateItem
+
+Individual item within navigation response.
+
+```go
+type NavigateItem struct {
+ Playable int `xml:"Playable,attr,omitempty"`
+ Name string `xml:"name"`
+ Type string `xml:"type"`
+ ContentItem *ContentItem `xml:"ContentItem,omitempty"`
+ MediaItemContainer *MediaItemContainer `xml:"mediaItemContainer,omitempty"`
+ ArtistName string `xml:"artistName,omitempty"`
+ AlbumName string `xml:"albumName,omitempty"`
+}
+```
+
+**Helper Methods:**
+- `GetDisplayName() string` - Get formatted display name
+- `IsPlayable() bool` - Check if `Playable="1"`
+- `IsDirectory() bool` - Check if `type="dir"`
+- `IsTrack() bool` - Check if `type="track"`
+- `IsStation() bool` - Check if `type="stationurl"`
+- `GetContentItem() *ContentItem` - Get associated content item
+- `GetArtwork() string` - Get artwork URL from content item
+
+**Common Type Values:**
+- `"dir"` - Directory/container
+- `"track"` - Music track
+- `"stationurl"` - Radio station
+- `"playlist"` - Playlist
+- `"album"` - Album
+
+---
+
+### SearchStationRequest
+
+Request structure for station search.
+
+```go
+type SearchStationRequest struct {
+ Source string `xml:"source,attr"`
+ SourceAccount string `xml:"sourceAccount,attr,omitempty"`
+ SearchTerm string `xml:",chardata"`
+}
+```
+
+**Constructor:**
+- `NewSearchStationRequest(source, sourceAccount, searchTerm string)`
+
+---
+
+### SearchStationResponse
+
+Response structure from search operations.
+
+```go
+type SearchStationResponse struct {
+ DeviceID string `xml:"deviceID,attr"`
+ Source string `xml:"source,attr"`
+ SourceAccount string `xml:"sourceAccount,attr,omitempty"`
+ Songs []SearchResult `xml:"songs>searchResult"`
+ Artists []SearchResult `xml:"artists>searchResult"`
+ Stations []SearchResult `xml:"stations>searchResult"`
+}
+```
+
+**Helper Methods:**
+- `GetSongs() []SearchResult` - Get song results
+- `GetArtists() []SearchResult` - Get artist results
+- `GetStations() []SearchResult` - Get station results
+- `GetAllResults() []SearchResult` - Get all results combined
+- `GetResultCount() int` - Count total results
+- `HasResults() bool` - Check if any results found
+- `IsEmpty() bool` - Check if no results
+
+---
+
+### SearchResult
+
+Individual search result item.
+
+```go
+type SearchResult struct {
+ Source string `xml:"source,attr"`
+ SourceAccount string `xml:"sourceAccount,attr,omitempty"`
+ Token string `xml:"token,attr"`
+ Name string `xml:"name"`
+ Artist string `xml:"artist,omitempty"`
+ Album string `xml:"album,omitempty"`
+ Logo string `xml:"logo,omitempty"`
+ Description string `xml:"description,omitempty"`
+}
+```
+
+**Helper Methods:**
+- `IsSong() bool` - Check if result is a song (has `Artist` field)
+- `IsArtist() bool` - Check if result is an artist (no `Artist` or `Description`)
+- `IsStation() bool` - Check if result is a station (has `Description`)
+- `GetDisplayName() string` - Get formatted name
+- `GetFullTitle() string` - Get name with artist for songs
+- `GetArtworkURL() string` - Get logo/artwork URL
+
+**Token Usage:**
+The `Token` field is used with `AddStation()` to add the result to your collection.
+
+---
+
+### AddStationRequest
+
+Request structure for adding stations.
+
+```go
+type AddStationRequest struct {
+ Source string `xml:"source,attr"`
+ SourceAccount string `xml:"sourceAccount,attr,omitempty"`
+ Token string `xml:"token,attr"`
+ Name string `xml:"name"`
+}
+```
+
+**Constructor:**
+- `NewAddStationRequest(source, sourceAccount, token, name string)`
+
+---
+
+### StationResponse
+
+Response structure from station management operations.
+
+```go
+type StationResponse struct {
+ Status string `xml:",chardata"`
+}
+```
+
+**Common Values:**
+- `"/addStation"` - Station added successfully
+- `"/removeStation"` - Station removed successfully
+
+---
+
+## HTTP Endpoints
+
+### POST /navigate
+
+Browse content within a source.
+
+**Request Body:**
+```xml
+
+ 1
+ 25
+
+```
+
+**Response Body:**
+```xml
+
+ 5
+
+ -
+ Station Name
+ stationurl
+
+ Station Name
+
+
+
+
+```
+
+---
+
+### POST /searchStation
+
+Search for stations and content.
+
+**Request Body:**
+```xml
+Taylor Swift
+```
+
+**Response Body:**
+```xml
+
+
+
+ Love Story
+ Taylor Swift
+ http://example.com/artwork.jpg
+
+
+
+
+ Taylor Swift
+ http://example.com/artist.jpg
+
+
+
+```
+
+---
+
+### POST /addStation
+
+Add a station to collection and start playing.
+
+**Request Body:**
+```xml
+
+ Taylor Swift Radio
+
+```
+
+**Response Body:**
+```xml
+/addStation
+```
+
+---
+
+### POST /removeStation
+
+Remove a station from collection.
+
+**Request Body:**
+```xml
+
+ Taylor Swift Radio
+
+```
+
+**Response Body:**
+```xml
+/removeStation
+```
+
+---
+
+## XML Schemas
+
+### Navigate Request Schema
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+### Search Request Schema
+
+```xml
+
+
+
+
+
+
+
+
+
+
+```
+
+### ContentItem Type Schema
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+```
+
+---
+
+## Error Codes
+
+### HTTP Status Codes
+
+| Status | Meaning | Description |
+|--------|---------|-------------|
+| 200 | OK | Request successful |
+| 400 | Bad Request | Invalid parameters or XML |
+| 404 | Not Found | Endpoint or content not found |
+| 500 | Internal Server Error | Device error |
+
+### Common Error Responses
+
+**Invalid Source:**
+```xml
+
+ INVALID_SOURCE
+ Source 'INVALID' is not available
+
+```
+
+**Authentication Required:**
+```xml
+
+ AUTH_REQUIRED
+ Source account required for this service
+
+```
+
+**Service Unavailable:**
+```xml
+
+ SERVICE_UNAVAILABLE
+ PANDORA service is not configured
+
+```
+
+### Client-Side Validation Errors
+
+The Go client performs validation before sending requests:
+
+| Error Message | Cause | Solution |
+|---------------|-------|----------|
+| `"source cannot be empty"` | Empty source parameter | Provide valid source |
+| `"search term cannot be empty"` | Empty search query | Provide search term |
+| `"startItem must be >= 1"` | Invalid start position | Use 1-based indexing |
+| `"numItems must be >= 1"` | Invalid page size | Use positive number |
+| `"content item cannot be nil"` | Nil ContentItem | Provide valid ContentItem |
+| `"container item cannot be nil"` | Nil container for NavigateContainer | Provide valid container |
+| `"Pandora source account cannot be empty"` | Missing Pandora account | Configure Pandora account |
+| `"token cannot be empty"` | Missing station token | Use token from search results |
+| `"station name cannot be empty"` | Missing station name | Provide station name |
+
+---
+
+## WebSocket Events
+
+Navigation and station operations generate WebSocket events:
+
+### presetsUpdated
+
+Generated when stations are added/removed that affect presets.
+
+```xml
+
+
+
+
+
+```
+
+### nowPlayingUpdated
+
+Generated when station operations affect current playback.
+
+```xml
+
+
+
+ Taylor Swift Radio
+
+
+ Taylor Swift
+ PLAY_STATE
+
+
+```
+
+---
+
+## Best Practices
+
+### Parameter Validation
+
+Always validate parameters before API calls:
+
+```go
+func validateNavigateParams(source string, startItem, numItems int) error {
+ if source == "" {
+ return fmt.Errorf("source cannot be empty")
+ }
+ if startItem < 1 {
+ return fmt.Errorf("startItem must be >= 1")
+ }
+ if numItems < 1 {
+ return fmt.Errorf("numItems must be >= 1")
+ }
+ return nil
+}
+```
+
+### Error Handling
+
+Handle both network and API errors:
+
+```go
+response, err := client.Navigate("TUNEIN", "", 1, 25)
+if err != nil {
+ // Check if it's a known API error
+ if strings.Contains(err.Error(), "not available") {
+ log.Printf("TuneIn not configured on device")
+ return
+ }
+ return fmt.Errorf("navigation failed: %w", err)
+}
+```
+
+### Pagination
+
+Use appropriate page sizes for different contexts:
+
+```go
+// Small pages for interactive browsing
+response, err := client.Navigate("TUNEIN", "", 1, 25)
+
+// Larger pages for bulk processing
+response, err := client.Navigate("STORED_MUSIC", "device/0", 1, 100)
+```
+
+### Resource Management
+
+Cache frequently accessed data:
+
+```go
+type CachedClient struct {
+ client *client.Client
+ sources *models.Sources
+ sourcesTime time.Time
+}
+
+func (c *CachedClient) GetSources() (*models.Sources, error) {
+ if c.sources == nil || time.Since(c.sourcesTime) > 5*time.Minute {
+ var err error
+ c.sources, err = c.client.GetSources()
+ c.sourcesTime = time.Now()
+ return c.sources, err
+ }
+ return c.sources, nil
+}
+```
+
+---
+
+*For complete usage examples and workflows, see [NAVIGATION-GUIDE.md](NAVIGATION-GUIDE.md).*
\ No newline at end of file
diff --git a/docs/NAVIGATION-GUIDE.md b/docs/NAVIGATION-GUIDE.md
new file mode 100644
index 0000000..f122e3d
--- /dev/null
+++ b/docs/NAVIGATION-GUIDE.md
@@ -0,0 +1,898 @@
+# Navigation and Station Management Guide
+
+## Overview
+
+The Bose SoundTouch Go client provides comprehensive navigation and station management functionality that allows you to:
+
+- **Browse content sources** (TuneIn, Pandora, Spotify, stored music)
+- **Search for stations and content** across music services
+- **Add stations and immediately play them**
+- **Remove stations from collections**
+- **Navigate directory structures** in music libraries
+
+This guide provides complete examples and best practices for using these features.
+
+## Table of Contents
+
+- [Quick Start](#quick-start)
+- [Content Navigation](#content-navigation)
+- [Station Search](#station-search)
+- [Station Management](#station-management)
+- [Complete Workflows](#complete-workflows)
+- [Error Handling](#error-handling)
+- [Best Practices](#best-practices)
+- [API Reference](#api-reference)
+
+## Quick Start
+
+### Basic Setup
+
+```go
+package main
+
+import (
+ "fmt"
+ "log"
+
+ "github.com/gesellix/bose-soundtouch/pkg/client"
+)
+
+func main() {
+ // Create client
+ config := &client.Config{
+ Host: "192.168.1.100",
+ Port: 8090,
+ }
+ soundtouch := client.NewClient(config)
+
+ // Your navigation code here...
+}
+```
+
+### Simple Navigation Example
+
+```go
+// Browse TuneIn content
+response, err := soundtouch.Navigate("TUNEIN", "", 1, 25)
+if err != nil {
+ log.Fatal(err)
+}
+
+fmt.Printf("Found %d items\n", response.TotalItems)
+for _, item := range response.Items {
+ fmt.Printf("- %s (%s)\n", item.GetDisplayName(), item.Type)
+}
+```
+
+## Content Navigation
+
+### Browse Different Sources
+
+```go
+// Browse TuneIn radio stations
+tuneInStations, err := soundtouch.GetTuneInStations("")
+if err != nil {
+ log.Printf("TuneIn not available: %v", err)
+} else {
+ fmt.Printf("TuneIn has %d items\n", tuneInStations.TotalItems)
+}
+
+// Browse Pandora stations (requires account)
+pandoraStations, err := soundtouch.GetPandoraStations("your_pandora_account")
+if err != nil {
+ log.Printf("Pandora not available: %v", err)
+} else {
+ stations := pandoraStations.GetStations()
+ fmt.Printf("Found %d Pandora stations\n", len(stations))
+}
+
+// Browse stored music library
+musicLibrary, err := soundtouch.GetStoredMusicLibrary("device_account/0")
+if err != nil {
+ log.Printf("Stored music not available: %v", err)
+} else {
+ directories := musicLibrary.GetDirectories()
+ tracks := musicLibrary.GetTracks()
+ fmt.Printf("Music library: %d dirs, %d tracks\n", len(directories), len(tracks))
+}
+```
+
+### Navigate Into Directories
+
+```go
+// First, get the root level
+musicLibrary, err := soundtouch.GetStoredMusicLibrary("device_account/0")
+if err != nil {
+ log.Fatal(err)
+}
+
+// Find a directory to browse into
+directories := musicLibrary.GetDirectories()
+if len(directories) == 0 {
+ fmt.Println("No directories found")
+ return
+}
+
+// Navigate into the first directory
+directory := directories[0]
+fmt.Printf("Browsing into: %s\n", directory.GetDisplayName())
+
+contents, err := soundtouch.NavigateContainer(
+ "STORED_MUSIC",
+ "device_account/0",
+ 1, 100, // Get up to 100 items starting from position 1
+ directory.ContentItem,
+)
+if err != nil {
+ log.Fatal(err)
+}
+
+// Show what's inside
+tracks := contents.GetTracks()
+subdirs := contents.GetDirectories()
+fmt.Printf("Found %d tracks and %d subdirectories\n", len(tracks), len(subdirs))
+
+// List first few tracks
+for i, track := range tracks[:min(5, len(tracks))] {
+ fmt.Printf("%d. %s", i+1, track.GetDisplayName())
+ if track.ArtistName != "" {
+ fmt.Printf(" - %s", track.ArtistName)
+ }
+ if track.AlbumName != "" {
+ fmt.Printf(" [%s]", track.AlbumName)
+ }
+ fmt.Println()
+}
+```
+
+### Advanced Navigation with Pagination
+
+```go
+// Browse large collections with pagination
+const pageSize = 50
+startItem := 1
+
+for {
+ response, err := soundtouch.Navigate("STORED_MUSIC", "device/0", startItem, pageSize)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ if len(response.Items) == 0 {
+ break // No more items
+ }
+
+ fmt.Printf("Page starting at %d: %d items\n", startItem, len(response.Items))
+
+ // Process this page
+ for _, item := range response.Items {
+ fmt.Printf(" %s (%s)\n", item.GetDisplayName(), item.Type)
+ }
+
+ // Move to next page
+ startItem += pageSize
+
+ // Stop if we've seen all items
+ if startItem > response.TotalItems {
+ break
+ }
+}
+```
+
+## Station Search
+
+### Basic Search
+
+```go
+// Search TuneIn for jazz stations
+results, err := soundtouch.SearchTuneInStations("jazz")
+if err != nil {
+ log.Fatal(err)
+}
+
+fmt.Printf("Found %d total results for 'jazz'\n", results.GetResultCount())
+
+// Show different types of results
+songs := results.GetSongs()
+artists := results.GetArtists()
+stations := results.GetStations()
+
+fmt.Printf("Songs: %d, Artists: %d, Stations: %d\n",
+ len(songs), len(artists), len(stations))
+```
+
+### Service-Specific Search
+
+```go
+// Search Pandora (requires account)
+pandoraResults, err := soundtouch.SearchPandoraStations("your_account", "Taylor Swift")
+if err != nil {
+ log.Fatal(err)
+}
+
+// Show artists found
+artists := pandoraResults.GetArtists()
+for _, artist := range artists {
+ fmt.Printf("Artist: %s (Token: %s)\n", artist.Name, artist.Token)
+ if artist.Logo != "" {
+ fmt.Printf(" Artwork: %s\n", artist.GetArtworkURL())
+ }
+}
+
+// Search Spotify content
+spotifyResults, err := soundtouch.SearchSpotifyContent("your_spotify_account", "Queen")
+if err != nil {
+ log.Fatal(err)
+}
+
+songs := spotifyResults.GetSongs()
+for _, song := range songs[:min(5, len(songs))] {
+ fmt.Printf("Song: %s\n", song.GetFullTitle())
+}
+```
+
+### Search Result Analysis
+
+```go
+results, err := soundtouch.SearchPandoraStations("account", "classic rock")
+if err != nil {
+ log.Fatal(err)
+}
+
+// Analyze all results
+for _, result := range results.GetAllResults() {
+ fmt.Printf("Name: %s, Token: %s\n", result.GetDisplayName(), result.Token)
+
+ // Determine result type
+ switch {
+ case result.IsSong():
+ fmt.Printf(" Type: Song by %s\n", result.Artist)
+ case result.IsArtist():
+ fmt.Printf(" Type: Artist\n")
+ case result.IsStation():
+ fmt.Printf(" Type: Station")
+ if result.Description != "" {
+ fmt.Printf(" - %s", result.Description)
+ }
+ fmt.Println()
+ }
+}
+```
+
+## Station Management
+
+### Adding Stations (Immediate Playback)
+
+```go
+// Search for content first
+results, err := soundtouch.SearchPandoraStations("your_account", "Led Zeppelin")
+if err != nil {
+ log.Fatal(err)
+}
+
+// Find an artist to create a station from
+artists := results.GetArtists()
+if len(artists) == 0 {
+ fmt.Println("No artists found")
+ return
+}
+
+artist := artists[0]
+stationName := artist.Name + " Radio"
+
+// Add station - this immediately starts playing it!
+err = soundtouch.AddStation("PANDORA", "your_account", artist.Token, stationName)
+if err != nil {
+ log.Fatal(err)
+}
+
+fmt.Printf("✓ Added and now playing: %s\n", stationName)
+
+// The station is now:
+// 1. Added to your Pandora collection
+// 2. Currently playing on the device
+```
+
+### Removing Stations
+
+```go
+// First, get existing stations
+stations, err := soundtouch.GetPandoraStations("your_account")
+if err != nil {
+ log.Fatal(err)
+}
+
+// Show current stations
+fmt.Printf("Current stations (%d):\n", len(stations.Items))
+for i, station := range stations.Items {
+ fmt.Printf("%d. %s\n", i+1, station.GetDisplayName())
+}
+
+// Remove a specific station (example: remove the first one)
+if len(stations.Items) > 0 {
+ stationToRemove := stations.Items[0]
+
+ if stationToRemove.ContentItem != nil {
+ fmt.Printf("Removing: %s\n", stationToRemove.GetDisplayName())
+
+ err := soundtouch.RemoveStation(stationToRemove.ContentItem)
+ if err != nil {
+ log.Printf("Failed to remove station: %v", err)
+ } else {
+ fmt.Println("✓ Station removed successfully")
+ }
+ }
+}
+```
+
+### Station Collection Management
+
+```go
+// Get current collection
+currentStations, err := soundtouch.GetPandoraStations("your_account")
+if err != nil {
+ log.Fatal(err)
+}
+
+fmt.Printf("Current collection has %d stations\n", len(currentStations.Items))
+
+// Search for new content
+searchResults, err := soundtouch.SearchPandoraStations("your_account", "indie rock")
+if err != nil {
+ log.Fatal(err)
+}
+
+// Add top 3 artist stations
+artists := searchResults.GetArtists()
+for i, artist := range artists[:min(3, len(artists))] {
+ stationName := fmt.Sprintf("%s Radio", artist.Name)
+
+ fmt.Printf("Adding station %d: %s\n", i+1, stationName)
+
+ err := soundtouch.AddStation("PANDORA", "your_account", artist.Token, stationName)
+ if err != nil {
+ log.Printf("Failed to add %s: %v", stationName, err)
+ continue
+ }
+
+ fmt.Printf("✓ Added: %s\n", stationName)
+
+ // Note: Each AddStation immediately starts playing that station
+ // You might want to pause between additions in a real app
+}
+
+fmt.Println("Station collection updated!")
+```
+
+## Complete Workflows
+
+### Discover and Play Workflow
+
+```go
+func discoverAndPlayWorkflow(soundtouch *client.Client) {
+ fmt.Println("=== Discover and Play Workflow ===")
+
+ // Step 1: Search for content
+ searchTerm := "electronic music"
+ fmt.Printf("🔍 Searching for '%s'...\n", searchTerm)
+
+ results, err := soundtouch.SearchTuneInStations(searchTerm)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ if results.IsEmpty() {
+ fmt.Println("❌ No results found")
+ return
+ }
+
+ // Step 2: Show options
+ stations := results.GetStations()
+ fmt.Printf("📻 Found %d stations:\n", len(stations))
+
+ for i, station := range stations[:min(5, len(stations))] {
+ fmt.Printf("%d. %s", i+1, station.GetDisplayName())
+ if station.Description != "" {
+ fmt.Printf(" - %s", station.Description)
+ }
+ fmt.Println()
+ }
+
+ // Step 3: Select and play (example: select first one)
+ if len(stations) > 0 {
+ selectedStation := stations[0]
+ fmt.Printf("🎵 Playing: %s\n", selectedStation.GetDisplayName())
+
+ // For services that support it, add the station to play it
+ if selectedStation.Token != "" {
+ err := soundtouch.AddStation("TUNEIN", "", selectedStation.Token, selectedStation.Name)
+ if err != nil {
+ log.Printf("Could not add station: %v", err)
+ } else {
+ fmt.Println("✓ Station added and playing!")
+ }
+ }
+ }
+}
+```
+
+### Library Organization Workflow
+
+```go
+func organizeLibraryWorkflow(soundtouch *client.Client, deviceAccount string) {
+ fmt.Println("=== Library Organization Workflow ===")
+
+ // Step 1: Explore library structure
+ fmt.Println("📂 Exploring music library...")
+
+ library, err := soundtouch.GetStoredMusicLibrary(deviceAccount)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ directories := library.GetDirectories()
+ tracks := library.GetTracks()
+
+ fmt.Printf("📊 Library overview: %d directories, %d tracks\n",
+ len(directories), len(tracks))
+
+ // Step 2: Navigate into each directory
+ for _, dir := range directories[:min(3, len(directories))] {
+ fmt.Printf("\n📁 Exploring: %s\n", dir.GetDisplayName())
+
+ contents, err := soundtouch.NavigateContainer(
+ "STORED_MUSIC", deviceAccount, 1, 20, dir.ContentItem)
+ if err != nil {
+ log.Printf("❌ Failed to explore %s: %v", dir.GetDisplayName(), err)
+ continue
+ }
+
+ subTracks := contents.GetTracks()
+ subDirs := contents.GetDirectories()
+
+ fmt.Printf(" Contains: %d tracks, %d subdirectories\n",
+ len(subTracks), len(subDirs))
+
+ // Show some tracks
+ for i, track := range subTracks[:min(3, len(subTracks))] {
+ fmt.Printf(" %d. %s", i+1, track.GetDisplayName())
+ if track.ArtistName != "" {
+ fmt.Printf(" - %s", track.ArtistName)
+ }
+ fmt.Println()
+ }
+ }
+
+ fmt.Println("\n✓ Library exploration complete!")
+}
+```
+
+### Multi-Service Content Discovery
+
+```go
+func multiServiceDiscovery(soundtouch *client.Client, accounts map[string]string) {
+ searchTerm := "jazz"
+ fmt.Printf("🔍 Searching '%s' across all services...\n", searchTerm)
+
+ // Search TuneIn (no account needed)
+ fmt.Println("\n📻 TuneIn Results:")
+ tuneInResults, err := soundtouch.SearchTuneInStations(searchTerm)
+ if err != nil {
+ fmt.Printf("❌ TuneIn search failed: %v\n", err)
+ } else {
+ stations := tuneInResults.GetStations()
+ fmt.Printf("✓ Found %d TuneIn stations\n", len(stations))
+ for i, station := range stations[:min(3, len(stations))] {
+ fmt.Printf(" %d. %s\n", i+1, station.GetDisplayName())
+ }
+ }
+
+ // Search Pandora (if account available)
+ if pandoraAccount, ok := accounts["PANDORA"]; ok {
+ fmt.Println("\n🎵 Pandora Results:")
+ pandoraResults, err := soundtouch.SearchPandoraStations(pandoraAccount, searchTerm)
+ if err != nil {
+ fmt.Printf("❌ Pandora search failed: %v\n", err)
+ } else {
+ artists := pandoraResults.GetArtists()
+ stations := pandoraResults.GetStations()
+ fmt.Printf("✓ Found %d artists, %d stations\n", len(artists), len(stations))
+
+ for i, artist := range artists[:min(2, len(artists))] {
+ fmt.Printf(" Artist: %s\n", artist.GetDisplayName())
+ }
+ }
+ }
+
+ // Search Spotify (if account available)
+ if spotifyAccount, ok := accounts["SPOTIFY"]; ok {
+ fmt.Println("\n🎼 Spotify Results:")
+ spotifyResults, err := soundtouch.SearchSpotifyContent(spotifyAccount, searchTerm)
+ if err != nil {
+ fmt.Printf("❌ Spotify search failed: %v\n", err)
+ } else {
+ songs := spotifyResults.GetSongs()
+ fmt.Printf("✓ Found %d songs\n", len(songs))
+
+ for i, song := range songs[:min(2, len(songs))] {
+ fmt.Printf(" Song: %s\n", song.GetFullTitle())
+ }
+ }
+ }
+
+ fmt.Println("\n✓ Multi-service discovery complete!")
+}
+```
+
+## Error Handling
+
+### Graceful Error Handling
+
+```go
+func robustNavigation(soundtouch *client.Client) error {
+ // Try multiple sources gracefully
+ sources := []string{"TUNEIN", "SPOTIFY", "STORED_MUSIC"}
+
+ for _, source := range sources {
+ fmt.Printf("Trying %s...\n", source)
+
+ response, err := soundtouch.Navigate(source, "", 1, 10)
+ if err != nil {
+ fmt.Printf("❌ %s failed: %v\n", source, err)
+ continue
+ }
+
+ if response.IsEmpty() {
+ fmt.Printf("⚠️ %s has no content\n", source)
+ continue
+ }
+
+ fmt.Printf("✓ %s available with %d items\n", source, response.TotalItems)
+ return nil
+ }
+
+ return fmt.Errorf("no sources available")
+}
+```
+
+### Retry Logic
+
+```go
+func searchWithRetry(soundtouch *client.Client, maxRetries int) (*models.SearchStationResponse, error) {
+ var lastErr error
+
+ for attempt := 1; attempt <= maxRetries; attempt++ {
+ fmt.Printf("Search attempt %d/%d...\n", attempt, maxRetries)
+
+ results, err := soundtouch.SearchTuneInStations("classical")
+ if err == nil {
+ return results, nil
+ }
+
+ lastErr = err
+ fmt.Printf("❌ Attempt %d failed: %v\n", attempt, err)
+
+ if attempt < maxRetries {
+ time.Sleep(time.Duration(attempt) * time.Second)
+ }
+ }
+
+ return nil, fmt.Errorf("search failed after %d attempts: %w", maxRetries, lastErr)
+}
+```
+
+### Validation and Safety
+
+```go
+func safeStationManagement(soundtouch *client.Client, pandoraAccount string) {
+ // Always validate inputs
+ if pandoraAccount == "" {
+ log.Fatal("Pandora account required")
+ }
+
+ // Search safely
+ results, err := soundtouch.SearchPandoraStations(pandoraAccount, "blues")
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ if results.IsEmpty() {
+ fmt.Println("No results found")
+ return
+ }
+
+ // Check what we have before adding stations
+ artists := results.GetArtists()
+ if len(artists) == 0 {
+ fmt.Println("No artists found to create stations from")
+ return
+ }
+
+ // Get current stations to avoid duplicates
+ currentStations, err := soundtouch.GetPandoraStations(pandoraAccount)
+ if err != nil {
+ log.Printf("Warning: Could not get current stations: %v", err)
+ }
+
+ // Create a map of existing station names
+ existingStations := make(map[string]bool)
+ for _, station := range currentStations.Items {
+ existingStations[station.GetDisplayName()] = true
+ }
+
+ // Add stations only if they don't exist
+ for _, artist := range artists[:min(2, len(artists))] {
+ stationName := artist.Name + " Radio"
+
+ if existingStations[stationName] {
+ fmt.Printf("⚠️ Station already exists: %s\n", stationName)
+ continue
+ }
+
+ fmt.Printf("Adding new station: %s\n", stationName)
+ err := soundtouch.AddStation("PANDORA", pandoraAccount, artist.Token, stationName)
+ if err != nil {
+ log.Printf("❌ Failed to add %s: %v", stationName, err)
+ } else {
+ fmt.Printf("✓ Added: %s\n", stationName)
+ }
+ }
+}
+```
+
+## Best Practices
+
+### 1. Check Source Availability
+
+```go
+// Always check what sources are available first
+sources, err := soundtouch.GetSources()
+if err != nil {
+ return err
+}
+
+// Check if TuneIn is ready
+for _, source := range sources.SourceItem {
+ if source.Source == "TUNEIN" && source.Status.IsReady() {
+ // TuneIn is available
+ break
+ }
+}
+```
+
+### 2. Use Pagination for Large Collections
+
+```go
+// For large libraries, use pagination
+const batchSize = 50
+
+func processLargeLibrary(soundtouch *client.Client, sourceAccount string) {
+ startItem := 1
+
+ for {
+ batch, err := soundtouch.Navigate("STORED_MUSIC", sourceAccount, startItem, batchSize)
+ if err != nil {
+ log.Printf("Error at position %d: %v", startItem, err)
+ break
+ }
+
+ if len(batch.Items) == 0 {
+ break // No more items
+ }
+
+ // Process this batch
+ processBatch(batch.Items)
+
+ startItem += batchSize
+
+ // Prevent infinite loops
+ if startItem > batch.TotalItems {
+ break
+ }
+ }
+}
+```
+
+### 3. Handle Service-Specific Behavior
+
+```go
+func handleServiceDifferences(soundtouch *client.Client) {
+ // TuneIn: Usually no account needed
+ tuneInStations, err := soundtouch.SearchTuneInStations("news")
+ if err == nil {
+ fmt.Printf("TuneIn: %d stations\n", len(tuneInStations.GetStations()))
+ }
+
+ // Pandora: Requires user account
+ pandoraResults, err := soundtouch.SearchPandoraStations("user_account", "rock")
+ if err == nil {
+ // Pandora returns artists you can create stations from
+ artists := pandoraResults.GetArtists()
+ fmt.Printf("Pandora: %d artists\n", len(artists))
+ }
+
+ // Spotify: Requires user account, returns tracks/playlists
+ spotifyResults, err := soundtouch.SearchSpotifyContent("spotify_user", "pop")
+ if err == nil {
+ songs := spotifyResults.GetSongs()
+ fmt.Printf("Spotify: %d songs\n", len(songs))
+ }
+}
+```
+
+### 4. Implement User-Friendly Interfaces
+
+```go
+func userFriendlySearch(soundtouch *client.Client, searchTerm string) {
+ fmt.Printf("🔍 Searching for '%s'...\n", searchTerm)
+
+ results, err := soundtouch.SearchTuneInStations(searchTerm)
+ if err != nil {
+ fmt.Printf("❌ Search failed: %v\n", err)
+ return
+ }
+
+ if results.IsEmpty() {
+ fmt.Printf("😞 No results found for '%s'\n", searchTerm)
+ fmt.Println("💡 Try different search terms like:")
+ fmt.Println(" - Genre names: jazz, rock, classical")
+ fmt.Println(" - Artist names: Beatles, Mozart")
+ fmt.Println(" - Station types: news, talk, music")
+ return
+ }
+
+ stations := results.GetStations()
+ fmt.Printf("🎵 Found %d stations:\n", len(stations))
+
+ for i, station := range stations {
+ fmt.Printf("%d. 📻 %s", i+1, station.GetDisplayName())
+ if station.Description != "" {
+ fmt.Printf("\n %s", station.Description)
+ }
+ if station.GetArtworkURL() != "" {
+ fmt.Printf("\n 🎨 %s", station.GetArtworkURL())
+ }
+ fmt.Println()
+ }
+}
+```
+
+### 5. Performance Considerations
+
+```go
+func efficientBrowsing(soundtouch *client.Client) {
+ // Use reasonable page sizes
+ const optimalPageSize = 25 // Good balance of network efficiency and memory usage
+
+ // Cache frequently accessed data
+ var cachedSources *models.Sources
+
+ getSources := func() (*models.Sources, error) {
+ if cachedSources == nil {
+ var err error
+ cachedSources, err = soundtouch.GetSources()
+ return cachedSources, err
+ }
+ return cachedSources, nil
+ }
+
+ // Use the cached sources
+ sources, err := getSources()
+ if err != nil {
+ return
+ }
+
+ // Process efficiently
+ for _, source := range sources.SourceItem {
+ if source.Status.IsReady() {
+ // Only browse ready sources
+ procesReadySource(soundtouch, source.Source, source.SourceAccount)
+ }
+ }
+}
+```
+
+## API Reference
+
+### Navigation Methods
+
+| Method | Description | Parameters | Returns |
+|--------|-------------|------------|---------|
+| `Navigate()` | Browse content source | source, account, start, count | NavigateResponse |
+| `NavigateWithMenu()` | Browse with menu/sort | source, account, menu, sort, start, count | NavigateResponse |
+| `NavigateContainer()` | Browse into directory | source, account, start, count, container | NavigateResponse |
+| `GetTuneInStations()` | Convenience for TuneIn | account | NavigateResponse |
+| `GetPandoraStations()` | Convenience for Pandora | account | NavigateResponse |
+| `GetStoredMusicLibrary()` | Convenience for stored music | account | NavigateResponse |
+
+### Search Methods
+
+| Method | Description | Parameters | Returns |
+|--------|-------------|------------|---------|
+| `SearchStation()` | Generic station search | source, account, term | SearchStationResponse |
+| `SearchTuneInStations()` | Search TuneIn | term | SearchStationResponse |
+| `SearchPandoraStations()` | Search Pandora | account, term | SearchStationResponse |
+| `SearchSpotifyContent()` | Search Spotify | account, term | SearchStationResponse |
+
+### Station Management Methods
+
+| Method | Description | Parameters | Returns |
+|--------|-------------|------------|---------|
+| `AddStation()` | Add station (plays immediately) | source, account, token, name | error |
+| `RemoveStation()` | Remove station from collection | contentItem | error |
+
+### Response Helper Methods
+
+#### NavigateResponse Methods
+
+- `GetPlayableItems()` - Filter playable items
+- `GetDirectories()` - Filter directories
+- `GetTracks()` - Filter music tracks
+- `GetStations()` - Filter radio stations
+- `IsEmpty()` - Check if response has no items
+
+#### SearchStationResponse Methods
+
+- `GetSongs()` - Filter song results
+- `GetArtists()` - Filter artist results
+- `GetStations()` - Filter station results
+- `GetAllResults()` - Get all results combined
+- `GetResultCount()` - Count total results
+- `HasResults()` - Check if any results found
+- `IsEmpty()` - Check if no results
+
+#### SearchResult Methods
+
+- `IsSong()` - Check if result is a song
+- `IsArtist()` - Check if result is an artist
+- `IsStation()` - Check if result is a station
+- `GetDisplayName()` - Get formatted name
+- `GetFullTitle()` - Get name with artist (for songs)
+- `GetArtworkURL()` - Get artwork/logo URL
+
+### Common Source Types
+
+| Source | Description | Account Required | Search Support |
+|--------|-------------|------------------|----------------|
+| `TUNEIN` | Internet radio stations | No | Yes |
+| `PANDORA` | Pandora music service | Yes | Yes |
+| `SPOTIFY` | Spotify music service | Yes | Yes |
+| `STORED_MUSIC` | Local/network music | Device account | No |
+| `BLUETOOTH` | Bluetooth audio input | No | No |
+| `AUX` | Auxiliary input | No | No |
+
+## Troubleshooting
+
+### Common Issues
+
+**"Source not available"**
+- Check if the service is configured on your SoundTouch device
+- Verify account credentials are set up properly
+- Use `GetSources()` to see what's actually available
+
+**"No results found"**
+- Try broader search terms
+- Check if the service is working (try via SoundTouch app)
+- Verify account has access to content
+
+**"AddStation failed"**
+- Ensure the token is valid (from search results)
+- Check that the service supports adding stations
+- Verify account permissions
+
+**Navigation timeouts**
+- Large libraries may take time to browse
+- Use smaller page sizes for better performance
+- Implement timeout handling in your code
+
+### Getting Help
+
+For additional help:
+- Check the SoundTouch device logs
+- Test functionality via the official SoundTouch app
+- Review network connectivity between client and device
+- Examine the raw XML responses for debugging
+
+---
+
+*This guide covers the complete navigation and station management functionality. For preset management, see [PRESET-MANAGEMENT.md](PRESET-MANAGEMENT.md).*
\ No newline at end of file
diff --git a/pkg/client/client.go b/pkg/client/client.go
index 3f90e61..55e01ca 100644
--- a/pkg/client/client.go
+++ b/pkg/client/client.go
@@ -973,6 +973,68 @@ func (c *Client) post(endpoint string, payload interface{}) error {
return nil
}
+// postWithResponse performs a POST request with XML body and parses the response
+func (c *Client) postWithResponse(endpoint string, payload interface{}, result interface{}) error {
+ url := c.baseURL + endpoint
+
+ var body io.Reader
+
+ if payload != nil {
+ xmlData, err := xml.Marshal(payload)
+ if err != nil {
+ return fmt.Errorf("failed to marshal XML request: %w", err)
+ }
+
+ body = bytes.NewReader(xmlData)
+ }
+
+ req, err := http.NewRequest("POST", url, body)
+ if err != nil {
+ return fmt.Errorf("failed to create request: %w", err)
+ }
+
+ req.Header.Set("User-Agent", c.userAgent)
+ req.Header.Set("Content-Type", "application/xml")
+ req.Header.Set("Accept", "application/xml")
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return fmt.Errorf("failed to execute request: %w", err)
+ }
+
+ defer func() {
+ if closeErr := resp.Body.Close(); closeErr != nil {
+ // Log the error but don't override the main error
+ _ = closeErr // Explicitly ignore the error
+ }
+ }()
+
+ if resp.StatusCode != http.StatusOK {
+ responseBody, _ := io.ReadAll(resp.Body)
+ return fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(responseBody))
+ }
+
+ if result != nil {
+ responseBody, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return fmt.Errorf("failed to read response body: %w", err)
+ }
+
+ // Parse the actual response first
+ if err := xml.Unmarshal(responseBody, result); err != nil {
+ // Check if it might be an API error response instead
+ var apiError models.APIError
+ if xmlErr := xml.Unmarshal(responseBody, &apiError); xmlErr == nil && apiError.Message != "" {
+ return &apiError
+ }
+
+ return fmt.Errorf("failed to unmarshal XML response: %w", err)
+ }
+ }
+
+ return nil
+}
+
// GetZone gets the current multiroom zone configuration
func (c *Client) GetZone() (*models.ZoneInfo, error) {
var zone models.ZoneInfo
@@ -1349,6 +1411,188 @@ func (c *Client) RequestToken() (*models.BearerToken, error) {
return &token, nil
}
+// Navigate browses content within a source (e.g., browse music libraries, stations)
+func (c *Client) Navigate(source, sourceAccount string, startItem, numItems int) (*models.NavigateResponse, error) {
+ if source == "" {
+ return nil, fmt.Errorf("source cannot be empty")
+ }
+ if startItem < 1 {
+ return nil, fmt.Errorf("startItem must be >= 1, got %d", startItem)
+ }
+ if numItems < 1 {
+ return nil, fmt.Errorf("numItems must be >= 1, got %d", numItems)
+ }
+
+ request := models.NewNavigateRequest(source, sourceAccount, startItem, numItems)
+
+ var response models.NavigateResponse
+ err := c.postWithResponse("/navigate", request, &response)
+ if err != nil {
+ return nil, fmt.Errorf("failed to navigate %s: %w", source, err)
+ }
+
+ return &response, nil
+}
+
+// NavigateWithMenu browses content with menu and sort parameters (e.g., Pandora stations)
+func (c *Client) NavigateWithMenu(source, sourceAccount, menu, sort string, startItem, numItems int) (*models.NavigateResponse, error) {
+ if source == "" {
+ return nil, fmt.Errorf("source cannot be empty")
+ }
+ if startItem < 1 {
+ return nil, fmt.Errorf("startItem must be >= 1, got %d", startItem)
+ }
+ if numItems < 1 {
+ return nil, fmt.Errorf("numItems must be >= 1, got %d", numItems)
+ }
+
+ request := models.NewNavigateRequestWithMenu(source, sourceAccount, menu, sort, startItem, numItems)
+
+ var response models.NavigateResponse
+ err := c.postWithResponse("/navigate", request, &response)
+ if err != nil {
+ return nil, fmt.Errorf("failed to navigate %s with menu %s: %w", source, menu, err)
+ }
+
+ return &response, nil
+}
+
+// NavigateContainer browses a specific container/directory within a source
+func (c *Client) NavigateContainer(source, sourceAccount string, startItem, numItems int, containerItem *models.ContentItem) (*models.NavigateResponse, error) {
+ if source == "" {
+ return nil, fmt.Errorf("source cannot be empty")
+ }
+ if containerItem == nil {
+ return nil, fmt.Errorf("container item cannot be nil")
+ }
+ if startItem < 1 {
+ return nil, fmt.Errorf("startItem must be >= 1, got %d", startItem)
+ }
+ if numItems < 1 {
+ return nil, fmt.Errorf("numItems must be >= 1, got %d", numItems)
+ }
+
+ request := models.NewNavigateRequestWithItem(source, sourceAccount, startItem, numItems, containerItem)
+
+ var response models.NavigateResponse
+ err := c.postWithResponse("/navigate", request, &response)
+ if err != nil {
+ return nil, fmt.Errorf("failed to navigate container in %s: %w", source, err)
+ }
+
+ return &response, nil
+}
+
+// AddStation adds a station to a music service collection and immediately starts playing it
+func (c *Client) AddStation(source, sourceAccount, token, name string) error {
+ if source == "" {
+ return fmt.Errorf("source cannot be empty")
+ }
+ if token == "" {
+ return fmt.Errorf("token cannot be empty")
+ }
+ if name == "" {
+ return fmt.Errorf("station name cannot be empty")
+ }
+
+ request := models.NewAddStationRequest(source, sourceAccount, token, name)
+
+ var response models.StationResponse
+ err := c.postWithResponse("/addStation", request, &response)
+ if err != nil {
+ return fmt.Errorf("failed to add station '%s' to %s: %w", name, source, err)
+ }
+
+ return nil
+}
+
+// RemoveStation removes a station from a music service collection
+func (c *Client) RemoveStation(contentItem *models.ContentItem) error {
+ if contentItem == nil {
+ return fmt.Errorf("content item cannot be nil")
+ }
+ if contentItem.Source == "" {
+ return fmt.Errorf("content item source cannot be empty")
+ }
+ if contentItem.Location == "" {
+ return fmt.Errorf("content item location cannot be empty")
+ }
+
+ var response models.StationResponse
+ err := c.postWithResponse("/removeStation", contentItem, &response)
+ if err != nil {
+ return fmt.Errorf("failed to remove station from %s: %w", contentItem.Source, err)
+ }
+
+ return nil
+}
+
+// GetPandoraStations gets all Pandora radio stations for an account
+func (c *Client) GetPandoraStations(sourceAccount string) (*models.NavigateResponse, error) {
+ if sourceAccount == "" {
+ return nil, fmt.Errorf("Pandora source account cannot be empty")
+ }
+
+ return c.NavigateWithMenu("PANDORA", sourceAccount, "radioStations", "dateCreated", 1, 100)
+}
+
+// GetTuneInStations browses TuneIn stations/content
+func (c *Client) GetTuneInStations(sourceAccount string) (*models.NavigateResponse, error) {
+ return c.Navigate("TUNEIN", sourceAccount, 1, 100)
+}
+
+// GetStoredMusicLibrary browses stored music library
+func (c *Client) GetStoredMusicLibrary(sourceAccount string) (*models.NavigateResponse, error) {
+ if sourceAccount == "" {
+ return nil, fmt.Errorf("stored music source account cannot be empty")
+ }
+
+ return c.Navigate("STORED_MUSIC", sourceAccount, 1, 1000)
+}
+
+// SearchStation searches for stations/content within a music service
+func (c *Client) SearchStation(source, sourceAccount, searchTerm string) (*models.SearchStationResponse, error) {
+ if source == "" {
+ return nil, fmt.Errorf("source cannot be empty")
+ }
+ if searchTerm == "" {
+ return nil, fmt.Errorf("search term cannot be empty")
+ }
+
+ request := models.NewSearchStationRequest(source, sourceAccount, searchTerm)
+
+ var response models.SearchStationResponse
+ err := c.postWithResponse("/searchStation", request, &response)
+ if err != nil {
+ return nil, fmt.Errorf("failed to search stations in %s: %w", source, err)
+ }
+
+ return &response, nil
+}
+
+// SearchPandoraStations searches for Pandora stations by artist/song name
+func (c *Client) SearchPandoraStations(sourceAccount, searchTerm string) (*models.SearchStationResponse, error) {
+ if sourceAccount == "" {
+ return nil, fmt.Errorf("Pandora source account cannot be empty")
+ }
+
+ return c.SearchStation("PANDORA", sourceAccount, searchTerm)
+}
+
+// SearchTuneInStations searches for TuneIn stations/content
+func (c *Client) SearchTuneInStations(searchTerm string) (*models.SearchStationResponse, error) {
+ return c.SearchStation("TUNEIN", "", searchTerm)
+}
+
+// SearchSpotifyContent searches for Spotify content (playlists, tracks, etc.)
+func (c *Client) SearchSpotifyContent(sourceAccount, searchTerm string) (*models.SearchStationResponse, error) {
+ if sourceAccount == "" {
+ return nil, fmt.Errorf("Spotify source account cannot be empty")
+ }
+
+ return c.SearchStation("SPOTIFY", sourceAccount, searchTerm)
+}
+
// hasCapability checks if a capability is present in the device capabilities
func (c *Client) hasCapability(capabilities *models.Capabilities, capability string) bool {
// Convert capabilities to string and check if it contains the capability
diff --git a/pkg/client/navigation_examples_test.go b/pkg/client/navigation_examples_test.go
new file mode 100644
index 0000000..8a4b317
--- /dev/null
+++ b/pkg/client/navigation_examples_test.go
@@ -0,0 +1,232 @@
+package client
+
+import (
+ "fmt"
+ "log"
+)
+
+// ExampleClient_Navigate demonstrates basic navigation of content sources
+func ExampleClient_Navigate() {
+ config := &Config{Host: "192.168.1.100", Port: 8090}
+ client := NewClient(config)
+
+ // Navigate TuneIn content
+ response, err := client.Navigate("TUNEIN", "", 1, 10)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ fmt.Printf("Found %d items in TuneIn\n", response.TotalItems)
+ for _, item := range response.Items {
+ fmt.Printf("- %s (%s)\n", item.GetDisplayName(), item.Type)
+ }
+}
+
+// ExampleClient_SearchStation demonstrates searching for radio stations
+func ExampleClient_SearchStation() {
+ config := &Config{Host: "192.168.1.100", Port: 8090}
+ client := NewClient(config)
+
+ // Search for jazz stations on TuneIn
+ results, err := client.SearchTuneInStations("jazz")
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ fmt.Printf("Found %d search results\n", results.GetResultCount())
+
+ // Show stations found
+ stations := results.GetStations()
+ for _, station := range stations {
+ fmt.Printf("Station: %s\n", station.GetDisplayName())
+ if station.Description != "" {
+ fmt.Printf(" Description: %s\n", station.Description)
+ }
+ }
+}
+
+// ExampleClient_AddStation demonstrates adding a station and playing it
+func ExampleClient_AddStation() {
+ config := &Config{Host: "192.168.1.100", Port: 8090}
+ client := NewClient(config)
+
+ // First, search for content to get a token
+ results, err := client.SearchPandoraStations("user123", "classic rock")
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ // Find an artist to create a station from
+ artists := results.GetArtists()
+ if len(artists) == 0 {
+ fmt.Println("No artists found")
+ return
+ }
+
+ artist := artists[0]
+ stationName := artist.Name + " Radio"
+
+ // Add the station (this immediately starts playing it)
+ err = client.AddStation("PANDORA", "user123", artist.Token, stationName)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ fmt.Printf("Added and started playing: %s\n", stationName)
+}
+
+// Example_navigationWorkflow demonstrates a complete workflow
+func Example_navigationWorkflow() {
+ config := &Config{Host: "192.168.1.100", Port: 8090}
+ client := NewClient(config)
+
+ // 1. Search for content
+ fmt.Println("Searching for Taylor Swift...")
+ searchResults, err := client.SearchPandoraStations("user123", "Taylor Swift")
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ fmt.Printf("Found %d total results\n", searchResults.GetResultCount())
+
+ // 2. Show different types of results
+ songs := searchResults.GetSongs()
+ artists := searchResults.GetArtists()
+ stations := searchResults.GetStations()
+
+ fmt.Printf("Songs: %d, Artists: %d, Stations: %d\n",
+ len(songs), len(artists), len(stations))
+
+ // 3. Find an artist to create a station from
+ if len(artists) > 0 {
+ artist := artists[0]
+ fmt.Printf("Creating station from artist: %s (Token: %s)\n",
+ artist.Name, artist.Token)
+
+ // Note: In a real scenario, you'd call AddStation here
+ // This would immediately start playing the new station
+ fmt.Printf("Would add station: %s Radio\n", artist.Name)
+ }
+
+ // 4. Browse existing Pandora stations
+ fmt.Println("\nBrowsing existing Pandora stations...")
+ pandoraStations, err := client.GetPandoraStations("user123")
+ if err != nil {
+ fmt.Printf("Could not get Pandora stations: %v\n", err)
+ return
+ }
+
+ fmt.Printf("Found %d existing stations\n", len(pandoraStations.Items))
+
+ // 5. Show how to remove a station (if any exist)
+ if len(pandoraStations.Items) > 0 {
+ station := pandoraStations.Items[0]
+ if station.ContentItem != nil {
+ fmt.Printf("Could remove station: %s\n", station.GetDisplayName())
+ // err := client.RemoveStation(station.ContentItem)
+ }
+ }
+}
+
+// ExampleClient_NavigateContainer demonstrates browsing into directories
+func ExampleClient_NavigateContainer() {
+ config := &Config{Host: "192.168.1.100", Port: 8090}
+ client := NewClient(config)
+
+ // First, get the stored music library root
+ musicLibrary, err := client.GetStoredMusicLibrary("device123/0")
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ fmt.Printf("Music library has %d items\n", musicLibrary.TotalItems)
+
+ // Find a directory to browse into
+ directories := musicLibrary.GetDirectories()
+ if len(directories) == 0 {
+ fmt.Println("No directories found")
+ return
+ }
+
+ // Browse into the first directory
+ directory := directories[0]
+ fmt.Printf("Browsing into: %s\n", directory.GetDisplayName())
+
+ contents, err := client.NavigateContainer(
+ "STORED_MUSIC",
+ "device123/0",
+ 1, 100,
+ directory.ContentItem,
+ )
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ // Show what's in the directory
+ tracks := contents.GetTracks()
+ subdirs := contents.GetDirectories()
+
+ fmt.Printf("Found %d tracks and %d subdirectories\n",
+ len(tracks), len(subdirs))
+
+ // Show first few tracks
+ for i, track := range tracks[:min(3, len(tracks))] {
+ fmt.Printf("%d. %s", i+1, track.GetDisplayName())
+ if track.ArtistName != "" {
+ fmt.Printf(" - %s", track.ArtistName)
+ }
+ fmt.Println()
+ }
+}
+
+// Example_searchAndPlayWorkflow demonstrates search -> add -> play workflow
+func Example_searchAndPlayWorkflow() {
+ config := &Config{Host: "192.168.1.100", Port: 8090}
+ client := NewClient(config)
+
+ searchTerm := "classic rock"
+ fmt.Printf("Searching for '%s'...\n", searchTerm)
+
+ // 1. Search for content
+ results, err := client.SearchTuneInStations(searchTerm)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ stations := results.GetStations()
+ if len(stations) == 0 {
+ fmt.Println("No stations found")
+ return
+ }
+
+ // 2. Show available stations
+ fmt.Printf("Found %d stations:\n", len(stations))
+ for i, station := range stations[:min(5, len(stations))] {
+ fmt.Printf("%d. %s", i+1, station.GetDisplayName())
+ if station.Description != "" {
+ fmt.Printf(" - %s", station.Description)
+ }
+ fmt.Println()
+ }
+
+ // 3. In a real app, user would select one
+ selectedStation := stations[0]
+ fmt.Printf("\nSelected: %s\n", selectedStation.GetDisplayName())
+
+ // 4. For TuneIn, you might need to add it as a station first
+ // (depending on the service and how the API works)
+ if selectedStation.Token != "" {
+ fmt.Printf("Would add station with token: %s\n", selectedStation.Token)
+ // err := client.AddStation("TUNEIN", "", selectedStation.Token, selectedStation.Name)
+ }
+
+ fmt.Println("Station would now be playing!")
+}
+
+// Helper function for min calculation
+func min(a, b int) int {
+ if a < b {
+ return a
+ }
+ return b
+}
diff --git a/pkg/client/navigation_integration_test.go b/pkg/client/navigation_integration_test.go
new file mode 100644
index 0000000..9b25bcd
--- /dev/null
+++ b/pkg/client/navigation_integration_test.go
@@ -0,0 +1,433 @@
+package client
+
+import (
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/gesellix/bose-soundtouch/pkg/models"
+)
+
+func TestClient_Navigation_Integration(t *testing.T) {
+ if testing.Short() {
+ t.Skip("Skipping integration tests in short mode")
+ }
+
+ host := os.Getenv("SOUNDTOUCH_TEST_HOST")
+ if host == "" {
+ t.Skip("SOUNDTOUCH_TEST_HOST environment variable not set - skipping integration tests")
+ }
+
+ // Parse host:port if provided
+ var finalHost string
+ var finalPort int
+ if strings.Contains(host, ":") {
+ parts := strings.Split(host, ":")
+ finalHost = parts[0]
+ if len(parts) > 1 {
+ // Use default port if parsing fails
+ finalPort = 8090
+ }
+ } else {
+ finalHost = host
+ finalPort = 8090
+ }
+
+ config := &Config{
+ Host: finalHost,
+ Port: finalPort,
+ Timeout: 30 * time.Second,
+ UserAgent: "Bose-SoundTouch-Go-Client-Integration-Test/1.0",
+ }
+
+ client := NewClient(config)
+
+ t.Run("Navigate_TuneIn", func(t *testing.T) {
+ response, err := client.Navigate("TUNEIN", "", 1, 10)
+ if err != nil {
+ t.Logf("Navigate TUNEIN failed (may not be available): %v", err)
+ t.Skip("TUNEIN not available on test device")
+ return
+ }
+
+ t.Logf("✓ Navigate TUNEIN succeeded")
+ t.Logf(" Total items: %d", response.TotalItems)
+ t.Logf(" Items returned: %d", len(response.Items))
+
+ if response.TotalItems > 0 {
+ t.Logf(" First item: %s", response.Items[0].GetDisplayName())
+ }
+ })
+
+ t.Run("GetTuneInStations", func(t *testing.T) {
+ response, err := client.GetTuneInStations("")
+ if err != nil {
+ t.Logf("GetTuneInStations failed (may not be available): %v", err)
+ t.Skip("TuneIn not available on test device")
+ return
+ }
+
+ t.Logf("✓ GetTuneInStations succeeded")
+ t.Logf(" Total stations: %d", response.TotalItems)
+
+ stations := response.GetStations()
+ t.Logf(" Station items: %d", len(stations))
+ })
+
+ t.Run("Navigate_StoredMusic", func(t *testing.T) {
+ // Get sources first to check if STORED_MUSIC is available
+ sources, err := client.GetSources()
+ if err != nil {
+ t.Fatalf("Failed to get sources: %v", err)
+ }
+
+ var storedMusicAccount string
+ for _, source := range sources.SourceItem {
+ if source.Source == "STORED_MUSIC" && source.Status.IsReady() {
+ storedMusicAccount = source.SourceAccount
+ break
+ }
+ }
+
+ if storedMusicAccount == "" {
+ t.Skip("STORED_MUSIC not available or not ready on test device")
+ }
+
+ response, err := client.GetStoredMusicLibrary(storedMusicAccount)
+ if err != nil {
+ t.Logf("GetStoredMusicLibrary failed: %v", err)
+ return
+ }
+
+ t.Logf("✓ GetStoredMusicLibrary succeeded")
+ t.Logf(" Source account: %s", storedMusicAccount)
+ t.Logf(" Total items: %d", response.TotalItems)
+
+ directories := response.GetDirectories()
+ t.Logf(" Directories: %d", len(directories))
+
+ tracks := response.GetTracks()
+ t.Logf(" Tracks: %d", len(tracks))
+ })
+
+ t.Run("SearchStation_TuneIn", func(t *testing.T) {
+ response, err := client.SearchTuneInStations("jazz")
+ if err != nil {
+ t.Logf("SearchTuneInStations failed (may not be supported): %v", err)
+ t.Skip("TuneIn search not supported on test device")
+ return
+ }
+
+ t.Logf("✓ SearchTuneInStations succeeded")
+ t.Logf(" Search term: jazz")
+ t.Logf(" Total results: %d", response.GetResultCount())
+
+ songs := response.GetSongs()
+ artists := response.GetArtists()
+ stations := response.GetStations()
+
+ t.Logf(" Songs: %d", len(songs))
+ t.Logf(" Artists: %d", len(artists))
+ t.Logf(" Stations: %d", len(stations))
+
+ if len(stations) > 0 {
+ station := stations[0]
+ t.Logf(" First station: %s", station.GetDisplayName())
+ if station.Token != "" {
+ t.Logf(" Station token: %s", station.Token)
+ }
+ }
+ })
+}
+
+func TestClient_StationManagement_Integration(t *testing.T) {
+ if testing.Short() {
+ t.Skip("Skipping integration tests in short mode")
+ }
+
+ host := os.Getenv("SOUNDTOUCH_TEST_HOST")
+ if host == "" {
+ t.Skip("SOUNDTOUCH_TEST_HOST environment variable not set - skipping integration tests")
+ }
+
+ // Parse host:port if provided
+ var finalHost string
+ var finalPort int
+ if strings.Contains(host, ":") {
+ parts := strings.Split(host, ":")
+ finalHost = parts[0]
+ if len(parts) > 1 {
+ finalPort = 8090
+ }
+ } else {
+ finalHost = host
+ finalPort = 8090
+ }
+
+ config := &Config{
+ Host: finalHost,
+ Port: finalPort,
+ Timeout: 30 * time.Second,
+ UserAgent: "Bose-SoundTouch-Go-Client-Integration-Test/1.0",
+ }
+
+ client := NewClient(config)
+
+ t.Run("SearchAndAddStation_Pandora", func(t *testing.T) {
+ // Get sources first to check if Pandora is available
+ sources, err := client.GetSources()
+ if err != nil {
+ t.Fatalf("Failed to get sources: %v", err)
+ }
+
+ var pandoraAccount string
+ for _, source := range sources.SourceItem {
+ if source.Source == "PANDORA" && source.Status.IsReady() {
+ pandoraAccount = source.SourceAccount
+ break
+ }
+ }
+
+ if pandoraAccount == "" {
+ t.Skip("Pandora not available or not configured on test device")
+ }
+
+ // Search for stations
+ searchResponse, err := client.SearchPandoraStations(pandoraAccount, "classic rock")
+ if err != nil {
+ t.Logf("SearchPandoraStations failed: %v", err)
+ t.Skip("Pandora search not working")
+ return
+ }
+
+ t.Logf("✓ SearchPandoraStations succeeded")
+ t.Logf(" Account: %s", pandoraAccount)
+ t.Logf(" Results: %d", searchResponse.GetResultCount())
+
+ // Try to find an artist or station result to add
+ var tokenToAdd string
+ var nameToAdd string
+
+ artists := searchResponse.GetArtists()
+ if len(artists) > 0 {
+ tokenToAdd = artists[0].Token
+ nameToAdd = artists[0].Name + " Radio"
+ } else {
+ stations := searchResponse.GetStations()
+ if len(stations) > 0 {
+ tokenToAdd = stations[0].Token
+ nameToAdd = stations[0].Name
+ }
+ }
+
+ if tokenToAdd == "" {
+ t.Skip("No suitable results found to test AddStation")
+ }
+
+ t.Logf(" Will attempt to add: %s (Token: %s)", nameToAdd, tokenToAdd)
+
+ // Note: AddStation immediately starts playing and modifies user's collection
+ // In a real integration test, you might want to skip this or use a test account
+ t.Logf(" Skipping actual AddStation to avoid modifying user collection")
+ t.Logf(" AddStation would call: client.AddStation(%q, %q, %q, %q)", "PANDORA", pandoraAccount, tokenToAdd, nameToAdd)
+ })
+
+ t.Run("NavigateContainer_Integration", func(t *testing.T) {
+ // Get sources to find a suitable container-based source
+ sources, err := client.GetSources()
+ if err != nil {
+ t.Fatalf("Failed to get sources: %v", err)
+ }
+
+ var testSource string
+ var testAccount string
+
+ // Look for STORED_MUSIC as it typically has containers
+ for _, source := range sources.SourceItem {
+ if source.Source == "STORED_MUSIC" && source.Status.IsReady() {
+ testSource = source.Source
+ testAccount = source.SourceAccount
+ break
+ }
+ }
+
+ if testSource == "" {
+ t.Skip("No suitable container-based source found")
+ }
+
+ // First, navigate to get a container
+ response, err := client.Navigate(testSource, testAccount, 1, 10)
+ if err != nil {
+ t.Logf("Initial navigate failed: %v", err)
+ return
+ }
+
+ directories := response.GetDirectories()
+ if len(directories) == 0 {
+ t.Skip("No directories found to test container navigation")
+ }
+
+ // Pick the first directory to navigate into
+ container := directories[0]
+ if container.ContentItem == nil {
+ t.Skip("Directory has no ContentItem for navigation")
+ }
+
+ t.Logf("✓ Found container: %s", container.GetDisplayName())
+
+ // Navigate into the container
+ containerResponse, err := client.NavigateContainer(testSource, testAccount, 1, 20, container.ContentItem)
+ if err != nil {
+ t.Logf("NavigateContainer failed: %v", err)
+ return
+ }
+
+ t.Logf("✓ NavigateContainer succeeded")
+ t.Logf(" Container: %s", container.GetDisplayName())
+ t.Logf(" Items in container: %d", len(containerResponse.Items))
+
+ tracks := containerResponse.GetTracks()
+ subdirs := containerResponse.GetDirectories()
+
+ t.Logf(" Tracks: %d", len(tracks))
+ t.Logf(" Subdirectories: %d", len(subdirs))
+ })
+}
+
+func TestClient_Navigation_ErrorHandling_Integration(t *testing.T) {
+ if testing.Short() {
+ t.Skip("Skipping integration tests in short mode")
+ }
+
+ host := os.Getenv("SOUNDTOUCH_TEST_HOST")
+ if host == "" {
+ t.Skip("SOUNDTOUCH_TEST_HOST environment variable not set - skipping integration tests")
+ }
+
+ // Parse host:port if provided
+ var finalHost string
+ var finalPort int
+ if strings.Contains(host, ":") {
+ parts := strings.Split(host, ":")
+ finalHost = parts[0]
+ if len(parts) > 1 {
+ finalPort = 8090
+ }
+ } else {
+ finalHost = host
+ finalPort = 8090
+ }
+
+ config := &Config{
+ Host: finalHost,
+ Port: finalPort,
+ Timeout: 10 * time.Second,
+ UserAgent: "Bose-SoundTouch-Go-Client-Integration-Test/1.0",
+ }
+
+ client := NewClient(config)
+
+ t.Run("Navigate_InvalidSource", func(t *testing.T) {
+ _, err := client.Navigate("INVALID_SOURCE", "", 1, 10)
+ if err == nil {
+ t.Error("Expected error for invalid source, got none")
+ } else {
+ t.Logf("✓ Correctly failed for invalid source: %v", err)
+ }
+ })
+
+ t.Run("SearchStation_InvalidSource", func(t *testing.T) {
+ _, err := client.SearchStation("INVALID_SOURCE", "", "test")
+ if err == nil {
+ t.Error("Expected error for invalid source, got none")
+ } else {
+ t.Logf("✓ Correctly failed for invalid source: %v", err)
+ }
+ })
+
+ t.Run("AddStation_InvalidToken", func(t *testing.T) {
+ err := client.AddStation("PANDORA", "fake_account", "invalid_token", "Test Station")
+ if err == nil {
+ t.Error("Expected error for invalid token, got none")
+ } else {
+ t.Logf("✓ Correctly failed for invalid token: %v", err)
+ }
+ })
+
+ t.Run("RemoveStation_InvalidContentItem", func(t *testing.T) {
+ invalidContentItem := &models.ContentItem{
+ Source: "PANDORA",
+ Location: "invalid_location",
+ ItemName: "Invalid Station",
+ }
+
+ err := client.RemoveStation(invalidContentItem)
+ if err == nil {
+ t.Error("Expected error for invalid content item, got none")
+ } else {
+ t.Logf("✓ Correctly failed for invalid content item: %v", err)
+ }
+ })
+}
+
+func BenchmarkClient_Navigate_Integration(b *testing.B) {
+ if testing.Short() {
+ b.Skip("Skipping integration benchmarks in short mode")
+ }
+
+ host := os.Getenv("SOUNDTOUCH_TEST_HOST")
+ if host == "" {
+ b.Skip("SOUNDTOUCH_TEST_HOST environment variable not set - skipping integration benchmarks")
+ }
+
+ // Parse host:port if provided
+ var finalHost string
+ var finalPort int
+ if strings.Contains(host, ":") {
+ parts := strings.Split(host, ":")
+ finalHost = parts[0]
+ if len(parts) > 1 {
+ finalPort = 8090
+ }
+ } else {
+ finalHost = host
+ finalPort = 8090
+ }
+
+ config := &Config{
+ Host: finalHost,
+ Port: finalPort,
+ Timeout: 10 * time.Second,
+ UserAgent: "Bose-SoundTouch-Go-Client-Benchmark/1.0",
+ }
+
+ client := NewClient(config)
+
+ b.ResetTimer()
+
+ b.Run("Navigate_TuneIn", func(b *testing.B) {
+ for i := 0; i < b.N; i++ {
+ _, err := client.Navigate("TUNEIN", "", 1, 10)
+ if err != nil {
+ b.Logf("Navigate failed: %v", err)
+ b.Skip("TuneIn not available")
+ return
+ }
+ }
+ })
+
+ b.Run("SearchStation_TuneIn", func(b *testing.B) {
+ searchTerms := []string{"jazz", "rock", "classical", "pop", "country"}
+
+ for i := 0; i < b.N; i++ {
+ term := searchTerms[i%len(searchTerms)]
+ _, err := client.SearchTuneInStations(term)
+ if err != nil {
+ b.Logf("Search failed: %v", err)
+ b.Skip("TuneIn search not available")
+ return
+ }
+ }
+ })
+}
diff --git a/pkg/client/navigation_test.go b/pkg/client/navigation_test.go
new file mode 100644
index 0000000..a3685c8
--- /dev/null
+++ b/pkg/client/navigation_test.go
@@ -0,0 +1,944 @@
+package client
+
+import (
+ "encoding/xml"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/gesellix/bose-soundtouch/pkg/models"
+)
+
+// Constants are already defined in other test files
+
+func TestClient_Navigate(t *testing.T) {
+ tests := []struct {
+ name string
+ source string
+ sourceAccount string
+ startItem int
+ numItems int
+ serverResponse string
+ serverStatus int
+ expectError bool
+ errorContains string
+ }{
+ {
+ name: "Valid TUNEIN navigate",
+ source: "TUNEIN",
+ sourceAccount: "",
+ startItem: 1,
+ numItems: 50,
+ serverResponse: `
+
+ 2
+
+ -
+ Station 1
+ stationurl
+
+ K-LOVE Radio
+
+
+ -
+ Station 2
+ stationurl
+
+ Test Radio
+
+
+
+`,
+ serverStatus: http.StatusOK,
+ expectError: false,
+ },
+ {
+ name: "Valid SPOTIFY navigate with account",
+ source: "SPOTIFY",
+ sourceAccount: "user@example.com",
+ startItem: 10,
+ numItems: 25,
+ serverResponse: `
+
+ 100
+
+ -
+ My Playlist
+ playlist
+
+ My Playlist
+
+
+
+`,
+ serverStatus: http.StatusOK,
+ expectError: false,
+ },
+ {
+ name: "Empty source",
+ source: "",
+ sourceAccount: "",
+ startItem: 1,
+ numItems: 50,
+ expectError: true,
+ errorContains: "source cannot be empty",
+ },
+ {
+ name: "Invalid startItem",
+ source: "TUNEIN",
+ sourceAccount: "",
+ startItem: 0,
+ numItems: 50,
+ expectError: true,
+ errorContains: "startItem must be >= 1",
+ },
+ {
+ name: "Invalid numItems",
+ source: "TUNEIN",
+ sourceAccount: "",
+ startItem: 1,
+ numItems: 0,
+ expectError: true,
+ errorContains: "numItems must be >= 1",
+ },
+ {
+ name: "Server error",
+ source: "TUNEIN",
+ startItem: 1,
+ numItems: 50,
+ serverStatus: http.StatusInternalServerError,
+ expectError: 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 tt.serverStatus != 0 {
+ w.WriteHeader(tt.serverStatus)
+ }
+ if tt.serverResponse != "" {
+ w.Write([]byte(tt.serverResponse))
+ }
+ }))
+ defer server.Close()
+
+ config := &Config{
+ Host: server.URL[7:],
+ Port: 80,
+ Timeout: testTimeout,
+ UserAgent: testUserAgent,
+ }
+ client := NewClient(config)
+ client.baseURL = server.URL
+
+ response, err := client.Navigate(tt.source, tt.sourceAccount, tt.startItem, tt.numItems)
+
+ if tt.expectError {
+ if err == nil {
+ t.Error("Expected error but got none")
+ } else if tt.errorContains != "" && !contains(err.Error(), tt.errorContains) {
+ t.Errorf("Expected error to contain '%s', got: %v", tt.errorContains, err)
+ }
+ return
+ }
+
+ if err != nil {
+ t.Errorf("Unexpected error: %v", err)
+ return
+ }
+
+ if response == nil {
+ t.Error("Expected response but got nil")
+ return
+ }
+
+ if response.Source != tt.source {
+ t.Errorf("Expected source %s, got %s", tt.source, response.Source)
+ }
+ })
+ }
+}
+
+func TestClient_NavigateWithMenu(t *testing.T) {
+ serverResponse := `
+
+ 5
+
+ -
+ My Station 1
+ stationurl
+
+ My Station 1
+
+
+
+`
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Verify request body contains menu and sort parameters
+ var request models.NavigateRequest
+ err := xml.NewDecoder(r.Body).Decode(&request)
+ if err != nil {
+ t.Errorf("Failed to decode request: %v", err)
+ }
+
+ if request.Menu != "radioStations" {
+ t.Errorf("Expected menu 'radioStations', got %s", request.Menu)
+ }
+ if request.Sort != "dateCreated" {
+ t.Errorf("Expected sort 'dateCreated', got %s", request.Sort)
+ }
+
+ w.Write([]byte(serverResponse))
+ }))
+ defer server.Close()
+
+ config := &Config{
+ Host: server.URL[7:],
+ Port: 80,
+ Timeout: testTimeout,
+ UserAgent: testUserAgent,
+ }
+ client := NewClient(config)
+ client.baseURL = server.URL
+
+ response, err := client.NavigateWithMenu("PANDORA", "user123", "radioStations", "dateCreated", 1, 100)
+
+ if err != nil {
+ t.Errorf("Unexpected error: %v", err)
+ return
+ }
+
+ if response.Source != "PANDORA" {
+ t.Errorf("Expected source PANDORA, got %s", response.Source)
+ }
+ if response.TotalItems != 5 {
+ t.Errorf("Expected totalItems 5, got %d", response.TotalItems)
+ }
+}
+
+func TestClient_NavigateContainer(t *testing.T) {
+ containerItem := &models.ContentItem{
+ Source: "STORED_MUSIC",
+ Location: "1",
+ SourceAccount: "device123/0",
+ IsPresetable: true,
+ ItemName: "Music",
+ }
+
+ serverResponse := `
+
+ 3
+
+ -
+ Album 1
+ dir
+
+ Album 1
+
+
+
+`
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Write([]byte(serverResponse))
+ }))
+ defer server.Close()
+
+ config := &Config{
+ Host: server.URL[7:],
+ Port: 80,
+ Timeout: testTimeout,
+ UserAgent: testUserAgent,
+ }
+ client := NewClient(config)
+ client.baseURL = server.URL
+
+ response, err := client.NavigateContainer("STORED_MUSIC", "device123/0", 1, 1000, containerItem)
+
+ if err != nil {
+ t.Errorf("Unexpected error: %v", err)
+ return
+ }
+
+ if response.Source != "STORED_MUSIC" {
+ t.Errorf("Expected source STORED_MUSIC, got %s", response.Source)
+ }
+
+ // Test error cases
+ _, err = client.NavigateContainer("", "device123/0", 1, 1000, containerItem)
+ if err == nil || !contains(err.Error(), "source cannot be empty") {
+ t.Error("Expected error for empty source")
+ }
+
+ _, err = client.NavigateContainer("STORED_MUSIC", "device123/0", 1, 1000, nil)
+ if err == nil || !contains(err.Error(), "container item cannot be nil") {
+ t.Error("Expected error for nil container item")
+ }
+}
+
+func TestClient_AddStation(t *testing.T) {
+ tests := []struct {
+ name string
+ source string
+ sourceAccount string
+ token string
+ stationName string
+ serverResponse string
+ serverStatus int
+ expectError bool
+ errorContains string
+ }{
+ {
+ name: "Valid add station",
+ source: "PANDORA",
+ sourceAccount: "user123",
+ token: "R4328162",
+ stationName: "Test Station",
+ serverResponse: `/addStation`,
+ serverStatus: http.StatusOK,
+ expectError: false,
+ },
+ {
+ name: "Empty source",
+ source: "",
+ sourceAccount: "user123",
+ token: "R4328162",
+ stationName: "Test Station",
+ expectError: true,
+ errorContains: "source cannot be empty",
+ },
+ {
+ name: "Empty token",
+ source: "PANDORA",
+ sourceAccount: "user123",
+ token: "",
+ stationName: "Test Station",
+ expectError: true,
+ errorContains: "token cannot be empty",
+ },
+ {
+ name: "Empty station name",
+ source: "PANDORA",
+ sourceAccount: "user123",
+ token: "R4328162",
+ stationName: "",
+ expectError: true,
+ errorContains: "station name cannot be empty",
+ },
+ {
+ name: "Server error",
+ source: "PANDORA",
+ token: "R4328162",
+ stationName: "Test Station",
+ serverStatus: http.StatusBadRequest,
+ expectError: 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 tt.serverStatus != 0 {
+ w.WriteHeader(tt.serverStatus)
+ }
+ if tt.serverResponse != "" {
+ w.Write([]byte(tt.serverResponse))
+ }
+
+ // Verify request format
+ if !tt.expectError {
+ var request models.AddStationRequest
+ err := xml.NewDecoder(r.Body).Decode(&request)
+ if err != nil {
+ t.Errorf("Failed to decode request: %v", err)
+ }
+
+ if request.Source != tt.source {
+ t.Errorf("Expected source %s, got %s", tt.source, request.Source)
+ }
+ if request.Token != tt.token {
+ t.Errorf("Expected token %s, got %s", tt.token, request.Token)
+ }
+ if request.Name != tt.stationName {
+ t.Errorf("Expected name %s, got %s", tt.stationName, request.Name)
+ }
+ }
+ }))
+ defer server.Close()
+
+ config := &Config{
+ Host: server.URL[7:],
+ Port: 80,
+ Timeout: testTimeout,
+ UserAgent: testUserAgent,
+ }
+ client := NewClient(config)
+ client.baseURL = server.URL
+
+ err := client.AddStation(tt.source, tt.sourceAccount, tt.token, tt.stationName)
+
+ if tt.expectError {
+ if err == nil {
+ t.Error("Expected error but got none")
+ } else if tt.errorContains != "" && !contains(err.Error(), tt.errorContains) {
+ t.Errorf("Expected error to contain '%s', got: %v", tt.errorContains, err)
+ }
+ } else {
+ if err != nil {
+ t.Errorf("Unexpected error: %v", err)
+ }
+ }
+ })
+ }
+}
+
+func TestClient_RemoveStation(t *testing.T) {
+ contentItem := &models.ContentItem{
+ Source: "PANDORA",
+ Location: "126740707481236361",
+ SourceAccount: "user123",
+ IsPresetable: true,
+ ItemName: "Test Station",
+ }
+
+ tests := []struct {
+ name string
+ contentItem *models.ContentItem
+ serverResponse string
+ serverStatus int
+ expectError bool
+ errorContains string
+ }{
+ {
+ name: "Valid remove station",
+ contentItem: contentItem,
+ serverResponse: `/removeStation`,
+ serverStatus: http.StatusOK,
+ expectError: false,
+ },
+ {
+ name: "Nil content item",
+ contentItem: nil,
+ expectError: true,
+ errorContains: "content item cannot be nil",
+ },
+ {
+ name: "Empty source",
+ contentItem: &models.ContentItem{
+ Source: "",
+ Location: "123",
+ },
+ expectError: true,
+ errorContains: "content item source cannot be empty",
+ },
+ {
+ name: "Empty location",
+ contentItem: &models.ContentItem{
+ Source: "PANDORA",
+ Location: "",
+ },
+ expectError: true,
+ errorContains: "content item location cannot be empty",
+ },
+ {
+ name: "Server error",
+ contentItem: contentItem,
+ serverStatus: http.StatusNotFound,
+ expectError: 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 tt.serverStatus != 0 {
+ w.WriteHeader(tt.serverStatus)
+ }
+ if tt.serverResponse != "" {
+ w.Write([]byte(tt.serverResponse))
+ }
+
+ // Verify request format
+ if !tt.expectError && tt.contentItem != nil {
+ var request models.ContentItem
+ err := xml.NewDecoder(r.Body).Decode(&request)
+ if err != nil {
+ t.Errorf("Failed to decode request: %v", err)
+ }
+
+ if request.Source != tt.contentItem.Source {
+ t.Errorf("Expected source %s, got %s", tt.contentItem.Source, request.Source)
+ }
+ if request.Location != tt.contentItem.Location {
+ t.Errorf("Expected location %s, got %s", tt.contentItem.Location, request.Location)
+ }
+ }
+ }))
+ defer server.Close()
+
+ config := &Config{
+ Host: server.URL[7:],
+ Port: 80,
+ Timeout: testTimeout,
+ UserAgent: testUserAgent,
+ }
+ client := NewClient(config)
+ client.baseURL = server.URL
+
+ err := client.RemoveStation(tt.contentItem)
+
+ if tt.expectError {
+ if err == nil {
+ t.Error("Expected error but got none")
+ } else if tt.errorContains != "" && !contains(err.Error(), tt.errorContains) {
+ t.Errorf("Expected error to contain '%s', got: %v", tt.errorContains, err)
+ }
+ } else {
+ if err != nil {
+ t.Errorf("Unexpected error: %v", err)
+ }
+ }
+ })
+ }
+}
+
+func TestClient_GetPandoraStations(t *testing.T) {
+ serverResponse := `
+
+ 2
+
+ -
+ Station 1
+ stationurl
+
+ Station 1
+
+
+
+`
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Verify it's calling navigate with the right parameters
+ var request models.NavigateRequest
+ xml.NewDecoder(r.Body).Decode(&request)
+
+ if request.Source != "PANDORA" {
+ t.Errorf("Expected source PANDORA, got %s", request.Source)
+ }
+ if request.Menu != "radioStations" {
+ t.Errorf("Expected menu radioStations, got %s", request.Menu)
+ }
+ if request.Sort != "dateCreated" {
+ t.Errorf("Expected sort dateCreated, got %s", request.Sort)
+ }
+
+ w.Write([]byte(serverResponse))
+ }))
+ defer server.Close()
+
+ config := &Config{
+ Host: server.URL[7:],
+ Port: 80,
+ Timeout: testTimeout,
+ UserAgent: testUserAgent,
+ }
+ client := NewClient(config)
+ client.baseURL = server.URL
+
+ response, err := client.GetPandoraStations("user123")
+
+ if err != nil {
+ t.Errorf("Unexpected error: %v", err)
+ return
+ }
+
+ if response.Source != "PANDORA" {
+ t.Errorf("Expected source PANDORA, got %s", response.Source)
+ }
+
+ // Test error case
+ _, err = client.GetPandoraStations("")
+ if err == nil || !contains(err.Error(), "Pandora source account cannot be empty") {
+ t.Error("Expected error for empty source account")
+ }
+}
+
+func TestClient_GetTuneInStations(t *testing.T) {
+ serverResponse := `
+
+ 1
+
+ -
+ Radio Station
+ stationurl
+
+ Radio Station
+
+
+
+`
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Write([]byte(serverResponse))
+ }))
+ defer server.Close()
+
+ config := &Config{
+ Host: server.URL[7:],
+ Port: 80,
+ Timeout: testTimeout,
+ UserAgent: testUserAgent,
+ }
+ client := NewClient(config)
+ client.baseURL = server.URL
+
+ response, err := client.GetTuneInStations("")
+
+ if err != nil {
+ t.Errorf("Unexpected error: %v", err)
+ return
+ }
+
+ if response.Source != "TUNEIN" {
+ t.Errorf("Expected source TUNEIN, got %s", response.Source)
+ }
+}
+
+func TestClient_GetStoredMusicLibrary(t *testing.T) {
+ serverResponse := `
+
+ 1
+
+ -
+ My Music
+ dir
+
+ My Music
+
+
+
+`
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Write([]byte(serverResponse))
+ }))
+ defer server.Close()
+
+ config := &Config{
+ Host: server.URL[7:],
+ Port: 80,
+ Timeout: testTimeout,
+ UserAgent: testUserAgent,
+ }
+ client := NewClient(config)
+ client.baseURL = server.URL
+
+ response, err := client.GetStoredMusicLibrary("device123/0")
+
+ if err != nil {
+ t.Errorf("Unexpected error: %v", err)
+ return
+ }
+
+ if response.Source != "STORED_MUSIC" {
+ t.Errorf("Expected source STORED_MUSIC, got %s", response.Source)
+ }
+
+ // Test error case
+ _, err = client.GetStoredMusicLibrary("")
+ if err == nil || !contains(err.Error(), "stored music source account cannot be empty") {
+ t.Error("Expected error for empty source account")
+ }
+}
+
+func TestClient_SearchStation(t *testing.T) {
+ tests := []struct {
+ name string
+ source string
+ sourceAccount string
+ searchTerm string
+ serverResponse string
+ serverStatus int
+ expectError bool
+ errorContains string
+ }{
+ {
+ name: "Valid Pandora search",
+ source: "PANDORA",
+ sourceAccount: "user123",
+ searchTerm: "Zach Williams",
+ serverResponse: `
+
+
+
+ Old Church Choir
+ Zach Williams
+ http://example.com/song.jpg
+
+
+
+
+ Zach Williams
+ http://example.com/artist.jpg
+
+
+`,
+ serverStatus: http.StatusOK,
+ expectError: false,
+ },
+ {
+ name: "Valid TuneIn search",
+ source: "TUNEIN",
+ sourceAccount: "",
+ searchTerm: "Classic Rock",
+ serverResponse: `
+
+
+
+ Classic Rock 101.5
+ The best classic rock hits
+ http://example.com/station.jpg
+
+
+`,
+ serverStatus: http.StatusOK,
+ expectError: false,
+ },
+ {
+ name: "Empty source",
+ source: "",
+ sourceAccount: "user123",
+ searchTerm: "test",
+ expectError: true,
+ errorContains: "source cannot be empty",
+ },
+ {
+ name: "Empty search term",
+ source: "PANDORA",
+ sourceAccount: "user123",
+ searchTerm: "",
+ expectError: true,
+ errorContains: "search term cannot be empty",
+ },
+ {
+ name: "Server error",
+ source: "PANDORA",
+ sourceAccount: "user123",
+ searchTerm: "test",
+ serverStatus: http.StatusBadRequest,
+ expectError: 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 tt.serverStatus != 0 {
+ w.WriteHeader(tt.serverStatus)
+ }
+ if tt.serverResponse != "" {
+ w.Write([]byte(tt.serverResponse))
+ }
+
+ // Verify request format for valid requests
+ if !tt.expectError {
+ var request models.SearchStationRequest
+ err := xml.NewDecoder(r.Body).Decode(&request)
+ if err != nil {
+ t.Errorf("Failed to decode request: %v", err)
+ }
+
+ if request.Source != tt.source {
+ t.Errorf("Expected source %s, got %s", tt.source, request.Source)
+ }
+ if request.SearchTerm != tt.searchTerm {
+ t.Errorf("Expected searchTerm %s, got %s", tt.searchTerm, request.SearchTerm)
+ }
+ }
+ }))
+ defer server.Close()
+
+ config := &Config{
+ Host: server.URL[7:],
+ Port: 80,
+ Timeout: testTimeout,
+ UserAgent: testUserAgent,
+ }
+ client := NewClient(config)
+ client.baseURL = server.URL
+
+ response, err := client.SearchStation(tt.source, tt.sourceAccount, tt.searchTerm)
+
+ if tt.expectError {
+ if err == nil {
+ t.Error("Expected error but got none")
+ } else if tt.errorContains != "" && !contains(err.Error(), tt.errorContains) {
+ t.Errorf("Expected error to contain '%s', got: %v", tt.errorContains, err)
+ }
+ return
+ }
+
+ if err != nil {
+ t.Errorf("Unexpected error: %v", err)
+ return
+ }
+
+ if response == nil {
+ t.Error("Expected response but got nil")
+ return
+ }
+
+ if response.Source != tt.source {
+ t.Errorf("Expected source %s, got %s", tt.source, response.Source)
+ }
+ })
+ }
+}
+
+func TestClient_SearchPandoraStations(t *testing.T) {
+ serverResponse := `
+
+
+
+ Taylor Swift
+ http://example.com/artist.jpg
+
+
+`
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Verify it's calling searchStation with the right parameters
+ var request models.SearchStationRequest
+ xml.NewDecoder(r.Body).Decode(&request)
+
+ if request.Source != "PANDORA" {
+ t.Errorf("Expected source PANDORA, got %s", request.Source)
+ }
+ if request.SourceAccount != "user123" {
+ t.Errorf("Expected sourceAccount user123, got %s", request.SourceAccount)
+ }
+ if request.SearchTerm != "Taylor Swift" {
+ t.Errorf("Expected searchTerm 'Taylor Swift', got %s", request.SearchTerm)
+ }
+
+ w.Write([]byte(serverResponse))
+ }))
+ defer server.Close()
+
+ config := &Config{
+ Host: server.URL[7:],
+ Port: 80,
+ Timeout: testTimeout,
+ UserAgent: testUserAgent,
+ }
+ client := NewClient(config)
+ client.baseURL = server.URL
+
+ response, err := client.SearchPandoraStations("user123", "Taylor Swift")
+
+ if err != nil {
+ t.Errorf("Unexpected error: %v", err)
+ return
+ }
+
+ if response.Source != "PANDORA" {
+ t.Errorf("Expected source PANDORA, got %s", response.Source)
+ }
+
+ // Test error case
+ _, err = client.SearchPandoraStations("", "test")
+ if err == nil || !contains(err.Error(), "Pandora source account cannot be empty") {
+ t.Error("Expected error for empty source account")
+ }
+}
+
+func TestClient_SearchTuneInStations(t *testing.T) {
+ serverResponse := `
+
+
+
+ Jazz 24/7
+ Smooth jazz all day
+ http://example.com/jazz.jpg
+
+
+`
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Write([]byte(serverResponse))
+ }))
+ defer server.Close()
+
+ config := &Config{
+ Host: server.URL[7:],
+ Port: 80,
+ Timeout: testTimeout,
+ UserAgent: testUserAgent,
+ }
+ client := NewClient(config)
+ client.baseURL = server.URL
+
+ response, err := client.SearchTuneInStations("Jazz")
+
+ if err != nil {
+ t.Errorf("Unexpected error: %v", err)
+ return
+ }
+
+ if response.Source != "TUNEIN" {
+ t.Errorf("Expected source TUNEIN, got %s", response.Source)
+ }
+
+ if len(response.Stations) != 1 {
+ t.Errorf("Expected 1 station result, got %d", len(response.Stations))
+ }
+}
+
+func TestClient_SearchSpotifyContent(t *testing.T) {
+ serverResponse := `
+
+
+
+ Bohemian Rhapsody
+ Queen
+ A Night at the Opera
+ http://example.com/queen.jpg
+
+
+`
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Write([]byte(serverResponse))
+ }))
+ defer server.Close()
+
+ config := &Config{
+ Host: server.URL[7:],
+ Port: 80,
+ Timeout: testTimeout,
+ UserAgent: testUserAgent,
+ }
+ client := NewClient(config)
+ client.baseURL = server.URL
+
+ response, err := client.SearchSpotifyContent("user@example.com", "Queen")
+
+ if err != nil {
+ t.Errorf("Unexpected error: %v", err)
+ return
+ }
+
+ if response.Source != "SPOTIFY" {
+ t.Errorf("Expected source SPOTIFY, got %s", response.Source)
+ }
+
+ // Test error case
+ _, err = client.SearchSpotifyContent("", "test")
+ if err == nil || !contains(err.Error(), "Spotify source account cannot be empty") {
+ t.Error("Expected error for empty source account")
+ }
+}
+
+// Helper functions are already defined in other test files
diff --git a/pkg/client/navigation_xml_test.go b/pkg/client/navigation_xml_test.go
new file mode 100644
index 0000000..22b9bbc
--- /dev/null
+++ b/pkg/client/navigation_xml_test.go
@@ -0,0 +1,678 @@
+package client
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/gesellix/bose-soundtouch/pkg/models"
+)
+
+func TestClient_NavigateXMLValidation(t *testing.T) {
+ tests := []struct {
+ name string
+ source string
+ sourceAccount string
+ startItem int
+ numItems int
+ expectedXML string
+ expectedEndpoint string
+ }{
+ {
+ name: "Basic navigate XML structure",
+ source: "TUNEIN",
+ sourceAccount: "",
+ startItem: 1,
+ numItems: 25,
+ expectedXML: `125`,
+ expectedEndpoint: "/navigate",
+ },
+ {
+ name: "Navigate with source account",
+ source: "SPOTIFY",
+ sourceAccount: "user@example.com",
+ startItem: 10,
+ numItems: 50,
+ expectedXML: `1050`,
+ expectedEndpoint: "/navigate",
+ },
+ {
+ name: "Navigate stored music with device account",
+ source: "STORED_MUSIC",
+ sourceAccount: "device123456/0",
+ startItem: 1,
+ numItems: 1000,
+ expectedXML: `11000`,
+ expectedEndpoint: "/navigate",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ var capturedXML string
+ var capturedEndpoint string
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ capturedEndpoint = r.URL.Path
+
+ body := make([]byte, r.ContentLength)
+ r.Body.Read(body)
+ capturedXML = string(body)
+
+ // Return valid navigate response
+ w.Write([]byte(`
+
+ 0
+
+`))
+ }))
+ defer server.Close()
+
+ config := &Config{
+ Host: server.URL[7:],
+ Port: 80,
+ Timeout: testTimeout,
+ UserAgent: testUserAgent,
+ }
+ client := NewClient(config)
+ client.baseURL = server.URL
+
+ _, err := client.Navigate(tt.source, tt.sourceAccount, tt.startItem, tt.numItems)
+ if err != nil {
+ t.Fatalf("Navigate failed: %v", err)
+ }
+
+ if capturedEndpoint != tt.expectedEndpoint {
+ t.Errorf("Expected endpoint %s, got %s", tt.expectedEndpoint, capturedEndpoint)
+ }
+
+ if capturedXML != tt.expectedXML {
+ t.Errorf("XML mismatch:\nExpected: %s\nActual: %s", tt.expectedXML, capturedXML)
+ }
+ })
+ }
+}
+
+func TestClient_NavigateWithMenuXMLValidation(t *testing.T) {
+ var capturedXML string
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body := make([]byte, r.ContentLength)
+ r.Body.Read(body)
+ capturedXML = string(body)
+
+ w.Write([]byte(`
+
+ 0
+
+`))
+ }))
+ defer server.Close()
+
+ config := &Config{
+ Host: server.URL[7:],
+ Port: 80,
+ Timeout: testTimeout,
+ UserAgent: testUserAgent,
+ }
+ client := NewClient(config)
+ client.baseURL = server.URL
+
+ _, err := client.NavigateWithMenu("PANDORA", "user123", "radioStations", "dateCreated", 1, 100)
+ if err != nil {
+ t.Fatalf("NavigateWithMenu failed: %v", err)
+ }
+
+ expectedXML := `1100`
+ if capturedXML != expectedXML {
+ t.Errorf("XML mismatch:\nExpected: %s\nActual: %s", expectedXML, capturedXML)
+ }
+}
+
+func TestClient_SearchStationXMLValidation(t *testing.T) {
+ tests := []struct {
+ name string
+ source string
+ sourceAccount string
+ searchTerm string
+ expectedXML string
+ }{
+ {
+ name: "Basic search XML",
+ source: "PANDORA",
+ sourceAccount: "user123",
+ searchTerm: "Taylor Swift",
+ expectedXML: `Taylor Swift`,
+ },
+ {
+ name: "Search without account",
+ source: "TUNEIN",
+ sourceAccount: "",
+ searchTerm: "Jazz Radio",
+ expectedXML: `Jazz Radio`,
+ },
+ {
+ name: "Search with special characters",
+ source: "SPOTIFY",
+ sourceAccount: "user@example.com",
+ searchTerm: "Rock & Roll",
+ expectedXML: `Rock & Roll`,
+ },
+ {
+ name: "Search with quotes",
+ source: "PANDORA",
+ sourceAccount: "user",
+ searchTerm: `"The Beatles"`,
+ expectedXML: `"The Beatles"`,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ var capturedXML string
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body := make([]byte, r.ContentLength)
+ r.Body.Read(body)
+ capturedXML = string(body)
+
+ w.Write([]byte(`
+
+
+
+
+`))
+ }))
+ defer server.Close()
+
+ config := &Config{
+ Host: server.URL[7:],
+ Port: 80,
+ Timeout: testTimeout,
+ UserAgent: testUserAgent,
+ }
+ client := NewClient(config)
+ client.baseURL = server.URL
+
+ _, err := client.SearchStation(tt.source, tt.sourceAccount, tt.searchTerm)
+ if err != nil {
+ t.Fatalf("SearchStation failed: %v", err)
+ }
+
+ if capturedXML != tt.expectedXML {
+ t.Errorf("XML mismatch:\nExpected: %s\nActual: %s", tt.expectedXML, capturedXML)
+ }
+ })
+ }
+}
+
+func TestClient_AddStationXMLValidation(t *testing.T) {
+ var capturedXML string
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body := make([]byte, r.ContentLength)
+ r.Body.Read(body)
+ capturedXML = string(body)
+
+ w.Write([]byte(`/addStation`))
+ }))
+ defer server.Close()
+
+ config := &Config{
+ Host: server.URL[7:],
+ Port: 80,
+ Timeout: testTimeout,
+ UserAgent: testUserAgent,
+ }
+ client := NewClient(config)
+ client.baseURL = server.URL
+
+ err := client.AddStation("PANDORA", "user123", "R4328162", "Test Station")
+ if err != nil {
+ t.Fatalf("AddStation failed: %v", err)
+ }
+
+ expectedXML := `Test Station`
+ if capturedXML != expectedXML {
+ t.Errorf("XML mismatch:\nExpected: %s\nActual: %s", expectedXML, capturedXML)
+ }
+}
+
+func TestClient_RemoveStationXMLValidation(t *testing.T) {
+ contentItem := &models.ContentItem{
+ Source: "PANDORA",
+ Location: "126740707481236361",
+ SourceAccount: "user123",
+ IsPresetable: true,
+ ItemName: "Test Station",
+ }
+
+ var capturedXML string
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body := make([]byte, r.ContentLength)
+ r.Body.Read(body)
+ capturedXML = string(body)
+
+ w.Write([]byte(`/removeStation`))
+ }))
+ defer server.Close()
+
+ config := &Config{
+ Host: server.URL[7:],
+ Port: 80,
+ Timeout: testTimeout,
+ UserAgent: testUserAgent,
+ }
+ client := NewClient(config)
+ client.baseURL = server.URL
+
+ err := client.RemoveStation(contentItem)
+ if err != nil {
+ t.Fatalf("RemoveStation failed: %v", err)
+ }
+
+ // Verify the XML contains the expected ContentItem structure
+ if !strings.Contains(capturedXML, `source="PANDORA"`) {
+ t.Error("XML should contain source attribute")
+ }
+ if !strings.Contains(capturedXML, `location="126740707481236361"`) {
+ t.Error("XML should contain location attribute")
+ }
+ if !strings.Contains(capturedXML, `Test Station`) {
+ t.Error("XML should contain itemName element")
+ }
+}
+
+func TestClient_NavigationResponseParsing(t *testing.T) {
+ tests := []struct {
+ name string
+ responseXML string
+ expectError bool
+ expectedItems int
+ expectedTotal int
+ }{
+ {
+ name: "Valid complex response",
+ responseXML: `
+
+ 3
+
+ -
+ Album Artists
+ dir
+
+ Album Artists
+ http://example.com/art.jpg
+
+
+ -
+ Test Track
+ track
+
+ Test Track
+
+ Test Artist
+ Test Album
+
+ -
+ Non-playable Item
+ unknown
+
+
+`,
+ expectError: false,
+ expectedItems: 3,
+ expectedTotal: 3,
+ },
+ {
+ name: "Empty response",
+ responseXML: `
+
+ 0
+
+`,
+ expectError: false,
+ expectedItems: 0,
+ expectedTotal: 0,
+ },
+ {
+ name: "Invalid XML",
+ responseXML: `
+
+ 1
+
+ -
+ Unclosed item
+
+ `,
+ expectError: 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) {
+ w.Write([]byte(tt.responseXML))
+ }))
+ defer server.Close()
+
+ config := &Config{
+ Host: server.URL[7:],
+ Port: 80,
+ Timeout: testTimeout,
+ UserAgent: testUserAgent,
+ }
+ client := NewClient(config)
+ client.baseURL = server.URL
+
+ response, err := client.Navigate("TUNEIN", "", 1, 10)
+
+ if tt.expectError {
+ if err == nil {
+ t.Error("Expected error but got none")
+ }
+ return
+ }
+
+ if err != nil {
+ t.Fatalf("Unexpected error: %v", err)
+ }
+
+ if len(response.Items) != tt.expectedItems {
+ t.Errorf("Expected %d items, got %d", tt.expectedItems, len(response.Items))
+ }
+
+ if response.TotalItems != tt.expectedTotal {
+ t.Errorf("Expected total %d, got %d", tt.expectedTotal, response.TotalItems)
+ }
+
+ // Test helper methods for complex response
+ if tt.name == "Valid complex response" {
+ playable := response.GetPlayableItems()
+ if len(playable) != 2 {
+ t.Errorf("Expected 2 playable items, got %d", len(playable))
+ }
+
+ directories := response.GetDirectories()
+ if len(directories) != 1 {
+ t.Errorf("Expected 1 directory, got %d", len(directories))
+ }
+
+ tracks := response.GetTracks()
+ if len(tracks) != 1 {
+ t.Errorf("Expected 1 track, got %d", len(tracks))
+ }
+
+ // Test individual item properties
+ firstItem := response.Items[0]
+ if !firstItem.IsPlayable() {
+ t.Error("First item should be playable")
+ }
+ if !firstItem.IsDirectory() {
+ t.Error("First item should be directory")
+ }
+ if firstItem.GetArtwork() == "" {
+ t.Error("First item should have artwork")
+ }
+
+ secondItem := response.Items[1]
+ if !secondItem.IsTrack() {
+ t.Error("Second item should be track")
+ }
+ if secondItem.ArtistName != "Test Artist" {
+ t.Errorf("Expected artist 'Test Artist', got %s", secondItem.ArtistName)
+ }
+
+ thirdItem := response.Items[2]
+ if thirdItem.IsPlayable() {
+ t.Error("Third item should not be playable")
+ }
+ }
+ })
+ }
+}
+
+func TestClient_SearchStationResponseParsing(t *testing.T) {
+ responseXML := `
+
+
+
+ Old Church Choir
+ Zach Williams
+ Chain Breaker
+ http://example.com/song.jpg
+
+
+ Fear Is a Liar
+ Zach Williams
+ http://example.com/song2.jpg
+
+
+
+
+ Zach Williams
+ http://example.com/artist.jpg
+
+
+
+
+ Christian Rock Radio
+ The best in Christian rock music
+ http://example.com/station.jpg
+
+
+`
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Write([]byte(responseXML))
+ }))
+ defer server.Close()
+
+ config := &Config{
+ Host: server.URL[7:],
+ Port: 80,
+ Timeout: testTimeout,
+ UserAgent: testUserAgent,
+ }
+ client := NewClient(config)
+ client.baseURL = server.URL
+
+ response, err := client.SearchStation("PANDORA", "user123", "Zach Williams")
+ if err != nil {
+ t.Fatalf("SearchStation failed: %v", err)
+ }
+
+ // Test basic properties
+ if response.DeviceID != "1004567890AA" {
+ t.Errorf("Expected deviceID '1004567890AA', got %s", response.DeviceID)
+ }
+ if response.Source != "PANDORA" {
+ t.Errorf("Expected source PANDORA, got %s", response.Source)
+ }
+
+ // Test result categorization
+ songs := response.GetSongs()
+ if len(songs) != 2 {
+ t.Errorf("Expected 2 songs, got %d", len(songs))
+ }
+
+ artists := response.GetArtists()
+ if len(artists) != 1 {
+ t.Errorf("Expected 1 artist, got %d", len(artists))
+ }
+
+ stations := response.GetStations()
+ if len(stations) != 1 {
+ t.Errorf("Expected 1 station, got %d", len(stations))
+ }
+
+ // Test total result count
+ if response.GetResultCount() != 4 {
+ t.Errorf("Expected 4 total results, got %d", response.GetResultCount())
+ }
+
+ // Test individual result properties
+ song := songs[0]
+ if !song.IsSong() {
+ t.Error("First result should be identified as song")
+ }
+ if song.GetFullTitle() != "Old Church Choir - Zach Williams" {
+ t.Errorf("Expected 'Old Church Choir - Zach Williams', got %s", song.GetFullTitle())
+ }
+
+ artist := artists[0]
+ if !artist.IsArtist() {
+ t.Error("Artist result should be identified as artist")
+ }
+ if artist.GetDisplayName() != "Zach Williams" {
+ t.Errorf("Expected 'Zach Williams', got %s", artist.GetDisplayName())
+ }
+
+ station := stations[0]
+ if !station.IsStation() {
+ t.Error("Station result should be identified as station")
+ }
+ if station.Description == "" {
+ t.Error("Station should have description")
+ }
+
+ // Test response helper methods
+ allResults := response.GetAllResults()
+ if len(allResults) != 4 {
+ t.Errorf("Expected 4 total results, got %d", len(allResults))
+ }
+
+ if response.IsEmpty() {
+ t.Error("Response should not be empty")
+ }
+
+ if !response.HasResults() {
+ t.Error("Response should have results")
+ }
+}
+
+func TestClient_NavigationHTTPHeaders(t *testing.T) {
+ var capturedHeaders http.Header
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ capturedHeaders = r.Header
+
+ w.Write([]byte(`
+
+ 0
+
+`))
+ }))
+ defer server.Close()
+
+ config := &Config{
+ Host: server.URL[7:],
+ Port: 80,
+ Timeout: testTimeout,
+ UserAgent: "Custom-Test-Agent/1.0",
+ }
+ client := NewClient(config)
+ client.baseURL = server.URL
+
+ _, err := client.Navigate("TUNEIN", "", 1, 10)
+ if err != nil {
+ t.Fatalf("Navigate failed: %v", err)
+ }
+
+ // Verify HTTP headers
+ if capturedHeaders.Get("Content-Type") != "application/xml" {
+ t.Errorf("Expected Content-Type 'application/xml', got %s", capturedHeaders.Get("Content-Type"))
+ }
+
+ if capturedHeaders.Get("Accept") != "application/xml" {
+ t.Errorf("Expected Accept 'application/xml', got %s", capturedHeaders.Get("Accept"))
+ }
+
+ if capturedHeaders.Get("User-Agent") != "Custom-Test-Agent/1.0" {
+ t.Errorf("Expected User-Agent 'Custom-Test-Agent/1.0', got %s", capturedHeaders.Get("User-Agent"))
+ }
+}
+
+func TestClient_NavigationEdgeCases(t *testing.T) {
+ t.Run("NavigateContainer_NilContentItem", func(t *testing.T) {
+ config := &Config{
+ Host: "localhost",
+ Port: 8090,
+ Timeout: testTimeout,
+ UserAgent: testUserAgent,
+ }
+ client := NewClient(config)
+
+ _, err := client.NavigateContainer("STORED_MUSIC", "device/0", 1, 100, nil)
+ if err == nil || !strings.Contains(err.Error(), "container item cannot be nil") {
+ t.Error("Expected error for nil container item")
+ }
+ })
+
+ t.Run("SearchStation_EmptySearchTerm", func(t *testing.T) {
+ config := &Config{
+ Host: "localhost",
+ Port: 8090,
+ Timeout: testTimeout,
+ UserAgent: testUserAgent,
+ }
+ client := NewClient(config)
+
+ _, err := client.SearchStation("PANDORA", "user", "")
+ if err == nil || !strings.Contains(err.Error(), "search term cannot be empty") {
+ t.Error("Expected error for empty search term")
+ }
+ })
+
+ t.Run("AddStation_EmptyParameters", func(t *testing.T) {
+ config := &Config{
+ Host: "localhost",
+ Port: 8090,
+ Timeout: testTimeout,
+ UserAgent: testUserAgent,
+ }
+ client := NewClient(config)
+
+ // Test empty source
+ err := client.AddStation("", "user", "token", "name")
+ if err == nil || !strings.Contains(err.Error(), "source cannot be empty") {
+ t.Error("Expected error for empty source")
+ }
+
+ // Test empty token
+ err = client.AddStation("PANDORA", "user", "", "name")
+ if err == nil || !strings.Contains(err.Error(), "token cannot be empty") {
+ t.Error("Expected error for empty token")
+ }
+
+ // Test empty name
+ err = client.AddStation("PANDORA", "user", "token", "")
+ if err == nil || !strings.Contains(err.Error(), "station name cannot be empty") {
+ t.Error("Expected error for empty station name")
+ }
+ })
+
+ t.Run("Navigate_InvalidRange", func(t *testing.T) {
+ config := &Config{
+ Host: "localhost",
+ Port: 8090,
+ Timeout: testTimeout,
+ UserAgent: testUserAgent,
+ }
+ client := NewClient(config)
+
+ // Test invalid startItem
+ _, err := client.Navigate("TUNEIN", "", 0, 10)
+ if err == nil || !strings.Contains(err.Error(), "startItem must be >= 1") {
+ t.Error("Expected error for invalid startItem")
+ }
+
+ // Test invalid numItems
+ _, err = client.Navigate("TUNEIN", "", 1, 0)
+ if err == nil || !strings.Contains(err.Error(), "numItems must be >= 1") {
+ t.Error("Expected error for invalid numItems")
+ }
+ })
+}
diff --git a/pkg/models/navigation.go b/pkg/models/navigation.go
new file mode 100644
index 0000000..a1a3466
--- /dev/null
+++ b/pkg/models/navigation.go
@@ -0,0 +1,329 @@
+package models
+
+import "encoding/xml"
+
+// NavigateRequest represents a request to navigate content sources
+type NavigateRequest struct {
+ XMLName xml.Name `xml:"navigate"`
+ Source string `xml:"source,attr"`
+ SourceAccount string `xml:"sourceAccount,attr,omitempty"`
+ Menu string `xml:"menu,attr,omitempty"`
+ Sort string `xml:"sort,attr,omitempty"`
+ StartItem int `xml:"startItem"`
+ NumItems int `xml:"numItems"`
+ Item *NavigateItem `xml:"item,omitempty"`
+}
+
+// NavigateResponse represents the response from a navigate request
+type NavigateResponse struct {
+ XMLName xml.Name `xml:"navigateResponse"`
+ Source string `xml:"source,attr"`
+ SourceAccount string `xml:"sourceAccount,attr,omitempty"`
+ TotalItems int `xml:"totalItems"`
+ Items []NavigateItem `xml:"items>item"`
+}
+
+// NavigateItem represents a single item in a navigate response
+type NavigateItem struct {
+ XMLName xml.Name `xml:"item"`
+ Playable int `xml:"Playable,attr,omitempty"`
+ Name string `xml:"name"`
+ Type string `xml:"type"`
+ ContentItem *ContentItem `xml:"ContentItem,omitempty"`
+ MediaItemContainer *MediaItemContainer `xml:"mediaItemContainer,omitempty"`
+ ArtistName string `xml:"artistName,omitempty"`
+ AlbumName string `xml:"albumName,omitempty"`
+}
+
+// MediaItemContainer represents a media container within a navigate item
+type MediaItemContainer struct {
+ XMLName xml.Name `xml:"mediaItemContainer"`
+ Offset int `xml:"offset,attr"`
+ ContentItem *ContentItem `xml:"ContentItem,omitempty"`
+}
+
+// AddStationRequest represents a request to add a station
+type AddStationRequest struct {
+ XMLName xml.Name `xml:"addStation"`
+ Source string `xml:"source,attr"`
+ SourceAccount string `xml:"sourceAccount,attr,omitempty"`
+ Token string `xml:"token,attr,omitempty"`
+ Name string `xml:"name"`
+}
+
+// RemoveStationRequest represents a request to remove a station
+// Note: For removeStation, we send the ContentItem directly, not wrapped
+type RemoveStationRequest = ContentItem
+
+// StationResponse represents the response from add/remove station operations
+type StationResponse struct {
+ XMLName xml.Name `xml:"status"`
+ Status string `xml:",chardata"`
+}
+
+// SearchStationRequest represents a request to search for stations
+type SearchStationRequest struct {
+ XMLName xml.Name `xml:"search"`
+ Source string `xml:"source,attr"`
+ SourceAccount string `xml:"sourceAccount,attr,omitempty"`
+ SearchTerm string `xml:",chardata"`
+}
+
+// SearchStationResponse represents the response from a station search
+type SearchStationResponse struct {
+ XMLName xml.Name `xml:"results"`
+ DeviceID string `xml:"deviceID,attr"`
+ Source string `xml:"source,attr"`
+ SourceAccount string `xml:"sourceAccount,attr,omitempty"`
+ Songs []SearchResult `xml:"songs>searchResult"`
+ Artists []SearchResult `xml:"artists>searchResult"`
+ Stations []SearchResult `xml:"stations>searchResult"`
+}
+
+// SearchResult represents a single search result (song, artist, or station)
+type SearchResult struct {
+ XMLName xml.Name `xml:"searchResult"`
+ Source string `xml:"source,attr"`
+ SourceAccount string `xml:"sourceAccount,attr,omitempty"`
+ Token string `xml:"token,attr"`
+ Name string `xml:"name"`
+ Artist string `xml:"artist,omitempty"`
+ Album string `xml:"album,omitempty"`
+ Logo string `xml:"logo,omitempty"`
+ Description string `xml:"description,omitempty"`
+}
+
+// NewNavigateRequest creates a new navigate request for browsing content
+func NewNavigateRequest(source, sourceAccount string, startItem, numItems int) *NavigateRequest {
+ return &NavigateRequest{
+ Source: source,
+ SourceAccount: sourceAccount,
+ StartItem: startItem,
+ NumItems: numItems,
+ }
+}
+
+// NewNavigateRequestWithMenu creates a navigate request with menu and sort parameters
+func NewNavigateRequestWithMenu(source, sourceAccount, menu, sort string, startItem, numItems int) *NavigateRequest {
+ return &NavigateRequest{
+ Source: source,
+ SourceAccount: sourceAccount,
+ Menu: menu,
+ Sort: sort,
+ StartItem: startItem,
+ NumItems: numItems,
+ }
+}
+
+// NewNavigateRequestWithItem creates a navigate request to browse a specific container item
+func NewNavigateRequestWithItem(source, sourceAccount string, startItem, numItems int, item *ContentItem) *NavigateRequest {
+ navigateItem := &NavigateItem{
+ Playable: 1,
+ Name: item.ItemName,
+ Type: "dir",
+ ContentItem: item,
+ }
+
+ return &NavigateRequest{
+ Source: source,
+ SourceAccount: sourceAccount,
+ StartItem: startItem,
+ NumItems: numItems,
+ Item: navigateItem,
+ }
+}
+
+// NewAddStationRequest creates a new add station request
+func NewAddStationRequest(source, sourceAccount, token, name string) *AddStationRequest {
+ return &AddStationRequest{
+ Source: source,
+ SourceAccount: sourceAccount,
+ Token: token,
+ Name: name,
+ }
+}
+
+// NewRemoveStationRequest creates a new remove station request
+func NewRemoveStationRequest(contentItem *ContentItem) *RemoveStationRequest {
+ return contentItem
+}
+
+// NewSearchStationRequest creates a new search station request
+func NewSearchStationRequest(source, sourceAccount, searchTerm string) *SearchStationRequest {
+ return &SearchStationRequest{
+ Source: source,
+ SourceAccount: sourceAccount,
+ SearchTerm: searchTerm,
+ }
+}
+
+// GetPlayableItems returns only the playable items from the navigate response
+func (nr *NavigateResponse) GetPlayableItems() []NavigateItem {
+ var playable []NavigateItem
+ for _, item := range nr.Items {
+ if item.Playable == 1 {
+ playable = append(playable, item)
+ }
+ }
+ return playable
+}
+
+// GetDirectories returns only the directory items from the navigate response
+func (nr *NavigateResponse) GetDirectories() []NavigateItem {
+ var directories []NavigateItem
+ for _, item := range nr.Items {
+ if item.Type == "dir" {
+ directories = append(directories, item)
+ }
+ }
+ return directories
+}
+
+// GetTracks returns only the track items from the navigate response
+func (nr *NavigateResponse) GetTracks() []NavigateItem {
+ var tracks []NavigateItem
+ for _, item := range nr.Items {
+ if item.Type == "track" {
+ tracks = append(tracks, item)
+ }
+ }
+ return tracks
+}
+
+// GetStations returns only the station items from the navigate response
+func (nr *NavigateResponse) GetStations() []NavigateItem {
+ var stations []NavigateItem
+ for _, item := range nr.Items {
+ if item.Type == "stationurl" || (item.ContentItem != nil && item.ContentItem.Type == "stationurl") {
+ stations = append(stations, item)
+ }
+ }
+ return stations
+}
+
+// IsEmpty returns true if the navigate response contains no items
+func (nr *NavigateResponse) IsEmpty() bool {
+ return nr.TotalItems == 0 || len(nr.Items) == 0
+}
+
+// GetDisplayName returns the display name for a navigate item
+func (ni *NavigateItem) GetDisplayName() string {
+ if ni.Name != "" {
+ return ni.Name
+ }
+ if ni.ContentItem != nil && ni.ContentItem.ItemName != "" {
+ return ni.ContentItem.ItemName
+ }
+ return "Unknown Item"
+}
+
+// IsPlayable returns true if the navigate item can be played directly
+func (ni *NavigateItem) IsPlayable() bool {
+ return ni.Playable == 1
+}
+
+// IsDirectory returns true if the navigate item is a directory/container
+func (ni *NavigateItem) IsDirectory() bool {
+ return ni.Type == "dir"
+}
+
+// IsTrack returns true if the navigate item is a track
+func (ni *NavigateItem) IsTrack() bool {
+ return ni.Type == "track"
+}
+
+// IsStation returns true if the navigate item is a radio station
+func (ni *NavigateItem) IsStation() bool {
+ return ni.Type == "stationurl" || (ni.ContentItem != nil && ni.ContentItem.Type == "stationurl")
+}
+
+// GetContentItem returns the ContentItem for this navigate item
+func (ni *NavigateItem) GetContentItem() *ContentItem {
+ return ni.ContentItem
+}
+
+// GetArtwork returns the artwork URL if available
+func (ni *NavigateItem) GetArtwork() string {
+ if ni.ContentItem != nil && ni.ContentItem.ContainerArt != "" {
+ return ni.ContentItem.ContainerArt
+ }
+ return ""
+}
+
+// GetAllResults returns all search results regardless of type
+func (sr *SearchStationResponse) GetAllResults() []SearchResult {
+ var allResults []SearchResult
+ allResults = append(allResults, sr.Songs...)
+ allResults = append(allResults, sr.Artists...)
+ allResults = append(allResults, sr.Stations...)
+ return allResults
+}
+
+// GetSongs returns only song results
+func (sr *SearchStationResponse) GetSongs() []SearchResult {
+ return sr.Songs
+}
+
+// GetArtists returns only artist results
+func (sr *SearchStationResponse) GetArtists() []SearchResult {
+ return sr.Artists
+}
+
+// GetStations returns only station results
+func (sr *SearchStationResponse) GetStations() []SearchResult {
+ return sr.Stations
+}
+
+// HasResults returns true if the search response contains any results
+func (sr *SearchStationResponse) HasResults() bool {
+ return len(sr.Songs) > 0 || len(sr.Artists) > 0 || len(sr.Stations) > 0
+}
+
+// GetResultCount returns the total number of results
+func (sr *SearchStationResponse) GetResultCount() int {
+ return len(sr.Songs) + len(sr.Artists) + len(sr.Stations)
+}
+
+// IsEmpty returns true if the search response contains no results
+func (sr *SearchStationResponse) IsEmpty() bool {
+ return !sr.HasResults()
+}
+
+// GetDisplayName returns the display name for a search result
+func (sr *SearchResult) GetDisplayName() string {
+ if sr.Name != "" {
+ return sr.Name
+ }
+ return "Unknown"
+}
+
+// GetArtworkURL returns the logo/artwork URL if available
+func (sr *SearchResult) GetArtworkURL() string {
+ return sr.Logo
+}
+
+// IsArtist returns true if this is an artist result
+func (sr *SearchResult) IsArtist() bool {
+ // Artist result: no artist field (name is the artist) and no description
+ return sr.Artist == "" && sr.Album == "" && sr.Description == ""
+}
+
+// IsSong returns true if this is a song result
+func (sr *SearchResult) IsSong() bool {
+ // Song result: has artist field populated
+ return sr.Artist != ""
+}
+
+// IsStation returns true if this is a station result
+func (sr *SearchResult) IsStation() bool {
+ // Station result: has description or is neither artist nor song
+ return sr.Description != "" || (!sr.IsSong() && !sr.IsArtist())
+}
+
+// GetFullTitle returns a full title with artist if available
+func (sr *SearchResult) GetFullTitle() string {
+ if sr.Artist != "" {
+ return sr.Name + " - " + sr.Artist
+ }
+ return sr.Name
+}
diff --git a/pkg/models/navigation_test.go b/pkg/models/navigation_test.go
new file mode 100644
index 0000000..f3d317a
--- /dev/null
+++ b/pkg/models/navigation_test.go
@@ -0,0 +1,682 @@
+package models
+
+import (
+ "encoding/xml"
+ "testing"
+)
+
+func TestNavigateRequest_NewNavigateRequest(t *testing.T) {
+ req := NewNavigateRequest("SPOTIFY", "user@example.com", 1, 50)
+
+ if req.Source != "SPOTIFY" {
+ t.Errorf("Expected source SPOTIFY, got %s", req.Source)
+ }
+ if req.SourceAccount != "user@example.com" {
+ t.Errorf("Expected sourceAccount user@example.com, got %s", req.SourceAccount)
+ }
+ if req.StartItem != 1 {
+ t.Errorf("Expected startItem 1, got %d", req.StartItem)
+ }
+ if req.NumItems != 50 {
+ t.Errorf("Expected numItems 50, got %d", req.NumItems)
+ }
+}
+
+func TestNavigateRequest_NewNavigateRequestWithMenu(t *testing.T) {
+ req := NewNavigateRequestWithMenu("PANDORA", "user123", "radioStations", "dateCreated", 1, 100)
+
+ if req.Source != "PANDORA" {
+ t.Errorf("Expected source PANDORA, got %s", req.Source)
+ }
+ if req.Menu != "radioStations" {
+ t.Errorf("Expected menu radioStations, got %s", req.Menu)
+ }
+ if req.Sort != "dateCreated" {
+ t.Errorf("Expected sort dateCreated, got %s", req.Sort)
+ }
+}
+
+func TestNavigateRequest_XMLMarshal(t *testing.T) {
+ tests := []struct {
+ name string
+ request *NavigateRequest
+ expected string
+ }{
+ {
+ name: "Basic navigate request",
+ request: NewNavigateRequest("TUNEIN", "", 1, 25),
+ expected: `125`,
+ },
+ {
+ name: "Navigate with source account",
+ request: NewNavigateRequest("SPOTIFY", "user@example.com", 10, 50),
+ expected: `1050`,
+ },
+ {
+ name: "Navigate with menu and sort",
+ request: NewNavigateRequestWithMenu("PANDORA", "user123", "radioStations", "dateCreated", 1, 100),
+ expected: `1100`,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ xmlData, err := xml.Marshal(tt.request)
+ if err != nil {
+ t.Fatalf("Failed to marshal XML: %v", err)
+ }
+
+ actual := string(xmlData)
+ if actual != tt.expected {
+ t.Errorf("XML mismatch:\nExpected: %s\nActual: %s", tt.expected, actual)
+ }
+ })
+ }
+}
+
+func TestNavigateRequest_XMLMarshalWithItem(t *testing.T) {
+ containerItem := &ContentItem{
+ Source: "STORED_MUSIC",
+ Location: "1",
+ SourceAccount: "device123/0",
+ IsPresetable: true,
+ ItemName: "Music",
+ }
+
+ request := NewNavigateRequestWithItem("STORED_MUSIC", "device123/0", 1, 1000, containerItem)
+
+ xmlData, err := xml.Marshal(request)
+ if err != nil {
+ t.Fatalf("Failed to marshal XML: %v", err)
+ }
+
+ // Debug: print the actual XML to see what's generated
+ xmlStr := string(xmlData)
+ t.Logf("Generated XML: %s", xmlStr)
+
+ // Check that the XML contains expected elements
+ if !contains(xmlStr, `source="STORED_MUSIC"`) {
+ t.Error("XML should contain source attribute")
+ }
+ if !contains(xmlStr, `1`) {
+ t.Error("XML should contain startItem element")
+ }
+ if !contains(xmlStr, `1000`) {
+ t.Error("XML should contain numItems element")
+ }
+ if !contains(xmlStr, `-
+ 2
+
+
-
+ Album Artists
+ dir
+
+ Album Artists
+
+
+ -
+ Test Track
+ track
+
+ Test Track
+
+ Test Artist
+ Test Album
+
+
+ `
+
+ var response NavigateResponse
+ err := xml.Unmarshal([]byte(xmlData), &response)
+ if err != nil {
+ t.Fatalf("Failed to unmarshal XML: %v", err)
+ }
+
+ if response.Source != "STORED_MUSIC" {
+ t.Errorf("Expected source STORED_MUSIC, got %s", response.Source)
+ }
+ if response.TotalItems != 2 {
+ t.Errorf("Expected totalItems 2, got %d", response.TotalItems)
+ }
+ if len(response.Items) != 2 {
+ t.Errorf("Expected 2 items, got %d", len(response.Items))
+ }
+
+ // Test first item (directory)
+ firstItem := response.Items[0]
+ if firstItem.Name != "Album Artists" {
+ t.Errorf("Expected first item name 'Album Artists', got %s", firstItem.Name)
+ }
+ if firstItem.Type != "dir" {
+ t.Errorf("Expected first item type 'dir', got %s", firstItem.Type)
+ }
+ if !firstItem.IsPlayable() {
+ t.Error("Expected first item to be playable")
+ }
+ if !firstItem.IsDirectory() {
+ t.Error("Expected first item to be a directory")
+ }
+
+ // Test second item (track)
+ secondItem := response.Items[1]
+ if secondItem.Name != "Test Track" {
+ t.Errorf("Expected second item name 'Test Track', got %s", secondItem.Name)
+ }
+ if secondItem.ArtistName != "Test Artist" {
+ t.Errorf("Expected artist name 'Test Artist', got %s", secondItem.ArtistName)
+ }
+ if !secondItem.IsTrack() {
+ t.Error("Expected second item to be a track")
+ }
+}
+
+func TestNavigateResponse_FilterMethods(t *testing.T) {
+ response := &NavigateResponse{
+ TotalItems: 3,
+ Items: []NavigateItem{
+ {
+ Name: "Directory 1",
+ Type: "dir",
+ Playable: 1,
+ },
+ {
+ Name: "Track 1",
+ Type: "track",
+ Playable: 1,
+ },
+ {
+ Name: "Station 1",
+ Type: "stationurl",
+ Playable: 1,
+ },
+ },
+ }
+
+ // Test GetPlayableItems
+ playable := response.GetPlayableItems()
+ if len(playable) != 3 {
+ t.Errorf("Expected 3 playable items, got %d", len(playable))
+ }
+
+ // Test GetDirectories
+ directories := response.GetDirectories()
+ if len(directories) != 1 {
+ t.Errorf("Expected 1 directory, got %d", len(directories))
+ }
+ if directories[0].Name != "Directory 1" {
+ t.Errorf("Expected directory name 'Directory 1', got %s", directories[0].Name)
+ }
+
+ // Test GetTracks
+ tracks := response.GetTracks()
+ if len(tracks) != 1 {
+ t.Errorf("Expected 1 track, got %d", len(tracks))
+ }
+ if tracks[0].Name != "Track 1" {
+ t.Errorf("Expected track name 'Track 1', got %s", tracks[0].Name)
+ }
+
+ // Test GetStations
+ stations := response.GetStations()
+ if len(stations) != 1 {
+ t.Errorf("Expected 1 station, got %d", len(stations))
+ }
+ if stations[0].Name != "Station 1" {
+ t.Errorf("Expected station name 'Station 1', got %s", stations[0].Name)
+ }
+}
+
+func TestNavigateResponse_IsEmpty(t *testing.T) {
+ tests := []struct {
+ name string
+ response *NavigateResponse
+ expected bool
+ }{
+ {
+ name: "Empty response - no items",
+ response: &NavigateResponse{TotalItems: 0, Items: []NavigateItem{}},
+ expected: true,
+ },
+ {
+ name: "Empty response - zero total",
+ response: &NavigateResponse{TotalItems: 0, Items: []NavigateItem{{Name: "test"}}},
+ expected: true,
+ },
+ {
+ name: "Empty response - nil items",
+ response: &NavigateResponse{TotalItems: 1, Items: nil},
+ expected: true,
+ },
+ {
+ name: "Non-empty response",
+ response: &NavigateResponse{TotalItems: 1, Items: []NavigateItem{{Name: "test"}}},
+ expected: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if actual := tt.response.IsEmpty(); actual != tt.expected {
+ t.Errorf("IsEmpty() = %v, expected %v", actual, tt.expected)
+ }
+ })
+ }
+}
+
+func TestNavigateItem_GetDisplayName(t *testing.T) {
+ tests := []struct {
+ name string
+ item *NavigateItem
+ expected string
+ }{
+ {
+ name: "Item with name",
+ item: &NavigateItem{Name: "Test Item"},
+ expected: "Test Item",
+ },
+ {
+ name: "Item with ContentItem itemName",
+ item: &NavigateItem{ContentItem: &ContentItem{ItemName: "Content Name"}},
+ expected: "Content Name",
+ },
+ {
+ name: "Item with both names - prefers item name",
+ item: &NavigateItem{Name: "Item Name", ContentItem: &ContentItem{ItemName: "Content Name"}},
+ expected: "Item Name",
+ },
+ {
+ name: "Item with no names",
+ item: &NavigateItem{},
+ expected: "Unknown Item",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if actual := tt.item.GetDisplayName(); actual != tt.expected {
+ t.Errorf("GetDisplayName() = %s, expected %s", actual, tt.expected)
+ }
+ })
+ }
+}
+
+func TestAddStationRequest_XMLMarshal(t *testing.T) {
+ req := NewAddStationRequest("PANDORA", "user123", "R4328162", "Test Station")
+
+ xmlData, err := xml.Marshal(req)
+ if err != nil {
+ t.Fatalf("Failed to marshal XML: %v", err)
+ }
+
+ expected := `Test Station`
+ actual := string(xmlData)
+
+ if actual != expected {
+ t.Errorf("XML mismatch:\nExpected: %s\nActual: %s", expected, actual)
+ }
+}
+
+func TestAddStationRequest_XMLMarshalWithoutAccount(t *testing.T) {
+ req := NewAddStationRequest("TUNEIN", "", "station123", "Radio Station")
+
+ xmlData, err := xml.Marshal(req)
+ if err != nil {
+ t.Fatalf("Failed to marshal XML: %v", err)
+ }
+
+ expected := `Radio Station`
+ actual := string(xmlData)
+
+ if actual != expected {
+ t.Errorf("XML mismatch:\nExpected: %s\nActual: %s", expected, actual)
+ }
+}
+
+func TestStationResponse_XMLUnmarshal(t *testing.T) {
+ xmlData := `/addStation`
+
+ var response StationResponse
+ err := xml.Unmarshal([]byte(xmlData), &response)
+ if err != nil {
+ t.Fatalf("Failed to unmarshal XML: %v", err)
+ }
+
+ if response.Status != "/addStation" {
+ t.Errorf("Expected status '/addStation', got %s", response.Status)
+ }
+}
+
+func TestRemoveStationRequest(t *testing.T) {
+ contentItem := &ContentItem{
+ Source: "PANDORA",
+ Location: "126740707481236361",
+ SourceAccount: "user123",
+ IsPresetable: true,
+ ItemName: "Test Station",
+ }
+
+ req := NewRemoveStationRequest(contentItem)
+
+ // Since RemoveStationRequest is just ContentItem, test that it's the same
+ if req != contentItem {
+ t.Error("NewRemoveStationRequest should return the same ContentItem")
+ }
+
+ // Test XML marshaling
+ xmlData, err := xml.Marshal(req)
+ if err != nil {
+ t.Fatalf("Failed to marshal XML: %v", err)
+ }
+
+ xmlStr := string(xmlData)
+ if !contains(xmlStr, `source="PANDORA"`) {
+ t.Error("XML should contain source attribute")
+ }
+ if !contains(xmlStr, `location="126740707481236361"`) {
+ t.Error("XML should contain location attribute")
+ }
+ if !contains(xmlStr, `Test Station`) {
+ t.Error("XML should contain itemName element")
+ }
+}
+
+func TestSearchStationRequest_NewSearchStationRequest(t *testing.T) {
+ req := NewSearchStationRequest("PANDORA", "user123", "Zach Williams")
+
+ if req.Source != "PANDORA" {
+ t.Errorf("Expected source PANDORA, got %s", req.Source)
+ }
+ if req.SourceAccount != "user123" {
+ t.Errorf("Expected sourceAccount user123, got %s", req.SourceAccount)
+ }
+ if req.SearchTerm != "Zach Williams" {
+ t.Errorf("Expected searchTerm 'Zach Williams', got %s", req.SearchTerm)
+ }
+}
+
+func TestSearchStationRequest_XMLMarshal(t *testing.T) {
+ tests := []struct {
+ name string
+ request *SearchStationRequest
+ expected string
+ }{
+ {
+ name: "Basic search request",
+ request: NewSearchStationRequest("PANDORA", "user123", "Classic Rock"),
+ expected: `Classic Rock`,
+ },
+ {
+ name: "Search without source account",
+ request: NewSearchStationRequest("TUNEIN", "", "Jazz"),
+ expected: `Jazz`,
+ },
+ {
+ name: "Search with special characters",
+ request: NewSearchStationRequest("SPOTIFY", "user@example.com", "Rock & Roll"),
+ expected: `Rock & Roll`,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ xmlData, err := xml.Marshal(tt.request)
+ if err != nil {
+ t.Fatalf("Failed to marshal XML: %v", err)
+ }
+
+ actual := string(xmlData)
+ if actual != tt.expected {
+ t.Errorf("XML mismatch:\nExpected: %s\nActual: %s", tt.expected, actual)
+ }
+ })
+ }
+}
+
+func TestSearchStationResponse_XMLUnmarshal(t *testing.T) {
+ xmlData := `
+
+
+
+ Old Church Choir
+ Zach Williams
+ http://example.com/song.jpg
+
+
+
+
+ Zach Williams
+ http://example.com/artist.jpg
+
+
+
+
+ Classic Rock Station
+ The best classic rock hits
+ http://example.com/station.jpg
+
+
+ `
+
+ var response SearchStationResponse
+ err := xml.Unmarshal([]byte(xmlData), &response)
+ if err != nil {
+ t.Fatalf("Failed to unmarshal XML: %v", err)
+ }
+
+ if response.DeviceID != "1004567890AA" {
+ t.Errorf("Expected deviceID '1004567890AA', got %s", response.DeviceID)
+ }
+ if response.Source != "PANDORA" {
+ t.Errorf("Expected source PANDORA, got %s", response.Source)
+ }
+ if len(response.Songs) != 1 {
+ t.Errorf("Expected 1 song result, got %d", len(response.Songs))
+ }
+ if len(response.Artists) != 1 {
+ t.Errorf("Expected 1 artist result, got %d", len(response.Artists))
+ }
+ if len(response.Stations) != 1 {
+ t.Errorf("Expected 1 station result, got %d", len(response.Stations))
+ }
+
+ // Test song result
+ song := response.Songs[0]
+ if song.Name != "Old Church Choir" {
+ t.Errorf("Expected song name 'Old Church Choir', got %s", song.Name)
+ }
+ if song.Artist != "Zach Williams" {
+ t.Errorf("Expected artist 'Zach Williams', got %s", song.Artist)
+ }
+ if song.Token != "S10657777" {
+ t.Errorf("Expected token 'S10657777', got %s", song.Token)
+ }
+
+ // Test artist result
+ artist := response.Artists[0]
+ if artist.Name != "Zach Williams" {
+ t.Errorf("Expected artist name 'Zach Williams', got %s", artist.Name)
+ }
+ if !artist.IsArtist() {
+ t.Error("Expected result to be identified as artist")
+ }
+
+ // Test station result
+ station := response.Stations[0]
+ if station.Name != "Classic Rock Station" {
+ t.Errorf("Expected station name 'Classic Rock Station', got %s", station.Name)
+ }
+ if !station.IsStation() {
+ t.Error("Expected result to be identified as station")
+ }
+}
+
+func TestSearchStationResponse_HelperMethods(t *testing.T) {
+ response := &SearchStationResponse{
+ Songs: []SearchResult{
+ {Name: "Song 1", Artist: "Artist 1", Token: "S1"},
+ {Name: "Song 2", Artist: "Artist 2", Token: "S2"},
+ },
+ Artists: []SearchResult{
+ {Name: "Artist 3", Token: "A1"},
+ },
+ Stations: []SearchResult{
+ {Name: "Station 1", Description: "Great music", Token: "R1"},
+ },
+ }
+
+ // Test GetAllResults
+ allResults := response.GetAllResults()
+ if len(allResults) != 4 {
+ t.Errorf("Expected 4 total results, got %d", len(allResults))
+ }
+
+ // Test GetSongs
+ songs := response.GetSongs()
+ if len(songs) != 2 {
+ t.Errorf("Expected 2 songs, got %d", len(songs))
+ }
+
+ // Test GetArtists
+ artists := response.GetArtists()
+ if len(artists) != 1 {
+ t.Errorf("Expected 1 artist, got %d", len(artists))
+ }
+
+ // Test GetStations
+ stations := response.GetStations()
+ if len(stations) != 1 {
+ t.Errorf("Expected 1 station, got %d", len(stations))
+ }
+
+ // Test HasResults
+ if !response.HasResults() {
+ t.Error("Expected response to have results")
+ }
+
+ // Test GetResultCount
+ if response.GetResultCount() != 4 {
+ t.Errorf("Expected result count 4, got %d", response.GetResultCount())
+ }
+
+ // Test IsEmpty
+ if response.IsEmpty() {
+ t.Error("Expected response not to be empty")
+ }
+
+ // Test empty response
+ emptyResponse := &SearchStationResponse{}
+ if !emptyResponse.IsEmpty() {
+ t.Error("Expected empty response to be empty")
+ }
+ if emptyResponse.HasResults() {
+ t.Error("Expected empty response to have no results")
+ }
+}
+
+func TestSearchResult_HelperMethods(t *testing.T) {
+ tests := []struct {
+ name string
+ result SearchResult
+ expectedType string
+ fullTitle string
+ }{
+ {
+ name: "Song result",
+ result: SearchResult{Name: "Test Song", Artist: "Test Artist"},
+ expectedType: "song",
+ fullTitle: "Test Song - Test Artist",
+ },
+ {
+ name: "Artist result",
+ result: SearchResult{Name: "Test Artist"},
+ expectedType: "artist",
+ fullTitle: "Test Artist",
+ },
+ {
+ name: "Station result",
+ result: SearchResult{Name: "Test Station", Description: "Great station"},
+ expectedType: "station",
+ fullTitle: "Test Station",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ // Test GetDisplayName
+ if tt.result.GetDisplayName() != tt.result.Name {
+ t.Errorf("Expected display name %s, got %s", tt.result.Name, tt.result.GetDisplayName())
+ }
+
+ // Test type detection
+ switch tt.expectedType {
+ case "song":
+ if !tt.result.IsSong() {
+ t.Error("Expected result to be identified as song")
+ }
+ if tt.result.IsArtist() || tt.result.IsStation() {
+ t.Error("Result incorrectly identified as artist or station")
+ }
+ case "artist":
+ if !tt.result.IsArtist() {
+ t.Error("Expected result to be identified as artist")
+ }
+ if tt.result.IsSong() || tt.result.IsStation() {
+ t.Error("Result incorrectly identified as song or station")
+ }
+ case "station":
+ if !tt.result.IsStation() {
+ t.Error("Expected result to be identified as station")
+ }
+ if tt.result.IsSong() || tt.result.IsArtist() {
+ t.Error("Result incorrectly identified as song or artist")
+ }
+ }
+
+ // Test GetFullTitle
+ if tt.result.GetFullTitle() != tt.fullTitle {
+ t.Errorf("Expected full title %s, got %s", tt.fullTitle, tt.result.GetFullTitle())
+ }
+ })
+ }
+}
+
+func TestSearchResult_GetArtworkURL(t *testing.T) {
+ result := SearchResult{
+ Name: "Test",
+ Logo: "http://example.com/artwork.jpg",
+ }
+
+ if result.GetArtworkURL() != "http://example.com/artwork.jpg" {
+ t.Errorf("Expected artwork URL 'http://example.com/artwork.jpg', got %s", result.GetArtworkURL())
+ }
+
+ // Test empty logo
+ emptyResult := SearchResult{Name: "Test"}
+ if emptyResult.GetArtworkURL() != "" {
+ t.Errorf("Expected empty artwork URL, got %s", emptyResult.GetArtworkURL())
+ }
+}
+
+// Helper function to check if string contains substring
+func contains(s, substr string) bool {
+ return len(s) >= len(substr) && (s == substr || len(substr) == 0 || (len(s) > len(substr) && (s[:len(substr)] == substr || s[len(s)-len(substr):] == substr || containsSubstring(s, substr))))
+}
+
+func containsSubstring(s, substr string) bool {
+ for i := 0; i <= len(s)-len(substr); i++ {
+ if s[i:i+len(substr)] == substr {
+ return true
+ }
+ }
+ return false
+}