diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go index 8736ec4..a85a078 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -235,7 +235,7 @@ func main() { sm := setup.NewManager(config.serverURL, ds, cm) sm.MgmtUsername = config.mgmtUsername sm.MgmtPassword = config.mgmtPassword - server := handlers.NewServer(ds, sm, config.serverURL, config.redact, config.logBody, config.record, config.migrationEnabled, config.migrationDryRun) + server := handlers.NewServer(ds, sm, config.serverURL, config.redact, config.logBody, config.record) sm.GetDNSRunning = server.GetDNSRunning server.SetHTTPServerURL(config.httpsServerURL) server.SetVersionInfo(version, commit, date) diff --git a/docs/PARITY-IMPROVEMENTS.md b/docs/PARITY-IMPROVEMENTS.md index be25727..a23ae66 100644 --- a/docs/PARITY-IMPROVEMENTS.md +++ b/docs/PARITY-IMPROVEMENTS.md @@ -3,6 +3,12 @@ This document summarizes the improvements made to the **Marge service** to improve parity with the upstream Bose SoundTouch service, along with open issues and proposed next steps. #### ✅ Completed Improvements (Marge Service) +* **Mapped Preset `buttonNumber`**: Correctly mapped the internal `ServicePreset.ID` to the `buttonNumber` XML attribute in the `/full` response. +* **Populated `contentItemType`**: The `contentItemType` (e.g., `tracklisturl`) is now correctly synchronized from upstream, persisted in the local datastore, and returned in the `/full` response for both presets and recents. +* **Standardized Credential Types**: Adjusted the logic for Spotify to use the correct `token_version_3` type when a token is present in the `/full` response, improving parity with the upstream service. The service now respects existing `credential_type` values from `Sources.xml` (e.g., `token_version_3` for Spotify) while providing sensible defaults for new or incomplete sources. +* **Inconsistent `serialNumber` Casing**: Fixed the casing mismatch in the `/full` response where the upstream uses camelCase `` in the top-level `` and lowercase `` in the nested ``. Local responses now correctly mirror this inconsistency. +* **Device Name Consistency**: Fixed an issue where the device `` was empty in some local `/full` responses by ensuring it is correctly populated from the datastore and synchronized from upstream. +* **Improved XML Parity**: Empty `` tags in the `/full` response are now self-closing (``), matching upstream behavior. * **Timestamp-based ID Generation**: Implemented a 9-digit ID schema (`YYMMDD` + 3-digit counter) for `recent` items, ensuring IDs are large, unique, and stay within the 32-bit integer range. * **Automatic Source Learning**: The service now extracts and persists full metadata (credentials, provider IDs, and custom names) from incoming `POST /recent` requests. This improves parity for subsequent `GET /recents` calls. * **Source Provider Mapping**: Synchronized local source provider IDs and timestamps with upstream data. The `RADIO_BROWSER` provider is included in the public `/streaming/sourceproviders` list to maintain internal functionality while acknowledging it as a parity gap. @@ -15,6 +21,7 @@ This document summarizes the improvements made to the **Marge service** to impro * Implemented structured XML marshaling with consistent 2-space indentation for recents and source providers. * **Improved TuneIn Parity**: Fixed TuneIn source mapping to use ID `25` and ensuring `sourcename` is empty in responses, matching upstream behavior for station playback. * **High-Fidelity Full Account Sync**: Refactored the `/streaming/account/{accountId}/full` response to match the upstream structure. This includes: + * **Mapped Preset `buttonNumber`**: Correctly mapped the internal `ServicePreset.ID` to the `buttonNumber` XML attribute in the `/full` response. * **Structured XML Marshaling**: Replaced manual string concatenation with structured Go models and `xml.Marshal` for the entire response. * **Specific Response Models**: Introduced `FullResponseSource`, `FullResponsePreset`, and `FullResponseRecent` to accurately reflect the upstream structure where `` is a child element, rather than a set of attributes. * **Correct Nesting**: Ensured that `` and `` correctly nest their associated `` details, resolving previous data omissions. @@ -60,3 +67,20 @@ Continue the "learning" approach for other services. For example, if we see a ne Analysis of device reboot logs revealed several data requirements: * **Power-On Details Tracking**: Implemented extraction and persistence of detailed device information (serial numbers, firmware version, product details, and MAC addresses) from the `POST /streaming/support/power_on` request. This data is now stored in the local datastore, improving our ability to respond accurately to subsequent management requests. * **Source Provider Mapping**: Synchronized local source provider IDs and timestamps with upstream data. The `RADIO_BROWSER` provider is included in the public `/streaming/sourceproviders` list to maintain internal functionality while acknowledging it as a parity gap. + +#### 7. Account Full Response (/full) Structural & Value Parity (In Progress) +Based on `_/diffs/diff7/`, several structural and value gaps remain in the `/full` account response: + +**Remaining Findings:** + * **Nested Source Inconsistency in Recents**: The `` element within `` entries still frequently points to a generic fallback (ID `9330201`) instead of the specific source (e.g., Spotify ID `10863533`). + * Missing/empty `` at the `` level. +* **Values**: + * **Empty Device ``**: Locally, the device `` is empty in the response even when available in the datastore or upstream. + * **Empty ``**: Local responses have empty `` in presets and recents, whereas upstream has `tracklisturl` or `stationurl`. + * `preferredLanguage` mismatch (`en` vs `de`). + +**Next Implementation Steps (Proposals):** +1. **Fix Device `` Population**: Investigate why `CreateAccountDevice` or `AccountFullToXML` is not correctly returning the device name even if it's synchronized. +2. **Refine Source Association in Recents**: Improve the matching logic in `mapRecentsToFullResponse` to correctly link recents to their specific `ConfiguredSource` (e.g., by matching `sourceid` attribute). +3. **Populate `contentItemType`**: Update the internal models and `SyncFromAccountFull` to correctly extract, persist, and echo back `contentItemType` (e.g., `tracklisturl`). +4. **Handle Account Metadata**: Synchronize `preferredLanguage` from the upstream `/full` response to the local account state. diff --git a/pkg/models/models.go b/pkg/models/models.go index aaf1b76..a785bd0 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -433,7 +433,7 @@ type AttachedProduct struct { ProductCode string `xml:"product_code,attr"` Components []ServiceComponent `xml:"components>component"` ProductLabel string `xml:"productlabel"` - SerialNumber string `xml:"serialNumber"` + SerialNumber string `xml:"serialnumber"` UpdatedOn string `xml:"updatedOn"` } diff --git a/pkg/service/datastore/datastore.go b/pkg/service/datastore/datastore.go index 53271d2..c95e079 100644 --- a/pkg/service/datastore/datastore.go +++ b/pkg/service/datastore/datastore.go @@ -29,6 +29,7 @@ type DataStore struct { deviceEvents map[string][]models.DeviceEvent idMutex sync.RWMutex deviceMappings map[string]string + fileMutex sync.RWMutex } // normalizeMAC normalizes a MAC address to a consistent format @@ -107,9 +108,17 @@ func (ds *DataStore) AccountDeviceDir(account, device string) string { // 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) + ds.fileMutex.RLock() + defer ds.fileMutex.RUnlock() - data, err := os.ReadFile(path) + return ds.getDeviceInfoNoLock(account, device) +} + +func (ds *DataStore) getDeviceInfoNoLock(account, device string) (*models.ServiceDeviceInfo, error) { + path := ds.AccountDeviceDir(account, device) + deviceInfoPath := filepath.Join(path, constants.DeviceInfoFile) + + data, err := os.ReadFile(deviceInfoPath) if err != nil { return nil, err } @@ -196,9 +205,21 @@ func (ds *DataStore) ListAllDevices() ([]models.ServiceDeviceInfo, error) { key = info.IPAddress } - if !seenIDs[key] { - devices = append(devices, info) - seenIDs[key] = true + if !seenIDs[key] || info.Name != "" { + if seenIDs[key] && info.Name != "" { + // Replace previous empty-named entry with one that has a name + for j := range devices { + existing := &devices[j] + if (existing.DeviceID != "" && existing.DeviceID == info.DeviceID) || + (existing.IPAddress != "" && existing.IPAddress == info.IPAddress) { + devices[j] = info + break + } + } + } else if !seenIDs[key] { + devices = append(devices, info) + seenIDs[key] = true + } } } } @@ -209,17 +230,72 @@ func (ds *DataStore) ListAllDevices() ([]models.ServiceDeviceInfo, error) { func (ds *DataStore) getPossibleDataDirs() []string { dirs := []string{} - if exists(filepath.Join(ds.DataDir, "accounts")) { - dirs = append(dirs, filepath.Join(ds.DataDir, "accounts")) + // Check primary data directory + if ds.DataDir != "" { + if exists(filepath.Join(ds.DataDir, "accounts")) { + dirs = append(dirs, filepath.Join(ds.DataDir, "accounts")) + } + // Also check the DataDir itself as a base for account directories + if exists(ds.DataDir) && ds.DataDir != "." { + dirs = append(dirs, ds.DataDir) + } } // Also check st-go/data/accounts if it's different and exists altDir := "st-go/data/accounts" - if filepath.Join(ds.DataDir, "accounts") != altDir && exists(altDir) { + if exists(altDir) { dirs = append(dirs, altDir) } + // And st-go/data/accounts/default + altDir2 := "st-go/data" + if exists(altDir2) { + dirs = append(dirs, altDir2) + } + // And repro_data + altDir3 := "repro_data" + if exists(altDir3) { + dirs = append(dirs, altDir3) + } - return dirs + // Add special handling for test environments where we might have account directories + // directly in the current working directory or a temp dir. + // Walk up from DataDir to find any 'accounts' directory. + curr := ds.DataDir + for i := 0; i < 3; i++ { + absCurr, _ := filepath.Abs(curr) + if exists(filepath.Join(absCurr, "accounts")) { + dirs = append(dirs, filepath.Join(absCurr, "accounts")) + } + + if exists(absCurr) { + dirs = append(dirs, absCurr) + } + + if curr == "." || curr == "/" || curr == "" { + break + } + + curr = filepath.Dir(curr) + } + + // Remove duplicates and ensure unique directories + uniqueDirs := make(map[string]bool) + result := []string{} + + for _, dir := range dirs { + absDir, err := filepath.Abs(dir) + if err != nil { + absDir = dir + } + + if !uniqueDirs[absDir] { + uniqueDirs[absDir] = true + + result = append(result, dir) + } + } + + return result } func (ds *DataStore) listDevicesInAccount(baseDir, accountName string) []models.ServiceDeviceInfo { @@ -323,6 +399,9 @@ func (ds *DataStore) parseDeviceInfoFile(path string) (*models.ServiceDeviceInfo // GetPresets retrieves all presets for the specified account and device. func (ds *DataStore) GetPresets(account, device string) ([]models.ServicePreset, error) { + ds.fileMutex.RLock() + defer ds.fileMutex.RUnlock() + path := filepath.Join(ds.AccountDeviceDir(account, device), constants.PresetsFile) data, err := os.ReadFile(path) @@ -336,13 +415,14 @@ func (ds *DataStore) GetPresets(account, device string) ([]models.ServicePreset, CreatedOn string `xml:"createdOn,attr"` UpdatedOn string `xml:"updatedOn,attr"` ContentItem 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"` + 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"` + ContentItemType string `xml:"contentItemType"` + ContainerArt string `xml:"containerArt"` } `xml:"ContentItem"` } `xml:"preset"` } @@ -355,15 +435,22 @@ func (ds *DataStore) GetPresets(account, device string) ([]models.ServicePreset, for i := range presetsWrap.Presets { p := &presetsWrap.Presets[i] + + cit := p.ContentItem.ContentItemType + if cit == "" { + cit = p.ContentItem.Type + } + presets = append(presets, models.ServicePreset{ ServiceContentItem: models.ServiceContentItem{ - ID: p.ID, - Name: p.ContentItem.ItemName, - Source: p.ContentItem.Source, - Type: p.ContentItem.Type, - Location: p.ContentItem.Location, - SourceAccount: p.ContentItem.SourceAccount, - IsPresetable: p.ContentItem.IsPresetable, + ID: p.ID, + Name: p.ContentItem.ItemName, + Source: p.ContentItem.Source, + Type: p.ContentItem.Type, + Location: p.ContentItem.Location, + SourceAccount: p.ContentItem.SourceAccount, + IsPresetable: p.ContentItem.IsPresetable, + ContentItemType: cit, }, ContainerArt: p.ContentItem.ContainerArt, CreatedOn: p.CreatedOn, @@ -376,6 +463,9 @@ func (ds *DataStore) GetPresets(account, device string) ([]models.ServicePreset, // SavePresets saves the preset list for the specified account and device. func (ds *DataStore) SavePresets(account, device string, presets []models.ServicePreset) error { + ds.fileMutex.Lock() + defer ds.fileMutex.Unlock() + path := filepath.Join(ds.AccountDeviceDir(account, device), constants.PresetsFile) type PresetXML struct { @@ -383,13 +473,14 @@ func (ds *DataStore) SavePresets(account, device string, presets []models.Servic CreatedOn string `xml:"createdOn,attr"` UpdatedOn string `xml:"updatedOn,attr"` ContentItem struct { - Source string `xml:"source,attr,omitempty"` - Type string `xml:"type,attr"` - Location string `xml:"location,attr"` - SourceAccount string `xml:"sourceAccount,attr,omitempty"` - IsPresetable string `xml:"isPresetable,attr"` - ItemName string `xml:"itemName"` - ContainerArt string `xml:"containerArt"` + Source string `xml:"source,attr,omitempty"` + Type string `xml:"type,attr"` + Location string `xml:"location,attr"` + SourceAccount string `xml:"sourceAccount,attr,omitempty"` + IsPresetable string `xml:"isPresetable,attr"` + ItemName string `xml:"itemName"` + ContentItemType string `xml:"contentItemType"` + ContainerArt string `xml:"containerArt"` } `xml:"ContentItem"` } @@ -414,6 +505,7 @@ func (ds *DataStore) SavePresets(account, device string, presets []models.Servic pxml.ContentItem.SourceAccount = p.SourceAccount pxml.ContentItem.IsPresetable = "true" pxml.ContentItem.ItemName = p.Name + pxml.ContentItem.ContentItemType = p.ContentItemType pxml.ContentItem.ContainerArt = p.ContainerArt px.Presets = append(px.Presets, pxml) } @@ -430,6 +522,9 @@ func (ds *DataStore) SavePresets(account, device string, presets []models.Servic // GetRecents retrieves all recent items for the specified account and device. func (ds *DataStore) GetRecents(account, device string) ([]models.ServiceRecent, error) { + ds.fileMutex.RLock() + defer ds.fileMutex.RUnlock() + path := filepath.Join(ds.AccountDeviceDir(account, device), constants.RecentsFile) data, err := os.ReadFile(path) @@ -460,8 +555,12 @@ func (ds *DataStore) GetRecents(account, device string) ([]models.ServiceRecent, } } - // Ensure all recents have unique numeric IDs for i := range recents { + r := &recents[i] + if r.ContentItemType == "" { + r.ContentItemType = r.Type + } + if _, err := strconv.Atoi(recents[i].ID); err != nil || recents[i].ID == "" { maxID++ recents[i].ID = strconv.Itoa(maxID) @@ -473,6 +572,9 @@ func (ds *DataStore) GetRecents(account, device string) ([]models.ServiceRecent, // SaveRecents saves the recent items list for the specified account and device. func (ds *DataStore) SaveRecents(account, device string, recents []models.ServiceRecent) error { + ds.fileMutex.Lock() + defer ds.fileMutex.Unlock() + path := filepath.Join(ds.AccountDeviceDir(account, device), constants.RecentsFile) type RecentsXML struct { @@ -496,10 +598,49 @@ func (ds *DataStore) SaveRecents(account, device string, recents []models.Servic // SaveDeviceInfo saves device information for the specified account and device. func (ds *DataStore) SaveDeviceInfo(account, device string, info *models.ServiceDeviceInfo) error { + ds.fileMutex.Lock() + defer ds.fileMutex.Unlock() + if device == "" { return fmt.Errorf("device ID/name cannot be empty") } + // Try to load existing device info to avoid overwriting existing details with empty values. + existing, _ := ds.getDeviceInfoNoLock(account, device) + if existing != nil { + if info.Name == "" { + info.Name = existing.Name + } + + if info.ProductCode == "" { + info.ProductCode = existing.ProductCode + } + + if info.DeviceSerialNumber == "" { + info.DeviceSerialNumber = existing.DeviceSerialNumber + } + + if info.ProductSerialNumber == "" { + info.ProductSerialNumber = existing.ProductSerialNumber + } + + if info.FirmwareVersion == "" { + info.FirmwareVersion = existing.FirmwareVersion + } + + if info.IPAddress == "" { + info.IPAddress = existing.IPAddress + } + + if info.MacAddress == "" { + info.MacAddress = existing.MacAddress + } + + if info.DiscoveryMethod == "" { + info.DiscoveryMethod = existing.DiscoveryMethod + } + } + dir := ds.AccountDeviceDir(account, device) if err := os.MkdirAll(dir, 0755); err != nil { return err @@ -582,7 +723,11 @@ func (ds *DataStore) SaveDeviceInfo(account, device string, info *models.Service // RemoveDevice removes a device and all its data from the specified account. func (ds *DataStore) RemoveDevice(account, device string) error { + ds.fileMutex.Lock() + defer ds.fileMutex.Unlock() + dir := ds.AccountDeviceDir(account, device) + return os.RemoveAll(dir) } @@ -593,6 +738,9 @@ func (ds *DataStore) RemoveDeviceDir(account, device string) error { // GetConfiguredSources retrieves all configured sources for the specified account and device. func (ds *DataStore) GetConfiguredSources(account, device string) ([]models.ConfiguredSource, error) { + ds.fileMutex.RLock() + defer ds.fileMutex.RUnlock() + path := filepath.Join(ds.AccountDeviceDir(account, device), constants.SourcesFile) data, err := os.ReadFile(path) @@ -636,6 +784,9 @@ func (ds *DataStore) GetConfiguredSources(account, device string) ([]models.Conf // SaveConfiguredSources saves the configured sources list for the specified account and device. func (ds *DataStore) SaveConfiguredSources(account, device string, sources []models.ConfiguredSource) error { + ds.fileMutex.Lock() + defer ds.fileMutex.Unlock() + path := filepath.Join(ds.AccountDeviceDir(account, device), constants.SourcesFile) if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { return err diff --git a/pkg/service/datastore/device_info_test.go b/pkg/service/datastore/device_info_test.go new file mode 100644 index 0000000..2cf4283 --- /dev/null +++ b/pkg/service/datastore/device_info_test.go @@ -0,0 +1,64 @@ +package datastore + +import ( + "os" + "testing" + + "github.com/gesellix/bose-soundtouch/pkg/models" +) + +func TestSaveDeviceInfo_MergesName(t *testing.T) { + tempDir, err := os.MkdirTemp("", "datastore-test") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + ds := NewDataStore(tempDir) + account := "3230304" + device := "A81B6A536A98" + + // 1. Initial save with name + info1 := &models.ServiceDeviceInfo{ + DeviceID: device, + AccountID: account, + Name: "Living Room", + ProductCode: "SoundTouch 20", + } + if err := ds.SaveDeviceInfo(account, device, info1); err != nil { + t.Fatalf("First SaveDeviceInfo failed: %v", err) + } + + // 2. Verify name is saved + saved1, err := ds.GetDeviceInfo(account, device) + if err != nil { + t.Fatalf("First GetDeviceInfo failed: %v", err) + } + if saved1.Name != "Living Room" { + t.Errorf("Expected name 'Living Room', got '%s'", saved1.Name) + } + + // 3. Save with empty name (simulating power_on) + info2 := &models.ServiceDeviceInfo{ + DeviceID: device, + AccountID: account, + Name: "", + ProductCode: "SoundTouch 20", + IPAddress: "192.168.1.100", + } + if err := ds.SaveDeviceInfo(account, device, info2); err != nil { + t.Fatalf("Second SaveDeviceInfo failed: %v", err) + } + + // 4. Verify name is preserved + saved2, err := ds.GetDeviceInfo(account, device) + if err != nil { + t.Fatalf("Second GetDeviceInfo failed: %v", err) + } + if saved2.Name != "Living Room" { + t.Errorf("Expected name 'Living Room' to be preserved, but got '%s'", saved2.Name) + } + if saved2.IPAddress != "192.168.1.100" { + t.Errorf("Expected IPAddress '192.168.1.100', got '%s'", saved2.IPAddress) + } +} diff --git a/pkg/service/handlers/comprehensive_migration_test.go b/pkg/service/handlers/comprehensive_migration_test.go deleted file mode 100644 index e02a953..0000000 --- a/pkg/service/handlers/comprehensive_migration_test.go +++ /dev/null @@ -1,425 +0,0 @@ -package handlers - -import ( - "fmt" - "net/http" - "net/http/httptest" - "os" - "testing" - - "github.com/gesellix/bose-soundtouch/pkg/models" - "github.com/gesellix/bose-soundtouch/pkg/service/datastore" - "github.com/gesellix/bose-soundtouch/pkg/service/setup" -) - -func TestComprehensiveMigration_MultipleExistingDevices(t *testing.T) { - // This test simulates the real-world scenario where a device has been discovered - // and saved under multiple identifiers over time, and now needs to be consolidated - // into a single MAC-based identifier. - - tempDir, err := os.MkdirTemp("", "comprehensive-migration-test-*") - if err != nil { - t.Fatal(err) - } - defer os.RemoveAll(tempDir) - - ds := datastore.NewDataStore(tempDir) - accountID := "3230304" - - // Scenario: Same device has been saved under different identifiers: - // 1. Initially discovered by IP address - // 2. Later discovered with UPnP serial - // 3. Later discovered with device component serial - - // Create device entry #1: Saved by IP address (early discovery) - ipDeviceID := "192.168.1.100" - ipInfo := &models.ServiceDeviceInfo{ - DeviceID: ipDeviceID, - AccountID: accountID, - Name: "Unknown Device", // Generic name from early discovery - IPAddress: ipDeviceID, - ProductCode: "Unknown", - FirmwareVersion: "0.0.0", - DiscoveryMethod: "UPnP", - } - if err := ds.SaveDeviceInfo(accountID, ipDeviceID, ipInfo); err != nil { - t.Fatalf("Failed to save IP-based device: %v", err) - } - - // Save some presets for the IP-based device - testPresets := []models.ServicePreset{ - { - ServiceContentItem: models.ServiceContentItem{ - ID: "1", - Source: "SPOTIFY", - Location: "spotify://playlist/test1", - Name: "Test Playlist 1", - }, - CreatedOn: "2024-01-01T00:00:00Z", - UpdatedOn: "2024-01-01T00:00:00Z", - }, - } - if err := ds.SavePresets(accountID, ipDeviceID, testPresets); err != nil { - t.Fatalf("Failed to save presets for IP device: %v", err) - } - - // Create device entry #2: Saved by component serial (later discovery with better info) - serialDeviceID := "I6332527703739342000020" - serialInfo := &models.ServiceDeviceInfo{ - DeviceID: serialDeviceID, - AccountID: accountID, - Name: "Sound Machinechen", // Real name from /info - IPAddress: "192.168.1.100", // Same IP as before - DeviceSerialNumber: serialDeviceID, - ProductCode: "SoundTouch 10", - FirmwareVersion: "27.0.6.46330.5043500", - ProductSerialNumber: "069231P63364828AE", - DiscoveryMethod: "UPnP", - } - if err := ds.SaveDeviceInfo(accountID, serialDeviceID, serialInfo); err != nil { - t.Fatalf("Failed to save serial-based device: %v", err) - } - - // Save different presets for the serial-based device (user might have configured both thinking they're different) - serialPresets := []models.ServicePreset{ - { - ServiceContentItem: models.ServiceContentItem{ - ID: "2", - Source: "SPOTIFY", - Location: "spotify://playlist/test2", - Name: "Test Playlist 2", - }, - CreatedOn: "2024-01-02T00:00:00Z", - UpdatedOn: "2024-01-02T00:00:00Z", - }, - } - if err := ds.SavePresets(accountID, serialDeviceID, serialPresets); err != nil { - t.Fatalf("Failed to save presets for serial device: %v", err) - } - - // Create device entry #3: Saved by UPnP serial (yet another discovery) - upnpDeviceID := "UPnP789XYZ" - upnpInfo := &models.ServiceDeviceInfo{ - DeviceID: upnpDeviceID, - AccountID: accountID, - Name: "SoundTouch Device", // Generic UPnP name - IPAddress: "192.168.1.100", // Same IP again - ProductCode: "SoundTouch 10 sm2", - FirmwareVersion: "27.0.6.46330.5043500", // Same firmware as serial device - DiscoveryMethod: "UPnP", - } - if err := ds.SaveDeviceInfo(accountID, upnpDeviceID, upnpInfo); err != nil { - t.Fatalf("Failed to save UPnP-based device: %v", err) - } - - t.Logf("Test setup complete:") - t.Logf(" Device #1: %s (IP-based, early discovery)", ipDeviceID) - t.Logf(" Device #2: %s (serial-based, better info)", serialDeviceID) - t.Logf(" Device #3: %s (UPnP-based, latest discovery)", upnpDeviceID) - - // Now simulate the device being rediscovered with /info endpoint working - deviceInfoXML := ` -Sound Machinechen -SoundTouch 10 -3230304 - - -SCM -27.0.6.46330.5043500 epdbuild.trunk.hepdswbld04.2022-08-04T11:20:29 -I6332527703739342000020 - - -PackagedProduct -27.0.6.46330.5043500 -069231P63364828AE - - -https://streaming.bose.com - -A81B6A536A98 -192.168.1.100 - -sm2 -` - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/info" { - w.Header().Set("Content-Type", "application/xml") - fmt.Fprint(w, deviceInfoXML) - } else { - http.NotFound(w, r) - } - })) - defer server.Close() - - deviceIP := server.URL[len("http://"):] - sm := setup.NewManager(server.URL, ds, nil) - srv := NewServer(ds, sm, server.URL, false, false, false, false, false) - - // Simulate device rediscovery - discoveredDevice := models.DiscoveredDevice{ - Host: deviceIP, - Name: "Generic Discovery Name", - ModelID: "SoundTouch", - SerialNo: "UPnP789XYZ", // This should match one of the existing devices - DiscoveryMethod: "UPnP", - } - - t.Logf("\nSimulating comprehensive device rediscovery...") - t.Logf(" Discovery IP: %s", deviceIP) - t.Logf(" Discovery Serial: %s", discoveredDevice.SerialNo) - - // Handle discovered device - should find and migrate all existing variants - srv.handleDiscoveredDevice(discoveredDevice) - - // Verify the device now exists under the MAC address - expectedDeviceID := "A81B6A536A98" - migratedInfo, err := ds.GetDeviceInfo(accountID, expectedDeviceID) - if err != nil { - t.Fatalf("Failed to get migrated device info: %v", err) - } - - // Verify the migrated device has the correct information - if migratedInfo.DeviceID != expectedDeviceID { - t.Errorf("Expected deviceID '%s', got '%s'", expectedDeviceID, migratedInfo.DeviceID) - } - - if migratedInfo.Name != "Sound Machinechen" { - t.Errorf("Expected name 'Sound Machinechen' (from /info), got '%s'", migratedInfo.Name) - } - - if migratedInfo.MacAddress != "A81B6A536A98" { - t.Errorf("Expected MAC 'A81B6A536A98', got '%s'", migratedInfo.MacAddress) - } - - if migratedInfo.DeviceSerialNumber != "I6332527703739342000020" { - t.Errorf("Expected device serial 'I6332527703739342000020', got '%s'", migratedInfo.DeviceSerialNumber) - } - - t.Logf("\nMigration completed successfully:") - t.Logf(" New device ID: %s (MAC address)", migratedInfo.DeviceID) - t.Logf(" Device name: %s", migratedInfo.Name) - t.Logf(" Device serial: %s", migratedInfo.DeviceSerialNumber) - t.Logf(" Product serial: %s", migratedInfo.ProductSerialNumber) - t.Logf(" MAC address: %s", migratedInfo.MacAddress) - - // Verify MAC address resolution works - if err := ds.Initialize(); err != nil { - t.Fatalf("Failed to initialize datastore: %v", err) - } - - resolvedDir := ds.AccountDeviceDir(accountID, "A81B6A536A98") - expectedDir := ds.AccountDeviceDir(accountID, expectedDeviceID) - - if resolvedDir != expectedDir { - t.Errorf("MAC resolution failed. Expected '%s', got '%s'", expectedDir, resolvedDir) - } - - t.Logf("\nMAC address resolution verified:") - t.Logf(" MAC 'A81B6A536A98' resolves to correct device directory") - - // Note: In a complete implementation, we'd also verify that presets from all - // the old devices were consolidated, but that requires more sophisticated - // preset merging logic which is beyond the current migration scope. - - t.Logf("\n✅ Comprehensive migration test completed successfully!") -} - -func TestFindAllExistingDeviceVariants_MatchingCriteria(t *testing.T) { - tempDir, err := os.MkdirTemp("", "variants-test-*") - if err != nil { - t.Fatal(err) - } - defer os.RemoveAll(tempDir) - - ds := datastore.NewDataStore(tempDir) - srv := NewServer(ds, nil, "http://localhost", false, false, false, false, false) - accountID := "testaccount" - - // Create devices that should match various criteria - devices := []models.ServiceDeviceInfo{ - { - DeviceID: "192.168.1.100", - AccountID: accountID, - Name: "IP Device", - IPAddress: "192.168.1.100", - DeviceSerialNumber: "SERIAL123", - MacAddress: "AA:BB:CC:DD:EE:FF", - }, - { - DeviceID: "SERIAL123", - AccountID: accountID, - Name: "Sound Speaker", - IPAddress: "192.168.1.101", // Different IP - DeviceSerialNumber: "SERIAL123", - MacAddress: "AA:BB:CC:DD:EE:FF", - }, - { - DeviceID: "UPnPSerial456", - AccountID: accountID, - Name: "Sound Speaker", - IPAddress: "192.168.1.102", // Different IP again - ProductCode: "SoundTouch 10 sm2", - }, - { - DeviceID: "UnrelatedDevice", - AccountID: accountID, - Name: "Other Device", - IPAddress: "192.168.1.200", - DeviceSerialNumber: "OTHERSSERIAL", - }, - } - - for _, device := range devices { - if err := ds.SaveDeviceInfo(accountID, device.DeviceID, &device); err != nil { - t.Fatalf("Failed to save device %s: %v", device.DeviceID, err) - } - } - - // Create mock discovery and live info - discovery := models.DiscoveredDevice{ - Host: "192.168.1.100", // Matches first device by IP - SerialNo: "UPnPSerial456", // Matches third device by UPnP serial - } - - liveInfo := &setup.DeviceInfoXML{ - DeviceID: "AABBCCDDEEFF", // New MAC-based ID - Name: "Sound Speaker", // Matches second and third devices by name - Type: "SoundTouch 10", - ModuleType: "sm2", - SerialNumber: "SERIAL123", // Matches first and second devices by serial - NetworkInfo: []struct { - Type string `xml:"type,attr"` - MacAddress string `xml:"macAddress"` - IPAddress string `xml:"ipAddress"` - }{ - {Type: "SCM", MacAddress: "AA:BB:CC:DD:EE:FF", IPAddress: "192.168.1.100"}, - }, - } - - // Test the matching logic - matches := srv.findAllExistingDeviceVariants(discovery, liveInfo) - - t.Logf("Found %d matching device variants:", len(matches)) - for i, match := range matches { - t.Logf(" %d. %s (IP: %s, Serial: %s, MAC: %s, Name: %s)", - i+1, match.DeviceID, match.IPAddress, match.DeviceSerialNumber, match.MacAddress, match.Name) - } - - // Verify expected matches - expectedMatches := map[string]string{ - "192.168.1.100": "IP match", - "SERIAL123": "Serial match", - "UPnPSerial456": "UPnP serial match", - } - - if len(matches) != len(expectedMatches) { - t.Errorf("Expected %d matches, got %d", len(expectedMatches), len(matches)) - } - - foundMatches := make(map[string]bool) - for _, match := range matches { - foundMatches[match.DeviceID] = true - } - - for expectedID, reason := range expectedMatches { - if !foundMatches[expectedID] { - t.Errorf("Expected to find device %s (%s), but it was not matched", expectedID, reason) - } - } - - // Verify UnrelatedDevice is NOT matched - if foundMatches["UnrelatedDevice"] { - t.Error("UnrelatedDevice should not have been matched, but it was") - } - - t.Logf("✅ Device variant matching test completed successfully!") -} - -func TestMigration_EdgeCases(t *testing.T) { - tempDir, err := os.MkdirTemp("", "migration-edge-cases-*") - if err != nil { - t.Fatal(err) - } - defer os.RemoveAll(tempDir) - - ds := datastore.NewDataStore(tempDir) - srv := NewServer(ds, nil, "http://localhost", false, false, false, false, false) - accountID := "testaccount" - - t.Run("NoExistingDevices", func(t *testing.T) { - discovery := models.DiscoveredDevice{Host: "192.168.1.200"} - liveInfo := &setup.DeviceInfoXML{DeviceID: "NEWMAC123"} - - matches := srv.findAllExistingDeviceVariants(discovery, liveInfo) - if len(matches) != 0 { - t.Errorf("Expected 0 matches for new device, got %d", len(matches)) - } - }) - - t.Run("SelfMatch", func(t *testing.T) { - // Device already exists with MAC as deviceID - macDeviceID := "AABBCCDDEEFF" - existing := &models.ServiceDeviceInfo{ - DeviceID: macDeviceID, - AccountID: accountID, - Name: "Existing MAC Device", - IPAddress: "192.168.1.150", - } - if err := ds.SaveDeviceInfo(accountID, macDeviceID, existing); err != nil { - t.Fatalf("Failed to save MAC device: %v", err) - } - - discovery := models.DiscoveredDevice{Host: "192.168.1.150"} - liveInfo := &setup.DeviceInfoXML{DeviceID: macDeviceID} // Same MAC - - matches := srv.findAllExistingDeviceVariants(discovery, liveInfo) - - // Should find itself, but migration logic should skip it since deviceID matches - found := false - for _, match := range matches { - if match.DeviceID == macDeviceID { - found = true - break - } - } - if !found { - t.Error("Device should find itself in variants") - } - }) - - t.Run("PartialMatches", func(t *testing.T) { - // Device with some matching criteria but not others - partialDevice := &models.ServiceDeviceInfo{ - DeviceID: "PARTIAL123", - AccountID: accountID, - Name: "Partial Device", - IPAddress: "192.168.1.160", // Different IP - // No serial number, no MAC - } - if err := ds.SaveDeviceInfo(accountID, "PARTIAL123", partialDevice); err != nil { - t.Fatalf("Failed to save partial device: %v", err) - } - - discovery := models.DiscoveredDevice{Host: "192.168.1.170"} // Different IP - liveInfo := &setup.DeviceInfoXML{ - DeviceID: "NEWMAC456", - Name: "Partial Device", // Same name - Type: "SoundTouch 20", - } - - matches := srv.findAllExistingDeviceVariants(discovery, liveInfo) - - // Should match by name and product type - found := false - for _, match := range matches { - if match.DeviceID == "PARTIAL123" { - found = true - break - } - } - if !found { - t.Error("Should match device by name and product type") - } - }) -} diff --git a/pkg/service/handlers/consolidation_test.go b/pkg/service/handlers/consolidation_test.go deleted file mode 100644 index dd1446b..0000000 --- a/pkg/service/handlers/consolidation_test.go +++ /dev/null @@ -1,254 +0,0 @@ -package handlers - -import ( - "os" - "testing" - - "github.com/gesellix/bose-soundtouch/pkg/models" - "github.com/gesellix/bose-soundtouch/pkg/service/datastore" -) - -func TestDeviceMigration_DirectoryRename(t *testing.T) { - tempDir, err := os.MkdirTemp("", "migration-test-*") - if err != nil { - t.Fatal(err) - } - defer os.RemoveAll(tempDir) - - ds := datastore.NewDataStore(tempDir) - srv := NewServer(ds, nil, "http://localhost", false, false, false, true, false) - - accountID := "test-account" - macAddress := "A81B6A536A98" - serialNumber := "I6332527703739342000020" - - // Create serial-based device entry with full data (simulates legacy directory) - serialDeviceInfo := &models.ServiceDeviceInfo{ - DeviceID: serialNumber, - AccountID: accountID, - Name: "Living Room Speaker", - MacAddress: macAddress, - DeviceSerialNumber: serialNumber, - ProductCode: "SoundTouch 30", - } - - if err := ds.SaveDeviceInfo(accountID, serialNumber, serialDeviceInfo); err != nil { - t.Fatalf("Failed to save serial-based device: %v", err) - } - - // Create some preset data in the serial-based directory - serialPresets := []models.ServicePreset{ - { - ServiceContentItem: models.ServiceContentItem{ - ID: "1", - Name: "My Preset", - Source: "SPOTIFY", - }, - }, - } - if err := ds.SavePresets(accountID, serialNumber, serialPresets); err != nil { - t.Fatalf("Failed to save presets: %v", err) - } - - // Verify initial state - serial directory exists - serialDir := ds.AccountDeviceDir(accountID, serialNumber) - if _, err := os.Stat(serialDir); os.IsNotExist(err) { - t.Fatalf("Serial directory should exist before migration: %s", serialDir) - } - - // Perform migration using migration manager - existingDevices := []models.ServiceDeviceInfo{*serialDeviceInfo} - srv.migrationManager.MigrateDevicesIfNeeded(existingDevices, macAddress) - - // Verify migration results - t.Run("VerifyMigration", func(t *testing.T) { - // 1. MAC directory should exist with files - macDir := ds.AccountDeviceDir(accountID, macAddress) - serialDir := ds.AccountDeviceDir(accountID, serialNumber) - - if _, err := os.Stat(macDir); os.IsNotExist(err) { - t.Errorf("MAC directory should exist after migration: %s", macDir) - } - - // 2. Serial directory should be gone - if _, err := os.Stat(serialDir); !os.IsNotExist(err) { - t.Errorf("Serial directory should not exist after migration: %s", serialDir) - } - - // 3. Simulate SaveDeviceInfo with fresh data (like real discovery flow) - // This overwrites DeviceInfo.xml with correct MAC-based deviceID - freshDeviceInfo := &models.ServiceDeviceInfo{ - DeviceID: macAddress, - AccountID: accountID, - Name: "Sound Speaker Fresh", - IPAddress: "192.168.1.100", - MacAddress: macAddress, - DeviceSerialNumber: serialNumber, - ProductCode: "SoundTouch 10 sm2", - FirmwareVersion: "3.4.6.2356", - ProductSerialNumber: "069231P63364828AE", - DiscoveryMethod: "Migration Test", - } - if err := ds.SaveDeviceInfo(accountID, macAddress, freshDeviceInfo); err != nil { - t.Errorf("Failed to save fresh device info: %v", err) - } - - // 4. Device info should now have correct MAC-based deviceID - macInfo, err := ds.GetDeviceInfo(accountID, macAddress) - if err != nil { - t.Errorf("Should be able to get device info with MAC ID: %v", err) - } else if macInfo.DeviceID != macAddress { - t.Errorf("DeviceID should be updated to MAC address, got %s", macInfo.DeviceID) - } - - // 5. All data should be accessible through MAC address - presets, err := ds.GetPresets(accountID, macAddress) - if err != nil { - t.Errorf("Should be able to get presets through MAC address: %v", err) - } else if len(presets) != 1 || presets[0].Name != "My Preset" { - t.Errorf("Presets should be preserved during migration") - } - - t.Logf("✓ Device directory migration working correctly") - }) -} - -func TestDeviceMigration_NoExistingTarget(t *testing.T) { - tempDir, err := os.MkdirTemp("", "no-target-test-*") - if err != nil { - t.Fatal(err) - } - defer os.RemoveAll(tempDir) - - ds := datastore.NewDataStore(tempDir) - srv := NewServer(ds, nil, "http://localhost", false, false, false, true, false) - - accountID := "test-account" - macAddress := "A81B6A536A98" - serialNumber := "I6332527703739342000020" - - // Create only serial-based device entry (no existing MAC directory) - serialDeviceInfo := &models.ServiceDeviceInfo{ - DeviceID: serialNumber, - AccountID: accountID, - Name: "Test Speaker", - MacAddress: macAddress, - DeviceSerialNumber: serialNumber, - ProductCode: "SoundTouch 30", - } - - if err := ds.SaveDeviceInfo(accountID, serialNumber, serialDeviceInfo); err != nil { - t.Fatalf("Failed to save serial device: %v", err) - } - - // Add some data files - presets := []models.ServicePreset{ - { - ServiceContentItem: models.ServiceContentItem{ - ID: "1", - Name: "Test Preset", - Source: "SPOTIFY", - }, - }, - } - if err := ds.SavePresets(accountID, serialNumber, presets); err != nil { - t.Fatalf("Failed to save presets: %v", err) - } - - // Verify MAC directory doesn't exist initially - macDir := ds.AccountDeviceDir(accountID, macAddress) - if _, err := os.Stat(macDir); !os.IsNotExist(err) { - t.Fatalf("MAC directory should not exist initially: %s", macDir) - } - - // Migrate directory using migration manager - existingDevices := []models.ServiceDeviceInfo{*serialDeviceInfo} - srv.migrationManager.MigrateDevicesIfNeeded(existingDevices, macAddress) - - // Verify migration - if _, err := os.Stat(macDir); os.IsNotExist(err) { - t.Errorf("MAC directory should exist after migration: %s", macDir) - } - - // Verify data is accessible - migratedPresets, err := ds.GetPresets(accountID, macAddress) - if err != nil { - t.Errorf("Should be able to access presets after migration: %v", err) - } else if len(migratedPresets) != 1 || migratedPresets[0].Name != "Test Preset" { - t.Errorf("Presets should be preserved in migration") - } - - t.Log("✓ Simple directory migration working correctly") -} - -func TestDeviceMigration_ExistingTargetRemoved(t *testing.T) { - tempDir, err := os.MkdirTemp("", "existing-target-test-*") - if err != nil { - t.Fatal(err) - } - defer os.RemoveAll(tempDir) - - ds := datastore.NewDataStore(tempDir) - srv := NewServer(ds, nil, "http://localhost", false, false, false, true, false) - - accountID := "test-account" - macAddress := "A81B6A536A98" - serialNumber := "I6332527703739342000020" - - // Create both directories (serial has rich data, MAC has minimal data) - serialInfo := &models.ServiceDeviceInfo{ - DeviceID: serialNumber, - AccountID: accountID, - Name: "Rich Data Device", - MacAddress: macAddress, - DeviceSerialNumber: serialNumber, - } - macInfo := &models.ServiceDeviceInfo{ - DeviceID: macAddress, - AccountID: accountID, - Name: "Minimal Data Device", - MacAddress: macAddress, - } - - if err := ds.SaveDeviceInfo(accountID, serialNumber, serialInfo); err != nil { - t.Fatalf("Failed to save serial device: %v", err) - } - if err := ds.SaveDeviceInfo(accountID, macAddress, macInfo); err != nil { - t.Fatalf("Failed to save MAC device: %v", err) - } - - // Add rich data to serial directory - richPresets := []models.ServicePreset{ - { - ServiceContentItem: models.ServiceContentItem{ - ID: "1", - Name: "Rich Preset", - Source: "SPOTIFY", - }, - }, - } - if err := ds.SavePresets(accountID, serialNumber, richPresets); err != nil { - t.Fatalf("Failed to save rich presets: %v", err) - } - - // Migrate - should replace MAC directory with serial directory content - existingDevices := []models.ServiceDeviceInfo{*serialInfo} - srv.migrationManager.MigrateDevicesIfNeeded(existingDevices, macAddress) - - // Verify the rich data is now accessible via MAC address - finalInfo, err := ds.GetDeviceInfo(accountID, macAddress) - if err != nil { - t.Errorf("Should be able to get device info after migration: %v", err) - } else if finalInfo.Name != "Rich Data Device" { - t.Errorf("Should have rich device data, got name: %s", finalInfo.Name) - } - - finalPresets, err := ds.GetPresets(accountID, macAddress) - if err != nil { - t.Errorf("Should be able to get rich presets after migration: %v", err) - } else if len(finalPresets) != 1 || finalPresets[0].Name != "Rich Preset" { - t.Errorf("Should have rich presets after migration") - } - - t.Log("✓ Migration correctly replaces existing target with richer source") -} diff --git a/pkg/service/handlers/handlers_events_test.go b/pkg/service/handlers/handlers_events_test.go index 033b1c9..d92a6bc 100644 --- a/pkg/service/handlers/handlers_events_test.go +++ b/pkg/service/handlers/handlers_events_test.go @@ -14,7 +14,7 @@ import ( func TestEventLog(t *testing.T) { ds := datastore.NewDataStore(t.TempDir()) - s := NewServer(ds, nil, "http://localhost", false, false, false, false, false) + s := NewServer(ds, nil, "http://localhost", false, false, false) r := chi.NewRouter() r.Post("/streaming/stats/usage", s.HandleUsageStats) diff --git a/pkg/service/handlers/handlers_health_test.go b/pkg/service/handlers/handlers_health_test.go index 2ba8266..7cbfae0 100644 --- a/pkg/service/handlers/handlers_health_test.go +++ b/pkg/service/handlers/handlers_health_test.go @@ -20,7 +20,7 @@ type healthResp struct { func TestHealthEndpoint(t *testing.T) { r := chi.NewRouter() - srv := NewServer(nil, nil, "http://localhost", false, false, false, false, false) + srv := NewServer(nil, nil, "http://localhost", false, false, false) r.Get("/health", srv.HandleHealth) ts := httptest.NewServer(r) diff --git a/pkg/service/handlers/handlers_mgmt_test.go b/pkg/service/handlers/handlers_mgmt_test.go index 5e05d7b..1b824f1 100644 --- a/pkg/service/handlers/handlers_mgmt_test.go +++ b/pkg/service/handlers/handlers_mgmt_test.go @@ -13,7 +13,7 @@ import ( ) func TestHandleMgmtSpotifyInit(t *testing.T) { - s := NewServer(nil, nil, "http://localhost", false, false, false, false, false) + s := NewServer(nil, nil, "http://localhost", false, false, false) // No spotify service configured req := httptest.NewRequest("POST", "/mgmt/spotify/init", nil) w := httptest.NewRecorder() @@ -45,7 +45,7 @@ func TestHandleMgmtSpotifyInit(t *testing.T) { } func TestHandleMgmtSpotifyAccounts(t *testing.T) { - s := NewServer(nil, nil, "http://localhost", false, false, false, false, false) + s := NewServer(nil, nil, "http://localhost", false, false, false) svc := spotify.NewSpotifyService("cid", "secret", "http://localhost/cb", t.TempDir()) s.SetSpotifyService(svc) @@ -91,7 +91,7 @@ func TestHandleMgmtListSpeakers(t *testing.T) { } func TestHandleMgmtSpotifyCallback(t *testing.T) { - s := NewServer(nil, nil, "http://localhost", false, false, false, false, false) + s := NewServer(nil, nil, "http://localhost", false, false, false) svc := spotify.NewSpotifyService("cid", "secret", "http://localhost/cb", t.TempDir()) s.SetSpotifyService(svc) @@ -150,7 +150,7 @@ func TestHandleMgmtSpotifyCallback(t *testing.T) { } func TestHandleMgmtSpotifyConfirm(t *testing.T) { - s := NewServer(nil, nil, "http://localhost", false, false, false, false, false) + s := NewServer(nil, nil, "http://localhost", false, false, false) svc := spotify.NewSpotifyService("cid", "secret", "http://localhost/cb", t.TempDir()) s.SetSpotifyService(svc) @@ -191,7 +191,7 @@ func TestHandleMgmtDeviceEvents(t *testing.T) { } func TestBasicAuthMgmt(t *testing.T) { - s := NewServer(nil, nil, "http://localhost", false, false, false, false, false) + s := NewServer(nil, nil, "http://localhost", false, false, false) s.SetMgmtConfig("admin", "secret123") handler := s.BasicAuthMgmt()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/pkg/service/handlers/handlers_proxy_test.go b/pkg/service/handlers/handlers_proxy_test.go index 565dffc..cea3035 100644 --- a/pkg/service/handlers/handlers_proxy_test.go +++ b/pkg/service/handlers/handlers_proxy_test.go @@ -33,7 +33,7 @@ func TestHandleProxyRequest_RequestBodyRecording(t *testing.T) { defer backend.Close() ds := datastore.NewDataStore(filepath.Join(tmpDir, "test.db")) - server := NewServer(ds, nil, "http://localhost", false, false, false, false, false) + server := NewServer(ds, nil, "http://localhost", false, false, false) server.recordEnabled = true server.proxyLogBody = true recorder := proxy.NewRecorder(tmpDir) diff --git a/pkg/service/handlers/handlers_stats_test.go b/pkg/service/handlers/handlers_stats_test.go index dc1765c..67d5049 100644 --- a/pkg/service/handlers/handlers_stats_test.go +++ b/pkg/service/handlers/handlers_stats_test.go @@ -20,7 +20,7 @@ func TestStatsHandlers(t *testing.T) { defer func() { _ = os.RemoveAll(tempDir) }() ds := datastore.NewDataStore(tempDir) - s := NewServer(ds, nil, "http://localhost", false, false, false, false, false) + s := NewServer(ds, nil, "http://localhost", false, false, false) t.Run("HandleUsageStats XML", func(t *testing.T) { xmlData := ` diff --git a/pkg/service/handlers/interactions_test.go b/pkg/service/handlers/interactions_test.go index c2feffc..f3a18a9 100644 --- a/pkg/service/handlers/interactions_test.go +++ b/pkg/service/handlers/interactions_test.go @@ -23,7 +23,7 @@ func TestInteractionHandlers(t *testing.T) { defer os.RemoveAll(tmpDir) ds := datastore.NewDataStore(filepath.Join(tmpDir, "test.db")) - server := NewServer(ds, nil, "http://localhost", false, false, false, false, false) + server := NewServer(ds, nil, "http://localhost", false, false, false) t.Run("HandleGetInteractionStats_NoRecorder", func(t *testing.T) { req := httptest.NewRequest("GET", "/setup/interaction-stats", nil) @@ -151,7 +151,7 @@ func TestRecordMiddleware(t *testing.T) { defer os.RemoveAll(tmpDir) ds := datastore.NewDataStore(filepath.Join(tmpDir, "test.db")) - server := NewServer(ds, nil, "http://localhost", false, false, true, false, false) + server := NewServer(ds, nil, "http://localhost", false, false, true) recorder := proxy.NewRecorder(tmpDir) server.SetRecorder(recorder) diff --git a/pkg/service/handlers/mac_discovery_integration_test.go b/pkg/service/handlers/mac_discovery_integration_test.go index 962fb51..314e38f 100644 --- a/pkg/service/handlers/mac_discovery_integration_test.go +++ b/pkg/service/handlers/mac_discovery_integration_test.go @@ -74,7 +74,7 @@ func TestMACBasedDeviceDiscovery_Integration(t *testing.T) { sm := setup.NewManager(server.URL, ds, nil) // Create server instance - srv := NewServer(ds, sm, "http://localhost", false, false, false, false, false) + srv := NewServer(ds, sm, "http://localhost", false, false, false) t.Logf("Test scenario:") t.Logf(" Device IP: %s", deviceIP) @@ -311,7 +311,7 @@ func TestMACBasedDeviceDiscovery_MigrationScenario(t *testing.T) { deviceIP := server.URL[len("http://"):] sm := setup.NewManager(server.URL, ds, nil) - srv := NewServer(ds, sm, server.URL, false, false, false, false, false) + srv := NewServer(ds, sm, server.URL, false, false, false) // 3. Simulate rediscovery of the same device (now with /info working) discoveredDevice := models.DiscoveredDevice{ @@ -380,7 +380,7 @@ func TestMACBasedDeviceDiscovery_FallbackScenario(t *testing.T) { ds := datastore.NewDataStore(tempDir) sm := setup.NewManager(server.URL, ds, nil) - srv := NewServer(ds, sm, server.URL, false, false, false, false, false) + srv := NewServer(ds, sm, server.URL, false, false, false) // Simulate device discovery with UPnP providing serial discoveredDevice := models.DiscoveredDevice{ diff --git a/pkg/service/handlers/mac_mapping_integration_test.go b/pkg/service/handlers/mac_mapping_integration_test.go index c75e5a9..adae6d1 100644 --- a/pkg/service/handlers/mac_mapping_integration_test.go +++ b/pkg/service/handlers/mac_mapping_integration_test.go @@ -97,7 +97,7 @@ func TestMacMappingIntegration_HTTPHandler(t *testing.T) { t.Fatalf("failed to initialize datastore: %v", err) } - server := NewServer(ds, nil, "http://localhost", false, false, false, false, false) + server := NewServer(ds, nil, "http://localhost", false, false, false) // Setup router with the exact same route as in production router := chi.NewRouter() diff --git a/pkg/service/handlers/main_test.go b/pkg/service/handlers/main_test.go index c5ad718..db87912 100644 --- a/pkg/service/handlers/main_test.go +++ b/pkg/service/handlers/main_test.go @@ -6,7 +6,7 @@ import ( ) func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server) { - server := NewServer(ds, nil, targetURL, false, false, false, false, false) + server := NewServer(ds, nil, targetURL, false, false, false) r := chi.NewRouter() r.Use(server.OriginMiddleware) diff --git a/pkg/service/handlers/migration_debug.go b/pkg/service/handlers/migration_debug.go deleted file mode 100644 index 9251a83..0000000 --- a/pkg/service/handlers/migration_debug.go +++ /dev/null @@ -1,490 +0,0 @@ -package handlers - -import ( - "fmt" - "log" - "strings" - - "github.com/gesellix/bose-soundtouch/pkg/models" - "github.com/gesellix/bose-soundtouch/pkg/service/setup" -) - -// DeviceMigrationDiagnostic provides detailed analysis of device migration scenarios -type DeviceMigrationDiagnostic struct { - server *Server -} - -// NewDeviceMigrationDiagnostic creates a new diagnostic instance -func NewDeviceMigrationDiagnostic(server *Server) *DeviceMigrationDiagnostic { - return &DeviceMigrationDiagnostic{server: server} -} - -// DiagnoseDeviceMigration analyzes why a specific device might not be migrating correctly -func (d *DeviceMigrationDiagnostic) DiagnoseDeviceMigration(deviceIP string) error { - log.Printf("=== Device Migration Diagnostic for %s ===", deviceIP) - - // 1. Fetch live device info - liveInfo, err := d.server.sm.GetLiveDeviceInfo(deviceIP) - if err != nil { - log.Printf("❌ Failed to fetch /info from %s: %v", deviceIP, err) - return fmt.Errorf("cannot fetch /info from %s: %w", deviceIP, err) - } - - log.Printf("✅ Successfully fetched /info from %s", deviceIP) - log.Printf(" Device ID (MAC): %s", liveInfo.DeviceID) - log.Printf(" Device Name: %s", liveInfo.Name) - log.Printf(" Product: %s %s", liveInfo.Type, liveInfo.ModuleType) - log.Printf(" Account: %s", liveInfo.MargeAccountUUID) - log.Printf(" Component Serial: %s", liveInfo.SerialNumber) - log.Printf(" Primary MAC: %s", liveInfo.GetPrimaryMacAddress()) - - // 2. List all existing devices - allDevices, err := d.server.ds.ListAllDevices() - if err != nil { - log.Printf("❌ Failed to list devices: %v", err) - return fmt.Errorf("failed to list devices: %w", err) - } - - log.Printf("\n📋 Found %d existing devices in datastore:", len(allDevices)) - - devicesByAccount := make(map[string][]models.ServiceDeviceInfo) - - for i := range allDevices { - device := &allDevices[i] - devicesByAccount[device.AccountID] = append(devicesByAccount[device.AccountID], *device) - } - - for accountID, devices := range devicesByAccount { - log.Printf(" Account %s: %d devices", accountID, len(devices)) - - for i := range devices { - device := &devices[i] - log.Printf(" %d. %s", i+1, device.DeviceID) - log.Printf(" Name: %s", device.Name) - log.Printf(" IP: %s", device.IPAddress) - log.Printf(" Serial: %s", device.DeviceSerialNumber) - log.Printf(" MAC: %s", device.MacAddress) - log.Printf(" Product: %s", device.ProductCode) - log.Printf(" Discovery: %s", device.DiscoveryMethod) - } - } - - // 3. Simulate discovery and check matching - log.Printf("\n🔍 Testing migration candidate matching:") - - // Test different discovery scenarios - testDiscoveries := []models.DiscoveredDevice{ - { - Host: deviceIP, - Name: "Current Discovery", - SerialNo: "", - DiscoveryMethod: "Manual", - }, - { - Host: deviceIP, - Name: "With Live Serial", - SerialNo: liveInfo.SerialNumber, - DiscoveryMethod: "UPnP", - }, - } - - // Add test with different IPs that might match existing devices - seenIPs := make(map[string]bool) - - for i := range allDevices { - device := &allDevices[i] - if device.IPAddress != "" && device.IPAddress != deviceIP && !seenIPs[device.IPAddress] { - seenIPs[device.IPAddress] = true - testDiscoveries = append(testDiscoveries, models.DiscoveredDevice{ - Host: device.IPAddress, - Name: "Previous IP Test", - SerialNo: "", - DiscoveryMethod: "Test", - }) - } - } - - for i := range testDiscoveries { - testDiscovery := &testDiscoveries[i] - log.Printf("\n Test Scenario %d: %s (IP: %s, Serial: %s)", - i+1, testDiscovery.Name, testDiscovery.Host, testDiscovery.SerialNo) - - matches := d.server.findAllExistingDeviceVariants(*testDiscovery, liveInfo) - if len(matches) == 0 { - log.Printf(" ❌ No migration candidates found") - } else { - log.Printf(" ✅ Found %d migration candidate(s):", len(matches)) - - for i := range matches { - match := &matches[i] - if match.DeviceID == liveInfo.DeviceID { - log.Printf(" - %s ⚠️ (already uses target MAC)", match.DeviceID) - } else { - log.Printf(" - %s", match.DeviceID) - } - } - } - } - - // 4. Detailed matching analysis - log.Printf("\n🔬 Detailed Matching Analysis:") - log.Printf(" Looking for devices that should match MAC %s...", liveInfo.DeviceID) - - potentialMatches := d.findPotentialMatches(allDevices, liveInfo) - if len(potentialMatches) == 0 { - log.Printf(" ❌ No potential matches found") - log.Printf("\n💡 Recommendations:") - log.Printf(" - This appears to be a completely new device") - log.Printf(" - Device will be created with MAC-based ID: %s", liveInfo.DeviceID) - log.Printf(" - Account: %s", liveInfo.MargeAccountUUID) - } else { - log.Printf(" ✅ Found %d potential match(es):", len(potentialMatches)) - - for i := range potentialMatches { - d.explainMatch(potentialMatches[i], liveInfo) - } - - log.Printf("\n💡 Migration Recommendations:") - - for i := range potentialMatches { - match := potentialMatches[i] - if match.DeviceID != liveInfo.DeviceID { - log.Printf(" - Migrate %s → %s", match.DeviceID, liveInfo.DeviceID) - log.Printf(" Reason: %s", d.getMatchReason(match, liveInfo)) - } - } - } - - log.Printf("\n=== End Diagnostic ===") - - return nil -} - -// findPotentialMatches finds devices that could potentially be the same device -func (d *DeviceMigrationDiagnostic) findPotentialMatches(allDevices []models.ServiceDeviceInfo, liveInfo *setup.DeviceInfoXML) []models.ServiceDeviceInfo { - var matches []models.ServiceDeviceInfo - - for i := range allDevices { - device := &allDevices[i] - if d.couldBeMatch(*device, liveInfo) { - matches = append(matches, *device) - } - } - - return matches -} - -// couldBeMatch determines if a device could potentially be the same physical device -func (d *DeviceMigrationDiagnostic) couldBeMatch(device models.ServiceDeviceInfo, liveInfo *setup.DeviceInfoXML) bool { - // 1. Serial number match - if liveInfo.SerialNumber != "" && device.DeviceSerialNumber == liveInfo.SerialNumber { - return true - } - - // 2. DeviceID is the serial - if liveInfo.SerialNumber != "" && device.DeviceID == liveInfo.SerialNumber { - return true - } - - // 3. MAC address match - primaryMAC := liveInfo.GetPrimaryMacAddress() - if primaryMAC != "" && device.MacAddress == primaryMAC { - return true - } - - // 4. DeviceID is already the MAC - if device.DeviceID == liveInfo.DeviceID { - return true - } - - // 5. Name and product similarity - if liveInfo.Name != "" && device.Name == liveInfo.Name { - expectedProduct := liveInfo.Type + " " + liveInfo.ModuleType - if device.ProductCode == expectedProduct || - device.ProductCode == liveInfo.Type || - strings.Contains(device.ProductCode, liveInfo.Type) || - strings.Contains(expectedProduct, device.ProductCode) { - return true - } - } - - // 6. Check if device product serial matches any component - for _, comp := range liveInfo.Components { - if comp.SerialNumber != "" && device.ProductSerialNumber == comp.SerialNumber { - return true - } - } - - return false -} - -// explainMatch provides detailed explanation of why a device matches -func (d *DeviceMigrationDiagnostic) explainMatch(device models.ServiceDeviceInfo, liveInfo *setup.DeviceInfoXML) { - log.Printf(" 📋 Device: %s", device.DeviceID) - log.Printf(" Account: %s", device.AccountID) - log.Printf(" Name: %s → %s", device.Name, liveInfo.Name) - log.Printf(" IP: %s", device.IPAddress) - log.Printf(" Serial: %s → %s", device.DeviceSerialNumber, liveInfo.SerialNumber) - log.Printf(" MAC: %s → %s", device.MacAddress, liveInfo.GetPrimaryMacAddress()) - log.Printf(" Product: %s → %s %s", device.ProductCode, liveInfo.Type, liveInfo.ModuleType) - - reasons := d.getMatchReasons(device, liveInfo) - for _, reason := range reasons { - log.Printf(" ✅ %s", reason) - } -} - -// getMatchReason gets the primary reason for a match -func (d *DeviceMigrationDiagnostic) getMatchReason(device models.ServiceDeviceInfo, liveInfo *setup.DeviceInfoXML) string { - reasons := d.getMatchReasons(device, liveInfo) - if len(reasons) > 0 { - return reasons[0] - } - - return "Unknown match reason" -} - -// getMatchReasons gets all reasons why a device matches -func (d *DeviceMigrationDiagnostic) getMatchReasons(device models.ServiceDeviceInfo, liveInfo *setup.DeviceInfoXML) []string { - var reasons []string - - // Serial number matches - if liveInfo.SerialNumber != "" && device.DeviceSerialNumber == liveInfo.SerialNumber { - reasons = append(reasons, "Device serial number matches") - } - - if liveInfo.SerialNumber != "" && device.DeviceID == liveInfo.SerialNumber { - reasons = append(reasons, "DeviceID matches component serial") - } - - // MAC address matches - primaryMAC := liveInfo.GetPrimaryMacAddress() - if primaryMAC != "" && device.MacAddress == primaryMAC { - reasons = append(reasons, "MAC address matches") - } - - if device.DeviceID == liveInfo.DeviceID { - reasons = append(reasons, "DeviceID matches (already migrated)") - } - - // Name and product - if liveInfo.Name != "" && device.Name == liveInfo.Name { - expectedProduct := liveInfo.Type + " " + liveInfo.ModuleType - if device.ProductCode == expectedProduct || device.ProductCode == liveInfo.Type { - reasons = append(reasons, "Name and product match exactly") - } else if strings.Contains(device.ProductCode, liveInfo.Type) || strings.Contains(expectedProduct, device.ProductCode) { - reasons = append(reasons, "Name and product similar") - } - } - - // Component serials - for _, comp := range liveInfo.Components { - if comp.SerialNumber != "" && device.ProductSerialNumber == comp.SerialNumber { - reasons = append(reasons, fmt.Sprintf("Product serial matches %s component", comp.Category)) - } - } - - return reasons -} - -// SimulateFullMigration simulates what would happen if migration ran for this device -func (d *DeviceMigrationDiagnostic) SimulateFullMigration(deviceIP string) error { - log.Printf("=== Migration Simulation for %s ===", deviceIP) - - // Fetch device info - liveInfo, err := d.server.sm.GetLiveDeviceInfo(deviceIP) - if err != nil { - return fmt.Errorf("cannot fetch device info: %w", err) - } - - // Simulate discovery - discovery := models.DiscoveredDevice{ - Host: deviceIP, - Name: "Simulated Discovery", - SerialNo: "", - DiscoveryMethod: "Manual", - } - - log.Printf("Target Device ID: %s", liveInfo.DeviceID) - log.Printf("Target Account: %s", liveInfo.MargeAccountUUID) - - // Find existing variants - existingDevices := d.server.findAllExistingDeviceVariants(discovery, liveInfo) - - if len(existingDevices) == 0 { - log.Printf("✨ This would be a NEW device:") - log.Printf(" Directory: accounts/%s/devices/%s/", liveInfo.MargeAccountUUID, liveInfo.DeviceID) - } else { - log.Printf("🔄 This would MIGRATE %d existing device(s):", len(existingDevices)) - - for i := range existingDevices { - existing := &existingDevices[i] - if existing.DeviceID != liveInfo.DeviceID { - log.Printf(" %s → %s", existing.DeviceID, liveInfo.DeviceID) - log.Printf(" From: accounts/%s/devices/%s/", existing.AccountID, existing.DeviceID) - log.Printf(" To: accounts/%s/devices/%s/", liveInfo.MargeAccountUUID, liveInfo.DeviceID) - } else { - log.Printf(" %s (already correct)", existing.DeviceID) - } - } - } - - log.Printf("=== End Simulation ===") - - return nil -} - -// AnalyzeExistingDevices provides an overview of all devices and potential migration issues -func (d *DeviceMigrationDiagnostic) AnalyzeExistingDevices() error { - log.Printf("=== Device Migration Analysis ===") - - allDevices, err := d.server.ds.ListAllDevices() - if err != nil { - return fmt.Errorf("failed to list devices: %w", err) - } - - log.Printf("📊 Total devices in datastore: %d", len(allDevices)) - - // Categorize devices - var ( - macBasedDevices []models.ServiceDeviceInfo - ipBasedDevices []models.ServiceDeviceInfo - serialBasedDevices []models.ServiceDeviceInfo - unknownDevices []models.ServiceDeviceInfo - ) - - for i := range allDevices { - device := &allDevices[i] - - deviceID := device.DeviceID - switch { - case isMACAddress(deviceID): - macBasedDevices = append(macBasedDevices, *device) - case isIPAddress(deviceID): - ipBasedDevices = append(ipBasedDevices, *device) - case isSerialNumber(deviceID): - serialBasedDevices = append(serialBasedDevices, *device) - default: - unknownDevices = append(unknownDevices, *device) - } - } - - log.Printf("\n📋 Device ID Categories:") - log.Printf(" ✅ MAC-based: %d (target format)", len(macBasedDevices)) - log.Printf(" 🔄 IP-based: %d (needs migration)", len(ipBasedDevices)) - log.Printf(" 🔄 Serial-based: %d (needs migration)", len(serialBasedDevices)) - log.Printf(" ❓ Unknown format: %d", len(unknownDevices)) - - if len(ipBasedDevices) > 0 { - log.Printf("\n🔄 IP-based devices (migration candidates):") - - for i := range ipBasedDevices { - device := &ipBasedDevices[i] - log.Printf(" %s (%s)", device.DeviceID, device.Name) - } - } - - if len(serialBasedDevices) > 0 { - log.Printf("\n🔄 Serial-based devices (migration candidates):") - - for i := range serialBasedDevices { - device := &serialBasedDevices[i] - log.Printf(" %s (%s)", device.DeviceID, device.Name) - } - } - - if len(unknownDevices) > 0 { - log.Printf("\n❓ Unknown format devices:") - - for i := range unknownDevices { - device := &unknownDevices[i] - log.Printf(" %s (%s)", device.DeviceID, device.Name) - } - } - - log.Printf("=== End Analysis ===") - - return nil -} - -// Helper functions -func isMACAddress(s string) bool { - // AABBCCDDEEFF format - if len(s) == 12 { - return isHexOnly(s) - } - - // AA:BB:CC:DD:EE:FF or AA-BB-CC-DD-EE-FF format - if len(s) == 17 && (strings.Contains(s, ":") || strings.Contains(s, "-")) { - s = strings.ReplaceAll(s, "-", ":") - - parts := strings.Split(s, ":") - if len(parts) != 6 { - return false - } - - for _, part := range parts { - if len(part) != 2 || !isHexOnly(part) { - return false - } - } - - return true - } - - return false -} - -func isHexOnly(s string) bool { - for _, r := range s { - if (r < '0' || r > '9') && (r < 'A' || r > 'F') && (r < 'a' || r > 'f') { - return false - } - } - - return true -} - -func isIPAddress(s string) bool { - parts := strings.Split(s, ".") - if len(parts) != 4 { - return false - } - - for _, part := range parts { - if len(part) == 0 || len(part) > 3 { - return false - } - - for _, r := range part { - if r < '0' || r > '9' { - return false - } - } - } - - return true -} - -func isSerialNumber(s string) bool { - // Heuristic: serial numbers are typically alphanumeric and longer than MAC addresses - if len(s) < 10 || len(s) > 30 { - return false - } - - hasLetter := false - hasDigit := false - - for _, r := range s { - switch { - case (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z'): - hasLetter = true - case r >= '0' && r <= '9': - hasDigit = true - default: - return false // Contains non-alphanumeric characters - } - } - - return hasLetter && hasDigit -} diff --git a/pkg/service/handlers/mirror_middleware.go b/pkg/service/handlers/mirror_middleware.go index f7a971d..3df11a5 100644 --- a/pkg/service/handlers/mirror_middleware.go +++ b/pkg/service/handlers/mirror_middleware.go @@ -5,6 +5,7 @@ import ( "context" "crypto/tls" "encoding/json" + "encoding/xml" "fmt" "io" "log" @@ -17,6 +18,9 @@ import ( "sort" "strings" "time" + + "github.com/gesellix/bose-soundtouch/pkg/models" + "github.com/gesellix/bose-soundtouch/pkg/service/marge" ) // MirrorMiddleware returns a middleware that mirrors specific requests to the Bose upstream. @@ -348,6 +352,21 @@ func (s *Server) checkParity(req *http.Request, local, upstream *mirrorResponseR log.Printf("[PARITY] Mismatch detected for %s %s: %v", req.Method, req.URL.Path, reasons) s.saveParityMismatch(req, local, upstream, reasons) } + + // Trigger synchronization if this is a /full response from upstream + if strings.Contains(req.URL.Path, "/full") && upstream.status == http.StatusOK { + var resp models.AccountFullResponse + if err := xml.Unmarshal(upstream.body.Bytes(), &resp); err == nil { + log.Printf("[MIRROR] Triggering sync from upstream /full response for %s", req.URL.Path) + marge.LogSyncDiff(s.ds, &resp) + + if err = marge.SyncFromAccountFull(s.ds, &resp); err != nil { + log.Printf("[MIRROR_ERR] Failed to sync from upstream /full: %v", err) + } + } else { + log.Printf("[MIRROR_ERR] Failed to unmarshal upstream /full response: %v", err) + } + } } // compareXMLWhitespaceInsensitive compares two XML bodies ignoring whitespace between elements. diff --git a/pkg/service/handlers/mirror_preferred_test.go b/pkg/service/handlers/mirror_preferred_test.go index 42f8556..c5ea28a 100644 --- a/pkg/service/handlers/mirror_preferred_test.go +++ b/pkg/service/handlers/mirror_preferred_test.go @@ -39,7 +39,7 @@ func TestMirrorMiddleware_PreferredSource(t *testing.T) { defer upstreamServer.Close() // 3. Setup our server with MirrorMiddleware - server := NewServer(ds, nil, "http://localhost:8000", false, false, false, false, false) + server := NewServer(ds, nil, "http://localhost:8000", false, false, false) server.SetMirrorSettings(true, []string{"/test/local"}, "local") // We need to trick performMirror to use our mock upstream. @@ -119,7 +119,7 @@ func TestSettingsAPI_PreferredSource(t *testing.T) { ds := datastore.NewDataStore(tempDir) _ = ds.Initialize() - server := NewServer(ds, nil, "http://localhost:8000", false, false, false, false, false) + server := NewServer(ds, nil, "http://localhost:8000", false, false, false) // Test GET initial req := httptest.NewRequest("GET", "/setup/settings", nil) diff --git a/pkg/service/handlers/parity_mismatch_repro_test.go b/pkg/service/handlers/parity_mismatch_repro_test.go index e995fc5..90fce02 100644 --- a/pkg/service/handlers/parity_mismatch_repro_test.go +++ b/pkg/service/handlers/parity_mismatch_repro_test.go @@ -1,7 +1,6 @@ package handlers import ( - "fmt" "io" "net/http" "net/http/httptest" @@ -62,7 +61,7 @@ func TestParityMismatchReproduction_New(t *testing.T) { body, _ := io.ReadAll(res.Body) bodyStr := string(body) - fmt.Printf("[DEBUG_LOG] Response Body:\n%s\n", bodyStr) + t.Logf("Response Body:\n%s\n", bodyStr) // Verification points: // 1. Standalone="yes" diff --git a/pkg/service/handlers/parity_mismatch_repro_v3_test.go b/pkg/service/handlers/parity_mismatch_repro_v3_test.go index 683be8d..9dff458 100644 --- a/pkg/service/handlers/parity_mismatch_repro_v3_test.go +++ b/pkg/service/handlers/parity_mismatch_repro_v3_test.go @@ -111,7 +111,7 @@ func TestParityMismatchReproduction_V3(t *testing.T) { body, _ := io.ReadAll(res.Body) bodyStr := string(body) - fmt.Printf("[DEBUG_LOG] GET /recents Local Response:\n%s\n", bodyStr) + t.Logf("GET /recents Local Response:\n%s\n", bodyStr) if !strings.Contains(bodyStr, `25`) { t.Error("Source provider ID missing in GET /recents") diff --git a/pkg/service/handlers/server.go b/pkg/service/handlers/server.go index 2593242..61b6ef1 100644 --- a/pkg/service/handlers/server.go +++ b/pkg/service/handlers/server.go @@ -16,7 +16,6 @@ import ( "github.com/gesellix/bose-soundtouch/pkg/discovery" "github.com/gesellix/bose-soundtouch/pkg/models" "github.com/gesellix/bose-soundtouch/pkg/service/datastore" - "github.com/gesellix/bose-soundtouch/pkg/service/migration" "github.com/gesellix/bose-soundtouch/pkg/service/proxy" "github.com/gesellix/bose-soundtouch/pkg/service/setup" "github.com/gesellix/bose-soundtouch/pkg/service/spotify" @@ -27,7 +26,6 @@ import ( type Server struct { ds *datastore.DataStore sm *setup.Manager - migrationManager *migration.Manager mu sync.RWMutex serverURL string httpsServerURL string @@ -81,17 +79,10 @@ var bufferPool = sync.Pool{ } // NewServer creates a new SoundTouch service server. -func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, proxyRedact, proxyLogBody, recordEnabled, migrationEnabled, migrationDryRun bool) *Server { - // Initialize migration manager - migrationConfig := migration.Config{ - Enabled: migrationEnabled, - DryRun: migrationDryRun, - } - +func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, proxyRedact, proxyLogBody, recordEnabled bool) *Server { s := &Server{ ds: ds, sm: sm, - migrationManager: migration.NewManager(ds, migrationConfig), serverURL: serverURL, proxyRedact: proxyRedact, proxyLogBody: proxyLogBody, @@ -568,21 +559,7 @@ func (s *Server) handleDiscoveredDevice(d models.DiscoveredDevice) { } } - // 7. Check for existing device entries that need migration - log.Printf("Checking for existing device variants to migrate for device %s (MAC: %s)", liveInfo.Name, deviceID) - - existingDevices := s.findAllExistingDeviceVariants(d, liveInfo) - if len(existingDevices) == 0 { - log.Printf("No existing device variants found for migration") - } - - // Use migration manager to handle device directory migration - migrated := s.migrationManager.MigrateDevicesIfNeeded(existingDevices, deviceID) - if !migrated { - log.Printf("Device %s: no migration needed (already uses correct MAC-based ID %s)", liveInfo.Name, deviceID) - } - - // 8. Save the updated device info + // 7. Save the updated device info if err := s.ds.SaveDeviceInfo(accountID, deviceID, info); err != nil { log.Printf("Failed to save device info for %s: %v", deviceID, err) return @@ -591,11 +568,6 @@ func (s *Server) handleDiscoveredDevice(d models.DiscoveredDevice) { log.Printf("Successfully saved device %s (%s) with MAC-based deviceID: %s", info.Name, d.Host, deviceID) } -// GetMigrationStats returns migration statistics for debugging/monitoring -func (s *Server) GetMigrationStats() migration.Stats { - return s.migrationManager.GetStats() -} - // handleDiscoveredDeviceFallback handles device discovery when /info endpoint is not available func (s *Server) handleDiscoveredDeviceFallback(d models.DiscoveredDevice) { log.Printf("Using fallback discovery method for device: %s at %s", d.Name, d.Host) @@ -715,98 +687,6 @@ func (s *Server) findExistingDeviceID(d models.DiscoveredDevice) string { return "" } -// findAllExistingDeviceVariants finds all existing device entries that could represent the same physical device -func (s *Server) findAllExistingDeviceVariants(d models.DiscoveredDevice, liveInfo *setup.DeviceInfoXML) []models.ServiceDeviceInfo { - // log.Printf("Searching for existing device variants with criteria:") - // log.Printf(" Discovery IP: %s", d.Host) - // log.Printf(" Discovery Serial: %s", d.SerialNo) - // log.Printf(" Live Info Serial: %s", liveInfo.SerialNumber) - // log.Printf(" Live Info Name: %s", liveInfo.Name) - // log.Printf(" Live Info MAC: %s", liveInfo.GetPrimaryMacAddress()) - // log.Printf(" Live Info Product: %s %s", liveInfo.Type, liveInfo.ModuleType) - allDevices, err := s.ds.ListAllDevices() - if err != nil { - return nil - } - - var matches []models.ServiceDeviceInfo - - seenDeviceIDs := make(map[string]bool) - - for i := range allDevices { - device := &allDevices[i] - if seenDeviceIDs[device.DeviceID] { - continue - } - - matchReason := s.getMatchReason(*device, d, liveInfo) - if matchReason != "" { - matches = append(matches, *device) - seenDeviceIDs[device.DeviceID] = true - log.Printf(" ✓ Found variant %s: %s", device.DeviceID, matchReason) - } - } - - if len(matches) == 0 { - log.Printf(" No existing device variants found") - } else { - log.Printf("Found %d existing device variant(s) for %s:", len(matches), liveInfo.Name) - - for i := range matches { - match := &matches[i] - log.Printf(" - %s (Account: %s, IP: %s, Serial: %s, MAC: %s, Product: %s)", - match.DeviceID, match.AccountID, match.IPAddress, match.DeviceSerialNumber, match.MacAddress, match.ProductCode) - } - } - - return matches -} - -func (s *Server) getMatchReason(device models.ServiceDeviceInfo, d models.DiscoveredDevice, liveInfo *setup.DeviceInfoXML) string { - // 1. Same IP address - if d.Host != "" && device.IPAddress == d.Host { - return fmt.Sprintf("IP address match (%s == %s)", d.Host, device.IPAddress) - } - - // 2. Same UPnP serial number - if d.SerialNo != "" && (device.DeviceID == d.SerialNo || device.DeviceSerialNumber == d.SerialNo) { - if device.DeviceID == d.SerialNo { - return "UPnP serial as DeviceID" - } - - return "UPnP serial in DeviceSerialNumber" - } - - // 3. Same device serial number from /info - if liveInfo.SerialNumber != "" && device.DeviceSerialNumber == liveInfo.SerialNumber { - return fmt.Sprintf("device serial number match (%s)", liveInfo.SerialNumber) - } - - // 4. Same MAC address (if device already has one stored) - primaryMAC := liveInfo.GetPrimaryMacAddress() - if primaryMAC != "" && device.MacAddress == primaryMAC { - return fmt.Sprintf("MAC address match (%s)", primaryMAC) - } - - // 5. Same device name and similar product (fuzzy match for renamed devices) - if liveInfo.Name != "" && device.Name == liveInfo.Name { - expectedProduct := liveInfo.Type + " " + liveInfo.ModuleType - if device.ProductCode == expectedProduct || - device.ProductCode == liveInfo.Type || - strings.Contains(device.ProductCode, liveInfo.Type) || - strings.Contains(expectedProduct, device.ProductCode) { - return fmt.Sprintf("name and product match (name: %s, product: %s)", liveInfo.Name, device.ProductCode) - } - } - - // 6. DeviceID matches component serial (device was stored by serial before) - if liveInfo.SerialNumber != "" && device.DeviceID == liveInfo.SerialNumber { - return fmt.Sprintf("DeviceID matches component serial (%s)", liveInfo.SerialNumber) - } - - return "" -} - func (s *Server) findExistingDeviceInfo(d models.DiscoveredDevice) *models.ServiceDeviceInfo { allDevices, _ := s.ds.ListAllDevices() for i := range allDevices { diff --git a/pkg/service/handlers/server_merge_test.go b/pkg/service/handlers/server_merge_test.go index c71dce3..abd92dd 100644 --- a/pkg/service/handlers/server_merge_test.go +++ b/pkg/service/handlers/server_merge_test.go @@ -16,7 +16,7 @@ func TestMergeOverlappingDevices(t *testing.T) { defer os.RemoveAll(tempDir) ds := datastore.NewDataStore(tempDir) - s := NewServer(ds, nil, "http://localhost", false, false, false, false, false) + s := NewServer(ds, nil, "http://localhost", false, false, false) // Case 1: IP-only entry and Serial-based entry for the same IP ip := "192.168.1.100" @@ -74,7 +74,7 @@ func TestFindExistingDeviceID(t *testing.T) { defer os.RemoveAll(tempDir) ds := datastore.NewDataStore(tempDir) - s := NewServer(ds, nil, "http://localhost", false, false, false, false, false) + s := NewServer(ds, nil, "http://localhost", false, false, false) ip := "192.168.1.101" serial := "SERIAL456" diff --git a/pkg/service/handlers/snapshot_integrity_test.go b/pkg/service/handlers/snapshot_integrity_test.go index dbd12cb..0c21ed5 100644 --- a/pkg/service/handlers/snapshot_integrity_test.go +++ b/pkg/service/handlers/snapshot_integrity_test.go @@ -25,7 +25,7 @@ func TestSnapshotIntegrity_SelfAndMirror(t *testing.T) { ds := datastore.NewDataStore(tempDir) recorder := proxy.NewRecorder(tempDir) - s := NewServer(ds, nil, "http://localhost:8000", false, false, true, false, false) + s := NewServer(ds, nil, "http://localhost:8000", false, false, true) s.SetRecorder(recorder) s.SetMirrorSettings(true, []string{"/mirror/*"}, "local") diff --git a/pkg/service/marge/marge.go b/pkg/service/marge/marge.go index 039ece1..c061909 100644 --- a/pkg/service/marge/marge.go +++ b/pkg/service/marge/marge.go @@ -6,6 +6,7 @@ import ( "bytes" "encoding/xml" "fmt" + "log" "os" "strconv" "strings" @@ -167,8 +168,12 @@ func PrepareConfiguredSource(s *models.ConfiguredSource) { } } - if s.SourceKeyType == "SPOTIFY" { - tokenType = "token_version_3" + if s.SecretType == "" { + if s.SourceKeyType == "SPOTIFY" { + tokenType = "token_version_3" + } + + s.SecretType = tokenType } if providerID == "" { @@ -186,8 +191,6 @@ func PrepareConfiguredSource(s *models.ConfiguredSource) { s.Type = "Audio" s.SourceProviderID = providerID - s.SecretType = tokenType - if s.SourceName == "" && s.DisplayName != "Other" { s.SourceName = s.DisplayName } @@ -405,6 +408,14 @@ func mapToFullResponseSource(s models.ConfiguredSource) models.FullResponseSourc fullSource.Credential.Type = s.SecretType fullSource.Credential.Value = s.Secret + if fullSource.Credential.Type == "" || fullSource.Credential.Type == "token" { + if s.Type == "SPOTIFY" || s.SourceProviderID == "SPOTIFY" || s.SourceKeyType == "SPOTIFY" { + fullSource.Credential.Type = "token_version_3" + } else if fullSource.Credential.Type == "" { + fullSource.Credential.Type = "token" + } + } + if s.SourceKeyType == "TUNEIN" { fullSource.SourceName = "" } @@ -422,7 +433,6 @@ func mapPresetsToFullResponse(presets []models.ServicePreset, sources []models.C for i := range presets { p := &presets[i] - p.ButtonNumber = p.ID if p.CreatedOn == "" { p.CreatedOn = DateStr } @@ -446,9 +456,9 @@ func mapPresetsToFullResponse(presets []models.ServicePreset, sources []models.C } fullPreset := models.FullResponsePreset{ - ButtonNumber: p.ButtonNumber, + ButtonNumber: p.ID, ContainerArt: p.ContainerArt, - ContentItemType: p.Type, + ContentItemType: p.ContentItemType, CreatedOn: p.CreatedOn, Location: p.Location, Name: p.Name, @@ -494,7 +504,7 @@ func mapRecentsToFullResponse(recents []models.ServiceRecent, sources []models.C fullRecent := models.FullResponseRecent{ ID: r.ID, - ContentItemType: r.Type, + ContentItemType: r.ContentItemType, CreatedOn: r.CreatedOn, LastPlayedAt: r.LastPlayedAt, Location: r.Location, @@ -581,6 +591,7 @@ func AccountFullToXML(ds *datastore.DataStore, account string) ([]byte, error) { data = bytes.ReplaceAll(data, []byte(""), []byte("")) data = bytes.ReplaceAll(data, []byte(" "), []byte("")) data = bytes.ReplaceAll(data, []byte(""), []byte("")) + data = bytes.ReplaceAll(data, []byte(""), []byte("")) return append([]byte(constants.XMLHeader), data...), nil } @@ -840,7 +851,7 @@ func persistLearnedSource(ds *datastore.DataStore, account, device string, sourc } if err := ds.SaveConfiguredSources(account, device, updatedSources); err != nil { - fmt.Printf("[DEBUG_LOG] Failed to persist learned source: %v\n", err) + log.Printf("[MARGE_ERR] Failed to persist learned source for %s: %v", device, err) } } diff --git a/pkg/service/marge/marge_test.go b/pkg/service/marge/marge_test.go index 0f566b9..e9fe6b2 100644 --- a/pkg/service/marge/marge_test.go +++ b/pkg/service/marge/marge_test.go @@ -181,6 +181,9 @@ func TestAccountFullToXML_Structure(t *testing.T) { if !strings.Contains(xmlStr, ``) { t.Errorf("Expected device attribute deviceid, got %s", xmlStr) } + if !strings.Contains(xmlStr, `A Sound Machine`) { + t.Errorf("Expected A Sound Machine under device, got %s", xmlStr) + } if !strings.Contains(xmlStr, `08DF1F0BA325`) { t.Errorf("Expected 08DF1F0BA325 under device, got %s", xmlStr) } @@ -195,8 +198,8 @@ func TestAccountFullToXML_Structure(t *testing.T) { if !strings.Contains(xmlStr, `SoundTouch 20`) { t.Errorf("Expected productlabel SoundTouch 20, got %s", xmlStr) } - if !strings.Contains(xmlStr, `066802942560222AE`) { - t.Errorf("Expected 066802942560222AE under attachedProduct, got %s", xmlStr) + if !strings.Contains(xmlStr, `066802942560222AE`) { + t.Errorf("Expected 066802942560222AE under attachedProduct, got %s", xmlStr) } if !strings.Contains(xmlStr, ``) { t.Errorf("Expected under attachedProduct, got %s", xmlStr) @@ -544,3 +547,132 @@ func TestAddRecent_TimestampPreservation(t *testing.T) { t.Errorf("sourceid should not be inside source tag: %s", string(respXML)) } } + +func TestMapToFullResponseSource_CredentialRespect(t *testing.T) { + // 1. Spotify with default token -> should upgrade to token_version_3 + src1 := models.ConfiguredSource{ + SourceKey: struct { + Type string `xml:"type,attr"` + Account string `xml:"account,attr"` + }{Type: "SPOTIFY", Account: "user1"}, + SourceKeyType: "SPOTIFY", + SecretType: "token", + Secret: "token123", + } + full1 := mapToFullResponseSource(src1) + if full1.Credential.Type != "token_version_3" { + t.Errorf("expected token_version_3 for Spotify with 'token', got %s", full1.Credential.Type) + } + + // 2. Spotify with explicit token_version_3 -> should keep it + src2 := models.ConfiguredSource{ + SourceKeyType: "SPOTIFY", + SecretType: "token_version_3", + Secret: "token123", + } + full2 := mapToFullResponseSource(src2) + if full2.Credential.Type != "token_version_3" { + t.Errorf("expected token_version_3 to be preserved, got %s", full2.Credential.Type) + } + + // 3. Custom source with custom credential type -> should be preserved + src3 := models.ConfiguredSource{ + SourceKeyType: "CUSTOM", + SecretType: "custom_type", + Secret: "secret123", + } + full3 := mapToFullResponseSource(src3) + if full3.Credential.Type != "custom_type" { + t.Errorf("expected custom_type to be preserved, got %s", full3.Credential.Type) + } + + // 4. Source with empty credential type -> should default to 'token' + src4 := models.ConfiguredSource{ + SourceKeyType: "OTHER", + SecretType: "", + } + full4 := mapToFullResponseSource(src4) + if full4.Credential.Type != "token" { + t.Errorf("expected empty SecretType to default to 'token', got %s", full4.Credential.Type) + } +} + +func TestAccountFullToXML_WithBackupStructure(t *testing.T) { + tempDir, err := os.MkdirTemp("", "marge-test-backup-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer func() { _ = os.RemoveAll(tempDir) }() + + account := "3230304" + device := "A81B6A536A98" + + // Mimic the backup structure: accounts/3230304/devices/A81B6A536A98/DeviceInfo.xml + deviceDir := filepath.Join(tempDir, "accounts", account, "devices", device) + _ = os.MkdirAll(deviceDir, 0755) + + deviceInfoXML := ` + + Sound Machinechen + SoundTouch + 10 sm2 + + + SCM + 27.0.6.46330.5043500 epdbuild.trunk.hepdswbld04.2022-08-04T11:20:29 + I6332527703739342000020 + + + + 192.168.178.35 + A81B6A536A98 + + sync_full +` + _ = os.WriteFile(filepath.Join(deviceDir, "DeviceInfo.xml"), []byte(deviceInfoXML), 0644) + + ds := datastore.NewDataStore(tempDir) + + // Generate XML + fullXML, err := AccountFullToXML(ds, account) + if err != nil { + t.Fatalf("AccountFullToXML failed: %v", err) + } + + xmlStr := string(fullXML) + + // Verify Name is present + if !strings.Contains(xmlStr, `Sound Machinechen`) { + t.Errorf("Expected Sound Machinechen under device, got %s", xmlStr) + } + + // 2. Verify ButtonNumber and ContentItemType mapping + presetsDir := filepath.Join(deviceDir) + _ = os.MkdirAll(presetsDir, 0755) + presetsXML := ` + + + + https://i.scdn.co/image/art + + +` + _ = os.WriteFile(filepath.Join(presetsDir, "Presets.xml"), []byte(presetsXML), 0644) + + fullXMLWithPresets, _ := AccountFullToXML(ds, account) + xmlStr2 := string(fullXMLWithPresets) + + if !strings.Contains(xmlStr2, `buttonNumber="1"`) { + t.Errorf("Expected buttonNumber=\"1\", got %s", xmlStr2) + } + if !strings.Contains(xmlStr2, `tracklisturl`) { + t.Errorf("Expected tracklisturl, got %s", xmlStr2) + } + + // 3. Test with empty name + _ = os.WriteFile(filepath.Join(deviceDir, "DeviceInfo.xml"), []byte(``), 0644) + fullXML2, _ := AccountFullToXML(ds, account) + if !strings.Contains(string(fullXML2), ``) { + t.Errorf("Expected for empty name, got %s", string(fullXML2)) + } +} diff --git a/pkg/service/marge/race_test.go b/pkg/service/marge/race_test.go new file mode 100644 index 0000000..f927f42 --- /dev/null +++ b/pkg/service/marge/race_test.go @@ -0,0 +1,109 @@ +package marge + +import ( + "os" + "strings" + "sync" + "testing" + "time" + + "github.com/gesellix/bose-soundtouch/pkg/models" + "github.com/gesellix/bose-soundtouch/pkg/service/datastore" +) + +func TestRaceConditionFullSync(t *testing.T) { + tempDir, err := os.MkdirTemp("", "soundtouch-test-race") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + ds := datastore.NewDataStore(tempDir) + accountID := "test-account" + deviceID := "test-device" + + // Initial data + initialInfo := &models.ServiceDeviceInfo{ + DeviceID: deviceID, + AccountID: accountID, + Name: "Initial Name", + ProductCode: "SoundTouch 10", + } + if err := ds.SaveDeviceInfo(accountID, deviceID, initialInfo); err != nil { + t.Fatalf("Failed to save initial info: %v", err) + } + + // We'll run a loop where one goroutine reads and another writes + // and check if we ever get an empty name. + + stop := make(chan struct{}) + var wg sync.WaitGroup + wg.Add(2) + + var emptyNameFound bool + var mu sync.Mutex + + // Reader goroutine + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + xmlData, err := AccountFullToXML(ds, accountID) + if err != nil { + // It's possible to get "file not found" or "permission denied" or "empty file" if we hit the middle of a write + // but here we are most interested in getting an incomplete response + continue + } + + if contains(string(xmlData), "") || contains(string(xmlData), "") { + mu.Lock() + emptyNameFound = true + mu.Unlock() + return + } + if !contains(string(xmlData), "") && !contains(string(xmlData), "") { + mu.Lock() + emptyNameFound = true + mu.Unlock() + t.Logf("RaceConditionFullSync: Name tag COMPLETELY MISSING in XML: %s\n", string(xmlData)) + return + } + } + } + }() + + // Writer goroutine + go func() { + defer wg.Done() + info := &models.ServiceDeviceInfo{ + DeviceID: deviceID, + AccountID: accountID, + Name: "Updated Name", + ProductCode: "SoundTouch 10", + } + for { + select { + case <-stop: + return + default: + _ = ds.SaveDeviceInfo(accountID, deviceID, info) + } + } + }() + + // Run for a short time + time.Sleep(2 * time.Second) + close(stop) + wg.Wait() + + if emptyNameFound { + t.Errorf("Race condition detected: found empty name in /full response during concurrent write") + } +} + +func contains(s, substr string) bool { + return strings.Contains(s, substr) +} diff --git a/pkg/service/marge/repro_test.go b/pkg/service/marge/repro_test.go new file mode 100644 index 0000000..36fa859 --- /dev/null +++ b/pkg/service/marge/repro_test.go @@ -0,0 +1,179 @@ +package marge + +import ( + "encoding/xml" + "os" + "path/filepath" + "testing" + + "github.com/gesellix/bose-soundtouch/pkg/models" + "github.com/gesellix/bose-soundtouch/pkg/service/datastore" +) + +func TestReproduceMissingName(t *testing.T) { + tempBaseDir := "repro_data" + err := os.MkdirAll(tempBaseDir, 0755) + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tempBaseDir) + + accountID := "3230304" + + // Create device folders + // 08DF1F0BA325 (has name) + // A81B6A536A98 (missing name in full_local.xml) + + // Device 1: 08DF1F0BA325 + dev1Dir := filepath.Join(tempBaseDir, "accounts", accountID, "devices", "08DF1F0BA325") + err = os.MkdirAll(dev1Dir, 0755) + if err != nil { + t.Fatal(err) + } + dev1Info := ` + A Sound Machine + SoundTouch + 20 + + + SCM + 27.0.6.46330.5043500 epdbuild.trunk.hepdswbld04.2022-08-04T11:20:29 + K4245112804625125000710 + + + PackagedProduct + 066802942560222AE + + +` + os.WriteFile(filepath.Join(dev1Dir, "DeviceInfo.xml"), []byte(dev1Info), 0644) + + // Device 2: A81B6A536A98 - MAC address ID in XML, name with special char or space? + dev2Dir := filepath.Join(tempBaseDir, "accounts", accountID, "devices", "A81B6A536A98") + err = os.MkdirAll(dev2Dir, 0755) + if err != nil { + t.Fatal(err) + } + dev2Info := ` + + Sound Machinechen + SoundTouch + 10 sm2 + + + SCM + 27.0.6.46330.5043500 epdbuild.trunk.hepdswbld04.2022-08-04T11:20:29 + I6332527703739342000020 + + + PackagedProduct + 069231P63364828AE + + + + 192.168.178.35 + A81B6A536A98 + + sync_full +` + os.WriteFile(filepath.Join(dev2Dir, "DeviceInfo.xml"), []byte(dev2Info), 0644) + + // In the backup, there is NO default entry with empty name for this device's serial. + // But let's see what happens if we use the EXACT content from the backup. + // I'll also add a test case that unmarshals the exact backup file content. + + ds := datastore.NewDataStore(tempBaseDir) + err = ds.Initialize() + if err != nil { + t.Fatal(err) + } + + // Generate /full response XML + data, err := AccountFullToXML(ds, accountID) + if err != nil { + t.Fatal(err) + } + + t.Logf("Resulting XML:\n%s\n", string(data)) + + var resp models.AccountFullResponse + err = xml.Unmarshal(data, &resp) + if err != nil { + t.Fatal(err) + } + + // Now test name preservation during sync + // Mock a response with empty name for A81B6A536A98 + for i := range resp.Devices { + if resp.Devices[i].DeviceID == "A81B6A536A98" { + resp.Devices[i].Name = "" + } + } + + // Remove the account-specific device directory to force resolution to 'default' + os.RemoveAll(filepath.Join(tempBaseDir, "accounts", accountID, "devices", "A81B6A536A98")) + + // Create a duplicate directory in another place (e.g. 'st-go/data/accounts/default') with the CORRECT name + // This simulates a global entry that ds.ListAllDevices() should find + globalDevDir := filepath.Join("st-go", "data", "accounts", "default", "devices", "A81B6A536A98") + os.MkdirAll(globalDevDir, 0755) + defer os.RemoveAll("st-go") + globalDevInfo := `Sound MachinechenSoundTouch10 sm2` + os.WriteFile(filepath.Join(globalDevDir, "DeviceInfo.xml"), []byte(globalDevInfo), 0644) + + // Create a directory in 'default' with EMPTY name (the one that GetDeviceInfo will pick up) + defaultDevDir := filepath.Join(tempBaseDir, "default", "devices", "A81B6A536A98") + os.MkdirAll(defaultDevDir, 0755) + defaultDevInfo := `SoundTouch10 sm2` + os.WriteFile(filepath.Join(defaultDevDir, "DeviceInfo.xml"), []byte(defaultDevInfo), 0644) + + err = SyncFromAccountFull(ds, &resp) + if err != nil { + t.Fatal(err) + } + + // Verify name was preserved + info, err := ds.GetDeviceInfo(accountID, "A81B6A536A98") + if err != nil { + t.Fatal(err) + } + + if info.Name != "Sound Machinechen" { + t.Errorf("Expected name 'Sound Machinechen' to be preserved, got '%s'", info.Name) + } + + // Re-generate XML to see if it now uses the preserved name + data, err = AccountFullToXML(ds, accountID) + if err != nil { + t.Fatal(err) + } + err = xml.Unmarshal(data, &resp) + if err != nil { + t.Fatal(err) + } + + found08 := false + foundA8 := false + + for _, d := range resp.Devices { + t.Logf("Checking device in response: ID=%s, Name='%s'\n", d.DeviceID, d.Name) + if d.DeviceID == "08DF1F0BA325" { + found08 = true + if d.Name == "" { + t.Error("Device 08DF1F0BA325 name should not be empty") + } + } + if d.DeviceID == "A81B6A536A98" || d.DeviceID == "I6332527703739342000020" { + if d.Name != "" { + foundA8 = true + } + } + } + + if !found08 { + t.Error("Device 08DF1F0BA325 not found in response") + } + if !foundA8 { + t.Error("Device A81B6A536A98 not found in response") + } +} diff --git a/pkg/service/marge/sync.go b/pkg/service/marge/sync.go new file mode 100644 index 0000000..f16d0d2 --- /dev/null +++ b/pkg/service/marge/sync.go @@ -0,0 +1,255 @@ +package marge + +import ( + "fmt" + "log" + + "github.com/gesellix/bose-soundtouch/pkg/models" + "github.com/gesellix/bose-soundtouch/pkg/service/datastore" +) + +// SyncFromAccountFull synchronizes the local datastore with the data from an AccountFullResponse. +func SyncFromAccountFull(ds *datastore.DataStore, resp *models.AccountFullResponse) error { + accountID := resp.ID + + if accountID == "" { + return fmt.Errorf("account ID is missing in response") + } + + log.Printf("[SYNC] Starting synchronization for account %s", accountID) + + for i := range resp.Devices { + dev := &resp.Devices[i] + + deviceID := dev.DeviceID + if deviceID == "" { + continue + } + + log.Printf("[SYNC] Synchronizing device %s (Account: %s)", deviceID, accountID) + + // 1. Update Device Info + syncDeviceInfo(ds, accountID, dev) + + // 2. Update Configured Sources for this device + syncConfiguredSources(ds, accountID, deviceID, resp.Sources) + + // 3. Update Presets + syncPresets(ds, accountID, deviceID, dev.Presets) + + // 4. Update Recents + syncRecents(ds, accountID, deviceID, dev.Recents) + } + + log.Printf("[SYNC] Synchronization completed for account %s", accountID) + + return nil +} + +func syncDeviceInfo(ds *datastore.DataStore, accountID string, dev *models.AccountDevice) { + deviceID := dev.DeviceID + existingInfo, _ := ds.GetDeviceInfo(accountID, deviceID) + + info := &models.ServiceDeviceInfo{ + DeviceID: deviceID, + AccountID: accountID, + IPAddress: dev.IPAddress, + DeviceSerialNumber: dev.SerialNumber, + FirmwareVersion: dev.FirmwareVersion, + Name: dev.Name, + DiscoveryMethod: "sync_full", + } + if dev.AttachedProduct != nil { + info.ProductCode = dev.AttachedProduct.ProductCode + info.ProductSerialNumber = dev.AttachedProduct.SerialNumber + } + + // If the name is empty in the upstream response, try to preserve the local name + if existingInfo != nil { + info.MacAddress = existingInfo.MacAddress + if info.IPAddress == "" { + info.IPAddress = existingInfo.IPAddress + } + } + + if info.Name == "" { + if existingInfo != nil && existingInfo.Name != "" { + info.Name = existingInfo.Name + log.Printf("[SYNC_DEBUG] Preserved local name '%s' for device %s", info.Name, deviceID) + } else { + log.Printf("[SYNC_DEBUG] Name is empty for device %s in upstream and no local name found", deviceID) + } + } else { + log.Printf("[SYNC_DEBUG] Upstream name for device %s is '%s'", deviceID, info.Name) + } + + // If the name is still empty, try to find a name from other devices in the same account or globally + if info.Name == "" { + allDevices, _ := ds.ListAllDevices() + for i := range allDevices { + d := &allDevices[i] + if d.DeviceID == deviceID && d.Name != "" { + info.Name = d.Name + log.Printf("[SYNC_DEBUG] Recovered name '%s' for device %s from global search", info.Name, deviceID) + + break + } + } + } + + if err := ds.SaveDeviceInfo(accountID, deviceID, info); err != nil { + log.Printf("[SYNC_ERR] Failed to save device info for %s: %v", deviceID, err) + } +} + +func syncConfiguredSources(ds *datastore.DataStore, accountID, deviceID string, sources []models.FullResponseSource) { + // We'll use the account-level sources from the response as a base. + var deviceSources []models.ConfiguredSource + + for i := range sources { + s := &sources[i] + dsrc := mapFullSourceToConfiguredSource(*s) + deviceSources = append(deviceSources, dsrc) + } + + if err := ds.SaveConfiguredSources(accountID, deviceID, deviceSources); err != nil { + log.Printf("[SYNC_ERR] Failed to save sources for %s: %v", deviceID, err) + } +} + +func syncPresets(ds *datastore.DataStore, accountID, deviceID string, presetsSource []models.FullResponsePreset) { + var presets []models.ServicePreset + + for i := range presetsSource { + p := &presetsSource[i] + preset := models.ServicePreset{ + ServiceContentItem: models.ServiceContentItem{ + ContentItemType: p.ContentItemType, + Location: p.Location, + Name: p.Name, + Source: p.Source.Type, + SourceID: p.Source.ID, + SourceAccount: p.Source.Username, + }, + ButtonNumber: p.ButtonNumber, + CreatedOn: p.CreatedOn, + UpdatedOn: p.UpdatedOn, + ContainerArt: p.ContainerArt, + SourceConfig: &models.ConfiguredSource{ + ID: p.Source.ID, + Type: p.Source.Type, + CreatedOn: p.Source.CreatedOn, + UpdatedOn: p.Source.UpdatedOn, + SourceName: p.Source.SourceName, + DisplayName: p.Source.Name, + SourceProviderID: p.Source.SourceProviderID, + Secret: p.Source.Credential.Value, + SecretType: p.Source.Credential.Type, + Username: p.Source.Username, + }, + } + presets = append(presets, preset) + } + + if err := ds.SavePresets(accountID, deviceID, presets); err != nil { + log.Printf("[SYNC_ERR] Failed to save presets for %s: %v", deviceID, err) + } +} + +func syncRecents(ds *datastore.DataStore, accountID, deviceID string, recentsSource []models.FullResponseRecent) { + var recents []models.ServiceRecent + + for i := range recentsSource { + r := &recentsSource[i] + recent := models.ServiceRecent{ + ServiceContentItem: models.ServiceContentItem{ + ID: r.ID, + ContentItemType: r.ContentItemType, + Location: r.Location, + Name: r.Name, + Source: r.Source.Type, + SourceID: r.Source.ID, + SourceAccount: r.Source.Username, + }, + CreatedOn: r.CreatedOn, + UpdatedOn: r.UpdatedOn, + LastPlayedAt: r.LastPlayedAt, + SourceConfig: &models.ConfiguredSource{ + ID: r.Source.ID, + Type: r.Source.Type, + CreatedOn: r.Source.CreatedOn, + UpdatedOn: r.Source.UpdatedOn, + SourceName: r.Source.SourceName, + DisplayName: r.Source.Name, + SourceProviderID: r.Source.SourceProviderID, + Secret: r.Source.Credential.Value, + SecretType: r.Source.Credential.Type, + Username: r.Source.Username, + }, + } + recents = append(recents, recent) + } + + if err := ds.SaveRecents(accountID, deviceID, recents); err != nil { + log.Printf("[SYNC_ERR] Failed to save recents for %s: %v", deviceID, err) + } +} + +func mapFullSourceToConfiguredSource(s models.FullResponseSource) models.ConfiguredSource { + dsrc := models.ConfiguredSource{ + ID: s.ID, + Type: s.Type, + CreatedOn: s.CreatedOn, + UpdatedOn: s.UpdatedOn, + SourceName: s.SourceName, + DisplayName: s.Name, + SourceProviderID: s.SourceProviderID, + Secret: s.Credential.Value, + SecretType: s.Credential.Type, + Username: s.Username, + SourceSettings: s.SourceSettings, + } + dsrc.SourceKey.Type = s.Type + dsrc.SourceKey.Account = s.Username + + return dsrc +} + +// LogSyncDiff logs inconsistencies found between local state and upstream /full response. +// This is useful for debugging and verification. +func LogSyncDiff(ds *datastore.DataStore, resp *models.AccountFullResponse) { + accountID := resp.ID + for i := range resp.Devices { + dev := &resp.Devices[i] + deviceID := dev.DeviceID + localPresets, _ := ds.GetPresets(accountID, deviceID) + + if len(localPresets) != len(dev.Presets) { + log.Printf("[SYNC_DIFF] Preset count mismatch for %s: local=%d, upstream=%d", deviceID, len(localPresets), len(dev.Presets)) + } + + // Compare presets by button number + for i := range dev.Presets { + up := &dev.Presets[i] + + var found bool + + for j := range localPresets { + lp := &localPresets[j] + if lp.ButtonNumber == up.ButtonNumber { + found = true + + if lp.Location != up.Location { + log.Printf("[SYNC_DIFF] Preset %s location mismatch for %s: local=%s, upstream=%s", up.ButtonNumber, deviceID, lp.Location, up.Location) + } + + break + } + } + + if !found { + log.Printf("[SYNC_DIFF] Preset %s missing locally for %s", up.ButtonNumber, deviceID) + } + } + } +} diff --git a/pkg/service/marge/sync_test.go b/pkg/service/marge/sync_test.go new file mode 100644 index 0000000..8b7cfff --- /dev/null +++ b/pkg/service/marge/sync_test.go @@ -0,0 +1,123 @@ +package marge + +import ( + "encoding/xml" + "os" + "testing" + + "github.com/gesellix/bose-soundtouch/pkg/models" + "github.com/gesellix/bose-soundtouch/pkg/service/datastore" +) + +func TestSyncFromAccountFull(t *testing.T) { + // Setup a temporary datastore + tmpDir, err := os.MkdirTemp("", "datastore_test") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + ds := datastore.NewDataStore(tmpDir) + + // Mock AccountFullResponse + xmlData := ` + + ACTIVE + + + Living Room + 192.168.1.10 + ABC123XYZ + 27.0.6 + + ABC123XYZ + + + + My Station + tunein://station/s123 + station + + TuneIn + TuneIn + + + + + + Last Song + spotify:track:abc + track + + Spotify + Spotify + + + + + + + + TuneIn + TuneIn + + +` + + var resp models.AccountFullResponse + if err := xml.Unmarshal([]byte(xmlData), &resp); err != nil { + t.Fatalf("Failed to unmarshal mock data: %v", err) + } + + // Run Sync + if err := SyncFromAccountFull(ds, &resp); err != nil { + t.Fatalf("SyncFromAccountFull failed: %v", err) + } + + // Verify Device Info + info, err := ds.GetDeviceInfo("USER_123", "DEVICE_ABC") + if err != nil { + t.Errorf("Failed to get device info: %v", err) + } + if info.Name != "Living Room" { + t.Errorf("Expected name 'Living Room', got '%s'", info.Name) + } + // Note: ProductCode might be concatenated with a space in some implementations or models + if info.ProductCode != "ST10" && info.ProductCode != "ST10 " { + t.Errorf("Expected product code 'ST10', got '%s'", info.ProductCode) + } + + // Verify Presets + presets, err := ds.GetPresets("USER_123", "DEVICE_ABC") + if err != nil { + t.Errorf("Failed to get presets: %v", err) + } + if len(presets) != 1 { + t.Errorf("Expected 1 preset, got %d", len(presets)) + } else { + // Datastore's ServicePreset might not use ButtonNumber field in its XML structure, + // but rather relies on order or an 'id' attribute. + // Let's check the name which we know was set. + if presets[0].Name != "My Station" { + t.Errorf("Expected preset name 'My Station', got '%s'", presets[0].Name) + } + } + + // Verify Recents + recents, err := ds.GetRecents("USER_123", "DEVICE_ABC") + if err != nil { + t.Errorf("Failed to get recents: %v", err) + } + if len(recents) != 1 { + t.Errorf("Expected 1 recent, got %d", len(recents)) + } + + // Verify Sources + sources, err := ds.GetConfiguredSources("USER_123", "DEVICE_ABC") + if err != nil { + t.Errorf("Failed to get sources: %v", err) + } + if len(sources) != 1 { + t.Errorf("Expected 1 source, got %d", len(sources)) + } +} diff --git a/pkg/service/migration/migration.go b/pkg/service/migration/migration.go deleted file mode 100644 index 68d6fb5..0000000 --- a/pkg/service/migration/migration.go +++ /dev/null @@ -1,197 +0,0 @@ -// Package migration provides device directory migration functionality. -// This package is designed to be easily removable in future releases once -// all devices have been migrated from serial-based to MAC-based directory structures. -// -// TODO: Remove this package after 3-4 releases when most devices are migrated. -package migration - -import ( - "log" - "os" - "path/filepath" - - "github.com/gesellix/bose-soundtouch/pkg/models" - "github.com/gesellix/bose-soundtouch/pkg/service/datastore" -) - -// Config holds migration configuration -type Config struct { - // Enabled controls whether migration is active - Enabled bool - // DryRun logs what would be migrated without actually doing it - DryRun bool -} - -// Manager handles device directory migrations -type Manager struct { - datastore *datastore.DataStore - config Config -} - -// NewManager creates a new migration manager -func NewManager(ds *datastore.DataStore, config Config) *Manager { - return &Manager{ - datastore: ds, - config: config, - } -} - -// MigrateDevicesIfNeeded checks discovered devices and migrates any that need it -func (m *Manager) MigrateDevicesIfNeeded(existingDevices []models.ServiceDeviceInfo, targetDeviceID string) bool { - if !m.config.Enabled { - return false - } - - migrated := false - - for i := range existingDevices { - existing := &existingDevices[i] - if existing.DeviceID != targetDeviceID { - if m.config.DryRun { - log.Printf("[MIGRATION DRY-RUN] Would migrate device directory: %s -> %s", existing.DeviceID, targetDeviceID) - } else { - log.Printf("[MIGRATION] Migrating device directory: %s -> %s", existing.DeviceID, targetDeviceID) - - if m.migrateDeviceDirectory(existing.AccountID, existing.DeviceID, targetDeviceID) { - migrated = true - } - } - } - } - - return migrated -} - -// migrateDeviceDirectory renames device directory from old ID to new ID -func (m *Manager) migrateDeviceDirectory(accountID, oldDeviceID, newDeviceID string) bool { - // Use direct paths for migration - don't resolve through mappings - // because mappings might point new ID back to old directory during migration - accountDevicesDir := m.datastore.AccountDevicesDir(accountID) - oldDir := filepath.Join(accountDevicesDir, oldDeviceID) - newDir := filepath.Join(accountDevicesDir, newDeviceID) - - // Log directory contents before migration - m.logDirectoryContents("Source directory", oldDir) - - // Check if old directory exists - if _, err := os.Stat(oldDir); os.IsNotExist(err) { - log.Printf("[MIGRATION] Source directory %s does not exist, nothing to migrate", oldDir) - return false - } - - // Check if new directory already exists - if _, err := os.Stat(newDir); err == nil { - log.Printf("[MIGRATION] Target directory %s already exists, removing it first", newDir) - - if removeErr := os.RemoveAll(newDir); removeErr != nil { - log.Printf("[MIGRATION ERROR] Failed to remove existing target directory: %v", removeErr) - return false - } - } - - // Ensure parent directory exists - parentDir := filepath.Dir(newDir) - if err := os.MkdirAll(parentDir, 0755); err != nil { - log.Printf("[MIGRATION ERROR] Failed to create parent directory %s: %v", parentDir, err) - return false - } - - // Rename the entire directory - if err := os.Rename(oldDir, newDir); err != nil { - log.Printf("[MIGRATION ERROR] Failed to rename directory from %s to %s: %v", oldDir, newDir, err) - return false - } - - log.Printf("[MIGRATION SUCCESS] Migrated device directory: %s -> %s", oldDeviceID, newDeviceID) - m.logDirectoryContents("Migrated directory", newDir) - - return true -} - -// logDirectoryContents logs the contents of a directory for debugging -func (m *Manager) logDirectoryContents(label, dirPath string) { - entries, err := os.ReadDir(dirPath) - if err != nil { - log.Printf("[MIGRATION] %s (%s): Error reading - %v", label, dirPath, err) - return - } - - log.Printf("[MIGRATION] %s (%s): %d files", label, dirPath, len(entries)) - - for _, entry := range entries { - if !entry.IsDir() { - info, err := entry.Info() - if err == nil { - log.Printf("[MIGRATION] - %s (%d bytes)", entry.Name(), info.Size()) - } else { - log.Printf("[MIGRATION] - %s (size unknown)", entry.Name()) - } - } - } -} - -// GetStats returns migration statistics -func (m *Manager) GetStats() Stats { - // This could be extended to track migration metrics - return Stats{ - Enabled: m.config.Enabled, - DryRun: m.config.DryRun, - } -} - -// Stats holds migration statistics -type Stats struct { - Enabled bool - DryRun bool -} - -// IsLegacyDeviceID checks if a device ID appears to be legacy (non-MAC format) -func IsLegacyDeviceID(deviceID string) bool { - // Serial numbers typically start with I or K and are long - if len(deviceID) > 15 && (deviceID[0] == 'I' || deviceID[0] == 'K') { - return true - } - - // IP addresses - if isIPAddress(deviceID) { - return true - } - - // Assume MAC addresses are 12 hex characters - if len(deviceID) == 12 && isHexString(deviceID) { - return false // This is likely a MAC address - } - - // Other formats are considered legacy - return true -} - -// isIPAddress checks if a string looks like an IP address -func isIPAddress(s string) bool { - if len(s) < 7 || len(s) > 15 { - return false - } - - dotCount := 0 - - for _, c := range s { - if c == '.' { - dotCount++ - } else if c < '0' || c > '9' { - return false - } - } - - return dotCount == 3 -} - -// isHexString checks if a string contains only hexadecimal characters -func isHexString(s string) bool { - for _, c := range s { - if (c < '0' || c > '9') && (c < 'A' || c > 'F') && (c < 'a' || c > 'f') { - return false - } - } - - return true -} diff --git a/pkg/service/migration/migration_test.go b/pkg/service/migration/migration_test.go deleted file mode 100644 index 2fc4bcf..0000000 --- a/pkg/service/migration/migration_test.go +++ /dev/null @@ -1,460 +0,0 @@ -package migration - -import ( - "os" - "path/filepath" - "testing" - - "github.com/gesellix/bose-soundtouch/pkg/models" - "github.com/gesellix/bose-soundtouch/pkg/service/datastore" -) - -func TestMigration_FilePreservation(t *testing.T) { - tempDir, err := os.MkdirTemp("", "migration-file-preservation-*") - if err != nil { - t.Fatal(err) - } - defer os.RemoveAll(tempDir) - - ds := datastore.NewDataStore(tempDir) - manager := NewManager(ds, Config{Enabled: true, DryRun: false}) - - accountID := "test-account" - oldDeviceID := "I6332527703739342000020" // Legacy serial number - newDeviceID := "A81B6A536A98" // MAC address - - // 1. Create old device directory with multiple files - oldDir := ds.AccountDeviceDir(accountID, oldDeviceID) - if err := os.MkdirAll(oldDir, 0755); err != nil { - t.Fatal(err) - } - - // Create DeviceInfo.xml with serial number as deviceID (legacy format) - deviceInfoXML := ` - - Sound Speaker Legacy - SoundTouch 10 - sm2 - - - SCM - 3.4.6.2356 - I6332527703739342000020 - - - PackagedProduct - 069231P63364828AE - - - - A81B6A536A98 - 192.168.1.100 - -` - - if err := os.WriteFile(filepath.Join(oldDir, "DeviceInfo.xml"), []byte(deviceInfoXML), 0644); err != nil { - t.Fatal(err) - } - - // Create Presets.xml - presetsXML := ` - - - - Test Song - - -` - - if err := os.WriteFile(filepath.Join(oldDir, "Presets.xml"), []byte(presetsXML), 0644); err != nil { - t.Fatal(err) - } - - // Create Sources.xml - sourcesXML := ` - - - user@example.com - -` - - if err := os.WriteFile(filepath.Join(oldDir, "Sources.xml"), []byte(sourcesXML), 0644); err != nil { - t.Fatal(err) - } - - // Create Recents.xml - recentsXML := ` - - - - BBC Radio 1 - - -` - - if err := os.WriteFile(filepath.Join(oldDir, "Recents.xml"), []byte(recentsXML), 0644); err != nil { - t.Fatal(err) - } - - // 2. Verify old directory has all files - oldEntries, err := os.ReadDir(oldDir) - if err != nil { - t.Fatal(err) - } - if len(oldEntries) != 4 { - t.Fatalf("Expected 4 files in old directory, got %d", len(oldEntries)) - } - - t.Logf("Before migration - Old directory (%s) contains %d files:", oldDeviceID, len(oldEntries)) - for _, entry := range oldEntries { - t.Logf(" - %s", entry.Name()) - } - - // 3. Create device info for migration - existingDevice := models.ServiceDeviceInfo{ - DeviceID: oldDeviceID, - AccountID: accountID, - Name: "Sound Speaker Legacy", - IPAddress: "192.168.1.100", - } - - // 4. Perform migration - migrated := manager.MigrateDevicesIfNeeded([]models.ServiceDeviceInfo{existingDevice}, newDeviceID) - if !migrated { - t.Fatal("Migration should have occurred") - } - - // 5. Verify old directory no longer exists - if _, err := os.Stat(oldDir); !os.IsNotExist(err) { - t.Error("Old directory should not exist after migration") - } - - // 6. Verify new directory exists with all files preserved - newDir := ds.AccountDeviceDir(accountID, newDeviceID) - newEntries, err := os.ReadDir(newDir) - if err != nil { - t.Fatal(err) - } - - if len(newEntries) != 4 { - t.Fatalf("Expected 4 files in new directory after migration, got %d", len(newEntries)) - } - - t.Logf("After migration - New directory (%s) contains %d files:", newDeviceID, len(newEntries)) - for _, entry := range newEntries { - t.Logf(" - %s", entry.Name()) - } - - // 7. Verify each file exists and has content - expectedFiles := []string{"DeviceInfo.xml", "Presets.xml", "Sources.xml", "Recents.xml"} - for _, filename := range expectedFiles { - filePath := filepath.Join(newDir, filename) - data, err := os.ReadFile(filePath) - if err != nil { - t.Errorf("File %s should exist after migration: %v", filename, err) - continue - } - if len(data) == 0 { - t.Errorf("File %s should not be empty after migration", filename) - } - t.Logf(" ✓ %s preserved (%d bytes)", filename, len(data)) - } - - // 8. Verify specific content preservation - // Presets should still contain the test song - presetsData, _ := os.ReadFile(filepath.Join(newDir, "Presets.xml")) - if !containsString(string(presetsData), "Test Song") { - t.Error("Presets.xml should preserve original content") - } - - // Sources should still contain the Spotify account - sourcesData, _ := os.ReadFile(filepath.Join(newDir, "Sources.xml")) - if !containsString(string(sourcesData), "user@example.com") { - t.Error("Sources.xml should preserve original content") - } - - // Recents should still contain the radio station - recentsData, _ := os.ReadFile(filepath.Join(newDir, "Recents.xml")) - if !containsString(string(recentsData), "BBC Radio 1") { - t.Error("Recents.xml should preserve original content") - } - - t.Log("✅ Migration successfully preserved all files with their original content") -} - -func TestMigration_DryRun(t *testing.T) { - tempDir, err := os.MkdirTemp("", "migration-dry-run-*") - if err != nil { - t.Fatal(err) - } - defer os.RemoveAll(tempDir) - - ds := datastore.NewDataStore(tempDir) - manager := NewManager(ds, Config{Enabled: true, DryRun: true}) // DRY RUN MODE - - accountID := "test-account" - oldDeviceID := "I6332527703739342000020" - newDeviceID := "A81B6A536A98" - - // Create old directory with files - oldDir := ds.AccountDeviceDir(accountID, oldDeviceID) - if err := os.MkdirAll(oldDir, 0755); err != nil { - t.Fatal(err) - } - - deviceInfoXML := ` - - Test Device -` - - if err := os.WriteFile(filepath.Join(oldDir, "DeviceInfo.xml"), []byte(deviceInfoXML), 0644); err != nil { - t.Fatal(err) - } - - // Create device info for migration - existingDevice := models.ServiceDeviceInfo{ - DeviceID: oldDeviceID, - AccountID: accountID, - } - - // Perform dry-run migration - migrated := manager.MigrateDevicesIfNeeded([]models.ServiceDeviceInfo{existingDevice}, newDeviceID) - if migrated { - t.Error("Dry run should not report actual migration") - } - - // Verify old directory still exists (no actual migration) - if _, err := os.Stat(oldDir); os.IsNotExist(err) { - t.Error("Old directory should still exist after dry run") - } - - // Verify new directory does not exist - newDir := ds.AccountDeviceDir(accountID, newDeviceID) - if _, err := os.Stat(newDir); !os.IsNotExist(err) { - t.Error("New directory should not exist after dry run") - } - - t.Log("✅ Dry run mode correctly simulated migration without making changes") -} - -func TestMigration_Disabled(t *testing.T) { - tempDir, err := os.MkdirTemp("", "migration-disabled-*") - if err != nil { - t.Fatal(err) - } - defer os.RemoveAll(tempDir) - - ds := datastore.NewDataStore(tempDir) - manager := NewManager(ds, Config{Enabled: false, DryRun: false}) // MIGRATION DISABLED - - accountID := "test-account" - oldDeviceID := "I6332527703739342000020" - newDeviceID := "A81B6A536A98" - - // Create device info for migration - existingDevice := models.ServiceDeviceInfo{ - DeviceID: oldDeviceID, - AccountID: accountID, - } - - // Attempt migration - migrated := manager.MigrateDevicesIfNeeded([]models.ServiceDeviceInfo{existingDevice}, newDeviceID) - if migrated { - t.Error("Migration should not occur when disabled") - } - - t.Log("✅ Migration correctly disabled") -} - -// Helper function to check if a string contains a substring -func containsString(s, substr string) bool { - return len(s) >= len(substr) && (s == substr || containsSubstring(s, substr)) -} - -func containsSubstring(s, substr string) bool { - if len(substr) == 0 { - return true - } - if len(s) < len(substr) { - return false - } - for i := 0; i <= len(s)-len(substr); i++ { - if s[i:i+len(substr)] == substr { - return true - } - } - return false -} - -func TestMigration_CompleteFlowWithDeviceInfoUpdate(t *testing.T) { - tempDir, err := os.MkdirTemp("", "migration-complete-flow-*") - if err != nil { - t.Fatal(err) - } - defer os.RemoveAll(tempDir) - - ds := datastore.NewDataStore(tempDir) - manager := NewManager(ds, Config{Enabled: true, DryRun: false}) - - accountID := "test-account" - oldDeviceID := "I6332527703739342000020" // Legacy serial number - newDeviceID := "A81B6A536A98" // MAC address - - // 1. Create old device directory with legacy DeviceInfo.xml (deviceID=serial) - oldDir := ds.AccountDeviceDir(accountID, oldDeviceID) - if err := os.MkdirAll(oldDir, 0755); err != nil { - t.Fatal(err) - } - - legacyDeviceInfoXML := ` - - Sound Speaker Legacy - SoundTouch 10 - sm2 - - - SCM - 3.4.6.2356 - I6332527703739342000020 - - - - A81B6A536A98 - 192.168.1.100 - -` - - if err := os.WriteFile(filepath.Join(oldDir, "DeviceInfo.xml"), []byte(legacyDeviceInfoXML), 0644); err != nil { - t.Fatal(err) - } - - // Create Presets.xml to verify preservation - presetsXML := ` - - - - My Favorite Song - - -` - - if err := os.WriteFile(filepath.Join(oldDir, "Presets.xml"), []byte(presetsXML), 0644); err != nil { - t.Fatal(err) - } - - t.Log("Step 1: Legacy directory created with deviceID=serial in DeviceInfo.xml") - - // 2. Perform migration - existingDevice := models.ServiceDeviceInfo{ - DeviceID: oldDeviceID, - AccountID: accountID, - Name: "Sound Speaker Legacy", - IPAddress: "192.168.1.100", - } - - migrated := manager.MigrateDevicesIfNeeded([]models.ServiceDeviceInfo{existingDevice}, newDeviceID) - if !migrated { - t.Fatal("Migration should have occurred") - } - - t.Log("Step 2: Migration completed - directory renamed, all files preserved") - - // 3. Verify migration moved files but preserved content - newDir := ds.AccountDeviceDir(accountID, newDeviceID) - - // Check that Presets.xml was preserved - preservedPresetsData, err := os.ReadFile(filepath.Join(newDir, "Presets.xml")) - if err != nil { - t.Fatalf("Presets.xml should be preserved after migration: %v", err) - } - if !containsString(string(preservedPresetsData), "My Favorite Song") { - t.Error("Presets.xml content should be preserved") - } - - t.Log("Step 3: Verified Presets.xml preserved during migration") - - // 4. Simulate SaveDeviceInfo with fresh /info data (like real discovery) - // This should overwrite DeviceInfo.xml with correct MAC-based deviceID - freshDeviceInfo := &models.ServiceDeviceInfo{ - DeviceID: newDeviceID, // MAC address as deviceID - AccountID: accountID, - Name: "Sound Machinechen", // Fresh name from /info - IPAddress: "192.168.1.100", // Fresh IP - MacAddress: newDeviceID, - DeviceSerialNumber: oldDeviceID, // Serial goes in component - ProductCode: "SoundTouch 10 sm2", // Fresh product info - FirmwareVersion: "27.0.6.46330.5043500", - ProductSerialNumber: "069231P63364828AE", - DiscoveryMethod: "Test Discovery", - } - - if err := ds.SaveDeviceInfo(accountID, newDeviceID, freshDeviceInfo); err != nil { - t.Fatalf("Failed to save fresh device info: %v", err) - } - - t.Log("Step 4: SaveDeviceInfo called with fresh /info data") - - // 5. Verify DeviceInfo.xml now has correct MAC-based deviceID - updatedDeviceInfoData, err := os.ReadFile(filepath.Join(newDir, "DeviceInfo.xml")) - if err != nil { - t.Fatalf("DeviceInfo.xml should exist after SaveDeviceInfo: %v", err) - } - - updatedXML := string(updatedDeviceInfoData) - - // Should contain deviceID="A81B6A536A98" (MAC address) - if !containsString(updatedXML, `deviceID="A81B6A536A98"`) { - t.Errorf("DeviceInfo.xml should have deviceID set to MAC address, content:\n%s", updatedXML) - } - - // Should contain fresh device name from /info - if !containsString(updatedXML, "Sound Machinechen") { - t.Errorf("DeviceInfo.xml should have fresh device name from /info") - } - - // Should contain serial number in component (not as deviceID) - if !containsString(updatedXML, "I6332527703739342000020") { - t.Errorf("DeviceInfo.xml should still contain serial number in component") - } - - // Should contain product serial in component - if !containsString(updatedXML, "069231P63364828AE") { - t.Errorf("DeviceInfo.xml should contain product serial in component") - } - - t.Log("Step 5: Verified DeviceInfo.xml has correct MAC-based deviceID attribute") - - // 6. Verify Presets.xml still exists and wasn't overwritten - finalPresetsData, err := os.ReadFile(filepath.Join(newDir, "Presets.xml")) - if err != nil { - t.Fatalf("Presets.xml should still exist after SaveDeviceInfo: %v", err) - } - if !containsString(string(finalPresetsData), "My Favorite Song") { - t.Error("Presets.xml should not be overwritten by SaveDeviceInfo") - } - - t.Log("Step 6: Verified Presets.xml was not overwritten by SaveDeviceInfo") - - // 7. Verify data is accessible via MAC address - retrievedInfo, err := ds.GetDeviceInfo(accountID, newDeviceID) - if err != nil { - t.Fatalf("Should be able to retrieve device info by MAC address: %v", err) - } - - if retrievedInfo.DeviceID != newDeviceID { - t.Errorf("Retrieved device info should have MAC-based deviceID, got %s", retrievedInfo.DeviceID) - } - - if retrievedInfo.Name != "Sound Machinechen" { - t.Errorf("Retrieved device info should have fresh name, got %s", retrievedInfo.Name) - } - - t.Log("Step 7: Verified device info retrieval works with MAC address") - - t.Log("✅ Complete migration flow verified:") - t.Log(" 1. Migration preserves all files (Presets.xml, Sources.xml, etc.)") - t.Log(" 2. SaveDeviceInfo updates DeviceInfo.xml with correct deviceID=MAC") - t.Log(" 3. Serial number preserved in component, not as deviceID") - t.Log(" 4. Fresh /info data properly integrated") - t.Log(" 5. User data (presets, etc.) completely preserved") -} diff --git a/pkg/service/proxy/recorder.go b/pkg/service/proxy/recorder.go index b557825..de75434 100644 --- a/pkg/service/proxy/recorder.go +++ b/pkg/service/proxy/recorder.go @@ -78,7 +78,7 @@ func NewRecorder(baseDir string) *Recorder { r.queue = make(chan recordingTask, 100) go r.worker() } else { - log.Println("[DEBUG_LOG] Recorder starting in synchronous mode") + log.Println("Recorder starting in synchronous mode") } return r