mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-10 12:46:13 +00:00
TuneIn's profiles API caps initial results at ~10 per container (Stations, Shows, etc.) and exposes a Pivots.More.Url cursor for the remainder. This change wires that cursor through the stack so users can load additional results without leaving the search view. - tuneInSearchSection now extracts Pivots.More.Url as bmx_next when itemToken is present; absent for containers already at their limit - TuneInSearchNext fetches the cursor URL, which returns a flat Items[] (not nested containers), and maps Station/Program/Topic items using the existing play/profile builders - New GET /v1/search/next and /api/tunein/search/next endpoints with matching handlers in both service paths - TuneInBrowser: flat items state replaced with per-section sections state; each section shows a header label and a Load more button when a cursor is available; browse/navigate mode is unaffected Relates to #336. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
920 lines
38 KiB
Go
920 lines
38 KiB
Go
// Package models defines data structures used for Bose SoundTouch API communication
|
|
// and service management. It includes types for BMX (Bose Media eXchange) services,
|
|
// device information, presets, recents, and other core data models.
|
|
package models
|
|
|
|
import (
|
|
"encoding/xml"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Link represents a navigational link with URL and client usage preferences.
|
|
type Link struct {
|
|
Href string `json:"href" xml:"href,attr"`
|
|
UseInternalClient string `json:"useInternalClient,omitempty" xml:"useInternalClient,attr,omitempty"`
|
|
ContainerArt string `json:"containerArt,omitempty" xml:"-"`
|
|
Filters interface{} `json:"filters,omitempty" xml:"-"`
|
|
Name string `json:"name,omitempty" xml:"-"`
|
|
Templated *bool `json:"templated,omitempty" xml:"-"`
|
|
Type string `json:"type,omitempty" xml:"-"`
|
|
}
|
|
|
|
// Links contains various navigation links used by BMX services.
|
|
type Links struct {
|
|
BmxLogout *Link `json:"bmx_logout,omitempty" xml:"bmx_logout,omitempty"`
|
|
BmxNavigate *Link `json:"bmx_navigate,omitempty" xml:"bmx_navigate,omitempty"`
|
|
BmxServicesAvailability *Link `json:"bmx_services_availability,omitempty" xml:"bmx_services_availability,omitempty"`
|
|
BmxToken *Link `json:"bmx_token,omitempty" xml:"bmx_token,omitempty"`
|
|
Self *Link `json:"self,omitempty" xml:"self,omitempty"`
|
|
BmxAvailability *Link `json:"bmx_availability,omitempty" xml:"bmx_availability,omitempty"`
|
|
BmxReporting *Link `json:"bmx_reporting,omitempty" xml:"bmx_reporting,omitempty"`
|
|
BmxFavorite *Link `json:"bmx_favorite,omitempty" xml:"bmx_favorite,omitempty"`
|
|
BmxNowPlaying *Link `json:"bmx_nowplaying,omitempty" xml:"bmx_nowplaying,omitempty"`
|
|
BmxTrack *Link `json:"bmx_track,omitempty" xml:"bmx_track,omitempty"`
|
|
BmxSearch *Link `json:"bmx_search,omitempty" xml:"-"`
|
|
BmxPlayback *Link `json:"bmx_playback,omitempty" xml:"-"`
|
|
BmxPreset *Link `json:"bmx_preset,omitempty" xml:"-"`
|
|
BmxNext *Link `json:"bmx_next,omitempty" xml:"-"`
|
|
}
|
|
|
|
// BmxNavItem represents a single item in a TuneIn browse or search result.
|
|
type BmxNavItem struct {
|
|
Links *Links `json:"_links,omitempty"`
|
|
ImageUrl string `json:"imageUrl,omitempty"`
|
|
Name string `json:"name"`
|
|
Subtitle string `json:"subtitle"`
|
|
}
|
|
|
|
// BmxNavSection represents a group of navigation items with a layout hint.
|
|
type BmxNavSection struct {
|
|
Links *Links `json:"_links,omitempty"`
|
|
Items []BmxNavItem `json:"items"`
|
|
Layout string `json:"layout,omitempty"`
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
// BmxNavResponse is the top-level response for TuneIn navigate and search endpoints.
|
|
type BmxNavResponse struct {
|
|
Links *Links `json:"_links,omitempty"`
|
|
BmxSections []BmxNavSection `json:"bmx_sections"`
|
|
Layout string `json:"layout"`
|
|
}
|
|
|
|
// IconSet represents a collection of icons with different sizes for media content.
|
|
type IconSet struct {
|
|
DefaultAlbumArt string `json:"defaultAlbumArt,omitempty" xml:"defaultAlbumArt,omitempty"`
|
|
LargeSvg string `json:"largeSvg" xml:"largeSvg"`
|
|
MonochromePng string `json:"monochromePng" xml:"monochromePng"`
|
|
MonochromeSvg string `json:"monochromeSvg" xml:"monochromeSvg"`
|
|
SmallSvg string `json:"smallSvg" xml:"smallSvg"`
|
|
}
|
|
|
|
// Asset represents a media asset with URL and content type information.
|
|
type Asset struct {
|
|
Color string `json:"color" xml:"color"`
|
|
Description string `json:"description" xml:"description"`
|
|
Icons IconSet `json:"icons" xml:"icons"`
|
|
Name string `json:"name" xml:"name"`
|
|
ShortDescription string `json:"shortDescription,omitempty" xml:"shortDescription,omitempty"`
|
|
}
|
|
|
|
// Id represents an identifier structure used in various API responses.
|
|
type Id struct {
|
|
Name string `json:"name" xml:"name"`
|
|
Value int `json:"value" xml:"value"`
|
|
}
|
|
|
|
// BmxService represents a Bose Media eXchange service configuration.
|
|
type BmxService struct {
|
|
Links *Links `json:"_links,omitempty" xml:"links,omitempty"`
|
|
AskAdapter bool `json:"askAdapter" xml:"askAdapter"`
|
|
Assets Asset `json:"assets" xml:"assets"`
|
|
BaseUrl string `json:"baseUrl" xml:"baseUrl"`
|
|
SignupUrl string `json:"signupUrl,omitempty" xml:"signupUrl,omitempty"`
|
|
StreamTypes []string `json:"streamTypes" xml:"streamTypes>streamType"`
|
|
AuthenticationModel map[string]interface{} `json:"authenticationModel" xml:"authenticationModel"`
|
|
ID Id `json:"id" xml:"id"`
|
|
}
|
|
|
|
// BmxResponse represents a response from BMX services.
|
|
type BmxResponse struct {
|
|
Links *Links `json:"_links,omitempty" xml:"links,omitempty"`
|
|
AskAgainAfter int `json:"askAgainAfter" xml:"askAgainAfter"`
|
|
BmxServices []Service `json:"bmx_services" xml:"bmx_services>service"`
|
|
}
|
|
|
|
// Stream represents audio stream information including URL and format details.
|
|
type Stream struct {
|
|
Links *Links `json:"_links,omitempty" xml:"links,omitempty"`
|
|
BufferingTimeout int `json:"bufferingTimeout,omitempty" xml:"bufferingTimeout,omitempty"`
|
|
ConnectingTimeout int `json:"connectingTimeout,omitempty" xml:"connectingTimeout,omitempty"`
|
|
HasPlaylist bool `json:"hasPlaylist" xml:"hasPlaylist"`
|
|
IsRealtime bool `json:"isRealtime" xml:"isRealtime"`
|
|
StreamUrl string `json:"streamUrl" xml:"streamUrl"`
|
|
}
|
|
|
|
// Audio represents audio content metadata including format and quality information.
|
|
type Audio struct {
|
|
HasPlaylist bool `json:"hasPlaylist" xml:"hasPlaylist"`
|
|
IsRealtime bool `json:"isRealtime" xml:"isRealtime"`
|
|
MaxTimeout int `json:"maxTimeout,omitempty" xml:"maxTimeout,omitempty"`
|
|
StreamUrl string `json:"streamUrl" xml:"streamUrl"`
|
|
Streams []Stream `json:"streams" xml:"streams>stream"`
|
|
}
|
|
|
|
// BmxPlaybackResponse represents a playback response from BMX services.
|
|
type BmxPlaybackResponse struct {
|
|
Links *Links `json:"_links,omitempty" xml:"links,omitempty"`
|
|
Artist struct {
|
|
Name string `json:"name,omitempty" xml:"name,omitempty"`
|
|
} `json:"artist,omitempty" xml:"artist,omitempty"`
|
|
Audio Audio `json:"audio" xml:"audio"`
|
|
ImageUrl string `json:"imageUrl" xml:"imageUrl"`
|
|
IsFavorite *bool `json:"isFavorite,omitempty" xml:"isFavorite,omitempty"`
|
|
Name string `json:"name" xml:"name"`
|
|
StreamType string `json:"streamType" xml:"streamType"`
|
|
Duration int `json:"duration,omitempty" xml:"duration,omitempty"`
|
|
ShuffleDisabled bool `json:"shuffle_disabled,omitempty" xml:"shuffleDisabled,omitempty"`
|
|
RepeatDisabled bool `json:"repeat_disabled,omitempty" xml:"repeatDisabled,omitempty"`
|
|
}
|
|
|
|
// Track represents track information for media playback.
|
|
type Track struct {
|
|
Links *Links `json:"_links,omitempty" xml:"links,omitempty"`
|
|
IsSelected bool `json:"isSelected" xml:"isSelected"`
|
|
Name string `json:"name" xml:"name"`
|
|
}
|
|
|
|
// BmxPodcastInfoResponse represents podcast information from BMX services.
|
|
type BmxPodcastInfoResponse struct {
|
|
Links *Links `json:"_links,omitempty" xml:"links,omitempty"`
|
|
Name string `json:"name" xml:"name"`
|
|
ShuffleDisabled bool `json:"shuffleDisabled" xml:"shuffleDisabled"`
|
|
RepeatDisabled bool `json:"repeatDisabled" xml:"repeatDisabled"`
|
|
StreamType string `json:"streamType" xml:"streamType"`
|
|
Tracks []Track `json:"tracks" xml:"tracks>track"`
|
|
}
|
|
|
|
// SourceProvider represents a media source provider configuration.
|
|
type SourceProvider struct {
|
|
ID int `json:"id" xml:"id,attr"`
|
|
CreatedOn string `json:"created_on" xml:"createdOn"`
|
|
Name string `json:"name" xml:"name"`
|
|
UpdatedOn string `json:"updated_on" xml:"updatedOn"`
|
|
}
|
|
|
|
// ServiceContentItem represents a media content item with source and location details.
|
|
type ServiceContentItem struct {
|
|
ID string `json:"id" xml:"id,attr"`
|
|
Name string `json:"name" xml:"name"`
|
|
Source string `json:"source,omitempty" xml:"source,attr,omitempty"`
|
|
Type string `json:"type,omitempty" xml:"type,attr,omitempty"`
|
|
ContentItemType string `json:"content_item_type,omitempty" xml:"contentItemType,omitempty"`
|
|
Location string `json:"location,omitempty" xml:"location,attr,omitempty"`
|
|
SourceAccount string `json:"source_account,omitempty" xml:"sourceAccount,attr,omitempty"`
|
|
SourceID string `json:"source_id,omitempty" xml:"sourceid"`
|
|
IsPresetable string `json:"is_presetable,omitempty" xml:"isPresetable,attr,omitempty"`
|
|
Username string `json:"username,omitempty" xml:"username,omitempty"`
|
|
ContainerArt string `json:"container_art,omitempty" xml:"containerArt,omitempty"`
|
|
}
|
|
|
|
// ServicePreset represents a user-defined preset for quick access to media content.
|
|
type ServicePreset struct {
|
|
ServiceContentItem
|
|
ID string `json:"id,omitempty" xml:"id,attr,omitempty"`
|
|
ContainerArt string `json:"container_art" xml:"containerArt"`
|
|
CreatedOn string `json:"created_on" xml:"createdOn"`
|
|
UpdatedOn string `json:"updated_on" xml:"updatedOn"`
|
|
ButtonNumber string `json:"button_number,omitempty" xml:"buttonNumber,attr,omitempty"`
|
|
Username string `json:"-" xml:"username,omitempty"`
|
|
}
|
|
|
|
// MarshalXML implements the xml.Marshaler interface for ServicePreset to match upstream parity.
|
|
func (p ServicePreset) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
|
|
type Alias struct {
|
|
ButtonNumber string `xml:"buttonNumber,attr,omitempty"`
|
|
ContainerArt string `xml:"containerArt"`
|
|
ContentItemType string `xml:"contentItemType"`
|
|
CreatedOn string `xml:"createdOn"`
|
|
Location string `xml:"location"`
|
|
Name string `xml:"name"`
|
|
SourceID string `xml:"sourceid,omitempty"`
|
|
UpdatedOn string `xml:"updatedOn"`
|
|
Username string `xml:"username"`
|
|
}
|
|
|
|
createdOn := p.CreatedOn
|
|
if _, err := strconv.ParseInt(createdOn, 10, 64); err == nil {
|
|
if t, err := strconv.ParseInt(createdOn, 10, 64); err == nil {
|
|
createdOn = time.Unix(t, 0).UTC().Format("2006-01-02T15:04:05.000+00:00")
|
|
}
|
|
}
|
|
|
|
updatedOn := p.UpdatedOn
|
|
if _, err := strconv.ParseInt(updatedOn, 10, 64); err == nil {
|
|
if t, err := strconv.ParseInt(updatedOn, 10, 64); err == nil {
|
|
updatedOn = time.Unix(t, 0).UTC().Format("2006-01-02T15:04:05.000+00:00")
|
|
}
|
|
}
|
|
|
|
a := Alias{
|
|
ButtonNumber: p.ButtonNumber,
|
|
ContainerArt: p.ContainerArt,
|
|
ContentItemType: p.ContentItemType,
|
|
CreatedOn: createdOn,
|
|
Location: p.Location,
|
|
Name: p.Name,
|
|
SourceID: p.SourceID,
|
|
UpdatedOn: updatedOn,
|
|
Username: p.Username,
|
|
}
|
|
|
|
if a.Username == "" && a.Name != "" {
|
|
a.Username = a.Name
|
|
}
|
|
|
|
start.Name.Local = "preset"
|
|
// Remove all attributes because they are handled in Alias
|
|
start.Attr = nil
|
|
|
|
return e.EncodeElement(a, start)
|
|
}
|
|
|
|
// ServiceRecent represents recently played media content as stored in Recents.xml.
|
|
type ServiceRecent struct {
|
|
XMLName xml.Name `json:"-" xml:"recent"`
|
|
ServiceContentItem
|
|
DeviceID string `json:"device_id" xml:"deviceID,attr,omitempty"`
|
|
UtcTime string `json:"utc_time" xml:"utcTime,attr,omitempty"`
|
|
CreatedOn string `json:"created_on,omitempty" xml:"createdOn,omitempty"`
|
|
UpdatedOn string `json:"updated_on,omitempty" xml:"updatedOn,omitempty"`
|
|
LastPlayedAt string `json:"last_played_at,omitempty" xml:"lastplayedat,omitempty"`
|
|
}
|
|
|
|
// RecentItemParity represents recently played media content for web API responses (flat format).
|
|
type RecentItemParity struct {
|
|
XMLName xml.Name `xml:"recent"`
|
|
ID string `xml:"id,attr"`
|
|
ContentItemType string `xml:"contentItemType"`
|
|
CreatedOn string `xml:"createdOn"`
|
|
LastPlayedAt string `xml:"lastplayedat"`
|
|
Location string `xml:"location"`
|
|
Name string `xml:"name"`
|
|
Source *RecentItemParitySource `xml:"source,omitempty"`
|
|
SourceID string `xml:"sourceid"`
|
|
UpdatedOn string `xml:"updatedOn"`
|
|
}
|
|
|
|
// RecentItemParitySource represents the source in a RecentItemParity.
|
|
type RecentItemParitySource struct {
|
|
ID string `xml:"id,attr"`
|
|
Type string `xml:"type,attr"`
|
|
CreatedOn string `xml:"createdOn"`
|
|
Credential *RecentItemParityCredential `xml:"credential"`
|
|
Name string `xml:"name"`
|
|
SourceProviderID string `xml:"sourceproviderid"`
|
|
SourceName string `xml:"sourcename"`
|
|
SourceSettings string `xml:"sourceSettings"`
|
|
UpdatedOn string `xml:"updatedOn"`
|
|
Username string `xml:"username"`
|
|
}
|
|
|
|
// RecentItemParityCredential represents the credential in a RecentItemParitySource.
|
|
type RecentItemParityCredential struct {
|
|
Type string `xml:"type,attr"`
|
|
Value string `xml:",chardata"`
|
|
}
|
|
|
|
// UnmarshalXML implements the xml.Unmarshaler interface to handle both nested and flat formats for ServiceRecent.
|
|
func (r *ServiceRecent) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
|
|
type NestedContentItem struct {
|
|
Source string `xml:"source,attr"`
|
|
Type string `xml:"type,attr"`
|
|
Location string `xml:"location,attr"`
|
|
SourceAccount string `xml:"sourceAccount,attr"`
|
|
IsPresetable string `xml:"isPresetable,attr"`
|
|
ItemName string `xml:"itemName"`
|
|
ContainerArt string `xml:"containerArt,omitempty"`
|
|
}
|
|
|
|
type Alias struct {
|
|
XMLName xml.Name `xml:"recent"`
|
|
ServiceContentItem
|
|
DeviceID string `xml:"deviceID,attr"`
|
|
UtcTime string `xml:"utcTime,attr"`
|
|
ID string `xml:"id,attr"`
|
|
CreatedOn string `xml:"createdOn,omitempty"`
|
|
UpdatedOn string `xml:"updatedOn,omitempty"`
|
|
ContainerArt string `xml:"containerArt,omitempty"`
|
|
SourceConfig *ConfiguredSource `xml:"source,omitempty"`
|
|
LastPlayedAt string `xml:"lastplayedat"`
|
|
ContentItem *NestedContentItem `xml:"contentItem,omitempty"`
|
|
// Flat format might use these tags
|
|
FlatLocation string `xml:"location"`
|
|
FlatTypeTag string `xml:"type"`
|
|
AttrType string `xml:"type,attr"`
|
|
FlatSourceAccount string `xml:"sourceAccount"`
|
|
FlatIsPresetable string `xml:"isPresetable"`
|
|
FlatContentItemType string `xml:"contentItemType"`
|
|
AttrContentItemType string `xml:"contentItemType,attr"`
|
|
FlatName string `xml:"name"`
|
|
FlatSourceID string `xml:"sourceid"`
|
|
FlatSourceIDAttr string `xml:"sourceid,attr"`
|
|
FlatSource string `xml:"source_key"`
|
|
AttrSource string `xml:"source,attr"`
|
|
}
|
|
|
|
var a Alias
|
|
if err := d.DecodeElement(&a, &start); err != nil {
|
|
return err
|
|
}
|
|
|
|
r.ServiceContentItem = a.ServiceContentItem
|
|
r.DeviceID = a.DeviceID
|
|
r.UtcTime = a.UtcTime
|
|
r.ID = a.ID
|
|
r.CreatedOn = a.CreatedOn
|
|
r.UpdatedOn = a.UpdatedOn
|
|
r.ContainerArt = a.ContainerArt
|
|
r.LastPlayedAt = a.LastPlayedAt
|
|
r.SourceID = a.FlatSourceID
|
|
|
|
// Prefer nested contentItem data if present
|
|
if a.ContentItem != nil {
|
|
r.Source = a.ContentItem.Source
|
|
r.Type = a.ContentItem.Type
|
|
r.ContentItemType = a.ContentItem.Type // Set ContentItemType from nested type
|
|
r.Location = a.ContentItem.Location
|
|
r.SourceAccount = a.ContentItem.SourceAccount
|
|
r.IsPresetable = a.ContentItem.IsPresetable
|
|
|
|
r.Name = a.ContentItem.ItemName
|
|
if a.ContentItem.ContainerArt != "" {
|
|
r.ContainerArt = a.ContentItem.ContainerArt
|
|
}
|
|
} else {
|
|
// Fallback to flat fields
|
|
if a.FlatLocation != "" {
|
|
r.Location = a.FlatLocation
|
|
}
|
|
|
|
switch {
|
|
case a.FlatContentItemType != "":
|
|
r.ContentItemType = a.FlatContentItemType
|
|
case a.FlatTypeTag != "":
|
|
r.ContentItemType = a.FlatTypeTag
|
|
case a.AttrType != "":
|
|
r.ContentItemType = a.AttrType
|
|
}
|
|
|
|
switch {
|
|
case a.FlatTypeTag != "":
|
|
r.Type = a.FlatTypeTag
|
|
case a.AttrType != "":
|
|
r.Type = a.AttrType
|
|
}
|
|
|
|
switch {
|
|
case a.FlatSourceID != "":
|
|
r.SourceID = a.FlatSourceID
|
|
case a.FlatSourceIDAttr != "":
|
|
r.SourceID = a.FlatSourceIDAttr
|
|
}
|
|
|
|
switch {
|
|
case a.FlatSource != "":
|
|
r.Source = a.FlatSource
|
|
case a.AttrSource != "":
|
|
r.Source = a.AttrSource
|
|
}
|
|
|
|
if a.FlatName != "" {
|
|
r.Name = a.FlatName
|
|
}
|
|
|
|
if a.FlatSourceAccount != "" {
|
|
r.SourceAccount = a.FlatSourceAccount
|
|
}
|
|
|
|
if a.FlatIsPresetable != "" {
|
|
r.IsPresetable = a.FlatIsPresetable
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// MarshalXML implements the xml.Marshaler interface for custom XML encoding of ServiceRecent (nested format).
|
|
func (r ServiceRecent) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
|
|
type NestedContentItem struct {
|
|
Source string `xml:"source,attr"`
|
|
Type string `xml:"type,attr"`
|
|
Location string `xml:"location,attr"`
|
|
SourceAccount string `xml:"sourceAccount,attr"`
|
|
IsPresetable string `xml:"isPresetable,attr"`
|
|
ItemName string `xml:"itemName"`
|
|
ContainerArt string `xml:"containerArt,omitempty"`
|
|
}
|
|
|
|
type Alias struct {
|
|
XMLName xml.Name `xml:"recent"`
|
|
ID string `xml:"id,attr"`
|
|
DeviceID string `xml:"deviceID,attr,omitempty"`
|
|
UtcTime string `xml:"utcTime,attr,omitempty"`
|
|
ContentItem *NestedContentItem `xml:"contentItem"`
|
|
CreatedOn string `xml:"createdOn"`
|
|
UpdatedOn string `xml:"updatedOn"`
|
|
LastPlayedAt string `xml:"lastplayedat"`
|
|
SourceID string `xml:"sourceid"`
|
|
Username string `xml:"username"`
|
|
}
|
|
|
|
a := Alias{
|
|
ID: r.ID,
|
|
DeviceID: r.DeviceID,
|
|
UtcTime: r.UtcTime,
|
|
CreatedOn: r.CreatedOn,
|
|
UpdatedOn: r.UpdatedOn,
|
|
LastPlayedAt: r.LastPlayedAt,
|
|
SourceID: r.SourceID,
|
|
Username: r.Name, // Using Name as Username for parity
|
|
ContentItem: &NestedContentItem{
|
|
Source: r.Source,
|
|
Type: r.Type,
|
|
Location: r.Location,
|
|
SourceAccount: r.SourceAccount,
|
|
IsPresetable: r.IsPresetable,
|
|
ItemName: r.Name,
|
|
ContainerArt: r.ContainerArt,
|
|
},
|
|
}
|
|
|
|
start.Name.Local = "recent"
|
|
|
|
return e.EncodeElement(a, start)
|
|
}
|
|
|
|
// ConfiguredSource represents a configured media source with authentication details.
|
|
type ConfiguredSource struct {
|
|
XMLName xml.Name `json:"-" xml:"source"`
|
|
DisplayName string `json:"display_name" xml:"displayName,attr,omitempty"`
|
|
ID string `json:"id" xml:"id,attr,omitempty"`
|
|
Secret string `json:"secret" xml:"secret,attr,omitempty"`
|
|
SecretType string `json:"secret_type" xml:"secretType,attr,omitempty"`
|
|
Credential struct {
|
|
Type string `xml:"type,attr"`
|
|
Value string `xml:",chardata"`
|
|
} `json:"-" xml:"credential"`
|
|
SourceKey struct {
|
|
Type string `xml:"type,attr"`
|
|
Account string `xml:"account,attr"`
|
|
} `json:"source_key" xml:"sourceKey"`
|
|
Type string `xml:"type,attr,omitempty"`
|
|
|
|
// Parity fields
|
|
CreatedOn string `json:"created_on,omitempty" xml:"createdOn,omitempty"`
|
|
UpdatedOn string `json:"updated_on,omitempty" xml:"updatedOn,omitempty"`
|
|
SourceProviderID string `json:"sourceproviderid,omitempty" xml:"sourceproviderid,omitempty"`
|
|
Username string `json:"username,omitempty" xml:"username,omitempty"`
|
|
SourceName string `json:"source_name,omitempty" xml:"sourcename,omitempty"`
|
|
Name string `json:"name,omitempty" xml:"name,omitempty"`
|
|
SourceSettings string `json:"-" xml:"sourceSettings,omitempty"`
|
|
Status string `json:"status,omitempty" xml:"-"`
|
|
|
|
// Legacy fields for backward compatibility in code if needed,
|
|
// though it's better to update the code to use SourceKey.
|
|
SourceKeyType string `json:"source_key_type" xml:"-"`
|
|
SourceKeyAccount string `json:"source_key_account" xml:"-"`
|
|
}
|
|
|
|
type sourceCredential struct {
|
|
Type string `xml:"type,attr"`
|
|
Value string `xml:",chardata"`
|
|
}
|
|
|
|
type sourceAlias struct {
|
|
XMLName xml.Name `xml:"source"`
|
|
DisplayName string `xml:"displayName,attr,omitempty"`
|
|
ID string `xml:"id,attr,omitempty"`
|
|
Type string `xml:"type,attr,omitempty"`
|
|
CreatedOn string `xml:"createdOn,omitempty"`
|
|
Credential *sourceCredential `xml:"credential,omitempty"`
|
|
Name string `xml:"name"`
|
|
SourceProviderID string `xml:"sourceproviderid,omitempty"`
|
|
SourceName string `xml:"sourcename"`
|
|
SourceSettings string `xml:"sourceSettings"`
|
|
UpdatedOn string `xml:"updatedOn,omitempty"`
|
|
Username string `xml:"username"`
|
|
}
|
|
|
|
func (s ConfiguredSource) getFirstNonEmpty(vals ...string) string {
|
|
for _, v := range vals {
|
|
if v != "" {
|
|
return v
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// MarshalXML implements the xml.Marshaler interface for custom XML encoding of ConfiguredSource.
|
|
func (s ConfiguredSource) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
|
|
a := sourceAlias{
|
|
XMLName: xml.Name{Local: start.Name.Local},
|
|
DisplayName: s.DisplayName,
|
|
ID: s.ID,
|
|
Type: s.Type,
|
|
CreatedOn: s.CreatedOn,
|
|
Name: s.Name,
|
|
SourceProviderID: s.SourceProviderID,
|
|
SourceName: s.SourceName,
|
|
SourceSettings: s.SourceSettings,
|
|
UpdatedOn: s.UpdatedOn,
|
|
Username: s.Username,
|
|
}
|
|
|
|
// Bose XML for sources usually does NOT include displayName attribute
|
|
// except for when it's explicitly stored in our datastore as such.
|
|
// For parity with official responses, we omit it if ID is present or for standard sources.
|
|
if s.ID != "" || s.SourceKeyType != "" || s.Type != "" {
|
|
a.DisplayName = ""
|
|
}
|
|
|
|
a.Name = s.Name
|
|
a.SourceName = s.SourceName
|
|
a.Username = s.Username
|
|
|
|
// Parity: for TuneIn and some others, sourcename, name and username should NOT automatically fall back to displayName
|
|
// if they are intended to be empty. However, if they are ALL empty, we need some value.
|
|
isTuneIn := strings.EqualFold(s.DisplayName, "TUNEIN") || strings.EqualFold(s.SourceKeyType, "TUNEIN") || strings.EqualFold(s.ID, "TUNEIN")
|
|
|
|
if a.Name == "" {
|
|
a.Name = s.getFirstNonEmpty(s.Name, s.SourceName, s.Username, s.DisplayName)
|
|
}
|
|
|
|
if a.SourceName == "" && !isTuneIn {
|
|
a.SourceName = s.getFirstNonEmpty(s.SourceName, s.Name, s.Username, s.DisplayName)
|
|
}
|
|
|
|
if a.Username == "" && !isTuneIn {
|
|
a.Username = s.getFirstNonEmpty(s.Username, s.Name, s.SourceName, s.DisplayName)
|
|
}
|
|
|
|
if s.Secret != "" || s.SecretType != "" {
|
|
a.Credential = &sourceCredential{
|
|
Type: s.SecretType,
|
|
Value: s.Secret,
|
|
}
|
|
} else if s.Credential.Value != "" || s.Credential.Type != "" {
|
|
a.Credential = &sourceCredential{
|
|
Type: s.Credential.Type,
|
|
Value: s.Credential.Value,
|
|
}
|
|
}
|
|
|
|
if a.SourceSettings == "" {
|
|
a.SourceSettings = ""
|
|
}
|
|
|
|
// Important: Clear automatically generated attributes from the start element
|
|
// because we are using Alias to control attribute order and presence.
|
|
start.Attr = nil
|
|
|
|
return e.EncodeElement(a, start)
|
|
}
|
|
|
|
// ServiceDeviceInfo represents information about a SoundTouch device.
|
|
type ServiceDeviceInfo struct {
|
|
DeviceID string `json:"device_id" xml:"deviceID,attr"`
|
|
ProductCode string `json:"product_code" xml:"type"`
|
|
DeviceSerialNumber string `json:"device_serial_number" xml:"serialnumber"`
|
|
ProductSerialNumber string `json:"product_serial_number" xml:"product_serial_number"`
|
|
FirmwareVersion string `json:"firmware_version" xml:"softwareVersion"`
|
|
IPAddress string `json:"ip_address" xml:"ipAddress"`
|
|
Name string `json:"name" xml:"name"`
|
|
MacAddress string `json:"mac_address,omitempty" xml:"-"`
|
|
DiscoveryMethod string `json:"discovery_method,omitempty"`
|
|
AccountID string `json:"account_id,omitempty"`
|
|
Components []ServiceComponent `json:"components,omitempty" xml:"-"`
|
|
// CreatedOn is the ISO8601 timestamp the device was first
|
|
// registered against the account. Preserved across renames so
|
|
// AfterTouch's PUT response matches real Bose's "first paired
|
|
// in 2017" semantics rather than rewriting `now()` on every
|
|
// update. Empty for never-persisted records.
|
|
CreatedOn string `json:"created_on,omitempty" xml:"-"`
|
|
// UpdatedOn is the ISO8601 timestamp of the most recent change
|
|
// to the device record (rename, IP refresh, …). Refreshed by
|
|
// every SaveDeviceInfo write that mutates a known device.
|
|
UpdatedOn string `json:"updated_on,omitempty" xml:"-"`
|
|
}
|
|
|
|
// ServiceComponent represents a hardware or software component of a device.
|
|
type ServiceComponent struct {
|
|
Type string `json:"type" xml:"type,attr"`
|
|
Category string `json:"category,omitempty" xml:"category,attr,omitempty"`
|
|
SoftwareVersion string `json:"firmware_version" xml:"firmware-version"`
|
|
SerialNumber string `json:"serial_number" xml:"serialnumber"`
|
|
Label string `json:"label,omitempty" xml:"componentlabel"`
|
|
}
|
|
|
|
// ServiceAccountInfo represents account-level metadata.
|
|
type ServiceAccountInfo struct {
|
|
AccountID string `json:"account_id"`
|
|
PreferredLanguage string `json:"preferred_language"`
|
|
ProviderSettings []ProviderSetting `json:"provider_settings"`
|
|
IsPlaceholder bool `json:"is_placeholder,omitempty"`
|
|
}
|
|
|
|
// CustomerSupportDevice represents device information for customer support purposes.
|
|
type CustomerSupportDevice struct {
|
|
ID string `xml:"id,attr"`
|
|
SerialNumber string `xml:"serialnumber"`
|
|
FirmwareVersion string `xml:"firmware-version"`
|
|
Product struct {
|
|
ProductCode string `xml:"product_code,attr"`
|
|
Type string `xml:"type,attr"`
|
|
SerialNumber string `xml:"serialnumber"`
|
|
} `xml:"product"`
|
|
}
|
|
|
|
// CustomerSupportRequest represents a customer support request with device and configuration details.
|
|
type CustomerSupportRequest struct {
|
|
XMLName xml.Name `xml:"device-data"`
|
|
Device CustomerSupportDevice `xml:"device"`
|
|
DiagnosticData struct {
|
|
DeviceLandscape struct {
|
|
RSSI string `xml:"rssi"`
|
|
GatewayIP string `xml:"gateway-ip-address"`
|
|
IPAddress string `xml:"ip-address"`
|
|
NetworkConnectionType string `xml:"network-connection-type"`
|
|
MacAddresses []string `xml:"macaddresses>macaddress"`
|
|
} `xml:"device-landscape"`
|
|
} `xml:"diagnostic-data"`
|
|
}
|
|
|
|
// UsageStats represents usage statistics for the service.
|
|
type UsageStats struct {
|
|
DeviceID string `json:"deviceId" xml:"deviceId"`
|
|
AccountID string `json:"accountId" xml:"accountId"`
|
|
Timestamp string `json:"timestamp" xml:"timestamp"`
|
|
EventType string `json:"eventType" xml:"eventType"`
|
|
Parameters map[string]interface{} `json:"parameters" xml:"parameters"`
|
|
}
|
|
|
|
// ErrorStats represents error statistics for monitoring and debugging.
|
|
type ErrorStats struct {
|
|
DeviceID string `json:"deviceId" xml:"deviceId"`
|
|
ErrorCode string `json:"errorCode" xml:"errorCode"`
|
|
ErrorMessage string `json:"errorMessage" xml:"errorMessage"`
|
|
Timestamp string `json:"timestamp" xml:"timestamp"`
|
|
Details string `json:"details,omitempty" xml:"details,omitempty"`
|
|
}
|
|
|
|
// DeviceEvent represents an event that occurred on a device.
|
|
type DeviceEvent struct {
|
|
Type string `json:"type"`
|
|
Time string `json:"time"`
|
|
MonoTime int64 `json:"monoTime"`
|
|
Data map[string]interface{} `json:"data"`
|
|
}
|
|
|
|
// DeviceEventsRequest represents a request containing multiple device events (stapp/scmudc).
|
|
type DeviceEventsRequest struct {
|
|
Envelope struct {
|
|
MonoTime int64 `json:"monoTime"`
|
|
PayloadProtocolVersion string `json:"payloadProtocolVersion"`
|
|
PayloadType string `json:"payloadType"`
|
|
ProtocolVersion string `json:"protocolVersion"`
|
|
Time string `json:"time"`
|
|
UniqueID string `json:"uniqueId"`
|
|
} `json:"envelope"`
|
|
Payload struct {
|
|
DeviceInfo struct {
|
|
BoseID string `json:"boseID"`
|
|
DeviceID string `json:"deviceID"`
|
|
DeviceType string `json:"deviceType"`
|
|
SoftwareVersion string `json:"softwareVersion"`
|
|
} `json:"deviceInfo"`
|
|
Events []struct {
|
|
Data map[string]interface{} `json:"data"`
|
|
Time string `json:"time"`
|
|
Type string `json:"type"`
|
|
} `json:"events"`
|
|
} `json:"payload"`
|
|
}
|
|
|
|
// DeviceSettingsResponse represents device settings.
|
|
type DeviceSettingsResponse struct {
|
|
XMLName xml.Name `xml:"deviceSettings"`
|
|
Settings []DeviceSetting `xml:"deviceSetting"`
|
|
}
|
|
|
|
// DeviceSetting represents a single device setting.
|
|
type DeviceSetting struct {
|
|
Name string `xml:"name"`
|
|
Value string `xml:"value"`
|
|
}
|
|
|
|
// AccountProfileResponse represents a customer account profile.
|
|
type AccountProfileResponse struct {
|
|
XMLName xml.Name `xml:"customer"`
|
|
AccountID string `xml:"accountID"`
|
|
Email string `xml:"email"`
|
|
FirstName string `xml:"firstName"`
|
|
LastName string `xml:"lastName"`
|
|
CountryCode string `xml:"countryCode"`
|
|
LanguageCode string `xml:"languageCode"`
|
|
Street string `xml:"street"`
|
|
City string `xml:"city"`
|
|
PostalCode string `xml:"postalCode"`
|
|
State string `xml:"state"`
|
|
Phone string `xml:"phone"`
|
|
MarketingOptIn bool `xml:"marketingOptIn"`
|
|
}
|
|
|
|
// ChangePasswordRequest represents a request to change the account password.
|
|
type ChangePasswordRequest struct {
|
|
XMLName xml.Name `xml:"passwordChange"`
|
|
OldPassword string `xml:"oldPassword"`
|
|
NewPassword string `xml:"newPassword"`
|
|
}
|
|
|
|
// EmailAddressResponse represents the account email address.
|
|
type EmailAddressResponse struct {
|
|
XMLName xml.Name `xml:"emailAddress"`
|
|
Email string `xml:",chardata"`
|
|
}
|
|
|
|
// FullResponseSource represents a configured media source specifically for the /full response.
|
|
// It follows the specific XML structure and field order of the upstream /full response.
|
|
type FullResponseSource struct {
|
|
ID string `json:"id" xml:"id,attr"`
|
|
Type string `json:"type" xml:"type,attr"`
|
|
DisplayName string `json:"display_name" xml:"displayName,attr,omitempty"`
|
|
CreatedOn string `json:"created_on" xml:"createdOn"`
|
|
Credential struct {
|
|
Type string `json:"type" xml:"type,attr"`
|
|
Value string `json:"value" xml:",chardata"`
|
|
} `json:"credential" xml:"credential"`
|
|
Name string `json:"name" xml:"name"`
|
|
SourceProviderID string `json:"sourceproviderid" xml:"sourceproviderid"`
|
|
SourceName string `json:"source_name" xml:"sourcename"`
|
|
SourceSettings string `json:"source_settings" xml:"sourceSettings"`
|
|
UpdatedOn string `json:"updated_on" xml:"updatedOn"`
|
|
Username string `json:"username" xml:"username"`
|
|
Account string `json:"account,omitempty" xml:"account,attr,omitempty"`
|
|
SourceLabel string `json:"source_label" xml:"-"`
|
|
ProviderLabel string `json:"provider_label,omitempty" xml:"-"`
|
|
}
|
|
|
|
// FullResponsePreset represents a preset specifically for the /full response.
|
|
type FullResponsePreset struct {
|
|
ButtonNumber string `json:"button_number" xml:"buttonNumber,attr"`
|
|
ContainerArt string `json:"container_art" xml:"containerArt"`
|
|
ContentItemType string `json:"content_item_type" xml:"contentItemType"`
|
|
CreatedOn string `json:"created_on" xml:"createdOn"`
|
|
Location string `json:"location" xml:"location"`
|
|
Name string `json:"name" xml:"name"`
|
|
Source FullResponseSource `json:"source" xml:"source"`
|
|
UpdatedOn string `json:"updated_on" xml:"updatedOn"`
|
|
Username string `json:"username" xml:"username"`
|
|
}
|
|
|
|
// FullResponseRecent represents a recent item specifically for the /full response.
|
|
type FullResponseRecent struct {
|
|
ID string `json:"id" xml:"id,attr"`
|
|
ContentItemType string `json:"content_item_type" xml:"contentItemType"`
|
|
CreatedOn string `json:"created_on" xml:"createdOn"`
|
|
LastPlayedAt string `json:"last_played_at" xml:"lastplayedat"`
|
|
Location string `json:"location" xml:"location"`
|
|
Name string `json:"name" xml:"name"`
|
|
Source FullResponseSource `json:"source" xml:"source"`
|
|
SourceID string `json:"source_id" xml:"sourceid"`
|
|
UpdatedOn string `json:"updated_on" xml:"updatedOn"`
|
|
Username string `json:"username" xml:"username"`
|
|
}
|
|
|
|
// AccountFullResponse represents the complete account XML structure.
|
|
type AccountFullResponse struct {
|
|
XMLName xml.Name `xml:"account"`
|
|
ID string `xml:"id,attr"`
|
|
AccountStatus string `xml:"accountStatus"`
|
|
Devices []AccountDevice `xml:"devices>device"`
|
|
Mode string `xml:"mode"`
|
|
PreferredLanguage string `xml:"preferredLanguage"`
|
|
ProviderSettings []ProviderSetting `xml:"providerSettings>providerSetting"`
|
|
Sources []FullResponseSource `xml:"sources>source"`
|
|
}
|
|
|
|
// AccountSourcesResponse represents the response from /streaming/account/{accountId}/sources.
|
|
type AccountSourcesResponse struct {
|
|
XMLName xml.Name `xml:"sources"`
|
|
Sources []FullResponseSource `xml:"source"`
|
|
}
|
|
|
|
// AccountDevicesResponse represents the response from /streaming/account/{accountId}/devices.
|
|
type AccountDevicesResponse struct {
|
|
XMLName xml.Name `xml:"devices"`
|
|
Devices []MargeAccountDevice `xml:"device"`
|
|
ProviderSettings []ProviderSetting `xml:"providerSettings>providerSetting"`
|
|
}
|
|
|
|
// MargeAccountDevice represents a device specifically for the /devices response.
|
|
// It matches the structure in 06_orig.xml, which is a subset of AccountDevice.
|
|
type MargeAccountDevice struct {
|
|
DeviceID string `json:"device_id" xml:"deviceid,attr"`
|
|
AttachedProduct *AttachedProduct `json:"attached_product" xml:"attachedProduct"`
|
|
CreatedOn string `json:"created_on" xml:"createdOn"`
|
|
IPAddress string `json:"ip_address" xml:"ipaddress"`
|
|
Name string `json:"name" xml:"name"`
|
|
UpdatedOn string `json:"updated_on" xml:"updatedOn"`
|
|
}
|
|
|
|
// AccountDevice represents a device in the account response.
|
|
type AccountDevice struct {
|
|
DeviceID string `json:"device_id" xml:"deviceid,attr"`
|
|
AttachedProduct *AttachedProduct `json:"attached_product" xml:"attachedProduct"`
|
|
CreatedOn string `json:"created_on" xml:"createdOn"`
|
|
FirmwareVersion string `json:"firmware_version" xml:"firmwareVersion"`
|
|
IPAddress string `json:"ip_address" xml:"ipaddress"`
|
|
Name string `json:"name" xml:"name"`
|
|
Presets []FullResponsePreset `json:"presets" xml:"presets>preset,omitempty"`
|
|
ProductCode string `json:"product_code" xml:"-"`
|
|
Recents []FullResponseRecent `json:"recents" xml:"recents>recent,omitempty"`
|
|
SerialNumber string `json:"serial_number" xml:"serialNumber,omitempty"`
|
|
DeviceSerialNumber string `json:"device_serial_number,omitempty" xml:"-"`
|
|
MacAddress string `json:"mac_address,omitempty" xml:"-"`
|
|
DiscoveryMethod string `json:"discovery_method,omitempty" xml:"-"`
|
|
UpdatedOn string `json:"updated_on" xml:"updatedOn"`
|
|
}
|
|
|
|
// AttachedProduct represents product information for a device.
|
|
type AttachedProduct struct {
|
|
ProductCode string `json:"product_code" xml:"product_code,attr"`
|
|
Components []ServiceComponent `json:"components" xml:"components>component,omitempty"`
|
|
ProductLabel string `json:"product_label" xml:"productlabel"`
|
|
SerialNumber string `json:"serial_number" xml:"serialnumber"`
|
|
UpdatedOn string `json:"updated_on" xml:"updatedOn"`
|
|
}
|
|
|
|
// ProviderSetting represents a single provider setting.
|
|
type ProviderSetting struct {
|
|
BoseID string `json:"bose_id" xml:"boseId"`
|
|
KeyName string `json:"key_name" xml:"keyName"`
|
|
Value string `json:"value" xml:"value"`
|
|
ProviderID string `json:"provider_id" xml:"providerId"`
|
|
ProviderName string `json:"provider_name,omitempty" xml:"-"`
|
|
}
|
|
|
|
// MargeLoginRequest represents a login request from Stockholm.
|
|
type MargeLoginRequest struct {
|
|
XMLName xml.Name `xml:"login"`
|
|
Username string `xml:"username"`
|
|
Password string `xml:"password"`
|
|
}
|
|
|
|
// MargeAccountCreateRequest represents an account creation request from Stockholm.
|
|
type MargeAccountCreateRequest struct {
|
|
XMLName xml.Name `xml:"account"`
|
|
ID string `xml:"id,attr,omitempty"` // Optional ID for testing/overrides
|
|
FirstName string `xml:"firstName"`
|
|
LastName string `xml:"lastName"`
|
|
Email string `xml:"email"`
|
|
Password string `xml:"password"`
|
|
CountryCode string `xml:"countryCode"`
|
|
PreferredLanguage string `xml:"preferredLanguage"`
|
|
}
|
|
|
|
// MargeAddSourceResponse represents the response after adding a source to Marge.
|
|
type MargeAddSourceResponse struct {
|
|
XMLName xml.Name `xml:"source"`
|
|
SourceID string `xml:"sourceID"`
|
|
SourceProviderID string `xml:"sourceProviderID"`
|
|
CreatedOn string `xml:"createdOn"`
|
|
UpdatedOn string `xml:"updatedOn"`
|
|
}
|
|
|
|
// EligibilityResponse represents the XML response for music provider eligibility.
|
|
type EligibilityResponse struct {
|
|
XMLName xml.Name `xml:"eligibility"`
|
|
IsEligible bool `xml:"isEligible"`
|
|
}
|
|
|
|
// MargeAPIVersionsResponse represents the XML response for Marge API versions.
|
|
type MargeAPIVersionsResponse struct {
|
|
XMLName xml.Name `xml:"marge"`
|
|
Version string `xml:"version,attr"`
|
|
Project string `xml:"project,attr"`
|
|
Apis []MargeAPI `xml:"apis>api"`
|
|
Dependencies string `xml:"dependencies"`
|
|
}
|
|
|
|
// MargeAPI represents a single API entry in MargeAPIVersionsResponse.
|
|
type MargeAPI struct {
|
|
Type string `xml:"type,attr"`
|
|
XML string `xml:"xml"`
|
|
JSON string `xml:"json"`
|
|
}
|