Implements the complete /navigate, /searchStation, /addStation, and /removeStation API endpoints with full client support, models, tests, and documentation. This resolves GitHub issue #14 by enabling direct radio station and custom stream playback without requiring preset storage first. ## New Features ### Content Navigation - Browse content sources (TuneIn, Pandora, Spotify, stored music) - Navigate directory structures in music libraries - Paginated browsing with configurable page sizes - Menu-based navigation for services like Pandora ### Station Search & Discovery - Search across music services for stations, artists, songs - Service-specific search methods for TuneIn, Pandora, Spotify - Smart result categorization (songs vs artists vs stations) - Rich metadata including artwork and descriptions ### Station Management - Add stations to collections with immediate playback - Remove stations from user collections - Token-based operations for discovered content - WebSocket event generation for real-time updates ## Implementation Details ### New Client Methods - Navigate(), NavigateWithMenu(), NavigateContainer() - SearchStation(), SearchTuneInStations(), SearchPandoraStations(), SearchSpotifyContent() - AddStation(), RemoveStation() - GetTuneInStations(), GetPandoraStations(), GetStoredMusicLibrary() ### New Models (pkg/models/navigation.go) - NavigateRequest/Response with helper methods - SearchStationRequest/Response with result filtering - AddStationRequest, RemoveStationRequest, StationResponse - Rich helper methods for type detection and display formatting ### Enhanced HTTP Client - Added postWithResponse() method for POST requests with XML response parsing - Proper error handling with API error response parsing - XML marshaling/unmarshaling for all new request/response types ## Testing ### Comprehensive Test Suite - Unit tests for all client methods (navigation_test.go) - XML validation tests (navigation_xml_test.go) - Integration tests for real devices (navigation_integration_test.go) - Example workflows (navigation_examples_test.go) - Complete model tests (navigation_test.go) - Edge case and error handling tests ### Test Coverage - ~50 new test cases across different categories - 100% coverage of new navigation methods - XML protocol compliance verification - Performance benchmarking capabilities - Integration testing ready for real devices ## Documentation ### User-Focused Guide (docs/NAVIGATION-GUIDE.md) - Complete usage examples from basic to advanced - Real-world workflows (discover → search → add → play) - Error handling patterns and best practices - Service-specific guidance (TuneIn vs Pandora vs Spotify) - Performance optimization tips ### Technical Reference (docs/API-NAVIGATION-REFERENCE.md) - Complete API method documentation - Model specifications with helper methods - HTTP endpoint mapping with XML examples - Error codes and troubleshooting guide - XML schema definitions ### Updated README.md - Added navigation to API coverage - Updated documentation links - Enhanced feature list ## API Endpoints Implemented - POST /navigate - Browse content sources - POST /searchStation - Search for stations and content - POST /addStation - Add station and immediately play - POST /removeStation - Remove station from collection ## Breaking Changes None - all additions are backwards compatible. ## Usage Examples This implementation enables the complete workflow requested in issue #14: direct radio station and custom stream playback without preset dependencies.
21 KiB
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.
Table of Contents
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"
- Valid values:
sourceAccount(string, optional): Account identifier for authenticated sourcesstartItem(int, required): Starting position (1-based index)numItems(int, required): Number of items to retrieve
Returns:
*models.NavigateResponse: Navigation results with items and metadataerror: Error if request fails
Example:
response, err := client.Navigate("TUNEIN", "", 1, 25)
Validation:
sourcecannot be emptystartItemmust be >= 1numItemsmust 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 identifiersourceAccount(string, optional): Account identifiermenu(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 resultserror: Error if request fails
Example:
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 identifiersourceAccount(string, optional): Account identifierstartItem(int, required): Starting position (1-based)numItems(int, required): Number of items to retrievecontainerItem(*models.ContentItem, required): Container to browse into
Returns:
*models.NavigateResponse: Container contentserror: Error if request fails
Example:
response, err := client.NavigateContainer("STORED_MUSIC", "device/0", 1, 100, albumContentItem)
Validation:
containerItemcannot be nil- Container must have valid
Locationfield
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:
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:
stations, err := client.GetPandoraStations("user123")
Validation:
sourceAccountcannot 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:
library, err := client.GetStoredMusicLibrary("A81B6A536A98/0")
Validation:
sourceAccountcannot 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 searchsourceAccount(string, optional): Account identifiersearchTerm(string, required): Search query
Returns:
*models.SearchStationResponse: Search results categorized by typeerror: Error if request fails
Example:
results, err := client.SearchStation("PANDORA", "user123", "jazz")
Validation:
sourcecannot be emptysearchTermcannot 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:
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 identifiersearchTerm(string, required): Artist or genre to search for
Returns:
*models.SearchStationResponse: Pandora search results with songs, artists, stations
Example:
results, err := client.SearchPandoraStations("user123", "Taylor Swift")
Validation:
sourceAccountcannot be empty
SearchSpotifyContent(sourceAccount, searchTerm string) (*models.SearchStationResponse, error)
Search Spotify for tracks, albums, and playlists.
Parameters:
sourceAccount(string, required): Spotify account identifiersearchTerm(string, required): Content to search for
Returns:
*models.SearchStationResponse: Spotify search results
Example:
results, err := client.SearchSpotifyContent("user@example.com", "Queen")
Validation:
sourceAccountcannot 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 identifiersourceAccount(string, optional): Account identifiertoken(string, required): Station token from search resultsname(string, required): Display name for the station
Returns:
error: Error if operation fails
Example:
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
presetsUpdatedWebSocket event if station is stored as preset
Validation:
sourcecannot be emptytokencannot be emptynamecannot 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:
err := client.RemoveStation(stationContentItem)
Behavior:
- Station is removed from user's collection
- If station is currently playing, playback stops
- Generates
nowPlayingUpdatedWebSocket event if playing station was removed
Validation:
contentItemcannot be nilcontentItem.Sourcecannot be emptycontentItem.Locationcannot be empty
Models
NavigateRequest
Request structure for /navigate endpoint.
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.
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 withPlayable="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.
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 nameIsPlayable() bool- Check ifPlayable="1"IsDirectory() bool- Check iftype="dir"IsTrack() bool- Check iftype="track"IsStation() bool- Check iftype="stationurl"GetContentItem() *ContentItem- Get associated content itemGetArtwork() 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.
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.
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 resultsGetArtists() []SearchResult- Get artist resultsGetStations() []SearchResult- Get station resultsGetAllResults() []SearchResult- Get all results combinedGetResultCount() int- Count total resultsHasResults() bool- Check if any results foundIsEmpty() bool- Check if no results
SearchResult
Individual search result item.
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 (hasArtistfield)IsArtist() bool- Check if result is an artist (noArtistorDescription)IsStation() bool- Check if result is a station (hasDescription)GetDisplayName() string- Get formatted nameGetFullTitle() string- Get name with artist for songsGetArtworkURL() 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.
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.
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:
<navigate source="TUNEIN" sourceAccount="">
<startItem>1</startItem>
<numItems>25</numItems>
</navigate>
Response Body:
<navigateResponse source="TUNEIN">
<totalItems>5</totalItems>
<items>
<item Playable="1">
<name>Station Name</name>
<type>stationurl</type>
<ContentItem source="TUNEIN" location="/v1/playback/station/s12345" isPresetable="true">
<itemName>Station Name</itemName>
</ContentItem>
</item>
</items>
</navigateResponse>
POST /searchStation
Search for stations and content.
Request Body:
<search source="PANDORA" sourceAccount="user123">Taylor Swift</search>
Response Body:
<results deviceID="A81B6A536A98" source="PANDORA" sourceAccount="user123">
<songs>
<searchResult source="PANDORA" sourceAccount="user123" token="S123">
<name>Love Story</name>
<artist>Taylor Swift</artist>
<logo>http://example.com/artwork.jpg</logo>
</searchResult>
</songs>
<artists>
<searchResult source="PANDORA" sourceAccount="user123" token="R456">
<name>Taylor Swift</name>
<logo>http://example.com/artist.jpg</logo>
</searchResult>
</artists>
</results>
POST /addStation
Add a station to collection and start playing.
Request Body:
<addStation source="PANDORA" sourceAccount="user123" token="R456">
<name>Taylor Swift Radio</name>
</addStation>
Response Body:
<status>/addStation</status>
POST /removeStation
Remove a station from collection.
Request Body:
<ContentItem source="PANDORA" location="126740707481236361" sourceAccount="user123" isPresetable="true">
<itemName>Taylor Swift Radio</itemName>
</ContentItem>
Response Body:
<status>/removeStation</status>
XML Schemas
Navigate Request Schema
<xs:element name="navigate">
<xs:complexType>
<xs:sequence>
<xs:element name="startItem" type="xs:int"/>
<xs:element name="numItems" type="xs:int"/>
<xs:element name="item" minOccurs="0">
<xs:complexType>
<xs:sequence>
<xs:element name="name" type="xs:string"/>
<xs:element name="type" type="xs:string"/>
<xs:element name="ContentItem" type="ContentItemType"/>
</xs:sequence>
<xs:attribute name="Playable" type="xs:int"/>
</xs:complexType>
</xs:element>
</xs:sequence>
<xs:attribute name="source" type="xs:string" use="required"/>
<xs:attribute name="sourceAccount" type="xs:string"/>
<xs:attribute name="menu" type="xs:string"/>
<xs:attribute name="sort" type="xs:string"/>
</xs:complexType>
</xs:element>
Search Request Schema
<xs:element name="search">
<xs:complexType>
<xs:simpleContent>
<xs:extension base="xs:string">
<xs:attribute name="source" type="xs:string" use="required"/>
<xs:attribute name="sourceAccount" type="xs:string"/>
</xs:extension>
</xs:simpleContent>
</xs:complexType>
</xs:element>
ContentItem Type Schema
<xs:complexType name="ContentItemType">
<xs:sequence>
<xs:element name="itemName" type="xs:string" minOccurs="0"/>
<xs:element name="containerArt" type="xs:string" minOccurs="0"/>
</xs:sequence>
<xs:attribute name="source" type="xs:string" use="required"/>
<xs:attribute name="type" type="xs:string"/>
<xs:attribute name="location" type="xs:string"/>
<xs:attribute name="sourceAccount" type="xs:string"/>
<xs:attribute name="isPresetable" type="xs:boolean"/>
</xs:complexType>
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:
<error>
<code>INVALID_SOURCE</code>
<message>Source 'INVALID' is not available</message>
</error>
Authentication Required:
<error>
<code>AUTH_REQUIRED</code>
<message>Source account required for this service</message>
</error>
Service Unavailable:
<error>
<code>SERVICE_UNAVAILABLE</code>
<message>PANDORA service is not configured</message>
</error>
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.
<presetsUpdated deviceID="A81B6A536A98">
<presets>
<!-- Updated preset list -->
</presets>
</presetsUpdated>
nowPlayingUpdated
Generated when station operations affect current playback.
<nowPlayingUpdated deviceID="A81B6A536A98">
<nowPlaying source="PANDORA">
<ContentItem source="PANDORA" location="R456" sourceAccount="user123" isPresetable="true">
<itemName>Taylor Swift Radio</itemName>
</ContentItem>
<track>Love Story</track>
<artist>Taylor Swift</artist>
<playStatus>PLAY_STATE</playStatus>
</nowPlaying>
</nowPlayingUpdated>
Best Practices
Parameter Validation
Always validate parameters before API calls:
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:
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:
// 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:
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.