fix(setup): explain presets the rendered account drops on purpose

mapPresetsToFullResponse omits a preset whose source is absent from the
account's configured sources and cannot be synthesised. The readiness check
then found the speaker holding a slot /full does not, refused migration, and
attached its default action: "Run Data Sync for this device and retry
migration".

Syncing cannot add a missing music service source, so the user looped with no
override and no path forward.

compareMigrationPresets now reports whether the speaker's own view holds a
slot the rendered account lacks, which is the signature of that deliberate
omission, and that case gets an action naming the real remedy: re-link or
repopulate the source. Every other mismatch keeps the sync advice, which is
still right for a stale snapshot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-09-05 21:55:20 +02:00
co-authored by Claude Opus 5
parent c2591f7aa5
commit a3566f6691
2 changed files with 70 additions and 11 deletions
+42 -11
View File
@@ -128,12 +128,12 @@ func (m *Manager) checkMigrationDataReady(deviceIP string) ([]string, error) {
persisted := migrationPresetIdentities(snapshot.Presets)
live := migrationPresetIdentities(livePresets)
if mismatch := compareMigrationPresets("persisted snapshot", persisted, "rendered /full", fullPresets); mismatch != "" {
return nil, migrationDataNotReadyf("%s", mismatch)
if mismatch := compareMigrationPresets("persisted snapshot", persisted, "rendered /full", fullPresets); mismatch != nil {
return nil, mismatch.err()
}
if mismatch := compareMigrationPresets("live /presets", live, "rendered /full", fullPresets); mismatch != "" {
return nil, migrationDataNotReadyf("%s", mismatch)
if mismatch := compareMigrationPresets("live /presets", live, "rendered /full", fullPresets); mismatch != nil {
return nil, mismatch.err()
}
return warnings, nil
@@ -208,33 +208,64 @@ func migrationFullPresets(fullXML []byte, deviceID string) ([]migrationPresetIde
return nil, len(full.Devices), fmt.Errorf("target device %q is missing", deviceID)
}
func compareMigrationPresets(leftName string, left []migrationPresetIdentity, rightName string, right []migrationPresetIdentity) string {
// migrationPresetMismatch describes why two preset views disagree.
type migrationPresetMismatch struct {
Reason string
// DroppedByFull marks the case where the speaker's own view holds a slot
// the rendered /full does not. mapPresetsToFullResponse omits a preset
// whose source is absent from the account's configured sources and cannot
// be synthesised, so this is not a stale snapshot and re-syncing cannot
// fix it: the source itself has to come back.
DroppedByFull bool
}
func (m *migrationPresetMismatch) err() error {
if !m.DroppedByFull {
return migrationDataNotReadyf("%s", m.Reason)
}
return &MigrationDataNotReadyError{
Reason: m.Reason,
Action: "The rendered account omits a preset whose music service source is missing, so Data Sync cannot restore it. " +
"Re-link or repopulate that source for this account, then retry migration.",
}
}
func compareMigrationPresets(leftName string, left []migrationPresetIdentity, rightName string, right []migrationPresetIdentity) *migrationPresetMismatch {
if len(left) != len(right) {
return fmt.Sprintf("%s has %d preset(s), but %s has %d", leftName, len(left), rightName, len(right))
return &migrationPresetMismatch{
Reason: fmt.Sprintf("%s has %d preset(s), but %s has %d", leftName, len(left), rightName, len(right)),
DroppedByFull: len(left) > len(right),
}
}
leftBySlot, problem := indexMigrationPresets(leftName, left)
if problem != "" {
return problem
return &migrationPresetMismatch{Reason: problem}
}
rightBySlot, problem := indexMigrationPresets(rightName, right)
if problem != "" {
return problem
return &migrationPresetMismatch{Reason: problem}
}
for slot, leftPreset := range leftBySlot {
rightPreset, ok := rightBySlot[slot]
if !ok {
return fmt.Sprintf("preset slot %s from %s is missing from %s", slot, leftName, rightName)
return &migrationPresetMismatch{
Reason: fmt.Sprintf("preset slot %s from %s is missing from %s", slot, leftName, rightName),
DroppedByFull: true,
}
}
if leftPreset.Name != rightPreset.Name || leftPreset.Location != rightPreset.Location {
return fmt.Sprintf("preset slot %s differs between %s and %s", slot, leftName, rightName)
return &migrationPresetMismatch{
Reason: fmt.Sprintf("preset slot %s differs between %s and %s", slot, leftName, rightName),
}
}
}
return ""
return nil
}
func indexMigrationPresets(name string, presets []migrationPresetIdentity) (map[string]migrationPresetIdentity, string) {
@@ -303,3 +303,31 @@ func TestMigrationDataReadinessIgnoresClearedPresetSlots(t *testing.T) {
t.Fatalf("a cleared preset slot blocked migration: %v", err)
}
}
// TestMigrationDataReadinessExplainsPresetsDroppedByFull: a preset whose music
// service source is missing from the account is omitted from the rendered
// /full on purpose. Telling the user to run Data Sync sends them in a loop,
// since syncing cannot bring the source back.
func TestMigrationDataReadinessExplainsPresetsDroppedByFull(t *testing.T) {
kept := readinessPreset("1", "Kept Station", "http://radio.example/kept")
dropped := readinessPreset("2", "Spotify Mix", "spotify:playlist:x")
dropped.Source = "SPOTIFY"
dropped.SourceID = "99999"
dropped.SourceAccount = "someone"
m, ds, deviceIP := newMigrationReadinessFixture(t, livePresetsXML(kept, dropped))
if err := ds.SavePresets(readinessAccount, readinessDevice, []models.ServicePreset{kept, dropped}); err != nil {
t.Fatalf("SavePresets: %v", err)
}
_, err := m.checkMigrationDataReady(deviceIP)
notReady := requireMigrationNotReady(t, err)
if strings.Contains(notReady.Action, "Run Data Sync") {
t.Errorf("action = %q, want it not to prescribe a sync that cannot help", notReady.Action)
}
if !strings.Contains(notReady.Action, "source") {
t.Errorf("action = %q, want it to point at the missing source", notReady.Action)
}
}