Files
Bose-SoundTouch/pkg/service/health/checks_refresh_sources.go
T
2026-05-24 09:52:02 +02:00

140 lines
4.3 KiB
Go

package health
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
// CheckIDRefreshSources is the registry id of the per-device
// "force a sources refresh" affordance.
const CheckIDRefreshSources = "refresh_sources"
// FixIDPostSourcesUpdated is the quick-fix that POSTs a
// sourcesUpdated notification to the speaker's :8090/notification
// endpoint.
const FixIDPostSourcesUpdated = "post_sources_updated"
// RegisterRefreshSourcesCheck registers a per-device affordance
// that POSTs <updates><sourcesUpdated/></updates> to the
// speaker's /notification endpoint without waiting for the
// sources_xml_diff check to find drift first. Useful after any
// service-side Sources.xml change (manual edit, fixed via the
// sources_xml_present quick fix, etc.) to push the new state
// onto the speaker without a reboot.
func RegisterRefreshSourcesCheck(r *Registry, ds *datastore.DataStore) {
r.Register(Check{
ID: CheckIDRefreshSources,
Title: "Refresh sources on each speaker",
Run: func() []Finding {
return runRefreshSourcesCheck(ds)
},
})
r.RegisterFix(CheckIDRefreshSources, FixIDPostSourcesUpdated, func(target Target) (string, error) {
return postSourcesUpdated(ds, target)
})
}
func runRefreshSourcesCheck(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(),
}}
}
out := make([]Finding, 0, len(devices))
for i := range devices {
dev := &devices[i]
if dev.IPAddress == "" || dev.DeviceID == "" {
continue
}
out = append(out, Finding{
Severity: SeverityInfo,
Target: Target{Account: dev.AccountID, Device: dev.DeviceID},
Message: fmt.Sprintf("Force a sources refresh on %s.", displayName(dev.Name, dev.DeviceID)),
Details: "POSTs <updates><sourcesUpdated/></updates> to the speaker so it re-fetches /streaming/account/<id>/full. Cheaper than power-cycling when the service-side Sources.xml has been updated.",
QuickFixes: []QuickFix{{
ID: FixIDPostSourcesUpdated,
Label: "Refresh sources",
}},
ManualCommands: []ManualCommand{{
Label: "Or trigger from the LAN:",
Command: sourcesUpdatedCurlCommand(dev.IPAddress, dev.DeviceID),
Hint: "Run on a host that can reach the speaker on port 8090. A reboot is sometimes still required for new source *types* (vs. updated metadata for existing types) to take effect.",
}},
})
}
return out
}
func postSourcesUpdated(ds *datastore.DataStore, target Target) (string, error) {
if target.Device == "" {
return "", fmt.Errorf("device is required")
}
dev, err := ds.GetDeviceInfo(target.Account, target.Device)
if err != nil || dev == nil {
return "", fmt.Errorf("device %s not found in datastore", target.Device)
}
if dev.IPAddress == "" {
return "", fmt.Errorf("device %s has no IP address recorded", target.Device)
}
body := sourcesUpdatedXML(target.Device)
notifyURL := fmt.Sprintf("http://%s:8090/notification", dev.IPAddress)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, notifyURL, bytes.NewReader([]byte(body)))
if err != nil {
return "", fmt.Errorf("build request: %w", err)
}
req.Header.Set("Content-Type", "application/xml")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("post to speaker: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= 300 {
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10))
return "", fmt.Errorf("speaker returned %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
}
return fmt.Sprintf("Sent sourcesUpdated to %s.", displayName(dev.Name, target.Device)), nil
}
func sourcesUpdatedXML(deviceID string) string {
return fmt.Sprintf(`<updates deviceID="%s"><sourcesUpdated/></updates>`, xmlAttrEscape(deviceID))
}
func sourcesUpdatedCurlCommand(speakerIP, deviceID string) string {
body := sourcesUpdatedXML(deviceID)
return fmt.Sprintf(
"curl -sS -X POST 'http://%s:8090/notification' -H 'Content-Type: application/xml' -d '%s'",
speakerIP, strings.ReplaceAll(body, "'", `'\''`),
)
}