mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-19 00:56:16 +00:00
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:
co-authored by
Claude Opus 4.7
parent
41f21f3761
commit
77188418a7
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user