replace copy-and-delete migration with atomic MoveDevice

This commit is contained in:
Marcin Mennemann
2026-05-24 09:52:02 +02:00
committed by Tobias Gesellchen
parent 7a268a0372
commit 65b142881a
3 changed files with 59 additions and 11 deletions
+18
View File
@@ -1647,6 +1647,24 @@ func (ds *DataStore) RemoveDeviceDir(account, device string) error {
return ds.RemoveDevice(account, device)
}
// MoveDevice atomically moves a device directory from one account to another
// on the same filesystem. If the target account directory doesn't exist it is
// created. Returns an error if the rename fails (leaving the source intact).
func (ds *DataStore) MoveDevice(oldAccount, newAccount, deviceID string) error {
ds.fileMutex.Lock()
defer ds.fileMutex.Unlock()
oldDir := ds.AccountDeviceDir(oldAccount, deviceID)
newDir := ds.AccountDeviceDir(newAccount, deviceID)
newAccountDir := filepath.Dir(newDir)
if err := ds.rootMkdirAll(newAccountDir, 0755); err != nil {
return err
}
return ds.rootRename(oldDir, newDir)
}
// DeduceSourceIDs updates the source IDs in the given slice by deducing them from recents and presets.
func (ds *DataStore) DeduceSourceIDs(account, device string, sources []models.ConfiguredSource) {
// Deduce source IDs from recents and presets
+37
View File
@@ -457,3 +457,40 @@ func TestSettingsPersistence(t *testing.T) {
t.Errorf("Expected DiscoveryEnabled %v, got %v", settings.DiscoveryEnabled, loaded.DiscoveryEnabled)
}
}
func TestMoveDeviceMigratesData(t *testing.T) {
tempDir := t.TempDir()
ds := NewDataStore(tempDir)
oldAccount := "default"
newAccount := "8637922"
deviceID := "F4E11E930BEB"
// Seed the device under the old account with presets
presets := []models.ServicePreset{
{ID: "1", ServiceContentItem: models.ServiceContentItem{Name: "Radio Preset", ContentItemType: "stationurl"}},
}
if err := ds.SavePresets(oldAccount, deviceID, presets); err != nil {
t.Fatalf("SavePresets under %s: %v", oldAccount, err)
}
// Move the device to the new account
if err := ds.MoveDevice(oldAccount, newAccount, deviceID); err != nil {
t.Fatalf("MoveDevice %s → %s: %v", oldAccount, newAccount, err)
}
// Verify presets survived the move under the new account
moved, err := ds.GetPresets(newAccount, deviceID)
if err != nil {
t.Fatalf("GetPresets under %s after move: %v", newAccount, err)
}
if len(moved) != 1 || moved[0].Name != "Radio Preset" {
t.Errorf("presets not preserved: got %+v", moved)
}
// Verify the old account no longer has the device
oldPresets, err := ds.GetPresets(oldAccount, deviceID)
if err == nil && len(oldPresets) > 0 {
t.Errorf("old account %s still has presets after move: %+v", oldAccount, oldPresets)
}
}
+4 -11
View File
@@ -1058,19 +1058,12 @@ func (s *Server) handleDiscoveredDevice(d models.DiscoveredDevice) {
}
// If the speaker reports a paired account that differs from the stored
// location, clean up the stale entry so ListAllDevices doesn't return duplicates.
// location, migrate the device directory so ListAllDevices doesn't return duplicates.
if liveInfo.MargeAccountUUID != "" && storedAccount != "" && liveInfo.MargeAccountUUID != storedAccount {
// Preserve presets, recents and sources before cleaning up
if presets, err := s.ds.GetPresets(storedAccount, deviceID); err == nil && len(presets) > 0 {
_ = s.ds.SavePresets(liveInfo.MargeAccountUUID, deviceID, presets)
if err := s.ds.MoveDevice(storedAccount, accountID, deviceID); err != nil {
log.Printf("Failed to migrate device %s from %s to %s: %v",
deviceID, storedAccount, accountID, err)
}
if recents, err := s.ds.GetRecents(storedAccount, deviceID); err == nil && len(recents) > 0 {
_ = s.ds.SaveRecents(liveInfo.MargeAccountUUID, deviceID, recents)
}
if sources, err := s.ds.GetConfiguredSources(storedAccount, deviceID); err == nil && len(sources) > 0 {
_ = s.ds.SaveConfiguredSources(liveInfo.MargeAccountUUID, deviceID, sources)
}
s.ds.RemoveDevice(storedAccount, deviceID)
}
// 4. Get primary MAC address from networkInfo