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", sanitizeLog(accountID)) // 0. Update Account Metadata syncAccountInfo(ds, accountID, resp) for i := range resp.Devices { dev := &resp.Devices[i] deviceID := dev.DeviceID if deviceID == "" { continue } log.Printf("[SYNC] Synchronizing device %s (Account: %s)", sanitizeLog(deviceID), sanitizeLog(accountID)) // 1. Update Device Info syncDeviceInfo(ds, accountID, dev) // 2. Update Presets syncPresets(ds, accountID, deviceID, dev.Presets) // 3. Update Recents syncRecents(ds, accountID, deviceID, dev.Recents) // 4. Update Configured Sources for this device (requires presets and recents to be on disk for deduction) syncConfiguredSources(ds, accountID, deviceID, resp.Sources, dev) } log.Printf("[SYNC] Synchronization completed for account %s", sanitizeLog(accountID)) return nil } func syncAccountInfo(ds *datastore.DataStore, accountID string, resp *models.AccountFullResponse) { info := &models.ServiceAccountInfo{ AccountID: accountID, PreferredLanguage: resp.PreferredLanguage, ProviderSettings: resp.ProviderSettings, } if err := ds.SaveAccountInfo(accountID, info); err != nil { log.Printf("[SYNC_ERR] Failed to save account info for %s: %v", sanitizeLog(accountID), err) } } 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 for _, comp := range dev.AttachedProduct.Components { info.Components = append(info.Components, models.ServiceComponent{ Category: comp.Category, SoftwareVersion: comp.SoftwareVersion, SerialNumber: comp.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", sanitizeLog(info.Name), sanitizeLog(deviceID)) } else { log.Printf("[SYNC_DEBUG] Name is empty for device %s in upstream and no local name found", sanitizeLog(deviceID)) } } else { log.Printf("[SYNC_DEBUG] Upstream name for device %s is '%s'", sanitizeLog(deviceID), sanitizeLog(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", sanitizeLog(info.Name), sanitizeLog(deviceID)) break } } } if err := ds.SaveDeviceInfo(accountID, deviceID, info); err != nil { log.Printf("[SYNC_ERR] Failed to save device info for %s: %v", sanitizeLog(deviceID), err) } } func syncConfiguredSources(ds *datastore.DataStore, accountID, deviceID string, sources []models.FullResponseSource, dev *models.AccountDevice) { // We'll use the account-level sources from the response as a base. var deviceSources []models.ConfiguredSource // Track seen sources to avoid duplicates seen := make(map[string]bool) // 1. Add sources from the account-level sources list for i := range sources { s := &sources[i] if s.ID != "" && seen[s.ID] { continue } dsrc := mapFullSourceToConfiguredSource(*s) deviceSources = append(deviceSources, dsrc) if s.ID != "" { seen[s.ID] = true } } // 2. Add sources from presets if they are not already in the list for i := range dev.Presets { p := &dev.Presets[i] if p.Source.ID != "" && !seen[p.Source.ID] { dsrc := mapFullSourceToConfiguredSource(p.Source) deviceSources = append(deviceSources, dsrc) seen[p.Source.ID] = true } } // 3. Add sources from recents if they are not already in the list for i := range dev.Recents { r := &dev.Recents[i] if r.Source.ID != "" && !seen[r.Source.ID] { dsrc := mapFullSourceToConfiguredSource(r.Source) deviceSources = append(deviceSources, dsrc) seen[r.Source.ID] = true } } // 4. Add deduction based on local presets/recents ds.DeduceSourceIDs(accountID, deviceID, deviceSources) // 5. Drop sources that cannot be assigned a canonical sourceproviderid. // Device-local / transient slots (STORED_MUSIC_MEDIA_RENDERER, UPNP, // INVALID_SOURCE, ...) have no StaticProviders entry; persisting them // causes the serve path to emit an empty , which the // speaker rejects and re-reports as INVALID_SOURCE — the #334 loop. var servable []models.ConfiguredSource var dropped []string for i := range deviceSources { if HasResolvableProviderID(deviceSources[i]) { servable = append(servable, deviceSources[i]) } else { dropped = append(dropped, sanitizeLog(deviceSources[i].SourceKeyType)) } } if len(dropped) > 0 { log.Printf("[SYNC] device %s: dropping %d unresolvable source(s) before save (types: %v)", sanitizeLog(deviceID), len(dropped), dropped) } if err := ds.SaveConfiguredSources(accountID, deviceID, servable); err != nil { log.Printf("[SYNC_ERR] Failed to save sources for %s: %v", sanitizeLog(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{ ID: p.ButtonNumber, ContentItemType: p.ContentItemType, Location: p.Location, Name: p.Name, Source: sourceKeyTypeFromFullSource(p.Source), SourceID: p.Source.ID, SourceAccount: p.Source.Username, Type: p.ContentItemType, }, ButtonNumber: p.ButtonNumber, ID: p.ButtonNumber, CreatedOn: p.CreatedOn, UpdatedOn: p.UpdatedOn, ContainerArt: p.ContainerArt, } presets = append(presets, preset) } if err := ds.SavePresets(accountID, deviceID, presets); err != nil { log.Printf("[SYNC_ERR] Failed to save presets for %s: %v", sanitizeLog(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: sourceKeyTypeFromFullSource(r.Source), SourceID: r.Source.ID, SourceAccount: r.Source.Username, Type: r.ContentItemType, }, CreatedOn: r.CreatedOn, UpdatedOn: r.UpdatedOn, LastPlayedAt: r.LastPlayedAt, } recents = append(recents, recent) } if err := ds.SaveRecents(accountID, deviceID, recents); err != nil { log.Printf("[SYNC_ERR] Failed to save recents for %s: %v", sanitizeLog(deviceID), err) } } // sourceKeyTypeFromFullSource derives the speaker-perspective SourceKeyType // ("TUNEIN", "INTERNET_RADIO", …) from an upstream FullResponseSource. // The upstream attribute is a protocol-level // classification, not the symbolic name the speaker stores locally — // persisting "Audio" into ServicePreset.Source means the on-disk shape // doesn't match what the speaker would write via its own /presets // (which is the source of truth). Falls back to whatever upstream gave // us when the providerid isn't one of the canonical built-ins, with a // log line so the fallback is visible to operators rather than silently // re-introducing the same protocol-level leak we just fixed. func sourceKeyTypeFromFullSource(s models.FullResponseSource) string { if t := canonicalSourceKeyTypeByProviderID(s.SourceProviderID); t != "" { return t } log.Printf("[SYNC] sourceKeyTypeFromFullSource: no canonical SourceKeyType for providerid=%q (source id=%q name=%q) — persisting upstream Type=%q verbatim; downstream consumers will read repairLeakedSource fallback or display %q as-is", sanitizeLog(s.SourceProviderID), sanitizeLog(s.ID), sanitizeLog(s.Name), sanitizeLog(s.Type), sanitizeLog(s.Type)) return s.Type } 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.DisplayName, Name: s.Name, SourceProviderID: s.SourceProviderID, Secret: s.Credential.Value, SecretType: s.Credential.Type, Username: s.Username, SourceSettings: s.SourceSettings, } if dsrc.DisplayName == "" { dsrc.DisplayName = s.Name } if dsrc.Name == "" { dsrc.Name = s.DisplayName } 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", sanitizeLog(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", sanitizeLog(up.ButtonNumber), sanitizeLog(deviceID), sanitizeLog(lp.Location), sanitizeLog(up.Location)) } break } } if !found { log.Printf("[SYNC_DIFF] Preset %s missing locally for %s", sanitizeLog(up.ButtonNumber), sanitizeLog(deviceID)) } } } }