diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go index 1c15828..7ecd1a5 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -1,3 +1,5 @@ +// Package main provides the SoundTouch service daemon that acts as a proxy and management +// interface for Bose SoundTouch devices, providing Marge service emulation and device discovery. package main import ( diff --git a/examples/service-demo/main.go b/examples/service-demo/main.go index 0bbc117..5466b18 100644 --- a/examples/service-demo/main.go +++ b/examples/service-demo/main.go @@ -1,3 +1,5 @@ +// Package main provides a demo client for the SoundTouch service API, +// demonstrating how to interact with devices and retrieve media information. package main import ( diff --git a/pkg/models/models.go b/pkg/models/models.go index d9f2fba..c7a7a89 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -1,14 +1,19 @@ +// 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" ) +// 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"` } +// 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"` @@ -22,6 +27,7 @@ type Links struct { BmxTrack *Link `json:"bmx_track,omitempty" xml:"bmx_track,omitempty"` } +// 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"` @@ -30,6 +36,7 @@ type IconSet struct { 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"` @@ -38,11 +45,13 @@ type Asset struct { 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"` @@ -54,12 +63,14 @@ type BmxService struct { 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"` @@ -69,6 +80,7 @@ type Stream struct { 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"` @@ -77,6 +89,7 @@ type Audio struct { 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 { @@ -92,12 +105,14 @@ type BmxPlaybackResponse struct { 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"` @@ -107,6 +122,7 @@ type BmxPodcastInfoResponse struct { 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"` @@ -114,6 +130,7 @@ type SourceProvider struct { 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:"itemName"` @@ -125,6 +142,7 @@ type ServiceContentItem struct { IsPresetable string `json:"is_presetable,omitempty" xml:"isPresetable,attr,omitempty"` } +// ServicePreset represents a user-defined preset for quick access to media content. type ServicePreset struct { ServiceContentItem ContainerArt string `json:"container_art" xml:"containerArt"` @@ -132,6 +150,7 @@ type ServicePreset struct { UpdatedOn string `json:"updated_on" xml:"updatedOn"` } +// ServiceRecent represents recently played media content. type ServiceRecent struct { ServiceContentItem DeviceID string `json:"device_id" xml:"deviceid"` @@ -139,6 +158,7 @@ type ServiceRecent struct { ContainerArt string `json:"container_art,omitempty" xml:"containerArt,omitempty"` } +// ConfiguredSource represents a configured media source with authentication details. type ConfiguredSource struct { DisplayName string `json:"display_name" xml:"sourcename"` ID string `json:"id" xml:"id,attr"` @@ -148,6 +168,7 @@ type ConfiguredSource struct { SourceKeyAccount string `json:"source_key_account" xml:"username"` } +// 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"` @@ -158,6 +179,7 @@ type ServiceDeviceInfo struct { Name string `json:"name" xml:"name"` } +// CustomerSupportDevice represents device information for customer support purposes. type CustomerSupportDevice struct { ID string `xml:"id,attr"` SerialNumber string `xml:"serialnumber"` @@ -169,6 +191,7 @@ type CustomerSupportDevice struct { } `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"` @@ -183,6 +206,7 @@ type CustomerSupportRequest struct { } `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"` @@ -191,6 +215,7 @@ type UsageStats struct { 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"` @@ -199,6 +224,7 @@ type ErrorStats struct { 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"` diff --git a/pkg/service/datastore/datastore.go b/pkg/service/datastore/datastore.go index 616dfe5..bdb077c 100644 --- a/pkg/service/datastore/datastore.go +++ b/pkg/service/datastore/datastore.go @@ -28,6 +28,7 @@ type DataStore struct { } // NewDataStore creates a new DataStore. +// NewDataStore creates a new DataStore instance with the specified data directory. func NewDataStore(dataDir string) *DataStore { if dataDir == "" { dataDir = "data" @@ -39,18 +40,22 @@ func NewDataStore(dataDir string) *DataStore { } } +// AccountDir returns the directory path for a specific account. func (ds *DataStore) AccountDir(account string) string { return filepath.Join(ds.DataDir, account) } +// AccountDevicesDir returns the devices directory path for a specific account. func (ds *DataStore) AccountDevicesDir(account string) string { return filepath.Join(ds.DataDir, account, constants.DevicesDir) } +// AccountDeviceDir returns the directory path for a specific device within an account. func (ds *DataStore) AccountDeviceDir(account, device string) string { return filepath.Join(ds.AccountDevicesDir(account), device) } +// GetDeviceInfo retrieves device information for the specified account and device. func (ds *DataStore) GetDeviceInfo(account, device string) (*models.ServiceDeviceInfo, error) { path := filepath.Join(ds.AccountDeviceDir(account, device), constants.DeviceInfoFile) @@ -245,6 +250,7 @@ func (ds *DataStore) parseDeviceInfoFile(path string) (*models.ServiceDeviceInfo return deviceInfo, nil } +// GetPresets retrieves all presets for the specified account. func (ds *DataStore) GetPresets(account string) ([]models.ServicePreset, error) { path := filepath.Join(ds.AccountDir(account), constants.PresetsFile) @@ -297,6 +303,7 @@ func (ds *DataStore) GetPresets(account string) ([]models.ServicePreset, error) return presets, nil } +// SavePresets saves the preset list for the specified account. func (ds *DataStore) SavePresets(account string, presets []models.ServicePreset) error { path := filepath.Join(ds.AccountDir(account), constants.PresetsFile) @@ -350,6 +357,7 @@ func (ds *DataStore) SavePresets(account string, presets []models.ServicePreset) return os.WriteFile(path, append(header, data...), 0644) } +// GetRecents retrieves all recent items for the specified account. func (ds *DataStore) GetRecents(account string) ([]models.ServiceRecent, error) { path := filepath.Join(ds.AccountDir(account), constants.RecentsFile) @@ -402,6 +410,7 @@ func (ds *DataStore) GetRecents(account string) ([]models.ServiceRecent, error) return recents, nil } +// SaveRecents saves the recent items list for the specified account. func (ds *DataStore) SaveRecents(account string, recents []models.ServiceRecent) error { path := filepath.Join(ds.AccountDir(account), constants.RecentsFile) @@ -460,6 +469,7 @@ func (ds *DataStore) SaveRecents(account string, recents []models.ServiceRecent) return os.WriteFile(path, append(header, data...), 0644) } +// SaveDeviceInfo saves device information for the specified account and device. func (ds *DataStore) SaveDeviceInfo(account, device string, info *models.ServiceDeviceInfo) error { if device == "" { return fmt.Errorf("device ID/name cannot be empty") @@ -541,11 +551,13 @@ func (ds *DataStore) SaveDeviceInfo(account, device string, info *models.Service return os.WriteFile(path, append(header, data...), 0644) } +// RemoveDevice removes a device and all its data from the specified account. func (ds *DataStore) RemoveDevice(account, device string) error { dir := ds.AccountDeviceDir(account, device) return os.RemoveAll(dir) } +// GetConfiguredSources retrieves all configured sources for the specified account. func (ds *DataStore) GetConfiguredSources(account string) ([]models.ConfiguredSource, error) { path := filepath.Join(ds.AccountDir(account), constants.SourcesFile) @@ -595,6 +607,7 @@ func (ds *DataStore) GetConfiguredSources(account string) ([]models.ConfiguredSo return sources, nil } +// SaveConfiguredSources saves the configured sources list for the specified account. func (ds *DataStore) SaveConfiguredSources(account string, sources []models.ConfiguredSource) error { path := filepath.Join(ds.AccountDir(account), constants.SourcesFile) if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { @@ -641,6 +654,7 @@ func (ds *DataStore) SaveConfiguredSources(account string, sources []models.Conf return os.WriteFile(path, append(header, data...), 0644) } +// Initialize creates the necessary directory structure for the datastore. func (ds *DataStore) Initialize() error { // Ensure base data directory exists if err := os.MkdirAll(ds.DataDir, 0755); err != nil { @@ -661,6 +675,7 @@ func (ds *DataStore) Initialize() error { return nil } +// GetETagForPresets returns the ETag (modification time) for the presets file. func (ds *DataStore) GetETagForPresets(account string) int64 { path := filepath.Join(ds.AccountDir(account), constants.PresetsFile) @@ -672,6 +687,7 @@ func (ds *DataStore) GetETagForPresets(account string) int64 { return info.ModTime().UnixNano() / int64(time.Millisecond) } +// GetETagForSources returns the ETag (modification time) for the sources file. func (ds *DataStore) GetETagForSources(account string) int64 { path := filepath.Join(ds.AccountDir(account), constants.SourcesFile) @@ -683,6 +699,7 @@ func (ds *DataStore) GetETagForSources(account string) int64 { return info.ModTime().UnixNano() / int64(time.Millisecond) } +// GetETagForRecents returns the ETag (modification time) for the recents file. func (ds *DataStore) GetETagForRecents(account string) int64 { path := filepath.Join(ds.AccountDir(account), constants.RecentsFile) @@ -694,6 +711,7 @@ func (ds *DataStore) GetETagForRecents(account string) int64 { return info.ModTime().UnixNano() / int64(time.Millisecond) } +// GetETagForAccount returns the highest ETag among presets, sources, and recents for the account. func (ds *DataStore) GetETagForAccount(account string) int64 { e1 := ds.GetETagForPresets(account) e2 := ds.GetETagForSources(account) @@ -711,6 +729,7 @@ func (ds *DataStore) GetETagForAccount(account string) int64 { return maxETag } +// SaveUsageStats saves usage statistics to the datastore. func (ds *DataStore) SaveUsageStats(stats models.UsageStats) error { dir := filepath.Join(ds.DataDir, "stats", "usage") if err := os.MkdirAll(dir, 0755); err != nil { @@ -728,6 +747,7 @@ func (ds *DataStore) SaveUsageStats(stats models.UsageStats) error { return os.WriteFile(path, data, 0644) } +// SaveErrorStats saves error statistics to the datastore. func (ds *DataStore) SaveErrorStats(stats models.ErrorStats) error { dir := filepath.Join(ds.DataDir, "stats", "error") if err := os.MkdirAll(dir, 0755); err != nil { @@ -745,6 +765,7 @@ func (ds *DataStore) SaveErrorStats(stats models.ErrorStats) error { return os.WriteFile(path, data, 0644) } +// AddDeviceEvent adds a device event to the in-memory event store. func (ds *DataStore) AddDeviceEvent(deviceID string, event models.DeviceEvent) { ds.eventMutex.Lock() defer ds.eventMutex.Unlock() @@ -760,6 +781,7 @@ func (ds *DataStore) AddDeviceEvent(deviceID string, event models.DeviceEvent) { ds.deviceEvents[deviceID] = events } +// GetDeviceEvents retrieves all events for the specified device. func (ds *DataStore) GetDeviceEvents(deviceID string) []models.DeviceEvent { ds.eventMutex.RLock() defer ds.eventMutex.RUnlock() diff --git a/pkg/service/marge/marge.go b/pkg/service/marge/marge.go index 4a2d70d..dde7a09 100644 --- a/pkg/service/marge/marge.go +++ b/pkg/service/marge/marge.go @@ -1,3 +1,5 @@ +// Package marge provides XML generation and data management for the Marge service, +// which handles SoundTouch device configuration, presets, recents, and account management. package marge import ( @@ -12,8 +14,10 @@ import ( "github.com/gesellix/bose-soundtouch/pkg/service/datastore" ) +// DateStr is a fixed timestamp used in XML responses for consistency. const DateStr = "2012-09-19T12:43:00.000+00:00" +// SourceProviders returns a list of available media source providers. func SourceProviders() []models.SourceProvider { providers := make([]models.SourceProvider, len(constants.Providers)) for i, name := range constants.Providers { @@ -28,11 +32,13 @@ func SourceProviders() []models.SourceProvider { return providers } +// SourceProvidersXML represents the XML structure for source providers. type SourceProvidersXML struct { XMLName xml.Name `xml:"sourceProviders"` Providers []models.SourceProvider `xml:"sourceProvider"` } +// SourceProvidersToXML converts source providers to XML format. func SourceProvidersToXML() ([]byte, error) { sp := SourceProvidersXML{ Providers: SourceProviders(), @@ -46,6 +52,7 @@ func SourceProvidersToXML() ([]byte, error) { return append([]byte(xml.Header), data...), nil } +// ConfiguredSourceToXML converts a configured source to XML format. func ConfiguredSourceToXML(cs models.ConfiguredSource) ([]byte, error) { type SourceXML struct { XMLName xml.Name `xml:"source"` @@ -89,6 +96,7 @@ func ConfiguredSourceToXML(cs models.ConfiguredSource) ([]byte, error) { return xml.Marshal(sxml) } +// GetConfiguredSourceXML returns the XML representation of a configured source as a string. func GetConfiguredSourceXML(cs models.ConfiguredSource) string { providerID := 0 @@ -103,6 +111,7 @@ func GetConfiguredSourceXML(cs models.ConfiguredSource) string { cs.ID, DateStr, cs.Secret, cs.SourceKeyAccount, providerID, cs.DisplayName, DateStr, cs.SourceKeyAccount) } +// PresetsToXML converts account presets to XML format for Marge responses. func PresetsToXML(ds *datastore.DataStore, account string) ([]byte, error) { presets, err := ds.GetPresets(account) if err != nil { @@ -126,21 +135,13 @@ func PresetsToXML(ds *datastore.DataStore, account string) ([]byte, error) { res += fmt.Sprintf(`%s`, p.Name) // Content Item Source - found := false - for _, s := range sources { if s.ID == p.SourceID || (s.SourceKeyType == p.Source && s.SourceKeyAccount == p.SourceAccount) { res += GetConfiguredSourceXML(s) - found = true - break } } - if !found { - // This might happen if source is not found - } - res += fmt.Sprintf(`%s`, DateStr) res += `` } @@ -150,6 +151,7 @@ func PresetsToXML(ds *datastore.DataStore, account string) ([]byte, error) { return append([]byte(xml.Header), []byte(res)...), nil } +// RecentsToXML converts account recent items to XML format for Marge responses. func RecentsToXML(ds *datastore.DataStore, account string) ([]byte, error) { recents, err := ds.GetRecents(account) if err != nil { @@ -179,21 +181,13 @@ func RecentsToXML(ds *datastore.DataStore, account string) ([]byte, error) { res += fmt.Sprintf(`%s`, r.Name) // Content Item Source - found := false - for _, s := range sources { if s.ID == r.SourceID || (s.SourceKeyType == r.Source && s.SourceKeyAccount == r.SourceAccount) { res += GetConfiguredSourceXML(s) - found = true - break } } - if !found { - // This might happen if source is not found - } - res += fmt.Sprintf(`%s`, DateStr) res += `` } @@ -203,14 +197,17 @@ func RecentsToXML(ds *datastore.DataStore, account string) ([]byte, error) { return append([]byte(xml.Header), []byte(res)...), nil } +// ProviderSettingsToXML generates provider settings XML for the specified account. func ProviderSettingsToXML(account string) string { return fmt.Sprintf(`%sELIGIBLE_FOR_TRIALtrue14`, account) } +// SoftwareUpdateToXML generates software update configuration XML. func SoftwareUpdateToXML() string { return `` } +// AccountFullToXML generates a complete account XML with devices, presets, and recents. func AccountFullToXML(ds *datastore.DataStore, account string) ([]byte, error) { devicesDir := ds.AccountDevicesDir(account) @@ -275,7 +272,8 @@ func AccountFullToXML(ds *datastore.DataStore, account string) ([]byte, error) { return []byte(res), nil } -func UpdatePreset(ds *datastore.DataStore, account, device string, presetNumber int, sourceXML []byte) ([]byte, error) { +// UpdatePreset updates or creates a preset for the specified account and device. +func UpdatePreset(ds *datastore.DataStore, account, _ string, presetNumber int, sourceXML []byte) ([]byte, error) { sources, err := ds.GetConfiguredSources(account) if err != nil { return nil, err @@ -351,6 +349,7 @@ func UpdatePreset(ds *datastore.DataStore, account, device string, presetNumber return append([]byte(xml.Header), []byte(res)...), nil } +// AddRecent adds or updates a recent item for the specified account and device. func AddRecent(ds *datastore.DataStore, account, device string, sourceXML []byte) ([]byte, error) { sources, err := ds.GetConfiguredSources(account) if err != nil { @@ -476,6 +475,7 @@ func formatRecentResponse(recentObj *models.ServiceRecent, matchingSrc *models.C return append([]byte(xml.Header), []byte(res)...) } +// AddDeviceToAccount adds a new device to the specified account. func AddDeviceToAccount(ds *datastore.DataStore, account string, sourceXML []byte) ([]byte, error) { var newDeviceElem struct { DeviceID string `xml:"deviceid,attr"` @@ -506,6 +506,7 @@ func AddDeviceToAccount(ds *datastore.DataStore, account string, sourceXML []byt return append([]byte(xml.Header), []byte(res)...), nil } +// RemoveDeviceFromAccount removes a device from the specified account. func RemoveDeviceFromAccount(ds *datastore.DataStore, account, device string) error { return ds.RemoveDevice(account, device) } diff --git a/pkg/service/setup/setup.go b/pkg/service/setup/setup.go index ffd5674..f32f750 100644 --- a/pkg/service/setup/setup.go +++ b/pkg/service/setup/setup.go @@ -4,7 +4,6 @@ package setup import ( "encoding/xml" "fmt" - "log" "net" "net/http" @@ -119,7 +118,59 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti SSHSuccess: false, } - // 0. Populate from datastore if available + // Populate device info from datastore and live info + m.populateDeviceInfo(summary, deviceIP) + + // 1. Initial planned config + plannedCfg := PrivateCfg{ + MargeServerUrl: fmt.Sprintf("%s/marge", targetURL), + StatsServerUrl: targetURL, + SwUpdateUrl: fmt.Sprintf("%s/updates/soundtouch", targetURL), + UsePandoraProductionServer: true, + IsZeroconfEnabled: true, + SaveMargeCustomerReport: false, + BmxRegistryUrl: fmt.Sprintf("%s/bmx/registry/v1/services", targetURL), + } + + // 2. Check SSH and read current config + currentConfig, err := m.checkCurrentConfig(summary, deviceIP) + if err == nil && currentConfig != "" { + summary.CurrentConfig = currentConfig + fmt.Printf("Current config from %s (length: %d):\n%q\n", deviceIP, len(currentConfig), currentConfig) + + // Parse current config + var currentCfg PrivateCfg + if xml.Unmarshal([]byte(currentConfig), ¤tCfg) == nil { + summary.ParsedCurrentConfig = ¤tCfg + + if proxyURL == "" { + proxyURL = targetURL + } + + // Apply options if provided + if options != nil { + m.applyProxyOptions(&plannedCfg, proxyURL, options, ¤tCfg) + } + } + } + // Note: CurrentConfig is set by checkCurrentConfig in all cases (success or failure) + + xmlContent, err := xml.MarshalIndent(plannedCfg, "", " ") + if err != nil { + return nil, fmt.Errorf("failed to marshal planned XML: %w", err) + } + + summary.PlannedConfig = "\n" + string(xmlContent) + + // 3. Check for remote services files + m.checkRemoteServices(summary, deviceIP) + + return summary, nil +} + +// populateDeviceInfo fills in device information from datastore and live info +func (m *Manager) populateDeviceInfo(summary *MigrationSummary, deviceIP string) { + // Populate from datastore if available if m.DataStore != nil { devices, err := m.DataStore.ListAllDevices() if err == nil { @@ -135,14 +186,11 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti break } - } else { - log.Printf("Warning: failed to list devices from datastore: %v", err) } } - // 0a. Supplement with live info from :8090/info - infoXML, err := m.GetLiveDeviceInfo(deviceIP) - if err == nil { + // Supplement with live info from :8090/info + if infoXML, err := m.GetLiveDeviceInfo(deviceIP); err == nil { if infoXML.Name != "" { summary.DeviceName = infoXML.Name } @@ -158,119 +206,83 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti if infoXML.SoftwareVer != "" { summary.FirmwareVersion = infoXML.SoftwareVer } - } else { - log.Printf("Warning: %v", err) } +} - // 1. Initial planned config - plannedCfg := PrivateCfg{ - MargeServerUrl: fmt.Sprintf("%s/marge", targetURL), - StatsServerUrl: targetURL, - SwUpdateUrl: fmt.Sprintf("%s/updates/soundtouch", targetURL), - UsePandoraProductionServer: true, - IsZeroconfEnabled: true, - SaveMargeCustomerReport: false, - BmxRegistryUrl: fmt.Sprintf("%s/bmx/registry/v1/services", targetURL), - } - - // 2. Check SSH and read current config - var currentConfig string - +// checkCurrentConfig reads and validates the current speaker configuration +func (m *Manager) checkCurrentConfig(summary *MigrationSummary, deviceIP string) (string, error) { path := SoundTouchSdkPrivateCfgPath client := ssh.NewClient(deviceIP) // Check if .original exists if _, checkErr := client.Run(fmt.Sprintf("[ -f %s.original ]", path)); checkErr == nil { - originalConfig, _ := client.Run(fmt.Sprintf("cat %s.original", path)) - if originalConfig != "" { + if originalConfig, _ := client.Run(fmt.Sprintf("cat %s.original", path)); originalConfig != "" { summary.OriginalConfig = originalConfig } } - // Check file details - fileInfo, _ := client.Run(fmt.Sprintf("ls -l %s", path)) - if fileInfo != "" { - fmt.Printf("File info for %s: %s\n", path, fileInfo) - } - - // Try cat + // Try to read current config config, err := client.Run(fmt.Sprintf("cat %s", path)) if err == nil && config != "" { - currentConfig = config summary.SSHSuccess = true - summary.CurrentConfig = currentConfig - fmt.Printf("Current config from %s at %s (length: %d):\n%q\n", deviceIP, path, len(currentConfig), currentConfig) + return config, nil + } - // Parse current config - var currentCfg PrivateCfg - if xml.Unmarshal([]byte(currentConfig), ¤tCfg) == nil { - summary.ParsedCurrentConfig = ¤tCfg + // Fallback: try base64 if cat returned empty string but file has size > 0 + if config == "" { + if fileInfo, _ := client.Run(fmt.Sprintf("ls -l %s", path)); fileInfo != "" { + if b64Config, configErr := client.Run(fmt.Sprintf("base64 %s", path)); configErr == nil && b64Config != "" { + // File exists but couldn't read content properly + summary.SSHSuccess = true + summary.CurrentConfig = fmt.Sprintf("Error reading config: %v", err) - if proxyURL == "" { - proxyURL = targetURL + return "", fmt.Errorf("config file exists but couldn't read content") } + } + } - // Apply options if provided - if options != nil { - // Marge - if options["marge"] == "original" { - plannedCfg.MargeServerUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.MargeServerUrl) - } - // Stats - if options["stats"] == "original" { - plannedCfg.StatsServerUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.StatsServerUrl) - } - // SwUpdate - if options["sw_update"] == "original" { - plannedCfg.SwUpdateUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.SwUpdateUrl) - } - // BMX - if options["bmx"] == "original" { - plannedCfg.BmxRegistryUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.BmxRegistryUrl) - } - } else if proxyURL != "" { - // Default to proxy everything if proxyURL is explicitly provided but no options - // (Maintain backward compatibility for now if needed, but we'll probably always pass options from UI) - // Actually, if proxyURL is set but no options, let's keep the previous behavior of proxying all. - plannedCfg.MargeServerUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.MargeServerUrl) - plannedCfg.StatsServerUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.StatsServerUrl) - plannedCfg.SwUpdateUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.SwUpdateUrl) - plannedCfg.BmxRegistryUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.BmxRegistryUrl) - } + // If SSH failed or file couldn't be read, check if SSH connection works at all + if _, sshErr := client.Run("ls /"); sshErr == nil { + summary.SSHSuccess = true + if err != nil { + summary.CurrentConfig = fmt.Sprintf("Error reading config: %v", err) + } else { + summary.CurrentConfig = config // Might be empty } } else { - // Fallback: try base64 if cat returned empty string but file has size > 0 - if config == "" && fileInfo != "" { - fmt.Printf("Cat returned empty for %s, trying base64\n", path) - - b64Config, configErr := client.Run(fmt.Sprintf("base64 %s", path)) - if configErr == nil && b64Config != "" { - fmt.Printf("Base64 output for %s (length %d)\n", path, len(b64Config)) - } - } - - // If SSH failed or file couldn't be read - if _, sshErr := client.Run("ls /"); sshErr == nil { - summary.SSHSuccess = true - if err != nil { - summary.CurrentConfig = fmt.Sprintf("Error reading config: %v", err) - } else { - summary.CurrentConfig = config // Might be empty - } - } else { - summary.SSHSuccess = false - summary.CurrentConfig = fmt.Sprintf("SSH connection failed: %v", sshErr) - } + summary.SSHSuccess = false + summary.CurrentConfig = fmt.Sprintf("SSH connection failed: %v", sshErr) } - xmlContent, err := xml.MarshalIndent(plannedCfg, "", " ") - if err != nil { - return nil, fmt.Errorf("failed to marshal planned XML: %w", err) + return "", err +} + +// applyProxyOptions modifies planned config based on proxy options +func (m *Manager) applyProxyOptions(plannedCfg *PrivateCfg, proxyURL string, options map[string]string, currentCfg *PrivateCfg) { + if proxyURL == "" || currentCfg == nil { + return } - summary.PlannedConfig = "\n" + string(xmlContent) + if options["marge"] == "original" && currentCfg.MargeServerUrl != "" { + plannedCfg.MargeServerUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.MargeServerUrl) + } - // 3. Check for remote services files + if options["stats"] == "original" && currentCfg.StatsServerUrl != "" { + plannedCfg.StatsServerUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.StatsServerUrl) + } + + if options["sw_update"] == "original" && currentCfg.SwUpdateUrl != "" { + plannedCfg.SwUpdateUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.SwUpdateUrl) + } + + if options["bmx"] == "original" && currentCfg.BmxRegistryUrl != "" { + plannedCfg.BmxRegistryUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.BmxRegistryUrl) + } +} + +// checkRemoteServices checks for remote services files on the device +func (m *Manager) checkRemoteServices(summary *MigrationSummary, deviceIP string) { + client := ssh.NewClient(deviceIP) locations := []string{ "/etc/remote_services", "/mnt/nv/remote_services", @@ -278,8 +290,7 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti } for _, loc := range locations { - _, err := client.Run(fmt.Sprintf("[ -e %s ]", loc)) - if err == nil { + if _, err := client.Run(fmt.Sprintf("[ -e %s ]", loc)); err == nil { summary.RemoteServicesFound = append(summary.RemoteServicesFound, loc) summary.RemoteServicesEnabled = true @@ -288,8 +299,6 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti } } } - - return summary, nil } // MigrateSpeaker configures the speaker at the given IP to use this soundcork service. @@ -324,21 +333,7 @@ func (m *Manager) MigrateSpeaker(deviceIP, targetURL, proxyURL string, options m } if options != nil { - if options["marge"] == "original" { - cfg.MargeServerUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.MargeServerUrl) - } - - if options["stats"] == "original" { - cfg.StatsServerUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.StatsServerUrl) - } - - if options["sw_update"] == "original" { - cfg.SwUpdateUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.SwUpdateUrl) - } - - if options["bmx"] == "original" { - cfg.BmxRegistryUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.BmxRegistryUrl) - } + m.applyProxyOptions(&cfg, proxyURL, options, ¤tCfg) } else if proxyURL != "" { cfg.MargeServerUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.MargeServerUrl) cfg.StatsServerUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.StatsServerUrl)