feat(health): detect dead Bose orion URLs in service Presets.xml

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 <location>, 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) <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-05-19 23:20:40 +02:00
co-authored by Claude Opus 4.7
parent 41f21f3761
commit 77188418a7
3 changed files with 276 additions and 0 deletions
+1
View File
@@ -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
}
+117
View File
@@ -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 <location>. 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
}
@@ -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 := `<?xml version="1.0" encoding="UTF-8"?>
<presets>
<preset id="1" createdOn="2024-01-01" updatedOn="2024-01-01">
<contentItem source="TUNEIN" type="stationurl" location="https://content.api.bose.io/core02/svc-bmx-adapter-orion/prod/orion/v1/playback/station/s1234" sourceAccount="">
<itemName>Dead preset</itemName>
</contentItem>
</preset>
<preset id="2" createdOn="2026-05-01" updatedOn="2026-05-01">
<contentItem source="TUNEIN" type="stationurl" location="/v1/playback/station/s5678" sourceAccount="">
<itemName>Healthy preset</itemName>
</contentItem>
</preset>
</presets>`
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 := `<?xml version="1.0" encoding="UTF-8"?>
<presets>
<preset id="1" createdOn="2026-05-01" updatedOn="2026-05-01">
<contentItem source="TUNEIN" type="stationurl" location="/v1/playback/station/s1234" sourceAccount="">
<itemName>Healthy preset</itemName>
</contentItem>
</preset>
</presets>`
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)
}
}