mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-19 00:56:16 +00:00
Root cause of #334's INVALID_SOURCE: a speaker reports device-local slots (STORED_MUSIC_MEDIA_RENDERER, UPNP) in /sources; AfterTouch imports them verbatim and re-serves them in /full. PrepareConfiguredSource fills sourceproviderid only for types in constants.StaticProviders, so these go out with an empty <sourceproviderid> — a required protobuf field — and the speaker rejects them as INVALID_SOURCE, which then re-syncs back into the datastore. Fix, keyed on the principle (no hardcoded denylist in production): - HasResolvableProviderID(s): true if the source already carries a provider id, or its source-key type resolves via StaticProviders. - Serve-side guard in getAccountSources: drop any source whose resolved sourceproviderid is still empty (generalises the existing AUX/#195 skip). Heals already-polluted datastores on the next /full, no resync needed. - Import-side filter in syncConfiguredSources (marge) and both branches of syncSources (setup): drop unresolvable sources before persisting, stopping future pollution and the re-import loop. Tests: reproduction converted to regression test (TestI334FullOmitsSourcesWithoutProviderID) seeded from a sanitised real #334 /sources capture; explicit servable/non-servable tables in TestHasResolvableProviderID. Two pre-existing fixtures that relied on sources with no provider id were given valid ones. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
346 lines
11 KiB
Go
346 lines
11 KiB
Go
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 <sourceproviderid>, 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 <source type="Audio"> 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))
|
|
}
|
|
}
|
|
}
|
|
}
|