From 77188418a7bf76af04f5a0c083f4745e4a128bd5 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Tue, 19 May 2026 21:17:32 +0200 Subject: [PATCH] feat(health): detect dead Bose orion URLs in service Presets.xml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recurring failure mode in issues #218 and #224: presets saved before the May 2026 cloud shutdown still carry content.api.bose.io/.../orion URLs in their , which the speaker fetches directly post-migration. Result: playback silently fails because the dead host can't serve the request and the speaker has no fallback path. Passive filesystem scan over every device's service-side Presets.xml; emits a warning per device listing the affected preset slot IDs and a copyable sed snippet that strips the dead host prefix, leaving the BMX-relative /v1/playback/... path that this service can resolve. No probe, no LAN access needed — purely a service-side data check, so it's also safe to run on cloud-deployed AfterTouch. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/service/handlers/server.go | 1 + pkg/service/health/checks_orion_paths.go | 117 +++++++++++++ pkg/service/health/checks_orion_paths_test.go | 158 ++++++++++++++++++ 3 files changed, 276 insertions(+) create mode 100644 pkg/service/health/checks_orion_paths.go create mode 100644 pkg/service/health/checks_orion_paths_test.go diff --git a/pkg/service/handlers/server.go b/pkg/service/handlers/server.go index 10c1113..f161004 100644 --- a/pkg/service/handlers/server.go +++ b/pkg/service/handlers/server.go @@ -117,6 +117,7 @@ func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, red serverURL, _ := s.GetSettings() return serverURL }) + health.RegisterOrionPathsCheck(s.healthRegistry, ds) return s } diff --git a/pkg/service/health/checks_orion_paths.go b/pkg/service/health/checks_orion_paths.go new file mode 100644 index 0000000..6d29061 --- /dev/null +++ b/pkg/service/health/checks_orion_paths.go @@ -0,0 +1,117 @@ +package health + +import ( + "fmt" + "strings" + + "github.com/gesellix/bose-soundtouch/pkg/models" + "github.com/gesellix/bose-soundtouch/pkg/service/datastore" +) + +// CheckIDOrionPaths is the registry id of the dead-orion-URL +// detector. +const CheckIDOrionPaths = "orion_paths_in_presets" + +// orionPathFragment is the dead Bose cloud path that lingers in +// presets saved before the May 2026 shutdown. Anything matching +// this in a preset Location will be fetched against the dead +// public host instead of routing through BMX, so playback fails. +const orionPathFragment = "content.api.bose.io/core02/svc-bmx-adapter-orion/prod/orion" + +// RegisterOrionPathsCheck registers a passive scan over every +// device's service-side Presets.xml looking for entries whose +// Location contains the dead Bose cloud orion path. Recurring +// pattern from #218 and #224: presets that worked pre-shutdown +// but silently fail post-migration because the location still +// references content.api.bose.io. +// +// Pure filesystem read; no probes. Always runs. +func RegisterOrionPathsCheck(r *Registry, ds *datastore.DataStore) { + r.Register(Check{ + ID: CheckIDOrionPaths, + Title: "Presets don't reference the dead Bose cloud orion path", + Run: func() []Finding { + return runOrionPathsCheck(ds) + }, + }) +} + +func runOrionPathsCheck(ds *datastore.DataStore) []Finding { + if ds == nil { + return nil + } + + devices, err := ds.ListAllDevices() + if err != nil { + return []Finding{{ + Severity: SeverityError, + Message: "Could not enumerate devices: " + err.Error(), + }} + } + + var findings []Finding + + for i := range devices { + dev := &devices[i] + if dev.AccountID == "" || dev.DeviceID == "" { + continue + } + + presets, err := ds.GetPresets(dev.AccountID, dev.DeviceID) + if err != nil { + continue + } + + hits := findOrionHits(presets) + if len(hits) == 0 { + continue + } + + findings = append(findings, Finding{ + Severity: SeverityWarning, + Target: Target{Account: dev.AccountID, Device: dev.DeviceID}, + Message: fmt.Sprintf( + "%d preset(s) reference the dead Bose orion path; playback will fail until rewritten: %s.", + len(hits), strings.Join(hits, ", "), + ), + Details: "The Bose cloud shut down in May 2026, but presets created before then still carry `content.api.bose.io/.../orion` URLs in their . Recurring debug pattern from #218 and #224. The fix is to rewrite the Location to the BMX-relative form (or simply re-create the preset against TUNEIN / RADIO_BROWSER).", + ManualCommands: []ManualCommand{{ + Label: "Strip the dead host from Presets.xml on the service host:", + Command: fmt.Sprintf( + "sed -i 's|https://content.api.bose.io/core02/svc-bmx-adapter-orion/prod/orion||g' /app/data/accounts/%s/devices/%s/Presets.xml", + dev.AccountID, dev.DeviceID, + ), + Hint: "Adjust /app/data to your actual data dir. The leading `https://...orion` is stripped so the remaining /v1/playback/... path is BMX-relative and routes through this service.", + }}, + }) + } + + return findings +} + +// findOrionHits returns a sorted slice of preset slot labels (or +// preset names when the slot is missing) that contain the dead +// orion path. Returned in input order. +func findOrionHits(presets []models.ServicePreset) []string { + var hits []string + + for i := range presets { + loc := presets[i].Location + if loc == "" || !strings.Contains(loc, orionPathFragment) { + continue + } + + label := presets[i].ID + if label == "" { + label = presets[i].Name + } + + if label == "" { + label = "(unnamed)" + } + + hits = append(hits, label) + } + + return hits +} diff --git a/pkg/service/health/checks_orion_paths_test.go b/pkg/service/health/checks_orion_paths_test.go new file mode 100644 index 0000000..90cffd5 --- /dev/null +++ b/pkg/service/health/checks_orion_paths_test.go @@ -0,0 +1,158 @@ +package health + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/gesellix/bose-soundtouch/pkg/models" + "github.com/gesellix/bose-soundtouch/pkg/service/datastore" +) + +func newOrionTestDS(t *testing.T, account, device string) *datastore.DataStore { + t.Helper() + + tempDir, err := os.MkdirTemp("", "orion-test-*") + if err != nil { + t.Fatalf("temp dir: %v", err) + } + + t.Cleanup(func() { os.RemoveAll(tempDir) }) + + ds := datastore.NewDataStore(tempDir) + + if err := ds.SaveDeviceInfo(account, device, &models.ServiceDeviceInfo{ + DeviceID: device, + AccountID: account, + }); err != nil { + t.Fatalf("SaveDeviceInfo: %v", err) + } + + return ds +} + +func writePresetsXML(t *testing.T, ds *datastore.DataStore, account, device, xml string) { + t.Helper() + + path := filepath.Join(ds.AccountDeviceDir(account, device), "Presets.xml") + if err := os.WriteFile(path, []byte(xml), 0644); err != nil { + t.Fatalf("write Presets.xml: %v", err) + } +} + +func TestOrionPaths_FlagsDeadCloudURLs(t *testing.T) { + account, device := "1000001", "DEVICEID01" + ds := newOrionTestDS(t, account, device) + + xml := ` + + + + Dead preset + + + + + Healthy preset + + +` + writePresetsXML(t, ds, account, device, xml) + + r := NewRegistry() + RegisterOrionPathsCheck(r, ds) + + results := r.RunAll() + if len(results) != 1 || results[0].Severity != SeverityWarning { + t.Fatalf("expected one warning, got %+v", results) + } + + if len(results[0].Findings) != 1 { + t.Fatalf("expected one finding, got %d", len(results[0].Findings)) + } + + finding := results[0].Findings[0] + if !strings.Contains(finding.Message, "1 preset") { + t.Errorf("expected mention of 1 affected preset, got %q", finding.Message) + } + + if len(finding.ManualCommands) != 1 { + t.Fatalf("expected one manual command (sed snippet)") + } + + if !strings.Contains(finding.ManualCommands[0].Command, "sed") || !strings.Contains(finding.ManualCommands[0].Command, account) { + t.Errorf("manual command should reference sed + account dir, got %q", finding.ManualCommands[0].Command) + } +} + +func TestOrionPaths_NoFindingWhenClean(t *testing.T) { + account, device := "1000001", "DEVICEID01" + ds := newOrionTestDS(t, account, device) + + xml := ` + + + + Healthy preset + + +` + writePresetsXML(t, ds, account, device, xml) + + r := NewRegistry() + RegisterOrionPathsCheck(r, ds) + + results := r.RunAll() + if results[0].Severity != SeverityOK { + t.Errorf("expected OK, got %q", results[0].Severity) + } + + if len(results[0].Findings) != 0 { + t.Errorf("expected no findings, got %+v", results[0].Findings) + } +} + +func TestOrionPaths_NoFindingWhenNoPresetsFile(t *testing.T) { + account, device := "1000001", "DEVICEID01" + ds := newOrionTestDS(t, account, device) + + r := NewRegistry() + RegisterOrionPathsCheck(r, ds) + + results := r.RunAll() + if len(results[0].Findings) != 0 { + t.Errorf("expected no findings for missing Presets.xml, got %+v", results[0].Findings) + } +} + +func TestFindOrionHits_LabelFallback(t *testing.T) { + presets := []models.ServicePreset{ + { + ID: "5", + ServiceContentItem: models.ServiceContentItem{ + Location: "https://content.api.bose.io/core02/svc-bmx-adapter-orion/prod/orion/abc", + }, + }, + { + ServiceContentItem: models.ServiceContentItem{ + Name: "Fallback Name", + Location: "https://content.api.bose.io/core02/svc-bmx-adapter-orion/prod/orion/xyz", + }, + }, + { + ServiceContentItem: models.ServiceContentItem{ + Location: "/v1/playback/station/s1234", + }, + }, + } + + got := findOrionHits(presets) + if len(got) != 2 { + t.Fatalf("expected 2 hits, got %v", got) + } + + if got[0] != "5" || got[1] != "Fallback Name" { + t.Errorf("unexpected labels: %v", got) + } +}